Compare commits
144 Commits
master
..
03f0c402b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 03f0c402b5 | |||
| 82d354f5e7 | |||
| afd03f1808 | |||
| bf97d501be | |||
| c36a7e0d54 | |||
| 6c473558ef | |||
| 5ed3549c66 | |||
| da39f0c8d5 | |||
| fc0a76feea | |||
| e4b8df6ae8 | |||
| 32497bcfab | |||
| 18557170aa | |||
| d74dda1ab5 | |||
| 0ddf99de08 | |||
| e1698b7c3d | |||
| f2b21d1f69 | |||
| 79d1187af7 | |||
| 7dd324d55e | |||
| e42c336478 | |||
| de7a26b89e | |||
| 8bc472a272 | |||
| 2dc40cd788 | |||
| 02bd2616fc | |||
| fabf97a402 | |||
| ac0d9efc84 | |||
| 6ef57b6b6b | |||
| 85a721f03b | |||
| 7c02ea0498 | |||
| 77a5940616 | |||
| 0f0cf83a4b | |||
| 3c3746d529 | |||
| d9937e5afa | |||
| 3d3bbdf64c | |||
| 576e6ba91b | |||
| fd890a0d74 | |||
| 368a2ffcdb | |||
| 7b58745855 | |||
| dcb6ed5d31 | |||
| 3b90c4c072 | |||
| c9cdb58275 | |||
| 339202b355 | |||
| d02781e8d2 | |||
| 9695748fbc | |||
| 23d81bee7b | |||
| 7569524d81 | |||
| f22970f321 | |||
| ebed3f5311 | |||
| ae17900cb2 | |||
| 25cebbd98c | |||
| 3d1fa40402 | |||
| b4dd396768 | |||
| f0e777efc5 | |||
| 0613bb596b | |||
| f5923d302d | |||
| 87ebd91ba2 | |||
| bf3e8bf9fc | |||
| 7b65f3bce6 | |||
| a33f124931 | |||
| 989e61c261 | |||
| 5d99701024 | |||
| 940c38eb33 | |||
| a2c8fcd6de | |||
| 0c82d01cd9 | |||
| 56bbcec2a8 | |||
| b6c1a9a3c6 | |||
| b83fd81399 | |||
| 633480252e | |||
| 5b70742efa | |||
| b0db956013 | |||
| d788cbb32b | |||
| b1e7fdf8fc | |||
| 3aee2e05ee | |||
| 88cc9df0b6 | |||
| e173978135 | |||
| 6b6c18b43d | |||
| 17523d422c | |||
| 7bcf69c16c | |||
| b6d14ca2b2 | |||
| bd80eb1c59 | |||
| 3a0652a337 | |||
| 4211633e3e | |||
| ac55233c1a | |||
| 5496f8c007 | |||
| 694e666160 | |||
| 47b9fa5aa7 | |||
| f2d36632cb | |||
| bd0e7195bb | |||
| 96628c5aaa | |||
| fc0cab9ec7 | |||
| b353ed34ae | |||
| b6e902a809 | |||
| 7d2c9803d6 | |||
| 7cf867a1a0 | |||
| 0e1c69e316 | |||
| 5a10ea5404 | |||
| fd862c4a8b | |||
| 056ca2c288 | |||
| 3402e6ac96 | |||
| b4c4a2cc6b | |||
| 382771b2e7 | |||
| a8e491e534 | |||
| 76029dbf68 | |||
| 4b02e40238 | |||
| ce6bd26ff7 | |||
| 54211b1443 | |||
| c8a4188618 | |||
| 7ca5197b6b | |||
| a010584d9b | |||
| e6d061cc91 | |||
| 0161b0231d | |||
| 34317e53f4 | |||
| 27e41077b1 | |||
| a37b1c0cc9 | |||
| 4eb635cc89 | |||
| 4734c04ec0 | |||
| 2f971a54c0 | |||
| 8cfe6c2521 | |||
| 665cbce1f9 | |||
| 6eead6d493 | |||
| 7b9f72e79f | |||
| cd0f976b4f | |||
| 919bc67bfa | |||
| ec5cbf6fa0 | |||
| f1b802299f | |||
| 0337422ae6 | |||
| d39244989b | |||
| 01424c1e22 | |||
| 1811e82005 | |||
| 0a1aaedef8 | |||
| 483ad3f278 | |||
| d27622d581 | |||
| f315c80748 | |||
| bda256d3a4 | |||
| c93b168f5c | |||
| a971cd610b | |||
| 48bbcaed1e | |||
| 6d4b4e66c1 | |||
| 27bb5a42ec | |||
| 8fd8987b70 | |||
| 7b3b597abc | |||
| 4a08161e4b | |||
| 39236c65c7 | |||
| b9dc1838c7 | |||
| ca859c4aa4 |
@@ -11,24 +11,3 @@ 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
|
||||
|
||||
# Interne Doku (Handover/Pendenzen/Design-Notizen) — nur lokal, nicht im
|
||||
# öffentlichen Gitea-Code-Browser. README.md bleibt als Repo-Beschreibung.
|
||||
/ARCHITECTURE.md
|
||||
/CONVENTIONS.md
|
||||
/HANDOVER.md
|
||||
/PENDENZEN.md
|
||||
/PORT_PLAN.md
|
||||
/RESEARCH_BAUTEILE_RHINO.md
|
||||
/RESEARCH_CAD_APPROACHES.md
|
||||
/ROADMAP.md
|
||||
/SPIKE_TEXTUR_render3d.md
|
||||
/STATUS.md
|
||||
/docs/*.md
|
||||
/docs/design/*.md
|
||||
/docs/research/*.md
|
||||
/docs/welle-c-hlr-spike/*.md
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
# Architektur — Standalone Browser-BIM (cad)
|
||||
|
||||
> 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).
|
||||
|
||||
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**.
|
||||
|
||||
Alle Bezeichner im Code sind **englisch** (Vectorworks-Terminologie); 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.
|
||||
Darstellung (Detailgrad, Stile, Schraffuren, Overrides) wird **beim Rendern**
|
||||
angewandt, nie in die Geometrie eingebacken.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Project (semantisches Modell, JSON) │ ← einzige Wahrheit
|
||||
│ resources · types · designLevels · │
|
||||
│ layers · elements │
|
||||
└──────────────┬───────────────────────────┘
|
||||
│ pure derive()
|
||||
┌───────────────────────┼────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
Scene3D (Three.js) PlanModel → SVG SectionModel → SVG
|
||||
buildScene() generatePlan() generateSection() (HLR)
|
||||
│ │ │
|
||||
└────────── beide 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).
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Datenmodell
|
||||
|
||||
### 2.1 Zwei unabhängige Achsen (DOSSIER-Modell, im Spike umgesetzt)
|
||||
|
||||
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** (`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 …`
|
||||
|
||||
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.
|
||||
|
||||
```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 }
|
||||
}
|
||||
```
|
||||
|
||||
`joinPriority` ist DOSSIERs `_MATERIAL_PRIO` (Beton 800 … Putz 100) als
|
||||
**Daten** statt Hardcode — steuert die Prioritäts-T-Verschneidung (Risiko #1).
|
||||
|
||||
### 2.3 Aufbauten (mehrschichtige Typen)
|
||||
|
||||
```ts
|
||||
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[]`.
|
||||
|
||||
### 2.4 Elemente
|
||||
|
||||
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 };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. State, Persistenz, Undo/Redo
|
||||
|
||||
### 3.1 Store — Zustand
|
||||
|
||||
Heute: `useState<Project>` in `App.tsx`, immutabel via `setProject`. Das skaliert
|
||||
nicht für Werkzeuge + Undo. Migration auf **Zustand** (ROADMAP §4):
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
**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 — 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.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rendering
|
||||
|
||||
### 4.1 Three.js-Szene spiegelt den Ebenen-Baum
|
||||
|
||||
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):
|
||||
|
||||
```
|
||||
scene
|
||||
└─ levelGroup[floorId] (y-Offset = baseElevation) ← Geschoss
|
||||
└─ categoryGroup[categoryCode] (userData.code) ← Ebene
|
||||
└─ element meshes (userData.elementId)
|
||||
```
|
||||
|
||||
```ts
|
||||
// scene.ts
|
||||
function buildScene(project: Project, vis: VisibilityState): THREE.Group
|
||||
function syncScene(root: THREE.Group, project, vis) // diff statt full rebuild (Perf)
|
||||
```
|
||||
|
||||
- **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.2 Grundriss = Clipping-Ebene + symbolische Generierung
|
||||
|
||||
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):
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 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
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rhino → Browser — Mapping-Tabelle
|
||||
|
||||
| DOSSIER (Rhino-Plugin) | cad (Standalone-Browser) | 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) | — |
|
||||
|
||||
---
|
||||
|
||||
## 7. Was wir von DOSSIER übernehmen — und was nicht
|
||||
|
||||
**Ü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.
|
||||
|
||||
**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`.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Projekt-Konventionen — Browser-BIM (cad)
|
||||
|
||||
Siehe [ROADMAP.md](ROADMAP.md) für Vision, Architektur und Phasen.
|
||||
|
||||
## Code-Konventionen (verbindlich)
|
||||
|
||||
- **Alle Bezeichner im Code sind ENGLISCH** — Funktionen, Variablen, Typen, Felder,
|
||||
Datei-/Modulnamen. Keine deutschen Bezeichner. (Beispiel: `computeJoins`, nicht
|
||||
`verschneidungBerechnen`.)
|
||||
- **UI-Texte und Kommentare dürfen Deutsch sein** (Nutzeroberfläche ist deutsch).
|
||||
- **Domänen-Begriffe** möglichst nach Vectorworks-Terminologie benennen (englisch):
|
||||
Design Layer, Sheet/Drawing Layer, Component, Class, Hatch, Wall Style, Viewport.
|
||||
- **Einheiten:** intern alles in **Metern** (number). Anzeige via `formatM`.
|
||||
- **Geometrie-Konventionen:** Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`; bei
|
||||
CCW-Wicklung zeigt `+n` nach innen. Schichten werden außen (−T/2) → innen (+T/2)
|
||||
gestapelt.
|
||||
|
||||
## Code-Struktur (kein God-Component)
|
||||
|
||||
- **`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: modular + parallel bearbeitbar (verschiedene Features ≠ dieselbe Datei).
|
||||
Siehe `docs/design/state-architecture.md`.
|
||||
|
||||
## UI-Konventionen
|
||||
|
||||
- **Listen-/Manager-Ansichten als saubere Tabellen:** eine Kopfzeile mit
|
||||
Spaltentiteln (sticky), darunter kompakte Datenzeilen mit Inline-Edit pro Zelle.
|
||||
KEINE wiederholten Feld-Beschriftungen pro Zeile. Gilt für Component-/Hatch-/
|
||||
Line-Manager und ähnliche Listen.
|
||||
- Dunkler DOSSIER-Stil; kompakt, ruhig, viel Inhalt pro Fläche.
|
||||
- **UI-Text immer übersetzbar (i18n):** KEINE hartcodierten sichtbaren Strings im
|
||||
JSX. Alle Texte über eine Übersetzungsfunktion `t('key')` aus einem Wörterbuch
|
||||
(Default-Sprache Deutsch). Keys wie bei DOSSIER (`common.delete`, `layers.settings`,
|
||||
`topbar.resources`). Neue Komponenten gleich mit `t(...)` schreiben. Identifier/Keys
|
||||
bleiben englisch; nur die Wörterbuch-Werte sind die übersetzbaren Texte.
|
||||
|
||||
## Native-App-Verhalten (kein Browser-Standard)
|
||||
|
||||
Die App soll sich wie ein natives Programm anfühlen, nicht wie eine Webseite:
|
||||
- **Browser-Kontextmenü global unterdrücken** (`document` `contextmenu` → `preventDefault`).
|
||||
Nur unser eigenes `ContextMenu` erscheint; auf Flächen ohne eigenes Menü passiert nichts.
|
||||
- **Keine Textauswahl / „Alles markieren":** `user-select: none` global; `user-select: text`
|
||||
NUR in echten Eingaben (`input`, `textarea`, `[contenteditable]`). Ctrl+A außerhalb von
|
||||
Eingaben unterbinden.
|
||||
- Bild-/Element-Drag aus (`draggable=false` wo nötig); keine Browser-Drag-Gesten.
|
||||
|
||||
## Architektur-Prinzip
|
||||
|
||||
Ein **semantisches Modell** ist die einzige Wahrheit; jede Ansicht (3D, Grundriss,
|
||||
Schnitt) wird **abgeleitet**. Darstellung (Detailgrad, Stile, Schraffuren) wird beim
|
||||
Rendern angewandt, nie in die Geometrie eingebacken.
|
||||
|
||||
## Arbeitsweise (für Beiträge)
|
||||
|
||||
- Substanzielle, mehrstufige Arbeit an **Subagenten** delegieren, wo möglich.
|
||||
- Ä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).
|
||||
@@ -0,0 +1,89 @@
|
||||
# HANDOVER — Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen)
|
||||
|
||||
Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen.
|
||||
|
||||
## NACHTRAG Session 3 (2026-07-04 mittags) — Prioritaets-Joins + Schnitt-Sanierung
|
||||
|
||||
**Prioritäts-Join-Epic (Kern-Direktive, siehe unten „Verschneidungs-Zielmodell"):** Joins werden nach `Component.joinPriority` materialbewusst aufgelöst — konsistent in Grundriss, Schnitt UND 3D. Gelandet:
|
||||
- `e1698b7` **Wand-T-Verbindungen** (T-Knoten 3 Enden + Mittelspannen-Stoss) in `computeJoins`; generatePlan wendet die Cuts unverändert an. `0ddf99d` Rust-Parität (geometry-Crate). `d74dda1` Sample: Querwand **W9** (neuer Wandtyp `iw` Innenputz/Backstein/Innenputz) als T-Demo, Tür/Fenster beiseite gerückt.
|
||||
- `fc0a76f` **Phase 1 materialbewusst**: `resolveJoinPriority` (merge/coexist/trim), `WallCuts.layerCuts` (per-Schicht-Cuts + L-Seitenlinien), Backstein-Kern verschmilzt, Putz bricht als L. `bf97d50` **Phase 1b**: Kern sticht nur durch den Nah-Putz und stoppt an der Rückgrat-Nahfläche (mergeCut bei `offT + sign*(tT/2 − nearPlaster)`), KEINE Achs-Überlappung; Naht entfällt über stroke:none/noStrokeEdges/Zeichenreihenfolge. Rust jeweils synchron.
|
||||
- `e4b8df6` **Phase 3 (3D)**: Wand wird an der Deckenunterkante getrimmt, wenn eine Decke höherer joinPriority bündig aufliegt (`trimWallTopForCeilings` in toWalls3d, BBox-Footprint-Näherung dokumentiert) → kein Z-Fighting Wand/Decke.
|
||||
|
||||
**Schnitt-Sanierung (Regressionen aus der 3D-Schicht-Umstellung `de7a26b` behoben):**
|
||||
- `da39f0c` per-Schicht-3D-Bänder nutzten die RECHTE Normale statt `leftNormal` → Schichten in 3D+Schnitt gespiegelt (Backstein aussen). Eine Zeile in `pushSegment` gedreht.
|
||||
- `6c47355` Schnitt bekam durch die per-Schicht-Boxen segments×layers Cut-Polygone → Owner-Index-Versatz → einheitliches Diagonal-Muster. Fix: `projectToModel3d(project, { layeredWalls:false })` für `computeSection` (Voll-Box pro Wand); 3D-Viewer behält Default true.
|
||||
- `5ed3549` **Alle Schraffuren monochrom**: HATCH_INK `#0f0f0f` / HATCH_PAPER `#f0f0f0` (resolveHatch erzwingt Ink; Albedo-Fallback im Schnitt entfernt). Print-Mono separat/unangetastet.
|
||||
- `c36a7e0` Wandrelative Muster drehen im Schnitt mit der Wand (`SECTION_WALL_AXIS_ANGLE_DEG=90` an resolveHatch) → Dämmung horizontal quer zur Dicke.
|
||||
|
||||
**Sonstiges:** `1855717` Renderer-Umschalter aus der Statusleiste in die **Settings**, Default **Nordstern** (WebGL2 nur explizit/Fallback). `32497bc` TopBar: Zoom-Cluster von Grid auf Flex (Phantom-Zeile +6px weg, jetzt bündig mit Zweizeilern), Bar 56→60px.
|
||||
|
||||
**IN FLIGHT bei Checkpoint:** (1) **3D-Live-Schnitt** (Opus-Agent): Clip-Plane-Uniform + discard in MESH/GRID_WGSL, `section_fill.rs` Cut-Caps aus `cut_section` mit prozeduraler 45°-Schraffur (CAP_WGSL), `set_section_plane` (web.rs cached walls/slabs), Viewport-Toggle; WASM-Rebuild nötig. (2) **Joins Phase 2** (Opus-Agent): Merge-Regel (gleiche Prio+Komponente) in der Schnitt-Dominanz `subtractDominantBands`. — `git status` prüfen, verifizieren, committen.
|
||||
|
||||
**NÄCHSTER Engine-Slice (Nutzer-Direktive, queued):** **Öffnungen als echte Boolean-Löcher** — Fenster/Türen sind heute Pfeiler/Brüstung/Sturz-Ersatzkörper (keine CSG, Triangulator lochfrei) → sichtbare Segment-Nähte. Ziel: EIN Wandkörper, Löcher via earcut-with-holes + Laibungsflächen; behebt auch die dokumentierte mesh↔cut_section-Öffnungs-Inkonsistenz. Läuft im selben Crate wie der 3D-Schnitt → erst danach starten.
|
||||
|
||||
**Verschneidungs-Zielmodell (Nutzer, verbindlich):** gleiche Komponente+Priorität = verschmelzen (Naht weg); schwächere Schicht bricht auf und schliesst als L; höhere Priorität schneidet bauteilübergreifend (Decke>Wand); Joins IMMER in allen drei Sichten identisch. Schraffur-Modell: Schnitt-Schraffur vs. Oberflächen-Schraffur, aktuell ALLE monochrom fg #0f0f0f auf bg #f0f0f0.
|
||||
|
||||
## NACHTRAG Session 2 (2026-07-04 nachmittags/abends) — TopBar-Redesign + 3D-Editierbarkeit
|
||||
|
||||
**TopBar/UI gelandet:** DOSSIER-Wortmarke + Ressourcen-Icon links (Rhino-Stil), Massstab/Zoom-Cluster bereinigt (Duplikat weg, Zoom-Aktionen unter Massstab-Dropdown gestapelt, gleiche Breite, Ring bei „am Massstab" statt Flächen-Aufhellung), PDF/DXF raus → **Datei-Burger-Menü** rechts (Speichern/Öffnen als echter Projekt-JSON-Download/-Upload, Import, Export). **Einstellungs-Fenster** (`SettingsDialog.tsx`): Accent-Palette (`src/theme/accents.ts`), Auswahlrahmen-Farbe (Default Yuyake #CEB188) + Snap-Farbe (vorläufig **Sora #5FA1C9** gewählt — „aki"-Rückfrage bleibt offen, per Picker änderbar), Projekt-MüM-Feld (`project.referenceElevationMasl`, nur gespeichert, Draping folgt). **Custom Fenstersteuerung** (_/□/X) für Electron (`electron-preload.cjs` + IPC, `WindowControls.tsx`, TopBar = Drag-Region). **Dark-Theme ist jetzt fester Standard** (vorher an OS-`prefers-color-scheme` gekoppelt → zeigte fälschlich Light; Light bleibt Opt-in via `:root[data-theme="light"]`). Alle TopBar-Pillen auf dieselbe stets-dunkle Kontextmenü-Fläche vereinheitlicht; Panel-Köpfe flach (kein hellerer Balken) + feste 40px-Höhe. **Undo/Redo** (`historySlice.ts`, Ctrl+Z/Y, Coalescing für Drags). **15 gebündelte OFL-Schriften** (`public/fonts/`, `src/text/fonts.css`) statt Systemfonts. **Farbfelder**: Hex separat editierbar (`ColorHexField.tsx`). **Text-Zeilenhöhe-Regler** ersetzt „+ Text" (Text wird eigenes Werkzeug, AUDIT B1). **Linienstil** im Attribute-Panel editierbar. 2D-Zoom-Grenzen stark erweitert (20000×/0.005×).
|
||||
|
||||
**3D-Editierbarkeit gelandet (Nordstern/render3d, NICHT three.js — Free=three.js-Viewer, Premium=editierbarer Nordstern, siehe Memory):**
|
||||
- **R1** `85a721f` Boden-Referenzraster auf y=baseElevation des aktiven Geschosses, ein-/ausschaltbar (Overlay-Button, `viewSlice.grid3dVisible`; eigene LineList-Pipeline `grid.rs`, `set_ground_grid`). Kamera: Links=Orbit, **Mitte/Rechts=Pan** (vorher Pan nur auf Shift+Mitte versteckt), Rad=Zoom-to-Cursor.
|
||||
- **R2** `6ef57b6` View-Styles **wireframe** + **hidden-line** + Fill-Light (`set_render_style`, `edges.rs` = Feature-Edge-Extraktion mit Crease-Dedup, Hidden-Line via Depth-Bias-Flächen + Kanten obenauf). `textured` → vorerst wie shaded (keine Textur-Pipeline).
|
||||
- **R3** `ac0d9ef` **Klick-Auswahl** im 3D (`raycast3d.ts`: OBB-Wand-/Prisma-Slab-Schnitt → Store-Selektion → Attribute+Objekt-Info-Panel). `toWalls3d.pickGeometry()` mit wallId/ceilingId.
|
||||
- **R4** `fabf97a` **Auswahl-Highlight**: orange Outline, immer sichtbar (No-Depth-Linien-Pipeline `set_highlight_lines`, `selectionHighlightLines()`).
|
||||
- **R5** `02bd261` **Materialfarben** im 3D: Wände/Decken in Farbe der dicksten Schicht statt Einheitsgrau.
|
||||
- **weiter:** `8bc472a` 2D **z-Anordnen** (Kontextmenü nach vorn/hinten, `reorderDrawings`); `de7a26b` **per-Schicht Wand-Bänder** (jede Materiallage als eigener farbiger Quader, wallId bleibt → Pick/Highlight ganze Wand); `e42c336` **AUDIT A6 Bauteil-Schedule-CSV** (Datei-Menü, `exportSchedule.ts`); `7dd324d` **Glasscheiben in Fenstern** (A5-Teil, RMesh in jeder Fensteröffnung).
|
||||
- Alle Runden **visuell im Browser verifiziert** (Playwright + WebGPU-Flags: `--enable-unsafe-webgpu --enable-features=Vulkan --use-gl=angle --use-angle=vulkan --no-sandbox`, URL `?engine=wasm`, Iso-View klicken).
|
||||
|
||||
**3D-REST (engine-schwer, bewusst NICHT blind gemacht — mit Nutzer angehen):**
|
||||
- **Schnitt-Schraffuren** (2D-Poché/Schraffur auf 3D-Schnittflächen; `section.rs`, ~66KB, komplex).
|
||||
- **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern).
|
||||
- **Wand-Schicht-Bänder** in 3D (Option B aus R5: jede Materiallage als eigener extrudierter Teilquader statt nur repräsentativer Farbe — pro-Schicht-Farben liegen in `RWall.layers` schon vor).
|
||||
- **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; für Front/Top/Side-Presets vor der ersten Navigation leicht ungenau).
|
||||
- **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
|
||||
|
||||
WASM-Workflow: `src/engine/pkg3d/` ist gitignore't → nach Rust-Änderung `npm run build:engine3d`, nur die `.rs` committen. DOSSIER-Audit-Details jetzt in `docs/design/dossier-feature-audit.md` (A1–A6/B1–B4/C/D/E) + `ROADMAP.md` §11.
|
||||
|
||||
## Konventionen (ZWINGEND — zuerst lesen)
|
||||
- **Keine KI-/Assistant-Spuren** im Repo: vor jedem Commit `git diff | grep -iE "claude|anthropic|opus|sonnet|generated with|co-authored|gpt|openai|assistant"` → muss leer sein. Commits deutsch, sachlich, KEINE Co-Authored-By-Trailer.
|
||||
- **NICHT ANFASSEN (fremde WIP):** `.gitignore`, `public/assets/materials/manifest.json`, `src/materials/library.ts`, `scripts/fetch-materials.mjs` — bleiben dauerhaft dirty, nie stagen.
|
||||
- **Agenten-Regeln:** Subagenten default **Sonnet** (Nutzer-Wunsch 2026-07-04); Opus nur bei intricater Geometrie. Agenten dürfen NICHT weiterdelegieren (Agent/Task-Tool verbieten — sonst Spawn-Schleifen), NICHT committen, keine Memory. Hauptinstanz verifiziert (`tsc --noEmit` clean + `vitest run`) + trace-scan, committet dann selbst, staged nur die Task-Dateien (Kollisionen: parallelen Agenten disjunkte Datei-Lanes geben; i18n exklusiv einem Agenten).
|
||||
- **Budget:** Session-Ende bei 98% — Sparflamme.
|
||||
- **Engine = „Nordstern"** (render2d/render3d/WASM). Referenz DOSSIER-Rhino: `https://git.openbureau.ch/karim/dossier` (Alias git.kgva.ch), geklont `/tmp/dossier-ref`.
|
||||
|
||||
## In dieser Session gelandet (Auswahl Commits)
|
||||
Schraffur/Linien-Epic komplett: Typmodell (vector/image Hatch, dash/zigzag/custom Line, Component Vordergrund/Hintergrund), ResourceManager **Master-Detail** (Schraffuren/Linien/Wandstile/Deckenstile, `9695748`), Rendering neuer Typen (Bild-`<pattern>`, random modellraum-verankert, Zickzack, Custom-Motiv-Editor `src/ui/MotifEditor.tsx`), modulares Linien-Segment-System (Strich/Punkt/Lücke), Attribut Vordergrund/Hintergrund + **By-Layer/By-Object-Quellen** (Nach Ebene/Bauteil/eigener Wert für Vordergrund/Hintergrund/Strichstärke/Schraffur, `d02781e`).
|
||||
Weiter: Gehrung spitze Winkel (2D+Schnitt+Rust), TopBar/Footer-Reorg, Zoom-% am Massstab, Tool-Shortcuts 1..0, Verschiebe-Dreieck flacher + Snap, Statusleiste „Nordstern", ResourceManager als **floating nicht-modales Fenster**, **swisstopo** Gebäude(extrudiert)+Terrain-Mesh in 3D (`f22970f`), Schnitt-Wandschicht-Orientierung (`7569524`), **Schnitt-Boolean-Dominanz** nach joinPriority (`23d81be`).
|
||||
|
||||
## IN FLIGHT bei Session-Ende (PRÜFEN!)
|
||||
- **Snap an Wand-Schichttrennlinien** (Agent Sonnet): sollte `src/tools/snapping.ts` (+ ggf. `src/model/geometry.ts` + Test) geändert haben. `git status` prüfen — falls dirty: `tsc`+`vitest`+trace, committen. Falls nicht gelandet: neu beauftragen (Schicht-Offsetlinien aus `addWallPoche`-Logik in generatePlan spiegeln [nur LESEN], als Snap-Targets in snapping.ts einspeisen).
|
||||
|
||||
## Offener Backlog (Priorität grob absteigend)
|
||||
1. **Zu verifizieren/klären**:
|
||||
- Isometrie — „echte" orthographische Isometrie? (nicht perspektivisch; alle Längen gleich dargestellt?) — Status unklar.
|
||||
- TopBar Detailgrad — zoom% + zoom/scale komplett aus Footer? Details der Reorg prüfen.
|
||||
- DOSSIER Audit — welche Features konkret übernommen (über AUDIT Punkt 9 hinaus)?
|
||||
2. **Ebene-Schraffur editierbar**: „Nach Ebene"-Schraffur ist in der Resolve fertig, aber `LayerCategory.hatch` ist im Kategorie-Dialog (App.tsx ~3350, editiert nur name/color/lw) noch nicht editierbar. Kleine Folge-Arbeit.
|
||||
2. **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
|
||||
- Reale Höhen + **Projekt-MüM**: Gebäude/Terrain sind auf z=0 (Terrain zentriert, Gebäude-Basis z=0). Nutzer will Projekt-Setting für MüM (EG-Referenzhöhe), Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert.
|
||||
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
|
||||
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + eine **Gelände-Ebene**; Kontext liegt in `project.context` (siteSlice) ohne floorId/categoryCode.
|
||||
- **Nordstern-Geo-Rendering**: importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh` in Viewport3D) auch in `projectToModel3d` einspeisen — Nordstern zeigt sie sonst nicht.
|
||||
- **3D-Mesh-DXF/DWG-Import** für präzise Gebäude (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
|
||||
3. **ResourceManager Bauteile-Tab** auf Master-Detail (wie Wandstile) — BEVOR Bauteil-Logik angefasst wird.
|
||||
4. **Einstellungs-Fenster**: Auswahlrahmen Default **Yuyake #CEB188**; Snap/Endpunkt Default = RÜCKFRAGE **„aki"** (nicht in Accents: Ajisai/Sakura/Suna/Ichigo/Yuyake/Sora/Kusa/Kori/Amagumo/Yuki); freier Picker + Accents-Presets; verdrahten in PlanView (Selektionsrahmen + Snap-Marker). Dazu Projekt-MüM-Feld.
|
||||
5. **Bildschraffur**: ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
||||
6. **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
||||
7. **Linienstile aufräumen** (Weight-only-Stile → „Volllinie", lineStyleId-Referenzen remappen).
|
||||
8. 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
|
||||
9. **AUDIT (DOSSIER-Studie)**: A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch; A6 Bauteil-Schedule-CSV. Belege in `/tmp/dossier-ref/rhino/*.py`.
|
||||
10. Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
||||
|
||||
## Offene Rückfragen
|
||||
- **„aki"** = welcher Accent für Snap/Endpunkt-Farbe.
|
||||
- Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT).
|
||||
- Geo-Block: Projekt-MüM zuerst oder Nordstern-Geo-Rendering.
|
||||
|
||||
## Verifikation
|
||||
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
|
||||
@@ -1,661 +0,0 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -1,17 +1,16 @@
|
||||
# Dossier
|
||||
# DOSSIER Standalone
|
||||
|
||||
Open-Source-CAAD (Computer Aided Architecture Design). Modelliert ein Gebäude
|
||||
aus semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
|
||||
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).
|
||||
Eigenständiges BIM-Werkzeug für Wohnbau. Modelliert ein Wohnhaus aus
|
||||
semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
|
||||
ohne Revit.
|
||||
|
||||
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.
|
||||
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/Three.js-App in einer Electron-Desktop-Shell statt als Rhino-Aufsatz.
|
||||
Kein Browser-Tab-Produkt: eigenes Fenster, eigene Rendering-Engine
|
||||
(Rust/WASM/WebGPU), gebaut für flüssiges CAD-Arbeiten statt für den Webview
|
||||
kleinster gemeinsamer Nenner.
|
||||
|
||||
## Grundgedanke
|
||||
|
||||
@@ -23,119 +22,117 @@ 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. Schnitte und Ansichten laufen
|
||||
über eine eigene, analytische Rust-Pipeline (kein Hidden-Line-Removal nötig)
|
||||
und sind seit Juli live im 3D-Viewport verdrahtet, nicht nur ein Spike.
|
||||
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.
|
||||
|
||||
## Stand heute
|
||||
|
||||
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. ~91.000 Zeilen TypeScript + ~19.500 Zeilen
|
||||
Rust (ohne Tests), 891 Vitest-Tests, alle grün.
|
||||
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.
|
||||
|
||||
**Funktioniert (Auszug, nicht abschliessend):**
|
||||
- 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).
|
||||
**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.
|
||||
|
||||
**Bewusst noch offen** (Auszug):
|
||||
- **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.
|
||||
**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).
|
||||
|
||||
## Stack
|
||||
|
||||
Bewusst leichtgewichtig — Three.js ist reiner Display-Layer, kein schwerer
|
||||
Geometrie-Kernel verfrüht eingezogen.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Shell | **Tauri** auf macOS (WKWebView+WebGPU) · **Electron/Chromium** auf Linux (WebKitGTK kann kein WebGPU) — beide randlos, eigene Titelleiste, gemeinsame React-App |
|
||||
| Shell | Electron (eigenes randloses App-Fenster, kein Browser-Tab) |
|
||||
| Frontend | React + TypeScript + Vite |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Import | `dxf-parser`, `@mlightcad/libredwg-web` (DWG), eigene `.lin`/`.pat`-Parser |
|
||||
| Materialien | `jszip` (ambientCG-Zip-Entpacken), `three.js` `TextureLoader` |
|
||||
| Schnitt/HLR-Spike | `opencascade.js` (OCCT-WASM) — isoliert, noch nicht verdrahtet |
|
||||
|
||||
Der ursprünglich geplante OCCT/`opencascade.js`-HLR-Pfad für Schnitte ist
|
||||
komplett entfernt (weder Dependency noch Code) — abgelöst durch die
|
||||
analytische Rust-Schnitt-Pipeline (siehe Grundgedanke oben).
|
||||
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.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Vite, http://localhost:5187
|
||||
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
|
||||
npm run dev # Vite, http://localhost:5187
|
||||
npm run electron # Electron-Fenster (Dev-Server + randlose App-Shell)
|
||||
npx tsc -b # Typecheck
|
||||
npm run build # tsc -b && vite build
|
||||
npm test # vitest run
|
||||
```
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
## Aufbau
|
||||
|
||||
```
|
||||
src/
|
||||
model/ semantisches Modell (types, joins, parametricWalls, roomStamp, terrain)
|
||||
geometry/ 2D-Kernel (offset/trim/fillet/intersect, ceiling/opening/roomArea/stair/roof/column)
|
||||
model/ semantisches Modell + Ableitungen (types, parametricWalls, roomStamp, joins, terrain)
|
||||
geometry/ 2D-Kernel (offset/trim/fillet/intersect, ceiling, opening, roomArea/-Boundary, stair)
|
||||
commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/)
|
||||
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
|
||||
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2) + Rust-Bindings
|
||||
viewport/ Viewport3D (Three.js) + Wasm3DViewport (Rust/wgpu „Nordstern", Default)
|
||||
export/ IFC4-, PDF-, DXF-, STL/OBJ-, CSV-Export aus derselben Plan-/Modell-Struktur
|
||||
materials/ ambientCG-Live-Suche + gebündelte Starter-Bibliothek + PBR-Runtime
|
||||
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)
|
||||
panels/ dockbares Panel-System + die einzelnen Paletten
|
||||
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
|
||||
state/ Store + Slices (project/selection/view/layout)
|
||||
ui/ Top-Bar, Statusleiste, Kontextmenü, Resource Manager, Command-Line
|
||||
io/ Import/Export (DXF, DWG, .lin, .pat), Swisstopo/LV95, OSM-Kontext
|
||||
i18n/ Wörterbücher de/en
|
||||
src-tauri/ 5 eigenständige Rust-Crates (render2d/render3d/kernel2d/trucksolid/
|
||||
dwgimport, je headless UND per wasm-pack baubar) + der Tauri-Host selbst
|
||||
src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WASM
|
||||
gebaut (npm run build:engine), Tauri-Host selbst ist ausrangiert
|
||||
```
|
||||
|
||||
## Konventionen
|
||||
@@ -144,26 +141,17 @@ src-tauri/ 5 eigenständige Rust-Crates (render2d/render3d/kernel2d/trucksol
|
||||
(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).
|
||||
|
||||
## Weiterlesen
|
||||
|
||||
Dieses README ist die einzige Doku im öffentlichen Repo — `STATUS.md`,
|
||||
`ARCHITECTURE.md`, `ROADMAP.md`, `HANDOVER.md`, `PENDENZEN.md`,
|
||||
`CONVENTIONS.md` und `docs/` sind bewusst per `.gitignore` ausgeschlossen
|
||||
(interne Arbeitsnotizen/Backlog, kein öffentlicher Anspruch auf Vollständigkeit
|
||||
oder Aktualität) und daher hier absichtlich **nicht** verlinkt.
|
||||
|
||||
## 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.
|
||||
|
||||
Die AGPL verlangt, dass auch bei Betrieb als Netzwerk-/Webdienst der (ggf.
|
||||
geänderte) Quellcode für die Nutzer verfügbar gemacht wird. Drittkomponenten
|
||||
behalten ihre jeweiligen Lizenzen (siehe „Über"-Dialog in der App).
|
||||
- [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 …)
|
||||
|
||||
---
|
||||
|
||||
Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, Swisstopo-Geodaten)
|
||||
sind ein bewusster Differenzierer.
|
||||
Privates Projekt. Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen,
|
||||
Swisstopo-Geodaten) sind als Differenzierer eingeplant.
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
# Browser-BIM für Wohnbau — Architektur & Produkt-Roadmap
|
||||
|
||||
> Arbeitstitel: **cad** (Name später)
|
||||
> Ausrichtung: **BIM-first** · Nische: **Wohnbau / Einfamilienhäuser**
|
||||
> Stand: 2026-06-28
|
||||
|
||||
## 1. Produktvision
|
||||
|
||||
Ein **browserbasiertes BIM-Werkzeug** für Wohnbau, das zwei Dinge verbindet:
|
||||
|
||||
1. **Einfaches 3D-Gebäudemodell** — aus semantischen Bauteilen: Wände, Türen, Fenster, Treppen, Decken, Dächer, Räume.
|
||||
2. **Schöne, normgerechte 2D-Pläne** — Grundrisse, Schnitte, Ansichten — automatisch aus dem Modell abgeleitet.
|
||||
|
||||
**Kernversprechen:** *Das schönste und einfachste Werkzeug, um ein Wohnhaus zu modellieren und daraus perfekte Pläne zu ziehen.* Nicht Revit nachbauen — radikaler Fokus auf Wohnbau + Plan-Qualität.
|
||||
|
||||
**Markt-Beleg:** Arcol, Snaptrude, TestFit zeigen, dass browserbasiertes BIM real ist und Nutzer schlanke, schöne Tools wollen statt der schwerfälligen Giganten (Revit/ArchiCAD).
|
||||
|
||||
---
|
||||
|
||||
## 2. Das mentale Modell — warum BIM anders ist als CAD
|
||||
|
||||
| | mechanisches CAD | **BIM (unser Weg)** |
|
||||
|---|---|---|
|
||||
| Bausteine | generische Volumenkörper | **semantische Bauteile** (Wand, Tür, Fenster…) |
|
||||
| Beziehungen | keine | Tür *hostet* in Wand & schneidet Öffnung; Wände *verbinden* sich |
|
||||
| Geschosse | — | **Stockwerke** als erste Klasse |
|
||||
| 2D-Plan | Hidden-Line-Projektion | **symbolische Darstellung** (Schwenkbögen, Schraffuren, Lauflinien) |
|
||||
| Standard | STEP | **IFC** |
|
||||
|
||||
---
|
||||
|
||||
## 2b. Kern-Prinzip: ein Modell, viele Darstellungen
|
||||
|
||||
Das **wichtigste Architektur-Prinzip**: Das semantische Modell ist die *eine
|
||||
Wahrheit*; jede Ansicht (3D, Grundriss, Schnitt) ist eine **abgeleitete
|
||||
Darstellung**. Im Spike steht das bereits. Daraus folgen direkt die Kern-Wünsche:
|
||||
|
||||
- **Modelldarstellungen / Detailgrade** — derselbe Tür/Fenster wird je nach
|
||||
`detailLevel` (grob / mittel / fein) unterschiedlich gezeichnet (≙ Revit
|
||||
„Detailgrad", ArchiCAD „Modelldarstellung"). Grob: Öffnung + Linie. Fein:
|
||||
Rahmen, Blatt, Schwenkbogen, Anschlag.
|
||||
- **Editierbare Stile** — Wandfarben, Linienstärken, Türlinien, Schraffuren als
|
||||
**Stil-Schicht**, erst beim Rendern angewandt (nicht in die Geometrie
|
||||
eingebacken). Pro Kategorie *und* pro Element überschreibbar.
|
||||
- **Mehrschichtige Bauteile** — Wände/Decken mit **Schichtaufbau** (`layers[]`:
|
||||
Material + Dicke + Priorität). 3D und Plan lesen dieselben Schichten.
|
||||
- **In 2D *und* 3D zeichnen** — beide sind editierbare Sichten auf *ein* Modell;
|
||||
Werkzeuge mutieren das Modell, alle Sichten re-derivieren reaktiv.
|
||||
|
||||
Schwierigkeit: Detailgrade/Stile/Schichten sind 🟢 gut machbar; 2D+3D-Editieren
|
||||
🟡 mittel; **mehrschichtige Wand-Verschneidung** 🔴 der härteste Teil (Risiko #1).
|
||||
|
||||
---
|
||||
|
||||
## 2c. Arbeitsweise & Dokumentmodell (DOSSIER-Modell) ⭐
|
||||
|
||||
Referenz: **DOSSIER** (Rhino-Plugin des Nutzers, https://git.kgva.ch/karim/DOSSIER).
|
||||
Dieses Projekt ist die **Standalone-Browser-Variante** davon. Zwei *unabhängige* Achsen:
|
||||
|
||||
- **Zeichnungsebenen** — die obersten Dokument-Abschnitte, zwei Arten:
|
||||
- **Geschosse** (EG, 1OG …): `hoehe` (Geschosshöhe), `schnitthoehe` (Schnitthöhe),
|
||||
`okff` (Niveau, akkumuliert), `visible`/`locked`.
|
||||
- **Schnitte / Ansichten** (`type:"schnitt"`): Schnittlinie `linePts`, Richtung
|
||||
`dirSign`, Höhenbereich, Tiefe.
|
||||
- Ein **Geschoss** wird **im 3D-View ODER im Plan-View** betrachtet (Umschalter,
|
||||
*keine* getrennten Daten). Plan-View = Clipping-Ebene auf `okff + schnitthoehe`.
|
||||
- **Ebenen** — das **Grafik-Kategorien-Schema**, in *jedem* Geschoss vorhanden;
|
||||
Baum-Knoten mit pro Ebene einstellbaren **Darstellungseinstellungen** (in den
|
||||
„Ebeneneinstellungen…"): **Stift** = Typ/Linienstil + Farbe + Dicke (lw);
|
||||
**Schraffur** = Typ + Skalierung + Rotation + **Stiftstärke der Schraffurlinien**.
|
||||
Modell: `{code, name, visible, locked, lineStyleId|{type,color,lw}, hatchId|{type,scale,angle,lineWeight}, children}`.
|
||||
Codes 1:1 wie DOSSIER:
|
||||
`00 Raster · 01 Vermessung · 20 Wände (└21 Türen/Fenster) · 30 Decken · 31 Dächer
|
||||
· 40 Treppen (└41 Treppen-2D) · 50 Tragwerk · 60 Räume · 80 Plangrafik …`
|
||||
- **Elemente** (Wand, Decke, Treppe, Öffnung, Plangrafik, Text) liegen **auf den
|
||||
Ebenen** und kennen ihr **Geschoss** *und* ihre **Ebene (Code)**.
|
||||
- **2D-Zeichnen** (Linie, Polylinie, Rechteck, Kreis, Bogen, Text) findet auf der
|
||||
Ebene `80 Plangrafik` (bzw. passender Kategorie) statt.
|
||||
|
||||
**Ansichtstypen = Kamera-Projektion + optionaler Schnitt** (vereinheitlicht):
|
||||
|
||||
| Typ | Projektion | Schnitt |
|
||||
|---|---|---|
|
||||
| **Grundriss** | Top-View (orthogonal) | horizontal auf `okff + schnitthöhe` |
|
||||
| **Schnitt** | Front-View in eine Richtung (orthogonal) | vertikale Schnittebene (Geschnittenes + dahinter) |
|
||||
| **Ansicht** | Front-View in eine Richtung (orthogonal) | kein Schnitt (Fassade außen) |
|
||||
| **Perspektive** | 3D perspektivisch | — |
|
||||
|
||||
Sichtbarkeit pro Ansicht über Ein-/Ausschalten von Ebenen & Zeichnungsebenen.
|
||||
|
||||
Persistenz (DOSSIER): zwei getrennte JSON-Bäume `dossier_zeichnungsebenen` und
|
||||
`dossier_ebenen`; Elemente tragen `geschoss`-id + Ebenen-`code`. Plan-View nutzt
|
||||
eine Clipping-Ebene; weitere Konzepte: Overrides (regelbasiert), Ausschnitte
|
||||
(View-Snapshots), Massstab (pro Viewport), Layer-Kombinationen, SIA-Räume.
|
||||
|
||||
## 2d. Resource Manager & Prioritäts-Verschneidung 🔴
|
||||
|
||||
Verwaltete Ressourcen-Bibliotheken wie in Vectorworks, jeweils mit eigenem Manager:
|
||||
|
||||
- **Line Manager** — Linienstile (Stärke, Farbe, Strichelung), wiederverwendbar.
|
||||
- **Hatch Manager** — Schraffurstile (Muster, Maßstab, Winkel, Linienstil).
|
||||
- **Component Manager** — Baustoffe mehrschichtiger Bauteile. Pro Component:
|
||||
**Schraffur** (→ Hatch Manager), **3D-Textur**, Farbe und
|
||||
**Verschneidungs-Priorität** (`joinPriority`).
|
||||
|
||||
Alles verweist per id auf diese Bibliotheken (Components nutzen Hatches, Hatches
|
||||
nutzen Linienstile, 2D-Objekte & Ebenen-Defaults nutzen Linienstile/Schraffuren) —
|
||||
zentral änderbar.
|
||||
|
||||
Regel: **höhere Priorität verschneidet sich zuerst / läuft durch.** Beispiel an
|
||||
einer T-Ecke (Beton-Wand mit Innen- und Außenputz):
|
||||
|
||||
- **Beton** (höchste Prio) läuft in der Mitte **durch** den Stoß.
|
||||
- **Putze** (niedrige Prio) **verbinden** sich jeweils auf ihrer Seite mit dem
|
||||
angrenzenden Putz, gehen aber **nirgends durch** den Beton.
|
||||
|
||||
Das ist die anspruchsvollste Verschneidungs-Logik (Revit „Layer Priority /
|
||||
Wrapping", Vectorworks „Component-Verschneidung"). Wir bauen sie stufenweise auf
|
||||
der bereits funktionierenden L-Ecken-Gehrung auf.
|
||||
|
||||
---
|
||||
|
||||
## 3. Zwei Wege zum 2D-Plan (zentrale Architektur-Erkenntnis)
|
||||
|
||||
Architektur-Pläne entstehen auf **zwei verschiedenen Wegen** — das prägt die ganze Engine:
|
||||
|
||||
**A) Grundriss = aus dem semantischen 2D-Footprint + Symbolik**
|
||||
Ein Grundriss ist ein horizontaler Schnitt auf ~1 m. Statt ein 3D-Mesh zu zerschneiden, generieren wir ihn **direkt aus den Parametern**: Wand-Achsen + Dicken → Linien; Öffnungen → Lücken + Tür-/Fenstersymbol; Treppe → Lauflinie. Schnell, exakt, sauber, vektorbasiert.
|
||||
|
||||
**B) Schnitt & Ansicht = aus 3D-Projektion (HLR)**
|
||||
Vertikale Schnitte und Ansichten brauchen echte 3D-Projektion mit verdeckten Kanten (Hidden Line Removal) durch das zusammengebaute Gebäude.
|
||||
|
||||
→ Wir brauchen **beides**: einen sauberen 2D-Symbol-Renderer *und* einen Projektions-Pfad.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tech-Stack
|
||||
|
||||
| Schicht | Wahl | Begründung |
|
||||
|---|---|---|
|
||||
| BIM-Datenmodell | **eigenes parametrisches Gebäudemodell** (TS) | web-ifc ist stark beim *Lesen/Anzeigen* von IFC, schwächer beim *Authoring/Editieren*. Für ein Editier-Tool brauchen wir ein eigenes, editierbares Modell. |
|
||||
| IFC-Interop | **web-ifc** (ThatOpen, WASM) | Import/Export nach IFC — Brücke zu Revit/ArchiCAD. |
|
||||
| 3D-Rendering | **Three.js** | Standard; ThatOpen baut darauf auf, also kompatibel. |
|
||||
| Geometrie-Booleans | **OpenCascade.js** (Öffnungen) *oder* Manifold | Tür/Fenster schneidet Loch in Wand. OCC = exakt (B-Rep), Manifold = schnell (Mesh). Entscheidung in Phase 0. |
|
||||
| Projektion/HLR | **OpenCascade.js** (`HLRBRep`) | Saubere Linien für Schnitte/Ansichten. |
|
||||
| 2D-Pläne | **SVG** + eigener Symbol-Renderer | Vektor, druckbar, exportierbar (DXF/PDF). |
|
||||
| Frontend | **React + TypeScript + Vite** | Schnelles HMR, großes Ökosystem. |
|
||||
| Worker-Bridge | **Comlink** | Schwere Geometrie im Web Worker, UI bleibt flüssig. |
|
||||
| State | **Zustand** o.ä. | Passt zum komplexen Dokumentmodell. |
|
||||
| Persistenz (später) | Postgres + Object Storage | Versionierbare Projekte. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Datenmodell (grob)
|
||||
|
||||
Vectorworks-orientiert: **Design Layers** (Modell-Eingabe) und **Drawing Layers**
|
||||
(abgeleitete Ausgabe). Bauteile beziehen ihren Aufbau aus **Components** (verwaltet
|
||||
im Component Manager).
|
||||
|
||||
```
|
||||
Project
|
||||
├─ Resources // verwaltete Bibliotheken (Vectorworks-Stil)
|
||||
│ ├─ LineStyles[] // Line Manager: { id, name, weight, color, dash }
|
||||
│ ├─ Hatches[] // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId }
|
||||
│ └─ Components[] // Component Manager: wiederverwendbare Baustoffe
|
||||
│ Component { id, name, hatchId, texture3d, color, joinPriority }
|
||||
│ // joinPriority: höher = verschneidet sich zuerst (geht durch)
|
||||
├─ Grids (Achsraster) (optional)
|
||||
├─ Types // mehrschichtige Aufbauten
|
||||
│ ├─ WallType { id, name, layers: Layer[] }
|
||||
│ └─ SlabType { id, name, layers: Layer[] }
|
||||
│ Layer = { componentId, thickness } // Priorität liegt am Component
|
||||
│
|
||||
├─ DesignLayers ("Ebenen") // hier wird modelliert & 2D gezeichnet
|
||||
│ DesignLayer { id, name, elevation(z), height(Δz),
|
||||
│ defaultLineStyle, defaultHatch,
|
||||
│ elements: Wall | Door | Window | Slab | Stair | Roof | Space ,
|
||||
│ draw2d: Line | Polyline | Rect | Circle | Arc | Text }
|
||||
│ Wall { axis, wallTypeId, height }
|
||||
│ Door { hostWall, position, width, height, swing, symbolId }
|
||||
│ Window { hostWall, position, width, height, sill }
|
||||
│ Slab { boundary, slabTypeId } · Space { boundary, name } // Fläche auto
|
||||
│
|
||||
└─ DrawingLayers ("Zeichnungsebenen") // abgeleitete Ausgabe
|
||||
DrawingLayer { id, name,
|
||||
type: plan | section | elevation | drawing,
|
||||
cutHeight(z), // bei plan: Schnitthöhe
|
||||
sectionLine, // bei section
|
||||
sourceDesignLayers[],
|
||||
detailLevel: coarse|medium|fine,
|
||||
scale, styleOverrides, dims[], labels[], annotations[] }
|
||||
```
|
||||
3D *und* jede Drawing Layer werden **aus den Design Layers abgeleitet**. `cutHeight`,
|
||||
`detailLevel`, `Styles` und `Component`-Eigenschaften steuern, *wie* abgeleitet wird.
|
||||
|
||||
---
|
||||
|
||||
## 6. Die harten Risiken (früh angehen)
|
||||
|
||||
1. **Wand-Verbindungen / Cleanup** ⚠️ — Wo Wände aufeinandertreffen, müssen sie sauber verschneiden. **L-Ecken-Gehrung: ✅ erledigt.** Offen & berüchtigt schwer: **Prioritäts-basierte T-/X-Stöße bei mehrschichtigen Wänden** (Beton durch, Putz verbindet seitlich, geht nicht durch — siehe 2d). → stufenweise auf der Gehrung aufbauen.
|
||||
2. **Gehostete Öffnungen** — Tür/Fenster muss synchron mit der Wand bleiben (verschieben, schneiden). → Saubere Host-Beziehung im Modell.
|
||||
3. **Symbolischer Plan-Renderer** — normgerechte Darstellung (Schwenkbögen, Schraffuren der geschnittenen Bauteile, Lauflinien). → Eigenes Regelwerk; früh prototypen.
|
||||
4. **Schnitt-/Ansichts-Projektion (HLR)** durch ganzes Gebäude — Performance. → Worker + Caching.
|
||||
5. **IFC-Treue** — verlustarmer Round-Trip. → Früh mit echten IFC-Dateien testen.
|
||||
6. **Geschoss-übergreifende Elemente** (Treppen, Lufträume).
|
||||
|
||||
---
|
||||
|
||||
## 7. Phasen-Roadmap
|
||||
|
||||
### Phase 0 — Spike: das größte Risiko zuerst
|
||||
**Ziel:** Beweisen, dass der symbolische Plan-Pfad im Browser funktioniert.
|
||||
- Eine Wand zeichnen, eine Tür platzieren → Öffnung wird geschnitten (3D).
|
||||
- Daraus **Grundriss als SVG** generieren: Wand-Schnittlinien + Tür-**Schwenkbogen**.
|
||||
- ✅ *Erfolg:* sauberer, schöner Grundriss-Ausschnitt aus einem semantischen Modell.
|
||||
|
||||
### Phase 1 — MVP: durchgehende Wohnbau-Scheibe
|
||||
- **Dokumentmodell (Vectorworks-Stil):** Design Layers ("Ebenen") + Drawing Layers
|
||||
("Zeichnungsebenen", Typ plan/section/elevation, mit `cutHeight`).
|
||||
- **Resource Manager:** Line Manager, Hatch Manager, Component Manager (mit
|
||||
`joinPriority`, Schraffur, 3D-Textur).
|
||||
- **Wände** mehrschichtig (✅) mit Eck-Gehrung (✅); **Prioritäts-T-Stöße** (Risiko #1).
|
||||
- **Türen & Fenster** gehostet in Wänden (Risiko #2).
|
||||
- **2D-Zeichnen:** Linie, Polylinie, Rechteck, Kreis, Bogen mit Stilen.
|
||||
- **Decken/Böden** (Slabs); 3D-Viewport + **live Grundriss**, Basis-Bemaßung.
|
||||
- → aus DOSSIER (§11): Wand-Referenzlage (mid/left/right), Öffnungs-Detailgrad mit Dokument-Override, Decken-Aussparungen, Element-Übersicht (BIM-Tree).
|
||||
- ✅ Ein einfaches Haus modellieren → saubere Pläne pro Geschoss.
|
||||
|
||||
### Phase 2 — Vollständiger Bauteil-Satz Wohnbau
|
||||
- **Treppen** (mit Lauflinie im Plan), **Dächer**, Geländer.
|
||||
- **Räume/Spaces** mit automatischer Flächenberechnung & Raumstempel.
|
||||
- Stützen/Unterzüge (falls nötig).
|
||||
- Materialien & einfache Visualisierung.
|
||||
- → aus DOSSIER (§11): Treppen-Typen (gerade/L/Wendel) + geschossübergreifend, Dach-Typen (Pult/Sattel/Walm/Mansarde), Stützen-Profile, **SIA-416-Räume** + CSV, Raumstempel-Builder, Stil-Kataloge (Wände/Öffnungen).
|
||||
|
||||
### Phase 3 — Plan-/Dokumentations-Modul ⭐ (Differenzierung)
|
||||
**Hier gewinnen wir. Maximale Politur.**
|
||||
- **Grundrisse, Schnitte (HLR, Risiko #4), Ansichten.**
|
||||
- **Automatische Bemaßung** (Außenketten, Achsen, Öffnungen) + manuelle.
|
||||
- Schraffuren geschnittener Bauteile, Raumstempel, Beschriftungen, Symbole.
|
||||
- **Plansätze/Sheets** mit Titelblock, Maßstäben, Layout.
|
||||
- Schöne Typografie & Linienführung — genau das, was die Großen vermasseln.
|
||||
- → aus DOSSIER (§11): **Massstab pro Viewport** (Auto-DPI, Plotweight-/Schraffur-Skalierung), Section-Style (3D-Schnittflächen), Ausschnitte (View-Snapshots) + Layer-Kombinationen, Kamera-Presets (Kardinal/Iso, **Norden-Rotation**), Detail-Bindung an Ausschnitt, Multi-Page-PDF @DPI, regelbasierte Overrides, Rich-Text-Annotationen.
|
||||
|
||||
### Phase 4 — Interop (Import/Export)
|
||||
- **Import:** **DWG/DXF** (2D-Pläne/Bestand), **IFC** (BIM-Bestand), **STL/OBJ**
|
||||
(Mesh-Referenzmodelle), **XYZ** (Punktwolken aus Vermessung).
|
||||
- **Export:** **IFC** (Brücke zu Revit/ArchiCAD), **DWG/DXF**, **glTF/OBJ**.
|
||||
- Round-Trip-Tests mit echten Dateien (Risiko #5).
|
||||
- → aus DOSSIER (§11): **Swisstopo-Import** (swissBUILDINGS3D / swissALTI3D / SWISSIMAGE, LV95↔WGS84) ⭐ CH, OSM-Overpass-Kontext, Terrain-Mesh-Generator.
|
||||
|
||||
### Phase 5 — Persistenz & Konten
|
||||
- Accounts, Projekte speichern/laden, Versionierung, Auto-Save.
|
||||
- → aus DOSSIER (§11): Projekt-Persistierung auf Browser-Storage migrieren (IndexedDB statt doc.Strings); Presets/Favoriten cross-projekt (LocalStorage, Export/Import).
|
||||
|
||||
### Phase 6 — Export & Kollaboration
|
||||
- Export: **PDF**, **DXF** (Pläne); **IFC**, **glTF** (3D).
|
||||
- Teilen per Link, Kommentare, später Echtzeit-Co-Editing.
|
||||
|
||||
### Phase 7 — Produktisierung
|
||||
- Performance-Härtung (große Modelle), Onboarding, Pricing, PWA/Offline.
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Fragen
|
||||
- Booleans: OpenCascade (exakt) vs. Manifold (schnell) — Entscheidung in Phase 0.
|
||||
- Welche Normen für Plandarstellung (SIA / DIN / …)? → beeinflusst Symbolik. *(Hinweis: User ist in der Schweiz → SIA prüfen.)*
|
||||
- Wie viel Statik/Bauphysik (gar nicht / später)?
|
||||
- Pricing-Modell (Freemium, pro Seat?).
|
||||
|
||||
---
|
||||
|
||||
## 9. Nächster konkreter Schritt
|
||||
**Phase 0 starten:** Projekt scaffolden + Spike bauen — Wand + Tür mit geschnittener Öffnung → schöner Grundriss-Ausschnitt als SVG (mit Schwenkbogen). Das entschärft Risiko #1–#3 (Modell, Hosting, Symbolik) auf einmal.
|
||||
|
||||
---
|
||||
|
||||
## 10. Leitentscheidungen & UI-Architektur
|
||||
|
||||
### 10a. Leitentscheidungen aus der Recherche (Details in `docs/`, Index `docs/README.md`)
|
||||
1. **Pure-Ableitungs-Architektur** als Fundament — ein semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern (Store + Undo früh).
|
||||
2. **OCCT/replicad im Web Worker** früh als Spike — kritischer Pfad für Schnitt/Ansicht (HLR), exakte Wand-Booleans und IFC. WASM-Größe + HLR-Kosten (pro Ansicht cachen) validieren.
|
||||
3. **Component-getriebene Prioritäts-Verschneidung** (`joinPriority` am Component): höchstes gemeinsames Material läuft durch, Rest mitert. 2D-Plan analytisch, exakte 3D-Booleans im Worker.
|
||||
4. **SVG/Paper-Space-Maßstabsmodell** — Strichstärke/Text/Hatch in mm, `dpi=96·devicePixelRatio`, ein Serializer für Bildschirm + PDF + DXF.
|
||||
5. **CH-Spezifika als Differenzierer** ohne Backend — SIA-416 (reine Logik) + serverloser Swisstopo-Flow (CORS-offen, Parzelle/EGRID, Norden-Rotation, Origin-Shift).
|
||||
|
||||
### 10b. Panel-System (dockbar, Tabs, erweiterbar) — NEU
|
||||
- **Docks links & rechts**; Panels in **Tab-Strips** gruppierbar (z. B. Zeichnungsebenen & Ebenen als Tabs eines Docks).
|
||||
- **Panel-Registry** → eigene Panels und spätere **Plugins** registrieren sich und erscheinen als Panel.
|
||||
- **Verschiebbar & floatend (am Tab gegriffen):** Ein Panel wird **am Tab selbst** (im Tab-Strip) gezogen → innerhalb des Docks **umsortieren**, ins andere Dock ziehen, oder aus dem Dock lösen. Andocken am **linken/rechten Rand** (Andock-Zonen beim Ziehen hervorheben); wird nicht angedockt, **schwebt** das Panel als freies (verschieb- und größenveränderbares) **Floating-Fenster** über dem Arbeitsbereich. Float-Position/Größe + Dock-Zustand werden gespeichert.
|
||||
- **Fenster-Layouts speicherbar** (localStorage, benannte Layouts; Standard-Layout als Default).
|
||||
- Der **Ressourcen-Manager** wird ebenfalls ein Panel (rechtes Dock).
|
||||
- Pro Layer-Panel oben ein **Anzeige-Modus-Dropdown**: *nur aktive · alle anzeigen · aktive + andere grau* (DOSSIER/Vectorworks „Layer Options") — wirkt auf Plan & 3D.
|
||||
|
||||
### 10c. Plan-Navigation
|
||||
- Grundriss/Schnitt/Ansicht: **Pan** (ziehen), **Zoom** (Mausrad zum Cursor), **Einpassen** — analog zum 3D-Viewport. SVG-`viewBox`-Transform.
|
||||
|
||||
### 10d. Backend & Kollaboration (Details: `docs/backend.md`)
|
||||
- **Jetzt:** client-only (IndexedDB + Datei-Export/Import), kein Server. Modell JSON-serialisierbar + Edits als Operationen → **CRDT-fähig** halten.
|
||||
- **Phase 5:** **Supabase self-hosted** (Docker Compose: Postgres + Auth + Storage) für Konten/Projekte/Dateien.
|
||||
- **Phase 6:** **Yjs + Hocuspocus** (CRDT-Sync-Container, persistiert nach Postgres) für Echtzeit-Kollaboration. Alles self-hosted.
|
||||
- Empfehlung: Stack **noch nicht** aufsetzen (würde den Modellierer ausbremsen); Weiche ist gestellt, Einführung additiv.
|
||||
|
||||
### 10e. Top-Bar & Footer/Status-Leiste (Vectorworks-Stil) — NEU
|
||||
- **Top-Bar (Oberleiste, wie DOSSIER `toolbar.py`/`ToolbarApp.jsx`):** Ansichts-Umschalter (Grundriss/Perspektive/Schnitt/Ansicht), Render-/Darstellungsmodus, aktives Geschoss + aktive Ebene, Snapping-Schalter, Massstab, Werkzeug-Kontext, Einstellungen/Ressourcen.
|
||||
- **Footer/Status-Leiste (wie Vectorworks unten):** Cursor-Koordinaten **X/Y/Z**, Einheit, aktueller **Massstab** (1:N) + **Zoom %**, aktives Geschoss/Ebene, **Snap-Status**, kurzer Werkzeug-Hinweis links.
|
||||
- Beide an das Panel-/Dock-Layout angedockt; Inhalte aus dem Modell abgeleitet.
|
||||
|
||||
### 10f. Maus-Interaktion & Kontextmenü — NEU
|
||||
- **Maus-Schema:** **Mitte = navigieren** (Plan: Pan · 3D: Orbit, `Shift`+Mitte: Pan) · **Links = Auswahl** (Einzelklick + **Markierrahmen/Aufziehrahmen** für Mehrfachauswahl im 2D) · **Rechts = Kontextmenü** · **Rad = Zoom** (zum Cursor).
|
||||
- Marquee: Aufziehen von links→rechts = nur vollständig umschlossene Elemente; rechts→links = auch berührte (wie CAD-üblich).
|
||||
- **Eigenes Kontextmenü-System** (gestylt, dunkel, wiederverwendbar) — kein Browser-Menü.
|
||||
- **Ebenen-Kontextmenü 1:1 wie DOSSIER** (Einträge aus `layers_panel.py`/`DrawingLevelsApp.jsx` übernehmen) — auch auf Zeichnungsebenen.
|
||||
- Kontextmenü generisch, damit Plan-Elemente, Panels & spätere Plugins eigene Einträge registrieren können.
|
||||
|
||||
---
|
||||
|
||||
## 11. Aus DOSSIER übernehmen — Backlog
|
||||
|
||||
Konkret im DOSSIER-Rhino-Plugin umgesetzte Features, die sich für den Standalone-Port lohnen. Bereits abgedeckt (Layer-Modell, mehrschichtige Wände, Prioritäts-Stöße, Component-/Line-/Hatch-Manager, Ansichtstypen, Detailgrade) ist hier **nicht** erneut gelistet — nur das Zusätzliche. Aufwand: S/M/L. Phase verweist auf §7.
|
||||
|
||||
> **⭐ CH-Schätze (Schweiz-spezifisch, kaum woanders verfügbar):**
|
||||
> - **Swisstopo-Geodaten** — swissBUILDINGS3D (3D-Bestand), swissALTI3D (präzises Höhenmodell), SWISSIMAGE (10-cm-Orthofoto), offene STAC-APIs ohne Auth, inkl. **LV95↔WGS84**-Transformation. Echter Standort-Kontext per Knopfdruck statt manuellem CAD-Import.
|
||||
> - **SIA-416-Flächen** — Raum-Klassifikation (HNF/NNF/VF/FF/GF/AGF) mit automatischer Bilanz + CSV/Excel-Export. Pflicht für CH-Energie-/Flächennachweise.
|
||||
> - **Norden-Rotation** bei Kamera-Presets — Georeferenzierung passend zu Swisstopo/swissBUILDINGS.
|
||||
|
||||
### Bauteile
|
||||
|
||||
| Feature | Nutzen | Aufwand | Phase |
|
||||
|---|---|---|---|
|
||||
| Wand-Referenzlage (mid/left/right) | Achse intuitiv auf Aussenkante/Mitte legen; hilft beim Import fremder Dateien | S | 1 |
|
||||
| Öffnungs-Detailgrad + Dokument-Override (`aktive_darstellung`) | LoD je Massstab (1:500 Rechteck → 1:50 Glas/Sims) global umschaltbar; kritisch für Mixed-Scale | M | 2 |
|
||||
| Fenster/Tür mit Rahmen, Brüstung, Sims, Glas, Flügelzahl | Öffnung als vollwertiges Bauteil statt nur Loch; Render-Realismus | M | 2 |
|
||||
| Tür-Schwenkbogen (Öffnungswinkel + Anschlagseite) | Öffnungsbahnen für Möblierung/Kollision; Standard-Plansymbol | M | 2 |
|
||||
| Decken-Aussparungen (Treppenauge, Schächte, Kamin) | Konstruktiv echte Deckenöffnungen, nicht nur sichtbar | M | 1–2 |
|
||||
| Decken UK/OK-Override | Abhängungen, schräge Brüstungen, abweichende Raumhöhen | S | 1 |
|
||||
| Treppen-Typen gerade/L/Wendel + Stufen/Lauflinie/Podest | Volle Vertikalerschliessung, volumetrisch korrekt, Plan-Symbole | M | 2 |
|
||||
| Treppe geschossübergreifend (`geschoss_end`, Höhen-Override) | Atrien, Rampen, Mehr-Geschoss-Läufe (Risiko #6) | S | 2 |
|
||||
| Treppen-2D-Symbol mit Auf-/Abpfeil + Schnitt | Normgerechtes Plansymbol (Richtung, Stufenzahl, Lauflinie) | M | 2 |
|
||||
| Dach-Typen Pult/Sattel/Walm/Mansarde + Neigung(en) | 3D-Volumen mit Gefälle, Kubatur, Material; Mansarde später (L) | M–L | 2–3 |
|
||||
| Stützen-Profile (Quadrat/Rechteck/Rund/I/Rohr) + Drehung | Beton- und Stahltragwerk mit echtem Querschnitt | M | 2 |
|
||||
| Träger achs-basiert, hängt unter Decken-OK | Unterzug folgt Deckenoberkante, weniger Fehler bei Updates | S | 2 |
|
||||
| **SIA-416-Räume** (HNF/NNF/VF/FF/GF/AGF) + Bilanz-CSV ⭐ | CH-Flächennachweis, Excel-Export | M | 2 |
|
||||
| Raum-Stempel-Builder (Drag-&-Drop-Felder) + Fläche-Rundung + Personen | Projekt-eigene Stempel-Layouts ohne Code; lesbare Listen; Brandschutz | M | 2–3 |
|
||||
| Element-Übersicht (BIM-Tree Geschoss→Kind→Element, Suche, Zoom) | Inhaltsverzeichnis bei 100+ Elementen; Shift-Klick = Zoom | S | 1 |
|
||||
| Stil-Kataloge Wände & Öffnungen (Presets) | Standard-Typen 1-Klick; globaler Stilwechsel | M | 2 |
|
||||
| Grip-Editing (Wand-Endpunkte, Schnitt-Symbole im Plan) | Direktes Ziehen statt Dialog; 2D/3D-Sync; wichtig im Browser | L | 3–4 |
|
||||
|
||||
### Darstellung / Ressourcen
|
||||
|
||||
| Feature | Nutzen | Aufwand | Phase |
|
||||
|---|---|---|---|
|
||||
| Regelbasierte Overrides (Layer-/Tag-/Name-Regel, Priorität, Templates) | Automatische Farb-/Strich-/Linientyp-Anpassung; wiederverwendbar (vertieft §2c „Overrides") | M | 3 |
|
||||
| Section-Style für 3D-Schnittflächen (Schraffur + Schnittkante/Silhouette) | 3D-Schnitt-Rendering im Viewport, nicht nur 2D-Plan | M | 4 |
|
||||
| Massstabs-abhängige Linientyp-/Plotweight-Skalierung | Linientypen & Strichstärken bei 1:N korrekt sichtbar; PDF-Treue | M | 3–4 |
|
||||
| Material-Bibliothek mit PBR (Rauheit/Reflexion/Transparenz) + Templates | Vertieft Component-Manager um Renderqualität; Seeds Beton/Holz/Dämmung | M | 3 |
|
||||
| Rich-Text-Annotationen (Bold/Italic/Hoch-/Tiefstellung, Maskierung, Rahmen) | Bemaßungs-Indizes, formatierte Beschriftungen auf Canvas | M | 3 |
|
||||
| LoD-bewusste Stil-UI (zeigt nur passende Controls je Geometrie-Typ) | Weniger kognitive Last (keine Füll-Optionen bei 3D-Auswahl) | S | 1 |
|
||||
|
||||
### Pläne / Output
|
||||
|
||||
| Feature | Nutzen | Aufwand | Phase |
|
||||
|---|---|---|---|
|
||||
| **Massstab pro Viewport** mit Auto-DPI + Schraffur-/Strich-Skalierung | Exakte Masse & lesbare Strichstärken ohne manuelle Kalibrierung | M | 3 |
|
||||
| Ausschnitte / View-Snapshots (Kamera + Darstellung + Massstab) | Navigation über 50+ Ansichten; ersetzt Ordner-Wildwuchs (vertieft §2c) | M | 3 |
|
||||
| Layer-Kombinationen als Presets (live oder eingefroren) | Bauphasen/Varianten/MEP per Klick statt manuellem Toggling (vertieft §2c) | S | 3 |
|
||||
| Kamera-Presets (Kardinal N/O/S/W, Iso-Oktanten, **Norden-Rotation** ⭐) | Schnelle Ansichtswechsel; Georeferenzierung für Swisstopo | S | 3 |
|
||||
| Detail↔Ausschnitt-Bindung + „Alle aktualisieren" | Titelblock/Detail synchron umbenennen; 1-Klick-Sync aller Schnitte | M | 3 |
|
||||
| Multi-Page-PDF-Export @DPI (Vektor) | Druckfertige Plansätze — Kern-Output (ergänzt Phase-3-Sheets) | M | 3 |
|
||||
| 9-Punkt-Bemaßung/Objekt-Info (lesen + verschieben/skalieren/rotieren) | Direktes Dimensionieren ohne Properties-Panel | M | 3 |
|
||||
|
||||
### Kontext / Daten
|
||||
|
||||
| Feature | Nutzen | Aufwand | Phase |
|
||||
|---|---|---|---|
|
||||
| **Swisstopo-Import** (swissBUILDINGS3D/ALTI3D/SWISSIMAGE) ⭐ CH | Authentischer Standort-Kontext, offene APIs, kein Auth | M | 4 |
|
||||
| LV95↔WGS84-Transformation ⭐ CH | Karten-Anzeige + präzise CH-Koordinaten; Formeln direkt portierbar | S | 4 |
|
||||
| OSM-Overpass-Import (Strassen/Gebäude/Wasser/Grün, 7 Kategorien) | Weltweiter, kostenloser Kontext; ergänzt Swisstopo | M | 4 |
|
||||
| Terrain-Mesh-Generator (Mesh/TIN/NURBS-Patch/Höhenlinien, Volumen für Schnitt) | Geländemodell aus Höhendaten; Section-Cut-Füllung; portierbar zu Three.js | L | 4 |
|
||||
| Auto-Zoom auf Import + Nullpunkt-Verschiebung (LV95→0/0/0) | Modellierungsgenauigkeit trotz Millionen-Koordinaten; UX-Standard | S | 4 |
|
||||
| Projekt-Persistierung browser-nativ (IndexedDB statt doc.Strings) | Projekt kapselt seine Einstellungen lokal | M | 5 |
|
||||
| Cross-Projekt-Presets (LocalStorage-Favoriten, Export/Import, Team-Sharing) | Einmal speichern, überall nutzen | S | 5 |
|
||||
@@ -0,0 +1,175 @@
|
||||
# HAUPTINSTANZ-BRIEFING: Tauri + wgpu (Korrigiert)
|
||||
|
||||
**Stand:** 2026-07-01 — **KORREKTUR** (vorherige Dokumente waren unvollständig)
|
||||
|
||||
---
|
||||
|
||||
## Die Entscheidung (FINAL)
|
||||
|
||||
**Browser-CAD (alt) → Desktop Tauri-App mit Rust-Backend + wgpu-Rendering (neu)**
|
||||
|
||||
| Aspekt | Alt | Neu |
|
||||
|---|---|---|
|
||||
| **App-Form** | Browser-Tab | Desktop Tauri-Window |
|
||||
| **Frontend** | React/Vite (TS) | React/Vite (TS) — UNVERÄNDERT |
|
||||
| **2D-Rendering** | SVG-Plan | SVG-Plan — UNVERÄNDERT |
|
||||
| **3D-Rendering** | three.js/WebGL | **wgpu** (Vulkan/Metal/DX12) |
|
||||
| **Rechenintensive Ops** | TS in Browser | **Rust im Backend** |
|
||||
| **Betriebssystem** | cross-platform browser | Windows/macOS/Linux Desktop App |
|
||||
|
||||
---
|
||||
|
||||
## Warum dieser Umstieg?
|
||||
|
||||
**Problem mit three.js/WebGL:**
|
||||
- komplexe Möbel = 100k+ Polygone
|
||||
- mehrere parallele Ops (kernel2d, bool-Ops, DXF-Parser, SIA-Raumerkennung)
|
||||
- WebGL hat harte Limits (GPU VRAM, draw calls, single-threaded)
|
||||
- → Laggy, nicht skalierbar für Professional CAD
|
||||
|
||||
**Lösung: Tauri + wgpu + Rust**
|
||||
- **wgpu** = low-level GPU API (direkt zu Vulkan/Metal/DX12, nicht WebGL)
|
||||
- **Rust** = rechenintensive Ops parallelisiert + native performance
|
||||
- **Desktop** = native App, nicht Browser (bessere Kontrolle, bessere Perf)
|
||||
- **React-Frontend bleibt** = UI/Sketching/Panels unverändert (wgpu nur für 3D-Display)
|
||||
|
||||
---
|
||||
|
||||
## Der neue Stack (FINAL)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Desktop Tauri-Window │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ React/Vite (UI-Shell, State, Panels) │
|
||||
│ ├─ SVG 2D-Plan (Grundriss) │
|
||||
│ └─ wgpu 3D-Viewport (3D-Ansicht + Rendering) │
|
||||
│ │
|
||||
│ ↓ invoke (IPC) ↓ │
|
||||
│ │
|
||||
│ Rust-Backend (src-tauri/) │
|
||||
│ ├─ computeJoins() — Wand-Eckverbindungen │
|
||||
│ ├─ kernel2d() — Offset/Trim/Extend/… │
|
||||
│ ├─ parseShapeFromDwg() — Geometrie-Import │
|
||||
│ ├─ detectRooms() — SIA-Raumerkennung │
|
||||
│ └─ booleanOps() — Union/Differenz/Schnitt │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Nicht verändert:**
|
||||
- App.tsx, state/, commands/, panels/, model/types.ts
|
||||
- UI-Logik, Befehlssystem, Sketching-Tools
|
||||
|
||||
**Neu/geändert:**
|
||||
- `src-tauri/` — Rust-Crate für Backend
|
||||
- `src/compute/index.ts` — Boundary (invoke + TS-Fallback)
|
||||
- Rendering-Engine: **three.js → wgpu**
|
||||
- Build: `npm run tauri:build` statt `npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Die Aufgabe (vier Agents)
|
||||
|
||||
**Siehe: `docs/design/tauri-migration-plan.md`**
|
||||
|
||||
Vier parallele Agents:
|
||||
|
||||
1. **Agent: Tauri-Shell Setup** → `src-tauri/` + Cargo.toml + main.rs (Tauri window registrieren)
|
||||
2. **Agent: Compute-Boundary (TS)** → `src/compute/index.ts` (invoke-Wrapper + TS-Fallbacks)
|
||||
3. **Agent: Rust compute_joins Impl** → `src-tauri/src/geometry.rs` (erste Op, Proof-of-Concept)
|
||||
4. **Agent: Vite/Package-Integration** → vite.config.ts + package.json (Tauri-Plugins, scripts)
|
||||
|
||||
**Deliverable:** lauffähige Desktop-App, wand-Edit triggert Rust-Op, Output identisch TS-Version.
|
||||
|
||||
---
|
||||
|
||||
## Dev-Workflow (post-Tauri)
|
||||
|
||||
```bash
|
||||
# Dev: Terminal 1 (Vite)
|
||||
npm run dev # localhost:5173
|
||||
|
||||
# Dev: Terminal 2 (Tauri)
|
||||
npm run tauri:dev # öffnet Desktop-Window, zeigt auf localhost:5173
|
||||
|
||||
# Build
|
||||
npm run tauri:build # → Windows .exe / macOS .app / Linux .deb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rendering: three.js → wgpu (Wichtig!)
|
||||
|
||||
**3D-Viewport wird NEU implementiert in wgpu:**
|
||||
- Nicht: "drei.js mit Rust-Fallback"
|
||||
- **Ja:** wgpu als native GPU-Renderer (skaliert auf 100k+ Polygone)
|
||||
|
||||
**2D-Plan bleibt SVG** (keine Änderung).
|
||||
|
||||
**Timeline für wgpu-Impl:**
|
||||
- Milestone 1 (jetzt): Tauri-Shell + Rust-Compute-Ops
|
||||
- Milestone 2 (nächst): wgpu 3D-Viewport-Impl (ersetzt drei.js/WebGL)
|
||||
- Milestone 3: Live clipping/sectioning in wgpu
|
||||
|
||||
---
|
||||
|
||||
## Migration-Reihenfolge
|
||||
|
||||
**Phase 1 (Tauri-Shell + Proof-of-Concept):**
|
||||
- [ ] `computeJoins` (Wand-Ecken) → Rust
|
||||
|
||||
**Phase 2 (Rendering-Umbau):**
|
||||
- [ ] wgpu Viewport-Impl (ersetzt drei.js)
|
||||
- [ ] Instancing/LOD für Möbel-Geometrie
|
||||
|
||||
**Phase 3 (weitere Ops):**
|
||||
- [ ] `kernel2d` (Offset/Trim) → Rust
|
||||
- [ ] DXF/DWG-Parser → Rust
|
||||
- [ ] detectRooms → Rust
|
||||
- [ ] booleanOps → Rust
|
||||
|
||||
---
|
||||
|
||||
## Was sich NICHT ändert
|
||||
|
||||
- `src/App.tsx`, `src/state/`, `src/commands/`
|
||||
- UI-Panels, Zeichenwerkzeuge, Befehlssystem
|
||||
- Semantisches Modell (types.ts)
|
||||
- i18n, Styling
|
||||
|
||||
## Was ändert sich
|
||||
|
||||
- **Engine:** three.js/WebGL → wgpu
|
||||
- **Backend:** TS-Only → Tauri + Rust
|
||||
- **Distribution:** Browser → Desktop App
|
||||
- **Build-Prozess:** `npm run build` → `npm run tauri:build`
|
||||
|
||||
---
|
||||
|
||||
## Fehlerquellen (zur Klarheit)
|
||||
|
||||
❌ **FALSCH:** "Wir bleiben bei three.js"
|
||||
✅ **RICHTIG:** "three.js → wgpu, Rust-Compute + Desktop Tauri"
|
||||
|
||||
❌ **FALSCH:** "Tauri + three.js als Frontend-Engine"
|
||||
✅ **RICHTIG:** "Tauri-Shell + React UI + wgpu 3D-Rendering + Rust-Compute"
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte (für Hauptinstanz)
|
||||
|
||||
1. **Lesen:** `docs/design/tauri-migration-plan.md` (technisch, konkret)
|
||||
2. **Spawn:** vier Agents (parallel, unabhängig)
|
||||
3. **Verifizieren:** `npm run tauri:dev` → App läuft, erste Op in Rust funktioniert
|
||||
4. **Aktualisieren:** HANDOVER.md mit Milestone-1-Status
|
||||
|
||||
---
|
||||
|
||||
## Fragen?
|
||||
|
||||
- **"Warum Desktop statt Browser?"** → Skalierbarkeit (native GPU + Rust-Compute)
|
||||
- **"Warum wgpu statt drei.js?"** → wgpu skaliert auf 100k+ Polygone, three.js/WebGL hat Limits
|
||||
- **"Bleibt React?"** → Ja, React/Vite UI bleibt, nur 3D-Engine wechselt
|
||||
- **"Wann ist wgpu-Impl fertig?"** → Milestone 2 (nach Tauri-Shell)
|
||||
@@ -0,0 +1,160 @@
|
||||
# Dokumentation — Standalone Browser-BIM (cad)
|
||||
|
||||
> Stand: 2026-06-29 · Übergeordnet: [ROADMAP.md](../ROADMAP.md) (Vision & Phasen) ·
|
||||
> [CONVENTIONS.md](../CONVENTIONS.md) (Konventionen) · [ARCHITECTURE.md](../ARCHITECTURE.md).
|
||||
|
||||
Dieses Verzeichnis bündelt die Recherche- und Design-Dokumente für `cad`, die
|
||||
eigenständige Browser-Variante des DOSSIER-Rhino-Plugins (React + TypeScript +
|
||||
Three.js + SVG, alles client-side). **Leitprinzip aller Dokumente:** ein
|
||||
semantisches Modell ist die einzige Wahrheit; jede Sicht (3D, Grundriss, Schnitt)
|
||||
wird **abgeleitet**, Darstellung erst beim Rendern angewandt. Bezeichner im Code
|
||||
englisch (Vectorworks-Terminologie), Prosa deutsch, Einheiten intern in Metern.
|
||||
|
||||
Die Dokumente sind in vier Gruppen geordnet: **Tech** (Bibliotheken/Kernel),
|
||||
**Architektur/Design** (Aufbau & Bauteile), **UX** (Oberfläche & Interaktion),
|
||||
**Swisstopo/SIA** (CH-Geodaten & Flächenstandards).
|
||||
|
||||
---
|
||||
|
||||
## Tech — Technologie- & Bibliotheksauswahl
|
||||
|
||||
### [research/tech-selection.md](research/tech-selection.md)
|
||||
Evaluiert den kompletten Client-Stack für ein serverloses BIM-Werkzeug und
|
||||
empfiehlt **`replicad`** (idiomatische TS-Schicht über `opencascade.js`/OCCT, MIT)
|
||||
als primären B-Rep-Kernel im Web Worker, ergänzt durch **`Manifold`** (Apache-2.0)
|
||||
für schnelle, robuste Mesh-Booleans auf Importgeometrie — denn nur ein echter
|
||||
B-Rep-Kernel liefert exakte 2D-Ableitungen, und genau das löst replicads
|
||||
`drawProjection` (OCC-HLR, `{visible, hidden}`-Kanten direkt im Browser). Weitere
|
||||
Wahl: Import via **web-ifc + Fragments** (IFC), `dxf-parser` (DXF) und
|
||||
`libredwg-web` (DWG, aber **GPL-3.0 → vorab klären/kapseln**); Vektor-Export über
|
||||
**`svg2pdf.js` + `jsPDF`** (PDF) und **`@tarikjabiri/dxf`** (echte Hatch-Entities);
|
||||
Schraffuren als SVG-`<pattern>` mit `userSpaceOnUse` (maßstabskorrekt); Rendering
|
||||
über **`three/webgpu`** mit automatischem WebGL2-Fallback. Top-Risiken: DWG-Lizenz,
|
||||
OCCT-WASM-Größe, HLR-Kosten (pro Ansicht cachen), WebGPU vor Migration benchmarken.
|
||||
|
||||
---
|
||||
|
||||
## Architektur/Design — Aufbau, Datenmodell & Bauteile
|
||||
|
||||
### [../ARCHITECTURE.md](../ARCHITECTURE.md)
|
||||
Die übergreifende Standalone-Architektur und die systematische Übersetzung jedes
|
||||
DOSSIER-Konzepts in ein Browser-Äquivalent (30-zeilige **Rhino→Browser-Mapping-
|
||||
Tabelle**). Kern: das semantische `Project` (JSON) als einzige Wahrheit mit pure
|
||||
`derive()` zu Scene3D/Plan/Section; ein **Zwei-Achsen-Datenmodell**
|
||||
(`drawingLevels` × `layers`) plus `Resources`/`WallType`/`Element`/`Sheet`; ein
|
||||
**Zustand-Store** ersetzt DOSSIERs `sc.sticky`-Bus, **`.cad.json`** (File System
|
||||
Access API) + IndexedDB-Autosave ersetzen `doc.Strings`, und ein **Immer-Patch-
|
||||
Undo/Redo** eliminiert die Cache-Stale-Bugs strukturell. Ziel-Repo-Struktur mit
|
||||
**kleinen Bauteil-Modulen** statt des 7244-LOC-`elemente.py`-Monolithen; Rendering
|
||||
über einen `THREE.Group`-Baum, der den Ebenen-Baum spiegelt.
|
||||
|
||||
### [design/parametric-walls.md](design/parametric-walls.md)
|
||||
Regelbasierte Wandgenerierung als Alternative zum Direktzeichnen. Vier Regel-Varianten
|
||||
(`GridRule`, `ModuleRule`, `ConditionalRule`, `PolylineRule`) erzeugen `Wall[]`-Arrays
|
||||
über einen reinen Auflöser (`resolveParametricWall`). Deckungsbereich: Schweizer 3-m-
|
||||
Wohnraster, bedingte Außen-/Innenwand-Dicken, 6-m-Jochbauweise. Phase A: Typsystem +
|
||||
Resolver isoliert, kein UI. Phase B: Command + Formular-Editor. Phase C: Grid-Ressource
|
||||
und IFC-Export.
|
||||
|
||||
### [design/elements.md](design/elements.md)
|
||||
Legt **Daten, Generierung (3D + Plan) und Grip-Editing pro Bauteil** fest. Wichtigste
|
||||
Empfehlung: die **Prioritäts-T-/X-Verschneidung mehrschichtiger Wände** (Backbone-
|
||||
Algorithmus, Port von `_t_junction_layer_overrides`) — das höchstpriorisierte
|
||||
gemeinsame Material läuft durch, der Rest mitert an; Priorität sitzt am **Component**
|
||||
(`joinPriority` als Daten, nicht Hardcode). Deckt zudem gehostete Öffnungen mit
|
||||
LoD-Stufen (`_OEFF_PIECE_DEFS`), Decken mit Aussparungen, Treppen (gerade/L/Wendel,
|
||||
geschossübergreifend, normgerechtes 2D-Symbol), Dächer, Tragwerk und **SIA-416-Räume**
|
||||
(Shoelace-Fläche, Stempel, Färbung über Override-Preset) ab; das `Tool`-Interface +
|
||||
Snap-Engine ersetzt DOSSIERs Rhino-Command-Aliases.
|
||||
|
||||
### [design/plans-output.md](design/plans-output.md)
|
||||
Der Weg zu **schönen, normgerechten, druckfertigen 2D-Plänen** (Vektor-PDF). Zentrale
|
||||
Erkenntnis: Ansichten = Kamera + optionaler Schnitt, und es gibt **zwei Plan-Pfade**
|
||||
(symbolischer Grundriss aus Parametern vs. Schnitt/Ansicht via **HLR im Worker**,
|
||||
gecacht). Empfiehlt SVG/Paper-Space als Maßstabsmodell — Strichstärke/Schraffur sind
|
||||
direkt in mm definiert (`dpi = 96·devicePixelRatio`, Hatch-Faktor `sqrt(N)/10`), was
|
||||
DOSSIERs fragiles Plotweight-Rescaling überflüssig macht. Behandelt außerdem
|
||||
Ausschnitte/View-Snapshots, Layer-Kombinationen, Kamera-Presets + Norden-Rotation,
|
||||
Bemaßung sowie Sheets + Vektor-PDF-Export (`svg2pdf.js`/`jsPDF`, `PAPER_MM`).
|
||||
|
||||
### [design/resources-graphics.md](design/resources-graphics.md)
|
||||
Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Component-/Hatch-/Line-
|
||||
Manager, alles per id referenziert), die `resolveStyle`-Kette
|
||||
(ByLayer → Element-Style → Override) und die **regelbasierte Overrides-Engine**.
|
||||
Schlüssel-Empfehlung: Overrides als **reine Render-Reads** modellieren (kein
|
||||
Backup/Restore wie in DOSSIER, da nichts mutiert wird) — inklusive eines
|
||||
**SIA-416-Presets** statt hartcodierter Färbung. Ergänzt Symbol-Bibliothek,
|
||||
Rich-Text-Annotationen, den LoD-Resolver (`resolveDetail`) und den Section-Style für
|
||||
geschnittene Bauteile; eine Tabelle zeigt, was der Browser hier gegenüber DOSSIER
|
||||
vereinfacht.
|
||||
|
||||
---
|
||||
|
||||
## UX — Oberfläche, Interaktion & gefühlte Geschwindigkeit
|
||||
|
||||
### [research/ux-patterns.md](research/ux-patterns.md)
|
||||
Untersucht UX-Muster moderner Browser-CAD/BIM-Tools (Arcol, Snaptrude, TestFit,
|
||||
Onshape, Vectorworks, Figma) und leitet **priorisierte Leitplanken** ab. Empfehlung
|
||||
für die Grundstruktur: eine feste, Figma-artige **3-Zonen-Shell**
|
||||
(Navigator/Viewport/Inspector) — explizit gegen Paletten-Wildwuchs —, mit
|
||||
Vectorworks-Navigation-Tabs für unsere zwei Achsen und einem zwei/drei-spaltigen
|
||||
Resource-Manager als Vorbild. Größte Differenzierungs-Hebel laut Doku:
|
||||
**Snapping/Inferencing** im Onshape-Stil (Vertex-Highlights, Achsenlinien, Shift
|
||||
unterdrückt) und **Grip-Editing über Sicht-Grenzen** (Schnittlinie im Plan ziehen);
|
||||
dazu perceived-performance-Muster (Skeletons, optimistic UI, 150-ms-Delay-then-show),
|
||||
eine Command-Palette (Cmd/Ctrl-K) und learn-by-doing-Onboarding am Sample-Projekt.
|
||||
|
||||
---
|
||||
|
||||
## Swisstopo/SIA — Schweizer Geodaten & Flächenstandards
|
||||
|
||||
### [research/swisstopo-sia.md](research/swisstopo-sia.md)
|
||||
Dokumentiert die **live getesteten** geo.admin.ch-Dienste und die SIA-Flächenlogik.
|
||||
Überraschendster Befund: **alles ist ohne eigenen Backend-Proxy nutzbar** — alle vier
|
||||
Hosts senden `access-control-allow-origin: *`, und der Height-Service antwortet
|
||||
faktisch frei. Schlüssel fürs Browser-Gelände-Mesh ist **swissALTI3D als Cloud-
|
||||
Optimized GeoTIFF** (Range-Requests via `geotiff.js`, kein Full-Download); die
|
||||
**Parzelle** kommt direkt als LV95-Polygon + EGRID aus dem Identify-Service. Empfiehlt
|
||||
einen konkreten Library-Satz (`proj4`, `geotiff`, `3DTilesRendererJS`/`loaders.gl`)
|
||||
und ordnet die Umsetzung in ROADMAP-Phasen ein (Phase 2 SIA-Räume = reine Logik →
|
||||
Phase 4a Koordinaten → 4b Gelände/Orthofoto → 4c Nachbargebäude). SIA-Teil:
|
||||
verifizierte SIA-416-Formeln, DOSSIERs SIA-Logik 1:1 portierbar (Shoelace,
|
||||
`compute_sia_bilanz`, CSV mit BOM); Origin-Shift (LV95 → 0/0/0) ist Pflicht wegen
|
||||
float32-Jitter, Caching über IndexedDB.
|
||||
|
||||
---
|
||||
|
||||
## Top-5 Querschnitts-Empfehlungen für die ROADMAP
|
||||
|
||||
Diese fünf Punkte tauchen in mehreren Dokumenten auf und sollten die ROADMAP-Planung
|
||||
und Priorisierung leiten:
|
||||
|
||||
1. **Pure-Ableitungs-Architektur als unverhandelbares Fundament** — ein
|
||||
semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern.
|
||||
Trägt ARCHITECTURE.md, beide Plan-/Stil-Designs und die UX-Doku (billiger
|
||||
Split-View, optimistic Edits, kein Cache-Stale-/Override-Restore-Aufwand). Muss
|
||||
früh stehen (Store + Undo, Phase 0–1), weil sie alles Spätere prägt.
|
||||
|
||||
2. **OCCT/replicad im Web Worker früh als Spike absichern** — der B-Rep-Kernel und
|
||||
sein `drawProjection`-HLR sind der kritische Pfad für Schnitt/Ansicht (Risiko #4)
|
||||
*und* für exakte Wand-Booleans (Risiko #1) *und* für IFC. WASM-Größe, HLR-Kosten
|
||||
(pro Ansicht cachen) und das Worker-Pattern sollten vor Phase 3 mit einer echten
|
||||
Szene validiert werden.
|
||||
|
||||
3. **Component-getriebene Prioritäts-Verschneidung (Backbone-T/X) als zentrales
|
||||
Geometrie-Risiko** — `joinPriority` als Daten am Component; höchstes gemeinsames
|
||||
Material läuft durch, Rest mitert. Verbindet elements.md + resources-graphics.md;
|
||||
2D-Plan rein analytisch, exakte 3D-Booleans im Worker. Stufenweise umsetzen
|
||||
(Risiko #1, Phase 1).
|
||||
|
||||
4. **SVG/Paper-Space-Maßstabsmodell + maßstabskorrekte Schraffuren durchgängig** —
|
||||
Strichstärke/Text/Hatch in mm, `dpi = 96·devicePixelRatio`, Hatch `sqrt(N)/10`,
|
||||
SVG-`<pattern>` mit `userSpaceOnUse`. Eliminiert DOSSIERs Plotweight-Rescaling und
|
||||
speist denselben Serializer für Bildschirm, PDF und DXF (tech-selection +
|
||||
plans-output + resources-graphics).
|
||||
|
||||
5. **Schweiz-Spezifika als Differenzierer ohne Backend-Last** — SIA-416-Bilanz
|
||||
(reine Logik, Phase 2, ⭐) und der serverlose Swisstopo-Flow (CORS-offen,
|
||||
COG-Terrain, Parzelle/EGRID, Norden-Rotation, Origin-Shift). Klein im Aufwand,
|
||||
groß im CH-Marktwert; SIA-Färbung läuft über das Override-Preset, nicht über
|
||||
Sonderpfade.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Backend & Kollaboration — Architekturentscheidung
|
||||
|
||||
> Stand: 2026-06-29 · Ziel: komplett self-hosted, kollaborations-offen
|
||||
|
||||
## Grundsatz
|
||||
So lange wie möglich **client-only** bleiben; das Backend additiv einführen, ohne
|
||||
den Kern umzubauen. Die Pure-Ableitungs-Architektur (ein serialisierbares Modell,
|
||||
alle Sichten abgeleitet) ist bereits kollaborations-freundlich.
|
||||
|
||||
## Phasen
|
||||
| Phase | Persistenz / Backend |
|
||||
|---|---|
|
||||
| **0–3** (Modellierer) | **Client-only**: IndexedDB + Datei-Export/Import (JSON). Offline-fähig (PWA möglich). Kein Server. |
|
||||
| **5** (Konten/Persistenz) | **Supabase self-hosted** (Docker Compose): Postgres + Auth + Storage. Projekte, Versionen, Dateien (IFC/Pläne/Assets). Row-Level-Security pro Nutzer/Projekt. |
|
||||
| **6** (Kollaboration) | **Yjs (CRDT)** + **Hocuspocus** Sync-Server (Container), persistiert Snapshots nach Postgres. Presence/Cursors. Optional Supabase-Realtime nur für leichte Broadcasts. |
|
||||
|
||||
## Warum Yjs/Hocuspocus statt reinem Supabase-Realtime
|
||||
Gleichzeitiges Editieren eines strukturierten Dokuments braucht Konfliktauflösung
|
||||
(CRDT). Yjs ist dafür Standard; Hocuspocus ist der self-hostbare Server dazu und
|
||||
kann nach Postgres (Supabase) persistieren. Supabase-Realtime allein wäre nur
|
||||
Pub/Sub ohne Merge-Semantik.
|
||||
|
||||
## Was wir JETZT schon richtig machen (damit Collab nicht blockiert)
|
||||
- Dokumentmodell rein **JSON-serialisierbar**, keine Zyklen, stabile IDs.
|
||||
- Edits immutable über `setProject` → später leicht auf Yjs-Doc abbildbar
|
||||
(`Y.Map`/`Y.Array` je Sammlung: drawingLevels, layers, components, walls …).
|
||||
- Kein Wahrheits-Zustand im Three.js-Scene-Graph oder im DOM — alles ableitbar.
|
||||
- Ressourcen (Components/Hatches/Lines) als referenzierte Bibliotheken (IDs) →
|
||||
gut mergebar.
|
||||
|
||||
## Self-hosted Stack (Skizze, Phase 5/6)
|
||||
```
|
||||
docker-compose:
|
||||
supabase (postgres, gotrue auth, storage, kong gateway, studio)
|
||||
hocuspocus (yjs websocket sync, persist -> postgres)
|
||||
web (vite build, statisch via nginx/caddy)
|
||||
```
|
||||
Alles auf eigener Infrastruktur lauffähig; keine externe Cloud nötig.
|
||||
|
||||
## Offene Punkte
|
||||
- Granularität der CRDT-Struktur (pro Sammlung vs. pro Element).
|
||||
- Datei-Storage (Supabase Storage vs. S3-kompatibel/MinIO im selben Stack).
|
||||
- Auth-Modell (E-Mail, OIDC/SSO fürs Büro).
|
||||
@@ -0,0 +1,54 @@
|
||||
# Kontextmenü & Anzeige-Modi — 1:1 wie DOSSIER
|
||||
|
||||
> Quelle: DOSSIER `src/components/ContextMenu.jsx` + `DrawingLevelsApp.jsx`/Ebenen-Panel.
|
||||
> Maus-Schema (unsere Festlegung): **Mitte = navigieren** (Plan Pan / 3D Orbit, Shift+Mitte Pan) ·
|
||||
> **Links = Auswahl** · **Rechts = Kontextmenü** · **Rad = Zoom**.
|
||||
|
||||
## ContextMenu-Komponente (generisch, wiederverwendbar)
|
||||
`ContextMenu({ x, y, items, onClose, title })`
|
||||
- **item**: `{ label, icon?, onClick, disabled?, danger?, shortcut?, divider? }`
|
||||
- Fixed-Position mit Rand-Clamp (4px); min-width 200px; Radius 13px; weicher Schatten;
|
||||
Mount-Animation `scale(.94) translateY(-5px)` 100ms; Item-Hover = `--accent-dim`;
|
||||
`danger` = rote Schrift; `divider` = 1px Trenner; Titel oben (caps, 10px, muted).
|
||||
- **Schließen:** Klick außerhalb · Escape · erneuter Rechtsklick · nach Item-Klick.
|
||||
- z-index ~300 (unter Modals).
|
||||
|
||||
## Ebenen-Kontextmenü (Rechtsklick auf Ebenen-Zeile)
|
||||
1. **Ebeneneinstellungen…** (`settings`) — Ebenen-Dialog *(Rhino-spez. → vorerst Stub)*
|
||||
2. — Trenner —
|
||||
3. **Sub-Ebene hinzufügen…** (`add`)
|
||||
4. **Selektion hierher übertragen** (`move_down`) *(braucht Auswahl → später)*
|
||||
5. — Trenner —
|
||||
6. **Duplizieren** (`content_copy`) — Klon mit Suffix „ KOPIE"
|
||||
7. **Eigenschaften kopieren** (`colorize`) — Farbe + Linienstärke
|
||||
8. **Eigenschaften einfügen** (`format_paint`) — disabled wenn Clipboard leer
|
||||
9. — Trenner —
|
||||
10. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene
|
||||
Titel = Ebenenname/-code.
|
||||
|
||||
## Zeichnungsebenen-Kontextmenü (Rechtsklick auf Geschoss/Schnitt/Zeichnung)
|
||||
1. **Einstellungen…** (`settings`)
|
||||
2. — Trenner —
|
||||
3. **Duplizieren** (`content_copy`) — Klon mit Suffix „ Kopie"
|
||||
4. — Trenner —
|
||||
5. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene
|
||||
Titel = Name.
|
||||
|
||||
## „+"-Menü (Zeichnungsebenen)
|
||||
**Geschoss** (`layers`) · **Schnitt / Ansicht** (`content_cut`) · — · **Zeichnung** (`edit_note`)
|
||||
|
||||
## Anzeige-Modi (Dropdown oben im Panel) — **5 Modi** (für Ebenen UND Zeichnungsebenen)
|
||||
| Wert | Label | Verhalten |
|
||||
|---|---|---|
|
||||
| `all_force` | **Alle anzeigen** | alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge → wechselt zu „Ausgewählte" |
|
||||
| `all` | **Ausgewählte** | sichtbar nach per-Zeile-Flag |
|
||||
| `active` | **Nur aktive** | nur aktive sichtbar; andere stark gedimmt |
|
||||
| `grey` | **Andere grau** | aktive normal, andere 45% (Sichtbarkeits-Flags gelten) |
|
||||
| `grey_locked` | **Andere grau & gesperrt** | wie grey + andere gesperrt |
|
||||
Regel: Klick aufs Auge in `all_force`/`active` schaltet automatisch auf `all`.
|
||||
*(Hinweis: Panel-System-Workflow hatte vorerst nur 3 Modi — beim Kontextmenü-Build auf diese 5 angleichen.)*
|
||||
|
||||
## Migration (was sofort geht / was Stub bleibt)
|
||||
- Sofort: ContextMenu-Komponente 1:1; Duplizieren, Eigenschaften kopieren/einfügen, Löschen,
|
||||
+Menü, 5 Anzeige-Modi (alles reine JSON-State-Operationen).
|
||||
- Stub/später: „Ebeneneinstellungen…"/„Einstellungen…" (Dialog), „Sub-Ebene hinzufügen", „Selektion hierher übertragen".
|
||||
@@ -0,0 +1,112 @@
|
||||
# DOSSIER-Feature-Audit (A1–A6, B1–B4, C1–C3, D1–D3, E)
|
||||
|
||||
Ausführliche Fassung des Audits, auf das HANDOVER.md Backlog-Punkt 9 nur noch mit
|
||||
Einzeilern verweist. Ursprünglich von einem Opus-Subagent erarbeitet, der das
|
||||
DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen den aktuellen Browser-Port
|
||||
abgeglichen hat — das Ergebnis lag bisher nur in einem Session-Transkript, nicht
|
||||
im Repo. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
|
||||
|
||||
Status-Symbole: ❌ nicht übernommen · 🟡 teilweise übernommen.
|
||||
|
||||
## A — Grösserer Funktionsumfang
|
||||
|
||||
**A1 · ❌ Grafische Overrides — Regel-Engine** (`overrides.py:7-40`)
|
||||
ArchiCAD/Vectorworks-Stil: Regeln der Form `condition{type: layer_name|user_string|
|
||||
object_name, operator: equals|contains|starts_with|not_equals, value, key} →
|
||||
actions{color, lineweight, linetype}`, additiv angewendet, oberste Regel gewinnt,
|
||||
reversibel. Cross-Doc-Presets + Rule-Templates (`list_presets:147`,
|
||||
`list_rule_templates:218`). Der Port hat nur manuelle Per-Instanz-Farbe (By-Layer/
|
||||
By-Object/eigener Wert, kein Regelwerk). Wert: enorm für Architektur-Pläne
|
||||
(Bestand grau, Brandabschnitte, Bauphasen farblich markieren). Aufwand: M-L.
|
||||
|
||||
**A2 · ❌ Ausschnitte / View-Snapshots** (`ausschnitte.py:466-531`)
|
||||
Benannte, gespeicherte Ansichten, die Kamera-Zustand + Layer-Sichtbarkeit +
|
||||
Massstab + Detailgrad einfangen und wiederherstellen; organisiert in Ordnern +
|
||||
Presets (`_capture_camera:78`, `_capture_layers:157`, `_capture:466`). Wert:
|
||||
zentral für Wiederverwendbarkeit und Voraussetzung für A3. Aufwand: L.
|
||||
|
||||
**A3 · ❌ Print-Layout-Blätter** (`layouts.py:7-8`)
|
||||
Layout-Seiten mit mehreren Details, jedes Detail an einen Ausschnitt-Snapshot
|
||||
gebunden (`_BIND_KEY:28`), Papierformate A4/A3, Ordnerstruktur, PDF-Export pro
|
||||
Layout-Blatt. Der Port exportiert aktuell nur eine einzelne Zeichnung nach PDF.
|
||||
Aufwand: L, hängt an A2.
|
||||
|
||||
**A4 · ❌ Tragwerk-Elemente** (`elemente.py`)
|
||||
Stütze, Träger, Unterzug, I-Profil als eigene Elementtypen. Der Port kennt nur
|
||||
Wand/Decke/Öffnung/Treppe/Raum. Wert: vervollständigt den BIM-Elementsatz.
|
||||
Aufwand: M je Typ.
|
||||
|
||||
**A5 · 🟡 Öffnungen viel reicher** (`elemente.py:59-68`)
|
||||
Mehrere Flügel (`OEFF_FLUEGEL`), Sims innen+aussen mit eigenen Stilen
|
||||
(`SIMS_AUS`/`SIMS_IN`), Glas-Toggle (`OEFF_GLAS`), Rahmen-Lage aussen/mittig/innen
|
||||
(`RAHMEN_POS`), Rahmen-Profilbreite/-tiefe, Detailgrad einfach/standard/detail =
|
||||
SIA-400-Darstellung (`OEFF_DARSTELLUNG:68`). Der Port hat nur swing/hinge/
|
||||
frameDepth/frameThickness. Aufwand: M.
|
||||
|
||||
**A6 · 🟡 Element-Schedule / Bauteilliste** (`elemente_uebersicht.py:45,214`)
|
||||
Voller Element-Überblick + SIA-Flächenbilanz + CSV-Export
|
||||
(`_export_bilanz:214`). Der Port hat nur einen Raum-CSV-Export. Aufwand: M.
|
||||
|
||||
## B — Werkzeuge & Objekt-Info
|
||||
|
||||
**B1 · ❌ Text-Platzierungs-Werkzeug** (`text_create.py:972,847,105`)
|
||||
On-Canvas-Text-Objekte als eigenständiges Zeichenwerkzeug (nicht nur
|
||||
Raumstempel): Textstile, Fonts, Rich-Text fett/kursiv/unterstrichen,
|
||||
Ausrichtung, Symbol-Einfügung, „auf Selektion anwenden". Der Port hat den
|
||||
Rich-Text-Editor (`src/text/RichTextEditor.tsx`) bereits, aber kein
|
||||
Platzierungs-Werkzeug, um damit ein freistehendes Textobjekt zu zeichnen
|
||||
(Shortcut „1" ist bewusst noch unbelegt). Aufwand: M.
|
||||
|
||||
**B2 · 🟡 Object-Info numerisch erweitern** (`dimensions.py:342-350,240,267`)
|
||||
Position, Rotation um Z-Achse (`_rotate_around_axis:240`), Kreis-Radius,
|
||||
Linien-Länge (`_set_line_length:267`), Rechteck Breite/Höhe,
|
||||
Koordinatensystem World/CPlane, 9-Punkt-Referenz. Der Port kann nur per
|
||||
Anker-Griff skalieren. Aufwand: S-M.
|
||||
|
||||
**B3 · ❌ Kamera-Presets + Nordwinkel** (`kamera.py:28,36-45,87`)
|
||||
Nicht in HANDOVER.md gelistet, aber im Audit gefunden: Kardinal-Presets
|
||||
(N/O/S/W), Iso-Oktanten, Rotation um einen einstellbaren Nordwinkel
|
||||
(georeferenziert, relevant für Swisstopo-Kontext).
|
||||
|
||||
**B4 · ❌ Massstab-Toolbar-Funktionen** (nicht separat referenziert, Teil der
|
||||
Toolbar-Logik) — Zoom 1:1/auf Selektion, Linienstärken-Set, Grid/Ortho/
|
||||
Referenzlinien-Toggles direkt aus der Symbolleiste.
|
||||
|
||||
## C — Zusätzlich gefunden, nicht in HANDOVER.md
|
||||
|
||||
**C1 · LoD pro Ansicht** — Darstellung einfach/standard/detail je Ansicht
|
||||
umschaltbar (SIA-400-Detailgrad-Konvention), nicht nur global.
|
||||
|
||||
**C2 · Override-Presets + Rule-Templates als Bibliothek** — vertieft A1: die
|
||||
Regeln selbst sind wiederverwendbare, benannte Presets/Templates, nicht nur
|
||||
Ad-hoc-Zustand pro Dokument.
|
||||
|
||||
**C3 · Mass-Style-Presets** — Dezimalstellen/Rundung als benannte,
|
||||
wiederverwendbare Bemassungs-Stile.
|
||||
|
||||
## D — Weitere Backlog-Ergänzungen aus dem Audit
|
||||
|
||||
**D1** Per-Layout-PDF-Export (vertieft A3).
|
||||
**D2** Bauteil-CSV mit vollem Element-Set (vertieft A6).
|
||||
**D3** Komponenten-Thumbnails (visuelle Vorschau im Component-Manager).
|
||||
|
||||
## E — Bewusst NICHT zu portieren
|
||||
|
||||
Rhino-/Windows-gebundene Implementierungsdetails ohne Browser-Äquivalent:
|
||||
Window-Layout-XML-Persistierung, Auto-DPI via CoreGraphics, Custom-Grips-Code
|
||||
(Rhino-SDK-spezifisch), nativer `.3dm`-Geometrie-Import.
|
||||
|
||||
## Priorisierung (aus dem Original-Audit)
|
||||
|
||||
1. A1 (Override-Regel-Engine)
|
||||
2. A2 (View-Snapshots)
|
||||
3. A3 (Print-Layout-Blätter)
|
||||
4. A5 (reichere Öffnungen)
|
||||
5. B1 (Text-Platzierungs-Werkzeug)
|
||||
6. A4 (Tragwerk)
|
||||
7. B2 (Object-Info numerisch)
|
||||
8. A6 (Bauteil-Schedule)
|
||||
|
||||
Siehe auch `ROADMAP.md` §11 für eine noch breitere, unabhängig entstandene
|
||||
Backlog-Liste (4-Wege-Survey über Bauteile/Darstellung/Pläne/Kontext) mit
|
||||
teilweiser Überschneidung.
|
||||
@@ -0,0 +1,760 @@
|
||||
# Aktive Zeichen- und Bearbeitungs-Werkzeuge
|
||||
|
||||
Status: Entwurf. Dieses Dokument spezifiziert das **Tool-System** für das aktive
|
||||
Erzeugen von Modell-Elementen durch Zeichnen im Grundriss: Wände (Achs-Polylinie
|
||||
→ `Wall` eines `WallType`) sowie reine 2D-Geometrie (Linie, Polylinie, Rechteck,
|
||||
Kreis, Bogen, Text). Es definiert die Werkzeug-Zustandsmaschine, die Live-Vorschau
|
||||
(Rubber-Band), das **Snapping** mit Bildschirm-Markern, die Ebenen-/Kategorie-/
|
||||
Stil-Zuordnung neuer Elemente und das neue Element `Drawing2D` samt Ableitung in
|
||||
`generatePlan`.
|
||||
|
||||
Bezugsdokumente: [elements.md](elements.md) (Wand-/Tür-Modell),
|
||||
[resources-graphics.md](resources-graphics.md) (Stil-Auflösung),
|
||||
[plans-output.md](plans-output.md) (Papier-Maßstab, mm-Strichstärken),
|
||||
[context-menu.md](context-menu.md) (Maus-Schema).
|
||||
|
||||
## 0. Architektur-Prinzip (Bezug zum Repo)
|
||||
|
||||
Die App folgt der Regel **ein semantisches Modell ist die einzige Wahrheit; jede
|
||||
Ansicht ist abgeleitet** (CONVENTIONS.md, `App.tsx`). Werkzeuge greifen darum NUR über
|
||||
`setProject` immutabel auf das `Project`-Modell zu; sie schreiben NIE Geometrie
|
||||
direkt in den Plan. Der `PlanView` bleibt eine reine Darstellungs-/Eingabe-
|
||||
Schicht. Das Tool-System setzt genau an der bestehenden Naht in `PlanView` an:
|
||||
|
||||
- **Modell↔Screen.** `PlanView` rechnet bereits Cursor-Pixel → viewBox-Einheiten
|
||||
(`clientToView`) → Modell-Meter (`viewToModel`). Diese Umrechnung ist die
|
||||
Grundlage; Werkzeuge arbeiten ausschließlich in **Modell-Metern** (CONVENTIONS.md:
|
||||
intern alles in Metern). Für Snap-Marker brauchen Werkzeuge zusätzlich die
|
||||
Rückrichtung Modell → viewBox (`toScreen`, existiert bereits) bzw. Modell →
|
||||
Client-Pixel.
|
||||
- **Pointer-Handling.** `PlanView` besitzt heute drei Gesten an der linken Taste/
|
||||
Mitte/rechts: Auswahl/Marquee, Pan, Kontextmenü. Das Tool-System schiebt sich
|
||||
VOR diese Logik: ist ein aktives Zeichenwerkzeug gewählt (≠ `select`), übernimmt
|
||||
das Werkzeug `pointerdown/move/up`; das `select`-Werkzeug delegiert an die heute
|
||||
schon vorhandene Auswahl-/Marquee-Logik (kein Verhaltensbruch).
|
||||
- **Pan/Zoom bleiben immer aktiv.** Mittlere Maustaste (Pan) und Mausrad (Zoom)
|
||||
laufen unverändert weiter, auch während ein Zeichenwerkzeug aktiv ist — sonst
|
||||
kann man beim Zeichnen nicht navigieren.
|
||||
|
||||
## 1. Datenfluss-Überblick
|
||||
|
||||
```
|
||||
TopBar (Werkzeugleiste) --activeTool--> App-State
|
||||
│
|
||||
┌──── activeTool, wallTypeId, defaultCategoryCode ────┐
|
||||
▼ ▼
|
||||
PlanView ── pointerdown/move/up (Modellpunkt) ──> ToolController
|
||||
▲ │
|
||||
Snap-Marker + Rubber-Band-Overlay <── DraftState (Vorschau) ──┘
|
||||
│ │
|
||||
└──────────────── commit ──> onToolCommit(Element) ──> setProject
|
||||
```
|
||||
|
||||
`activeTool` und die Werkzeug-Parameter (aktiver `WallType`, Default-Kategorie)
|
||||
liegen als **View-State** in `App.tsx` — wie `viewType`, `detail`, `selectedWallIds`
|
||||
bereits dort liegen. Der `ToolController` ist **frameworkfrei** (reines TS, kein
|
||||
React-State pro Mausbewegung — analog zu `drag`/`marquee` als `useRef` in
|
||||
`PlanView`), damit die Live-Vorschau ohne Re-Render des ganzen Baums läuft. Nur
|
||||
beim **Commit** wird `setProject` (Re-Render) ausgelöst.
|
||||
|
||||
## 2. Koordinaten & Hilfsfunktionen
|
||||
|
||||
`PlanView` exportiert künftig zwei reine Konverter (heute intern vorhanden),
|
||||
plus die effektive Pixel-pro-Meter-Skala für die Snap-Toleranz:
|
||||
|
||||
```ts
|
||||
// PlanView-intern bereits da; wird als stabile Callbacks nach außen gereicht.
|
||||
type ToModel = (clientX: number, clientY: number) => Vec2; // Pixel → Meter
|
||||
type ToClient = (m: Vec2) => { x: number; y: number }; // Meter → Pixel
|
||||
type PxPerMeter = () => number; // aktuelle meet-Skala * PX_PER_M (Snap-Toleranz)
|
||||
```
|
||||
|
||||
`PxPerMeter` ergibt sich aus `meetScale(view) * PX_PER_M` (beides in `PlanView`
|
||||
vorhanden). Snap-Toleranzen werden in **Bildschirm-Pixeln** definiert (z. B. 10 px)
|
||||
und über `pxPerMeter` in Meter umgerechnet — so ist der Fangradius zoom-unabhängig
|
||||
konstant am Bildschirm.
|
||||
|
||||
## 3. Tool-System
|
||||
|
||||
### 3.1 Werkzeug-Identität und Registry
|
||||
|
||||
```ts
|
||||
export type ToolId =
|
||||
| "select" // Default: Auswahl/Marquee (heutiges Verhalten)
|
||||
| "wall" // Wand-Achs-Polylinie → Wall je Segment
|
||||
| "line" // einzelne 2D-Strecke
|
||||
| "polyline" // offene 2D-Polylinie
|
||||
| "rect" // 2D-Rechteck (zwei Ecken)
|
||||
| "circle" // 2D-Kreis (Zentrum + Radius)
|
||||
| "arc" // 2D-Bogen (3-Punkt oder Zentrum-Start-Ende)
|
||||
| "text"; // 2D-Textmarke
|
||||
|
||||
/** Live-Kontext, den ein Werkzeug bei jedem Schritt erhält. */
|
||||
export interface ToolContext {
|
||||
project: Project;
|
||||
/** Aktives Geschoss/Zeichnungsebene (Ziel der neuen Elemente). */
|
||||
level: DrawingLevel;
|
||||
/** Default-Kategorie-Code für neue Elemente (siehe §6). */
|
||||
defaultCategoryCode: string;
|
||||
/** Aktiver Wandtyp für das Wand-Werkzeug. */
|
||||
activeWallTypeId: string;
|
||||
/** Aktiver Linienstil-Code für 2D-Primitive (Line Manager). */
|
||||
activeLineStyleId: string;
|
||||
/** Snapping-Einstellungen (an/aus je Typ, ortho, grid). */
|
||||
snap: SnapSettings;
|
||||
/** Pixel pro Meter (für Snap-Toleranz in Metern). */
|
||||
pxPerMeter: number;
|
||||
}
|
||||
|
||||
/** Ein an einer Modellposition ausgelöstes Pointer-Ereignis. */
|
||||
export interface ToolPointer {
|
||||
/** Roher Modellpunkt (vor Snapping), in Metern. */
|
||||
raw: Vec2;
|
||||
/** Gesnappter Punkt + Marker-Info (siehe §5). null = kein Snap. */
|
||||
snap: SnapResult | null;
|
||||
/** Effektiver Punkt = snap?.point ?? raw. */
|
||||
point: Vec2;
|
||||
/** Modifikatoren (Shift = Ortho erzwingen, Ctrl = Snap aus, Alt = …). */
|
||||
shift: boolean;
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
button: number; // 0 links, 2 rechts
|
||||
}
|
||||
|
||||
/** Was ein Werkzeug-Schritt nach außen meldet. */
|
||||
export interface ToolResult {
|
||||
/** Neuer Vorschau-Zustand (Rubber-Band-Geometrie); null = nichts zu zeigen. */
|
||||
draft: ToolDraft | null;
|
||||
/** Bei Abschluss: Mutation, die App über setProject anwendet. */
|
||||
commit?: (p: Project) => Project;
|
||||
/** true → Werkzeug ist fertig und kehrt in seinen Ruhezustand zurück. */
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
/** Die Werkzeug-Schnittstelle (reine Funktionen über einen internen State). */
|
||||
export interface Tool {
|
||||
id: ToolId;
|
||||
/** UI-Label-Key (i18n), z. B. "tool.wall". */
|
||||
labelKey: string;
|
||||
/** Material-Symbol-Name für die Werkzeugleiste. */
|
||||
icon: string;
|
||||
/** Statuszeilen-Hinweis-Key je Phase (z. B. "tool.wall.firstPoint"). */
|
||||
hintKey: (state: ToolState) => string;
|
||||
|
||||
/** Initialer Ruhezustand. */
|
||||
init(): ToolState;
|
||||
/** Klick/Tap (pointerdown→up ohne Drag, bzw. „setze Punkt"). */
|
||||
onClick(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
|
||||
/** Bewegung (Hover/Drag): nur Vorschau, nie Commit. */
|
||||
onMove(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
|
||||
/** Doppelklick/Enter: mehrteilige Werkzeuge abschließen (z. B. Polylinie). */
|
||||
onCommitGesture(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
|
||||
/** Esc: aktuellen Entwurf verwerfen, zurück in den Ruhezustand. */
|
||||
onCancel(state: ToolState): [ToolState, ToolResult];
|
||||
/** Backspace: letzten gesetzten Punkt zurücknehmen (mehrteilig). */
|
||||
onUndoPoint?(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
|
||||
}
|
||||
```
|
||||
|
||||
`ToolState` ist je Werkzeug ein Discriminated Union (Beispiel Wand in §4). Der
|
||||
`ToolController` hält genau eine aktive `Tool`-Instanz + deren `ToolState` in
|
||||
einem `useRef` und ist die einzige Stelle, die diese Methoden aufruft.
|
||||
|
||||
### 3.2 Vorschau-Geometrie (Rubber-Band)
|
||||
|
||||
```ts
|
||||
/** Darstellbare Vorschau — dieselben Primitive wie der Plan, plus Marker. */
|
||||
export interface ToolDraft {
|
||||
/** Vorschau-Primitive (gestrichelt/halbtransparent gezeichnet). */
|
||||
preview: Primitive[];
|
||||
/** Bereits gesetzte „feste" Stützpunkte (kleine Quadrate). */
|
||||
vertices: Vec2[];
|
||||
/** Optionaler Maß-/Winkel-Text am Cursor (z. B. "3.20 m, 90°"). */
|
||||
hud?: { at: Vec2; text: string };
|
||||
}
|
||||
```
|
||||
|
||||
Wichtig: Die Vorschau benutzt **dieselben `Primitive`-Typen** wie `generatePlan`
|
||||
(`polygon | line | arc`). Damit kann der Vorschau-Layer mit derselben
|
||||
`PrimitiveShape`-Renderlogik gezeichnet werden (DRY) — nur mit einer
|
||||
Vorschau-CSS-Klasse (gestrichelt, Akzentfarbe). Für die Wand-Vorschau kann das
|
||||
Werkzeug sogar `generatePlan` auf einem **temporären Projekt** (Original + die in
|
||||
Bau befindliche Wand) aufrufen, um echte gehrte Poché live zu zeigen; in der
|
||||
ersten Phase reicht eine einfache Bandvorschau (`wallCorners`).
|
||||
|
||||
### 3.3 Zustandsmaschine (allgemein)
|
||||
|
||||
Jedes Werkzeug ist eine kleine Maschine über `pointerdown → move → up`. Da
|
||||
`PlanView` Pointer-Capture nutzt, kommen `move`/`up` zuverlässig an. Generisches
|
||||
Muster:
|
||||
|
||||
```
|
||||
ruht ──pointerdown──> (Werkzeug setzt 1. Punkt / startet Drag)
|
||||
▲ │
|
||||
│ ├──move──> Vorschau (rubber-band), kein Commit
|
||||
│ │
|
||||
│ (mehrteilig) pointerdown──> Punkt anhängen, Vorschau weiter
|
||||
│ │
|
||||
└──Esc/Cancel─────────────┤
|
||||
▼
|
||||
Doppelklick/Enter/letzter Punkt ──> commit(project) ──> ruht
|
||||
```
|
||||
|
||||
- **Klick-vs-Drag.** Wie heute in `PlanView` (`MARQUEE_THRESHOLD_PX`): unter der
|
||||
Schwelle ist es ein „Punkt setzen" (Klick), darüber ein Drag. Rechteck/Kreis/
|
||||
Linie unterstützen BEIDE Bedienarten: Zwei-Klick (Punkt, Punkt) ODER Drücken-
|
||||
Ziehen-Loslassen. Polyline/Wall sind reine Klickfolgen mit Abschluss per
|
||||
Doppelklick/Enter.
|
||||
- **Esc** verwirft den Entwurf (`onCancel`) und bleibt im selben Werkzeug.
|
||||
Zweites Esc (im Ruhezustand) schaltet zurück auf `select`.
|
||||
- **Rechtsklick** während eines aktiven Entwurfs = „abschließen/abbrechen"
|
||||
(CAD-üblich), KEIN Kontextmenü; im Ruhezustand öffnet Rechtsklick wie bisher
|
||||
das Plan-Kontextmenü.
|
||||
|
||||
### 3.4 Einbettung in PlanView (Pointer-Routing)
|
||||
|
||||
`PlanView` bekommt zwei neue Props:
|
||||
|
||||
```ts
|
||||
interface PlanViewProps {
|
||||
// … bisherige Props …
|
||||
/** Aktives Werkzeug; "select" = bisheriges Verhalten. */
|
||||
activeTool?: ToolId;
|
||||
/**
|
||||
* Werkzeug-Treiber. PlanView ruft diese Callbacks mit fertig gesnappten
|
||||
* Modellpunkten auf und rendert den zurückgegebenen Draft als Overlay.
|
||||
*/
|
||||
toolHandlers?: {
|
||||
onToolDown(p: ToolPointer): void;
|
||||
onToolMove(p: ToolPointer): void;
|
||||
onToolUp(p: ToolPointer): void;
|
||||
onToolDoubleClick(): void;
|
||||
/** liefert die zu zeichnende Vorschau (von App/Controller gehalten). */
|
||||
draft: ToolDraft | null;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Routing in `onPointerDown` (Ergänzung der bestehenden Methode):
|
||||
|
||||
```
|
||||
onPointerDown(e):
|
||||
if e.button === 1: → bestehender Pan (unverändert)
|
||||
if e.button === 0:
|
||||
if activeTool === "select": → bestehende Auswahl-/Marquee-Geste
|
||||
else:
|
||||
setPointerCapture
|
||||
p = makeToolPointer(e) // raw → snap → point (§5)
|
||||
toolHandlers.onToolDown(p)
|
||||
if e.button === 2 (rechts):
|
||||
if activeTool !== "select" && entwurf aktiv: toolHandlers.onToolUp({button:2,…}) // abschließen
|
||||
else: bestehendes Kontextmenü
|
||||
```
|
||||
|
||||
`onPointerMove`/`onPointerUp` analog: bei aktivem Zeichenwerkzeug an
|
||||
`onToolMove`/`onToolUp` routen statt an Pan/Marquee. Der Cursor wird auf
|
||||
`crosshair` gesetzt. `makeToolPointer` führt das Snapping aus (§5) und liefert den
|
||||
fertigen `ToolPointer`.
|
||||
|
||||
Die **Snap-Marker** und der **Draft** werden als zusätzliche SVG-Gruppe NACH den
|
||||
Plan-Primitiven, aber vor der Auswahl-Hervorhebung gerendert (immer obenauf,
|
||||
`pointerEvents="none"`). Marker werden in viewBox-Einheiten über `toScreen`
|
||||
positioniert (existiert bereits).
|
||||
|
||||
## 4. Werkzeug: Wand (Wall)
|
||||
|
||||
Das Wand-Werkzeug zeichnet eine **Achs-Polylinie**; jedes Segment wird zu einem
|
||||
eigenständigen `Wall`-Element des aktiven `WallType` auf dem aktiven Geschoss.
|
||||
Aufeinanderfolgende Segmente teilen sich einen Knoten → die bestehende
|
||||
`computeJoins`-Verschneidung (in `generatePlan`) erzeugt automatisch saubere
|
||||
Gehrungen an den Ecken. Kein zusätzlicher Join-Code nötig.
|
||||
|
||||
### 4.1 Zustand
|
||||
|
||||
```ts
|
||||
type WallToolState =
|
||||
| { phase: "idle" }
|
||||
| {
|
||||
phase: "drawing";
|
||||
/** Bisher gesetzte Achs-Knoten (in Metern). */
|
||||
points: Vec2[];
|
||||
/** Aktuelle Cursor-Position (gesnappt) für die Rubber-Band-Vorschau. */
|
||||
cursor: Vec2 | null;
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 Pseudocode
|
||||
|
||||
```
|
||||
WallTool.onClick(state, p, ctx):
|
||||
if state.phase === "idle":
|
||||
return [{phase:"drawing", points:[p.point], cursor:p.point}, {draft: draftFor([p.point], p.point, ctx)}]
|
||||
else: // weiteren Knoten anhängen
|
||||
pts = [...state.points, p.point]
|
||||
# Ortho/Snap haben p.point bereits ausgerichtet (§5).
|
||||
return [{phase:"drawing", points: pts, cursor: p.point}, {draft: draftFor(pts, p.point, ctx)}]
|
||||
|
||||
WallTool.onMove(state, p, ctx):
|
||||
if state.phase !== "drawing": return [state, {draft:null}]
|
||||
return [{...state, cursor:p.point}, {draft: draftFor(state.points, p.point, ctx)}]
|
||||
|
||||
WallTool.onCommitGesture(state, ctx): // Doppelklick / Enter / Rechtsklick
|
||||
if state.phase !== "drawing" || state.points.length < 2:
|
||||
return [{phase:"idle"}, {draft:null, done:true}]
|
||||
pts = state.points
|
||||
return [{phase:"idle"}, {
|
||||
draft: null, done: true,
|
||||
commit: (proj) => appendWalls(proj, pts, ctx)
|
||||
}]
|
||||
|
||||
WallTool.onCancel(state):
|
||||
return [{phase:"idle"}, {draft:null, done:true}]
|
||||
|
||||
WallTool.onUndoPoint(state):
|
||||
if state.phase==="drawing" && state.points.length>1:
|
||||
return [{...state, points: state.points.slice(0,-1)}, {draft: …}]
|
||||
return [{phase:"idle"}, {draft:null}]
|
||||
```
|
||||
|
||||
`draftFor` baut die Vorschau: feste Segmente zwischen `points` + ein „lebendes"
|
||||
Segment `points[last] → cursor`. Pro Segment werden die vier Band-Eckpunkte über
|
||||
`wallCorners(a, b, thickness)` (vorhanden) berechnet und als Vorschau-`polygon`
|
||||
gezeichnet; zusätzlich ein HUD mit Länge `|b−a|` und Winkel. `thickness =
|
||||
wallTypeThickness(getWallType(...))`.
|
||||
|
||||
### 4.3 Commit ins Modell
|
||||
|
||||
```
|
||||
appendWalls(project, pts, ctx):
|
||||
newWalls = []
|
||||
for i in 0 .. pts.length-2:
|
||||
a = pts[i]; b = pts[i+1]
|
||||
if |b-a| < EPS: continue // Null-Segmente überspringen
|
||||
newWalls.push({
|
||||
id: uniqueId("W"), // siehe §8 (ID-Vergabe)
|
||||
type: "wall",
|
||||
floorId: ctx.level.id, // aktives Geschoss
|
||||
categoryCode: ctx.defaultCategoryCode, // §6
|
||||
start: a, end: b,
|
||||
wallTypeId: ctx.activeWallTypeId,
|
||||
height: ctx.level.floorHeight ?? 2.6, // Geschosshöhe als Default
|
||||
})
|
||||
return { ...project, walls: [...project.walls, ...newWalls] }
|
||||
```
|
||||
|
||||
Hinweise:
|
||||
- **Höhe** erbt die lichte Geschosshöhe (`DrawingLevel.floorHeight`), Fallback 2.6 m.
|
||||
- **Geschossbindung**: Das Wand-Werkzeug ist nur aktiv, wenn `level.kind === "floor"`
|
||||
(sonst gibt es keine Wände). In `drawing`-Ebenen ist das Wand-Werkzeug
|
||||
deaktiviert (nur 2D-Werkzeuge); siehe §6.
|
||||
- Die Wicklung wird NICHT erzwungen — `leftNormal`-Konvention (CONVENTIONS.md) und
|
||||
`computeJoins` arbeiten richtungsunabhängig pro Segment.
|
||||
|
||||
## 5. Snapping
|
||||
|
||||
Snapping läuft in `makeToolPointer` (PlanView) BEVOR der Punkt an das Werkzeug
|
||||
geht. Es prüft mehrere Snap-Quellen, wählt die nächstgelegene innerhalb der
|
||||
Toleranz und liefert sowohl den gefangenen Punkt als auch eine **Marker-Art** für
|
||||
die Bildschirmdarstellung.
|
||||
|
||||
### 5.1 Typen
|
||||
|
||||
```ts
|
||||
export type SnapKind =
|
||||
| "endpoint" // Wand-Achsenende, Polylinien-Knoten, Primitiv-Endpunkt
|
||||
| "midpoint" // Mitte einer Strecke/Wandachse
|
||||
| "intersection" // Schnittpunkt zweier Achsen/Linien
|
||||
| "center" // Kreis-/Bogenzentrum
|
||||
| "quadrant" // Kreis-Quadrantenpunkte (0/90/180/270°)
|
||||
| "onEdge" // nächster Punkt AUF einer Wandachse/Linie (Lot)
|
||||
| "grid" // Rasterpunkt
|
||||
| "ortho" // orthogonal/winkelrastriert zum vorigen Punkt
|
||||
| "extension"; // Verlängerung einer Achse (gestrichelte Hilfslinie)
|
||||
|
||||
export interface SnapResult {
|
||||
point: Vec2; // gefangener Punkt (Meter)
|
||||
kind: SnapKind;
|
||||
/** Quell-Element (für Marker/Hilfslinien), optional. */
|
||||
refA?: Vec2;
|
||||
refB?: Vec2;
|
||||
/** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */
|
||||
distPx: number;
|
||||
}
|
||||
|
||||
export interface SnapSettings {
|
||||
enabled: boolean; // Master-Schalter (Ctrl invertiert temporär)
|
||||
endpoint: boolean;
|
||||
midpoint: boolean;
|
||||
intersection: boolean;
|
||||
center: boolean;
|
||||
onEdge: boolean;
|
||||
grid: boolean;
|
||||
gridSize: number; // Rasterweite in Metern, z. B. 0.10
|
||||
ortho: boolean; // Shift erzwingt zusätzlich
|
||||
angleStep: number; // Winkelraster in Grad (z. B. 45)
|
||||
tolerancePx: number; // Fangradius am Bildschirm, z. B. 10
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Snap-Kandidaten sammeln
|
||||
|
||||
Quellen pro Geschoss (gefiltert auf sichtbare Kategorien, wie der Plan):
|
||||
|
||||
| Snap | Quelle |
|
||||
|------|--------|
|
||||
| endpoint | `wall.start`, `wall.end` aller sichtbaren Wände; Knoten bereits gesetzter Draft-Punkte; `Drawing2D`-Vertices |
|
||||
| midpoint | Mitte jeder Wandachse und jedes 2D-Segments |
|
||||
| intersection | paarweise `lineIntersect` der Wandachsen (nur Paare, deren Boxen sich am Cursor nähern) |
|
||||
| center/quadrant | Kreise/Bögen aus `Drawing2D` |
|
||||
| onEdge | Lotfußpunkt des Cursors auf jede nahe Wandachse/2D-Linie |
|
||||
| grid | Rundung des Cursors auf `gridSize` |
|
||||
| ortho | Ausrichtung relativ zum letzten Draft-Punkt (§5.4) |
|
||||
|
||||
Performance: Kandidaten werden je `move` neu erzeugt, aber **früh nach
|
||||
Bildschirm-Distanz gefiltert** (nur Punkte innerhalb ~`2·tolerancePx`). Bei
|
||||
großen Modellen kann eine grobe Bounding-Box-Vorauswahl je Wand vorgeschaltet
|
||||
werden; in den ersten Phasen genügt lineares Scannen (Wandzahl ist klein).
|
||||
|
||||
### 5.3 Auswahl-Pseudocode
|
||||
|
||||
```
|
||||
computeSnap(rawModel, ctx, draftPoints, lastPoint):
|
||||
if ctrl(): return null # Snap temporär aus
|
||||
s = ctx.snap
|
||||
tolM = s.tolerancePx / ctx.pxPerMeter # px-Toleranz → Meter
|
||||
cands: SnapResult[] = []
|
||||
|
||||
if s.endpoint: cands += endpoints(...) filtered to within tolM
|
||||
if s.midpoint: cands += midpoints(...)
|
||||
if s.intersection: cands += intersections(...)
|
||||
if s.center: cands += centers/quadrants(...)
|
||||
if s.onEdge: cands += perpendicularFeet(...) # niedrigere Priorität
|
||||
|
||||
# Punkt-Snaps haben Vorrang vor Linien-/Raster-Snaps:
|
||||
pick = argmin(cands, by distPx within tolM, tie-break by priority)
|
||||
if pick: rawModel = pick.point
|
||||
|
||||
# Ortho/Winkelraster wirkt RELATIV zum letzten Punkt und ÜBERLAGERT:
|
||||
if (s.ortho || shift()) && lastPoint:
|
||||
rawModel = applyAngleConstraint(lastPoint, rawModel, s.angleStep)
|
||||
# Wenn dabei auch ein Punkt-Snap nahe der Ortho-Linie liegt → bevorzugen.
|
||||
|
||||
if !pick && s.grid:
|
||||
g = snapToGrid(rawModel, s.gridSize)
|
||||
if dist(g, rawModel) within tolM: return {point:g, kind:"grid", …}
|
||||
|
||||
return pick ?? null
|
||||
```
|
||||
|
||||
Prioritätsreihenfolge bei gleichem Abstand: `endpoint > intersection > midpoint >
|
||||
center/quadrant > onEdge > grid`. Ortho/Winkelraster ist eine **Projektion**, kein
|
||||
Punkt-Kandidat: es verschiebt den (ggf. schon gesnappten) Punkt auf die nächste
|
||||
erlaubte Richtung vom letzten Knoten.
|
||||
|
||||
### 5.4 Ortho / Winkelraster
|
||||
|
||||
```
|
||||
applyAngleConstraint(from, to, stepDeg):
|
||||
d = to - from
|
||||
ang = atan2(d.y, d.x)
|
||||
k = round(ang / rad(stepDeg)) * rad(stepDeg)
|
||||
len = |d|
|
||||
return from + (cos(k), sin(k)) * len
|
||||
```
|
||||
|
||||
Mit `stepDeg = 90` ist das klassisches Ortho (H/V); `45` erlaubt Diagonalen.
|
||||
`Shift` erzwingt Ortho temporär unabhängig von der Einstellung.
|
||||
|
||||
### 5.5 Bildschirm-Marker
|
||||
|
||||
Pro aktivem Snap zeichnet `PlanView` ein Marker-Glyph an `toScreen(snap.point)`
|
||||
(`pointerEvents="none"`, eigene CSS-Klassen, papierkonstante Größe via
|
||||
non-scaling):
|
||||
|
||||
- `endpoint` → kleines Quadrat ▫
|
||||
- `midpoint` → Dreieck �△
|
||||
- `intersection` → ✕
|
||||
- `center` → ○, `quadrant` → ◇
|
||||
- `onEdge` → ⟂-Glyph
|
||||
- `grid` → feiner Punkt
|
||||
- `ortho`/`extension` → zusätzlich eine **gestrichelte Hilfslinie** von `refA`
|
||||
(Bezugspunkt) zum Cursor
|
||||
|
||||
Marker erscheinen NUR während ein Zeichenwerkzeug aktiv ist. i18n-Tooltips/Status
|
||||
(„Endpunkt", „Mittelpunkt", …) über `t('snap.endpoint')` etc.
|
||||
|
||||
## 6. Ebene, Kategorie und Stil neuer Elemente
|
||||
|
||||
Neue Elemente brauchen eine **Zeichnungsebene** (DrawingLevel) und eine
|
||||
**Kategorie** (LayerCategory `code`) sowie — bei 2D-Primitiven — einen Stift/
|
||||
Schraffur-Stil.
|
||||
|
||||
### 6.1 Zeichnungsebene (Ziel)
|
||||
|
||||
- Ziel ist **immer das aktive Geschoss/die aktive Zeichnungsebene** (`activeLevelId`
|
||||
in `App.tsx`). Wände nur auf `kind === "floor"`. 2D-Primitive (`Drawing2D`) auf
|
||||
jeder Ebene, also auch auf `kind === "drawing"` (freie 2D-Zeichnung).
|
||||
|
||||
### 6.2 Kategorie (categoryCode)
|
||||
|
||||
- Es gibt eine **aktive Kategorie** als View-State (`activeCategoryCode` in App,
|
||||
neu). Default beim Start: der Code der gewählten Wand-Kategorie (im Sample „20"
|
||||
Wände), bzw. die erste sichtbare Kategorie. Die Statusleiste zeigt heute schon
|
||||
die „aktive Ebene" (`activeLayerName`); diese wird künftig von `activeCategoryCode`
|
||||
gespeist statt nur aus der Auswahl abgeleitet.
|
||||
- Neue Wände: `categoryCode = activeCategoryCode` (z. B. „20").
|
||||
- Neue 2D-Primitive: ebenfalls `activeCategoryCode`. Sinnvoll ist eine eigene
|
||||
2D-/Hilfslinien-Kategorie (z. B. „90 Zeichnung"); diese wird über die
|
||||
Kategorie-Auswahl in der Statusleiste/Werkzeugleiste gesetzt.
|
||||
- Die Kategorie liefert Farbe + Strichstärke (`LayerCategory.color`, `.lw`), genau
|
||||
wie `generatePlan` es heute für Wände via `categoryLwMap` nutzt.
|
||||
|
||||
### 6.3 Stift/Schraffur
|
||||
|
||||
- **Wände** erhalten KEINEN eigenen Stift — ihr Erscheinungsbild kommt aus dem
|
||||
`WallType` (Component → Hatch → LineStyle) und der Kategorie-`lw` (bestehender
|
||||
Pfad in `generatePlan`).
|
||||
- **2D-Primitive** referenzieren optional einen `LineStyle` aus dem Line Manager
|
||||
(`activeLineStyleId`). Ohne expliziten Stil erben sie Farbe/Strichstärke aus der
|
||||
Kategorie (`color`, `lw`). Flächige 2D-Primitive (geschlossenes Rechteck/Kreis/
|
||||
Polyline) können optional eine Schraffur (`hatchId`) tragen.
|
||||
|
||||
## 7. Speicherung der 2D-Primitive: `Drawing2D`
|
||||
|
||||
2D-Geometrie wird als neues Modell-Element `Drawing2D` gespeichert — analog zu
|
||||
`Wall`/`Door` ein semantisches Element, das beim Rendern abgeleitet wird (KEINE
|
||||
vorab erzeugten Primitive im Modell). Damit bleibt die Architektur „Modell →
|
||||
abgeleitete Ansicht" intakt.
|
||||
|
||||
### 7.1 Typ
|
||||
|
||||
```ts
|
||||
/** Geometrie-Form eines 2D-Zeichenelements. */
|
||||
export type Drawing2DGeom =
|
||||
| { shape: "line"; a: Vec2; b: Vec2 }
|
||||
| { shape: "polyline"; pts: Vec2[]; closed: boolean }
|
||||
| { shape: "rect"; min: Vec2; max: Vec2 } // achsparallel
|
||||
| { shape: "circle"; center: Vec2; r: number }
|
||||
| {
|
||||
shape: "arc";
|
||||
center: Vec2;
|
||||
r: number;
|
||||
/** Start-/Endwinkel in Radiant (math. Konvention, CCW positiv). */
|
||||
a0: number;
|
||||
a1: number;
|
||||
}
|
||||
| { shape: "text"; at: Vec2; text: string; height: number; angle: number };
|
||||
|
||||
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
|
||||
export interface Drawing2D {
|
||||
id: string;
|
||||
type: "drawing2d";
|
||||
/** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */
|
||||
levelId: string;
|
||||
/** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */
|
||||
categoryCode: string;
|
||||
geom: Drawing2DGeom;
|
||||
/** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */
|
||||
lineStyleId?: string;
|
||||
/** Optionale Schraffur für geschlossene Formen (Hatch Manager). */
|
||||
hatchId?: string;
|
||||
/** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */
|
||||
color?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Ergänzung am `Project`:
|
||||
|
||||
```ts
|
||||
export interface Project {
|
||||
// … bisher …
|
||||
drawings2d: Drawing2D[]; // NEU
|
||||
}
|
||||
export type Element = Wall | Door | Drawing2D; // erweitert
|
||||
```
|
||||
|
||||
`sampleProject` bekommt ein leeres `drawings2d: []`. Lösch-/Referenz-Regeln:
|
||||
beim Löschen einer Zeichnungsebene werden auch deren `Drawing2D` entfernt (analog
|
||||
zur bestehenden Wand-/Tür-Bereinigung in `deleteLevel`).
|
||||
|
||||
### 7.2 Ableitung in `generatePlan`
|
||||
|
||||
`generatePlan` rendert künftig zusätzlich die `Drawing2D` des Geschosses (gefiltert
|
||||
wie Wände auf sichtbare Kategorien + `categoryDisplay`). Neue Funktion
|
||||
`addDrawing2D(out, project, d, greyed, lwMm)`:
|
||||
|
||||
```
|
||||
addDrawing2D(out, project, d):
|
||||
color = d.color ?? categoryColor(d.categoryCode)
|
||||
weight = lineStyle(d.lineStyleId)?.weight ?? categoryLw(d.categoryCode)
|
||||
dash = lineStyle(d.lineStyleId)?.dash ?? null
|
||||
switch d.geom.shape:
|
||||
"line": out.push({kind:"line", a, b, cls:"draw2d", weightMm:weight, dash})
|
||||
"polyline": for each segment → line-Primitive (closed → Schluss-Segment)
|
||||
"rect": vier Kanten als line-Primitive (oder polygon, falls hatchId)
|
||||
"circle": → als zwei 180°-Bögen (arc-Primitive) ODER neues Primitiv (s. u.)
|
||||
"arc": → arc-Primitive (center/from/to/r aus a0,a1)
|
||||
"text": → neues text-Primitiv (s. u.)
|
||||
```
|
||||
|
||||
Dabei wird, wo möglich, der **vorhandene** `Primitive`-Vorrat (`line`, `arc`,
|
||||
`polygon`) wiederverwendet — die Strichstärke kommt in mm Papier (wie der Rest des
|
||||
Plans), Farbe über eine CSS-Klasse oder ein neues optionales `color`-Feld am
|
||||
`line`-Primitive.
|
||||
|
||||
Zwei `Primitive`-Erweiterungen sind nötig:
|
||||
|
||||
```ts
|
||||
// kreisförmige Vollkurve (Kreis) — sonst muss man sie in zwei Bögen zerlegen:
|
||||
| { kind: "circle"; center: Vec2; r: number; cls: string; weightMm: number;
|
||||
dash?: number[] | null; fill?: string; greyed?: boolean }
|
||||
// Textmarke:
|
||||
| { kind: "text"; at: Vec2; text: string; heightMm: number; angle: number;
|
||||
cls: string; color?: string; greyed?: boolean }
|
||||
```
|
||||
|
||||
`PlanView.renderPrimitive` bekommt entsprechende `case`-Zweige (`<circle>`,
|
||||
`<text>`). Text wird in **Papier-Millimetern** dimensioniert (Höhe → `mmToPx`,
|
||||
non-scaling), damit die Schrifthöhe beim Zoomen papierkonstant bleibt (analog zu
|
||||
Strichstärken in `plans-output.md`).
|
||||
|
||||
Das `arc`-Primitiv zeichnet heute nur Kurzbögen (≤180°, `large-arc=0`). Für
|
||||
beliebige 2D-Bögen wird es um ein `largeArc`-Flag erweitert (aus `|a1−a0|`
|
||||
berechnet); abwärtskompatibel (Default 0).
|
||||
|
||||
## 8. ID-Vergabe & Immutabilität
|
||||
|
||||
- Neue IDs über einen kleinen Helfer `uniqueId(prefix)` (z. B.
|
||||
`\`${prefix}-${Date.now()}-${counter++}\``), konsistent mit der bestehenden
|
||||
Praxis in `App.tsx` (`floor-${Date.now()}` usw.). Wand-Präfix „W", 2D-Präfix
|
||||
„dr2d".
|
||||
- Alle Mutationen laufen über `setProject` immutabel (CONVENTIONS.md / App-Konvention).
|
||||
Der `commit(project)` eines Werkzeugs ist eine reine Funktion `Project →
|
||||
Project`; App ruft `setProject(prev => result.commit(prev))`.
|
||||
|
||||
## 9. App- und PlanView-Verdrahtung (konkret)
|
||||
|
||||
Neuer View-State in `App.tsx`:
|
||||
|
||||
```ts
|
||||
const [activeTool, setActiveTool] = useState<ToolId>("select");
|
||||
const [activeCategoryCode, setActiveCategoryCode] = useState<string>(/* erste Wand-Kat */);
|
||||
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(project.wallTypes[0].id);
|
||||
const [activeLineStyleId, setActiveLineStyleId] = useState<string>(project.lineStyles[0].id);
|
||||
const [snap, setSnap] = useState<SnapSettings>(DEFAULT_SNAP);
|
||||
const toolStateRef = useRef<ToolState>(getTool(activeTool).init());
|
||||
const [draft, setDraft] = useState<ToolDraft | null>(null);
|
||||
```
|
||||
|
||||
Der `ToolController` ist eine kleine Hook/Klasse, die `toolStateRef` hält und die
|
||||
`PlanView.toolHandlers` implementiert:
|
||||
|
||||
```
|
||||
onToolDown(p): [st, res] = tool.onClick(toolStateRef.current, p, ctx)
|
||||
toolStateRef.current = st; setDraft(res.draft)
|
||||
if res.commit: setProject(res.commit)
|
||||
if res.done: toolStateRef.current = tool.init()
|
||||
onToolMove(p): [st, res] = tool.onMove(...); toolStateRef.current=st; setDraft(res.draft)
|
||||
onToolDoubleClick(): [st,res]=tool.onCommitGesture(...); apply commit/done; setDraft(null)
|
||||
```
|
||||
|
||||
Keyboard (global, nur wenn ein Zeichenwerkzeug aktiv ist):
|
||||
`Esc → onCancel`, `Enter → onCommitGesture`, `Backspace → onUndoPoint`. Beim
|
||||
Wechsel von `activeLevelId`/`viewType` wird der laufende Entwurf verworfen (analog
|
||||
zur bestehenden Auswahl-Bereinigung in den `useEffect`s).
|
||||
|
||||
`ctx` (ToolContext) wird in App via `useMemo` aus Project + aktiven Selektionen
|
||||
gebaut und an PlanView/Controller gereicht.
|
||||
|
||||
### 9.1 Werkzeugleiste (TopBar)
|
||||
|
||||
Eine neue Werkzeug-Gruppe in der `TopBar` (links, vor den Ansichts-Toggles), als
|
||||
i18n-beschriftete Icon-Buttons (`t('tool.select')`, `t('tool.wall')`, …). Aktiv-
|
||||
Zustand hervorgehoben. Daneben: Auswahl des aktiven `WallType` (für Wand) und der
|
||||
aktiven Kategorie/des Linienstils (Dropdowns), sowie Snap-Toggles (kleines
|
||||
Snap-Menü mit Checkboxen je `SnapKind`, Grid-Größe, Winkelraster). Wand-/2D-
|
||||
Werkzeuge werden je nach `level.kind` aktiviert/deaktiviert (Tooltip nennt den
|
||||
Grund — wie die bestehenden disabled-Menüpunkte in `App.tsx`).
|
||||
|
||||
### 9.2 i18n-Keys (neu, Auszug)
|
||||
|
||||
```
|
||||
tool.select / tool.wall / tool.line / tool.polyline / tool.rect /
|
||||
tool.circle / tool.arc / tool.text
|
||||
tool.wall.firstPoint / tool.wall.nextPoint / tool.wall.finish
|
||||
snap.endpoint / snap.midpoint / snap.intersection / snap.center /
|
||||
snap.quadrant / snap.onEdge / snap.grid / snap.ortho
|
||||
snap.settings / snap.gridSize / snap.angleStep
|
||||
status.activeWallType / status.activeCategory / status.activeTool
|
||||
```
|
||||
|
||||
Alle sichtbaren Strings über `t(...)` (CONVENTIONS.md). Identifier bleiben englisch.
|
||||
|
||||
## 10. Übrige Werkzeuge (Kurzspezifikation)
|
||||
|
||||
| Werkzeug | Eingabe | Zustand | Commit |
|
||||
|----------|---------|---------|--------|
|
||||
| **Line** | 2 Punkte (Klick-Klick oder Drag) | `{a?}` | `Drawing2D{shape:"line"}` |
|
||||
| **Polyline** | n Punkte, Abschluss Doppelklick/Enter; `closed` per „C" oder Klick auf Start | `{pts}` | `Drawing2D{shape:"polyline"}` |
|
||||
| **Rectangle** | 2 Ecken (Drag oder Klick-Klick) | `{p0?}` | `Drawing2D{shape:"rect"}` (min/max sortiert) |
|
||||
| **Circle** | Zentrum + Radius-Punkt | `{center?}` | `Drawing2D{shape:"circle"}` |
|
||||
| **Arc** | 3 Punkte (Start, durch, Ende) ODER Zentrum-Start-Ende (Modus-Toggle) | `{p0?,p1?}` | `Drawing2D{shape:"arc"}` (a0/a1 aus Punkten) |
|
||||
| **Text** | 1 Punkt → Inline-Eingabefeld (wie `InlineEditor` in App) | `{at?}` | `Drawing2D{shape:"text"}` |
|
||||
|
||||
Alle nutzen dasselbe `Tool`-Interface, dasselbe Snapping und denselben Draft-/
|
||||
Commit-Pfad. Text öffnet beim Setzen des Ankerpunkts ein kleines Overlay-Eingabe-
|
||||
feld (an `toClient(at)` positioniert) und committet bei Enter/Blur.
|
||||
|
||||
3-Punkt-Bogen → Zentrum: Umkreismittelpunkt der drei Punkte (Schnitt der
|
||||
Mittelsenkrechten via `lineIntersect`), `r`, `a0/a1` aus Start-/Endwinkel; Drehsinn
|
||||
aus dem mittleren Punkt.
|
||||
|
||||
## 11. Phasenplan
|
||||
|
||||
**Phase 1 — Gerüst + Select + Wall + Line (MVP).**
|
||||
1. `ToolId`, `Tool`, `ToolContext`, `ToolPointer`, `ToolDraft`, `ToolResult`,
|
||||
`SnapResult`, `SnapSettings`, `Drawing2D`(+`Project.drawings2d`) als Typen.
|
||||
2. `PlanView`: `toScreen`/`viewToModel`/`pxPerMeter` als Callbacks nach außen;
|
||||
Pointer-Routing für `activeTool !== "select"`; Draft-/Marker-Overlay-Rendering;
|
||||
Crosshair-Cursor.
|
||||
3. `ToolController` + App-State (`activeTool`, `activeCategoryCode`,
|
||||
`activeWallTypeId`, `snap`) + Keyboard (Esc/Enter/Backspace).
|
||||
4. **WallTool** voll funktionsfähig (Polylinie → Wände, Live-Band-Vorschau, HUD,
|
||||
Commit via `appendWalls`). Verschneidung kommt automatisch aus `computeJoins`.
|
||||
5. **LineTool** als erstes 2D-Werkzeug; `generatePlan.addDrawing2D` für `line`;
|
||||
`Drawing2D`-Löschung beim Geschoss-Löschen.
|
||||
6. **Snapping Stufe 1**: endpoint + grid + ortho (Shift), mit Bildschirm-Markern.
|
||||
7. TopBar-Werkzeuggruppe (select/wall/line) + WallType-/Kategorie-Auswahl;
|
||||
i18n-Keys; Statusleiste zeigt aktives Werkzeug + Kategorie.
|
||||
8. Verifizieren: `npx tsc -b`, `npm run build`, Screenshot via `scripts/probe.mjs`
|
||||
(Wand zeichnen, Gehrung prüfen).
|
||||
|
||||
**Phase 2 — Snapping vervollständigen + 2D-Grundformen.**
|
||||
- Snap: midpoint, intersection, onEdge (Lot), extension-Hilfslinien, Winkelraster
|
||||
(45°), Snap-Einstellungsmenü in der TopBar.
|
||||
- Werkzeuge: Polyline, Rectangle (inkl. optionaler Schraffur für geschlossene
|
||||
Formen). `Primitive`-Erweiterung nur für tatsächlich gebrauchte Formen.
|
||||
|
||||
**Phase 3 — Kurven + Text.**
|
||||
- `Primitive` um `circle` (+ `arc` `largeArc`) und `text` erweitern; PlanView-
|
||||
Renderzweige; Text papierkonstant.
|
||||
- Werkzeuge: Circle, Arc (3-Punkt), Text (Inline-Eingabe). Snap: center/quadrant.
|
||||
|
||||
**Phase 4 — Bearbeitung (Folge-Doku).**
|
||||
- Grips/Editieren bestehender Elemente (Wand-Enden ziehen, 2D-Vertices verschieben),
|
||||
Verschieben/Kopieren/Rotieren der Auswahl, numerische Direkteingabe von
|
||||
Länge/Winkel im HUD. Baut auf demselben Snapping + Draft-Pfad auf. (Eigenes
|
||||
Design-Dokument; hier nur als Ausblick.)
|
||||
|
||||
## 12. Architektur-Garantien (Checkliste)
|
||||
|
||||
- Modell bleibt einzige Wahrheit; Werkzeuge schreiben nur `Project`, nie Plan-
|
||||
Primitive. Ansichten (Plan/3D) leiten ab.
|
||||
- Alle Bezeichner englisch; alle UI-Texte über `t(...)`; Einheiten in Metern,
|
||||
Anzeige via `formatM`; Strichstärken/Texthöhen in mm Papier (non-scaling).
|
||||
- Native-App-Verhalten: kein Browser-Kontextmenü während des Zeichnens; keine
|
||||
Textauswahl (außer Text-Eingabefeld); Pan/Zoom immer verfügbar.
|
||||
- DRY: Vorschau nutzt dieselben `Primitive` + Renderlogik wie der Plan; Snapping
|
||||
und Commit-Pfad sind werkzeugübergreifend geteilt.
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,424 @@
|
||||
# Design — Bauteile (Elements)
|
||||
|
||||
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||
> Output/Pläne: [plans-output.md](plans-output.md). Ressourcen/Stile:
|
||||
> [resources-graphics.md](resources-graphics.md).
|
||||
|
||||
Dieses Dokument legt die **Daten**, die **Generierung** (3D-Geometrie + Plan-
|
||||
Symbolik) und das **Grip-Editing** je Bauteil fest und übersetzt DOSSIERs
|
||||
`elemente.py` (7244 LOC, Monolith) in **kleine Bauteil-Module** (`src/model/
|
||||
elements/wall.ts`, `opening.ts`, …). Bezeichner englisch, Prosa deutsch, Meter.
|
||||
|
||||
DOSSIERs Architektur dort: pro Element eine **Achse/Outline-Source** (editierbar)
|
||||
+ ein **auto-generiertes Volumen** (`wand_axis`+`wand_volume`, Outline+Brep).
|
||||
Browser-Äquivalent: das **semantische Element ist die Source**; Geometrie wird per
|
||||
`generate*()` **abgeleitet** (nie persistiert). Das ist sauberer als DOSSIERs
|
||||
zwei-Objekt-Modell und kennt kein Cache-Stale.
|
||||
|
||||
---
|
||||
|
||||
## 0. Gemeinsames Fundament
|
||||
|
||||
```ts
|
||||
// src/model/elements/base.ts
|
||||
interface ElementBase {
|
||||
id: string;
|
||||
type: ElementType; // "wall" | "window" | "door" | "slab" | "stair" | "roof"
|
||||
// | "column" | "beam" | "space" | "draw2d"
|
||||
floorId: string; // Zeichnungsebene (Geschoss); bei gehosteten via Host
|
||||
categoryCode: string; // Ebene (Grafik-Kategorie), z.B. "20"
|
||||
styleId?: string; // optionaler Element-Override-Stil (resources-graphics.md)
|
||||
name?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Geometrie-Konvention** (aus CONVENTIONS.md, im Spike etabliert): Wand-Normale
|
||||
`n = leftNormal(u) = (-u.y, u.x)`; bei CCW-Wicklung zeigt `+n` nach innen.
|
||||
Schichten werden außen (`-T/2`) → innen (`+T/2`) gestapelt (`generatePlan.addWallPoche`,
|
||||
`Viewport3D.addWallMeshes`).
|
||||
|
||||
**Detailgrad** (LoD) — DOSSIERs `darstellung` (`auto|einfach|standard|detail`):
|
||||
```ts
|
||||
type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail
|
||||
// Auflösung: Element-Wert "auto" → Dokument-/Snapshot-Wert; sonst Element-Wert.
|
||||
function resolveDetail(el: ElementBase, doc: { detailLevel: DetailLevel }): DetailLevel
|
||||
```
|
||||
≙ DOSSIER `_resolve_oeff_darstellung` + `get_aktive_darstellung`. Steuert, *wie
|
||||
viel* Symbolik gezeichnet wird (1:500 Rechteck → 1:50 Glas/Sims/Schwenkbogen).
|
||||
|
||||
**Generierungs-Signaturen** (jedes Modul exportiert beides):
|
||||
```ts
|
||||
function build3d(project, el, ctx): THREE.Object3D // Volumen (Schichten/Brep)
|
||||
function generatePlan(project, el, ctx, lod): Primitive[] // Schnittflächen + Symbol
|
||||
// ctx trägt baseElevation, joins, sichtbare Codes, resolver für Components/Styles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Wand (Wall) — mehrschichtig
|
||||
|
||||
### 1.1 Daten
|
||||
```ts
|
||||
interface Wall extends ElementBase {
|
||||
type: "wall";
|
||||
start: Vec2; end: Vec2; // Achse (Centerline) im Grundriss [im Spike]
|
||||
wallTypeId: string; // → WallType.layers (außen→innen)
|
||||
height: number;
|
||||
reference: "mid" | "left" | "right"; // Referenzlage der Achse (DOSSIER _wand_referenz)
|
||||
baseOffset?: number; // UK relativ zu OKFF (default 0)
|
||||
topOffset?: number; // OK-Override (default = floorHeight)
|
||||
jointRole?: "auto" | "through" | "butt"; // T-Stoss-Rolle (DOSSIER wand_joint_rolle)
|
||||
// Mehrsegment-Wände (Polyline): optional axisPoints statt start/end
|
||||
axisPoints?: Vec2[];
|
||||
}
|
||||
```
|
||||
`reference` verschiebt die Achse auf Außenkante/Mitte (DOSSIER
|
||||
`_wall_offsets_from_referenz`): hilft beim Modellieren *und* beim Import fremder
|
||||
Pläne (ROADMAP §11). Offsets: `mid → [+T/2, -T/2]`, `left → [0, -T]`, `right → [+T, 0]`.
|
||||
|
||||
### 1.2 Generierung — 3D + Plan (Status: im Spike, einschichtig→mehrschichtig ✅)
|
||||
Beide Sichten extrudieren/füllen **dasselbe gehrte Band-Polygon** pro Schicht.
|
||||
Heute schon vorhanden:
|
||||
- `geometry.clippedBand(start, end, offA, offB, startCut, endCut)` — Band mit
|
||||
Gehrungsschnitt.
|
||||
- `generatePlan.addWallPoche` — pro Schicht ein gefülltes Polygon (Component-Fill +
|
||||
Schraffur), Öffnungen ausgespart.
|
||||
- `Viewport3D.addLayerPrism` — dasselbe Polygon via `ExtrudeGeometry`.
|
||||
|
||||
### 1.3 Wand-Verschneidung (Joins) — Risiko #1
|
||||
|
||||
**Status: L-Ecken-Gehrung ✅** (`joins.computeJoins` → `miterLine`, robust gegen
|
||||
Wicklung + ungleiche Dicken). **Offen: Prioritäts-T-/X-Stöße** bei mehrschichtigen
|
||||
Wänden.
|
||||
|
||||
DOSSIERs gelöste Logik (`elemente._t_junction_layer_overrides`, `_wand_should_apply_t_miter`),
|
||||
die wir portieren:
|
||||
|
||||
1. **Knoten finden:** Endpunkte auf Gitter runden (`roundKey`, existiert),
|
||||
gruppieren. `==1` freies Ende, `==2` L-Ecke (Gehrung, ✅), `>2` T/X.
|
||||
2. **Through-Wand bestimmen:** an einem T-Stoß läuft genau **eine** Wand durch.
|
||||
Auswahl nach `jointRole` (DOSSIER-Regel), sonst nach Component-`joinPriority`:
|
||||
```
|
||||
my.role="through" → ich laufe durch (kein Miter)
|
||||
my.role="butt" → ich stoße an (Miter)
|
||||
beide "auto" → höhere joinPriority = Through-Wand
|
||||
```
|
||||
3. **Schicht-Durchdringung (Backbone):** **nur das Material mit der höchsten
|
||||
gemeinsamen `joinPriority`** in *beiden* Wänden läuft durch und unioniert
|
||||
(T-Form). Beispiel ROADMAP §2d: Beton (800) läuft mittig durch; Putze (100)
|
||||
verbinden sich seitlich, gehen aber nirgends durch den Beton. Alle Nicht-
|
||||
Backbone-Schichten der anstoßenden Wand mitern an der Through-Außenkante
|
||||
(`standard_miter`). Ergebnis: gleichfarbige Außenlagen bilden automatisch
|
||||
saubere L-Stöße.
|
||||
|
||||
```ts
|
||||
// joins.ts — Erweiterung der bestehenden API
|
||||
interface WallCuts { startCut: Line|null; endCut: Line|null;
|
||||
// neu: pro-Schicht Overrides am T-Stoss
|
||||
layerExtensions?: number[]; // wie weit jede Schicht in Through-Body drillt
|
||||
layerMiters?: (Line|null)[]; // pro-Schicht Mitre (null = Backbone, läuft durch)
|
||||
}
|
||||
function computeJoins(project, walls): Map<string, WallCuts> // erweitert
|
||||
```
|
||||
|
||||
**Implementierungsplan (stufenweise, Risiko #1):**
|
||||
- (a) ✅ L-Gehrung bleibt.
|
||||
- (b) T-Stoß ohne Schichten: Backbone = ganze Wand; Through union, Stem mitert.
|
||||
- (c) T-Stoß mit Schichten: Backbone-Material-Logik wie oben (Port von
|
||||
`_t_junction_layer_overrides`).
|
||||
- (d) X-Stoß: paarweise als zwei T behandeln.
|
||||
- **Booleans:** Union/Extension der Backbone-Säule via **OpenCascade.js/Manifold**
|
||||
im Worker (`workers/geometry.worker.ts`), nur für 3D + exakten B-Rep-Export; der
|
||||
2D-Plan bleibt rein analytisch (Polygon-Clipping, kein Kernel) — schnell.
|
||||
- **Validierung:** Screenshot-Probe der T-Ecke (Beton durch, Putz seitlich).
|
||||
|
||||
### 1.4 Grip-Editing (Risiko #L, Phase 3–4)
|
||||
DOSSIER: Display-Conduit zeichnet dicke Marker an Achs-Endpunkten, MouseCallback
|
||||
fängt Klick → `GetPoint` mit Snap → `_replace_axis_vertex` → Volumen regeneriert
|
||||
(`wand_grips.py`). Browser-Port:
|
||||
- **Marker:** SVG-Kreise (r ≈ 7 px) an Endpunkten/Knicks der *selektierten* Wand,
|
||||
als Overlay über dem Plan (unabhängig von Ebenen-Sichtbarkeit) — exakt DOSSIERs
|
||||
Conduit-Idee.
|
||||
- **Hit-Test:** Pointer-Distanz < 14 px (DOSSIER `_HIT_RADIUS_PX`).
|
||||
- **Drag:** `pointerdown` auf Marker → Live-Preview-Linien zu Nachbar-Vertices →
|
||||
Snap (Endpunkt/Ortho/Raster) → `pointerup` → `store.apply(p => wall.start = newPt)`.
|
||||
Abgeleitete Sichten (Plan + 3D) re-derivieren reaktiv — kein manuelles Regen.
|
||||
- Funktioniert für Line (2 Grips) und Polyline (jeder Knick ein Grip), wie DOSSIER.
|
||||
|
||||
---
|
||||
|
||||
## 2. Öffnungen (Window / Door) — gehostet, LoD
|
||||
|
||||
### 2.1 Daten
|
||||
```ts
|
||||
interface OpeningBase extends ElementBase {
|
||||
hostWallId: string; // Host-Wand (Geschoss ergibt sich daraus) [im Spike]
|
||||
position: number; // Abstand entlang Wandachse vom Wand-Start (m)
|
||||
width: number; height: number;
|
||||
reference: "mid" | "left" | "right"; // Lage des Klickpunkts in der Öffnung
|
||||
detailLevel: DetailLevel | "auto";
|
||||
frame?: { width: number; depth: number; pos: "outer"|"mid"|"inner"; offset: number };
|
||||
outerSide: "left" | "right"; // welche Wandseite ist außen
|
||||
}
|
||||
interface Window extends OpeningBase {
|
||||
type: "window";
|
||||
sill: number; // Brüstungshöhe
|
||||
sashes: 1|2|3|4; // Flügelzahl
|
||||
sillProfileOut?: "none"|"narrow"|"standard"|"wide"; // Sims außen (DOSSIER _OEFF_SIMS_STYLES)
|
||||
sillProfileIn?: "none"|"narrow"|"standard"|"wide";
|
||||
glass: boolean;
|
||||
}
|
||||
interface Door extends OpeningBase {
|
||||
type: "door";
|
||||
swing: "left" | "right"; // Anschlagseite [im Spike]
|
||||
hinge: "start" | "end"; // Scharnierpfosten [im Spike]
|
||||
openAngle: number; // Plan-Öffnungswinkel 0–180 (default 90)
|
||||
doorType: "normal" | "wall-opening"; // Wandöffnung = ohne Blatt
|
||||
frameType: "casing" | "block"; // Zarge | Blockrahmen
|
||||
lintel?: "none"|"inner"|"outer"|"both";// Sturzlinien-Anzeige (DOSSIER _OEFF_STURZ)
|
||||
}
|
||||
```
|
||||
Felder 1:1 aus DOSSIERs `_OEFF_*`-Keys + `_OEFF_STYLE_FIELDS`. Presets (Fenster
|
||||
Standard/Gross/Bandlage, Tür Innen/Eingang/Verglast, Wandöffnung) als
|
||||
Style-Katalog (resources-graphics.md), seed wie `_OEFF_DEFAULT_STYLES`.
|
||||
|
||||
### 2.2 Host-Beziehung (Risiko #2)
|
||||
Die Öffnung kennt ihre Wand (`hostWallId`); ihre Geometrie wird **relativ zur
|
||||
Wandachse** berechnet (`opening.axisFrame(wall, position)` → Punkt + Tangente +
|
||||
Normale, ≙ DOSSIER `_oeff_axis_frame`). Verschiebt sich die Wand, folgt die
|
||||
Öffnung automatisch (sie hält keinen absoluten Punkt). Beim Plan/3D wird die
|
||||
Wand an `[position, position+width]` ausgespart — steht im Spike (`addWallPoche`
|
||||
Segmentierung, `addWallMeshes` Sturz).
|
||||
|
||||
### 2.3 Generierung nach LoD
|
||||
| LoD | Plan-Symbol | 3D |
|
||||
|---|---|---|
|
||||
| **coarse** (1:200/500) | Öffnung als Lücke + dünne Linie | Aussparung, kein Rahmen |
|
||||
| **medium** (1:100) | + Rahmenlinien, Tür-Schwenkbogen (`addDoorSymbol` ✅), Sturz gestrichelt | Aussparung + einfacher Rahmen-Quader |
|
||||
| **fine** (1:50) | + Glas-Doppellinie, Sims, Flügel-Teilung, Anschlag | Rahmen + Blatt + Glas (transparent) + Sims (DOSSIER `_OEFF_PIECE_DEFS`) |
|
||||
|
||||
- **Tür-Schwenkbogen:** im Spike (`generatePlan.addDoorSymbol` — Blatt + Arc).
|
||||
Ausbau: `openAngle`, lichte vs. volle Breite je LoD (Port von
|
||||
`_make_tuer_swing_curves`).
|
||||
- **3D-Stücke** (Rahmen/Glas/Flügel/Sims/Sturz) ≙ DOSSIER `_make_oeffnung_pieces`
|
||||
/ `_OEFF_PIECE_DEFS` — jeweils eigene Component (Farbe + Transparenz: Glas
|
||||
α≈0.88, IOR 1.5). Pieces landen auf Unter-Ebenen von `21 Türen/Fenster`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Decke / Boden (Slab) — mit Aussparungen
|
||||
|
||||
### 3.1 Daten
|
||||
```ts
|
||||
interface Slab extends ElementBase {
|
||||
type: "slab";
|
||||
boundary: Vec2[]; // geschlossener Umriss (CCW)
|
||||
slabTypeId: string; // mehrschichtig (analog WallType)
|
||||
openings?: Vec2[][]; // Aussparungen: Treppenauge, Schacht, Kamin (DOSSIER aussp)
|
||||
ukOverride?: number; okOverride?: number; // UK/OK statt auto (Abhängung, schräge Brüstung)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Generierung
|
||||
- **Z-Auflösung:** `okOverride ?? (baseElevation_oberes_Geschoss)`,
|
||||
`ukOverride ?? (ok - thickness)` — Port von `_resolve_decke_z`. Decke sitzt
|
||||
standardmäßig zwischen zwei Geschossen.
|
||||
- **3D:** `boundary` als `THREE.Shape`, Aussparungen als `shape.holes` (`THREE.Path`),
|
||||
`ExtrudeGeometry` über die Schichten (≙ `_make_decke_volume(outline, holes)`).
|
||||
- **Plan:** im Schnitt unter `cutHeight` meist nur Kante; Aussparungs-Ränder als
|
||||
Linien; geschnittene Decke (in Schnitt-Ansicht) bekommt Schraffur.
|
||||
- **Aussparung↔Decke:** Aussparung als geschlossene Curve, die räumlich in der
|
||||
Decke liegt (`_find_decke_containing_point` / `_find_aussparungen_for_decke`).
|
||||
Bei uns: `Slab.openings` direkt im Slab — keine separate Source nötig (einfacher
|
||||
als DOSSIERs Parent-Child).
|
||||
|
||||
---
|
||||
|
||||
## 4. Treppe (Stair) — Typen, Lauflinie, geschossübergreifend
|
||||
|
||||
### 4.1 Daten
|
||||
```ts
|
||||
interface Stair extends ElementBase {
|
||||
type: "stair";
|
||||
kind: "straight" | "l-shaped" | "spiral"; // gerade | L | Wendel (DOSSIER _TREPPE_ARTEN)
|
||||
run: Vec2[]; // Lauflinien-Stützpunkte (gerade: 2; L: 3; Wendel: Zentrum+Start)
|
||||
width: number;
|
||||
reference: "mid" | "left" | "right"; // Lage der Lauflinie zur Treppe
|
||||
steps: number; // Anzahl Steigungen
|
||||
mode: "solid" | "flat" | "slab-edge"; // massiv | flach | Plattenrand
|
||||
runSlabThickness?: number; // Lauf-Plattendicke
|
||||
floorEndId?: string; // Zielgeschoss (geschossübergreifend, Risiko #6)
|
||||
heightOverride?: number; ukOverride?: number;
|
||||
rules?: { riser:[lo,hi,on]; tread:[lo,hi,on]; stepGo:[lo,hi,on] }; // SIA-Komfortregeln
|
||||
lockRiser?: { value: number }; // Schrittmass-Lock (S fix, N passt sich an)
|
||||
// Plan-Symbol-Flags (DOSSIER _KEY_TREPPE_SHOW_*)
|
||||
show?: { treads; runLine; outline; breakLine };
|
||||
upperDashed?: boolean; // obere Stufen gestrichelt (über Schnitthöhe)
|
||||
arrowStyle?: "classic"|"filled"|"double"|"line";
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Generierung
|
||||
- **Steigung/Auftritt:** `riser = height/steps`; `tread` aus Lauflinienlänge /
|
||||
(steps−1). SIA-Komfort: `2·riser + tread ∈ [0.60, 0.65]` (DOSSIER
|
||||
`_TREPPE_SOLL_DEFAULT`). Lock: ist `lockRiser` gesetzt, wird `steps` neu
|
||||
berechnet statt `riser` zu ändern.
|
||||
- **3D je `kind`:** gerade → Stapel von Tritt-Quadern oder massive Rampe;
|
||||
L → zwei Läufe + Podest (`podestMin`); Wendel → um Zentrum rotierte Tritte
|
||||
(Port `_make_treppe_*_preview` / Volume-Funktionen). `mode` steuert massiv vs.
|
||||
Lauf-Platte.
|
||||
- **Geschossübergreifend (Risiko #6):** Höhe = `(baseElevation[floorEndId] -
|
||||
baseElevation[floorId])` falls `floorEndId` gesetzt; sonst Geschosshöhe. Treppe
|
||||
taucht dann in beiden Geschoss-Grundrissen auf (mit Schnitt an `cutHeight`).
|
||||
- **Plan-Symbol (normgerecht):** Lauflinie mit **Auf-/Abpfeil** (`arrowStyle`),
|
||||
Stufenkanten, Bruchlinie an `cutHeight` (untere durchgezogen, obere gestrichelt
|
||||
via `upperDashed`), Außenkante. ≙ DOSSIERs 2D-Treppensymbol; liegt auf Ebene
|
||||
`40 Treppen`/`41 Treppen-2D`.
|
||||
|
||||
### 4.3 Grip-Editing
|
||||
Lauflinien-Stützpunkte als Grips (wie Wand-Vertices, §1.4); Ziehen ändert
|
||||
Geometrie + Stufenzahl reaktiv.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dach (Roof)
|
||||
|
||||
### 5.1 Daten
|
||||
```ts
|
||||
interface Roof extends ElementBase {
|
||||
type: "roof";
|
||||
outline: Vec2[]; // Grundriss-Umriss
|
||||
roofType: "mono" | "gable" | "hip" | "mansard"; // Pult|Sattel|Walm|Mansarde
|
||||
thickness: number;
|
||||
slope: number; // Grad (Hauptneigung)
|
||||
eaveIndex?: number; // Index der Traufkante (Pult)
|
||||
ridge?: "long" | "short"; // Firstrichtung (Sattel)
|
||||
// Mansarde:
|
||||
slopeLower?: number; kinkHeight?: number;
|
||||
mansardVariant?: "hip" | "gable" | "hip-gable";
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Generierung
|
||||
Port von DOSSIERs `_make_pultdach/_satteldach/_walmdach/_mansardendach*` +
|
||||
`_thicken_roof_inward`. Aufwand M–L (Mansarde später). Reihenfolge: Pult →
|
||||
Sattel → Walm → Mansarde. 3D als Brep/Mesh über OpenCascade.js (Worker), da
|
||||
Schräg-Verschneidung Booleans braucht. Plan: Firstlinien + Traufe + ggf.
|
||||
Höhenkoten.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tragwerk (Column / Beam)
|
||||
|
||||
### 6.1 Daten
|
||||
```ts
|
||||
interface ProfileDef {
|
||||
shape: "square"|"rect"|"round"|"i-beam"|"tube"; // DOSSIER _TRAG_PROFILE
|
||||
b?: number; h?: number; d?: number; t?: number; // Breite/Höhe/Durchm./Wanddicke
|
||||
angle: number; // Rotation um Z
|
||||
}
|
||||
interface Column extends ElementBase { type:"column"; point: Vec2; profile: ProfileDef;
|
||||
uk?: number; ok?: number; }
|
||||
interface Beam extends ElementBase { type:"beam"; axis:[Vec2,Vec2]; profile: ProfileDef;
|
||||
zTop?: number; // hängt unter Decken-OK (zTop = ok der Decke)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Generierung
|
||||
- **Querschnitt:** `profileCurve(shape, b,h,d,t, angle)` (Port `_trag_profile_curve`)
|
||||
→ für Stütze entlang Z extrudieren (`_make_stuetze_volume`), für Träger entlang
|
||||
der Achse (`_make_traeger_volume`, Profil in der Schnitt-Ebene).
|
||||
- **Träger achs-basiert unter Decke:** `zTop` default = OK der darüberliegenden
|
||||
Decke → Unterzug folgt automatisch (weniger Update-Fehler, ROADMAP §11).
|
||||
- Stützen liegen auf `25 Stützen`, Träger auf `35 Träger`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Raum (Space) — SIA-416 + Stempel
|
||||
|
||||
### 7.1 Daten
|
||||
```ts
|
||||
interface Space extends ElementBase {
|
||||
type: "space";
|
||||
boundary: Vec2[]; // geschlossener Umriss
|
||||
number?: string; spaceName?: string; function?: string;
|
||||
sia?: "" | "HNF"|"NNF"|"VF"|"FF"|"GF"|"AGF"; // SIA-416-Klasse
|
||||
persons?: number; // Personenbelegung (Brandschutz)
|
||||
areaRounding: "exact"|"0.01"|"0.1"|"0.5"|"1";
|
||||
stamp: StampConfig; // Raumstempel-Layout (s.u.)
|
||||
fill?: string; // Füll-Hatch-Id (optional)
|
||||
}
|
||||
interface StampConfig { // ≙ DOSSIER Stempel-Builder
|
||||
layout: FieldId[][]; // Zeilen × Felder, z.B. [["number","name"],["function"],["area"]]
|
||||
font; bold; italic; textHeight; textMode:"fixed"|"scale"; align:"left"|"mid"|"right";
|
||||
offset: Vec2; // Stempel-Position relativ zum Centroid (User-Move)
|
||||
}
|
||||
type FieldId = "number"|"name"|"function"|"area"|"sia";
|
||||
```
|
||||
|
||||
### 7.2 Generierung & Bilanz
|
||||
- **Fläche:** Shoelace-Formel über `boundary`, gerundet nach `areaRounding`
|
||||
(`_resolve_raum_rundung`). Umfang analog.
|
||||
- **Stempel:** als SVG-Text-Block aus `layout`-Zeilen am Centroid + `offset`
|
||||
(User kann verschieben; Offset persistiert wie DOSSIER `stamp_dx/dy`).
|
||||
`textMode:"scale"` → Texthöhe in Paper-mm × Massstab (plans-output.md).
|
||||
- **SIA-Färbung:** über die **Overrides-Engine** (regelbasiert), nicht hartcodiert
|
||||
— DOSSIER `_build_sia_preset_rules` erzeugt 4 Regeln `userString sia == hnf|nnf|vf|ff`
|
||||
→ Farbe + Solid-Hatch. Bei uns: ein Override-Preset „SIA-416" (resources-graphics.md),
|
||||
das auf `space.sia` matcht. Toggle = Preset aktivieren.
|
||||
- **SIA-Bilanz + CSV:** `panels/SiaBalance.tsx` summiert Flächen je Klasse je
|
||||
Geschoss → Tabelle + CSV-Export (`HNF/NNF/VF/FF/GF/AGF`). Pflicht für
|
||||
CH-Flächennachweis (ROADMAP ⭐).
|
||||
|
||||
---
|
||||
|
||||
## 8. Werkzeuge (Tools) — ersetzt Rhino-Command-Aliases
|
||||
|
||||
DOSSIER hat pro Bauteil ein Command-Alias (`rhino/aliases/cmd/wand.py`, `tuer.py`,
|
||||
`treppe.py`, …) das `GetPoint`-Interaktionen fährt. Browser: ein **Tool-Interface**
|
||||
mit Pointer-Handlern + Snap.
|
||||
|
||||
```ts
|
||||
interface Tool {
|
||||
id: ToolId;
|
||||
onPointerDown(pt: Vec2, snap: SnapResult, state): void;
|
||||
onPointerMove(pt: Vec2, snap: SnapResult, state): Primitive[]; // Live-Preview
|
||||
onPointerUp(pt: Vec2, snap: SnapResult, state): void;
|
||||
commit(store): void; // ruft store.apply()
|
||||
}
|
||||
```
|
||||
|
||||
| Tool | DOSSIER-Alias | Kurzbeschrieb |
|
||||
|---|---|---|
|
||||
| `wall` | `cmd/wand` | Achse zeichnen (Linie/Polyline), Dicke/Referenz/Typ aus „last used" |
|
||||
| `door`/`window` | `cmd/tuer`,`fenster` | Punkt auf Wandachse → hosten (Snap an Wand) |
|
||||
| `slab` | `cmd/decke` | Umriss klicken; Aussparung als Loch |
|
||||
| `stair` | `cmd/treppe` | Lauflinie + Breite + Stufen |
|
||||
| `roof` | `cmd/dach` | Umriss + Typ + Neigung |
|
||||
| `column`/`beam` | `cmd/stuetze`,`traeger` | Punkt / Achse + Profil |
|
||||
| `space` | `cmd/raum` | Umriss → Fläche auto, Stempel |
|
||||
| `draw2d` | `cmd/symbol`,`stempel` | Linie/Polyline/Rect/Kreis/Bogen/Text auf `60 Plangrafik` |
|
||||
| `pipette` | `cmd/pipette` | Stil/Typ von Element übernehmen |
|
||||
|
||||
**Snap-Engine** (`tools/snap.ts`): Endpunkt, Mitte, Schnitt, senkrecht, Raster,
|
||||
Ortho — ersetzt Rhinos OSnap. T-Snap an andere Wandachsen (Port
|
||||
`_t_snap_to_wand_axis`, `_snap_endpoint_to_other_wand_axis`) sorgt für saubere
|
||||
Knoten.
|
||||
|
||||
---
|
||||
|
||||
## 9. Element-Übersicht (BIM-Tree)
|
||||
`panels/ElementTree.tsx`: Baum Geschoss → Bauteiltyp → Element, mit Suche und
|
||||
Shift-Klick = Zoom (DOSSIER ELEMENTE-ÜBERSICHT). Inhaltsverzeichnis bei 100+
|
||||
Elementen — reine Ableitung aus `project.elements`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Reihenfolge der Umsetzung (verweist auf ROADMAP-Phasen)
|
||||
|
||||
1. **Phase 1:** Wand mehrschichtig ✅ + L-Gehrung ✅ → **Prio-T-Stoß** (§1.3);
|
||||
Tür/Fenster gehostet (§2); Decke + Aussparung (§3); Wand-Referenzlage (§1.1);
|
||||
Element-Übersicht (§9).
|
||||
2. **Phase 2:** Treppe (§4), Dach (§5), Tragwerk (§6), SIA-Räume + Stempel (§7),
|
||||
Stil-Kataloge.
|
||||
3. **Phase 3–4:** Grip-Editing (§1.4/§4.3), exakte B-Rep-Booleans im Worker.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Engine-Nordstern — Headless-Rendering (PNG-Export & Golden-Image-Tests)
|
||||
|
||||
> Betrifft `src-tauri/render2d` (Feature `headless`). Bezug: HANDOVER.md,
|
||||
> Abschnitt ENGINE-NORDSTERN, Punkt 3 ("deterministisches Headless-Rendering,
|
||||
> PNG-Export und Golden-Image-Tests ohne Fenster").
|
||||
|
||||
## Warum
|
||||
|
||||
`render2d` trennt die serde-only-Tessellierung (Feature-los, headless testbar)
|
||||
von der GPU-Schicht (Feature `render`, wgpu). Der Fenster-Pfad (`gpu::Renderer`,
|
||||
Feature `window`) braucht dafür bislang eine `wgpu::Surface` — also ein echtes
|
||||
Fenster mit Wayland-/X11-Session. Für deterministische PNG-Exporte und
|
||||
Golden-Image-Tests (CI, Regressions-Screenshots) ist das unnötig: wgpu kann
|
||||
genauso gut in eine `wgpu::Texture` rendern, ganz ohne Surface/Fenster.
|
||||
|
||||
`gpu::Renderer::render` nahm bereits vorher nur `Device`/`Queue`/`TextureView`
|
||||
entgegen — Surface- oder Offscreen-Textur macht für den Draw-Code keinen
|
||||
Unterschied. Der Offscreen-Pfad (`src/headless.rs`) dupliziert daher NICHTS,
|
||||
sondern baut nur Device/Queue ohne Surface sowie eine eigene Ziel-Textur +
|
||||
Buffer-Readback drumherum.
|
||||
|
||||
## Feature-Gating
|
||||
|
||||
Neues Cargo-Feature `headless = ["render", "dep:image"]`:
|
||||
- zieht `render` (wgpu/glyphon/bytemuck/pollster) plus die `image`-Crate
|
||||
(nur der PNG-Codec, `default-features = false, features = ["png"]`).
|
||||
- **berührt den wasm/web-Build nicht**: `cargo check --target wasm32-unknown-unknown
|
||||
--no-default-features --features web` zieht `image` nicht mit.
|
||||
- Binary `render_png` und Test `golden` sind zusätzlich per
|
||||
`required-features = ["headless"]` in `Cargo.toml` abgesichert.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
pub struct HeadlessRenderer { /* Device, Queue, gpu::Renderer */ }
|
||||
|
||||
impl HeadlessRenderer {
|
||||
/// Instance/Adapter/Device OHNE Surface (Backend fest auf Vulkan gepinnt).
|
||||
/// `Err`, wenn kein Adapter verfügbar ist (z.B. CI-Runner ohne GPU) — die
|
||||
/// Aufrufer (CLI, Golden-Test) behandeln das, statt zu paniken.
|
||||
pub fn new() -> Result<Self, String>;
|
||||
|
||||
/// Rendert `scene` in ein `width`x`height`-Bild (Papier-Maßstab
|
||||
/// `paper_scale_n`, z.B. `100.0` für 1:100). Lädt die Szene bei jedem Aufruf
|
||||
/// neu hoch (kein Zwischenzustand nötig für CLI-/Test-Anwendungsfall).
|
||||
pub fn render_to_image(
|
||||
&mut self,
|
||||
scene: &Scene,
|
||||
width: u32,
|
||||
height: u32,
|
||||
view_box: ViewBox,
|
||||
paper_scale_n: f32,
|
||||
) -> RgbaImage; // { width, height, pixels: Vec<u8> (straff gepackt, RGBA8) }
|
||||
}
|
||||
|
||||
impl RgbaImage {
|
||||
pub fn encode_png(&self) -> Vec<u8>;
|
||||
/// Gegenstück für den Golden-Test: Referenz-PNG -> straff gepacktes RGBA8.
|
||||
pub fn decode_png(bytes: &[u8]) -> Result<Self, String>;
|
||||
}
|
||||
```
|
||||
|
||||
Backend bewusst auf **Vulkan** gepinnt (nicht das Standard-Backend-Set): Vulkan
|
||||
rendert offscreen ohne jede Fenster-/Display-Server-Abhängigkeit. GL bräuchte auf
|
||||
Linux i.d.R. einen EGL/GLX-Kontext, der ohne aktive Display-Session (X11/Wayland)
|
||||
Sonderfälle hat — auf dieser Maschine ist ohnehin ein echter Vulkan-ICD (RADV)
|
||||
vorhanden, daher kein Rückgriff auf `lavapipe`/`llvmpipe` nötig gewesen.
|
||||
|
||||
## Row-Alignment-Falle (wichtig für zukünftige Agenten)
|
||||
|
||||
`wgpu::Queue::copy_texture_to_buffer` / `CommandEncoder::copy_texture_to_buffer`
|
||||
verlangt, dass `bytes_per_row` im `ImageDataLayout` ein Vielfaches von
|
||||
`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT` (256) ist. Bei RGBA8 (4 Byte/Pixel) trifft
|
||||
das **nur zufällig** zu — z.B. Breite 300 px → 1200 Byte/Zeile, kein Vielfaches
|
||||
von 256, der Copy schlägt sonst mit einem Validierungsfehler fehl (oder liefert
|
||||
verzerrte Zeilen, je nach Backend).
|
||||
|
||||
Lösung in `headless::HeadlessRenderer::read_pixels`:
|
||||
1. `padded_bytes_per_row = align_up(width * 4, 256)` — der Zielpuffer wird auf
|
||||
diese (größere oder gleiche) Zeilenbreite alloziert.
|
||||
2. Nach dem Mapping wird **jede Zeile einzeln** von `padded_bytes_per_row` auf
|
||||
die echte Breite (`width * 4`) zurückgeschnitten und in einen straff
|
||||
gepackten `Vec<u8>` kopiert — sonst hätte das Ausgabebild pro Zeile
|
||||
Garbage-Padding am rechten Rand.
|
||||
|
||||
## Referenzbild neu erzeugen
|
||||
|
||||
Bei einer **gewollten** Rendering-Änderung (z.B. neue Shader, neue Demo-Szene,
|
||||
geänderte Antialiasing-Parameter) weicht das Golden-Bild ab. Referenzbild
|
||||
danach bewusst neu erzeugen:
|
||||
|
||||
```sh
|
||||
cd src-tauri/render2d
|
||||
cargo run --features headless --bin render_png -- --width 990 --height 630 --out target/headless-demo.png
|
||||
cp target/headless-demo.png tests/golden/demo.png
|
||||
```
|
||||
|
||||
Vorher unbedingt das erzeugte PNG (`target/headless-demo.png`) visuell prüfen
|
||||
(z.B. mit einem Bildbetrachter oder Read-Tool eines Agenten) — nicht blind
|
||||
kopieren. Bei einem fehlgeschlagenen Testlauf schreibt der Golden-Test
|
||||
automatisch ein Diff-Bild nach `target/golden-diff.png` (abweichende Pixel
|
||||
signalrot markiert, Rest unverändert) — das hilft beim Einordnen, ob die
|
||||
Abweichung erwartet (Layout-/Farbänderung) oder ein Bug ist (z.B. verschobene
|
||||
Geometrie, fehlender Text).
|
||||
|
||||
## Golden-Test
|
||||
|
||||
`tests/golden.rs`, Test `demo_szene_entspricht_referenzbild`:
|
||||
- rendert die Demo-Szene (`demo::demo_scene`, dieselbe wie der Fenster-Spike)
|
||||
in 990×630 und vergleicht sie gegen `tests/golden/demo.png`.
|
||||
- Toleranz: ein Pixel gilt als "abweichend", wenn irgendein Kanal-Delta > 2 ist
|
||||
(deckt AA-Rundungsrauschen ab); der Test schlägt fehl, wenn mehr als 0,5%
|
||||
aller Pixel abweichen.
|
||||
- `#[ignore]`, weil eine Vulkan-fähige GPU auf CI-Runnern nicht garantiert ist
|
||||
(und ein Software-Rasterizer wie llvmpipe anderes AA liefern würde als das
|
||||
Referenzbild). Lokal explizit ausführen:
|
||||
`cargo test --features headless -- --include-ignored`.
|
||||
- Bei Abweichung über der Toleranz schreibt der Test zusätzlich zum Diff-Bild
|
||||
auch das Ist-Bild nach `target/golden-actual.png` — nach visueller Prüfung
|
||||
ist das die Vorlage für eine gewollte Referenz-Aktualisierung.
|
||||
- `HeadlessRenderer::new()` liefert bei fehlendem Adapter `Err` statt Panik —
|
||||
der Test überspringt sich dann sauber (`eprintln!` + `return`) statt rot zu
|
||||
schlagen, falls doch mal ohne `--ignored`/ohne GPU ausgeführt.
|
||||
|
||||
## Offener Punkt: Text-Determinismus zwischen GPU-Treibern
|
||||
|
||||
Der Textpass läuft über `glyphon`/`cosmic-text` (Systemfonts, `Inter`). Die
|
||||
exakte Glyphen-Rasterisierung (Subpixel-Antialiasing, Hinting) kann je nach
|
||||
installierter Font-Version, Fontconfig-Konfiguration und GPU-Treiber leicht
|
||||
variieren — auch bei identischer Geometrie. Auf **derselben** Maschine (gleicher
|
||||
Treiber, gleiche Font-Version) ist der Test wie erwartet bit-nah deterministisch
|
||||
(hier: 0 abweichende Pixel bei zwei aufeinanderfolgenden Läufen). Bei einem
|
||||
Wechsel der Maschine/des Treibers/der Fontconfig-Version ist nicht
|
||||
auszuschließen, dass die 0,5%-Toleranz für Textkanten knapp wird — sollte das
|
||||
in der Praxis auftreten: entweder Toleranz für den Text-Bereich lockern, oder
|
||||
den Text testweise aus der Golden-Szene ausklammern und separat (z.B. nur
|
||||
Zeilenbreite/Position, nicht Pixel) prüfen.
|
||||
@@ -0,0 +1,511 @@
|
||||
# Engine-Nordstern 1 — Strichbreiten-Audit (Papier-mm end-to-end)
|
||||
|
||||
> Bezug: HANDOVER.md, Abschnitt ENGINE-NORDSTERN Punkt 1 ("Papier-mm-exakte
|
||||
> Strichbreiten überall — Bildschirm bei jedem Massstab/Zoom = Druck"). Dieses
|
||||
> Dokument ist ein Prüfbericht, kein Umbau — es fasst zusammen, wie die
|
||||
> Strichbreite heute vom Modell bis zum Papier läuft, wo sie divergieren kann,
|
||||
> und was konkret zu tun wäre. Nichts hieraus wurde umgesetzt.
|
||||
|
||||
Betroffene Pfade: `src/plan/PlanView.tsx` (SVG-Default + Print-Vorschau),
|
||||
`src/plan/glPlan/*` (WebGL2, aktueller Default-GPU-Pfad), `src-tauri/render2d`
|
||||
(WASM/WebGPU, `?engine=wasm`), `src/export/sceneToPrintSvg.ts` +
|
||||
`src/export/exportPdf.ts` (Vektor-PDF). Referenz-Baseline für die
|
||||
2D-Engine-Parität ist laut Commit `ce6bd26` **`?gl=0`** (reiner SVG-Pfad) —
|
||||
NICHT der App-Default (der ist WebGL2, siehe Finding 1).
|
||||
|
||||
## 1. Modell der Wahrheit
|
||||
|
||||
So sollte eine Strichbreite in diesem Code korrekt fliessen:
|
||||
|
||||
1. **Quelle**: `generatePlan.ts` legt jede Strichbreite als `weightMm` /
|
||||
`strokeWidthMm` in **echten Papier-Millimetern** ab (Kommentarkopf,
|
||||
`generatePlan.ts:76-79`: "Alle Stricharten sind in mm Papier definiert").
|
||||
Diese Zahl ist unabhängig von Zoom, Massstab und Render-Pfad — eine 0.18 mm
|
||||
Wand-Umrisslinie bleibt 0.18 mm, ganz gleich wo sie später landet.
|
||||
2. **Bildschirm bei Massstab 1:N**: 1 Modell-Meter entspricht auf Papier
|
||||
`1000/N` mm. Der Bildschirm zeigt `PX_PER_M = 90` viewBox-Einheiten je
|
||||
Modell-Meter (`PlanView.tsx:23`). Eine `mm`-Breite belegt daher
|
||||
`mm · N/1000 · PX_PER_M` viewBox-Einheiten — das ist exakt
|
||||
`printStrokeVb()` (`PlanView.tsx:2598-2600`). Multipliziert mit der
|
||||
Geräte-px-je-viewBox-Einheit-Skala (`meet`, `PlanView.tsx:2074-2078`) ergibt
|
||||
das die tatsächliche Bildschirmbreite in Geräte-Pixeln. Reinzoomen (kleinere
|
||||
viewBox-Breite, grösseres `meet`) macht die Linie dicker — genau wie beim
|
||||
Herausvergrössern eines gedruckten Plans mit der Lupe. Das gilt für JEDE
|
||||
Zoomstufe gleichermassen: 250 % Zoom bei 1:50 zeigt exakt die 5-fache
|
||||
Pixelbreite von 100 % Zoom bei 1:50, und bei gegebenem Zoom ist 1:50 exakt
|
||||
doppelt so dick wie 1:100 (halber Nenner → doppelt so viele Weltmeter je
|
||||
Papiermm).
|
||||
3. **Druck/PDF**: dieselbe Formel, nur ohne Geräte-px-Zwischenschritt — direkt
|
||||
`mm` bleibt `mm` — die Seite ist direkt in echten
|
||||
Papiermillimetern aufgespannt (`sceneToPrintSvg.ts:99-125`). Zusätzlich wird
|
||||
auf ISO-nahe Stiftstufen gerundet (`PEN_STEPS`,
|
||||
`sceneToPrintSvg.ts:44`), weil ein reales Zeichengerät/Plotter nur endlich
|
||||
viele Stiftbreiten kennt.
|
||||
4. **Eine Wahrheit**: Seit `382771b` bauen sowohl der Viewport
|
||||
(`useWasmPlanRenderer.ts`/`nativeSync.ts`) als auch der PDF-Export
|
||||
(`exportPdf.ts:73`) dieselbe `planToRenderScene(plan)`-Szene — der PDF-Pfad
|
||||
ist nur ein anderes *Ziel* derselben Szene, kein zweiter Interpret.
|
||||
|
||||
Korrekt hiesse also: **jede** der vier Anzeige-/Exportarten (SVG-Default,
|
||||
SVG-Print-Vorschau, GPU-Viewport, PDF) muss aus **derselben** `weightMm`, für
|
||||
**denselben** `N`, exakt dieselbe Papier-mm-Breite ergeben — bis auf die
|
||||
bewusste PEN_STEPS-Rundung im Druckpfad, die dokumentiert und überall
|
||||
gleichermassen sichtbar sein sollte (ist sie nicht, siehe Finding 2).
|
||||
|
||||
Ausdrücklich **kein** Teil dieses Modells: der Haarlinien-Modus
|
||||
(`lineMode: "display"`, App-Default, `viewSlice.ts:38,74`). Er ist als
|
||||
bewusster Papier-mm-*Ausstieg* gedacht ("Display: all lines as constant
|
||||
hairlines (calm editing)", `en.ts:173`) — 1 Geräte-px, konstant, unabhängig
|
||||
von Zoom/Massstab. Er muss also NICHT der Papier-mm-Formel folgen, aber er
|
||||
muss in JEDEM Render-Pfad *gleichermassen* als Ausstieg wirken. Tut er nicht
|
||||
(Finding 1).
|
||||
|
||||
## 2. Pfad-für-Pfad-Trace
|
||||
|
||||
### 2a. SVG-Default (App-Start ohne `?gl=0`, `lineMode:"display"`)
|
||||
|
||||
Nur aktiv, wenn WebGL2 fehlschlägt (`wantGl` ist sonst `true`, s. Finding 1) —
|
||||
de facto der reine Fallback-Pfad.
|
||||
|
||||
- `PlanView.tsx:2613`: `print = !hairline && paperScale != null && paperScale > 0`
|
||||
— mit `hairline=true` (Default) ist `print` immer `false`.
|
||||
- `PlanView.tsx:2617-2618`:
|
||||
```ts
|
||||
const weight = (mm: number): number =>
|
||||
hairline ? HAIRLINE_PX : print ? printStrokeVb(mm, paperScale!) : mmToPx(mm);
|
||||
```
|
||||
`HAIRLINE_PX = 1` (`PlanView.tsx:2583`), Einheit: Geräte-unabhängiger
|
||||
CSS-Pixel, gezeichnet mit `vector-effect="non-scaling-stroke"`
|
||||
(`PlanView.tsx:2621`, `vfx`) → bleibt beim Pan/Zoom optisch exakt 1 px, egal
|
||||
wie stark reingezoomt wird. Papier-mm spielt hier explizit KEINE Rolle.
|
||||
|
||||
### 2b. SVG-Print-Vorschau (`lineMode:"print"`, weiterhin `?gl=0` oder
|
||||
WebGL2-Fallback)
|
||||
|
||||
- `print = true`, `weight(mm) = printStrokeVb(mm, paperScale!)`
|
||||
(`PlanView.tsx:2598-2600`):
|
||||
```ts
|
||||
function printStrokeVb(mm: number, n: number): number {
|
||||
return Math.max(1e-4, (mm * n) / 1000) * PX_PER_M;
|
||||
}
|
||||
```
|
||||
`vectorEffect` entfällt (`vfx = undefined`, `PlanView.tsx:2621`) — die Linie
|
||||
skaliert MIT der Geometrie beim Zoomen, wie physisches Papier unter der
|
||||
Lupe. `paperScale` selbst ist der **stabile, gemessene** 1:N-Nenner
|
||||
(`PlanView.tsx:458-460`), nicht der live aus jedem Radzoom abgeleitete Wert
|
||||
— sonst würde Reinzoomen die Linien nicht dicker, sondern konstant halten
|
||||
(das wäre wieder Haarlinien-Verhalten). Keine PEN_STEPS-Rundung — die rohe
|
||||
`mm`-Zahl wird 1:1 in viewBox-Einheiten übersetzt.
|
||||
|
||||
### 2c. GPU-Viewport (WebGL2 `useGlPlanRenderer.ts` — App-DEFAULT; WASM
|
||||
`useWasmPlanRenderer.ts` bei `?engine=wasm`)
|
||||
|
||||
- Beide Hooks reichen `paperScaleN` unverändert an die Engine durch:
|
||||
`useWasmPlanRenderer.ts:131-149` (`render(viewBox, paperScaleN=100,
|
||||
textScaleN=100)` → `r.set_paper_scale(paperScaleN)`); WebGL2 analog
|
||||
(`useGlPlanRenderer.ts:102-116`, kein `textScaleN`-Parameter überhaupt, s.
|
||||
Finding 3).
|
||||
- `PlanView.tsx` ruft in JEDEM Render-Aufruf
|
||||
(`PlanView.tsx:494`,`502`,`1080`) `renderGl(view, paperScaleForGl(view),
|
||||
textScaleForGl())`. `paperScaleForGl` (`PlanView.tsx:475-476`):
|
||||
```ts
|
||||
const paperScaleForGl = (v: ViewBox): number =>
|
||||
paperScaleRef.current ?? scaleFromView(v, svgRef.current) ?? 100;
|
||||
```
|
||||
**Kein `hairline`-Zweig.** Egal ob `lineMode` "display" oder "print" ist,
|
||||
hier kommt immer ein echter 1:N-Papier-Nenner heraus.
|
||||
- Rust-Seite (`gpu.rs:606-616`):
|
||||
```rust
|
||||
let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n);
|
||||
```
|
||||
`mm_to_device_px` (`ortho.rs:96-99`) reproduziert exakt `printStrokeVb`:
|
||||
`(paper_scale_n/1000.0) * PX_PER_M * meet` — `PX_PER_M = 90.0`
|
||||
(`tessellate.rs:22`, identisch zu TS). Die reine Mathematik ist also
|
||||
deckungsgleich zum SVG-Print-Pfad (2b) — aber sie läuft **immer**, auch wenn
|
||||
der Nutzer "Display: Haarlinien" gewählt hat. Siehe Finding 1.
|
||||
- Stiftbreite pro Batch im Shader (`shaders.rs:91`, `LINE_WGSL`):
|
||||
`width_px = max(0.6, stroke_px * stroke_scale) * miter` — harte Untergrenze
|
||||
0.6 Geräte-px, die weder die SVG- noch die PDF-Seite kennt (Finding 6).
|
||||
Identische Formel für Bögen (`shaders.rs:209`, `ARC_WGSL`) und im
|
||||
WebGL2-Pfad (`glPlanRender.ts:243-249`, Kommentar "klemmt bei ~0.6 px").
|
||||
- Schraffur-Musterlinien laufen NICHT über `mm_px`, sondern über
|
||||
`width_screen`/`px_per_screen` (`gpu.rs:661-665`): Geräte-px = Breite ×
|
||||
`meet`-Skala statt × mm→px — bewusst zoom-skalierend wie das SVG-`<pattern>`,
|
||||
nicht papierkonstant (s. `toRenderScene.ts:371-375`, Finding 4).
|
||||
|
||||
### 2d. Vektor-PDF (`exportPdf.ts` → `sceneToPrintSvg.ts`)
|
||||
|
||||
- `exportPdf.ts:73`: `const scene = planToRenderScene(plan);` — dieselbe
|
||||
Szene wie 2c, unabhängig vom aktuell gewählten `lineMode` der Ansicht.
|
||||
- `sceneToPrintSvg.ts:243-244`:
|
||||
```ts
|
||||
const effectiveMm = widthScreen ? (widthMm / PX_PER_M) * mmPerM : widthMm;
|
||||
const strokeMm = quantizePen(effectiveMm);
|
||||
```
|
||||
`PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0]`, `MIN_PEN_MM = 0.13`
|
||||
(`sceneToPrintSvg.ts:44,47`). JEDE Linie/Umriss/Bogen/Schraffur wird auf die
|
||||
nächsthöhere Stufe gerundet, bevor sie ins SVG/PDF geht
|
||||
(`sceneToPrintSvg.ts:251,274,301`). Das ist der EINZIGE der vier Pfade, der
|
||||
überhaupt quantisiert.
|
||||
- `widthScreen`-Konvertierung: `widthMm` (hier eigentlich viewBox-Einheiten,
|
||||
siehe 2c) wird über `/PX_PER_M * mmPerM` zurück in Weltmeter und dann in
|
||||
Papier-mm beim GEWÄHLTEN `opts.scaleDenominator` übersetzt — nicht beim
|
||||
Massstab, den die Live-Ansicht gerade zeigt (Finding 4).
|
||||
- Text: `sizeMm` kommt unverändert aus `RText.sizeMm`
|
||||
(`sceneToPrintSvg.ts:325`, `t.sizeMm`, keine Nachskalierung) — aber die
|
||||
VERTIKALE Zeilenposition dieser Texte wurde in `toRenderScene.ts` mit einem
|
||||
festen `STAMP_REF_N = 100` in Weltmeter umgerechnet (`toRenderScene.ts:163-165,
|
||||
481-483`), unabhängig vom tatsächlich gewählten Export-`N` (Finding 3).
|
||||
|
||||
## 3. Findings (nach Schwere geordnet)
|
||||
|
||||
### 1. Haarlinien-Modus (App-Default) wird vom GPU-Renderer komplett ignoriert — betrifft den Standard-Zustand der App
|
||||
|
||||
`lineMode` startet auf `"display"` (`viewSlice.ts:74`), und WebGL2 ist der
|
||||
Default-Renderer (`wantGl` ist `true`, ausser `?gl=0`,
|
||||
`PlanView.tsx:433-437`) — **d. h. im frisch geladenen, unkonfigurierten App-
|
||||
Zustand rendert bereits der GPU-Pfad, und die "Display: Haarlinien"-Option
|
||||
tut nichts.** `paperScaleForGl` (`PlanView.tsx:475-476`) liest nur
|
||||
`paperScaleRef.current ?? scaleFromView(...) ?? 100` — der `hairline`-Boolean
|
||||
aus den Props (`PlanView.tsx:2571`) wird an dieser Stelle nie geprüft, obwohl
|
||||
das benachbarte `textScaleForGl` (`PlanView.tsx:485-488`) exakt diesen Zweig
|
||||
korrekt hat:
|
||||
```ts
|
||||
const textScaleForGl = (): number => {
|
||||
const print = !hairline && paperScale != null && paperScale > 0;
|
||||
return print ? paperScale! : 100;
|
||||
};
|
||||
```
|
||||
Ergebnis: Text (Raumstempel) fällt im Haarlinien-Modus korrekt auf die
|
||||
Referenzskala 100 zurück, aber jede Wand-/Tür-/2D-Zeichenlinie wird trotzdem
|
||||
mit dem echten (gemessenen oder geschätzten) `paperScale` gezeichnet — sie
|
||||
wird beim Reinzoomen dicker statt konstant zu bleiben, exakt das Gegenteil
|
||||
dessen, was der Menüpunkt verspricht ("calm editing", `en.ts:173`). Der Bug
|
||||
betrifft sowohl `useGlPlanRenderer.ts` (Default) als auch
|
||||
`useWasmPlanRenderer.ts` (`?engine=wasm`) — keiner der beiden `render()`-
|
||||
Aufrufe kennt einen `hairline`-Parameter überhaupt.
|
||||
|
||||
### 2. PEN_STEPS-Quantisierung existiert NUR im PDF-Export — beide Live-Vorschauen (SVG-Print und GPU) zeigen unquantisierte Rohwerte
|
||||
|
||||
Konkret, mit den tatsächlichen Konstanten aus `generatePlan.ts`:
|
||||
|
||||
- `LAYER_LINE_MM = 0.13` (`generatePlan.ts:82`), `LAYER_DETAIL_FACTOR.fein =
|
||||
0.7` (`generatePlan.ts:90-93`) → Schichtfuge im Detailgrad "fein":
|
||||
`0.13 · 0.7 = 0.091 mm` (`generatePlan.ts:1022`,
|
||||
`strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail]`). Die
|
||||
Bildschirm-Print-Vorschau zeigt genau diese 0.091 mm (`printStrokeVb(0.091,
|
||||
N)`); der PDF-Export klemmt via `quantizePen` auf `MIN_PEN_MM = 0.13` mm —
|
||||
**+43 % dicker im Druck als in der Vorschau.**
|
||||
- Türschwenkbogen: `doorLwMm * 0.6` (`generatePlan.ts:902`) mit dem üblichen
|
||||
Fallback `doorLwMm = LAYER_LINE_MM = 0.13` (`generatePlan.ts:354`) ergibt
|
||||
`0.078 mm`. Screen zeigt 0.078 mm (nahezu unsichtbar dünn bei kleinen
|
||||
Massstäben), PDF klemmt auf 0.13 mm — **+67 %.**
|
||||
- Wand-Umriss im Detailgrad "grob": `outlineMm = wallLwMm ·
|
||||
OUTLINE_DETAIL_FACTOR.grob (1.6)` (`generatePlan.ts:84-88, 648, 796`); beim
|
||||
häufigen Fallback `WALL_FALLBACK_MM = 0.18` (`generatePlan.ts:97`) ergibt das
|
||||
`0.288 mm`. `quantizePen(0.288)` rundet auf die nächste Stufe `0.35`
|
||||
(`PEN_STEPS`, da `0.25 < 0.288 ≤ 0.35`) — **+21.5 % im Druck.**
|
||||
|
||||
Das systematische Muster: der Detailgrad "fein" existiert explizit, um
|
||||
Nebenlinien DÜNNER zu machen (`generatePlan.ts:52-58`,
|
||||
`LAYER_DETAIL_FACTOR.fein = 0.7`) — aber sobald der berechnete Wert unter
|
||||
`MIN_PEN_MM` fällt, hebt die PDF-Quantisierung ihn wieder auf die Untergrenze
|
||||
an, ohne dass die Bildschirm-Vorschau (weder SVG-Print noch GPU) davon
|
||||
irgendetwas zeigt. Wer die Druckvorschau am Bildschirm beurteilt, sieht NICHT,
|
||||
was tatsächlich gedruckt wird.
|
||||
|
||||
### 3. Stempel-Zeilenabstand ist nur bei Massstab 1:100 exakt — SVG- und RenderScene-Pfad nutzen zwei verschiedene Formeln für dieselbe Grösse
|
||||
|
||||
Bereits als bekannte Alt-Lücke in HANDOVER.md (Commit `382771b`, Punkt 6)
|
||||
vermerkt; hier präzise verortet:
|
||||
|
||||
- SVG (`PlanView.tsx:2738-2739`):
|
||||
```ts
|
||||
const nRef = print ? paperScale! : 100;
|
||||
const unitPerPt = (1 / 72) * 0.0254 * nRef * PX_PER_M;
|
||||
```
|
||||
`nRef` folgt im Druckmodus dem TATSÄCHLICH aktiven `paperScale` — der
|
||||
Zeilenabstand (`lineGap = baseFs * 1.3`, `PlanView.tsx:2741`) ist für jedes
|
||||
`N` korrekt in viewBox-Einheiten, weil er direkt aus `unitPerPt` (das `N`
|
||||
enthält) abgeleitet wird.
|
||||
- RenderScene (`toRenderScene.ts:163-165`):
|
||||
```ts
|
||||
const STAMP_REF_N = 100;
|
||||
const MM_TO_M = STAMP_REF_N / 1000;
|
||||
```
|
||||
und (`toRenderScene.ts:481-483`):
|
||||
```ts
|
||||
const baseH = baseMm * MM_TO_M; // Basisgröße in Modell-Metern
|
||||
const lineGap = baseH * 1.3;
|
||||
```
|
||||
Hier ist der Umrechnungsfaktor `MM_TO_M` FEST auf `N = 100` verdrahtet,
|
||||
unabhängig davon, mit welchem `N` der WASM-Viewport oder der PDF-Export
|
||||
tatsächlich rendert (`paper_scale_n` in `gpu.rs`, `opts.scaleDenominator` in
|
||||
`sceneToPrintSvg.ts`). Bei `N = 100` sind beide Formeln identisch (das war
|
||||
offenbar der Verifikationsfall in `382771b`); bei jedem anderen `N` — z. B.
|
||||
1:50 — driftet der vertikale Zeilenabstand der Stempel-Mehrzeiler
|
||||
proportional zum Verhältnis `N_wahr/100` auseinander, während die einzelne
|
||||
Zeilenhöhe (`sizeMm`) selbst korrekt bleibt. Betroffen: WASM-Viewport (2c)
|
||||
UND PDF-Export (2d) gleichermassen (beide beziehen `RText` aus derselben
|
||||
`toRenderScene`-Funktion); die WebGL2-Ansicht ist NICHT betroffen, weil sie
|
||||
Text grundsätzlich nicht selbst zeichnet, sondern die SVG-Overlay-Ebene
|
||||
weiterverwendet (`PlanView.tsx:1592`,
|
||||
`useGpuRenderer ? wantWasm || p.kind !== "text" : ...` — bei WebGL2
|
||||
(`wantWasm=false`) werden Text-Primitive NIE aus dem SVG-Rendering
|
||||
herausgefiltert).
|
||||
|
||||
### 4. Schraffur-Strichbreite im PDF hängt vom GEWÄHLTEN Export-Massstab ab, nicht vom Massstab der Live-Vorschau
|
||||
|
||||
`hatchPx` wird in `toRenderScene.ts:374-375` genau einmal, massstabsunabhängig
|
||||
berechnet:
|
||||
```ts
|
||||
const hatchMm = p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13;
|
||||
const hatchPx = Math.max(0.6, hatchMm * (1 / 0.13));
|
||||
```
|
||||
— eine reine viewBox-Grösse (analog zum SVG-`<pattern>`-Strich, der
|
||||
absichtlich mit dem Zoom mitskaliert, damit die Schraffur bei jedem Zoom
|
||||
gleich dicht aussieht: Kommentar `toRenderScene.ts:371-373`). Erst beim
|
||||
Export wird daraus in `sceneToPrintSvg.ts:243`
|
||||
`effectiveMm = (widthMm / PX_PER_M) * mmPerM` — und `mmPerM = 1000 /
|
||||
opts.scaleDenominator` (`sceneToPrintSvg.ts:101`) verwendet den vom
|
||||
Export-Dialog gewählten Massstab, NICHT den `paperScale`, den der Nutzer
|
||||
gerade in der "Print"-Live-Vorschau sieht (`exportPdf.ts` ruft
|
||||
`sceneToPrintSvg` mit `opts.scaleDenominator` aus den Export-Optionen,
|
||||
`exportPdf.ts:61-79`, völlig unabhängig vom `PlanView`-State).
|
||||
|
||||
Beispiel: `lineWeight = 0.13` mm (Standard-Fuge, `getHatch`-Default) →
|
||||
`hatchPx = max(0.6, 0.13 · (1/0.13)) = 1.0` viewBox-Einheiten.
|
||||
- Export bei 1:50 (`mmPerM = 20`): `effectiveMm = (1.0/90)·20 = 0.222 mm` →
|
||||
`quantizePen` → **0.25 mm**.
|
||||
- Export bei 1:200 (`mmPerM = 5`): `effectiveMm = (1.0/90)·5 = 0.056 mm` →
|
||||
auf `MIN_PEN_MM` geklemmt → **0.13 mm**.
|
||||
|
||||
Fast die doppelte Strichstärke allein durch die Wahl des Export-Massstabs,
|
||||
bei UNVERÄNDERTER Quell-`lineWeight` — und ohne dass die Bildschirm-
|
||||
"Print"-Vorschau (die ja mit dem live gemessenen `paperScale` arbeitet,
|
||||
nicht mit `opts.scaleDenominator`) das anzeigen könnte, solange beide Werte
|
||||
nicht zufällig übereinstimmen.
|
||||
|
||||
### 5. Dieselbe Formel existiert vierfach, nur durch Kommentare (nicht durch Typen/Code) synchron gehalten
|
||||
|
||||
`PX_PER_M = 90` ist unabhängig deklariert in: `PlanView.tsx:23` (SVG),
|
||||
`glPlan/glPlanRender.ts:12` (WebGL2), `tessellate.rs:22` (Rust, geteilt
|
||||
zwischen WASM-Viewport und Headless/Golden-Test) und `sceneToPrintSvg.ts:64`
|
||||
(PDF-Serializer, mit explizitem Kommentar "MUSS mit dem dortigen
|
||||
uebereinstimmen, sonst driftet die Schraffur-Dichte des PDFs vom Viewport
|
||||
ab", `sceneToPrintSvg.ts:61-62`). Die Hatch-Dichte-Formel
|
||||
`Math.max(0.6, weightMm · (1/0.13))` ist separat dupliziert in
|
||||
`PlanView.tsx:2344-2345` (`hatchStrokePx`, SVG-`<pattern>`) und
|
||||
`toRenderScene.ts:375` (`hatchPx`, GPU + PDF) — beide mit demselben
|
||||
"magischen" Faktor `1/0.13`, ohne gemeinsame Konstante. Aktuell sind alle vier
|
||||
Kopien konsistent; das Risiko ist rein prospektiv (nächste Tuning-Änderung an
|
||||
einer Stelle vergisst die anderen drei) — aber genau das ist der
|
||||
Mechanismus, über den Findings wie 1-4 überhaupt erst entstehen können, ohne
|
||||
dass ein Typfehler oder Test anschlägt.
|
||||
|
||||
### 6. Drei unabhängige, nicht aufeinander abgestimmte Mindestbreiten-Politiken
|
||||
|
||||
- SVG-Print: `Math.max(1e-4, ...)` (`PlanView.tsx:2599`) — praktisch keine
|
||||
Untergrenze, verlässt sich auf das Antialiasing des Browsers.
|
||||
- GPU (WebGL2 UND WASM, Linien UND Bögen): harter Floor von **0.6
|
||||
Geräte-Pixel** (`shaders.rs:91,209`; `glPlanRender.ts:243-249`,
|
||||
Kommentar "klemmt bei ~0.6 px").
|
||||
- PDF: **`MIN_PEN_MM = 0.13` mm** (`sceneToPrintSvg.ts:38` in
|
||||
`planToPrintSvg.ts`, äquivalent `sceneToPrintSvg.ts:47`) — eine
|
||||
Papier-mm-Grösse, kein Pixelwert, konzeptionell etwas anderes als die
|
||||
GPU-Pixel-Untergrenze.
|
||||
|
||||
Auswirkung: bei sehr kleinem Massstab (weit rausgezoomt oder grosses `N`,
|
||||
z. B. Übersichtsplan 1:500) hält der GPU-Pfad sehr dünne Linien künstlich bei
|
||||
0.6 px sichtbar, während dieselbe Linie im SVG-Pfad fast verschwindet und im
|
||||
PDF auf eine ganz andere (mm-basierte, massstabsabhängige) Grösse geklemmt
|
||||
wird. Kein Pfad kennt die Politik der anderen beiden. Niedrigere Priorität
|
||||
als 1-4, weil hier keine der drei Politiken die *exportierte* Papier-mm-Wahrheit
|
||||
verändert (die bleibt PDF-exklusiv über `MIN_PEN_MM`) — es geht nur um
|
||||
Bildschirm-Konsistenz zwischen SVG/GPU bei Extremzoom.
|
||||
|
||||
### 7. Toter Zweitpfad `planToPrintSvg.ts` dupliziert PEN_STEPS/mm-Logik komplett, unbenutzt aber vorhanden
|
||||
|
||||
`src/export/planToPrintSvg.ts` trägt seit `382771b` einen Kopfkommentar
|
||||
"ERSETZT durch sceneToPrintSvg.ts … wird vom PDF-Export NICHT MEHR
|
||||
verwendet" (`planToPrintSvg.ts:1-6`) und ist tatsächlich nirgends mehr
|
||||
importiert (verifiziert per Grep über `src/`). Er enthält jedoch weiterhin
|
||||
eine eigene, unabhängige Kopie von `PEN_STEPS`/`MIN_PEN_MM`/`quantizePen`
|
||||
(`planToPrintSvg.ts:35,38,40-45`) und einer kompletten Plan→SVG-Serialisierung
|
||||
inkl. eigener `PX_PER_M`-Handhabung (`planToPrintSvg.ts:326`). Kein aktiver
|
||||
Bug, aber eine Falle: Copy-Paste-Wiederverwendung dieses Altpfads (z. B. für
|
||||
einen zukünftigen DXF/PNG-Export) würde eine dritte, potenziell abweichende
|
||||
Quantisierungs-Tabelle in den Baum ziehen.
|
||||
|
||||
## 4. Vorgeschlagene Fixes
|
||||
|
||||
### zu Finding 1 (Haarlinien-Modus im GPU-Pfad)
|
||||
|
||||
Kleinste, korrekte Lösung: den `hairline`-Zustand als eigenen Parameter bis
|
||||
in den Shader durchreichen, analog zu `paper_scale_n`/`text_scale_n`.
|
||||
|
||||
- **Rust (`src-tauri/render2d/src/gpu.rs`)**: neues Feld
|
||||
`pub hairline: bool` auf `Renderer` (Default `false`, neben `paper_scale_n`
|
||||
bei `gpu.rs:422`). In `Renderer::render` (`gpu.rs:606-616`): wenn
|
||||
`self.hairline`, `mm_px` NICHT aus `mm_to_device_px(...)` berechnen, sondern
|
||||
auf einen konstanten Geräte-px-Wert setzen (z. B. `1.0`), UND dafür sorgen,
|
||||
dass der Shader `width_mm` in diesem Fall ignoriert (sonst bleibt die
|
||||
RELATIVE Differenz zwischen z. B. 0.13 mm und 0.35 mm bestehen, nur global
|
||||
skaliert — nicht das gewünschte "alle Linien exakt 1 px"). Sauberster Weg:
|
||||
ein neues `hairline: u32`-Feld in die pro-Frame-Uniform (`Globals`,
|
||||
`shaders.rs:20-23` und `ArcGlobals`), im Fragment-/Vertex-Shader
|
||||
(`shaders.rs:91`, `LINE_WGSL`; `shaders.rs:209`, `ARC_WGSL`) per
|
||||
`select(...)` auf einen festen `1.0`-px-Wert umschalten statt
|
||||
`max(0.6, stroke_px * stroke_scale)`.
|
||||
- **`src-tauri/render2d/src/web.rs`**: neue Methode `set_hairline(&mut self,
|
||||
on: bool)` neben `set_paper_scale`/`set_text_scale`
|
||||
(`web.rs:146-155`-Nachbarschaft).
|
||||
- **`src/plan/useWasmPlanRenderer.ts`**: `render()`
|
||||
(`useWasmPlanRenderer.ts:131-155`) um einen `hairline: boolean`-Parameter
|
||||
erweitern, `r.set_hairline(hairline)` vor `r.render()` aufrufen.
|
||||
- **`src/plan/useGlPlanRenderer.ts`**: analog — `render()`
|
||||
(Signatur aktuell `(viewBox, paperScaleN=100)`) um `hairline` erweitern;
|
||||
im WebGL2-Shader-Uniform-Pfad (`glPlanRender.ts:159-165,213-215`)
|
||||
`mmToDevicePx` bei `hairline===true` durch einen konstanten Wert ersetzen
|
||||
und im Fragment-Shader denselben `select`-Trick wie oben anwenden
|
||||
(`glPlanShaders.ts`, dort wo "~0.6 px"-Klemmung passiert, siehe Finding 6).
|
||||
- **`src/plan/PlanView.tsx`**: `paperScaleForGl` (`PlanView.tsx:475-476`)
|
||||
bleibt wie sie ist (wird weiter für den Massstab-Nenner gebraucht, sobald
|
||||
der Nutzer zurück auf "Print" schaltet); stattdessen an allen drei
|
||||
`renderGl(...)`-Aufrufstellen (`PlanView.tsx:494,502,1080`) zusätzlich
|
||||
`hairline` durchreichen: `renderGl(view, paperScaleForGl(view),
|
||||
textScaleForGl(), hairline)`.
|
||||
|
||||
### zu Finding 2 (PEN_STEPS nur im PDF)
|
||||
|
||||
Zwei mögliche Richtungen, je nach gewünschtem Produktverhalten:
|
||||
|
||||
- **Option A (Vorschau matcht Druck)**: `quantizePen`
|
||||
(`sceneToPrintSvg.ts:50-54`) in ein gemeinsames Modul auslagern (z. B.
|
||||
`src/plan/penSteps.ts`, exportiert `PEN_STEPS`, `MIN_PEN_MM`,
|
||||
`quantizePen`), von `sceneToPrintSvg.ts` UND von `PlanView.tsx`
|
||||
(`printStrokeVb`, `PlanView.tsx:2598-2600`) importieren und dort ebenfalls
|
||||
anwenden: `printStrokeVb(mm, n) = Math.max(1e-4, (quantizePen(mm) * n) /
|
||||
1000) * PX_PER_M`. Für den GPU-Pfad müsste die Quantisierung dann VOR dem
|
||||
Scene-Bau passieren (in `toRenderScene.ts`, auf `widthMm` jeder
|
||||
`RLine`/`ROutline`/`RPolyline`/`RArc`, ausgenommen `widthScreen`-Einträge),
|
||||
damit WASM/WebGL2-Viewport dieselben Stufen zeigen wie SVG und PDF.
|
||||
- **Option B (Vorschau bleibt Rohgrösse, aber sichtbar gemacht)**: falls die
|
||||
feinkörnige Vorschau bewusst erhalten bleiben soll, zumindest den
|
||||
Stiftstufen-Sprung an der Stelle sichtbar machen, an der die Detailgrad-
|
||||
Faktoren definiert werden (`generatePlan.ts:84-93`) — z. B. ein
|
||||
Entwickler-/Lint-Test, der bei jedem `OUTLINE_DETAIL_FACTOR`/
|
||||
`LAYER_DETAIL_FACTOR`-Wert prüft, ob das Produkt mit den üblichen
|
||||
Fallback-`weightMm`-Werten (`WALL_FALLBACK_MM`, `LAYER_LINE_MM`) exakt auf
|
||||
einer `PEN_STEPS`-Stufe landet, und sonst warnt.
|
||||
|
||||
Empfehlung: Option A — sie erfüllt den Nordstern wörtlich ("Bildschirm bei
|
||||
jedem Massstab = Druck").
|
||||
|
||||
### zu Finding 3 (STAMP_REF_N)
|
||||
|
||||
In `toRenderScene.ts` `STAMP_REF_N = 100` (`toRenderScene.ts:163`) durch den
|
||||
tatsächlichen Ziel-Massstab ersetzen. `planToRenderScene(plan)` kennt aktuell
|
||||
keinen `N`-Parameter (`exportPdf.ts:73` ruft es ohne Massstabsangabe auf) —
|
||||
die Funktion müsste ein optionales `paperScaleN`-Argument bekommen (Default
|
||||
100, um den Viewport-Aufruf ohne Massstabskontext — `nativeSync.ts` — nicht
|
||||
zu brechen, dort ist 100 ohnehin die richtige Referenz für den WASM-Viewport
|
||||
im Anzeigemodus, s. `gpu.rs:422`), und `exportPdf.ts:73` müsste
|
||||
`planToRenderScene(plan, opts.scaleDenominator)` aufrufen. `MM_TO_M`
|
||||
(`toRenderScene.ts:165`) dann aus diesem Parameter statt aus der Konstante
|
||||
ableiten.
|
||||
|
||||
### zu Finding 4 (Hatch-mm im PDF folgt dem Export-N, nicht dem Vorschau-N)
|
||||
|
||||
Kein Bug im engeren Sinn (die Formel ist in sich konsistent — die Schraffur
|
||||
soll ja pro *gewähltem Blatt-Massstab* eine sinnvolle Dichte haben), aber die
|
||||
Bildschirm-„Print"-Vorschau sollte denselben `N` verwenden, den der
|
||||
Export-Dialog tatsächlich benutzen wird, sonst lügt die Vorschau. Fix:
|
||||
`paperScale` in `PlanView.tsx` beim Öffnen des Export-Dialogs (bzw. der
|
||||
Export-Dialog selbst) mit `opts.scaleDenominator` vorbelegen/synchronisieren,
|
||||
statt zwei unabhängige State-Quellen zu pflegen — Ort: dort, wo der
|
||||
Export-Dialog `scaleDenominator` initialisiert (App.tsx, PDF-Export-UI) einen
|
||||
Default aus `paperScale`/`liveScale` (`App.tsx`, `onScale`-Callback,
|
||||
`PlanView.tsx:794`) übernehmen.
|
||||
|
||||
### zu Finding 5 (vierfache Formel-Duplikation)
|
||||
|
||||
Eine gemeinsame TS-Konstantendatei `src/plan/renderConstants.ts` mit
|
||||
`PX_PER_M = 90`, `HATCH_DENSITY_FACTOR = 1/0.13`, `HATCH_MIN_PX = 0.6`
|
||||
anlegen; `PlanView.tsx`, `glPlan/glPlanRender.ts`, `toRenderScene.ts`,
|
||||
`sceneToPrintSvg.ts` importieren daraus statt eigener Literale. Für die
|
||||
Rust-Seite (`tessellate.rs:22`, `shaders.rs:142`) ist echtes Teilen über die
|
||||
Sprachgrenze hinweg nicht trivial — dort bleibt nur ein Kommentar-Link auf die
|
||||
TS-Konstante plus ein Parity-Test (siehe Abschnitt 5), der bei Abweichung
|
||||
fehlschlägt statt bei stillem Drift.
|
||||
|
||||
### zu Finding 6 (drei Mindestbreiten-Politiken)
|
||||
|
||||
Niedrige Priorität, aber falls angegangen: den 0.6-px-Floor aus den Shadern
|
||||
(`shaders.rs:91,209`, `glPlanShaders.ts`) als benannte Konstante
|
||||
exportieren/dokumentieren und explizit von `1e-4` (SVG) und `MIN_PEN_MM=0.13`
|
||||
(PDF) abgrenzen — mindestens per Kommentar klarstellen, dass die drei
|
||||
UNTERSCHIEDLICHE Zwecke haben (GPU: Pixel-Sichtbarkeits-Floor;
|
||||
PDF: Papier-mm-Stiftgrenze) und NICHT synchronisiert werden müssen, damit
|
||||
niemand versehentlich versucht, sie anzugleichen und dabei die jeweils
|
||||
andere Semantik bricht.
|
||||
|
||||
### zu Finding 7 (toter Pfad)
|
||||
|
||||
`src/export/planToPrintSvg.ts` entfernen (nach kurzer Rücksprache, ob der
|
||||
Kopfkommentar-Vermerk "bleibt nur als Referenz stehen" noch gewollt ist) oder,
|
||||
falls er als Referenz bleiben soll, seine `PEN_STEPS`/`quantizePen`-Kopie
|
||||
durch einen Import aus der in Finding 2 vorgeschlagenen gemeinsamen
|
||||
`penSteps.ts` ersetzen.
|
||||
|
||||
## 5. Verifikations-Rezept
|
||||
|
||||
Was schon existiert:
|
||||
|
||||
- `scripts/probe-engine-parity.mjs` vergleicht `?gl=0` gegen `?engine=wasm`
|
||||
rein visuell (Screenshot-Diff von Auge, `probe-parity-svg.png` vs.
|
||||
`probe-parity-wasm.png`) — prüft NICHT quantitativ, ob Strichbreiten in
|
||||
Pixeln übereinstimmen, und deckt weder den Haarlinien-Modus noch den
|
||||
WebGL2-Default-Pfad noch den PDF-Export ab.
|
||||
- `src-tauri/render2d/tests/golden.rs` vergleicht die Demo-Szene
|
||||
Pixel-für-Pixel gegen `tests/golden/demo.png` (Toleranz: Kanal-Delta > 2 auf
|
||||
< 0.5 % der Pixel, `docs/design/engine-headless.md`) — ein reiner
|
||||
GPU-Regressionstest, ohne SVG- oder PDF-Vergleich.
|
||||
- Die einzige bisher dokumentierte mm-genaue Messung ist manuell:
|
||||
HANDOVER.md, Commit `382771b` — `pdftoppm`-Messung eines exportierten PDFs
|
||||
(53.51×43.69 mm gegen erwartete 53.45×43.45 mm bei 1:100), einmalig, nicht
|
||||
als wiederholbares Skript hinterlegt.
|
||||
|
||||
Um den Nordstern ("Bildschirm bei jedem Zoom/Massstab = Druck") tatsächlich
|
||||
beweisbar zu machen, fehlt ein quantitativer End-to-End-Test, der:
|
||||
|
||||
1. Für eine feste Test-Szene (idealerweise die bestehende `demo::demo_scene`
|
||||
aus `src-tauri/render2d/src/demo.rs`, die bereits sowohl einen `width_mm`-
|
||||
als auch einen `width_screen`-Strich enthält, `demo.rs:34,49-50`) bei
|
||||
mehreren `N` (1:10, 1:50, 1:100) UND mehreren Zoomstufen:
|
||||
- den PDF-Export erzeugt und via `pdftoppm -r <dpi>` in ein PNG rendert,
|
||||
dort die Strichbreite in Pixeln misst und in mm zurückrechnet (bekannte
|
||||
DPI ⇒ bekannte mm/px);
|
||||
- denselben Frame headless über `HeadlessRenderer::render_to_image`
|
||||
(`src-tauri/render2d/src/headless.rs`) mit identischem `paper_scale_n`
|
||||
rendert und dieselbe Pixel-Breite misst;
|
||||
- beide mm-Werte gegen den erwarteten `mm · N/1000`-Sollwert UND
|
||||
gegeneinander vergleicht, mit einer Toleranz, die die PEN_STEPS-Rundung
|
||||
(Finding 2, sobald behoben: siehe Option A) einbezieht.
|
||||
2. Den Haarlinien-Modus separat abdeckt: ein Screenshot-Vergleich bei zwei
|
||||
verschiedenen Zoomstufen im `lineMode:"display"` — die gemessene
|
||||
Pixelbreite MUSS bei beiden Zoomstufen identisch sein (Beweis, dass
|
||||
Finding 1 behoben ist), sowohl für WebGL2 (`?gl` ohne `=0`, App-Default)
|
||||
als auch für WASM (`?engine=wasm`).
|
||||
3. `probe-engine-parity.mjs` um eine tatsächliche Pixel-Differenz-Metrik
|
||||
erweitert (statt nur Kindanzahl/Warnungen zu loggen wie aktuell
|
||||
`probe-engine-parity.mjs:36-44`) — z. B. eine bekannte Referenzlinie im
|
||||
Testmodell an fester Bildschirmposition, deren Strichbreite in beiden
|
||||
Screenshots per Pixel-Sampling gemessen und verglichen wird.
|
||||
|
||||
Bis dieser Test existiert, bleibt jede Aussage über "Bildschirm = Druck" eine
|
||||
Behauptung, die nur durch manuelles `pdftoppm`-Nachmessen einzelner
|
||||
Stichproben gedeckt ist — die in diesem Audit gefundenen Divergenzen (v. a.
|
||||
Finding 1 und 2) hätten mit den heutigen Probes nicht auffallen können, weil
|
||||
keiner von ihnen den Default-Zustand der App (`lineMode:"display"`, WebGL2)
|
||||
gegen den Druckpfad misst.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Schnitt/Ansicht-Pipeline: analytische Prismen-Projektion (`render3d::section`)
|
||||
|
||||
> Gegenstueck zum OCCT-WASM-HLR-Spike (`docs/welle-c-hlr-spike/FEASIBILITY.md`):
|
||||
> statt eines generischen CAD-Kernels nutzt dieser Ansatz eine Invariante des
|
||||
> Modells, um Schnitt/Ansicht rein analytisch (kein Hidden-Line-Removal-Solver)
|
||||
> zu berechnen. Implementiert in `src-tauri/render3d/src/section.rs`.
|
||||
|
||||
## Ansatz
|
||||
|
||||
Jedes Bauteil in diesem Modell ist ein **Prisma**: ein 2D-Grundriss-Fussabdruck-
|
||||
Polygon (Wand-Band bzw. Decken-Umriss, siehe `mesh.rs`), konstant extrudiert
|
||||
ueber ein Hoehenintervall `[z0, z1]`. Diese Einschraenkung — konstanter
|
||||
Querschnitt ueber die gesamte Hoehe — macht die Schnittgeometrie trivial im
|
||||
Vergleich zu generischem HLR:
|
||||
|
||||
- **Schnitt einer vertikalen Ebene mit einem Prisma** = 2D-Geraden/Polygon-
|
||||
Clipping IM GRUNDRISS (die Schnittebene projiziert im Grundriss auf eine
|
||||
Linie) → ein oder mehrere u-Intervalle, in denen die Linie das Fussabdruck-
|
||||
Polygon durchquert. Jedes Intervall × `[z0, z1]` ist das Cut-Rechteck. Da die
|
||||
Hoehe unabhaengig von der Grundriss-Position ist, ist das Ergebnis **immer
|
||||
ein Rechteck**, nie ein Trapez — auch bei diagonalen Wandachsen.
|
||||
- **Projektion/Ansicht** (Kanten hinter der Ebene, in Blickrichtung) reduziert
|
||||
sich auf ein **2D-Sichtbarkeitsproblem im Grundriss** kombiniert mit einem
|
||||
Hoehen-Ueberlappungstest: da alle Seitenflaechen der Prismen vertikal sind,
|
||||
genuegt ein Sichtstrahl-Test im Grundriss (verdeckt ein naeheres Prisma-
|
||||
Fussabdruckpolygon die Sichtlinie?) plus Ueberlappung der Hoehenintervalle.
|
||||
|
||||
Das ersetzt einen 66-MB-WASM-CAD-Kernel durch closed-form Arithmetik in reinem
|
||||
Rust, ohne zusaetzliches Laufzeitgewicht ueber `render3d` hinaus.
|
||||
|
||||
## `SectionOutput`-Format
|
||||
|
||||
Modul: `render3d::section`. Alle Groessen in **Metern**.
|
||||
|
||||
```rust
|
||||
pub struct SectionPlane { pub point: [f32; 3], pub normal: [f32; 3] }
|
||||
|
||||
pub struct SectionOutput {
|
||||
pub cut_polygons: Vec<CutPolygon>, // JSON: "cutPolygons"
|
||||
pub visible_edges: Vec<SectionEdge>, // JSON: "visibleEdges"
|
||||
pub hidden_edges: Vec<SectionEdge>, // JSON: "hiddenEdges"
|
||||
}
|
||||
|
||||
pub struct CutPolygon { pub component: ComponentRef, pub color: Rgb, pub pts: Vec<[f32; 2]> }
|
||||
pub struct SectionEdge { pub component: ComponentRef, pub a: [f32; 2], pub b: [f32; 2] }
|
||||
pub struct ComponentRef { pub kind: ComponentKind /* Wall | Slab */, pub index: usize }
|
||||
```
|
||||
|
||||
**Koordinatensystem der Ausgabe (u, v):**
|
||||
|
||||
- Ursprung: `SectionPlane::point`, projiziert.
|
||||
- `u` (horizontal): Strecke entlang der Schnittebene, senkrecht zur
|
||||
Blickrichtung, berechnet als `normalize(cross(normal, world_up))` — dieselbe
|
||||
rechtshaendige Konvention wie `math::look_at`s `right`-Vektor. Bei den vier
|
||||
Standard-Konstruktoren (`looking_plus_x/minus_x/plus_y/minus_y`) ist `u`
|
||||
direkt die jeweils andere Grundriss-Achse.
|
||||
- `v` (vertikal, "Hoehe"): `v = world.y`, ABSOLUT. Diese Codebasis ist
|
||||
durchgaengig Y-up (`types.rs`/`mesh.rs`/`math.rs`: „world.y = Hoehe"); `v`
|
||||
folgt bewusst dieser etablierten Konvention statt einer wortwoertlichen
|
||||
„world.z"-Lesart, um modulübergreifend konsistent zu bleiben.
|
||||
- Nur **vertikale** Schnittebenen (Normale ohne Hoehen-Komponente) sind
|
||||
unterstuetzt — Grundriss-/Horizontalschnitte bleiben Sache der bestehenden
|
||||
2D-Plan-Pipeline.
|
||||
|
||||
`cut_polygons` sind bei diesem Modell immer Rechtecke (siehe oben), aber als
|
||||
generischer Punktering abgelegt — kompatibel zu `render2d::types::FillPolygon`
|
||||
(`pts: Vec<Point>`), dem vorgesehenen Zielformat fuer die spaetere 1:1-
|
||||
Uebersetzung in die Plan-/Schnitt-Ansicht.
|
||||
|
||||
## Oeffnungen (Tueren/Fenster)
|
||||
|
||||
`WallInput` hat seit diesem Nachtrag ein Feld `openings: Vec<Opening>`
|
||||
(`types.rs`), unabhaengig von der reinen Achse/Dicke/Hoehe: je Oeffnung ein
|
||||
Intervall entlang der Wandachse (`from`/`to`, Meter ab `start`) plus Bruestungs-
|
||||
und Kopfhoehe (`sill`/`height`, relativ zu `base_elevation`). Eine Tuer ist der
|
||||
Sonderfall `sill == 0.0`.
|
||||
|
||||
**Cut-Polygone.** Trifft die Schnittlinie eine Wand exakt im Bereich einer
|
||||
Oeffnung, splittet `wall_cut_rectangles` das sonst einzelne Vollrechteck
|
||||
`[z0,z1]` in bis zu ZWEI Teil-Rechtecke: Bruestung `[z0, z0+sill]` und Sturz
|
||||
`[z0+sill+height, z1]`. Gewaehlt wurde diese Mehrfach-Rechteck-Darstellung
|
||||
BEWUSST gegenueber einem Loch-Polygon (Ring mit Aussparung): mehrere einfache,
|
||||
konvexe Ringe sind direkt kompatibel zum Zielformat `render2d::types::
|
||||
FillPolygon` (nur einfache Ringe, kein Loch-Format), waehrend ein
|
||||
Loch-Polygon eine zusaetzliche Datenstruktur (Ring-mit-Loch) noetig gemacht
|
||||
haette, die die Zielstruktur (noch) nicht kennt. Ausserhalb einer Oeffnung
|
||||
bleibt es beim einzelnen Vollrechteck (Regressionsfall).
|
||||
|
||||
Die Zuordnung "welche Wandachsen-Position entspricht dieser Schnittposition"
|
||||
ist fuer den STANDARDFALL (Schnittebene senkrecht zur Wandachse — der uebliche
|
||||
architektonische Wandquerschnitt) EXAKT konstant ueber das Cut-Intervall. Fuer
|
||||
schraege Wand/Ebene-Kombinationen wird sie als AFFINE Funktion (`axis_map`)
|
||||
exakt mitgefuehrt (keine Naeherung noetig, da die Abbildung linear ist).
|
||||
|
||||
**Projektion/Ansicht.** Eine Wand mit Oeffnungen bekommt zusaetzlich zur
|
||||
Boxen-Drahtsilhouette die vier Rahmenkanten jeder Oeffnung (zwei Leibungen,
|
||||
Sturz, Bruestung), berechnet auf der Wandachsen-Mittellinie (dieselbe
|
||||
Vereinfachung wie die Bounding-Box-Verdeckung generell — keine eigene
|
||||
Dicken-Aufloesung der Leibungsflaeche). Fuer die Verdeckung wird jede
|
||||
Wand-Bounding-Box um ihre Oeffnungen als "Loch" reduziert
|
||||
(`PrismBounds::opening_voids`): ein anderes (oder dasselbe) Bauteil hinter der
|
||||
Wand wird in genau dem (u, Hoehe)-Rechteck der Oeffnung NICHT von dieser Wand
|
||||
verdeckt — "Durchblick". Die eigenen Rahmenkanten einer Oeffnung werden dabei
|
||||
NICHT gegen das eigene Bauteil auf Verdeckung geprueft (ein Loch kann sich
|
||||
nicht selbst verdecken); gegen alle anderen Bauteile gilt die normale
|
||||
Verdeckungslogik unveraendert (inkl. der bereits dokumentierten
|
||||
Selbstverdeckung der Rueckseite eines ANDEREN Bauteils durch dessen eigene
|
||||
Vorderseite).
|
||||
|
||||
GENAUIGKEIT (bewusste Vereinfachung): die u-Zuordnung fuer den Durchblick ist
|
||||
nur DANN aussagekraeftig, wenn die Wandachse hinreichend parallel zur u-Achse
|
||||
der Schnittebene steht — das ist GENAU der Fall, in dem man die Wand als
|
||||
Elevation/Ansicht von vorne sieht (und ein Fenster ueberhaupt als Durchblick
|
||||
sichtbar waere). Steht die Wand naeher an "senkrecht zur u-Achse" (Wand auf
|
||||
Kante gesehen bzw. der reine Cut-Fall), wird die Durchblick-Berechnung
|
||||
uebersprungen und die Wand bleibt fuer die Verdeckung VOLL UNDURCHSICHTIG
|
||||
(konservativ hidden) — Schwelle `AXIS_ALIGN_EPS = 1e-3` in `section.rs`. Ein
|
||||
Kante-auf-Kante gesehenes Fenster liefert ohnehin keine sinnvolle
|
||||
Durchblick-Flaeche in der Projektion.
|
||||
|
||||
Getestet in `section::tests` (u. a. `schnitt_durch_fenster_liefert_bruestung_
|
||||
und_sturz`, `schnitt_neben_dem_fenster_liefert_volles_rechteck`,
|
||||
`ansicht_zeigt_fensterrahmen_sichtbar_und_durchblick_bei_dahinterliegender_
|
||||
kante`) und im Beweis-SVG (`examples/section_svg.rs`): Schenkel A der L-Wand
|
||||
hat dort ein Fenster, eine kurze zusaetzliche Wand steht dahinter genau im
|
||||
Fensterband und erscheint im SVG als durchgezogene (sichtbare) Linie zwischen
|
||||
Bruestungs- und Sturzhoehe, gestrichelt (verdeckt) darueber/darunter.
|
||||
|
||||
## Bekannte Luecken
|
||||
|
||||
- **Keine Wandknoten-Verschneidung (T-/X-Stoesse).** Wie in `mesh.rs`
|
||||
(M1-Stand) werden Waende als eigenstaendige, stumpf abgeschlossene Quader
|
||||
behandelt, die sich an Ecken UEBERLAPPEN statt sich zu vereinen (kein
|
||||
Miter-Join). Der Schnitt-Extraktor erbt das: an einem L-/T-Knoten kann eine
|
||||
Wand als „in die andere eingebettet" verdeckt erscheinen (im Testmodul
|
||||
`section::tests` bewusst als reales, erwartetes Verhalten dokumentiert und
|
||||
geprueft — kein Bug dieses Moduls, sondern ein Artefakt der fehlenden
|
||||
Verschneidungslogik weiter oben in der Pipeline).
|
||||
- **Oeffnungen sind NICHT in der Vollkoerper-Extrusion (`mesh.rs`)
|
||||
nachgezogen.** `wall_prism`/`wall_cut_rectangles`/die Verdeckung in
|
||||
`section.rs` werten `WallInput::openings` vollstaendig aus (siehe Abschnitt
|
||||
"Oeffnungen" oben), aber `mesh::extrude_wall` extrudiert weiterhin die volle
|
||||
Wandflaeche ohne Aussparung (`mesh.rs`-Moduldoc: „Oeffnungen kommen in
|
||||
spaeteren Milestones"). Cut-/Ansichts-Pipeline und 3D-Solid-Mesh sind bis zum
|
||||
Nachziehen von `mesh.rs` also bewusst inkonsistent — eine bekannte, separate
|
||||
Luecke (nicht Gegenstand dieses Nachtrags).
|
||||
- **Oeffnungs-Durchblick nutzt dieselbe achsparallele Bounding-Box-Naeherung**
|
||||
wie die allgemeine Verdeckung (kein exaktes Polygon-Clipping der Lochflaeche
|
||||
gegen dahinterliegende Kanten) und wird bei Wand-Orientierungen nahe
|
||||
"senkrecht zur u-Achse" konservativ auf "kein Durchblick" zurueckgestuft
|
||||
(siehe Abschnitt "Oeffnungen" oben, `AXIS_ALIGN_EPS`).
|
||||
- **Keine echte Component-/Material-Id.** `WallInput`/`SlabInput` haben aktuell
|
||||
keine eigene Id; `ComponentRef` referenziert daher nur `(Art, Index im
|
||||
Eingabe-Array)` + die rohe Albedo-Farbe als Material-Platzhalter. Sobald ein
|
||||
echtes Ressourcen-/Material-System existiert, sollte `ComponentRef` auf eine
|
||||
stabile Id umgestellt werden (Indizes sind nicht stabil ueber Modell-Edits).
|
||||
- **Verdeckungstest nutzt Bounding-Boxen, nicht die exakte Fussabdruckform.**
|
||||
Fuer Wand-Baender und (i. d. R. konvexe) Deckenumrisse ist das exakt; bei
|
||||
stark konkaven oder diagonalen Grundrissen kann es zu Ueberverdeckung
|
||||
fuehren (ein Prisma blockiert dann auch Bereiche seiner eigenen „Nischen").
|
||||
- **Kein generisches HLR.** Der Ansatz funktioniert NUR, weil alle Bauteile
|
||||
Prismen mit konstantem Querschnitt sind. Fuer echte gekrümmte oder nicht-
|
||||
prismatische Geometrie (Bogenwaende, Freiformdaecher, …) waere er nicht
|
||||
anwendbar — dafuer bliebe der OCCT-Weg (oder eine eigene, generischere
|
||||
HLR-Implementierung) die Referenz.
|
||||
- **Keine robuste Sonderfallbehandlung fuer Vertices exakt auf der
|
||||
Schnittlinie** (Toleranz-basiert, kein Tie-Breaking/Pertubation) — die
|
||||
Testszenarien vermeiden diesen Fall bewusst.
|
||||
|
||||
## Vergleich zum OCCT-WASM-Spike
|
||||
|
||||
| | OCCT-WASM (`docs/welle-c-hlr-spike`) | Dieser Ansatz (`render3d::section`) |
|
||||
|---|---|---|
|
||||
| Verfahren | Generisches HLR (`HLRAppli_ReflectLines`) | Analytische Prismen-Projektion |
|
||||
| Anwendbarkeit | Beliebige BREP-Geometrie | Nur Prismen (konstanter Querschnitt über Höhe) |
|
||||
| Zusaetzliches Gewicht | ~62,8 MB WASM (~19,6 MB gzip), separater Lazy-Chunk | Keins — reines Rust in `render3d`, kein weiteres WASM-Asset |
|
||||
| Modul-Init | ~450 ms (Browser) / ~680 ms (Node) | Kein Initialisierungsschritt (kein Fremd-Modul zu laden) |
|
||||
| Rechenzeit (L-Wand + Platte) | ~21 ms (reiner HLR-Lauf, gemessen im Browser) | Nicht separat gemessen (kein Millisekunden-Timer im Test); die Operationen sind reine Vektor-/Intervall-Arithmetik über wenige Kanten (O(Anzahl-Prismen × Kanten-pro-Prisma) mit kleinen Konstanten) und liegen der Groessenordnung nach klar unter 1 ms fuer Szenen dieser Groesse — eine belastbare Messung steht noch aus |
|
||||
| Sichtbarkeit (verdeckte Kanten) | Exakt (echter HLR-Solver) | Naeherung über Bounding-Boxen im Grundriss + Hoehenintervall-Ueberlappung; exakt fuer achsparallele/konvexe Fussabdruecke |
|
||||
| Reifegrad | Isolierter Spike, nicht verdrahtet | Isolierter Spike (dieses Modul), nicht in `render2d`/die Plan-Ansicht verdrahtet |
|
||||
|
||||
**Fazit:** Fuer den ueberwiegenden Regelfall dieses Projekts (Waende, Decken —
|
||||
alles Prismen) ist die analytische Loesung der pragmatischere Weg: kein
|
||||
zusaetzliches WASM-Gewicht, keine Fremd-Bibliothek, headless testbar wie der
|
||||
Rest von `render3d`. Der OCCT-Weg bleibt die Referenz, falls/sobald echte
|
||||
generische Volumenkoerper (Booleans, gekrümmte Flaechen) ins Modell kommen.
|
||||
@@ -0,0 +1,498 @@
|
||||
# Design — Ebenen-Darstellung (Layer Display Settings)
|
||||
|
||||
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||
> Ressourcen/Stile: [resources-graphics.md](resources-graphics.md). Output/Pläne:
|
||||
> [plans-output.md](plans-output.md). Kontextmenü/Inline-Editor:
|
||||
> [context-menu.md](context-menu.md).
|
||||
|
||||
Dieses Dokument spezifiziert die **per-Ebene Darstellungseinstellungen** auf der
|
||||
`LayerCategory` (Grafik-Kategorie) und den dazugehörigen Editor
|
||||
„Ebeneneinstellungen…", der aus dem Ebenen-Kontextmenü geöffnet wird.
|
||||
|
||||
Heute trägt jede `LayerCategory` nur eine flache Strichstärke (`lw`), eine `color`
|
||||
und eine optionale `hatch` (ein freier String, der nirgends aufgelöst wird). Das
|
||||
reicht nicht: Eine Ebene soll — wie in Vectorworks/DOSSIER — einen vollständigen
|
||||
**Stift (PEN)** und eine vollständige **Standard-Schraffur (HATCH)** definieren,
|
||||
die beim Rendern angewandt werden. Bezeichner englisch, Prosa/UI-Text deutsch
|
||||
(CONVENTIONS.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Zielbild
|
||||
|
||||
Jede Ebene definiert zwei Darstellungs-Aspekte, die in den Grundriss-Generator
|
||||
einfließen:
|
||||
|
||||
- **PEN** — Linienstil der Ebene: `type` (durchgezogen / gestrichelt / …),
|
||||
`color` und `lw` (Strichstärke in mm Papier). Steuert alle Umriss-/Symbol-Linien
|
||||
der Elemente dieser Ebene (Wand-Umriss, Tür-Symbol, Referenzlinie).
|
||||
- **HATCH** — Standard-Schraffur der Ebene: `pattern`, `scale`, `angle` und die
|
||||
`lineWeight` der Musterlinien. Wird angewandt, wo ein Element keine eigene
|
||||
Schraffur aus einem `Component` mitbringt (z. B. einschichtige/„grob"-Flächen,
|
||||
reine 2D-Zeichnungsobjekte einer Ebene).
|
||||
|
||||
Beides folgt dem Architektur-Prinzip: **Darstellung wird beim Rendern aufgelöst,
|
||||
nie in die Geometrie eingebacken.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Reference vs. Inline — Entscheidung
|
||||
|
||||
Es gibt drei Modelle, ein Datum für PEN/HATCH einer Ebene zu halten:
|
||||
|
||||
1. **Pure inline** — die Ebene trägt `{type,color,lw}` und `{pattern,scale,angle,
|
||||
lineWeight}` direkt. Einfach, aber: kein Wiederverwenden, kein zentrales
|
||||
Ändern; widerspricht der Ressourcen-Architektur (resources-graphics.md §1:
|
||||
„alles verweist per id, zentral änderbar").
|
||||
2. **Pure reference** — die Ebene trägt nur `lineStyleId` / `hatchId`. Konsistent,
|
||||
zentral, aber unflexibel: Eine Ebene kann z. B. nicht „den Stil X, aber in
|
||||
ihrer eigenen Farbe" wollen, ohne einen Klon-Stil anzulegen.
|
||||
3. **Reference + optionale per-Ebene Overrides** (EMPFOHLEN) — die Ebene
|
||||
**verweist** auf eine `LineStyle`- bzw. `HatchStyle`-Ressource und darf
|
||||
**einzelne Felder lokal überschreiben**. Das ist exakt das DOSSIER/Vectorworks-
|
||||
Muster: ein Stil als Basis, regelbasierte/lokale Overrides obendrauf
|
||||
(resources-graphics.md, `overrides.py`).
|
||||
|
||||
### Empfehlung: Reference + optionale Overrides
|
||||
|
||||
Begründung:
|
||||
|
||||
- **Zentrale Pflege bleibt erhalten:** Ändert man den Linienstil „Wand stark" im
|
||||
Line Manager, ziehen alle Ebenen nach, die ihn referenzieren und das jeweilige
|
||||
Feld nicht überschreiben.
|
||||
- **Lokale Freiheit ohne Stil-Wildwuchs:** Eine Ebene kann punktuell `color` oder
|
||||
`lw` anpassen (häufigster Fall: gleiche Strichart, andere Farbe), ohne einen
|
||||
fast identischen Stil zu duplizieren.
|
||||
- **Migrationsfähig:** Die heutige flache `{color, lw}` der Ebene wird zu reinen
|
||||
Overrides über einem neutralen Basis-Stil — verlustfrei (siehe §6).
|
||||
- **Konsistent mit der bestehenden Kette:** `Component → Hatch → LineStyle`
|
||||
verweist bereits per id; Ebenen reihen sich nahtlos ein.
|
||||
|
||||
Die Overrides sind **sparse**: nur gesetzte Felder überschreiben. Ein leeres
|
||||
Override-Objekt (oder `undefined`) bedeutet „komplett dem Stil folgen".
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenmodell (TS)
|
||||
|
||||
### 3.1 LineStyle erweitern um `type`
|
||||
|
||||
`LineStyle` trägt heute schon `weight`, `color`, `dash`. Wir machen die
|
||||
Strichart explizit benennbar (statt nur via `dash`-Array), damit der Editor ein
|
||||
sauberes Dropdown anbietet und `dash` daraus ableiten kann.
|
||||
|
||||
```ts
|
||||
/** Benannte Strichart eines Stifts (für UI-Dropdown). */
|
||||
export type LineKind = "solid" | "dashed" | "dotted" | "dashdot";
|
||||
|
||||
/** mm-Strichmuster je Strichart (relativ zur Papier-mm). */
|
||||
export const LINE_DASH: Record<LineKind, number[] | null> = {
|
||||
solid: null,
|
||||
dashed: [0.6, 0.4],
|
||||
dotted: [0.1, 0.25],
|
||||
dashdot: [0.6, 0.25, 0.1, 0.25],
|
||||
};
|
||||
|
||||
export interface LineStyle {
|
||||
id: string;
|
||||
name: string;
|
||||
/** NEU: benannte Strichart; `dash` wird daraus abgeleitet, falls nicht gesetzt. */
|
||||
kind: LineKind;
|
||||
/** Strichstärke in Millimetern (≙ Rhino PlotWeight). */
|
||||
weight: number;
|
||||
color: string;
|
||||
/** Strichmuster in mm; `null` = durchgezogen. Optional — sonst aus `kind`. */
|
||||
dash: number[] | null;
|
||||
}
|
||||
```
|
||||
|
||||
> Hinweis: `kind` ist additiv; bestehende `LineStyle`-Daten setzen es per Migration
|
||||
> aus `dash` (§6).
|
||||
|
||||
### 3.2 PEN- und HATCH-Override-Typen
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Per-Ebene Stift (PEN). Verweist auf einen LineStyle; einzelne Felder dürfen
|
||||
* lokal überschrieben werden. Alle Override-Felder optional (sparse).
|
||||
*/
|
||||
export interface LayerPen {
|
||||
/** Basis-Linienstil (Line Manager). */
|
||||
lineStyleId: string;
|
||||
/** Lokale Overrides — nur gesetzte Felder gewinnen. */
|
||||
override?: {
|
||||
kind?: LineKind;
|
||||
color?: string;
|
||||
/** Strichstärke in mm Papier. */
|
||||
lw?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-Ebene Standard-Schraffur (HATCH). Verweist auf einen HatchStyle; einzelne
|
||||
* Felder dürfen lokal überschrieben werden. `enabled=false` = Ebene hat keine
|
||||
* Default-Schraffur (Umriss-only).
|
||||
*/
|
||||
export interface LayerHatch {
|
||||
/** Aktiv? false = keine Default-Schraffur dieser Ebene. */
|
||||
enabled: boolean;
|
||||
/** Basis-Schraffur (Hatch Manager). */
|
||||
hatchId: string;
|
||||
/** Lokale Overrides — nur gesetzte Felder gewinnen. */
|
||||
override?: {
|
||||
pattern?: HatchPattern;
|
||||
scale?: number;
|
||||
/** Drehung in Grad. */
|
||||
angle?: number;
|
||||
color?: string;
|
||||
/** Strichstärke der Musterlinien in mm Papier. */
|
||||
lineWeight?: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 LayerCategory erweitern
|
||||
|
||||
```ts
|
||||
export interface LayerCategory {
|
||||
code: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
|
||||
// ── NEU: vollständige Darstellung ──────────────────────────────────────────
|
||||
/** Stift der Ebene (PEN) — Linien aller Elemente dieser Ebene. */
|
||||
pen: LayerPen;
|
||||
/** Standard-Schraffur der Ebene (HATCH). */
|
||||
hatch: LayerHatch;
|
||||
|
||||
/** Unterkategorien (Baum). */
|
||||
children?: LayerCategory[];
|
||||
|
||||
// ── DEPRECATED (nur Übergang; siehe Migration §6) ──────────────────────────
|
||||
/** @deprecated → pen.override.color. */
|
||||
color?: string;
|
||||
/** @deprecated → pen.override.lw. */
|
||||
lw?: number;
|
||||
}
|
||||
```
|
||||
|
||||
`color` und `lw` bleiben als optionale, deprecatete Felder bestehen, bis alle
|
||||
Lesepfade auf den Resolver (§4) umgestellt sind, und werden dann entfernt. Die
|
||||
Panel-Swatch (`LayersPanel`) liest künftig die **aufgelöste** Stift-Farbe.
|
||||
|
||||
---
|
||||
|
||||
## 4. Resolver — vom Modell zur Render-Entscheidung
|
||||
|
||||
Der Resolver löst PEN/HATCH einer Ebene gegen die Ressourcen-Bibliotheken auf und
|
||||
wendet die Overrides an. Er ist die **einzige** Stelle, an der „Stil + Override"
|
||||
zusammenfließen; Generator und Panel rufen nur ihn.
|
||||
|
||||
### 4.1 Aufgelöste Render-Typen
|
||||
|
||||
`HatchRender` existiert bereits in `generatePlan.ts`. Wir ergänzen ein paralleles
|
||||
`PenRender` und exportieren beide Resolver aus einem neuen Modul
|
||||
`src/model/layerStyle.ts` (damit Panel und Generator teilen).
|
||||
|
||||
```ts
|
||||
/** Aufgelöster Stift einer Ebene — alles, was die Linie zu zeichnen braucht. */
|
||||
export interface PenRender {
|
||||
color: string;
|
||||
/** Strichstärke in mm Papier. */
|
||||
lw: number;
|
||||
/** Strichmuster in mm Papier; null = durchgezogen. */
|
||||
dash: number[] | null;
|
||||
}
|
||||
|
||||
// HatchRender: bereits in generatePlan.ts definiert (pattern, scale, angle,
|
||||
// color, lineWeight, dash). Wird nach layerStyle.ts gezogen und re-exportiert.
|
||||
```
|
||||
|
||||
### 4.2 Resolver-Funktionen (Pseudocode)
|
||||
|
||||
```ts
|
||||
function resolvePen(project: Project, layer: LayerCategory): PenRender {
|
||||
const ls = getLineStyle(project, layer.pen.lineStyleId); // wirft, falls fehlend
|
||||
const o = layer.pen.override ?? {};
|
||||
const kind = o.kind ?? ls.kind;
|
||||
return {
|
||||
color: o.color ?? ls.color,
|
||||
lw: o.lw ?? ls.weight,
|
||||
// Override-kind setzt das dash neu; sonst Stil-dash bzw. aus kind abgeleitet.
|
||||
dash: o.kind ? LINE_DASH[o.kind] : (ls.dash ?? LINE_DASH[ls.kind]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLayerHatch(project: Project, layer: LayerCategory): HatchRender | null {
|
||||
if (!layer.hatch.enabled) return null; // Ebene ohne Default-Schraffur
|
||||
const h = getHatch(project, layer.hatch.hatchId); // wirft, falls fehlend
|
||||
const o = layer.hatch.override ?? {};
|
||||
// Musterlinien-Stärke: Override > LineStyle der Schraffur > Default 0.13 mm.
|
||||
const baseLs = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
|
||||
return {
|
||||
pattern: o.pattern ?? h.pattern,
|
||||
scale: o.scale ?? h.scale,
|
||||
angle: o.angle ?? h.angle,
|
||||
color: o.color ?? h.color,
|
||||
lineWeight: o.lineWeight ?? baseLs?.weight ?? 0.13,
|
||||
dash: baseLs?.dash ?? null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Beide bauen eine `Map<code, …>` über den ganzen Baum, analog zur heutigen
|
||||
`categoryLwMap`:
|
||||
|
||||
```ts
|
||||
export function penMap(project: Project): Map<string, PenRender> {
|
||||
const m = new Map<string, PenRender>();
|
||||
for (const c of flattenCategories(project.layers)) m.set(c.code, resolvePen(project, c));
|
||||
return m;
|
||||
}
|
||||
export function layerHatchMap(project: Project): Map<string, HatchRender | null> {
|
||||
const m = new Map<string, HatchRender | null>();
|
||||
for (const c of flattenCategories(project.layers))
|
||||
m.set(c.code, resolveLayerHatch(project, c));
|
||||
return m;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Einfluss auf `generatePlan`
|
||||
|
||||
Heute (generatePlan.ts):
|
||||
|
||||
- `categoryLwMap(project.layers)` liefert nur `lw` je Code; die Umriss-Strichstärke
|
||||
kommt daraus, **Farbe** der Umrisse ist fest `POCHE_STROKE`.
|
||||
- Schraffur kommt ausschließlich aus dem `Component` der jeweiligen Schicht
|
||||
(`resolveHatch(project, comp.hatchId)`); die Ebenen-`hatch` wird **nicht** genutzt.
|
||||
|
||||
Änderungen (minimal-invasiv, additiv):
|
||||
|
||||
### 5.1 Pens ersetzen `lwByCode`
|
||||
|
||||
```ts
|
||||
const pens = penMap(project); // statt categoryLwMap
|
||||
const layerHatches = layerHatchMap(project);
|
||||
…
|
||||
const pen = pens.get(wall.categoryCode) ?? FALLBACK_PEN; // {color, lw, dash}
|
||||
```
|
||||
|
||||
`addWallPoche` und `addDoorSymbol` bekommen statt `wallLwMm: number` /
|
||||
`doorLwMm: number` jeweils das ganze `pen: PenRender`:
|
||||
|
||||
- **Wand-Umrisslinie:** `stroke: pen.color` (statt fix `POCHE_STROKE`),
|
||||
`strokeWidthMm: pen.lw * OUTLINE_DETAIL_FACTOR[detail]`, `dash: pen.dash`.
|
||||
→ Das `Primitive` „polygon" braucht ein optionales `dash?: number[] | null`
|
||||
(Schichtfugen bleiben durchgezogen; nur die Umriss-Kontur nutzt `pen.dash`).
|
||||
- **Schichtfugen:** behalten `POCHE_STROKE` und ihre dünne `LAYER_LINE_MM`
|
||||
(interne Hilfslinien sind bewusst neutral, nicht stift-gefärbt).
|
||||
- **Tür-Symbol / Referenzlinie:** `cls` bleibt, aber `weightMm` aus `pen.lw`,
|
||||
und die PlanView darf die Stift-Farbe nutzen (`door-leaf` etc. erhalten optional
|
||||
ein `stroke`-Feld am line/arc-Primitive; ansonsten greift die CSS-Klasse wie
|
||||
bisher).
|
||||
|
||||
### 5.2 Default-Schraffur der Ebene
|
||||
|
||||
Die Ebenen-Schraffur greift dort, wo **keine Component-Schraffur** vorliegt:
|
||||
|
||||
- **`detail === "grob"`** (eine Sammelfläche, heute `NO_HATCH`): statt `NO_HATCH`
|
||||
nun `layerHatches.get(wall.categoryCode) ?? NO_HATCH`. So bekommt die grobe
|
||||
Poché die Standard-Schraffur der Ebene (z. B. ein leichtes Diagonalmuster),
|
||||
falls die Ebene eine definiert; sonst bleibt sie ungeschraffiert.
|
||||
- **mittel/fein, mehrschichtig:** unverändert — die Component-Schraffur je Schicht
|
||||
hat Vorrang (spezifischer als die Ebene). Die Ebenen-Schraffur ist der
|
||||
*Fallback*, nicht der Default-Override.
|
||||
- **Reine 2D-Zeichnungsobjekte** (künftige `drawing`-Ebenen-Elemente ohne
|
||||
Component): nutzen direkt `resolveLayerHatch` als ihre Füllschraffur.
|
||||
|
||||
Auflöse-Reihenfolge der Schraffur einer gezeichneten Fläche:
|
||||
|
||||
```
|
||||
Component.hatch > LayerCategory.hatch (enabled) > keine Schraffur
|
||||
```
|
||||
|
||||
### 5.3 Geänderte Signaturen (Zusammenfassung)
|
||||
|
||||
```ts
|
||||
// vorher: addWallPoche(out, project, wall, doors, cuts, greyed, detail, wallLwMm)
|
||||
function addWallPoche(out, project, wall, doors, cuts, greyed, detail,
|
||||
pen: PenRender, layerHatch: HatchRender | null): void
|
||||
|
||||
// vorher: addDoorSymbol(out, wall, door, greyed, detail, doorLwMm)
|
||||
function addDoorSymbol(out, wall, door, greyed, detail, pen: PenRender): void
|
||||
```
|
||||
|
||||
`Primitive` (polygon) erhält optional `dash?: number[] | null`; line/arc erhalten
|
||||
optional `stroke?: string`, damit Pen-Farbe durchschlagen kann (CSS-Klasse bleibt
|
||||
Default).
|
||||
|
||||
---
|
||||
|
||||
## 6. Editor „Ebeneneinstellungen…"
|
||||
|
||||
Geöffnet wie heute über `layerMenuItems → openLayerEditor(code)` →
|
||||
`setEditor({ kind: "layer", code, x, y })`. Der bestehende `InlineEditor`-Rahmen
|
||||
(dunkel, am Anker, Esc/Außenklick schließt) und die `EditorField`-Zeilen bleiben;
|
||||
der Inhalt wächst von 3 Feldern auf zwei kompakte Abschnitte **PEN** und **HATCH**.
|
||||
|
||||
Da der Editor jetzt mehr Felder trägt, wird er als **kompakte Sektions-Form**
|
||||
gestaltet (zwei Gruppen mit Trenn-Überschrift), gemäß CONVENTIONS.md UI-Konventionen
|
||||
(saubere Form, keine wiederholten Beschriftungen, DOSSIER-Stil, alles via `t()`).
|
||||
|
||||
### 6.1 Aufbau
|
||||
|
||||
```
|
||||
┌ Ebene 20 ───────────────── ×
|
||||
│ Name [ Wände ]
|
||||
│
|
||||
│ ── Stift (PEN) ──────────────
|
||||
│ Linienstil [ Wand stark ▾ ] ← Dropdown über project.lineStyles
|
||||
│ Strichart [ durchgezogen ▾ ] ← override.kind (leer = "vom Stil")
|
||||
│ Farbe [■] [↺] ← override.color; ↺ = Override entfernen
|
||||
│ Stärke [ 0.35 ] mm [↺] ← override.lw
|
||||
│
|
||||
│ ── Schraffur (HATCH) ────────
|
||||
│ [✓] aktiv
|
||||
│ Schraffur [ Beton ▾ ] ← Dropdown über project.hatches
|
||||
│ Muster [ vom Stil ▾ ] ← override.pattern
|
||||
│ Maßstab [ 1.00 ] [↺]
|
||||
│ Drehung [ 45 ] ° [↺]
|
||||
│ Farbe [■] [↺]
|
||||
│ Linienst. [ 0.13 ] mm [↺]
|
||||
└──────────────────────────────
|
||||
```
|
||||
|
||||
- **Override-Semantik im UI:** Jedes Override-Feld zeigt entweder „vom Stil"
|
||||
(Override leer → Platzhalter mit dem aufgelösten Stil-Wert als Hint) oder einen
|
||||
konkreten Wert. Ein kleiner **Reset-Knopf `↺`** je Override-Feld löscht das
|
||||
Override (setzt es zurück auf `undefined` → Feld folgt wieder dem Stil).
|
||||
- **Live, kein Bestätigen:** wie der heutige Editor — jede Änderung ruft sofort
|
||||
`patchCategory(code, patch)`.
|
||||
- **i18n:** alle Labels über `t()`. Neue Keys (Beispiele):
|
||||
`editor.pen`, `editor.lineStyle`, `editor.lineKind`, `editor.color`,
|
||||
`editor.lineWeight`, `editor.hatch`, `editor.hatchEnabled`, `editor.pattern`,
|
||||
`editor.scale`, `editor.rotation`, `editor.fromStyle`, `editor.resetOverride`.
|
||||
Strichart-/Muster-Werte: `lineKind.solid`, `lineKind.dashed`, …,
|
||||
`hatchPattern.solid`, `hatchPattern.diagonal`, … . Menü-Label bleibt
|
||||
`ctx.layerSettings`.
|
||||
|
||||
### 6.2 Patch-Helfer
|
||||
|
||||
`patchCategory(code, patch: Partial<LayerCategory>)` bleibt die Schnittstelle.
|
||||
Für die verschachtelten Overrides nutzt der Editor schmale Helfer (im App-Scope),
|
||||
die sparse mergen und leere Overrides auf `undefined` kollabieren:
|
||||
|
||||
```ts
|
||||
function setPenOverride(cat: LayerCategory, patch: Partial<LayerPen["override"]>) {
|
||||
const next = pruneEmpty({ ...cat.pen.override, ...patch });
|
||||
patchCategory(cat.code, { pen: { ...cat.pen, override: next } });
|
||||
}
|
||||
function setHatchOverride(cat, patch) { /* analog für cat.hatch.override */ }
|
||||
// pruneEmpty: entfernt undefined-Felder; gibt undefined zurück, wenn leer.
|
||||
```
|
||||
|
||||
`setLineStyleId` / `setHatchId` setzen nur die Referenz; `hatch.enabled` ist ein
|
||||
Checkbox-Patch.
|
||||
|
||||
### 6.3 „Eigenschaften kopieren / einfügen"
|
||||
|
||||
Der bestehende `layerClipboard` (heute `{ color, lw }`) wird auf die volle
|
||||
Darstellung erweitert: `{ pen, hatch }` (die Override-tragenden Strukturen, ohne
|
||||
`code/name/visible/locked`). „Kopieren" liest `{ pen, hatch }` der Quelle,
|
||||
„Einfügen" patcht sie auf das Ziel. So überträgt sich der komplette Stift +
|
||||
Schraffur einer Ebene auf eine andere.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration bestehender Beispieldaten
|
||||
|
||||
Bestehende Projekte/Sample-Daten haben `LayerCategory { color, lw, hatch?: string }`
|
||||
und `LineStyle { weight, color, dash }` (ohne `kind`). Eine reine Lese-Zeit-
|
||||
Migration (`migrateProject(project)`), idempotent, beim Laden:
|
||||
|
||||
1. **LineStyle.kind ableiten** — aus `dash`:
|
||||
```
|
||||
dash == null || dash.length === 0 → "solid"
|
||||
sonst, wenn min(dash) sehr klein → "dotted" (heuristisch)
|
||||
sonst → "dashed"
|
||||
```
|
||||
(Eine genaue Zuordnung ist nicht nötig; `dash` bleibt führend, `kind` ist nur
|
||||
für das Dropdown.)
|
||||
|
||||
2. **Neutralen Basis-Linienstil sicherstellen** — falls die Bibliothek noch keinen
|
||||
generischen „Standard"-Stift hat, einen `lineStyle` mit
|
||||
`{ id: "ls-default", name: "Standard", kind: "solid", weight: <Ebenen-lw>, color: "#000", dash: null }`
|
||||
anlegen. (Pro Ebene wird der Stift referenziert; die Ebenen-spezifischen
|
||||
`color`/`lw` wandern in das **Override**, nicht in den Stil — so bleibt der
|
||||
Stil wiederverwendbar.)
|
||||
|
||||
3. **Pro LayerCategory `pen` bauen:**
|
||||
```ts
|
||||
pen = {
|
||||
lineStyleId: "ls-default",
|
||||
override: pruneEmpty({ color: cat.color, lw: cat.lw }),
|
||||
}
|
||||
```
|
||||
Damit ist die Darstellung **pixelgenau wie vorher** (gleiche Farbe, gleiche lw),
|
||||
nur jetzt über die Resolver-Kette.
|
||||
|
||||
4. **Pro LayerCategory `hatch` bauen** — aus dem alten `hatch?: string`:
|
||||
- War `hatch` ein gültiger `HatchStyle.id` → `{ enabled: true, hatchId: hatch }`.
|
||||
- War es ein Pattern-Name oder leer/unbekannt → `{ enabled: false, hatchId:
|
||||
<erste Hatch-id der Bibliothek> }` (Referenz muss existieren, aber inaktiv).
|
||||
So entsteht **keine** unbeabsichtigte Schraffur (Default heute: keine).
|
||||
|
||||
5. **Deprecated-Felder belassen** für eine Übergangsphase; nach Umstellung aller
|
||||
Lesepfade (`generatePlan`, `LayersPanel`-Swatch, Clipboard) in einem zweiten
|
||||
Schritt `color`/`lw` aus `LayerCategory` und der alte `hatch: string` entfernen.
|
||||
|
||||
Migration ist **idempotent**: Liegt `pen`/`hatch` bereits vor, wird die Ebene
|
||||
unverändert durchgereicht.
|
||||
|
||||
---
|
||||
|
||||
## 8. Build-Plan (phasiert)
|
||||
|
||||
**Phase 1 — Datenmodell & Resolver (keine UI-Sichtbarkeit).**
|
||||
- `LineKind` + `LINE_DASH`, `LineStyle.kind`, `LayerPen`, `LayerHatch`,
|
||||
`LayerCategory.pen/hatch` in `types.ts`.
|
||||
- `src/model/layerStyle.ts`: `PenRender`, `resolvePen`, `resolveLayerHatch`,
|
||||
`penMap`, `layerHatchMap`; `HatchRender` hierher ziehen + re-exportieren.
|
||||
- `migrateProject()` (Schritte §7) + Aufruf beim Laden/Seed.
|
||||
- `npx tsc -b` grün.
|
||||
|
||||
**Phase 2 — Generator umstellen.**
|
||||
- `generatePlan` nutzt `penMap`/`layerHatchMap` statt `categoryLwMap`.
|
||||
- `Primitive`-polygon `dash?`, line/arc `stroke?` ergänzen; `addWallPoche`/
|
||||
`addDoorSymbol`-Signaturen auf `PenRender` + `HatchRender|null`.
|
||||
- Ebenen-Default-Schraffur in „grob" und für Schicht-lose Flächen verdrahten.
|
||||
- Visuell prüfen via `node scripts/probe.mjs` (Geometrie unverändert, Farben/lw
|
||||
identisch zur Migration).
|
||||
|
||||
**Phase 3 — Panel.**
|
||||
- `LayersPanel`-Swatch liest aufgelöste Stift-Farbe (`resolvePen(...).color`).
|
||||
|
||||
**Phase 4 — Editor.**
|
||||
- `InlineEditor`-Inhalt für `kind: "layer"` auf die PEN/HATCH-Sektionen erweitern
|
||||
(§6), mit Dropdowns über `project.lineStyles` / `project.hatches`, Reset-Knöpfen,
|
||||
neuen i18n-Keys.
|
||||
- `layerClipboard` auf `{ pen, hatch }` erweitern; Kopieren/Einfügen anpassen.
|
||||
|
||||
**Phase 5 — Aufräumen.**
|
||||
- Deprecatete `color`/`lw`/`hatch: string` aus `LayerCategory` entfernen, sobald
|
||||
kein Lesepfad sie mehr nutzt; Sample-Daten direkt im neuen Format ablegen.
|
||||
|
||||
---
|
||||
|
||||
## 9. Offene Punkte / bewusst nicht jetzt
|
||||
|
||||
- **Pro-Geschoss-Overrides der Ebene** (eine Ebene anders je `DrawingLevel`):
|
||||
nicht in dieser Iteration; das Schema gilt geschossübergreifend (types.ts).
|
||||
Falls später nötig, als zweite Override-Ebene über demselben Resolver.
|
||||
- **Regelbasierte Overrides** (resources-graphics.md, `overrides.py`): orthogonal;
|
||||
würden nach der Ebenen-Auflösung greifen.
|
||||
- **Linienstil-Endkappen/Joins** und feinere Dash-Skalierung: bleiben in der
|
||||
PlanView (Darstellung), nicht im Modell.
|
||||
@@ -0,0 +1,672 @@
|
||||
# Parametrische Wände
|
||||
|
||||
Status: Implementiert (Phase A — Typ-System und Resolver in `src/model/`, kein UI).
|
||||
Dieses Dokument spezifiziert die **Parametrischen Wände**: regelbasierte Definitionen,
|
||||
die beim Auflösen eine Liste von `Wall`-Elementen erzeugen, anstatt sie einzeln vom
|
||||
Nutzer zeichnen zu lassen.
|
||||
|
||||
Bezugsdokumente: [elements.md](elements.md) (Wand-/Türmodell),
|
||||
[drawing-tools.md](drawing-tools.md) (Werkzeugsystem, Direktzeichnen),
|
||||
[state-architecture.md](state-architecture.md) (Projekt-Store),
|
||||
[resources-graphics.md](resources-graphics.md) (WallType/Component-Auflösung).
|
||||
|
||||
Implementierungsdateien:
|
||||
- `src/model/types.ts` — `ParametricWall`, `ParametricRule` und alle Regel-Varianten.
|
||||
- `src/model/parametricWalls.ts` — `resolveParametricWall()`, `applyRule()` und
|
||||
Hilfsfunktionen.
|
||||
|
||||
---
|
||||
|
||||
## 0. Überblick
|
||||
|
||||
Eine **parametrische Wand** (`ParametricWall`) ist kein festes `Wall`-Element, sondern
|
||||
ein **Regelwerk**, das beim Auflösen (`resolveParametricWall`) eine Menge von `Wall[]`-
|
||||
Elementen generiert. Die erzeugten Wände sind gewöhnliche `Wall`-Objekte; sie
|
||||
unterscheiden sich lediglich in ihrer Herkunft. Das semantische Modell (`Project`)
|
||||
bleibt die einzige Wahrheit — parametrische Wände sind eine Ressource in der
|
||||
Ressourcen-Bibliothek, nicht eine separate Laufzeit-Geometrie-Schicht.
|
||||
|
||||
```
|
||||
Project.parametricWalls: ParametricWall[]
|
||||
│
|
||||
│ resolveParametricWall(pw, floorId, context, defaultWallType)
|
||||
▼
|
||||
Wall[] ──→ normales Rendering über generatePlan / Viewport3D
|
||||
```
|
||||
|
||||
Erzeugte Wände können entweder **temporär** (zur Laufzeit, als Ergänzung zu
|
||||
`project.walls` im Rendering-Pfad) oder **eingebacken** (als `Wall[]` fest in
|
||||
`Project.walls` gespeichert) behandelt werden. Phase A legt nur den Auflöser fest;
|
||||
die Auswahl liegt bei der aufrufenden Komponente.
|
||||
|
||||
---
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
### 1.1 Schnellere Modellierung von Regelgrundrissen
|
||||
|
||||
Schweizer Wohnbauten folgen häufig einem 3-m-Achsraster (SIA-Norm, Modul-/
|
||||
Skelettbauweise). Zwanzig Wände eines Rasters von Hand zu zeichnen ist fehleranfällig
|
||||
und verhindert spätere parametrische Änderungen (z. B. Geschossanzahl, Rasterweite,
|
||||
Wandtyp).
|
||||
|
||||
Eine `GridRule` erzeugt dieses Muster aus wenigen Parametern (Achsabstand, Richtung,
|
||||
Bereich) und lässt sich mit einer einzigen Zahl auf „4-m-Büroraster" umstellen.
|
||||
|
||||
### 1.2 Kongruenz mit FreeCAD BIM / IFC
|
||||
|
||||
FreeCAD BIM kennt **ParametricObjects**, die ihre Geometrie aus Regeln ableiten (z. B.
|
||||
`ArchWall` mit `Length`, `Width`, `Height`). Obwohl das Datenformat hier kein IFC ist,
|
||||
schafft ein ähnliches Abstraktionsniveau eine spätere Brücke: Beim IFC-Export können
|
||||
parametrische Wände als `IfcWallStandardCase` mit konstanten Attributen exportiert
|
||||
werden — kein Informationsverlust gegenüber manuell gezeichneten Wänden.
|
||||
|
||||
### 1.3 Bedingte Wandtypen ohne manuelle Klassifizierung
|
||||
|
||||
Außenwände sind dicker als Innenwände; Trennwände zwischen Einheiten erfordern
|
||||
Schallschutz. Eine `ConditionalThicknessRule` (`condition: "exterior" → thickType`)
|
||||
weist den richtigen Wandtyp automatisch aus der geometrischen Lage zu — ohne dass der
|
||||
Nutzer jeden Wandabschnitt einzeln klassifizieren muss.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architektur
|
||||
|
||||
### 2.1 Typen (`src/model/types.ts`)
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Eine parametrische Wand-Regel — generiert automatisch Wall[]-Einträge für
|
||||
* ein gegebenes Geschoss. Lebt in Project.parametricWalls[].
|
||||
*/
|
||||
export interface ParametricWall {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/**
|
||||
* Geordnete Liste der anzuwendenden Regeln. Spätere Regeln können die
|
||||
* Ausgabe früherer verfeinern (z. B. Dickenzuweisung nach Raster).
|
||||
*/
|
||||
rules: ParametricRule[];
|
||||
/**
|
||||
* Rückfall-Wandtyp, falls eine Regel keinen eigenen `wallTypeId` nennt.
|
||||
*/
|
||||
defaultWallTypeId: string;
|
||||
}
|
||||
|
||||
/** Diskriminierte Union aller Regel-Varianten. */
|
||||
export type ParametricRule =
|
||||
| GridRule
|
||||
| ModuleRule
|
||||
| ConditionalThicknessRule
|
||||
| ReferenceLineRule
|
||||
| SequenceRule;
|
||||
```
|
||||
|
||||
### 2.2 Einbettung ins Projekt
|
||||
|
||||
```ts
|
||||
export interface Project {
|
||||
// … bestehende Felder …
|
||||
/**
|
||||
* Parametrische Wanddefinitionen (Ressourcen-Bibliothek). Optional, damit
|
||||
* bestehende Projekte/Tests ohne `parametricWalls` gültig bleiben.
|
||||
*/
|
||||
parametricWalls?: ParametricWall[];
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Resolver-Kontext (`src/model/parametricWalls.ts`)
|
||||
|
||||
```ts
|
||||
export interface ParametricContext {
|
||||
/** Das Ziel-Geschoss. */
|
||||
floor: DrawingLevel;
|
||||
/**
|
||||
* Optionale Rasterachsen (Phase C: verlinkter Grid-Ressource). Fehlen sie,
|
||||
* berechnet die Engine die Achsen aus GridRule.spacing.
|
||||
*/
|
||||
gridAxes?: { x: number[]; y: number[] };
|
||||
/**
|
||||
* Optionales Clipping-Polygon (Meter). Fehlt es, reicht das Raster über
|
||||
* einen Standardbereich (0 … spacing × 10).
|
||||
*/
|
||||
boundaryGeometry?: { boundary: Vec2[] };
|
||||
/**
|
||||
* Bereits im Projekt vorhandene Wände des Geschosses. Werden von
|
||||
* refinierenden Regeln (ConditionalThicknessRule, ReferenceLineRule) genutzt.
|
||||
*/
|
||||
existingWalls?: Wall[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Regel-Varianten
|
||||
|
||||
### 3.1 GridRule — Achsraster
|
||||
|
||||
Erzeugt parallele Wände auf einem gleichmäßigen Raster. Typischer Einsatz: Schweizer
|
||||
Wohnbau-Achsraster (3 m), Büro-Konstruktionsraster (6 m), strukturelle Raster mit
|
||||
fester Stützweite.
|
||||
|
||||
```ts
|
||||
export interface GridRule {
|
||||
type: "grid";
|
||||
/**
|
||||
* Optionaler Verweis auf eine Grid-Ressource (Phase C). Für Phase A wird
|
||||
* stattdessen `spacing` genutzt.
|
||||
*/
|
||||
gridId?: string;
|
||||
/** Rasterabstand in Metern (Default: 3.0). */
|
||||
spacing?: number;
|
||||
/**
|
||||
* Achsrichtungen: „x" = nur Wände entlang der Y-Achse,
|
||||
* „y" = nur Wände entlang der X-Achse, „both" = Vollraster.
|
||||
*/
|
||||
directions: "x" | "y" | "both";
|
||||
/** Optionaler Verweis auf Clipping-Polygon. */
|
||||
boundaryId?: string;
|
||||
/** Optionale Wandtyp-Übersteuerung; sonst defaultWallTypeId. */
|
||||
wallTypeId?: string;
|
||||
/** Lage der Wandachse über die Dicke (Vectorworks-Stil). */
|
||||
referenceLine?: WallReferenceLine;
|
||||
/** Optionale Höhenübersteuerung in Metern; sonst Geschosshöhe. */
|
||||
height?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Geometrieausgabe (top-down Grundriss):**
|
||||
|
||||
```
|
||||
directions: "x", spacing: 3.0, Bereich 0…12 m:
|
||||
|
||||
y
|
||||
│
|
||||
12 ──────────────────────────
|
||||
│
|
||||
9 ──────────────────────────
|
||||
│
|
||||
6 ──────────────────────────
|
||||
│
|
||||
3 ──────────────────────────
|
||||
│
|
||||
0 ──────────────────────────
|
||||
│
|
||||
└──────────────────────────► x
|
||||
0 12
|
||||
```
|
||||
|
||||
**Wann verwenden:**
|
||||
- Tragende Wände auf fester Stützweite (Wohnbau 3 m, Büro 6 m).
|
||||
- Vollraster (`"both"`) für strukturelle Rastersysteme.
|
||||
- In Kombination mit `ConditionalThicknessRule` zur automatischen Außen/Innen-Klassifizierung.
|
||||
|
||||
**Beispiel: Schweizer 3-m-Wohnraster**
|
||||
|
||||
```ts
|
||||
const pw: ParametricWall = {
|
||||
id: "pw-eg-raster",
|
||||
name: "EG Längswände 3m-Raster",
|
||||
defaultWallTypeId: "wt-innen-15",
|
||||
rules: [
|
||||
{
|
||||
type: "grid",
|
||||
spacing: 3.0,
|
||||
directions: "x", // Wände in X-Richtung (y = 0, 3, 6, 9, 12)
|
||||
wallTypeId: "wt-innen-15",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Auflösung:
|
||||
const walls = resolveParametricWall(pw, "floor-eg", {
|
||||
floor: egFloor,
|
||||
boundaryGeometry: { boundary: rectBoundary(0, 0, 12, 12) },
|
||||
}, defaultWallType);
|
||||
// → 5 Wände bei y = 0, 3, 6, 9, 12, je 12 m lang
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 ModuleRule — Bay-/Jochbauweise
|
||||
|
||||
Unterteilt eine Referenzspanne in gleiche Module und erzeugt Querwände an jedem
|
||||
Teilungspunkt. Typisch für Bürogebäude (6-m-Joch) oder Reihenhäuser mit modularer
|
||||
Erschließung.
|
||||
|
||||
```ts
|
||||
export interface ModuleRule {
|
||||
type: "module";
|
||||
/** Modulmaß in Metern (z. B. 6.0, 3.6). */
|
||||
moduleSize: number;
|
||||
/** Ausrichtung der Trennwände: „x" = Querwände senkrecht zu X, „y" = zu Y. */
|
||||
direction: "x" | "y";
|
||||
/**
|
||||
* Optionaler Verweis auf eine Referenzwand, die die Spannweite definiert.
|
||||
* Fehlt er, wird die Geschoss-Ausdehnung (Bounding-Box) genutzt.
|
||||
*/
|
||||
referenceWallId?: string;
|
||||
/** Optionale Wandtyp-Übersteuerung; sonst defaultWallTypeId. */
|
||||
wallTypeId?: string;
|
||||
referenceLine?: WallReferenceLine;
|
||||
height?: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Wann verwenden:**
|
||||
- Wenn sich Querwände aus einer Referenzspanne (Fassade, Achswand) ergeben.
|
||||
- Vorzug vor `GridRule`, wenn nur in eine Richtung unterteilt wird und eine
|
||||
Referenzwand die Spanne definiert.
|
||||
|
||||
**Beispiel: 6-m-Bay-Bürogebäude**
|
||||
|
||||
```ts
|
||||
const pw: ParametricWall = {
|
||||
id: "pw-buero-joch",
|
||||
name: "Büro 6m-Joch",
|
||||
defaultWallTypeId: "wt-beton-20",
|
||||
rules: [
|
||||
{
|
||||
type: "module",
|
||||
moduleSize: 6.0,
|
||||
direction: "x", // Querwände senkrecht zur X-Achse
|
||||
// referenceWallId: "W-sudfassade" → Spanne aus der Südwand ableiten
|
||||
},
|
||||
],
|
||||
};
|
||||
// resolveParametricWall → Querwände bei x = 6, 12, 18, 24, 30 (bei 36-m-Fassade)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 ConditionalThicknessRule — Bedingte Wandtypen
|
||||
|
||||
Weist bereits erzeugten Wänden (aus vorherigen Regeln in der Sequenz) einen anderen
|
||||
Wandtyp zu — abhängig von einer Bedingung. Gibt modifizierte **Kopien** zurück; die
|
||||
Eingabe-Wände werden nicht mutiert.
|
||||
|
||||
```ts
|
||||
export interface ConditionalThicknessRule {
|
||||
type: "conditional-thickness";
|
||||
/**
|
||||
* Bedingung für den Treffer:
|
||||
* • „exterior" — Wand liegt am Außenrand (Bounding-Box des Kontexts).
|
||||
* • „interior" — Wand liegt im Inneren.
|
||||
* • „bearing" — tragende Wand (Heuristikum: Wand läuft ±10° zu X/Y-Achse).
|
||||
* • beliebiger String — benutzerdefiniertes Tag (Phase C: Wall.tags[]).
|
||||
*/
|
||||
condition: "exterior" | "interior" | "bearing" | string;
|
||||
/** Ziel-Wandtyp, der bei Treffer gesetzt wird. */
|
||||
wallTypeId: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Wann verwenden:**
|
||||
- Immer in Kombination mit `GridRule` oder `ModuleRule` (als zweite Regel in
|
||||
`ParametricWall.rules`): Raster erzeugt, Dicke verfeinert.
|
||||
- Wenn Außen- und Innenwände denselben geometrischen Ursprung haben, aber
|
||||
verschiedene Aufbauten benötigen.
|
||||
|
||||
**Beispiel: Außen dick, Innen dünn**
|
||||
|
||||
```ts
|
||||
const pw: ParametricWall = {
|
||||
id: "pw-eg-komplett",
|
||||
name: "EG Vollraster mit Außenwand-Differenzierung",
|
||||
defaultWallTypeId: "wt-innen-15",
|
||||
rules: [
|
||||
{
|
||||
type: "grid", spacing: 3.0, directions: "both",
|
||||
wallTypeId: "wt-innen-15",
|
||||
},
|
||||
{
|
||||
type: "conditional-thickness",
|
||||
condition: "exterior",
|
||||
wallTypeId: "wt-aussen-36", // Außenwände erhalten dicken Aufbau
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 ReferenceLineRule — Wandachsen-Lage
|
||||
|
||||
Setzt `referenceLine` bei passenden Wänden einheitlich (Vectorworks-Stil: Achse
|
||||
links/rechts/mittig). Gibt modifizierte Kopien zurück.
|
||||
|
||||
```ts
|
||||
export interface ReferenceLineRule {
|
||||
type: "reference-line";
|
||||
/** Neue Lage der Wandachse, die einheitlich gesetzt wird. */
|
||||
referenceLine: WallReferenceLine; // "left" | "center" | "right"
|
||||
/**
|
||||
* Filterziel:
|
||||
* • „all" — alle Wände im aktuellen Satz.
|
||||
* • „exterior" — nur Außenwände.
|
||||
* • beliebiger String — benutzerdefiniertes Tag (Phase C).
|
||||
*/
|
||||
target: "all" | "exterior" | string;
|
||||
}
|
||||
```
|
||||
|
||||
**Wann verwenden:**
|
||||
- Außenwände auf `"left"` setzen (Achse liegt auf der Fassadenfläche).
|
||||
- Als abschließende Regel in einer `SequenceRule` nach Raster und Dickenzuweisung.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 SequenceRule — Zusammenfassung von Unterregeln
|
||||
|
||||
Fasst mehrere Regeln als atomare Einheit zusammen. Jede Unterregel erhält die Ausgabe
|
||||
der vorherigen als `existingWalls` — so können spätere Regeln frühere verfeinern.
|
||||
|
||||
```ts
|
||||
export interface SequenceRule {
|
||||
type: "sequence";
|
||||
rules: ParametricRule[];
|
||||
/**
|
||||
* Wenn true: Abbruch nach der ersten Unterregel, die mindestens eine Wand
|
||||
* generiert/verändert hat (Short-Circuit-Fallback).
|
||||
*/
|
||||
stopOnMatch?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Wann verwenden:**
|
||||
- Um eine zusammengehörige Kombination (Raster → Dicke → Referenzlinie) als
|
||||
Untermodul wiederzuverwenden — z. B. in unterschiedlichen Geschossen mit leicht
|
||||
abweichenden Parametern.
|
||||
|
||||
---
|
||||
|
||||
## 4. Resolver-API (`src/model/parametricWalls.ts`)
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Löst ein ParametricWall-Regelwerk zu einem Wall[]-Array für ein gegebenes
|
||||
* Geschoss auf.
|
||||
*
|
||||
* Ablauf:
|
||||
* 1. Regelwerk sequenziell ausführen; jede Regel erhält die Ausgabe der
|
||||
* vorherigen als existingWalls (ermöglicht Verfeinerung).
|
||||
* 2. Duplikate (gleicher Start-/Endpunkt innerhalb tolerance) entfernen.
|
||||
* 3. Bereinigte Wall[]-Liste zurückgeben.
|
||||
*
|
||||
* Die Ausgabe ist sofort bereit zur Einfügung in project.walls. Es werden
|
||||
* keine Seiteneffekte erzeugt — kein Store, kein Dispatch, kein React.
|
||||
*
|
||||
* @param parametricWall Das Regelwerk.
|
||||
* @param floorId ID des Ziel-Geschosses.
|
||||
* @param context Kontext (Geschoss-Objekt, Grid-Achsen, Grenzen, …).
|
||||
* @param defaultWallType Fallback-Wandtyp, wenn eine Regel keinen nennt.
|
||||
* @param tolerance Näherungstoleranz für Duplikat-Erkennung (Meter, Default 0.01).
|
||||
* @returns Wall[]-Array, bereit zur Einfügung.
|
||||
*/
|
||||
export function resolveParametricWall(
|
||||
parametricWall: ParametricWall,
|
||||
floorId: string,
|
||||
context: ParametricContext,
|
||||
defaultWallType: WallType,
|
||||
tolerance?: number,
|
||||
): Wall[];
|
||||
|
||||
/**
|
||||
* Dispatcher: delegiert eine Regel an die passende Implementierung.
|
||||
* Exportiert für Unit-Tests und erweiterbare Regeltypen.
|
||||
*/
|
||||
export function applyRule(rule: ParametricRule, ctx: RuleCtx): Wall[];
|
||||
|
||||
/**
|
||||
* Entfernt doppelte Wände: zwei Wände gelten als Duplikat, wenn Start- und
|
||||
* Endpunkt jeweils innerhalb tolerance übereinstimmen (vorwärts und rückwärts).
|
||||
*/
|
||||
export function deduplicateWalls(walls: Wall[], tolerance?: number): Wall[];
|
||||
```
|
||||
|
||||
### 4.1 Höhenauflösung
|
||||
|
||||
Die Wandhöhe (`Wall.height`) ergibt sich nach folgender Priorität:
|
||||
|
||||
1. `rule.height`, falls an der einzelnen Regel gesetzt.
|
||||
2. `context.floor.floorHeight` des Zielgeschosses.
|
||||
3. Fallback: 2.6 m (globaler Default, CONVENTIONS.md).
|
||||
|
||||
### 4.2 ID-Schema
|
||||
|
||||
```
|
||||
"pw-<floorId>-gx-<counter>" // GridRule, X-Achse
|
||||
"pw-<floorId>-gy-<counter>" // GridRule, Y-Achse
|
||||
"pw-<floorId>-mx-<counter>" // ModuleRule, X-Teilung
|
||||
"pw-<floorId>-ct-<counter>" // ConditionalThicknessRule
|
||||
"pw-<floorId>-rl-<counter>" // ReferenceLineRule
|
||||
```
|
||||
|
||||
IDs sind sessionlokal (Zähler startet bei 0 je Modullade). Eingebrannte Wände
|
||||
erhalten beim Commit neue stabile IDs über `uniqueId("W")` — konsistent mit dem
|
||||
Wand-Werkzeug (vgl. [drawing-tools.md §8](drawing-tools.md#8-id-vergabe--immutabilität)).
|
||||
|
||||
### 4.3 Duplikat-Erkennung
|
||||
|
||||
`deduplicateWalls` vergleicht Start-/Endpunkte beider Wände (vorwärts: A→B == A→B,
|
||||
und rückwärts: A→B == B→A) innerhalb einer Toleranz von 1 cm (0.01 m). Die **erste**
|
||||
Instanz wird behalten; spätere Duplikate werden verworfen. Dies ist wichtig bei
|
||||
Vollrastern (`"both"`), bei denen X- und Y-Wände exakt auf einem Rasterpunkt
|
||||
zusammentreffen könnten.
|
||||
|
||||
### 4.4 Verhalten bei ungültigen Eingaben
|
||||
|
||||
| Situation | Verhalten |
|
||||
|-----------|-----------|
|
||||
| `spacing <= 0` oder `moduleSize <= 0` | `[]` |
|
||||
| `referenceWallId` nicht in `existingWalls` | Fallback auf Bounding-Box, kein Fehler |
|
||||
| Unbekannter `condition`-String | `matchesCondition` gibt `false` zurück (kein Treffer) |
|
||||
| Unbekannter `SequenceRule`-Untertyp | TypeScript exhaustiveness-Guard, `[]` |
|
||||
| Segment mit `|end - start| < 1e-6` m | Kann durch deduplicateWalls entfernt werden |
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration ins Projekt
|
||||
|
||||
### 5.1 Ressourcen-Speicherung
|
||||
|
||||
`ParametricWall`-Einträge leben unter `Project.parametricWalls` (optionales Array).
|
||||
Sie sind Teil des `.cad.json`-Dokuments und werden mit dem Rest des Projekts gespeichert.
|
||||
|
||||
```ts
|
||||
// sampleProject.ts — Beispieleintrag
|
||||
export const sampleProject: Project = {
|
||||
// …
|
||||
parametricWalls: [
|
||||
{
|
||||
id: "pw-eg-raster",
|
||||
name: "EG Längswände 3m-Raster",
|
||||
defaultWallTypeId: "wt-innen-15",
|
||||
rules: [
|
||||
{ type: "grid", spacing: 3.0, directions: "x" },
|
||||
{ type: "conditional-thickness", condition: "exterior",
|
||||
wallTypeId: "wt-aussen-36" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 Rendering ohne UI (Phase A)
|
||||
|
||||
In Phase A werden parametrische Wände **nicht** automatisch gerendert. Der Auflöser
|
||||
ist eine reine Funktion; Aufrufer müssen ihn explizit einbinden. Mögliche Verwendung
|
||||
in `generatePlan` oder `Viewport3D`:
|
||||
|
||||
```ts
|
||||
// generatePlan.ts (Ergänzung, Phase A)
|
||||
const defaultWallType = project.wallTypes[0];
|
||||
const extraWalls = (project.parametricWalls ?? []).flatMap((pw) =>
|
||||
resolveParametricWall(pw, activeLevelId, {
|
||||
floor: activeFloor,
|
||||
boundaryGeometry: projectBoundary,
|
||||
}, defaultWallType)
|
||||
);
|
||||
const allWalls = [...project.walls, ...extraWalls];
|
||||
// … allWalls statt project.walls in der Rendering-Pipeline verwenden
|
||||
```
|
||||
|
||||
### 5.3 Keine UI in Phase A
|
||||
|
||||
Kein Command, kein Panel, kein Formular. `ParametricWall`-Einträge werden in Phase A
|
||||
ausschließlich **programmatisch** (Unit-Tests, `sampleProject`, direkte JSON-Bearbeitung
|
||||
des Projekts) erstellt.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ausblick: Folge-Phasen
|
||||
|
||||
### Phase B — UI und Command-Schnittstelle
|
||||
|
||||
- Neues Command (z. B. `PWWALL`) oder Ressourcen-Manager-Tab „Parametrische Wände"
|
||||
mit Formular-Editor je Regeltyp.
|
||||
- „Einbrennen" (Flatten): `ParametricWall` → feste `Wall[]` in `Project.walls`
|
||||
einfügen und den `ParametricWall`-Eintrag entfernen (unidirektional, Undo über Store).
|
||||
- Auswahl parametrischer Wände im Plan (als Gruppe); Grip-Editing der Raster-Parameter
|
||||
und Spannweiten.
|
||||
|
||||
### Phase C — Grid-Ressource und Schnittpunkt-Clipping
|
||||
|
||||
- `GridResource`: ein projektweites, benanntes Koordinatenraster (LV95-Offset,
|
||||
Rasterweite, Drehung), auf das mehrere `GridRule`-Instanzen via `gridId` verweisen.
|
||||
- Präzises Clipping: erzeugte Wände werden am Gebäudeumriss getrimmt — exakte
|
||||
`lineIntersect`-Berechnung statt Bounding-Box-Approximation.
|
||||
- Benutzerdefinierte Tags (`Wall.tags[]`) für komplexe `ConditionalThicknessRule`-
|
||||
Bedingungen jenseits von „exterior/interior/bearing".
|
||||
- IFC-Export: `ParametricWall`-Gruppen → `IfcWallStandardCase` mit parametrischen
|
||||
Attributen und `IfcRelDefinesByType`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Vollständige Anwendungsbeispiele
|
||||
|
||||
### 7.1 Schweizer Wohnbau: 3-m-Raster EG + 1.OG
|
||||
|
||||
Zwei-Geschoss-Wohnhaus, typisches CH-Wohnbauraster. Die Längswände beider Geschosse
|
||||
entstehen aus zwei `ParametricWall`-Einträgen mit identischen Regeln, unterschieden
|
||||
nur durch `floorId` beim Auflösen:
|
||||
|
||||
```
|
||||
Top-down (Grundriss):
|
||||
|
||||
y=12 ──────────────────────────── (W5)
|
||||
y=9 ──────────────────────────── (W4)
|
||||
y=6 ──────────────────────────── (W3)
|
||||
y=3 ──────────────────────────── (W2)
|
||||
y=0 ──────────────────────────── (W1)
|
||||
↑
|
||||
x=0 x=12
|
||||
```
|
||||
|
||||
```ts
|
||||
const rasterRegel: ParametricWall = {
|
||||
id: "pw-laengswand-raster",
|
||||
name: "Längswände 3m-Raster",
|
||||
defaultWallTypeId: "wt-innen-15",
|
||||
rules: [
|
||||
{ type: "grid", spacing: 3.0, directions: "x" },
|
||||
// Außenwände (y=0 und y=12) erhalten den dicken Aufbau:
|
||||
{ type: "conditional-thickness", condition: "exterior",
|
||||
wallTypeId: "wt-aussen-36" },
|
||||
// Außenwände: Achse liegt auf der Fassadenfläche:
|
||||
{ type: "reference-line", referenceLine: "left", target: "exterior" },
|
||||
],
|
||||
};
|
||||
|
||||
// EG auflösen:
|
||||
const wallsEG = resolveParametricWall(rasterRegel, "floor-eg",
|
||||
{ floor: egFloor, boundaryGeometry: { boundary: rect(0,0,12,12) } },
|
||||
project.wallTypes[0]);
|
||||
|
||||
// 1.OG auflösen (gleiche Regel, anderes Geschoss):
|
||||
const wallsOG = resolveParametricWall(rasterRegel, "floor-og1",
|
||||
{ floor: ogFloor, boundaryGeometry: { boundary: rect(0,0,12,12) } },
|
||||
project.wallTypes[0]);
|
||||
// Änderung spacing: 3.5 → beide Geschosse sofort konsistent.
|
||||
```
|
||||
|
||||
### 7.2 Vollraster mit Außen/Innen-Differenzierung
|
||||
|
||||
Gebäudeumriss als Rechteck; die Randwände erhalten automatisch den dicken
|
||||
Außenwand-Typ, alle anderen den dünnen Innenwand-Typ:
|
||||
|
||||
```ts
|
||||
const vollraster: ParametricWall = {
|
||||
id: "pw-eg-vollraster",
|
||||
name: "EG Vollraster mit Differenzierung",
|
||||
defaultWallTypeId: "wt-innen-15",
|
||||
rules: [
|
||||
{ type: "grid", spacing: 3.0, directions: "both" },
|
||||
{ type: "conditional-thickness", condition: "exterior",
|
||||
wallTypeId: "wt-aussen-36" },
|
||||
{ type: "conditional-thickness", condition: "interior",
|
||||
wallTypeId: "wt-innen-15" },
|
||||
{ type: "reference-line", referenceLine: "left", target: "exterior" },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
```
|
||||
─────┬─────┬─────┬─────
|
||||
│ │ │ │ │
|
||||
─────┼─────┼─────┼─────
|
||||
│ │ │ │ │
|
||||
─────┴─────┴─────┴─────
|
||||
|
||||
Rand-Segmente: wt-aussen-36 (dicker Aufbau)
|
||||
Innen-Segmente: wt-innen-15 (dünner Aufbau)
|
||||
```
|
||||
|
||||
### 7.3 Modulbauweise: 6-m-Joch, Bürogebäude
|
||||
|
||||
Längliches Bürogebäude, 36 m × 12 m, 6-m-Joch. Querwände entstehen automatisch;
|
||||
Entwurfsänderung (z. B. auf 7.2-m-Joch) erfordert eine einzige Zahl:
|
||||
|
||||
```ts
|
||||
const joch: ParametricWall = {
|
||||
id: "pw-buero-joch",
|
||||
name: "Büro 6m-Joch",
|
||||
defaultWallTypeId: "wt-beton-20",
|
||||
rules: [
|
||||
{
|
||||
type: "module",
|
||||
moduleSize: 6.0,
|
||||
direction: "x", // Querwände senkrecht zur X-Achse
|
||||
// referenceWallId: "W-sudfassade" → Spanne aus Referenzwand
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// resolveParametricWall → Querwände bei x = 6, 12, 18, 24, 30
|
||||
// (bei Bounding-Box minX=0, maxX=36, Enden selbst ausgespart)
|
||||
|
||||
// Änderung auf 7.2-m-Joch: moduleSize: 7.2
|
||||
// → 4 Trennwände bei x ≈ 7.2, 14.4, 21.6, 28.8 — automatisch neu berechnet.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Architektur-Garantien
|
||||
|
||||
- **Modell bleibt einzige Wahrheit.** `ParametricWall`-Definitionen sind Daten in
|
||||
`Project.parametricWalls`; `resolveParametricWall` ist eine **reine Funktion** ohne
|
||||
Side-Effects. Keine globale Laufzeit-Geometrie-Schicht.
|
||||
- **Erzeugte Wände sind gewöhnliche `Wall`-Objekte.** Alle nachgelagerten Systeme
|
||||
(`generatePlan`, `Viewport3D`, `computeJoins`) arbeiten unverändert; sie müssen
|
||||
nicht zwischen „parametrisch erzeugten" und „direkt gezeichneten" Wänden
|
||||
unterscheiden.
|
||||
- **Fehlertoleranz statt Absturz.** Unbekannte Regeltypen liefern `[]`; der TypeScript-
|
||||
exhaustiveness-Guard fängt fehlende `case`-Zweige zur Compilezeit. Unbekannte
|
||||
Bedingungsstrings in `ConditionalThicknessRule` geben `false` (kein Treffer) statt
|
||||
zu werfen.
|
||||
- **Keine vorzeitige Generalisierung.** Phase A liefert fünf Regel-Varianten und
|
||||
einen Auflöser. UI, Command-Schnittstelle und Grid-Ressource folgen in Phase B/C.
|
||||
- **Immutabilität.** Verfeinerungsregeln (`ConditionalThicknessRule`,
|
||||
`ReferenceLineRule`) geben modifizierte **Kopien** zurück; `existingWalls` werden
|
||||
nie mutiert — konsistent mit der `setProject`-Konvention (CONVENTIONS.md).
|
||||
@@ -0,0 +1,338 @@
|
||||
# Design — Pläne & Output
|
||||
|
||||
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||
> Bauteile: [elements.md](elements.md). Ressourcen/Stile: [resources-graphics.md](resources-graphics.md).
|
||||
|
||||
Hier gewinnen wir (ROADMAP §3, Phase 3 ⭐): **schöne, normgerechte 2D-Pläne**,
|
||||
automatisch aus dem Modell abgeleitet, druckfertig als Vektor-PDF. Dieses Dokument
|
||||
übersetzt DOSSIERs `schnitte.py`, `massstab.py`, `ausschnitte.py`, `kamera.py`,
|
||||
`dimensionen.py`, `layouts.py` in Browser-Module. Bezeichner englisch, Prosa
|
||||
deutsch, Meter.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ansichtstypen = Kamera-Projektion + optionaler Schnitt
|
||||
|
||||
Vereinheitlichtes Modell (ROADMAP §2c, im Spike als `DrawingLevelKind` angelegt):
|
||||
|
||||
| Typ | Projektion | Schnitt | Erzeugung |
|
||||
|---|---|---|---|
|
||||
| **Grundriss** | Ortho Top | horizontal auf `okff + cutHeight` | symbolisch aus Footprint (Pfad A) |
|
||||
| **Schnitt** | Ortho Front (Richtung) | vertikale Schnittebene + Tiefe | 3D-Projektion/HLR (Pfad B) |
|
||||
| **Ansicht** | Ortho Front (Richtung) | kein Schnitt (Fassade außen) | 3D-Projektion/HLR (Pfad B) |
|
||||
| **Perspektive** | 3D perspektivisch | — | Three.js direkt |
|
||||
|
||||
```ts
|
||||
type ViewType = "plan" | "section" | "elevation" | "perspective";
|
||||
interface DerivedView { // was der Viewport gerade zeigt
|
||||
type: ViewType;
|
||||
levelId?: string; // Geschoss (plan) bzw. Schnitt/Ansicht (DrawingLevel)
|
||||
camera: CameraState;
|
||||
cut?: CutSpec; // Clipping-Spezifikation (s.u.)
|
||||
detailLevel: DetailLevel;
|
||||
}
|
||||
interface CutSpec {
|
||||
planes: { point: Vec3; normal: Vec3 }[]; // 1 (plan/elevation) oder 2 (section: cut+back)
|
||||
}
|
||||
```
|
||||
|
||||
**Zwei Wege zum Plan** (zentrale Architektur-Erkenntnis, ROADMAP §3) — wir bauen
|
||||
**beide**:
|
||||
- **A) Grundriss = symbolisch** aus den Parametern (`plan/generatePlan.ts`, im
|
||||
Spike). Schnell, exakt, vektorbasiert. Kein Mesh-Schnitt.
|
||||
- **B) Schnitt & Ansicht = 3D-Projektion mit Hidden-Line-Removal** (`plan/
|
||||
generateSection.ts`, §4). Durch das zusammengebaute Gebäude.
|
||||
|
||||
---
|
||||
|
||||
## 2. Schnitt & Ansicht — Datenmodell & Aktivierung
|
||||
|
||||
DOSSIER speichert Schnitte als Zeichnungsebenen-Eintrag (`type:"schnitt"`) mit
|
||||
`linePts/dirSign/depthBack/cutAtLine/heightMin/heightMax/projection`
|
||||
(`schnitte.create_schnitt_entry`). Im Spike sind die Felder als `DrawingLevel`
|
||||
(`kind:"section"|"elevation"`, `linePoints`, `directionSign`) angelegt — wir
|
||||
ergänzen:
|
||||
|
||||
```ts
|
||||
interface SectionLevel extends DrawingLevel { // kind: "section" | "elevation"
|
||||
linePoints: [Vec2, Vec2];
|
||||
directionSign: 1 | -1; // Blickrichtung (Pfeil im Plan)
|
||||
depthBack: number; // Tiefe hinter der Schnittlinie (default 8)
|
||||
cutAtLine: boolean; // true=Schnitt (cut+back), false=Ansicht (nur back)
|
||||
heightMin: number; heightMax: number;
|
||||
projection: "parallel" | "perspective";
|
||||
}
|
||||
```
|
||||
|
||||
**Aktivierung** (Port `schnitte.activate_schnitt`):
|
||||
1. `view_dir` = senkrecht zur Linie in XY, Richtung = `directionSign`.
|
||||
2. **3D-Vorschau:** `THREE.Plane`s setzen —
|
||||
- Cut (nur `cutAtLine`): auf der Linie, Normale `+view_dir`.
|
||||
- Back (immer): um `depthBack` in `+view_dir` versetzt, Normale `−view_dir`.
|
||||
- via `renderer.localClippingEnabled = true`, `material.clippingPlanes`.
|
||||
3. **Kamera:** `OrthographicCamera`, Position `mid − view_dir·dist`, Target `mid`,
|
||||
Up `+Z`; Zoom auf BBox (`linePoints` + Höhenbereich + `depthBack`). Bei
|
||||
`perspective`: `PerspectiveCamera` + FOV.
|
||||
4. **Vektor-Ergebnis:** HLR (§4).
|
||||
|
||||
**2D-Plan-Symbol** (Schnittmarke im Grundriss, Port `make_schnitt_symbol`): Linie
|
||||
+ Endpfeile in `view_dir`, Beschriftung. Bleibt im Grundriss sichtbar (liegt auf
|
||||
einer eigenen Ebene, z.B. `18 Schnittlinien`). **Doppelklick** auf das Symbol
|
||||
aktiviert den Schnitt (`onDoubleClick` auf das SVG-Symbol → `setActiveLevel(id)`,
|
||||
≙ DOSSIER `_SchnittDoubleClickHandler`).
|
||||
|
||||
**Grip-Editing der Schnittlinie:** Endpunkte als Grips im Grundriss; Ziehen
|
||||
aktualisiert `linePoints` + Symbol + (falls aktiv) Clipping — ohne Re-Zoom der
|
||||
3D-View (DOSSIER `skip_view`-Flag-Äquivalent: Drag aktualisiert nur die Clip-
|
||||
Ebenen, nicht die Kamera).
|
||||
|
||||
---
|
||||
|
||||
## 3. Massstab (Scale) — pro Viewport, Auto-DPI
|
||||
|
||||
### 3.1 Mathematik (Port `massstab._compute_scale`, identisch im Browser)
|
||||
```
|
||||
frustumWidth_world = ortho-Kamera-Breite in Modell-Einheiten (Meter)
|
||||
frustumWidth_mm = frustumWidth_world * 1000 (Meter→mm)
|
||||
screenWidth_mm = canvasWidthCssPx * 25.4 / dpi
|
||||
N (1:N) = frustumWidth_mm / screenWidth_mm
|
||||
```
|
||||
- **Nur bei Orthografie** sinnvoll; in Perspektive zeigt die UI „—" (wie DOSSIER).
|
||||
- **DPI:** Browser kennt das nativ — `dpi = 96 * window.devicePixelRatio` (CSS
|
||||
definiert 1 px = 1/96 inch). Das ersetzt DOSSIERs CoreGraphics-JXA-Detection
|
||||
komplett und ist exakter. Optional manuell kalibrierbar (Eingabe in den
|
||||
Settings), persistiert pro Projekt.
|
||||
- **Massstab setzen** (1:N → Zoom): `frustumWidth_world = screenWidth_mm · N /
|
||||
1000`; bei `THREE.OrthographicCamera` `camera.zoom = canvasWidthCssPx /
|
||||
(frustumWidth_world / metersPerPixelAtZoom1)` bzw. direkt `left/right` setzen.
|
||||
|
||||
```ts
|
||||
// plan/scale.ts
|
||||
function computeScale(view: { frustumWidthWorld; canvasCssWidthPx; dpi }): number|null // 1:N
|
||||
function applyScale(camera: THREE.OrthographicCamera, n: number, canvasCssWidthPx, dpi): void
|
||||
const SCALE_PRESETS = [1,5,10,20,25,50,100,200,500,1000]; // 1:N Dropdown
|
||||
```
|
||||
|
||||
### 3.2 Massstabs-abhängige Skalierung (DOSSIER-Stärke)
|
||||
Bei 1:N müssen **Strichstärken** und **Schraffuren** lesbar bleiben:
|
||||
- **Plotweight → SVG stroke-width:** `strokeWidthPx = lwMm / 25.4 · dpi`
|
||||
(Welt-unabhängig; die Linie ist im Plan immer z.B. 0.25 mm dick). DOSSIER
|
||||
skaliert dafür die PlotWeights (`_apply_scaled_lineweights`); im SVG-Modell
|
||||
rechnen wir die mm-Strichstärke direkt in Pixel — **viel einfacher**, da SVG
|
||||
von Natur aus papierbezogen ist.
|
||||
- **Schraffur-Skalierung:** DOSSIER nutzt `factor = sqrt(N)/10` (1:100 ⇒ 1.0,
|
||||
1:50 ⇒ 0.71, 1:500 ⇒ 2.24; `apply_scaled_hatches`). Port: SVG `<pattern>`-
|
||||
`patternTransform="scale(factor)"` bzw. `patternUnits` so wählen, dass das Muster
|
||||
die gewünschte Paper-Dichte hat. Formel 1:1 übernehmen.
|
||||
- **Linetype-Dash:** `stroke-dasharray` in mm→px, ebenfalls papierbezogen.
|
||||
|
||||
> **Kernvorteil gegenüber DOSSIER:** Weil der Plan **SVG/Paper-Space** ist,
|
||||
> entfällt das fragile Welt↔Bildschirm-Plotweight-Rescaling (DOSSIER `write_plotweight`,
|
||||
> `read_plotweight`, Print-Display-Toggle). Strichstärke und Maßlinien sind direkt
|
||||
> in mm definiert und werden 1:1 gedruckt.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schnitt/Ansicht-Projektion (HLR) — Risiko #4
|
||||
|
||||
Vertikale Schnitte/Ansichten brauchen **echte 3D-Projektion mit verdeckten
|
||||
Kanten** durch das zusammengebaute Gebäude.
|
||||
|
||||
```ts
|
||||
// plan/generateSection.ts (läuft im Web Worker via Comlink)
|
||||
interface SectionRequest { meshes: SerializedBrep[]; cut: CutSpec; camera: CameraState; }
|
||||
interface SectionResult {
|
||||
cutLines: Primitive[]; // Schnittkanten (dick) — geschnittene Bauteile
|
||||
cutFaces: Primitive[]; // Schnittflächen → Component-Schraffur (Poché)
|
||||
visibleLines: Primitive[]; // sichtbare Projektion (dünn)
|
||||
hiddenLines?: Primitive[]; // verdeckte (gestrichelt, optional)
|
||||
}
|
||||
function generateSection(req: SectionRequest): SectionResult
|
||||
```
|
||||
|
||||
- **Kernel:** **OpenCascade.js** `HLRBRep_Algo` / `HLRBRep_HLRToShape` (B-Rep →
|
||||
sichtbare/verdeckte Kanten). Eingabe = die Bauteil-Breps (Wände/Decken/Treppen…),
|
||||
Projektionsrichtung aus `camera`. Alternativ Mesh-basiert (langsamer, weniger
|
||||
sauber).
|
||||
- **Schnittflächen-Schraffur (Section-Style):** wo die Cut-Plane ein Bauteil
|
||||
durchschneidet, entsteht eine Fläche → mit der Component-Schraffur füllen
|
||||
(resources-graphics.md). ≙ DOSSIER `SectionStyle` (Hatch + Schnittkante +
|
||||
Silhouette), nur dass wir es als SVG-Fill rendern statt als Rhino-Layer-Property.
|
||||
- **Performance:** schwer → **Worker + Cache**. Cache-Key =
|
||||
hash(sichtbare Element-IDs + Geometrie-Hash + CutSpec + camera). Nur neu rechnen,
|
||||
wenn sich relevante Eingaben ändern (ROADMAP Risiko #4). Geschnittene vs. dahinter
|
||||
liegende Geometrie über die Back-Plane begrenzen (`depthBack`).
|
||||
- **Stufenweise:** (a) Ansicht ohne Verdeckung (einfache Projektion) → (b) HLR
|
||||
sichtbar → (c) verdeckte Kanten gestrichelt → (d) Schnittflächen-Poché.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ausschnitte (View-Snapshots)
|
||||
|
||||
Navigation über 50+ Ansichten ohne Ordner-Wildwuchs (DOSSIER `ausschnitte.py`).
|
||||
Ein Snapshot speichert **Kamera + Sichtbarkeit + Massstab + Darstellung + Overrides**.
|
||||
|
||||
```ts
|
||||
// in Project: viewSnapshots: ViewSnapshot[]
|
||||
interface ViewSnapshot {
|
||||
id; name; folder?: string;
|
||||
camera: CameraState; // pos/target/up/parallel/fov + frustumWidth (Zoom!)
|
||||
scale: number; // 1:N (DOSSIER speichert "1:50"-String)
|
||||
detailLevel: DetailLevel; // LoD-Override (DOSSIER darstellung)
|
||||
visibility: VisibilityState; // pro Geschoss + pro Ebene visible/locked
|
||||
layerCombinationId?: string; // ODER Verweis auf Layer-Kombi (live) — s.u.
|
||||
overrides?: { presetId?: string; enabled: boolean };
|
||||
}
|
||||
interface CameraState { position; target; up; parallel; fov?; frustumWidth?; }
|
||||
```
|
||||
|
||||
- **Save:** aktuellen `ui`-Zustand einfrieren (Port `_capture`: Kamera inkl.
|
||||
Frustum-Breite für exakten Zoom-Restore, Layer-Sichtbarkeit, Massstab, LoD).
|
||||
- **Restore:** Snapshot → `ui` + ggf. `project`-Sichtbarkeit anwenden (Port
|
||||
`_restore`): Kamera, Sichtbarkeit (oder referenzierte Layer-Kombi), LoD,
|
||||
optional Overrides-Preset. Da alles im Store liegt, ist das ein einfacher
|
||||
State-Set — kein Multi-Panel-Force-Send-Tanz wie in DOSSIER.
|
||||
- **Ordner, Umbenennen, Duplizieren, Settings-Drawer** wie DOSSIER (`_duplicate`,
|
||||
`_set_field`, `_open_settings_window` → React-Drawer statt Eto-Form).
|
||||
|
||||
### 5.1 Layer-Kombinationen (Presets)
|
||||
```ts
|
||||
interface LayerCombination { id; name; visibility: VisibilityState; }
|
||||
```
|
||||
Bauphasen/Varianten/MEP per Klick (DOSSIER `_save_preset`/`apply_layer_preset_by_name`).
|
||||
Snapshot kann **live** auf eine Kombi verweisen (folgt Änderungen) **oder**
|
||||
eingefroren den `visibility`-Stand halten — genau DOSSIERs Wahl (`layerCombination`
|
||||
vs. `layers`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Kamera-Presets & Norden-Rotation ⭐
|
||||
|
||||
Port `kamera.py`. Schnelle Ansichtswechsel + Georeferenzierung (Swisstopo, Phase 4).
|
||||
|
||||
```ts
|
||||
// viewport/camera.ts
|
||||
function setCardinal(cam, dir: "N"|"E"|"S"|"W", northAngle: number): void
|
||||
function setIso(cam, octant: "NE"|"SE"|"SW"|"NW"|..., northAngle: number): void
|
||||
function setTop(cam, northAngle: number): void // Plan-Norden zeigt nach oben
|
||||
// northAngle = Grad im Uhrzeigersinn von +Y (DOSSIER dossier_north_angle, default 0)
|
||||
const north = (deg) => ({ x: Math.sin(rad(deg)), y: Math.cos(rad(deg)) });
|
||||
interface CameraPreset { id; name; camera: CameraState; } // benutzerdefiniert, gespeichert
|
||||
```
|
||||
- **Norden-Rotation:** alle Kardinal-/Iso-Richtungen werden um `northAngle`
|
||||
rotiert (Port `set_cardinal_view`, `_set_iso`, `set_top_view`). `northAngle`
|
||||
liegt im `Project` (georeferenziert zu swissBUILDINGS).
|
||||
- **Benutzer-Presets:** speichern/laden wie DOSSIER (`_load_presets`/`_save_presets`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Bemaßung (Dimensions)
|
||||
|
||||
Port `dimensionen.py`. Maße werden **aus dem Modell abgeleitet** (Wand-Dicken,
|
||||
Geschoss-Höhen, Öffnungen) + manuelle Maßketten.
|
||||
|
||||
```ts
|
||||
interface Dimension {
|
||||
id; floorId; categoryCode; // liegt auf einer Ebene
|
||||
kind: "linear" | "chain" | "aligned" | "level"; // Einzel|Kette|ausgerichtet|Höhenkote
|
||||
refs: DimRef[]; // Bezugspunkte (frei ODER an Element gebunden)
|
||||
offset: number; // Abstand der Maßlinie vom Objekt
|
||||
style: DimStyleId; // Pfeile, Texthöhe, Einheiten
|
||||
}
|
||||
type DimRef = { point: Vec2 } | { elementId: string; anchor: "start"|"end"|"jamb"|... };
|
||||
```
|
||||
- **Auto-Bemaßung** (Phase 3): Außenketten (Gebäude-Hülle), Achsketten (Achsraster),
|
||||
Öffnungs-Ketten — aus der Geometrie generiert, dann editierbar.
|
||||
- **9-Punkt-Objekt-Info** (DOSSIER ROADMAP §11): Bounding-Box-Maße lesen +
|
||||
Element via Greifen verschieben/skalieren/rotieren — direkt im Plan.
|
||||
- **Rich-Text-Indizes** (Bold/Hoch-/Tiefstellung) für Maßzahlen — als SVG
|
||||
`<tspan>` mit `baseline-shift` (resources-graphics.md §Rich-Text).
|
||||
- **Massstabsbezug:** Texthöhe/Pfeilgröße in **Paper-mm**, rendern × Massstab —
|
||||
konsistent mit §3.2.
|
||||
|
||||
---
|
||||
|
||||
## 8. Plansätze (Sheets) & PDF-Export
|
||||
|
||||
DOSSIER nutzt Rhinos `RhinoPageView` + `Detail`-Viewports + `FilePdf`
|
||||
(`layouts.py`). Browser-Äquivalent: eigenes Sheet-Modell + SVG → PDF.
|
||||
|
||||
### 8.1 Datenmodell
|
||||
```ts
|
||||
interface Sheet {
|
||||
id; name; folder?;
|
||||
paper: "A0"|"A1"|"A2"|"A3"|"A4"|"Letter"; landscape: boolean;
|
||||
viewports: SheetViewport[];
|
||||
titleBlock?: TitleBlock; // Titelblock (Projekt/Plan/Massstab/Datum)
|
||||
}
|
||||
interface SheetViewport { // ≙ DOSSIER Detail + gebundener Ausschnitt
|
||||
id; rect: { x; y; w; h }; // Position auf dem Blatt (mm)
|
||||
source: { kind: "level"; levelId } | { kind: "snapshot"; snapshotId };
|
||||
scale: number; // 1:N
|
||||
clipToRect: boolean;
|
||||
}
|
||||
const PAPER_MM = { A0:[841,1189], A1:[594,841], A2:[420,594], A3:[297,420],
|
||||
A4:[210,297], Letter:[216,279] }; // Port PAPER_SIZES_MM
|
||||
```
|
||||
|
||||
### 8.2 Sheet-Editor
|
||||
`sheets/SheetEditor.tsx`: Blatt als SVG in mm, Viewports per Drag platzieren/
|
||||
skalieren, Quelle (Geschoss/Snapshot) + Massstab zuweisen. Ein Viewport rendert
|
||||
den abgeleiteten Plan/Schnitt **bei seinem Massstab** in sein `rect` (≙ DOSSIER
|
||||
`apply_snapshot_to_detail`). Bei Änderung der Quelle re-derivieren (live), kein
|
||||
manuelles Re-Sync nötig (DOSSIER war Snapshot-Mode).
|
||||
|
||||
### 8.3 Detail↔Ausschnitt-Bindung
|
||||
`SheetViewport.source.snapshotId` ist die Bindung (DOSSIER `_BIND_KEY`). „Alle
|
||||
aktualisieren" = alle Viewports neu rendern; weil rein abgeleitet, ist das
|
||||
automatisch. Umbenennen synchronisiert Titelblock + Schnitt-Symbol (DOSSIER
|
||||
Detail↔Ausschnitt-Sync).
|
||||
|
||||
### 8.4 PDF-Export (Vektor, Multi-Page, @DPI)
|
||||
Port `layouts._export_pdf`, aber **vektorbasiert** (DOSSIER rasterte via
|
||||
`ViewCaptureToFile` @DPI — wir bleiben Vektor → schärfer, kleiner):
|
||||
|
||||
```ts
|
||||
// sheets/exportPdf.ts
|
||||
async function exportSheetsPdf(sheets: Sheet[], opts: { vector: boolean }): Promise<Blob>
|
||||
```
|
||||
- **Vektor-Pfad (bevorzugt):** jeder Sheet-Viewport rendert seinen Plan als SVG;
|
||||
SVG → PDF via **`svg2pdf.js` + `jsPDF`** (oder `pdf-lib` mit eigenem Pfad-
|
||||
Emit). Eine PDF-Seite pro Sheet, Größe = `PAPER_MM`. Strichstärken/Schraffuren
|
||||
sind bereits in mm (§3.2) → 1:1 druckbar.
|
||||
- **Raster-Fallback** (Perspektiven/3D-Inhalte): Three.js `renderer` → Canvas →
|
||||
PNG @DPI → in PDF-Seite (`px = mm/25.4·dpi`, Port der DOSSIER-Pixelrechnung).
|
||||
- **Speichern:** Blob → File System Access API (`showSaveFilePicker`) / Download.
|
||||
|
||||
---
|
||||
|
||||
## 9. Primitive & SVG-Serializer (gemeinsame Basis)
|
||||
|
||||
Alle Pläne (Grundriss, Schnitt, Ansicht, Sheet-Viewport) sprechen dieselbe
|
||||
`Primitive`-Sprache (heute in `generatePlan.ts`), erweitert um Schraffur/Text:
|
||||
|
||||
```ts
|
||||
type Primitive =
|
||||
| { kind:"polygon"; pts:Vec2[]; fill:string; stroke:string; strokeWidthMm:number; hatchId?:string }
|
||||
| { kind:"line"; a:Vec2; b:Vec2; styleId:string } // styleId → LineStyle (mm, dash)
|
||||
| { kind:"arc"; center:Vec2; from:Vec2; to:Vec2; r:number; styleId:string }
|
||||
| { kind:"text"; at:Vec2; text:string; heightMm:number; align; font; rich?:RichRun[] }
|
||||
| { kind:"symbol"; at:Vec2; symbolId:string; scale:number; angle:number }; // Symbol-Bibliothek
|
||||
interface Plan { primitives: Primitive[]; bounds: Rect; }
|
||||
```
|
||||
- **SVG-Serializer** (`plan/primitives.ts`): Primitive → SVG-Elemente.
|
||||
`strokeWidthMm` → px via `mm·dpi/25.4`; `hatchId` → `<pattern>`-Referenz;
|
||||
`styleId` → `stroke`/`stroke-dasharray`. Derselbe Serializer für Bildschirm
|
||||
*und* PDF.
|
||||
- **DXF-Export** (Phase 4): dieselben Primitive → DXF-Entities (`dxf`-Writer-lib).
|
||||
|
||||
---
|
||||
|
||||
## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen)
|
||||
|
||||
1. **Phase 1 (MVP):** Grundriss-Generator ✅ ausbauen (Schraffuren, LoD), Live-
|
||||
Grundriss neben 3D, Basis-Bemaßung; Massstab pro Viewport (§3).
|
||||
2. **Phase 3 ⭐:** Schnitt/Ansicht via HLR (§4, Worker), Auto-Bemaßung (§7),
|
||||
Ausschnitte + Layer-Kombinationen (§5), Kamera-Presets + Norden (§6),
|
||||
Sheets + Vektor-PDF (§8).
|
||||
3. **Phase 4:** DXF-Export (§9), Detail↔Ausschnitt-Sync-Politur.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Design — Ressourcen & Grafik
|
||||
|
||||
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||
> Bauteile: [elements.md](elements.md). Output/Pläne: [plans-output.md](plans-output.md).
|
||||
|
||||
Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Vectorworks-Stil),
|
||||
regelbasierte Overrides, Symbol-/Text-Bibliotheken und Detailgrad-Steuerung. Sie
|
||||
wird **beim Rendern angewandt, nie in die Geometrie eingebacken** (ROADMAP §2b).
|
||||
Dieses Dokument übersetzt DOSSIERs `styles.py`/`gestaltung.py`, `mass_style.py`,
|
||||
`overrides.py`, `library.py`, `text_create.py`. Bezeichner englisch, Prosa
|
||||
deutsch.
|
||||
|
||||
---
|
||||
|
||||
## 1. Resource Manager — verwaltete Bibliotheken
|
||||
|
||||
Drei Bibliotheken im `Project.resources`-Block; **alles verweist per id**, zentral
|
||||
änderbar (ROADMAP §2d). Verweis-Kette: 2D-Objekte/Ebenen → LineStyle/Hatch;
|
||||
Hatch → LineStyle; Component → Hatch (+3D-Material).
|
||||
|
||||
```ts
|
||||
interface Resources {
|
||||
lineStyles: LineStyle[];
|
||||
hatches: Hatch[];
|
||||
components: Component[];
|
||||
}
|
||||
|
||||
interface LineStyle { // Line Manager
|
||||
id; name;
|
||||
weight: number; // Strichstärke in mm (≙ Rhino PlotWeight)
|
||||
color: string; // hex
|
||||
dash: number[]; // Strichmuster in mm ([] = durchgezogen)
|
||||
}
|
||||
|
||||
interface Hatch { // Hatch Manager
|
||||
id; name;
|
||||
pattern: PatternId; // "solid" | "diagonal" | "insulation" | "concrete" | ...
|
||||
scale: number; // Grundmaßstab des Musters
|
||||
angle: number; // Grad
|
||||
lineStyleId: string; // Linien der Schraffur → Line Manager
|
||||
}
|
||||
|
||||
interface Component { // Component Manager (= DOSSIER-Material, erweitert)
|
||||
id; name;
|
||||
hatchId: string; // Schnitt-Schraffur → Hatch Manager
|
||||
color3d: string; // 3D-Diffusfarbe
|
||||
texture3d?: TextureRef; // optionale PBR-Textur (Phase 3)
|
||||
pbr?: { roughness; metalness; opacity; ior }; // Material-Bibliothek (ROADMAP §11)
|
||||
joinPriority: number; // Verschneidungs-Rang (DOSSIER _MATERIAL_PRIO als Daten)
|
||||
}
|
||||
```
|
||||
|
||||
**Migration vom heutigen Stand:** Der Spike hat `Material { color, planFill, hatch }`
|
||||
und `Layer { materialId, thickness, priority }`. Ziel: `Material → Component`
|
||||
(`color→color3d`, `planFill→` Fill aus `hatch.pattern==solid`+Farbe, `hatch`-Enum
|
||||
→ `hatchId`), `Layer.priority → Component.joinPriority` (Priorität wandert vom
|
||||
Layer zum Component, damit man sie nur einmal pflegt — siehe elements.md §1.3).
|
||||
|
||||
### 1.1 Manager-UI
|
||||
`managers/ComponentManager.tsx`, `HatchManager.tsx`, `LineManager.tsx` — je eine
|
||||
Liste mit CRUD + Vorschau (Three-Sphere für Component-3D, SVG-Swatch für
|
||||
Hatch/Line). **Seeds** beim ersten Projekt (DOSSIER-Defaults):
|
||||
- LineStyles: 0.13 / 0.18 / 0.25 / 0.35 / 0.50 mm (aus `DEFAULT_LAYER_SCHEMA`-lw).
|
||||
- Hatches: `solid`, `diagonal`, `concrete`, `insulation` (Dämmung).
|
||||
- Components: Stahlbeton (prio 800), Beton (800), Mauerwerk (600), Ziegel (550),
|
||||
Holzständer (400), Dämmung (200), Putz (100) — exakt DOSSIER `_MATERIAL_PRIO`
|
||||
(elements.md §1.3). Plus Glas (transparent), Holz-Türblatt (DOSSIER
|
||||
`_OEFF_PIECE_DEFS`).
|
||||
|
||||
### 1.2 Render-Anwendung
|
||||
- **3D:** Component → `MeshStandardMaterial` (`color3d`/`pbr`), pro `componentId`
|
||||
gecacht (`viewport/scene.ts`).
|
||||
- **Plan/Schnitt:** geschnittene Schicht → Polygon mit `fill` (Component-Farbe) +
|
||||
`<pattern>` aus `hatchId`. Pattern als SVG `<pattern>` mit `patternTransform`
|
||||
für Massstab (plans-output.md §3.2). Der Hatch nutzt seinen `lineStyleId` für
|
||||
die Musterlinien.
|
||||
|
||||
---
|
||||
|
||||
## 2. Mehrschichtige Aufbauten ↔ Ressourcen
|
||||
`WallType.layers[].componentId` / `SlabType.layers[].componentId` verweisen auf
|
||||
Components. 3D und Plan lesen dieselben Schichten (elements.md §1). Die
|
||||
**Prioritäts-Verschneidung** (Risiko #1) liest `Component.joinPriority`:
|
||||
höhere Priorität läuft am Stoß durch (Backbone), niedrigere stößt seitlich an —
|
||||
Algorithmus in elements.md §1.3 (Port DOSSIER `_t_junction_layer_overrides`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Stile & Element-Override (Selektions-Attribute)
|
||||
|
||||
DOSSIERs GESTALTUNG-Panel (`styles.py`) setzt Farbe/Lineweight/Linetype/Hatch auf
|
||||
die *Selektion*. Browser-Äquivalent — zwei Ebenen, in Render-Reihenfolge:
|
||||
|
||||
```
|
||||
ByLayer (Ebenen-Default) → Element-Style (styleId) → Override-Regeln → gerendert
|
||||
```
|
||||
|
||||
```ts
|
||||
// Effektiver Stil eines Elements (resolve beim Rendern, nie persistiert)
|
||||
interface EffectiveStyle { color; lineStyleId; hatchId?; }
|
||||
function resolveStyle(project, el, doc): EffectiveStyle {
|
||||
// 1) Default aus LayerCategory(categoryCode)
|
||||
// 2) überschrieben durch el.styleId (Element-Override, optional)
|
||||
// 3) überschrieben durch passende Override-Regeln (§4)
|
||||
}
|
||||
```
|
||||
|
||||
- **Wall-/Opening-Stil-Kataloge** (Presets, ROADMAP §11): benannte Sätze von
|
||||
Default-Werten (Wandtyp + Farbe + lw; Öffnung mit Rahmen/Sims/…). 1-Klick-
|
||||
Anwendung, globaler Stilwechsel. Speicherung: pro Projekt + cross-Projekt
|
||||
(LocalStorage), Seed wie DOSSIER `_OEFF_DEFAULT_STYLES` (elements.md §2.1).
|
||||
- **LoD-bewusste Stil-UI** (DOSSIER ROADMAP §11): das Stil-Panel zeigt nur
|
||||
passende Controls je Geometrietyp (keine Füll-Optionen bei einer 3D-/Linien-
|
||||
Auswahl). `panels/StylePanel.tsx` schaltet Felder nach `selection`-Typ.
|
||||
- **Pipette:** Stil/Typ von einem Element auf ein anderes übernehmen (DOSSIER
|
||||
`cmd/pipette`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Regelbasierte Overrides (Engine)
|
||||
|
||||
Port `overrides.py` (ArchiCAD Graphical Overrides / Vectorworks
|
||||
Datenvisualisierung). Im Browser **viel einfacher**, weil Overrides reine
|
||||
**Render-Transformationen** sind — kein UserString-Backup/Restore nötig (DOSSIER
|
||||
musste Originalwerte sichern, weil es echte Rhino-Objekte mutierte; wir mutieren
|
||||
nichts).
|
||||
|
||||
```ts
|
||||
interface OverrideConfig { enabled: boolean; rules: OverrideRule[]; activePresetId?: string; }
|
||||
interface OverrideRule {
|
||||
id; name; enabled: boolean;
|
||||
conditions: Condition[]; conditionsLogic: "and" | "or";
|
||||
actions: { color?: string; lineWeight?: number; lineStyleId?: string;
|
||||
hatchId?: string; hatchScale?: number };
|
||||
}
|
||||
interface Condition {
|
||||
type: "category" | "userField" | "name" | "elementType"; // ≙ layer_name/user_string/object_name
|
||||
operator: "equals"|"notEquals"|"contains"|"startsWith"|"endsWith";
|
||||
value: string;
|
||||
key?: string; // nur für userField (z.B. "sia")
|
||||
}
|
||||
```
|
||||
|
||||
**Auswertung** (Port `_compose_overrides`):
|
||||
```ts
|
||||
function composeOverrides(el, project, cfg): Partial<Actions> {
|
||||
// additive: Actions aller matchenden, aktiven Regeln kombinieren;
|
||||
// bei Konflikt für dieselbe Property gewinnt die Regel WEITER OBEN (kleinerer Index).
|
||||
}
|
||||
```
|
||||
- **Anwendung:** `resolveStyle` (§3) ruft `composeOverrides` — Override liegt
|
||||
über Element-Style. Reines Read beim Rendern → kein `apply_all`/`restore_all`,
|
||||
kein Backup, **keine reversibilität nötig**. Toggle `enabled` rendert neu.
|
||||
- **Live:** Da abgeleitet, schlägt jede Modell-/Regel-Änderung sofort durch (kein
|
||||
`install_listeners`/`AddRhinoObject`-Hook wie DOSSIER).
|
||||
- **Presets & Templates** (cross-Projekt): Preset = Satz Regeln, Template =
|
||||
einzelne Regel; LocalStorage statt `~/Library/.../override_presets.json`
|
||||
(`save_preset`/`load_preset`/`list_rule_templates` → `resources/overridePresets.ts`).
|
||||
- **SIA-416-Preset** (elements.md §7): vier Regeln `userField sia == HNF|NNF|VF|FF`
|
||||
→ Farbe + Solid-Hatch, Port `_build_sia_preset_rules`. Aktivieren = Preset
|
||||
`activePresetId` setzen.
|
||||
|
||||
```ts
|
||||
// resources/overrides.ts
|
||||
function composeOverrides(el, project, cfg): Partial<OverrideAction>
|
||||
function setActivePreset(project, presetId): Project // immutabel
|
||||
const PRESETS_NS = "cad.presets.overrides"; // LocalStorage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Symbol-Bibliothek
|
||||
|
||||
Port `library.py` + `cmd/symbol`. Wiederverwendbare 2D-Symbole (Möbel, Sanitär,
|
||||
Bäume, Nordpfeil, Pflanzen) für den Plan.
|
||||
|
||||
```ts
|
||||
interface Symbol {
|
||||
id; name; category: string; // "furniture" | "sanitary" | "vegetation" | "annotation"
|
||||
svgPath: string; // Pfad-/Gruppen-Markup im Symbol-Koordinatensystem (m)
|
||||
defaultScale: number;
|
||||
}
|
||||
interface SymbolInstance extends ElementBase { // type:"draw2d", subtype:"symbol"
|
||||
symbolId: string; at: Vec2; scale: number; angle: number;
|
||||
}
|
||||
```
|
||||
- **Speicherung:** mitgelieferte Symbole als statische Assets (SVG); Nutzer-
|
||||
Symbole im Projekt + cross-Projekt (LocalStorage). DOSSIER nutzt Block-
|
||||
Definitionen; bei uns SVG-Definition + Instanz-Transform (`<use>`-artig).
|
||||
- **Picker:** `panels/SymbolPicker.tsx` (≙ DOSSIER `SymbolPicker.jsx`) — Grid mit
|
||||
Vorschau, Drag in den Plan; Instanz auf Ebene `60 Plangrafik` (bzw. `22 Möbel`).
|
||||
- **Render:** `Primitive{ kind:"symbol", ... }` → SVG `<g transform>` mit dem
|
||||
Symbol-Markup (plans-output.md §9).
|
||||
|
||||
---
|
||||
|
||||
## 6. Text & Rich-Text-Annotationen
|
||||
|
||||
Port `text_create.py` + `text_editor.py`. Formatierte Beschriftungen auf Canvas.
|
||||
|
||||
```ts
|
||||
interface TextElement extends ElementBase { // type:"draw2d", subtype:"text"
|
||||
at: Vec2; runs: RichRun[];
|
||||
heightMm: number; // Paper-mm (rendern × Massstab) ODER Modell-m
|
||||
heightMode: "paper" | "model"; // DOSSIER raum_txt_modus fix|masstab
|
||||
font: string; align: "left"|"mid"|"right"; angle: number;
|
||||
mask?: boolean; // Hintergrund-Maskierung (verdeckt Linien darunter)
|
||||
frame?: boolean; // Rahmen um den Text
|
||||
}
|
||||
interface RichRun {
|
||||
text: string;
|
||||
bold?; italic?; super?; sub?; // Hoch-/Tiefstellung (Maß-Indizes)
|
||||
}
|
||||
interface TextStyle { id; name; font; heightMm; bold; italic; } // Text-Presets
|
||||
```
|
||||
- **Render:** `<text>` mit `<tspan>` pro Run; `super/sub` via `baseline-shift` +
|
||||
kleinerer `font-size`; `mask` via weißem `<rect>` darunter; `frame` via `<rect>`.
|
||||
- **Editor:** Inline-Rich-Text-Editor (contentEditable oder leichter Custom-Editor)
|
||||
→ `RichRun[]`. Fonts aus einer kuratierten Web-Font-Liste + System-Fonts
|
||||
(DOSSIER `_list_system_fonts` mit Preferred-Liste DM Mono/Krungthep/…); im
|
||||
Browser via `document.fonts` / `queryLocalFonts()` (wo verfügbar) + gebündelte
|
||||
Web-Fonts.
|
||||
- **Massstabsbezug:** `heightMode:"paper"` → Texthöhe in mm, gerendert × Massstab
|
||||
(plans-output.md §3) — Beschriftung bleibt bei jedem Massstab lesbar.
|
||||
|
||||
---
|
||||
|
||||
## 7. Detailgrad (Level of Detail)
|
||||
|
||||
Querschnittsthema (ROADMAP §2b „Modelldarstellungen"). Drei Stufen, Dokument-/
|
||||
Snapshot-weiter Override mit Per-Element-Ausnahme — DOSSIER
|
||||
`darstellung`/`aktive_darstellung`.
|
||||
|
||||
```ts
|
||||
type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail
|
||||
// Auflösung (Port _resolve_oeff_darstellung):
|
||||
function resolveDetail(el, doc): DetailLevel {
|
||||
const v = el.detailLevel ?? "auto";
|
||||
return v === "auto" ? doc.detailLevel : v; // doc-Level hat IMMER konkreten Wert
|
||||
}
|
||||
```
|
||||
- **Dokument-Ebene:** `Project.detailLevel` (Default `coarse`/einfach = 1:100,
|
||||
DOSSIER `_DARSTELLUNG_DEFAULT_GLOBAL`). In der TopBar global umschaltbar; ein
|
||||
ViewSnapshot kann ihn pro Ansicht überschreiben (plans-output.md §5).
|
||||
- **Wirkung:** jedes Bauteil-`generatePlan`/`build3d` liest den aufgelösten LoD und
|
||||
zeichnet entsprechend (elements.md: Tür coarse=Lücke, fine=Glas/Schwenkbogen/
|
||||
Sims). Kritisch für Mixed-Scale-Pläne (1:50 Detail neben 1:200 Übersicht).
|
||||
- **Schnitt-Schraffur** koppelt an LoD: grob ggf. nur Umriss, fein voll schraffiert.
|
||||
|
||||
---
|
||||
|
||||
## 8. Section-Style (3D-Schnittflächen)
|
||||
Port DOSSIER `_apply_section_style` (`layer_builder.py`). Wo die Schnittebene ein
|
||||
Bauteil durchschneidet: Schnittfläche bekommt die **Component-Schraffur**, die
|
||||
Schnittkante einen dicken Rand, optional eine Silhouette.
|
||||
|
||||
```ts
|
||||
interface SectionStyle { // pro Component (oder Ebene) ableitbar
|
||||
hatchId?: string; hatchScale; hatchAngle;
|
||||
boundaryShow: boolean; boundaryLineStyleId; boundaryWidthScale;
|
||||
fillBackground: boolean;
|
||||
}
|
||||
```
|
||||
- **3D-Viewport:** Three.js hat keinen nativen „Schnittflächen-Cap". Cap-Geometrie
|
||||
selbst erzeugen: Schnittpolygon der Cut-Plane mit den Breps → Fläche mit
|
||||
Hatch-Material (oder Stencil-Cap-Technik). Phase 4.
|
||||
- **2D-Schnitt (SVG):** `generateSection` (plans-output.md §4) liefert `cutFaces`
|
||||
→ mit `SectionStyle.hatchId` füllen. Das ist der Hauptweg; der 3D-Cap ist Bonus.
|
||||
|
||||
---
|
||||
|
||||
## 9. Was Browser hier einfacher macht (vs. DOSSIER)
|
||||
|
||||
| DOSSIER-Aufwand | entfällt im Browser, weil … |
|
||||
|---|---|
|
||||
| UserString-Backup/Restore bei Overrides (`_backup_original`/`_restore_original`) | Overrides sind reine Render-Reads — nichts wird mutiert |
|
||||
| Hatch-Curve-Link über Sticky (`gestaltung_curve_hatch`, Pending-TTL) | Schraffur ist eine Eigenschaft des Polygons, kein separates Objekt |
|
||||
| Plotweight-Welt↔Bildschirm-Rescaling (`write/read_plotweight`) | Strichstärke ist in mm im SVG-Paper-Space (plans-output.md §3.2) |
|
||||
| `install_listeners` für Live-Override-Reapply | reaktiver Store re-rendert automatisch |
|
||||
| SectionStyle-API-Reflection über Rhino-Versionen | wir definieren das Rendering selbst (SVG/Three) |
|
||||
| Cross-doc Presets als Dateien im User-Home | LocalStorage + Export/Import |
|
||||
|
||||
---
|
||||
|
||||
## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen)
|
||||
|
||||
1. **Phase 1:** Component/Hatch/Line-Manager (§1) + `resolveStyle` (§3) +
|
||||
LoD-Grundgerüst (§7) + LoD-bewusste Stil-UI.
|
||||
2. **Phase 2:** Stil-Kataloge (Wände/Öffnungen), Material-Seeds mit `joinPriority`.
|
||||
3. **Phase 3:** Overrides-Engine + SIA-Preset (§4), Symbol-Bibliothek (§5),
|
||||
Rich-Text (§6), PBR-Material-Bibliothek; massstabsabhängige Hatch-/Linetype-
|
||||
Skalierung (plans-output.md §3.2).
|
||||
4. **Phase 4:** Section-Style 3D-Cap (§8).
|
||||
@@ -0,0 +1,370 @@
|
||||
# Handoff — Rhino-artiges Befehlssystem + Modellier-Werkzeuge
|
||||
|
||||
> Für die Instanz, die das Befehlssystem (Tab-getriggert) und die Rhino-artigen
|
||||
> Modellierfunktionen baut. Stand: 2026-06-30. Zuerst lesen: `CONVENTIONS.md`,
|
||||
> `ROADMAP.md`, `HANDOVER.md`, `docs/design/drawing-tools.md`. Volle Autonomie,
|
||||
> selbst bestätigen (Memory `proceed-autonomously`, `wire-dont-stub`, `prefer-agents`).
|
||||
|
||||
Dieses Dokument hat drei Teile:
|
||||
1. **Was schon steht** (worauf du aufbaust — exakte Dateien/Typen/Actions).
|
||||
2. **Rhino-Referenz** (Interaktionsmodell, das nachzubilden ist).
|
||||
3. **Konkreter Bauplan für DIESE Codebase** (Architektur, Dateien, Reihenfolge).
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — die Kernidee
|
||||
|
||||
Es gibt **kein** Befehlssystem, keine Command-Line, keinen Tab-Handler. Das baust du
|
||||
greenfield. **Aber:** Das Werkzeug-System ist bereits eine saubere Pure-Function-Registry
|
||||
(`src/tools/`) mit generischem Controller in `App.tsx`, und die Mutations-Schicht
|
||||
(`projectSlice`) ist umfassend. Ein neues Modellier-Tool steckst du durch Hinzufügen eines
|
||||
`Tool`-Objekts ein — **PlanView muss dafür nicht angefasst werden**.
|
||||
|
||||
Das Befehlssystem ist im Kern eine **State-Machine-Engine über prompt → pick/type →
|
||||
options**, plus eine **Command-Line-UI** (Statusleiste), plus ein **Koordinaten-Parser**.
|
||||
Befehle dispatchen auf bestehende Store-Actions + `setActiveTool`/`setProject`.
|
||||
|
||||
**Wichtigster konzeptioneller Sprung:** Die heutigen Tools haben je eine *eigene*
|
||||
ad-hoc-Phasenlogik (`onClick`/`onMove`). Rhino-Feel verlangt eine **gemeinsame
|
||||
Prompt/Option/Numerik-Engine**, die alle Befehle teilen. Plane das als Verallgemeinerung
|
||||
des bestehenden `Tool`-Interfaces, nicht als Parallelwelt daneben (sonst zwei Eingabe-Pfade,
|
||||
die divergieren).
|
||||
|
||||
---
|
||||
|
||||
# TEIL 1 — Was schon steht (Baufundament)
|
||||
|
||||
Stack: React 18 + TS + Vite, `three` 0.169 (nur 3D-Display). Einheiten intern **Meter**.
|
||||
Eigener winziger Store (`useSyncExternalStore`, kein Redux/Zustand). Identifier englisch,
|
||||
UI-Text deutsch via `t()`. Strict tsc (`noUnusedLocals` → ungenutzte Vars brechen den Build).
|
||||
|
||||
## 1.1 Werkzeug-System — `src/tools/`
|
||||
- **`Tool`-Interface** `src/tools/types.ts:139` — reine Funktionen über internen `ToolState`
|
||||
(Discriminated Union je Tool). Handler geben `[nextState, ToolResult]` zurück. Tools
|
||||
schreiben NIE Plan-Primitive; sie geben `commit(project) => project` zurück.
|
||||
- `ToolId` `types.ts:11`: `"select" | "wall" | "line" | "polyline" | "rect"`.
|
||||
- `ToolContext` `types.ts:72`: `{ project, level, defaultCategoryCode, activeWallTypeId, activeLineStyleId }`.
|
||||
- `ToolPointer` `types.ts:85`: `{ raw, snap, point (=snap?.point ?? raw), shift, ctrl, alt, button }`.
|
||||
- `ToolResult` `types.ts:121`: `{ draft, commit?, done? }`. `ToolDraft` `types.ts:109`:
|
||||
`{ preview: DraftShape[], vertices: Vec2[], snap?, hud?: {at,text} }`.
|
||||
- **Registry** `src/tools/tools.ts:375`: `TOOLS: Record<ToolId,Tool>`, `getTool(id)` `:384`,
|
||||
`TOOL_ORDER` `:389`. Implementiert: select (Platzhalter), wall, line, polyline, rect.
|
||||
- `uniqueId(prefix)` `types.ts:188` — ID-Generator.
|
||||
|
||||
## 1.2 Controller / Verdrahtung — alles in `App.tsx` (NICHT in den Tool-Dateien)
|
||||
- Aktives Tool: `const [activeTool,setActiveTool]=useState<ToolId>("select")` `App.tsx:197`.
|
||||
- Laufender Zustand in **Ref** `toolStateRef` `App.tsx:205` (kein Re-Render je Mausschritt).
|
||||
Live-Vorschau `const [draft,setDraft]` `App.tsx:206`.
|
||||
- **Controller** `runToolStep(kind,raw,pxPerMeter,mods)` `App.tsx:350`: baut `ToolContext`
|
||||
(`toolCtx` `:294`), snappt via `snapFor` `:322` (→ `computeSnap`), baut `ToolPointer`,
|
||||
ruft `tool.onClick/onMove`, speichert State in Ref, `applyToolResult` `:339` wendet
|
||||
draft/commit/done an. `toolHandlers` `App.tsx:419` verbindet PlanView↔Controller.
|
||||
- Tool-Tasten `App.tsx:448`: Esc=Abbruch/zurück-zu-select, Enter=Commit-Geste, Backspace=Punkt zurück.
|
||||
|
||||
## 1.3 Semantisches Modell — `src/model/types.ts`
|
||||
- `type Element = Wall | Door | Drawing2D` `:251`. `Vec2={x,y}`. **Kein Slab/Stair-Typ.**
|
||||
- `Project` `:254`: `{ ..., wallTypes[], drawingLevels[], layers[], walls[], doors[], drawings2d[] }`.
|
||||
- `Wall` `:150`: `{ id,type:"wall", floorId, categoryCode, start, end, wallTypeId, height, color? }`
|
||||
(Mittellinie + mehrschichtiger `WallType`).
|
||||
- **Plan-Primitive `Drawing2DGeom`** `:200` — die Geom-Typen existieren bereits ALLE:
|
||||
`line | polyline | rect | circle | arc | text`. Aber Tools erzeugen heute nur line/polyline/rect,
|
||||
und `drawingVertices` (Grips) kennt nur diese drei. **circle/arc/text sind im Typ da, aber
|
||||
nicht durchgängig gerendert/editierbar** — Lücke, kein Neubau nötig.
|
||||
- `Drawing2D` `:209`: `{ id,type:"drawing2d", levelId, categoryCode, geom, lineStyleId?, hatchId?, color?, fillColor?, weightMm? }`.
|
||||
|
||||
## 1.4 Geometrie — `src/model/geometry.ts`
|
||||
`sub,add,scale,len,normalize`; `leftNormal(a)={x:-a.y,y:a.x}` `:17` (Wand-Normale-Konvention);
|
||||
`cross`, `lineIntersect(a,da,b,db)`, `along`, `wallBand`, `wallCorners` `:70`, `clippedBand` `:87`.
|
||||
`src/model/joins.ts`: `computeJoins(project,walls)` `:44` (nur L-Ecken gehrt; T/X eckig).
|
||||
**Für Offset/Trim/Fillet (Rhino-Kern) gibt es NOCH KEIN 2D-Geometrie-Kernel** — Kurven/Kurven-
|
||||
Schnitt, Polylinien-Offset usw. musst du ergänzen (siehe Bauplan §3.4).
|
||||
|
||||
## 1.5 Store — `src/state/`
|
||||
- `createStore` `store.ts:51` über `useSyncExternalStore`. **Actions leben IM State**
|
||||
(`useStore(s=>s.action)`, referenzstabil). `RootState = Project & Selection & View & Layout`
|
||||
`appStore.ts:21`. Exports `useStore`, `getState`, `setState`.
|
||||
- **projectSlice**: `project` + `setProject(next|(p)=>p)`. Mutationen u.a. `addFloor`,
|
||||
`addCategory`, `setElementColor/Weight/Fill`, `resizeElement`, `moveGripOf`, `moveElementByOf`,
|
||||
`moveEdgeOf`, `commitTransformOn`. **Es gibt keine generische „addWall/addDrawing2d"-Action** —
|
||||
Tools committen via `setProject`. (Beim Befehlssystem ggf. saubere Actions ergänzen.)
|
||||
- **Aktive Zeichenebene + aktive Kategorie liegen im viewSlice**, NICHT in selection:
|
||||
`activeLevelId` `viewSlice.ts:46`/`setActiveLevelId`, `activeCategoryCode` `:42`/`setActiveCategoryCode`.
|
||||
- selectionSlice: `selectedWallIds[]`, `selectedDrawingId` + Setter/`clearSelection`.
|
||||
|
||||
## 1.6 Views, Eingabe, Koordinaten — `src/plan/PlanView.tsx` (SVG-Vektor)
|
||||
- Modell → `Plan`-Primitive via `generatePlan` `src/plan/generatePlan.ts:203`. `Primitive` =
|
||||
`polygon|line|arc` (polygons tragen `wallId`/`drawingId` für Hit-Test).
|
||||
- **Transform (entscheidend):** `PX_PER_M=90` `:20`; `toScreen(p)={x:p.x*90,y:-p.y*90}` `:31`
|
||||
(fixer Welt-Ursprung 0,0; Y flippt). Invers `viewToModel` `:440`. SVG `viewBox`=State `view`;
|
||||
Pan/Zoom ändern nur `view`, nie das Modell↔Screen-Mapping.
|
||||
- **`rawModelAt(clientX,clientY)`** `:446` = aktuelle Mauswelt-Position in Meter (der Eine-Aufruf,
|
||||
den ein Tool/Befehl braucht). `currentPxPerMeter()` `:488`.
|
||||
- **Pointer-Events** alle am `<svg>` `:910`: down `:553`, move `:625`, up `:715`, wheel `:820`,
|
||||
dblclick `:847`, contextmenu `:857`. Schema: Mitte=Pan, Links=Select/Marquee/Tool, Rechts=Menü.
|
||||
Bei `toolActive` `:268` routen Links-Events zu `toolHandlers`. **PlanView meldet bereits
|
||||
`(rawModelAt, currentPxPerMeter, toolMods)` nach oben** — neue Tools brauchen hier NICHTS.
|
||||
- **Snapping** `src/tools/snapping.ts`: `computeSnap(input)` `:88` — endpoint/midpoint/intersection/
|
||||
onEdge/grid/ortho mit Prioritätstabelle. Wird in App (`snapFor`) konsumiert, nicht in PlanView.
|
||||
`applyAngleConstraint` für Ortho. `SnapSettings`/`DEFAULT_SNAP` in `tools/types.ts:36/55`.
|
||||
- 3D `src/viewport/Viewport3D.tsx` (three.js, Raycaster): nur Anzeige+Auswahl, **keine
|
||||
Zeichenwerkzeuge**. 3D-Authoring = eigene spätere Phase (Raycast auf Arbeitsebene).
|
||||
|
||||
## 1.7 Tastatur / globale Eingabe — **kein Dispatch-System**
|
||||
- `main.tsx:38` globaler `contextmenu`→preventDefault; `:42` blockt Ctrl/Cmd+A außerhalb Inputs;
|
||||
`isTextEntry(el)` `:24`.
|
||||
- App-useEffects mit `window.addEventListener("keydown")`: Tool-Tasten `:448`, Delete `:604`,
|
||||
Transform-Shortcuts m/s/d + u/i/o/p `:637`. **Jeder Guard wiederholt inline den
|
||||
INPUT/TEXTAREA/contentEditable-Check** — es gibt keine geteilte Keymap. Dein Tab-Handler +
|
||||
Command-Input kommt als neuer globaler `keydown` dazu (siehe §3.2).
|
||||
|
||||
## 1.8 UI-Shell + i18n
|
||||
- `App.tsx` (~2200 Z., enthält noch ToolController/Grips/Transform). JSX `:1043`: TopBar → body
|
||||
(Dock links, Content-View-Router, TransformBar, Dock rechts, Floating) → StatusBar →
|
||||
ResourceManager → ContextMenu → InlineEditor. Panel-Daten via `PanelHostContext` (`baseHost`
|
||||
`App.tsx:729`, Typ `host.ts`).
|
||||
- `StatusBar.tsx` — Footer: links `hint` (Tool-Hinweis), rechts X/Y, Einheit, Massstab 1:N, Zoom,
|
||||
aktives Geschoss, aktive Ebene. **Bester Ort für die Command-Line** (Rhino hat sie klassisch unten).
|
||||
- **i18n** `src/i18n/`: `t(key,params?)` `index.ts:67`, `useT()` `:84`. Flaches `as const`-Dict,
|
||||
Punkt-Namespaces (`tool.*`,`snap.*`,`transform.*`,`status.*`…). `de.ts` (Quelle, ~309 Keys) +
|
||||
`en.ts`; `TranslationKey=keyof typeof de` erzwingt Parität. **Neue Keys IMMER in beide Dateien.**
|
||||
Keine hartcodierten JSX-Strings.
|
||||
|
||||
## 1.9 Verifizieren
|
||||
- `npx tsc -b` · `npm run build` · Dev `npm run dev` (Vite 5173, `host:true`).
|
||||
- Screenshot `node scripts/probe.mjs` → `scripts/probe.png` (Puppeteer headless, `deviceScaleFactor:2`,
|
||||
URL via `PROBE_URL`). Viele task-Probes existieren (`probe-tools.mjs`, `probe-line.mjs`,
|
||||
`probe-transform.mjs` …) — gute Vorlagen, um Tools/Befehle programmatisch zu treiben.
|
||||
**Screenshot ansehen + Geometrie prüfen**, nicht nur „kompiliert".
|
||||
|
||||
---
|
||||
|
||||
# TEIL 2 — Rhino-Referenz (das Interaktionsmodell)
|
||||
|
||||
## 2.1 Die Command-Line ist das Rückgrat
|
||||
**Alles ist ein Befehl**, und die Command-Line **hört immer zu**: Tastenanschläge gehen an die
|
||||
Command-Line, wenn sie nicht von einem Feld konsumiert werden. Kein „Tool aktiv vs. Eingabe aktiv".
|
||||
Die Zeile hat gleichzeitig drei Rollen: **Eingabe** (Befehl/Wert tippen), **Prompt**
|
||||
(„Start of line", „Next point"), **Optionen** (eckige, klickbare Inline-Optionen).
|
||||
|
||||
## 2.2 Befehl aufrufen
|
||||
- Namen tippen, z. B. `Line`. **Präfix-Autocomplete** (case-insensitiv): `L`→`Li`→`Lin` zeigt
|
||||
Kandidatenliste mit Best-Match. **Tab/Pfeile** akzeptieren Vorschlag, **Enter/Leertaste** führt aus.
|
||||
- **Aliase**: nutzerdefinierte Kürzel → Makro (z. B. `L`→`!_Line`, `cp`→`!_Copy`). Werden VOR
|
||||
Autocomplete gematcht. (Minimal: Einzelbuchstabe→Befehl.)
|
||||
|
||||
## 2.3 Enter / Leertaste / Rechtsklick (leicht falsch gemacht)
|
||||
- **Enter = Leertaste** in der Command-Line. Beide: Befehl ausführen / Default akzeptieren /
|
||||
mehrteiligen Befehl **beenden** / bei **leerer** Zeile **letzten Befehl wiederholen**.
|
||||
- **Rechtsklick im Viewport = Enter.** Also: Rechtsklick beendet Polyline UND wiederholt bei
|
||||
leerer Zeile den letzten Befehl. → `lastCommand` speichern, bei Leer-Enter/Rechtsklick neu starten.
|
||||
|
||||
## 2.4 Inline-Optionen (klickbare Klammern)
|
||||
```
|
||||
Start of line ( BothSides=No Chamfer Mode=Distance ):
|
||||
```
|
||||
- Jede Option **klickbar UND tippbar** (genug Buchstaben zur Eindeutigkeit + Enter).
|
||||
- **Toggle** `Name=Value` flippt beim Klick. **Value**-Option fragt Unterwert ab. **Action**-Option
|
||||
(ohne `=`) verzweigt sofort.
|
||||
- Optionen sind **innerhalb des Befehls persistent**, viele **über Aufrufe hinweg** (letzte
|
||||
Offset-Distanz, Array-Anzahl, Fillet-Radius merken). **Zuletzt benutzte Optionswerte je Befehl
|
||||
persistieren** — Nutzer erwarten das.
|
||||
|
||||
## 2.5 Sub-Prompts = State-Machine
|
||||
Befehle laufen Prompts ab. `Line`: „Start of line:" → Punkt → „End of line:" → Punkt → fertig.
|
||||
`Polyline`: „Start" → „Next point ( Close Undo ):" → … → **Enter** beendet. Prompt-Text ist
|
||||
sichtbar und lehrreich („Next point. Press Enter when done") — literal nachbilden.
|
||||
|
||||
## 2.6 Transparente/verschachtelbare Befehle
|
||||
Manche Befehle (Zoom/Pan, Osnap-Toggle, alles mit `'`-Präfix) laufen **innerhalb** eines anderen,
|
||||
ohne ihn abzubrechen, und kehren zum Original-Prompt zurück. → Command-Runner braucht einen **Stack**.
|
||||
|
||||
## 2.7 Koordinaten- & Numerik-Eingabe (Herz der Präzision)
|
||||
| Eingabe | Bedeutung |
|
||||
|---|---|
|
||||
| `5,3` / `5,3,2` | absolut X,Y(,Z) |
|
||||
| `r5,3` | **relativ** zum letzten Punkt (das `r`-Idiom) |
|
||||
| `<45` | Winkel-Constraint auf 45°, dann Maus/Distanz |
|
||||
| `5<45` | **polar**: Distanz 5 unter 45° vom letzten Punkt |
|
||||
| Zahl tippen während Drag | **Distanz-Lock**: Richtung per Maus, Länge per Zahl+Enter (meistgenutzte Geste) |
|
||||
| Zahl + **Tab** | Lock umschalten (Länge fix → Winkel folgt Maus, oder umgekehrt) |
|
||||
Das Feld parst **kontextabhängig**: Befehlsname / Optionsbuchstabe / Koordinate / nackte Zahl —
|
||||
je nach Befehlszustand. Das Live-Tool muss **einen primären Skalar** (Länge/Radius/Distanz)
|
||||
exponieren, an den eine getippte Zahl bindet.
|
||||
|
||||
## 2.8 Osnaps + Ortho + Gumball
|
||||
- **Osnaps** (persistente Toggles): End, Mid, Cen, Int, Perp, Near, Quad, Tan, Point. Pro Mausschritt
|
||||
gegen nahe Geometrie geprüft (Pixel-Toleranz), Marker+Label am Cursor; liefert **exakte
|
||||
Modellkoordinate** (nie Roh-Maus, wenn Snap aktiv). One-Shot-Osnap überschreibt für den nächsten Pick.
|
||||
- **Ortho** (F8): Winkelraster (90°/konfigurierbar), **Shift** togglet temporär. **Grid Snap** (F9).
|
||||
**SmartTrack**: temporäre Hilfslinien aus zuletzt gehoverten Punkten.
|
||||
- **Gumball**: On-Object-Widget (Pfeile=Move, Bögen=Rotate, Handles=Scale); Handle klicken →
|
||||
Zahl tippen für exakten Transform. Direkt-Manipulations-Gegenstück zu getippten Befehlen.
|
||||
|
||||
> Präzisionsmodell = **(Snap ODER getippte Koordinate) × (Ortho/Winkel-Constraint) ×
|
||||
> (Distanz-Constraint)**, in EINEM Pick komponierbar.
|
||||
|
||||
## 2.9 Auswahl-Modell (links/rechts-Regel exakt)
|
||||
- Klick = wählen; Shift+Klick add; Ctrl+Klick remove.
|
||||
- **Links→rechts = Window** (nur voll umschlossene; **durchgezogenes** Rechteck).
|
||||
- **Rechts→links = Crossing** (auch berührte; **gestricheltes** Rechteck). Richtung bestimmt
|
||||
Modus — starke Konvention, exakt nachbilden.
|
||||
- **SelLast** (zuletzt erzeugte/gewählte erneut wählen) ist enorm nützlich („erzeugen, dann sofort
|
||||
bewegen"). Min. `SelLast`, `SelAll`, `SelNone`, `Invert`.
|
||||
|
||||
## 2.10 Befehls-Prompt-Sequenzen (Kurz)
|
||||
2D: **Line** (2 Pkt) · **Polyline** (Close/Undo, Enter beendet) · **Rectangle** (Ecke+Ecke, oder
|
||||
Breite/Höhe tippen; 3Point/Center) · **Circle** (Center+Radius; 2P/3P/Tan) · **Arc** (Center-Start-End /
|
||||
3Point) · **Offset** (Kurve wählen → Seite klicken/Distanz tippen; Distanz persistent) ·
|
||||
**Fillet/Chamfer** (Kurve1→Kurve2, Radius/Distances persistent) · **Trim** (Schneider wählen→Enter→
|
||||
wegzuschneidendes Stück klicken) · **Split** · **Extend** · **Join** · **Explode** ·
|
||||
**Move/Copy/Rotate/Scale/Mirror** (Auswahl→Basispunkt→Ziel; Copy-Option) · **ArrayRect/ArrayPolar** ·
|
||||
**Group/Ungroup**.
|
||||
3D (braucht CSG, später): **ExtrudeCrv** (geschlossene Kurve→Solid, Cap) · **Box** · **Boolean
|
||||
Union/Difference/Intersection** · **Cap** · **Gumball-Face-Drag = PushPull** · Loft/Sweep/Revolve.
|
||||
|
||||
---
|
||||
|
||||
# TEIL 3 — Bauplan für DIESE Codebase
|
||||
|
||||
> Ziel: nutzbarer 2D-Architektur-Drafter mit Rhino-Feel, dann einfaches Massing. Halte das
|
||||
> ROADMAP-Prinzip: **ein semantisches Modell → Sichten abgeleitet**; Extrusionshöhe ist eine
|
||||
> Eigenschaft, nie eingebackene Geometrie.
|
||||
|
||||
## 3.0 Kuratierungs-Prinzip (WICHTIG — Nutzer-Vorgabe)
|
||||
**NICHT den ganzen Rhino-Katalog stumpf portieren.** Wir bauen ein **Wohnbau-BIM**, keinen
|
||||
NURBS-Allzweck-Modeller. Nimm nur, was dem Wohnbau-Workflow dient; lass den Rest weg, bis er
|
||||
konkret gebraucht wird. Faustregel: *Brauche ich das, um ein Einfamilienhaus zu zeichnen und
|
||||
daraus Pläne zu ziehen?* Wenn nein → weglassen.
|
||||
|
||||
**Bewusst WEGLASSEN (vorerst):** Loft / Sweep1+2 / Revolve (Sonderformen, kaum Wohnbau) ·
|
||||
freie NURBS-Kurven (`Curve`/`InterpCrv` Grad>1, Deformable, FromFoci) · Ellipse · Tangent/
|
||||
Bisector/4Point-Linienvarianten · SmartTrack (nett, nicht kritisch) · der volle `Sel*`-Zoo
|
||||
(nur SelLast/SelAll/SelNone/Invert) · Knot/Vertex/Tan-Osnaps. Alle leicht später additiv
|
||||
nachrüstbar — kein Grund, sie jetzt mitzuschleppen.
|
||||
|
||||
**Booleans sind KEIN „nice to have später"** — sie werden gebraucht, **sobald Tür/Fenster als
|
||||
echte 3D-Öffnung** kommen (heute schneidet `Door` nur eine Plan-Lücke, kein 3D-Boolean, siehe
|
||||
HANDOVER). Darum: CSG/Booleans an die **Tür/Fenster-Phase koppeln** und dann reinnehmen — nicht
|
||||
ans Ende schieben. ABER (das ist der „nicht stumpf"-Teil):
|
||||
> Für **rechteckige** Öffnungen in extrudierten Wänden braucht es **keinen allgemeinen
|
||||
> Boolean-Kernel**. Eine analytische **Wand-minus-Box-Subtraktion** (Öffnung als parametrische
|
||||
> Aussparung im Wand-Solid) ist einfacher, robuster und für 95 % Wohnbau ausreichend. Den
|
||||
> allgemeinen CSG-Boolean (`rhino3dm`) erst ziehen, wenn schräge/runde/verschnittene Fälle
|
||||
> wirklich auftreten. Also: **Öffnungen zuerst analytisch, allgemeine Booleans erst bei Bedarf.**
|
||||
|
||||
## 3.1 Leitentscheidung: Engine verallgemeinern, nicht parallel bauen
|
||||
Baue eine gemeinsame **Command-Engine**, die das bestehende `Tool`-Interface erweitert/ablöst,
|
||||
sodass es **einen** Eingabepfad gibt (Maus + Tastatur + Command-Line speisen dieselbe Maschine).
|
||||
Konkret: ein `Command`-Modell, das je Schritt einen **Prompt** (Text), erwartete **Eingabearten**
|
||||
(Punkt | Zahl | Option | Auswahl) und **Optionen** beschreibt. Die heutigen Tools werden zu
|
||||
Befehlen dieser Engine (wall/line/polyline/rect lassen sich 1:1 portieren — ihre Phasenlogik ist
|
||||
schon eine Mini-State-Machine).
|
||||
|
||||
**Warum nicht das alte Tool-Interface unangetastet lassen und Command-Line nur draufsetzen?**
|
||||
Weil die Command-Line getippte Koordinaten/Optionen in denselben Schritt einspeisen muss, in dem
|
||||
die Maus pickt. Zwei getrennte Pfade divergieren garantiert (Snapping, Constraints, HUD doppelt).
|
||||
|
||||
## 3.2 Neue Dateien (Vorschlag)
|
||||
- `src/commands/engine.ts` — Command-Runner: aktiver Befehl, Prompt-Stack (für transparente
|
||||
Befehle §2.6), `lastCommand`-Wiederholung, Routing von Maus-Pick / getippter Eingabe / Option-Klick
|
||||
in den aktuellen Schritt. Hält `CommandState`.
|
||||
- `src/commands/types.ts` — `Command`-Interface (Verallgemeinerung von `Tool`): Schritte mit
|
||||
`prompt: TranslationKey`, `accepts: ("point"|"number"|"option"|"selection")[]`, `options: CmdOption[]`,
|
||||
`onInput(state,input,ctx): [state, CommandResult]`. `CommandResult` wie `ToolResult` (+`commit`).
|
||||
- `src/commands/parseInput.ts` — Koordinaten-Parser (§2.7): `5,3` · `r5,3` · `5<45` · `<45` ·
|
||||
nackte Zahl (Distanz-Lock) · Optionsbuchstabe. Liefert eine Discriminated Union, die die Engine
|
||||
in einen Modellpunkt/Constraint auflöst (mit `lastPoint` für `r`/polar).
|
||||
- `src/commands/registry.ts` — `COMMANDS: Record<string,Command>` + Aliase + Autocomplete (Präfix).
|
||||
- `src/ui/CommandLine.tsx` — die Command-Line-UI **in/über der Statusleiste** (`StatusBar.tsx`):
|
||||
zeigt Prompt + klickbare Optionen + Texteingabe; Autocomplete-Dropdown. Tab fokussiert sie.
|
||||
- (später) `src/geometry/kernel2d.ts` — 2D-Kernel für Offset/Trim/Fillet/Schnitt (§3.4).
|
||||
- (viel später) `src/geometry/solid3d.ts` o. `rhino3dm`-Anbindung für Massing/Booleans (§3.5).
|
||||
|
||||
## 3.3 Verdrahtung (minimal-invasiv)
|
||||
- **Globaler Tab-Handler**: neuer `window.keydown` in App (gleicher Guard wie `App.tsx:448` —
|
||||
INPUT/TEXTAREA/contentEditable überspringen). Tab → Command-Line fokussieren/öffnen. Jeder
|
||||
getippte Buchstabe ohne aktives Tool startet den Befehlsmodus (Rhino „hört immer zu" — optional
|
||||
in Phase 2; Phase 1 reicht Tab).
|
||||
- **Command-Line → Engine → Store**: Befehle dispatchen auf `setActiveTool` (für tool-artige) bzw.
|
||||
direkt auf Store-Actions / `setProject`. Nutze `getState()/setState()` (referenzstabil) aus
|
||||
`appStore.ts`.
|
||||
- **Pick-Eingabe**: die Engine konsumiert dieselben `(rawModelAt, currentPxPerMeter, toolMods)`,
|
||||
die PlanView schon hochmeldet (`ToolHandlers`). `computeSnap` für Punktfang wiederverwenden.
|
||||
→ PlanView braucht im Idealfall **keine Änderung** (höchstens: Window/Crossing-Marquee-Visual
|
||||
durchgezogen vs. gestrichelt nach Drag-Richtung, §2.9 — heute evtl. nur ein Modus).
|
||||
- **Prompt/HUD**: Prompt-Text in die Statusleiste (`StatusBar` `hint` existiert schon). Distanz/
|
||||
Winkel-HUD am Cursor existiert in `ToolDraft.hud`.
|
||||
|
||||
## 3.4 Reihenfolge (Tiers — strikt 2D zuerst)
|
||||
**Tier 0 — Substrat (VOR jedem Befehl; das ist der „Feel"):**
|
||||
1. Command-Runner + Command-Line-UI (Prompt → pick/type → Optionen; Enter/Space/Rechtsklick =
|
||||
bestätigen/beenden/wiederholen; `lastCommand`).
|
||||
2. Koordinaten-Parser (`x,y` · `rdx,dy` · `dist<angle` · nackte-Zahl-Lock).
|
||||
3. Osnaps (End/Mid/Cen/Int/Perp/Near) — `computeSnap` ist da, ggf. Cen/Perp/Near ergänzen.
|
||||
4. Ortho (90°/45°, Shift-Toggle) + Grid-Snap — teils vorhanden (`applyAngleConstraint`).
|
||||
5. Auswahl: Klick, Shift/Ctrl add/remove, **Window vs. Crossing** (durchgezogen/gestrichelt,
|
||||
links/rechts-Regel).
|
||||
|
||||
**Tier 1 — 2D-Pflicht (reines SVG/2D), grobe Baufolge:**
|
||||
6. **Line** (validiert die ganze pick/snap/constrain-Schleife) → 7. **Polyline** (Close/Undo) →
|
||||
8. **Rectangle** (Ecke + Center/3Point) → 9. **Circle** (Center+Radius). Diese vier portieren die
|
||||
heutigen Tools auf die Engine + numerische Eingabe.
|
||||
10. **Move** → 11. **Copy** (wiederholend) → 12. **Offset** (persistente Distanz — DAS Architektur-
|
||||
Primitiv) → 13. **Trim** + **Split** → 14. **Join** + **Explode**. **Undo/Redo** durchgängig
|
||||
annehmen (heute? — prüfen; ggf. Command-History/Undo-Stack im Store ergänzen).
|
||||
|
||||
**Tier 2 — 2D stark nützlich:** Rotate/Scale/Mirror (Copy-Option) · Fillet/Chamfer · Arc ·
|
||||
Extend · ArrayRect/ArrayPolar · Group/Ungroup · Gumball(2D) · Sel*-Helfer (min. SelLast).
|
||||
|
||||
**Tier 3 — Massing + Öffnungen (an Tür/Fenster-Phase gekoppelt):**
|
||||
- **Öffnungen zuerst analytisch:** Tür/Fenster als parametrische Aussparung im Wand-Solid
|
||||
(Wand-Extrude minus Öffnungs-Box) — KEIN allgemeiner Boolean-Kernel nötig (§3.0). Das ist der
|
||||
kritische, roadmap-markierte 🔴-Teil (echte 3D-Öffnung statt nur Plan-Lücke) und kommt MIT
|
||||
Tür/Fenster, nicht danach.
|
||||
- **Massing-Befehle:** ExtrudeCrv (geschlossene Plan-Kurve → gecapptes Solid) → Box →
|
||||
Gumball-Face-Drag-PushPull.
|
||||
- **Allgemeine Booleans** (Union/Difference/Intersection) **erst bei Bedarf** (schräge/runde/
|
||||
verschnittene Fälle): **kein eigener Kernel — `rhino3dm` (WASM-openNURBS)** als `src/io/`-Schicht
|
||||
(Roadmap-Entscheid, HANDOVER). Bis dahin reicht die analytische Subtraktion.
|
||||
- **Weggelassen:** Loft/Sweep/Revolve/OffsetSrf (§3.0 — Sonderformen, kaum Wohnbau).
|
||||
|
||||
## 3.5 Was sauber 2D ist vs. was hart ist
|
||||
- **Sauber SVG/2D:** Line, Polyline, Rect, Circle, Arc, Move/Copy/Rotate/Scale/Mirror, Array, Group,
|
||||
Control-Point-Edit, Gumball(2D). Affine Transforms + Kurven-Schnitt.
|
||||
- **Echte Arbeit (2D-Kernel nötig):** **Offset, Trim, Fillet** brauchen kompetenten Kurven-Schnitt
|
||||
und Polylinien-Offset — dafür Zeit einplanen (`src/geometry/kernel2d.ts`).
|
||||
- **Braucht 3D/CSG:** Extrude, Box, Boolean*, Cap, OffsetSrf, Loft/Sweep/Revolve, Face-Drag. Booleans
|
||||
sind das Korrektheits-Zentrum → `rhino3dm`.
|
||||
|
||||
## 3.6 Gotchas (aus Rhino-Verhalten + dieser Codebase)
|
||||
- **Command-Line hört immer zu** — Tasten global routen, aber die `isTextEntry`-Disziplin
|
||||
(`main.tsx:24`) + die Native-App-Regeln (kein Ctrl+A/keine Textauswahl, CONVENTIONS.md) wahren.
|
||||
- **Zuletzt benutzte Optionswerte je Befehl persistieren** (Offset-Distanz, Array-Anzahl, Fillet-Radius).
|
||||
- **Enter = Rechtsklick = Wiederholen/Bestätigen/Mehrteiliges-Beenden** — alle drei auf EIN Signal.
|
||||
- **Distanz-Lock:** Live-Befehl muss EINEN primären Skalar exponieren, an den eine getippte Zahl bindet.
|
||||
- **Window vs. Crossing** über Drag-Richtung + durchgezogen/gestrichelt — nicht global ein Modus.
|
||||
- **Osnap liefert exakte Modellkoordinate** — nie Roh-Maus, wenn Snap aktiv (`ToolPointer.point`).
|
||||
- **Strict tsc** (`noUnusedLocals`) — ungenutzte Vars/Parameter brechen `npm run build`.
|
||||
- **i18n**: jeder sichtbare String über `t('key')`, Keys in `de.ts` UND `en.ts` (Parität erzwungen).
|
||||
- **Keine generische addWall/addDrawing2d-Action** — entweder via `setProject` committen (wie heute)
|
||||
oder beim Refactor saubere Actions im `projectSlice` ergänzen (besser für Undo/Redo).
|
||||
- **App.tsx ist bereits ~2200 Z.** (God-Component-Kritik in HANDOVER). Lege Command-Engine in
|
||||
`src/commands/`, halte App-Verdrahtung dünn (nur Tab-Handler + CommandLine-Mount + Dispatch-Brücke).
|
||||
|
||||
## 3.7 Verifikations-Drehbuch
|
||||
Pro Tier eine Probe (Vorlage: `scripts/probe-tools.mjs`/`probe-transform.mjs`): Befehl per Command-
|
||||
Line tippen → Punkte/Werte tippen → Screenshot → Geometrie visuell prüfen. Tier 0 zuerst headless
|
||||
treiben (Tab → „line" → „0,0" Enter → „r3,0" Enter → Linie im PNG sichtbar). `npx tsc -b` +
|
||||
`npm run build` grün halten. **Screenshot ansehen**, nicht nur Kompilat vertrauen (Memory `wire-dont-stub`).
|
||||
|
||||
---
|
||||
|
||||
## Anhang — Minimaler erster Meilenstein (konkret)
|
||||
1. `src/commands/types.ts` + `engine.ts` + `parseInput.ts` (Tier 0.1/0.2).
|
||||
2. `src/ui/CommandLine.tsx`, in `StatusBar` gemountet; Tab-Handler in App.
|
||||
3. `Line` als erster Command (portiert `lineTool`), inkl. getippter `0,0` / `r3,0` / `3<45`.
|
||||
4. Probe `scripts/probe-command-line.mjs`: Tab→line→zwei getippte Koordinaten→PNG prüfen.
|
||||
5. Dann Polyline/Rect/Circle, danach Move/Copy/Offset.
|
||||
|
||||
Damit steht der Rhino-Feel-Kern, und jeder weitere Befehl ist additiv (neues `Command`-Objekt in
|
||||
`registry.ts`, keine PlanView-/App-Änderung).
|
||||
@@ -0,0 +1,52 @@
|
||||
# Zustands-Architektur & Code-Aufteilung (App.tsx entschlacken)
|
||||
|
||||
> Ziel: `App.tsx` von „God-Component" zu dünnem Shell. Globaler Zustand in einen
|
||||
> Store, Features in eigene Module → **wartbar + parallel bearbeitbar**.
|
||||
|
||||
## Problem
|
||||
`App.tsx` hält aktuell: Projekt-State, Auswahl, View-State (viewType/detailLevel/
|
||||
renderMode/referenceLines/scale/activeLevel), Layout, ALLE Mutations-Handler
|
||||
(floors/layers/components/hatches/lineStyles/walls/doors), Kontextmenü-Builder,
|
||||
Inline-Editoren, Layout-Menü, View-Routing. → Risiko + Flaschenhals (jedes Feature
|
||||
fasst App.tsx an → kein paralleles Arbeiten).
|
||||
|
||||
## Zielstruktur
|
||||
```
|
||||
src/state/
|
||||
store.ts // Store (Zustand) — kombiniert die Slices, ein useStore-Hook
|
||||
projectSlice.ts // project + alle Mutationen (floors, layers, components,
|
||||
// hatches, lineStyles, walls, doors) inkl. recompute/refs-aware delete
|
||||
selectionSlice.ts // selectedWallIds (+ marquee-Ergebnis)
|
||||
viewSlice.ts // viewType, detailLevel, renderMode, referenceLines, activeLevelId, scale
|
||||
layoutSlice.ts // wraps src/panels/layout.ts (docks + floating)
|
||||
src/views/ // LevelPlanView, PerspectiveView, SectionStub, DrawingView (+ ViewRouter)
|
||||
src/editors/ // FloorSettingsEditor, LayerSettingsEditor, … (heute inline in App)
|
||||
src/menus/ // layerContextMenu(), levelContextMenu(), planContextMenu() — bauen ContextMenu-Items
|
||||
src/ui/ // TopBar, StatusBar, ResourceManager, ContextMenu (bestehen)
|
||||
src/panels/ // Docks/Panels (bestehen)
|
||||
App.tsx // DÜNN: Store-Provider · TopBar · (Docks + ViewRouter) · StatusBar
|
||||
// · Floating-Panels · Ressourcen-Overlay
|
||||
```
|
||||
|
||||
## Store-Wahl: Zustand (empfohlen)
|
||||
- Winzige Lib, kein Boilerplate, **Slices** gut teilbar, Selektoren verhindern
|
||||
Re-Render-Sturm, kein Prop-Drilling. Passt zu „verschiedene Features = verschiedene
|
||||
Slice-Dateien" → Parallelität.
|
||||
- Alternative ohne Dependency: Context + useReducer oder `useSyncExternalStore`
|
||||
(wie i18n). Mehr Boilerplate; bei der State-Menge ist Zustand ergonomischer.
|
||||
- Komponenten: `const walls = useStore(s => s.walls)` / `useStore(s => s.addFloor)`.
|
||||
PanelHostContext entfällt (Panels lesen direkt aus dem Store).
|
||||
|
||||
## Vorgehen (reiner Refactor — Verhalten MUSS identisch bleiben)
|
||||
1. Store + Slices anlegen, Projekt-State + Mutationen aus App.tsx hierher ziehen
|
||||
(1:1, gleiche Logik inkl. recomputeFloorElevations, refs-aware delete).
|
||||
2. View-/Selection-/Layout-State in ihre Slices.
|
||||
3. Inline-Editoren, Kontextmenü-Builder, View-Routing in `src/editors/`,`src/menus/`,`src/views/` extrahieren; sie lesen den Store.
|
||||
4. App.tsx auf den Shell reduzieren.
|
||||
5. Verifizieren: tsc + build + Screenshots — **pixel-/funktionsgleich** zu vorher
|
||||
(Auswahl, Massstab, Kontextmenü, Panels, 3D/Plan, i18n). Reiner Umbau, kein
|
||||
Feature-Wechsel.
|
||||
|
||||
## Auszahlung
|
||||
- Danach editiert ein Wand-Feature `projectSlice`/`views`, ein Panel-Feature `panels`,
|
||||
ein Editor `editors` — **disjunkte Dateien → mehrere Code-Workflows parallel** möglich.
|
||||
@@ -0,0 +1,306 @@
|
||||
# Architektur-Pivot: Tauri + Rust-Backend (2026-07-01)
|
||||
|
||||
## Entscheidung
|
||||
|
||||
**Alte Welt:** Browser-CAD (React/Vite + WebGL/three.js)
|
||||
**Neue Welt:** Desktop Tauri-App (React/Vite Frontend + Rust-Backend + **wgpu 3D-Rendering**)
|
||||
|
||||
**Grund:** Komplexe Möbel mit vielen Polygonen (100k+) + mehrere parallele rechenintensive Ops → WebGL/three.js wird zum Bottleneck. **wgpu** (low-level GPU-API auf Vulkan/Metal/DX12) + Rust-Compute skaliert native.
|
||||
|
||||
**Scope:** Desktop-only Release (Milestone 1). WASM-Fallback für Browser später wenn gebraucht.
|
||||
|
||||
---
|
||||
|
||||
## Post-Migration Stack
|
||||
|
||||
### Frontend (React/Vite — Komponenten + State, unverändert)
|
||||
|
||||
```
|
||||
src/
|
||||
App.tsx ← Shell-Komponente
|
||||
compute/index.ts ← Compute-Boundary (neu)
|
||||
model/types.ts ← Semantisches Modell
|
||||
commands/ ← Befehlssystem
|
||||
panels/ ← UI-Panels
|
||||
plan/PlanView.tsx ← 2D-SVG-Rendering
|
||||
viewport/Viewport3D.tsx ← three.js 3D-Display
|
||||
ui/ ← Topbar, Dialogs, etc.
|
||||
state/ ← Redux-Slices (project, selection, view, layout)
|
||||
...
|
||||
```
|
||||
|
||||
**Rolle:** User-Input-Handling, 2D/3D-Darstellung (Display-Layer), State-Management.
|
||||
|
||||
### Backend (Rust/Tauri — neu)
|
||||
|
||||
```
|
||||
src-tauri/
|
||||
src/
|
||||
main.rs ← Tauri window + invoke-handler registration
|
||||
geometry.rs ← compute_joins(), kernel2d(), etc.
|
||||
parsers/
|
||||
dwg.rs ← DXF/DWG-Geometrie-Parsing
|
||||
dxf.rs
|
||||
sia/
|
||||
room_detection.rs ← detectRooms() (SIA-416)
|
||||
...
|
||||
Cargo.toml ← Dependencies (serde, tauri, …)
|
||||
```
|
||||
|
||||
**Rolle:** Rechenintensive Ops, Geometrie-Kernel, Parsing, SIA-Raumerkennung.
|
||||
|
||||
### IPC: Tauri invoke (async, serde)
|
||||
|
||||
```typescript
|
||||
// Frontend ruft Rust auf
|
||||
const result = await invoke<JoinInfo[]>('compute_joins', { walls, joints });
|
||||
|
||||
// Rust bearbeitet + serialisiert Ergebnis
|
||||
#[tauri::command]
|
||||
async fn compute_joins(input: JoinInput) -> Result<JoinOutput, String> {
|
||||
geometry::compute_joins(input).map_err(|e| e.to_string())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compute-Boundary (Key Design)
|
||||
|
||||
**Neue Datei:** `src/compute/index.ts` — einziger Eingang für rechenintensive Ops.
|
||||
|
||||
```typescript
|
||||
// Beispiel-Schnittstellen
|
||||
export async function computeJoins(walls: Wall[], joints: Array<[number, number]>): Promise<JoinInfo[]> { … }
|
||||
export async function computeKernel2D(op: 'offset'|'trim', geom: Polyline, …): Promise<Polyline[]> { … }
|
||||
export async function detectRooms(…): Promise<Room[]> { … }
|
||||
export async function parseShapeFromDwg(…): Promise<DwgGeometry> { … }
|
||||
```
|
||||
|
||||
**Hinter der Boundary:**
|
||||
1. Versuche Tauri invoke zu Rust (`#[tauri::command]`)
|
||||
2. Fallback auf lokale TS-Impl wenn Rust nicht verfügbar (während Migration)
|
||||
|
||||
**Effekt:** Aufrufort im Code bleibt stabil; Caller sieht nicht, ob Op schon in Rust ist oder noch TS.
|
||||
|
||||
**Migrationsfluss:**
|
||||
```
|
||||
1. TS-Impl existiert (z.B. src/model/joins.ts)
|
||||
2. Neue Op in Compute-Boundary mit Invoke+Fallback
|
||||
3. Parallel: Rust-Impl in src-tauri/src/geometry.rs
|
||||
4. Tests: Rust-Output == TS-Output (Parität)
|
||||
5. TS-Impl bleibt (Fallback, wird nicht entfernt bis Rust stable)
|
||||
6. Eventuell: TS-Impl löschen wenn Rust bewährt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dev-Workflow (post-Tauri)
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Terminal 1: Vite dev-server
|
||||
npm run dev # localhost:5173
|
||||
|
||||
# Terminal 2: Tauri dev
|
||||
npm run tauri:dev # öffnet Tauri-Fenster, zeigt auf localhost:5173
|
||||
# Rust hot-reload + TS hot-reload gleichzeitig
|
||||
```
|
||||
|
||||
**Voraussetzungen:**
|
||||
- Node.js + npm (wie heute)
|
||||
- Rust + Cargo (neu)
|
||||
- Tauri CLI: `npm install -D @tauri-apps/cli`
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Single command
|
||||
npm run tauri:build
|
||||
|
||||
# Erzeugt:
|
||||
# - Windows: src-tauri/target/release/cad.exe
|
||||
# - macOS: src-tauri/target/release/bundle/macos/cad.app
|
||||
# - Linux: src-tauri/target/release/bundle/deb/cad_*.deb (oder Flatpak)
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Rust-Unit-Tests
|
||||
cargo test # in src-tauri/
|
||||
|
||||
# TS-Tests (unverändert)
|
||||
npm run test
|
||||
|
||||
# Integration-Test: App starten + Aktion prüfen
|
||||
npm run tauri:dev # manuell testen oder Puppeteer-Probe erweitern
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GPU-Strategy (für später)
|
||||
|
||||
**Milestone 1 (jetzt):** CPU-Ops in Rust (kernel2d, joins, parsing, SIA).
|
||||
|
||||
**Milestone 2 (später):** GPU-Compute via wgpu
|
||||
- Tauri + wgpu Renderer (optional, nicht erforderlich)
|
||||
- ODER drei.js bleibt, Rust handelt CPU-Ops, three.js handelt Display
|
||||
- GPU-Heavy-Ops (z.B. große Boolean-Operationen) können in wgpu laufen, aber MVP braucht das nicht
|
||||
|
||||
**Aktueller Plan:** three.js bleibt für 3D-Display (skaliert ausreichend für Möbel-Geometrie mit Instancing + LOD).
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy: Ops nach Priorisierung
|
||||
|
||||
**Phase 1 (aktuell — Tauri-Shell + Proof-of-Concept):**
|
||||
- [ ] `computeJoins` (Wand-Eckverbindungen) → Rust
|
||||
- Gründe: klein, häufig, zeigt invoke-Flow
|
||||
|
||||
**Phase 2 (nächst):**
|
||||
- [ ] `kernel2d` (Offset/Trim/Extend/Fillet) → Rust
|
||||
- Gründe: Rechenlast ⭐⭐, Frequenz hoch
|
||||
- [ ] DXF/DWG-Parser → Rust (Geometrie-Extraktion)
|
||||
- Gründe: Rechenlast ⭐⭐, Frequenz mittel (Import-Dialog)
|
||||
|
||||
**Phase 3 (später):**
|
||||
- [ ] `detectRooms` (SIA-416 Raumerkennung) → Rust
|
||||
- Gründe: Rechenlast ⭐, async-freundlich
|
||||
- [ ] `booleanOps` (Union/Differenz/Schnitt) → Rust
|
||||
- Gründe: Rechenlast ⭐⭐, Frequenz gering (ad-hoc)
|
||||
|
||||
---
|
||||
|
||||
## Folgen für bestehenden Code
|
||||
|
||||
### Was ändert sich NICHT
|
||||
|
||||
- `src/model/types.ts` — semantisches Modell bleibt in TS (Frontend kennt es)
|
||||
- `src/state/` — Redux-Store unverändert
|
||||
- `src/ui/` — Komponenten unverändert
|
||||
- `src/plan/PlanView.tsx` — SVG-Rendering unverändert
|
||||
- `src/viewport/Viewport3D.tsx` — three.js-Rendering unverändert
|
||||
- `src/commands/` — Befehlssystem unverändert
|
||||
|
||||
### Was ändert sich
|
||||
|
||||
- **Neue `src/compute/index.ts`** — alle rechenintensiven Ops laufen durch hier
|
||||
- **Neue `src-tauri/`** — Rust-Backend
|
||||
- **Vite-Config:** Tauri plugin hinzufügen
|
||||
- **Package.json:** tauri scripts hinzufügen
|
||||
- **Build-Prozess:** `npm run tauri:build` statt `npm run build`
|
||||
|
||||
### Was wird migriert (schrittweise)
|
||||
|
||||
- `src/model/joins.ts` → `src-tauri/src/geometry.rs` (Phase 1)
|
||||
- `src/geometry/kernel2d.ts` → `src-tauri/src/geometry.rs` (Phase 2)
|
||||
- `src/io/{dxfParser, dwgParser}.ts` → `src-tauri/src/parsers/` (Phase 2)
|
||||
- `src/geometry/{roomArea, roomBoundary}.ts` → `src-tauri/src/sia/room_detection.rs` (Phase 3)
|
||||
- `src/editors/booleanOps.ts` → `src-tauri/src/geometry.rs` (Phase 3)
|
||||
|
||||
**Wichtig:** TS-Versionen bleiben als Fallback (nicht gelöscht).
|
||||
|
||||
---
|
||||
|
||||
## Distribution (später)
|
||||
|
||||
### Desktop Binaries (post-Tauri)
|
||||
|
||||
- **Windows:** `.exe` (standalone executable)
|
||||
- **macOS:** `.app` bundle (code-signed)
|
||||
- **Linux:** `.deb` package ODER **Flatpak** (preferred)
|
||||
- Flatpak = moderne WebKitGTK6 immer dabei, unabhängig von Distro-Alter
|
||||
|
||||
### Browser (wenn gebraucht)
|
||||
|
||||
- **WASM-Fallback** für `src/compute/` Ops (Rust → WASM via wasm-bindgen)
|
||||
- Later-phase feature, nicht Milestone 1
|
||||
|
||||
---
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Serialisierung (TS ↔ Rust)
|
||||
|
||||
**serde + serde_json** für Geometrie-Typen:
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Vec2 { pub x: f64, pub y: f64 }
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Wall {
|
||||
pub id: String,
|
||||
pub start: Vec2,
|
||||
pub end: Vec2,
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TS (type-safe invoke)
|
||||
interface Vec2 { x: number; y: number }
|
||||
interface Wall { id: string; start: Vec2; end: Vec2; /* … */ }
|
||||
|
||||
await invoke<JoinInfo[]>('compute_joins', { walls: Wall[] })
|
||||
```
|
||||
|
||||
### Tauri Security (default)
|
||||
|
||||
- Invoke-Handler sind Rust-side validiert
|
||||
- Whitelist-Makro (`#[tauri::command]`) registered nur explizit erlaubte Functions
|
||||
- CORS/CSP Policy default secure
|
||||
- Keine arbitrary-Script-Execution (native app)
|
||||
|
||||
---
|
||||
|
||||
## Abhängigkeiten (neu post-Tauri)
|
||||
|
||||
### Frontend (npm)
|
||||
- Bestehende: react, vite, three.js, redux, etc.
|
||||
- Neu: `@tauri-apps/api` (JS-SDK für invoke)
|
||||
- Optional später: `@tauri-apps/cli` dev-dependency (bereits in package.json)
|
||||
|
||||
### Backend (Cargo)
|
||||
```toml
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["webkit2gtk-6.0"] } # GTK4
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
# später: wgpu, delaunator, opencascade-sys, etc.
|
||||
```
|
||||
|
||||
### System
|
||||
- Rust 1.70+
|
||||
- GTK4 dev libraries (Linux only, auto-handled by Tauri)
|
||||
- Xcode Command Line Tools (macOS, auto-checked)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Koordination)
|
||||
|
||||
**Aufgabe für nächste Phase:**
|
||||
→ Siehe `docs/design/tauri-migration-plan.md` (Schritt-für-Schritt, vier parallele Agents)
|
||||
|
||||
**HANDOVER.md:** wird aktualisiert nach Tauri-Shell stabil.
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Läuft die App noch im Browser?**
|
||||
A: Nein (Milestone 1). Desktop-only. WASM-Fallback für Browser später wenn gebraucht.
|
||||
|
||||
**Q: Was passiert mit dem existing TS-Code?**
|
||||
A: Bleibt unverändert (außer neue Compute-Boundary). TS-Implementierungen = Fallback bis Rust stabil.
|
||||
|
||||
**Q: Muss ich Rust können um das Projekt zu verstehen?**
|
||||
A: Nein. Frontend bleibt React/TS. Rust ist "blackbox" hinter invoke. Aber bei Rust-Bugs muss man rein.
|
||||
|
||||
**Q: Wann ist Tauri-Shell fertig?**
|
||||
A: Nach den vier Agents (Schritt 1–4 in tauri-migration-plan.md), ~1–2 Wochen.
|
||||
|
||||
**Q: Kann ich lokal testen?**
|
||||
A: Ja, `npm run tauri:dev` öffnet lokale App. Alles wie heute, nur Rust hinten dran.
|
||||
@@ -0,0 +1,243 @@
|
||||
# Tauri-Migration + Compute-Boundary — Aufgabe für nächste Instanz
|
||||
|
||||
**Entscheidung (fix, 2026-07-01):** Browser-CAD → **Desktop Tauri-App mit Rust-Backend + wgpu-Rendering.**
|
||||
|
||||
**Grund:** komplexe Möbel mit vielen Polygonen (100k+) + mehrere parallele rechenintensive Ops → WebGL/three.js wird zum Blocker. **wgpu** (low-level GPU-API) + Rust-Compute skaliert native.
|
||||
|
||||
**Scope:** Desktop-only Release (Milestone 1). WASM-Fallback für Browser später wenn gebraucht.
|
||||
|
||||
**Rendering-Engine:** three.js → **wgpu** (Milestone 2)
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe: Shell aufsetzen + Compute-Boundary + erste Op migrieren
|
||||
|
||||
### Schritt 0 — Compute-Boundary (TS-Kontrakt)
|
||||
|
||||
**Neu:** `src/compute/index.ts` — die einzige Stelle, durch die alle rechenintensiven Ops laufen.
|
||||
|
||||
```typescript
|
||||
// src/compute/index.ts — einheitliche Schnittstelle
|
||||
export async function computeKernel2D(op: 'offset'|'trim', …): Promise<Polyline[]> { … }
|
||||
export async function detectRooms(…): Promise<Room[]> { … }
|
||||
export async function computeJoins(…): Promise<JoinInfo[]> { … } // ← erste Op
|
||||
export async function parseShapeFromDwg(…): Promise<DwgGeometry> { … }
|
||||
```
|
||||
|
||||
**Hinter der Boundary:**
|
||||
- Erst Tauri-invoke zu Rust `#[tauri::command]`
|
||||
- Fallback auf lokale TS-Impl (bleibt unberührt, bis Rust stabil)
|
||||
- `catch(err) → console.warn('Rust failed, using TS fallback'); return tsImpl(…)`
|
||||
|
||||
**Effekt:** Aufrufort im Code bleibt stabil; Caller sieht nicht, ob Op schon in Rust ist oder noch TS.
|
||||
|
||||
---
|
||||
|
||||
### Schritt 1 — Tauri-Shell aufsetzen
|
||||
|
||||
**Struktur:**
|
||||
```
|
||||
repo/
|
||||
src-tauri/ ← neue Rust-Seite (Tauri-Konvention)
|
||||
src/
|
||||
main.rs ← Tauri window + invoke handlers
|
||||
geometry.rs ← compute_joins() + weitere Ops später
|
||||
...
|
||||
Cargo.toml
|
||||
src/ ← React/TS (unverändert)
|
||||
compute/
|
||||
index.ts ← Compute-Boundary
|
||||
...
|
||||
vite.config.ts ← Tauri plugin integrieren
|
||||
package.json ← tauri scripts
|
||||
```
|
||||
|
||||
**Setup:**
|
||||
1. `cargo init --name cad-tauri src-tauri` (oder `src-tauri` manuell anlegen)
|
||||
2. `Cargo.toml`: Tauri v2 einbinden mit Feature `webkit2gtk-6.0` (GTK4)
|
||||
```toml
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["webkit2gtk-6.0"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
```
|
||||
3. `src-tauri/src/main.rs`: Minimal-Fenster, invoke-Handler registrieren
|
||||
```rust
|
||||
#[tauri::command]
|
||||
async fn compute_joins(input: JoinInput) -> Result<JoinOutput, String> {
|
||||
// Rust-Impl
|
||||
geometry::compute_joins(input).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![compute_joins])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
```
|
||||
4. `vite.config.ts`: Tauri plugin + dev-server-Integration
|
||||
```typescript
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5173 } // Tauri dev zeigt hier drauf
|
||||
})
|
||||
```
|
||||
5. `package.json`: Tauri scripts hinzufügen
|
||||
```json
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Schritt 2 — Erste Op migrieren: `computeJoins` (Wand-Eckverbindungen)
|
||||
|
||||
**Warum `computeJoins` zuerst?**
|
||||
- Läuft häufig (bei jedem Wall-Edit)
|
||||
- Klein und fokussiert (~50 Zeilen Kernlogik)
|
||||
- Proof-of-Concept für Tauri-invoke-Flow
|
||||
- Danach `kernel2d` parallel hochfahren
|
||||
|
||||
**Rust-Impl:** `src-tauri/src/geometry.rs`
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Vec2 { pub x: f64, pub y: f64 }
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct WallJoinInput {
|
||||
pub walls: Vec<Wall>,
|
||||
pub joints: Vec<(usize, usize)>, // wall indices
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct JoinInfo { /* … */ }
|
||||
|
||||
pub fn compute_joins(input: WallJoinInput) -> Result<Vec<JoinInfo>, Box<dyn std::error::Error>> {
|
||||
// Port der Logik aus src/model/joins.ts
|
||||
// L-Ecken, T-Stösse, +-Kreuzungen
|
||||
Ok(vec![]) // Placeholder
|
||||
}
|
||||
```
|
||||
|
||||
**TS-Fallback bleibt:** `src/model/joins.ts` (LS vor Rust-Port)
|
||||
|
||||
**Compute-Boundary:** `src/compute/index.ts`
|
||||
```typescript
|
||||
export async function computeJoins(walls: Wall[], joints: Array<[number, number]>): Promise<JoinInfo[]> {
|
||||
try {
|
||||
return await invoke<JoinInfo[]>('compute_joins', { walls, joints });
|
||||
} catch (err) {
|
||||
console.warn('Rust compute_joins failed, using TS fallback:', err);
|
||||
return joinsTS(walls, joints); // Fallback
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Schritt 3 — Migrations-Parität testen
|
||||
|
||||
**Test-Fixtures:**
|
||||
- Aus `src/model/sampleProject.ts` exportieren: Wand-Arrays mit bekannten L/T/±-Konfigurationen
|
||||
- Rust-Unit-Tests: gleiche Fixtures → gleiche JoinInfo-Outputs
|
||||
- Vergleich: `actual == expected`
|
||||
|
||||
**Beispiel (Rust):**
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_l_corner() {
|
||||
let input = WallJoinInput { /* L-shaped walls */ };
|
||||
let result = compute_joins(input).unwrap();
|
||||
assert_eq!(result[0].kind, JoinKind::LCorner);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Schritt 4 — Vite/Tauri-Integration
|
||||
|
||||
**Dev-Workflow:**
|
||||
```bash
|
||||
npm run tauri:dev
|
||||
# → Vite dev-server (localhost:5173) lädt React-App
|
||||
# → Tauri-window zeigt auf :5173
|
||||
# → invoke() ruft Rust-Commands auf
|
||||
```
|
||||
|
||||
**Build-Workflow:**
|
||||
```bash
|
||||
npm run build # Vite → dist/
|
||||
npm run tauri:build # Tauri packt dist/ + Rust-Binary
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deliverable (Proof-of-Concept)
|
||||
|
||||
**Git-Stand nach dieser Aufgabe:**
|
||||
|
||||
- [ ] `src-tauri/` Verzeichnis mit `Cargo.toml` + `src/main.rs` + `src/geometry.rs`
|
||||
- [ ] `Cargo.toml` buildet sauber (`cargo check` 0 Fehler)
|
||||
- [ ] `src/compute/index.ts` mit `computeJoins()` Schnittstelle (invoke + TS-fallback)
|
||||
- [ ] Rust `compute_joins()` implementiert, Tests pass (`cargo test`)
|
||||
- [ ] `package.json` `tauri` scripts hinzugefügt
|
||||
- [ ] **App läuft:** `npm run tauri:dev` → Tauri-Fenster öffnet, Wand-Edit triggert Rust-Op, Output identisch TS-Version
|
||||
- [ ] **Trace-Scan sauber** (kein TS/Rust-Code übrig, beide Impl. aktiv)
|
||||
- [ ] HANDOVER.md aktualisiert: `computeJoins` migriert, nächste Ops in Queue
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
# Build-Check
|
||||
cargo check # 0 Fehler
|
||||
|
||||
# Rust-Tests
|
||||
cargo test
|
||||
|
||||
# App end-to-end
|
||||
npm run tauri:dev
|
||||
# → Wand zeichnen + editieren → computeJoins() über Rust aufgerufen
|
||||
# → Plan + 3D aktualisiert wie vorher
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nächste Ops (Priorisierung)
|
||||
|
||||
Nach `computeJoins` stabil:
|
||||
|
||||
1. **`kernel2d`** (Offset/Trim/Extend) — großer Hebel, läuft häufig
|
||||
2. **DXF/DWG-Parser** (Geometrie) — heavy, aber niedrige Frequenz
|
||||
3. **`detectRooms`** (SIA-Raumerkennung) — async, kann auf Hintergrund ziehen
|
||||
4. **`booleanOps`** (Union/Differenz/Schnitt) — Kandidat für später
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte (NICHT jetzt)
|
||||
|
||||
- **GPU-Compute (wgpu):** Erst nach CPU-Ops stabil (kernel2d, joins, parsing)
|
||||
- **WASM-Fallback:** Nur wenn Browser-Support nötig wird
|
||||
- **Flatpak-Distribution:** Nach Tauri-Shell stable + erste Ops migriert
|
||||
|
||||
---
|
||||
|
||||
## Referenzen
|
||||
|
||||
- Tauri v2 Docs: https://tauri.app/v1/guides/getting-started/prerequisites
|
||||
- Serde: https://serde.rs/
|
||||
- CONVENTIONS.md: Identifiers englisch, UI-Text via `t()`, …
|
||||
- Commit-Regel: kein AI-Attribution im Repo
|
||||
@@ -0,0 +1,151 @@
|
||||
# Oberleiste – Angleichung an DOSSIER (Umsetzungs-Spezifikation)
|
||||
|
||||
Verbindliche Vorlage für den Umbau der Top-Bar (`src/ui/TopBar.tsx`,
|
||||
`src/styles.css`, Verdrahtung in `src/App.tsx`). Quelle: das DOSSIER-Rhino-
|
||||
Plugin (`ToolbarApp.jsx`, `components/BarControls.jsx`, `TextEditorApp.jsx`).
|
||||
Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
## 0. Grundprimitive (neu, DOSSIER-konform)
|
||||
|
||||
Alle Leisten-Controls teilen dieselbe Höhe und Pillenform.
|
||||
|
||||
- `BAR_H = 22px` Basis-Höhe. Segmentierte Pillen `BAR_H + 2 = 24px`
|
||||
(`box-sizing:border-box`, 1px Rand inbegriffen).
|
||||
- Pille: `border:1px solid var(--border)`, `border-radius:999px`,
|
||||
`background:var(--input)`. Hover (interaktiv): `border-color:var(--accent-border)`,
|
||||
`background:var(--accent-dim)`.
|
||||
- Aktiver Zustand (Toggle AN / aktive Segmentzelle): `background:var(--accent)`,
|
||||
`color:#fff`.
|
||||
|
||||
### BarCombo (Pillen-Dropdown)
|
||||
Wir haben bereits `src/ui/Dropdown.tsx`. Der Dropdown-Trigger MUSS optisch der
|
||||
Pille entsprechen (Höhe 24, radius 999, obige Farben). Ein optionales Icon sitzt
|
||||
LINKS **ausserhalb** der Pille (18px breit, `var(--muted)`), ein optionaler
|
||||
Zahnrad-Knopf („settings") sitzt rechts **innerhalb** der Pille. Prüfen, ob
|
||||
`Dropdown` bereits so aussieht; falls nicht → Trigger-CSS angleichen (Klasse
|
||||
`tb-dd-trigger`). KEINE zweite Dropdown-Implementierung bauen.
|
||||
|
||||
### Segmentpille (3er/4er)
|
||||
Aussencontainer `display:inline-flex; height:24px; border:1px solid var(--border);
|
||||
border-radius:999px; overflow:hidden`. Zellen ohne eigenen Radius; interne Trenner
|
||||
über `border-left:1px solid var(--border)` (erste Zelle ohne). Aktive Zelle
|
||||
`var(--accent)`/#fff, inaktiv `var(--input)`/`var(--ink)`, Hover
|
||||
`var(--accent-dim)`/`var(--accent)`. Genutzt für: Ansichts-Icons, Zoom (%/fit/center),
|
||||
**B/I/U**, **L/C/R**.
|
||||
|
||||
### BarButton (quadratischer Icon-Knopf)
|
||||
22×22, `border-radius:999px`, sonst wie Pille. Aktiv = Akzentfüllung, Icon #fff.
|
||||
|
||||
## 1. Reihenfolge der Gruppen (links → rechts)
|
||||
|
||||
1. Marke (bestehend, unverändert).
|
||||
2. Ansichts-Gruppe (bestehend `view-grid`; Zellen auf Segmentpillen-Look bringen).
|
||||
3. Sichtbarkeits-Kombinationen (bestehend, `BarCombo`-Look).
|
||||
4. Detailgrad + Massstab (gestapelt, `BarCombo`-Look).
|
||||
5. **Massstab/Zoom-Cluster NEU** (siehe §2) — ersetzt die heutige Gruppe mit der
|
||||
DOPPELTEN Zoom-Anzeige.
|
||||
6. Darstellungsart (bestehend, `BarCombo`).
|
||||
7. **Text-Gruppe NEU** (siehe §3) — die zentrale neue Leiste.
|
||||
8. Referenzlinien / Linien-Modus (bestehend).
|
||||
9. Rechts: Layout · Ressourcen · Projektname.
|
||||
|
||||
## 2. Massstab/Zoom-Cluster (ersetzt Doppel-Zoom-Bug)
|
||||
|
||||
HEUTE FALSCH: In `TopBar.tsx` wird `tb-zoom` (Zoom %) ZWEIMAL gerendert
|
||||
(einmal im `tb-zoomstack`, einmal darunter als eigener `<span>`). Die zweite,
|
||||
lose `<span className="tb-zoom">…%</span>` ersatzlos ENTFERNEN.
|
||||
|
||||
NEUES Layout — 2×2-Raster (`display:grid; grid-template-columns:auto auto;
|
||||
gap:4px 6px; align-items:center`):
|
||||
|
||||
- **Spalte 1, beide Zeilen** (`grid-row:1 / span 2`): EINE kombinierte Stat-Pille,
|
||||
`width:70px`, Höhe `BAR_H*2+6 = 50px`, `border-radius:14px` (NICHT 999),
|
||||
`border:1px solid var(--border)`, `background:var(--input)`, Innen zwei Zeilen
|
||||
mittig, getrennt durch 1px-Linie (`var(--border)`):
|
||||
- oben: Live-Massstab `1:N` (Akzentfarbe, `var(--font-mono)`, 11px, 700)
|
||||
- unten: Zoom `NN%` (`var(--ink-2)`, mono, 11px)
|
||||
- „am Massstab" (Zoom==gewählter Massstab): Pille `background:var(--accent-dim)`,
|
||||
`border-color:var(--accent)`, Text `var(--accent)`.
|
||||
- Nicht-Plan-Ansicht: beide Werte „—".
|
||||
- **Spalte 2, Zeile 1**: Massstab-Dropdown (`BarCombo`, ~140px, mono) + Print/PDF
|
||||
bleibt separat. (Massstab-Dropdown ist der bestehende `scaleOptions`-Dropdown.)
|
||||
- **Spalte 2, Zeile 2**: Zoom-Segmentpille mit 3 Zellen — `%` (=`onZoom100`,
|
||||
Label „1:1"/100 %), `fit_screen` (=`onFit`), `center_focus_strong`
|
||||
(=`onFitSelection`). Material-Icons. Daneben ggf. Referenzlinien-BarButton.
|
||||
|
||||
Export-Knöpfe (PDF/DXF) wandern in eine eigene kleine BarButton-Reihe rechts vom
|
||||
Cluster (Icons `picture_as_pdf` / `download`) ODER bleiben Pillen — Hauptsache
|
||||
NICHT mehr Teil des Zoom-Blocks, damit der Cluster ruhig bleibt.
|
||||
|
||||
## 3. Text-Gruppe in der Oberleiste (NEU – Kern dieser Aufgabe)
|
||||
|
||||
Immer sichtbar. 3×2-Raster (`grid-template-columns:110px 130px 80px; gap:4px 6px`).
|
||||
Setzt Defaults für neuen Text UND formatiert die aktuelle Auswahl live.
|
||||
|
||||
Zeile 1:
|
||||
- **Stil-Preset** `BarCombo` (110px): Optionen aus `DEFAULT_PRESETS`
|
||||
(Titel/Untertitel/Label/Notiz) + „— Stil —".
|
||||
- **Font** `BarCombo` (130px): Systemfont-Liste (mind. Helvetica, Arial, Inter,
|
||||
Times New Roman, Georgia, Courier New). `applyMark(doc,range,'font',v)`.
|
||||
- **Grösse** `BarCombo` (80px): Presets in pt `[8,9,10,11,12,14,18,24,36,48]`
|
||||
+ „Eigene…" → Zahl-Input-Pille. `applyMark(...,'sizePt',n)`.
|
||||
|
||||
Zeile 2:
|
||||
- **B/I/U** Segmentpille (110px, Icons `format_bold`/`format_italic`/
|
||||
`format_underlined`): `toggleMark(doc,range,'bold'|'italic'|'underline')`.
|
||||
Aktiv-Zustand aus `isMarkActive(doc,range,mark)`.
|
||||
- **L/C/R** Segmentpille (130px, Icons `format_align_left`/`_center`/`_right`):
|
||||
setzt `paragraph.align` im Bereich.
|
||||
- **„+"-Text-Button** (80px, BarButton/Pille, Icon `add`, Label „Text"): startet
|
||||
das Text-Werkzeug (neues Textobjekt platzieren). Falls das Text-Annotation-
|
||||
Werkzeug noch nicht existiert, Button vorerst `disabled` mit Tooltip
|
||||
(kein stiller No-Op) — aber Verdrahtung vorbereiten.
|
||||
|
||||
**Auswahl-Bewusstsein (WICHTIG):** Prop `textTarget` (oder aus App-State): entweder
|
||||
`null` (nichts Text-artiges selektiert → Controls setzen nur Defaults, Ränder
|
||||
normal) ODER `{ doc: RichTextDoc, range: TextRange|null, apply: (doc)=>void }`
|
||||
für den aktuell selektierten Raumstempel/Text. Ist `textTarget != null`, tragen
|
||||
die Zeile-2-Pillen `border-color:var(--accent)` (Akzent-Glow), und alle Aktionen
|
||||
wirken auf `textTarget.doc` via `textTarget.apply(newDoc)`. Ohne aktive Range
|
||||
(nur Objekt selektiert, kein Editor offen) wirkt Formatierung auf das GANZE Doc.
|
||||
|
||||
Quelle der `textTarget`-Daten: der Raum-Agent exponiert Stempel-Doc + Setter
|
||||
(`setRoomStampDoc(roomId, doc)`) im App-State (siehe Peer-Absprache). App leitet
|
||||
für den selektierten Raum `{doc: room.stampDoc, range: activeStampRange,
|
||||
apply: d => setRoomStampDoc(room.id, d)}` an die Text-Gruppe.
|
||||
|
||||
## 4. Text-Inhalt bearbeiten: kleines Fenster (kein Footer)
|
||||
|
||||
Doppelklick auf einen Raumstempel/ein Textobjekt öffnet ein **schwebendes
|
||||
Dialog-Fenster** (nicht den Footer, kein Panel-Aufklappen). Umsetzung: neue
|
||||
Komponente `src/ui/TextEditorDialog.tsx` — ein zentriertes/абgesetztes Fenster
|
||||
(~560×420, `--shadow-3`, `border-radius:8px`, Titel „Text bearbeiten",
|
||||
Kopf mit Schliessen-✕), Body = der bestehende `src/text/RichTextEditor.tsx`
|
||||
(er bringt seine eigene Mini-Toolbar mit — das ist hier ok, weil es ein eigenes
|
||||
Fenster ist), Fuss = „Abbrechen" / „Übernehmen". „Übernehmen" ruft
|
||||
`setRoomStampDoc(roomId, editedDoc)`.
|
||||
|
||||
Der Doppelklick-Handler lebt in App (Plan-View/Viewport → onDoubleClick auf
|
||||
Stempel-Hit → `openTextEditor(roomId)`), NICHT im Footer. Falls der Raum-Agent
|
||||
den Footer benutzt hat: diesen Pfad entfernen und durch den Dialog ersetzen.
|
||||
|
||||
## 5. Farb-/Stil-Tokens
|
||||
|
||||
Bestehende CSS-Variablen weiterverwenden (`--panel`,`--input`,`--border`,
|
||||
`--accent`,`--accent-dim`,`--accent-border`,`--ink`,`--ink-2`,`--muted`,
|
||||
`--font-mono`,`--shadow-1..3`). KEINE neuen Farbwerte hart kodieren. Falls ein
|
||||
Token fehlt (z. B. `--accent-border`), prüfen und ggf. aus bestehenden ableiten.
|
||||
|
||||
## 6. i18n
|
||||
|
||||
Neue Keys in de.ts UND en.ts: `text.style`, `text.font`, `text.size`,
|
||||
`text.size.custom`, `text.bold/italic/underline`, `text.align.left/center/right`,
|
||||
`text.add`, `text.add.hint`, `text.editTitle`, `text.apply`, `text.cancel`,
|
||||
`text.selectedHint`. Presets-Namen über bestehende `rt.*`/Preset-Keys, sofern da.
|
||||
|
||||
## 7. Gate (Pflicht)
|
||||
|
||||
`rm -f tsconfig.tsbuildinfo && npx tsc -b` grün, `npm run build` grün, keine
|
||||
AI-Spuren (grep auf Claude/Anthropic/AI/Generated/Co-Authored), Boot-Probe
|
||||
(`node scripts/probe.mjs`) ohne Konsolenfehler, Screenshot der Oberleiste.
|
||||
KEIN Commit.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Top-Bar (Oberleiste) & Footer/Status-Leiste — Design
|
||||
|
||||
> Referenz: DOSSIER `rhino/toolbar.py` + `src/ToolbarApp.jsx`. Hier auf unseren
|
||||
> Standalone-Stack (React+TS, eigenes Modell) übersetzt. DOSSIER hat KEINEN Footer
|
||||
> (delegiert an Rhinos eigene Leiste) — den Footer ergänzen wir neu (Vectorworks-Stil).
|
||||
|
||||
## Top-Bar — Gruppen (links → rechts)
|
||||
|
||||
1. **Marke/Logo** + Settings-Icons (Projekt-Einstellungen, App-Einstellungen).
|
||||
2. **Ansicht** — Umschalter: Grundriss · Perspektive · Schnitt · Ansicht.
|
||||
Später: 3D-Views Top/Iso + Himmelsrichtungen N/O/S/W (mit Nordwinkel-Rotation).
|
||||
3. **Darstellung** — Render-/Anzeigemodus (Wireframe/Shaded/…) + **Detailgrad**
|
||||
(grob/mittel/fein, ≙ DOSSIER „Darstellung" Einfach/Standard/Detail).
|
||||
4. **Massstab & Zoom** — Live-Anzeige „1:N" + Dropdown (1:1,1:5,…,1:1000, frei) +
|
||||
**Plan-Ansicht-Toggle** (Linienstärken für Druck) + Zoom-Buttons: 100% · Einpassen ·
|
||||
Auswahl. Quelle: viewBox-Skala / `dpi = 96·devicePixelRatio` (siehe docs/design/plans-output.md).
|
||||
5. **Overrides** (regelbasiert, Toggle + Preset) · **Masse** (Bemaßungs-Preset) — später.
|
||||
6. **Anordnen (Z-Order)** — nach vorne/hinten (für 2D-Plangrafik) — später.
|
||||
7. **Snapping** — Master-Osnap + Modi (End/Mitte/Schnittpunkt/Lot/Zentrum/Nah) +
|
||||
**Raster** an/aus + **Referenzlinien** (Wandachsen) an/aus — wenn Zeichenwerkzeuge da sind.
|
||||
8. **Text** — Stil/Font/Größe + B/I/U + Ausrichtung + „+" — mit den 2D-Werkzeugen.
|
||||
|
||||
### MVP jetzt (zu vorhandenem Modell)
|
||||
- Ansichts-Umschalter (haben wir, ausbauen) · Detailgrad-Dropdown · **Massstab 1:N +
|
||||
Zoom: Einpassen/Auswahl/100%** · Render-Modus · Referenzlinien-Toggle · Ressourcen.
|
||||
|
||||
## Footer / Status-Leiste (neu, unten, ~22 px)
|
||||
|
||||
`[Werkzeug-Hinweis] … [Cursor X/Y/Z] · [Einheit] · [Massstab 1:N] · [Zoom %] · [Geschoss] · [Ebene] · [Snap] … [Auswahl: n]`
|
||||
|
||||
- **Cursor X/Y/Z** — live aus der Plan-/3D-Position (Plan: aus viewBox-Inverse der Maus).
|
||||
- **Einheit** — m (aus Projekt). **Massstab** 1:N + **Zoom %** — aus der View-Transform.
|
||||
- **Aktives Geschoss** + **aktive Ebene** — aus Selection/State.
|
||||
- **Snap-Status** — aktive Fänge (später). **Auswahl: n** — Anzahl selektierter Objekte.
|
||||
- **Werkzeug-Hinweis** links — kontextueller Text des aktiven Werkzeugs.
|
||||
|
||||
### MVP jetzt
|
||||
- Cursor X/Y (im Grundriss), Einheit, Massstab 1:N, Zoom %, aktives Geschoss + Ebene.
|
||||
Snap/Werkzeug/Auswahl kommen mit den Zeichenwerkzeugen.
|
||||
|
||||
## Anbindung
|
||||
Beide Leisten docken an das Panel-/Dock-Layout an (Top über den Docks, Footer darunter),
|
||||
Inhalte rein aus dem Modell + View-State abgeleitet (keine Sonderzustände).
|
||||
@@ -0,0 +1,636 @@
|
||||
# Design — Prioritätsbasierte mehrschichtige Wand-Verschneidung (T/X)
|
||||
|
||||
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||
> Aufbauend auf der bestehenden **L-Ecken-Gehrung** in `src/model/joins.ts`
|
||||
> (`computeJoins`, `miterLine`) und `src/model/geometry.ts` (`clippedBand`,
|
||||
> `lineIntersect`). Plan-Verbraucher: `src/plan/generatePlan.ts` (`addWallPoche`).
|
||||
> Bezeichner **englisch**, Prosa deutsch, Einheiten **Meter**.
|
||||
|
||||
> **Hinweis Referenz:** Die im Auftrag genannte DOSSIER-Datei
|
||||
> `rhino/wand_grips.py` ist in dieser Umgebung **nicht vorhanden** (das einzige
|
||||
> auffindbare `dossier` ist ein Typst-Portfolio, kein Rhino-Plugin). Das Design
|
||||
> stützt sich daher auf (a) den bestehenden Code, (b) die in CONVENTIONS.md/elements.md
|
||||
> festgehaltenen Geometrie-Konventionen und (c) die etablierte Bau-Semantik der
|
||||
> prioritätsbasierten Schichtverschneidung (Vectorworks „Komponenten-Verbindung",
|
||||
> Revit „Layer Priority", ArchiCAD „Composite Priority"). Sobald `wand_grips.py`
|
||||
> verfügbar ist, sollte Abschnitt 7 (Priorität/Backbone) gegen DOSSIERs konkrete
|
||||
> Rangregel abgeglichen werden.
|
||||
|
||||
---
|
||||
|
||||
## 0. Problem & Leitbild
|
||||
|
||||
Heute (`computeJoins`) wird **nur die L-Ecke** behandelt: genau zwei Wandenden
|
||||
treffen sich in einem Knoten, eine gemeinsame **Gehrungslinie** (`miterLine`)
|
||||
schneidet beide Wände sauber. T-/X-Stöße (≥ 3 Enden) bleiben rechtwinklig
|
||||
gekappt (`if (ends.length !== 2) continue;`) — die durchgehende Wand wird von der
|
||||
ankommenden Wand nicht durchdrungen, und die Schichten überlappen sich oder
|
||||
klaffen.
|
||||
|
||||
**Ziel:** An jedem Knoten — L (2 Enden), T (3), X/Kreuz (4+) — soll **jede
|
||||
Schicht jeder Wand** genau bis zu der Fläche reichen, die ihre Verschneidungs-
|
||||
Semantik vorgibt:
|
||||
|
||||
- Die **gemeinsame, höchstpriorisierte Schicht** (Backbone, z. B. Stahlbeton)
|
||||
läuft **durch** den Stoß.
|
||||
- **Niederpriorisierte Schichten** (z. B. Putz, Dämmung) **stoßen** an die nächst-
|
||||
höhere Schicht des Nachbarn und **enden** dort.
|
||||
- Putz **läuft auf jeder Seite** bis zur Betonfläche und endet dort; Putz
|
||||
**überquert nie** den Beton (kanonisches Beispiel, siehe §7.1).
|
||||
|
||||
**Architektur-Prinzip (CONVENTIONS.md):** Das semantische Modell ist die einzige
|
||||
Wahrheit. Die Verschneidung ist eine **reine Ableitung** (`Project + Wall[]` →
|
||||
Trimm-Linien je Schicht-Band), nichts wird in die Geometrie eingebacken. Sie wird
|
||||
beim Generieren des Plans angewandt und ist **deterministisch** und **idempotent**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Geometrie-Konventionen (Wiederholung, verbindlich)
|
||||
|
||||
Aus CONVENTIONS.md und `geometry.ts`:
|
||||
|
||||
- Achsrichtung `u = normalize(end - start)`.
|
||||
- Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`.
|
||||
- Schichten werden **außen → innen** gestapelt: Offset entlang `n` läuft von
|
||||
`-T/2` (linke/„außen"-Kante) nach `+T/2` (rechte/„innen"-Kante). Eine Schicht `k`
|
||||
belegt das Intervall `[off_k, off_k + thickness_k]` mit `off_0 = -T/2`.
|
||||
- Eine **unendliche Gerade** ist `Line { point, dir }`; Schnittpunkt zweier
|
||||
Geraden via `lineIntersect(a, da, b, db)` (null bei parallel).
|
||||
- Ein Schicht-**Band** zwischen Offsets `offA, offB` wird über `clippedBand(start,
|
||||
end, offA, offB, startCut, endCut)` gebildet; jede Bandlängskante wird mit einer
|
||||
optionalen Schnittlinie verschnitten statt rechtwinklig gekappt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Datenmodell — was schon da ist, was neu kommt
|
||||
|
||||
### 2.1 Vorhanden (`types.ts`)
|
||||
|
||||
```ts
|
||||
interface Component { …; joinPriority: number; } // höher = läuft am Stoß durch
|
||||
interface Layer { componentId: string; thickness: number; }
|
||||
interface WallType { id; name; layers: Layer[]; } // layers: außen → innen
|
||||
interface Wall { start: Vec2; end: Vec2; wallTypeId; height; … }
|
||||
```
|
||||
|
||||
`joinPriority` ist bereits **pro Bauteil (Component)** definiert — genau richtig:
|
||||
Priorität ist eine **Material-Eigenschaft**, nicht eine Schicht-Eigenschaft. Damit
|
||||
verschneidet sich Beton mit Beton unabhängig vom Wandtyp.
|
||||
|
||||
### 2.2 Neue, abgeleitete Strukturen (keine neuen persistenten Felder)
|
||||
|
||||
Die heutige `WallCuts`-Struktur (eine Schnittlinie je **Wandende**) reicht für L
|
||||
(Gehrung) — aber **nicht** für T/X, weil dort verschiedene Schichten **verschiedene**
|
||||
Trimm-Linien brauchen (Beton läuft durch, Putz stoppt früher). Wir erweitern auf
|
||||
**pro Schicht, pro Ende** eine eigene Trimm-Linie:
|
||||
|
||||
```ts
|
||||
// src/model/joins.ts (erweitert)
|
||||
|
||||
/** Eine gerichtete Halbkante eines Wandendes an einem Knoten. */
|
||||
interface WallEnd {
|
||||
wallId: string;
|
||||
end: "start" | "end";
|
||||
}
|
||||
|
||||
/** Trimm-Linie + Klassifikation für GENAU EIN Schicht-Band an EINEM Ende. */
|
||||
interface LayerCut {
|
||||
/** Schnittgerade in Weltkoordinaten; null = rechtwinklig kappen (Default). */
|
||||
line: Line | null;
|
||||
/**
|
||||
* "miter" – Gehrung (L): geteilte Diagonale mit dem Nachbarn.
|
||||
* "butt" – Band stößt stumpf an eine Nachbarfläche (niedrigere Priorität).
|
||||
* "through" – Band läuft durch den Knoten (höchste Priorität / Backbone).
|
||||
* "square" – freies Ende, rechtwinklig (line === null).
|
||||
*/
|
||||
kind: "miter" | "butt" | "through" | "square";
|
||||
}
|
||||
|
||||
/** Pro Wandende: eine Trimm-Linie je Schicht-Index (parallel zu WallType.layers). */
|
||||
interface EndCuts {
|
||||
/** layerCuts[k] gilt für layers[k]; Länge === wt.layers.length. */
|
||||
layerCuts: LayerCut[];
|
||||
}
|
||||
|
||||
/** Ersetzt die alte WallCuts: jetzt schichtweise an beiden Enden. */
|
||||
interface WallJoin {
|
||||
start: EndCuts;
|
||||
end: EndCuts;
|
||||
}
|
||||
|
||||
export type JoinMap = Map<string /*wallId*/, WallJoin>;
|
||||
```
|
||||
|
||||
**Abwärtskompatibilität:** Die alte L-Gehrung ist der Spezialfall „alle
|
||||
`layerCuts[k].line` an einem Ende sind dieselbe `miter`-Linie". `clippedBand`
|
||||
bleibt unverändert; der Plan-Generator ruft es jetzt **je Schicht mit der
|
||||
schichtspezifischen Linie** auf (siehe §8).
|
||||
|
||||
### 2.3 Knoten-Repräsentation
|
||||
|
||||
```ts
|
||||
/** Ein Verschneidungsknoten: alle Wandenden, die sich (gerundet) berühren. */
|
||||
interface Junction {
|
||||
key: string; // roundKey(p)
|
||||
p: Vec2; // Knotenposition (Mittel der Enden)
|
||||
arms: Arm[]; // sortiert nach Außenwinkel (CCW)
|
||||
}
|
||||
|
||||
/** Ein „Arm" = ein Wandende, gesehen als Strahl, der vom Knoten WEGzeigt. */
|
||||
interface Arm {
|
||||
we: WallEnd;
|
||||
wall: Wall;
|
||||
/** Richtung VOM Knoten weg in die Wand hinein (immer normiert). */
|
||||
dirOut: Vec2; // = end==="start" ? +u : -u
|
||||
/** Außenwinkel atan2(dirOut.y, dirOut.x) für Sortierung. */
|
||||
angle: number;
|
||||
total: number; // Gesamtdicke der Wand
|
||||
/** Schicht-Profil, vom Knoten aus gesehen (siehe §3.2). */
|
||||
layers: ArmLayer[];
|
||||
}
|
||||
|
||||
/** Eine Schicht eines Arms, mit ihren beiden Längs-Flächengeraden am Knoten. */
|
||||
interface ArmLayer {
|
||||
index: number; // Index in wt.layers
|
||||
componentId: string;
|
||||
priority: number; // Component.joinPriority
|
||||
/** Offsets entlang n der Wand: [innerEdge..outerEdge] des Bandes. */
|
||||
off0: number; off1: number;
|
||||
/** Die zwei Längsflächen als Geraden (point am Knoten, dir = u der Wand). */
|
||||
faceA: Line; faceB: Line;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Knotenerkennung (Junction Detection)
|
||||
|
||||
### 3.1 Knoten clustern
|
||||
|
||||
Identisch zur heutigen Logik, nur ohne die `length !== 2`-Abbruchbedingung:
|
||||
|
||||
```
|
||||
function buildJunctions(walls): Junction[]
|
||||
map = Map<key, WallEnd[]>
|
||||
for w in walls:
|
||||
map.push(roundKey(w.start), {wallId:w.id, end:"start"})
|
||||
map.push(roundKey(w.end), {wallId:w.id, end:"end"})
|
||||
out = []
|
||||
for (key, ends) in map:
|
||||
if ends.length < 2: continue // freies Ende → alle Schichten "square"
|
||||
arms = ends.map(buildArm)
|
||||
arms.sort(by angle) // CCW um den Knoten
|
||||
out.push({ key, p: avgEndPoint(ends), arms })
|
||||
return out
|
||||
```
|
||||
|
||||
`roundKey` (vorhanden) gruppiert Endpunkte auf ein 0.1-mm-Gitter. **Wichtig für
|
||||
T-Stöße:** Bei einem echten T endet die ankommende Wand **auf der Achse** der
|
||||
durchgehenden Wand, nicht an deren Endpunkt. Solche Knoten werden über die
|
||||
Endpunkt-Gruppierung **nicht** gefunden, wenn die durchgehende Wand dort kein Ende
|
||||
hat. Daher zusätzlich (siehe §3.3) eine **Achs-Auf-Achs-Inzidenz**.
|
||||
|
||||
### 3.2 Arm-Schichtprofil (kanonische Orientierung)
|
||||
|
||||
Damit Schichten zweier Arme vergleichbar sind, muss jeder Arm seine Schichten in
|
||||
**konsistenter Welt-Orientierung** kennen. Wir speichern je Schicht ihre beiden
|
||||
**Längsflächen** als Geraden mit Stützpunkt am Knoten und Richtung `u`:
|
||||
|
||||
```
|
||||
function buildArm(we): Arm
|
||||
wall = byId(we.wallId); u = dirOf(wall); n = leftNormal(u)
|
||||
dirOut = we.end==="start" ? u : negate(u) // vom Knoten in die Wand
|
||||
j = we.end==="start" ? wall.start : wall.end
|
||||
total = wallTypeThickness(wt)
|
||||
off = -total/2
|
||||
layers = []
|
||||
for (k, layer) in wt.layers:
|
||||
off0 = off; off1 = off + layer.thickness
|
||||
faceA = { point: j + n*off0, dir: u }
|
||||
faceB = { point: j + n*off1, dir: u }
|
||||
layers.push({ index:k, componentId, priority, off0, off1, faceA, faceB })
|
||||
off = off1
|
||||
return { we, wall, dirOut, angle: atan2(dirOut), total, layers }
|
||||
```
|
||||
|
||||
### 3.3 T-Stoß-Erkennung (Achs-auf-Achs)
|
||||
|
||||
Zusätzlich zu Endpunkt-Clustern: ein Wandende `e` (Punkt `P`) bildet einen
|
||||
**T-Stoß** mit Wand `B`, wenn `P` (innerhalb Toleranz) auf der **Strecke** `B.start
|
||||
→ B.end` liegt, aber **nicht** auf deren Endpunkten:
|
||||
|
||||
```
|
||||
function findTeeIncidences(walls):
|
||||
for endpoint P of each wall A (as WallEnd e):
|
||||
for each wall B != A:
|
||||
if pointOnSegment(P, B.start, B.end, tol) and not nearEndpoint(P, B):
|
||||
// virtueller Knoten: A endet, B läuft durch.
|
||||
registerTee(P, armOf(e), passThroughWall=B)
|
||||
```
|
||||
|
||||
`B` wird hier als **durchgehender Strang** behandelt (kein Ende am Knoten); im
|
||||
Junction-Modell taucht `B` als zwei kollineare „Arme" auf (Richtung `+u` und
|
||||
`−u`), die der Prioritätsalgorithmus (§5) automatisch als „durchlaufend" erkennt
|
||||
(zwei kollineare Arme gleicher Wand → ihre Schichten enden nie gegeneinander).
|
||||
|
||||
**MVP-Vereinfachung (Phase 1, §10):** T-Stöße zunächst NUR über koinzidente
|
||||
Endpunkte (B hat dort tatsächlich einen Eckpunkt, z. B. weil die Wand dort geteilt
|
||||
wurde). Echte Achs-auf-Achs-T-Stöße (B durchgehend) folgen in Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## 4. Winkel-Sektoren & Nachbar-Flächen
|
||||
|
||||
Für die Prioritätsauflösung muss man wissen, **welche Fläche eines Arms welcher
|
||||
Fläche des Nachbarn gegenübersteht**. Die Arme sind CCW nach `angle` sortiert.
|
||||
Zwischen zwei aufeinanderfolgenden Armen `arms[i]` und `arms[i+1]` (zyklisch) liegt
|
||||
ein **Sektor** (Keil). Jeder Sektor wird von **einer Längsfläche jedes der beiden
|
||||
Arme** begrenzt:
|
||||
|
||||
```
|
||||
arm[i+1]
|
||||
\ Sektor S_i
|
||||
\ /
|
||||
faceR(i+1)\ ___ faceL(i)
|
||||
X (Knoten)
|
||||
/
|
||||
/
|
||||
arm[i]
|
||||
```
|
||||
|
||||
Konvention: pro Arm hat die **äußere** Schicht (Index 0, Offset `-T/2`) die Fläche,
|
||||
die in den **CCW-vorausgehenden** Sektor zeigt; die **innere** Schicht den
|
||||
**nachfolgenden**. Konkret bestimmen wir die „dem Sektor zugewandte" Fläche jeder
|
||||
Schicht über das Vorzeichen von `cross(dirOut, sektorrichtung)` — analog zur
|
||||
`lbCloser`-Heuristik in `miterLine`, aber pro Sektor statt global.
|
||||
|
||||
```
|
||||
function sectorFaces(armLeft, armRight):
|
||||
// armRight ist CCW vor armLeft (Sektor liegt zwischen ihnen).
|
||||
// Wähle für jeden Arm die Schicht-Flächen, die in den Sektor zeigen.
|
||||
faceOf(arm, towards): pick faceA or faceB of each layer by sign of
|
||||
cross(arm.dirOut, towards - knoten)
|
||||
```
|
||||
|
||||
Diese Sektor-Sicht verallgemeinert die bestehende `miterLine`: bei genau zwei
|
||||
Armen (L) gibt es zwei Sektoren (innen/außen), und die Mittel-Gehrungslinie ergibt
|
||||
sich wie bisher aus dem Schnitt der gegenüberliegenden Außen- bzw. Innenflächen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Prioritätsauflösung — das Kernstück
|
||||
|
||||
### 5.1 Idee
|
||||
|
||||
An einem Knoten konkurrieren Schichten verschiedener Arme um denselben Raum. Regel:
|
||||
|
||||
> Eine Schicht **läuft durch** (`through`), wenn sie zur **höchsten am Knoten
|
||||
> präsenten Priorität** gehört **und** auf der „gegenüberliegenden" Seite eine
|
||||
> Schicht **gleichen Materials** (oder ≥ gleicher Priorität) existiert, an die sie
|
||||
> nahtlos anschließt. Andernfalls **stößt** sie (`butt`) an die nächsthöher-
|
||||
> priorisierte Nachbarfläche und endet dort.
|
||||
|
||||
Praktisch lösen wir das **fläche-gegen-fläche** je Schicht: Für jede Schicht `L`
|
||||
eines Arms suchen wir die **Trimm-Fläche**, die ihr Band beendet. Das ist die
|
||||
**erste** (vom Knoten aus, entlang `dirOut`) der gegenüberliegenden Flächen mit
|
||||
**echt höherer Priorität**; existiert keine, läuft die Schicht bis zur
|
||||
Knoten-Mittelachse durch.
|
||||
|
||||
### 5.2 Pro Schicht: Trimm-Fläche finden
|
||||
|
||||
```
|
||||
function resolveLayerCut(arm, layer, junction): LayerCut
|
||||
// Kandidaten: alle Schichten ALLER ANDEREN Arme, deren Band den
|
||||
// Halbraum dieses Layers überlappt (Offset-Überlapp im gemeinsamen
|
||||
// Sektor) UND deren Priorität die Trimm-Entscheidung bestimmt.
|
||||
candidates = []
|
||||
for other in junction.arms where other.wall.id-end != arm.id-end:
|
||||
for ol in other.layers:
|
||||
if overlapsInSector(layer, ol, arm, other):
|
||||
candidates.push({ other, ol, face: facingFace(ol, towards arm) })
|
||||
|
||||
// Höchste am Knoten präsente Priorität (global, für "through").
|
||||
maxPrio = max over all candidate.ol.priority and layer.priority
|
||||
|
||||
if layer.priority == maxPrio and hasCollinearSamePrio(arm, layer, junction):
|
||||
// Backbone: läuft durch bis zur Mittel-/Gehrungslinie.
|
||||
return { line: miterMidline(arm, layer, junction), kind: "through" }
|
||||
|
||||
// Sonst: stoße an die NÄCHSTE Fläche höherer Priorität entlang dirOut.
|
||||
blockers = candidates.filter(c => c.ol.priority > layer.priority)
|
||||
if blockers.empty:
|
||||
// niemand höher → Gehrung mit dem Nachbarn gleicher Stufe (L-Fall)
|
||||
return { line: miterMidline(arm, layer, junction), kind: "miter" }
|
||||
|
||||
nearest = argmin over blockers of distanceAlong(dirOut, face)
|
||||
return { line: nearest.face, kind: "butt" }
|
||||
```
|
||||
|
||||
Hilfsbegriffe:
|
||||
|
||||
- `overlapsInSector(layer, ol, …)` — projiziert beide Bänder auf die **Normale des
|
||||
Sektors** und prüft Intervall-Überlapp `[off0,off1] ∩ [off0',off1'] ≠ ∅`. Nur
|
||||
überlappende Bänder können sich gegenseitig trimmen.
|
||||
- `facingFace(ol, towards arm)` — die der `arm`-Seite zugewandte Längsfläche von
|
||||
`ol` (die `faceA`/`faceB` von §3.2), als `Line`.
|
||||
- `distanceAlong(dirOut, face)` — Abstand des Schnittpunkts `faceLine ∩ layerAxis`
|
||||
vom Knoten, gemessen entlang `dirOut`. Negative bzw. hinter dem Knoten liegende
|
||||
Treffer werden verworfen (eine Trimm-Fläche muss **vor** dem Band liegen).
|
||||
- `miterMidline(...)` — die gemeinsame Diagonale für gleichrangige Begegnung; für
|
||||
zwei Arme exakt die heutige `miterLine`.
|
||||
- `hasCollinearSamePrio(...)` — true, wenn auf der „anderen Seite" des Knotens eine
|
||||
Schicht **gleichen Materials/Priorität** existiert, in die das Band nahtlos
|
||||
übergeht (Backbone-Kontinuität, §7).
|
||||
|
||||
### 5.3 Resultat in `LayerCut.line` schreiben
|
||||
|
||||
`resolveLayerCut` liefert für `layers[k]` eine `Line | null`. Diese wird in
|
||||
`WallJoin[end].layerCuts[k]` abgelegt. `clippedBand` kappt damit **dieses eine
|
||||
Band** an genau dieser Linie — alle anderen Schichten derselben Wand können andere
|
||||
Linien (oder `null`) haben. Das ist die ganze Verkabelung zum Renderer.
|
||||
|
||||
---
|
||||
|
||||
## 6. Master-Pseudocode `computeJoins` (neu)
|
||||
|
||||
```
|
||||
function computeJoins(project, walls): JoinMap
|
||||
result = init each wall → { start:{layerCuts:[square…]}, end:{layerCuts:[square…]} }
|
||||
junctions = buildJunctions(walls) // §3.1
|
||||
// (Phase 3: junctions += findTeeIncidences(walls)) // §3.3
|
||||
|
||||
for J in junctions:
|
||||
if J.arms.length == 1: continue // freies Ende: alles "square"
|
||||
if J.arms.length == 2:
|
||||
resolveL(J, project, result) // bestehende Gehrung, schichtweise
|
||||
else:
|
||||
resolveTorX(J, project, result) // §5, pro Arm pro Schicht
|
||||
return result
|
||||
|
||||
function resolveTorX(J, project, result):
|
||||
for arm in J.arms:
|
||||
cuts = []
|
||||
for layer in arm.layers:
|
||||
cuts[layer.index] = resolveLayerCut(arm, layer, J) // §5.2
|
||||
writeEnd(result, arm.we, cuts) // start oder end
|
||||
|
||||
function resolveL(J, project, result):
|
||||
[a, b] = J.arms
|
||||
// Wie heute: eine gemeinsame Gehrungslinie pro Sektor; aber wenn die
|
||||
// Dicken/Schichten ungleich sind, kann pro Schicht über §5.2 verfeinert werden.
|
||||
// MVP: eine miterLine für alle Schichten beider Arme (= heutiges Verhalten,
|
||||
// schichtweise dupliziert).
|
||||
line = miterLine(project, a.wall, a.we.end, b.wall)
|
||||
for arm in [a,b]:
|
||||
cuts = arm.layers.map(_ => ({ line, kind:"miter" }))
|
||||
writeEnd(result, arm.we, cuts)
|
||||
```
|
||||
|
||||
So bleibt die **L-Ecke bit-genau wie heute** (Regressionssicherheit), und die
|
||||
neue Logik greift nur bei ≥ 3 Armen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Prioritäts-Semantik im Detail (das Putz/Beton-Beispiel)
|
||||
|
||||
### 7.1 Kanonisches Beispiel
|
||||
|
||||
Wandtyp „Aussenwand" (außen → innen):
|
||||
|
||||
| k | Component | thickness | joinPriority |
|
||||
|---|------------|-----------|--------------|
|
||||
| 0 | Putz aussen| 0.02 | 1 |
|
||||
| 1 | Stahlbeton | 0.18 | 9 |
|
||||
| 2 | Putz innen | 0.015 | 1 |
|
||||
|
||||
Drei solche Wände treffen in einem T-Knoten (zwei kollinear durchgehend „BB",
|
||||
eine ankommend „A"):
|
||||
|
||||
1. **Beton (prio 9)** der durchgehenden Wände `BB` läuft durch — beide
|
||||
Beton-Bänder sind kollinear gleicher Priorität → `hasCollinearSamePrio` =
|
||||
true → `through`. Der Beton von `A` (prio 9) **stößt** an die **Betonfläche**
|
||||
von `BB` (gegenüberliegende Fläche, gleiche höchste Priorität, aber kein
|
||||
kollinearer Partner für `A`) → `butt` an Betonaußenfläche von `BB`.
|
||||
2. **Putz (prio 1)** jeder Wand stößt an die **erste höherpriorisierte Fläche**
|
||||
entlang `dirOut`. Für die Putzschichten von `A` ist das die **Betonfläche von
|
||||
`BB`**: Putz **läuft auf jeder Seite bis zum Beton** und endet dort (`butt`).
|
||||
3. Putz **überquert nie** den Beton, weil eine `butt`-Trimm-Linie immer die
|
||||
**nächste** höhere Fläche ist — sie liegt vor dem Beton-Durchlauf.
|
||||
|
||||
Ergebnis exakt wie gefordert: *Beton durch, Putz schließt beidseitig an, Putz
|
||||
kreuzt Beton nicht.*
|
||||
|
||||
### 7.2 Warum Priorität pro Component (nicht pro Layer)
|
||||
|
||||
Beton trifft Beton → gleiche Priorität → nahtlos. Würde Priorität pro Schicht
|
||||
vergeben, müsste man sie pro Wandtyp neu pflegen. `joinPriority` am Component löst
|
||||
das materialweise — Stahlbeton hat **immer** 9, egal in welchem Wandtyp.
|
||||
|
||||
### 7.3 Backbone-Erkennung für „through"
|
||||
|
||||
`hasCollinearSamePrio(arm, layer, junction)`:
|
||||
|
||||
```
|
||||
for other in junction.arms where other != arm:
|
||||
if collinear(arm.dirOut, other.dirOut) (antiparallel, gleiche Achse) and
|
||||
other has a layer ol with ol.priority == layer.priority and
|
||||
bandsAlign(layer, ol): // gleiche Offsets relativ zur gemeinsamen Achse
|
||||
return true
|
||||
return false
|
||||
```
|
||||
|
||||
Nur wenn das Band auf der **gegenüberliegenden** Achse einen passenden Partner
|
||||
gleicher Priorität und Lage hat, läuft es wirklich **durch**. Sonst (z. B. der
|
||||
ankommende Beton von `A`) **stößt** es an — exakt das gewünschte Verhalten.
|
||||
|
||||
---
|
||||
|
||||
## 8. Layer-Wrapping (Umschlagen niederpriorisierter Schichten)
|
||||
|
||||
Ein subtiler, aber wichtiger Fall: An einem T-Stoß endet die **innere** Putzschicht
|
||||
der ankommenden Wand `A` an der Betonfläche von `BB`. Damit der Putz **um die Ecke
|
||||
herum sichtbar bleibt** (auf der Innenseite der durchgehenden Wand zieht der innere
|
||||
Putz von `BB` durch), ist nichts Zusätzliches nötig — `BB`s innerer Putz läuft als
|
||||
eigenes durchgehendes Band weiter.
|
||||
|
||||
Wo Wrapping aktiv nötig ist: wenn eine **niederpriorisierte Außenschicht** an einer
|
||||
Ecke **um eine höhere Schicht herumgeführt** werden soll (z. B. Dämmung, die außen
|
||||
um die Stütze läuft). Modellierung:
|
||||
|
||||
- Standard (MVP): kein Wrapping — jede Schicht endet an ihrer Trimm-Fläche
|
||||
(`butt`). Das deckt T/X korrekt ab.
|
||||
- Optional (Phase 4): Ein `wrap`-Flag pro Component (`Component.wrapAtEnds?:
|
||||
boolean`). Ist es gesetzt, erzeugt der Generator an der Trimm-Fläche ein
|
||||
**zusätzliches kurzes Stirnband** quer (Offset-Intervall der Schicht, Länge =
|
||||
Tiefe bis zur nächsten Fläche), sodass die Schicht ihre eigene Stirnseite
|
||||
„umschließt". Geometrisch ein weiteres `clippedBand` mit getauschten Achsen.
|
||||
|
||||
Wrapping ist bewusst **nachgelagert**; es ändert die Trimm-Logik (§5) nicht,
|
||||
sondern fügt nur Zusatzpolygone hinzu.
|
||||
|
||||
---
|
||||
|
||||
## 9. Anbindung an den Plan-Generator (`generatePlan.addWallPoche`)
|
||||
|
||||
Heute ruft `addWallPoche` `clippedBand(p1, p2, off, off+thickness, startCut,
|
||||
endCut)` mit **einer** `WallCuts` je Ende. Neu:
|
||||
|
||||
```ts
|
||||
// joins: JoinMap (neu)
|
||||
const join = joins.get(wall.id) ?? emptyJoin(wt.layers.length);
|
||||
…
|
||||
let off = -total / 2;
|
||||
wt.layers.forEach((layer, k) => {
|
||||
const startCut = (s <= 1e-6) ? join.start.layerCuts[k].line : null;
|
||||
const endCut = (e >= axisLen - 1e-6) ? join.end.layerCuts[k].line : null;
|
||||
out.push({ kind:"polygon",
|
||||
pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut),
|
||||
fill: comp.color, hatch: resolveHatch(...), … });
|
||||
off += layer.thickness;
|
||||
});
|
||||
```
|
||||
|
||||
- Die **Umrisslinie** über die volle Dicke (`-T/2..+T/2`) nutzt für ihre beiden
|
||||
Kanten die Cuts der **äußersten** bzw. **innersten** Schicht — oder wird, falls
|
||||
Schichten unterschiedlich getrimmt sind, durch eine **abgeleitete Outline**
|
||||
(Vereinigung der Schicht-Polygone, §11) ersetzt. MVP: weiterhin volle-Dicke-Band
|
||||
mit den Cuts von Schicht 0 / letzter Schicht.
|
||||
- `grob`-Detailgrad nutzt nur die **Backbone-Schicht** → ihr `through`/`miter`-Cut
|
||||
für die ganze Sammelfläche (entspricht der bestehenden `backboneColor`-Logik).
|
||||
|
||||
**Keine Signaturänderung an `clippedBand`** — es bleibt der zentrale Trimmer; wir
|
||||
füttern es nur schichtweise mit verschiedenen Linien.
|
||||
|
||||
---
|
||||
|
||||
## 10. 2D-analytisch jetzt vs. exakte 3D-Booleans später (OCCT)
|
||||
|
||||
### 10.1 Jetzt — 2D-analytisch (dieses Design)
|
||||
|
||||
- **Plan/Grundriss** ist eine reine 2D-Ableitung (vgl. `generatePlan.ts`-Kopf:
|
||||
„nicht durch Zerschneiden eines 3D-Meshes, sondern direkt aus den Parametern").
|
||||
- Trimmen = **Geraden-Schnitt** (`lineIntersect`) + Polygon-Kappen (`clippedBand`).
|
||||
Kein Polygon-Boolean nötig, solange Schichten als **konvexe Bänder** mit
|
||||
schrägen Stirnflächen modelliert werden. Das ist exakt, schnell (O(Arme² ·
|
||||
Schichten) je Knoten), und deterministisch.
|
||||
- **3D-Wand** im Viewport: Extrusion der getrimmten 2D-Schichtpolygone in `z`
|
||||
(Höhe). Damit stimmen Plan und 3D ohne separaten Kernel überein.
|
||||
|
||||
Grenzen der 2D-Analytik: nicht-konvexe Stirnprofile, echte Materialdurchdringung in
|
||||
`z` (z. B. wenn Wände unterschiedlicher Höhe / Sturz / Brüstung interagieren),
|
||||
Verschneidung Wand × Decke × Stütze. Das braucht echte Volumen-Booleans.
|
||||
|
||||
### 10.2 Später — exakte 3D-Booleans (OCCT / opencascade.js)
|
||||
|
||||
- **Bibliothek:** `opencascade.js` (WASM-Port von OCCT) — `BRepAlgoAPI_Cut/Common`,
|
||||
`BRepPrimAPI_MakePrism` für Extrusionen. Alternativ `manifold-3d` (schneller,
|
||||
robuster für reine Mesh-Booleans, aber ohne B-Rep/Fillets).
|
||||
- **Modell bleibt gleich:** Priorität (`joinPriority`) bestimmt die **Cut-
|
||||
Reihenfolge**. Algorithmus: baue je Schicht einen über-langen Solid (Band ×
|
||||
Höhe, über den Knoten hinaus verlängert); subtrahiere von jeder niederpriori-
|
||||
sierten Schicht die Solids **aller höherpriorisierten** Schichten am Knoten
|
||||
(`result = layerSolid − ⋃ higherPrioritySolids`). Gleiche Priorität: kein Cut
|
||||
(Beton bleibt an Beton). Das ist die **3D-Verallgemeinerung exakt derselben
|
||||
Prioritätsregel** — die 2D-`butt`/`through`-Klassifikation aus §5 ist die
|
||||
2D-Projektion dieser Boolean-Reihenfolge.
|
||||
- Die Datenstrukturen (§2) bleiben unverändert; nur der **Trimmer** wird
|
||||
ausgetauscht (`clippedBand` → OCCT-Cut). Deshalb ist das 2D-Design bereits
|
||||
„OCCT-ready".
|
||||
|
||||
---
|
||||
|
||||
## 11. Robuste Outline (optional, Phase 4)
|
||||
|
||||
Wenn Schichten an einem Knoten unterschiedlich getrimmt sind, ist die „volle
|
||||
Dicke"-Umrisslinie nicht mehr ein einfaches Band. Saubere Lösung: die
|
||||
**Wand-Outline** als **Vereinigung aller Schicht-Polygone** berechnen
|
||||
(Polygon-Boolean in 2D). Empfohlene Library: **`polygon-clipping`** (Martinez-
|
||||
Rueda, robust, klein) oder **`@flatten-js/boolean-op`**. Damit wird der Umriss
|
||||
exakt die Außenkontur der getrimmten Bänder — auch bei X-Knoten. MVP verzichtet
|
||||
darauf und nutzt die Schicht-0/N-Cuts (visuell für die meisten Fälle ausreichend).
|
||||
|
||||
---
|
||||
|
||||
## 12. Edge Cases
|
||||
|
||||
1. **Gleiche Priorität, nicht kollinear** (z. B. zwei Betonwände treffen im rechten
|
||||
Winkel im T): kein „through" (kein kollinearer Partner), aber auch kein
|
||||
`butt`-Blocker höherer Priorität → Fallback **`miter`** (gemeinsame Gehrung wie
|
||||
im L-Fall). Bei drei gleichrangigen Armen: paarweise Gehrung pro Sektor; die
|
||||
resultierenden Stirnflächen sind die Sektor-Halbierenden.
|
||||
2. **Kollinear (180°)** — zwei Wände in einer Linie: `lineIntersect` liefert null
|
||||
(parallel). Behandlung wie heute: **kein Schnitt**, Bänder laufen gerade durch
|
||||
(bei gleichem Typ nahtlos). Bei ungleichem Typ: die schmalere Wand stößt an die
|
||||
breitere; pro Schicht über §5 auflösbar.
|
||||
3. **> 2 Schichten / asymmetrische Wandtypen**: Der Algorithmus ist in der Schicht-
|
||||
anzahl generisch (`resolveLayerCut` läuft je Schicht-Index). Treffen Wände
|
||||
verschiedener Typen (verschiedene Schichtzahl/-dicke), entscheidet **nur die
|
||||
Priorität pro Fläche**, nicht der Index — deshalb arbeiten wir mit
|
||||
`ArmLayer.faceA/faceB` (Welt-Flächen), nicht mit Schicht-Indizes über Wände
|
||||
hinweg.
|
||||
4. **Backbone fehlt** (alle Prioritäten gleich): degeneriert sauber zu reinen
|
||||
Gehrungen (Fall 1).
|
||||
5. **Sehr spitze Winkel**: Trimm-Schnittpunkte können weit vom Knoten wegwandern.
|
||||
Begrenzung: `distanceAlong` auf `≤ maxReach` (z. B. `3 · total`) clampen; sonst
|
||||
rechtwinklig kappen (`square`), um Artefakte zu vermeiden.
|
||||
6. **Öffnungen am Wandende** (`addWallPoche`-Segmentierung): Cuts gelten nur für das
|
||||
echte Wandende (`s≤ε` / `e≥len−ε`), wie heute. Türnahe Segmentenden bleiben
|
||||
`square`.
|
||||
7. **Numerische Knoten-Toleranz**: `roundKey`-Gitter (0.1 mm) und ε in
|
||||
`pointOnSegment` müssen konsistent sein, sonst „flackernde" Knoten. Toleranz
|
||||
zentral als Konstante (`JOIN_EPS = 1e-4` m).
|
||||
8. **Mehr als 2 Wände gleicher Achse** (degenerierter X, alle kollinear): als ein
|
||||
durchgehender Strang behandeln; Prioritätsregel pro Schicht greift normal.
|
||||
|
||||
---
|
||||
|
||||
## 13. Staged Build-Plan
|
||||
|
||||
**Phase 1 — Single-Layer T (Fundament).**
|
||||
- `JoinMap`/`WallJoin`/`EndCuts`/`LayerCut` einführen; `WallCuts` darin als
|
||||
Spezialfall abbilden. `clippedBand` unverändert.
|
||||
- `buildJunctions` ohne `length!==2`-Abbruch; L-Fall ruft bestehende `miterLine`
|
||||
(schichtweise dupliziert) → **Regressionsgleichheit** zu heute.
|
||||
- T-Knoten **nur über koinzidente Endpunkte** (§3.3 MVP). Für **einschichtige**
|
||||
Wände: die durchgehende Wand läuft durch, die ankommende stößt rechtwinklig an
|
||||
deren nächste Fläche. Verifizieren: `npx tsc -b`, `npm run build`,
|
||||
`node scripts/probe.mjs`, Screenshot prüfen.
|
||||
|
||||
**Phase 2 — Prioritäts-Auflösung mehrschichtig (T).**
|
||||
- `ArmLayer`-Profil (§3.2), Sektor-Flächen (§4), `resolveLayerCut` (§5) inkl.
|
||||
`through`/`butt`/`miter`-Klassifikation und `hasCollinearSamePrio`.
|
||||
- `addWallPoche` schichtweise verkabeln (§9). Putz/Beton-Testszene (§7.1) anlegen
|
||||
und visuell prüfen.
|
||||
|
||||
**Phase 3 — X-Knoten & echte Achs-T-Stöße.**
|
||||
- `findTeeIncidences` (Achs-auf-Achs, §3.3): durchgehende Wand wird nicht geteilt.
|
||||
- 4+-Arm-Sektorlogik vollständig; Edge Cases 1/5/8 absichern.
|
||||
|
||||
**Phase 4 — Politur.**
|
||||
- Robuste Outline via Polygon-Boolean (§11); optionales Layer-Wrapping (§8,
|
||||
`Component.wrapAtEnds`).
|
||||
|
||||
**Phase 5 — Exakte 3D-Booleans (OCCT).**
|
||||
- `opencascade.js` integrieren; Trimmer-Interface so abstrahieren, dass 2D
|
||||
(`clippedBand`) und 3D (OCCT-Cut) dieselbe Prioritäts-Reihenfolge nutzen (§10.2).
|
||||
Plan bleibt 2D-analytisch; nur das 3D-Volumen nutzt Booleans.
|
||||
|
||||
---
|
||||
|
||||
## 14. Trimmer-Abstraktion (für §10.2-Migration)
|
||||
|
||||
Damit Phase 5 nicht den Aufrufer ändert, kapseln wir das Trimmen hinter einer
|
||||
Schnittstelle. 2D nutzt `clippedBand`; 3D nutzt OCCT — beide konsumieren dieselbe
|
||||
`JoinMap`.
|
||||
|
||||
```ts
|
||||
interface LayerTrimmer {
|
||||
/** 2D: getrimmtes Bandpolygon. 3D-Variante liefert stattdessen einen Solid. */
|
||||
trimBand(p1: Vec2, p2: Vec2, offA: number, offB: number,
|
||||
startCut: Line | null, endCut: Line | null): Vec2[];
|
||||
}
|
||||
```
|
||||
|
||||
Die Prioritätslogik (`computeJoins`) bleibt der **gemeinsame, kernel-unabhängige**
|
||||
Kopf; nur der `LayerTrimmer` wird ausgetauscht. Das hält das Design dem
|
||||
Architektur-Prinzip treu: ein semantisches Modell, viele abgeleitete Ansichten.
|
||||
@@ -0,0 +1,581 @@
|
||||
# WebGL2 GPU-Accelerated 2D Plan Renderer — Architecture
|
||||
|
||||
## Executive Summary
|
||||
|
||||
A WebGL2 canvas renderer for PlanView's heavy geometry (polygons, lines, hatches) with CPU fallback. Geometry tessellates once per plan and caches; pan/zoom only updates a transform-matrix uniform. Screen-space stroke width via vertex shader normal expansion. Thin SVG overlay handles text, grips, snap markers, tool preview.
|
||||
|
||||
**No npm dependencies** — raw WebGL2 + TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## Current State (SVG Bottleneck)
|
||||
|
||||
**PlanView.tsx** renders `Primitive[]` (polygon/line/arc/text) → SVG DOM:
|
||||
- ~2500 LOC: pan/zoom via viewBox, toScreen() scaling (1 meter = 90 viewBox units)
|
||||
- `Primitive` types (generatePlan.ts:133):
|
||||
- **polygon**: `pts: Vec2[]`, fill/stroke/strokeWidthMm, hatch (solid/insulation/diagonal/crosshatch)
|
||||
- **line**: a/b endpoints, className, weightMm, dash[], optional color
|
||||
- **arc**: center, from/to points, r, className, weightMm, dash[]
|
||||
- **text**: anchor, RichTextDoc, roomStamp metadata
|
||||
- Bottleneck: **pan/zoom re-renders entire SVG DOM** → Cairo rasterizes geometry at 144 Hz
|
||||
|
||||
**Key constants:**
|
||||
- `PX_PER_M = 90` (viewBox units per meter; Modell-Y up → SVG-Y down via negation)
|
||||
- `PAD = 60` (margin in viewBox units)
|
||||
- `mmToPx(mm) = (mm / 25.4) * dpi()` (stroke width: constant screen-px via non-scaling-stroke)
|
||||
- `ZOOM_MAX/MIN = 50/0.2` (pan/zoom bounds)
|
||||
|
||||
---
|
||||
|
||||
## Architecture: PlanRenderer (WebGL2 + Fallback SVG)
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
src/plan/
|
||||
├── PlanRenderer.ts (Main GPU/CPU dispatcher)
|
||||
├── glPlan/
|
||||
│ ├── glPlanCompile.ts (Tessellation & buffer upload)
|
||||
│ ├── glPlanShaders.ts (Vertex/fragment sources + compilation)
|
||||
│ ├── glPlanRender.ts (Draw loop: matrix uniform, state mgmt)
|
||||
│ └── glPlanTypes.ts (TypeScript interfaces for GPU data)
|
||||
└── PlanView.tsx (React wrapper, unchanged API)
|
||||
```
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
```
|
||||
PlanView.tsx
|
||||
↓ [receives plan: Plan]
|
||||
↓
|
||||
PlanRenderer (new abstraction)
|
||||
↓
|
||||
├─→ GPU path [if WebGL2 available && flag=true]
|
||||
│ ├─ glPlanCompile() → upload tessellated geometry to VRAM
|
||||
│ ├─ glPlanRender() → draw with pan/zoom matrix uniform
|
||||
│ └─ [fast pan/zoom via matrix only]
|
||||
│
|
||||
└─→ Fallback: SVG [if WebGL fails || flag=false]
|
||||
└─ existing PlanView render path (toScreen + DOM)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GPU Path: Tessellation & Shaders
|
||||
|
||||
### 1. Tessellation Strategy
|
||||
|
||||
#### **Polygon** → Fans + Ear Clipping
|
||||
- **Input**: Primitive.polygon = { pts: Vec2[], fill, stroke, strokeWidthMm, hatch }
|
||||
- **Output**: Indexed triangle mesh
|
||||
- **Algorithm**: Earcut2D (existing JS library logic, inlined to avoid npm)
|
||||
- Convert polygon pts to 2D float32 array in **world space** (meters)
|
||||
- Earcut → triangle indices
|
||||
- Store: `{ vertices: Float32Array, indices: Uint32Array, color: vec4, hasHatch: bool }`
|
||||
- **Hatch rendering**: Bake hatch as texture or re-implement in fragment shader (MVP: solid fill only; hatch deferred)
|
||||
|
||||
#### **Line** → Quad Expansion (Screen-Space Width)
|
||||
- **Input**: Primitive.line = { a, b, weightMm, cls, dash?, color }
|
||||
- **Output**: Degenerate quad (2 triangles) with screen-space normal offset
|
||||
- **Strategy**:
|
||||
1. Vertex shader receives `{ pos: vec2, side: float }` (side = ±1 for left/right edge)
|
||||
2. Transform pos to clip space via matrix uniform
|
||||
3. Compute screen-space perpendicular via `dFdx/dFdy` or pre-compute normal in CPU
|
||||
4. Expand by `(weightMm / 25.4) * dpi * (screenPixelsPerClipUnit)` in clip space
|
||||
5. Fragment shader: solid color (no dash MVP; dashing deferred or CPU pre-tessellation)
|
||||
|
||||
#### **Arc** → Line Segments (Polyline → Quads)
|
||||
- **Input**: Primitive.arc = { center, from, to, r, weightMm, cls, dash }
|
||||
- **Output**: Tessellate arc to ~30 line segments (adaptive based on radius/zoom), expand each as quad
|
||||
- Fallback: SVG arc for MVP
|
||||
|
||||
#### **Text, Grips, Snap-Markers, Tool-Preview**
|
||||
- **Stays in SVG overlay** (thin, non-bottleneck)
|
||||
- Render above WebGL canvas at z-order 1
|
||||
|
||||
---
|
||||
|
||||
### 2. Shader Sources (GLSL 3.00 ES)
|
||||
|
||||
#### **Vertex Shader: Solid Fill (polygon)**
|
||||
```glsl
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform mat4 viewProjection; // pan/zoom as 2×3 affine (expand to mat4)
|
||||
|
||||
layout(location=0) in vec2 position; // world-space (meters)
|
||||
layout(location=1) in vec4 color; // fill color
|
||||
|
||||
out VS_OUT {
|
||||
flat vec4 vertexColor;
|
||||
} vs_out;
|
||||
|
||||
void main() {
|
||||
vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
|
||||
gl_Position = clipPos;
|
||||
vs_out.vertexColor = color;
|
||||
}
|
||||
```
|
||||
|
||||
#### **Vertex Shader: Screen-Space Stroked Line**
|
||||
```glsl
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform mat4 viewProjection; // world → clip space
|
||||
uniform vec2 screenSize; // canvas (width, height) in pixels
|
||||
uniform float strokeWidthMm; // millimeters
|
||||
uniform float dpi; // 96 * devicePixelRatio
|
||||
|
||||
layout(location=0) in vec2 position; // world-space endpoint
|
||||
layout(location=1) in float sideFlag; // ±1.0 (left/right edge)
|
||||
layout(location=2) in vec4 lineColor; // stroke color
|
||||
|
||||
out VS_OUT {
|
||||
flat vec4 vertexColor;
|
||||
} vs_out;
|
||||
|
||||
void main() {
|
||||
vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
|
||||
|
||||
// Convert stroke width (mm) → screen pixels
|
||||
float strokePx = (strokeWidthMm / 25.4) * dpi;
|
||||
// Convert screen pixels → normalized device coords (NDC)
|
||||
// NDC ∈ [-1,1]²; screen (0,screenSize) → NDC [-1,1]
|
||||
float strokeNdc = (strokePx / screenSize.x) * 2.0;
|
||||
|
||||
// Expand in clip space (simple; assumes aspect ≈ 1)
|
||||
vec4 expanded = clipPos + vec4(sideFlag * strokeNdc, 0.0, 0.0, 0.0);
|
||||
|
||||
gl_Position = expanded;
|
||||
vs_out.vertexColor = lineColor;
|
||||
}
|
||||
```
|
||||
|
||||
#### **Fragment Shader (both)**
|
||||
```glsl
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in VS_OUT {
|
||||
flat vec4 vertexColor;
|
||||
} fs_in;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
fragColor = fs_in.vertexColor;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. GPU Data Structures (TypeScript)
|
||||
|
||||
**glPlanTypes.ts:**
|
||||
```typescript
|
||||
export interface GLGeometryBatch {
|
||||
/** Vertex buffer: interleaved (x, y, [z if 3D], ...) in world space. */
|
||||
vertexBuffer: WebGLBuffer;
|
||||
vertexCount: number;
|
||||
|
||||
/** Index buffer (triangles for fill, degenerate quads for strokes). */
|
||||
indexBuffer: WebGLBuffer;
|
||||
indexCount: number;
|
||||
|
||||
/** Vertex Array Object (VAO) binds VBO + IBO. */
|
||||
vao: WebGLVertexArrayObject;
|
||||
|
||||
/** Per-batch metadata. */
|
||||
batches: Array<{
|
||||
kind: "polygon" | "line" | "arc";
|
||||
indexStart: number;
|
||||
indexCount: number;
|
||||
color: [r: number, g: number, b: number, a: number]; // RGBA [0,1]
|
||||
hasHatch: boolean;
|
||||
hatchPattern?: "solid" | "insulation" | "diagonal" | "crosshatch";
|
||||
strokeWidthMm?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GLPlanRenderState {
|
||||
// Pan/zoom transform: world (meters) → clip space
|
||||
viewMatrix: Matrix3 | Matrix4; // 2×3 affine
|
||||
projMatrix: Matrix4; // orthographic
|
||||
|
||||
// Viewport size & DPI for screen-space stroke width
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
dpi: number;
|
||||
|
||||
// Compiled shaders
|
||||
solidFillProgram: WebGLProgram;
|
||||
strokeProgram: WebGLProgram;
|
||||
|
||||
// Geometry cache (tessellated once per plan)
|
||||
geometryBatch: GLGeometryBatch | null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MVP API: PlanRenderer Class
|
||||
|
||||
### Interface
|
||||
|
||||
```typescript
|
||||
export class PlanRenderer {
|
||||
/**
|
||||
* Create renderer with WebGL2 context + fallback config.
|
||||
*/
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
options?: {
|
||||
enableGpu?: boolean; // default: true
|
||||
enableGpuFallback?: boolean; // SVG fallback if GL fails
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Compile and cache geometry from primitives.
|
||||
* Call once per plan change.
|
||||
*/
|
||||
compilePlan(plan: Plan): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set pan/zoom transform matrix.
|
||||
* Call on every view change (pan, zoom, fit).
|
||||
*/
|
||||
setViewMatrix(viewBox: { x, y, w, h }, canvasSize: { w, h }): void;
|
||||
|
||||
/**
|
||||
* Render one frame: clear, draw batches, composite.
|
||||
* Called from requestAnimationFrame loop.
|
||||
*/
|
||||
render(): void;
|
||||
|
||||
/**
|
||||
* Release WebGL resources.
|
||||
*/
|
||||
dispose(): void;
|
||||
|
||||
/**
|
||||
* Query GPU availability / fallback state.
|
||||
*/
|
||||
isGpuReady(): boolean;
|
||||
isFallbackActive(): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in PlanView
|
||||
|
||||
**Before** (SVG only):
|
||||
```tsx
|
||||
function PlanView({ plan, ... }) {
|
||||
return (
|
||||
<svg ref={svgRef}>
|
||||
<defs>{hatches}</defs>
|
||||
{plan.primitives.map((p, i) => <PrimitiveShape ... />)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**After** (GPU + SVG fallback):
|
||||
```tsx
|
||||
function PlanView({ plan, ... }) {
|
||||
const rendererRef = useRef<PlanRenderer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
rendererRef.current = new PlanRenderer(canvas, { enableGpu: true });
|
||||
rendererRef.current.compilePlan(plan);
|
||||
}, [plan]);
|
||||
|
||||
useEffect(() => {
|
||||
rendererRef.current?.setViewMatrix(view, { w: canvasWidth, h: canvasHeight });
|
||||
}, [view, canvasWidth, canvasHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = () => {
|
||||
rendererRef.current?.render();
|
||||
rafId = requestAnimationFrame(frame);
|
||||
};
|
||||
rafId = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* GPU canvas (or SVG fallback if GL unavailable) */}
|
||||
<canvas ref={canvasRef} style={{ position: "absolute" }} />
|
||||
{/* Thin SVG overlay: text, grips, snap-markers, tool preview */}
|
||||
<svg ref={svgRef} style={{ position: "absolute", zIndex: 1 }}>
|
||||
{/* text, grips, snaps only; geometry stays in WebGL */}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow: From Primitives → GPU
|
||||
|
||||
### 1. **Compile Phase** (glPlanCompile.ts)
|
||||
|
||||
```typescript
|
||||
export function compilePlan(gl: WebGL2RenderingContext, plan: Plan): GLGeometryBatch {
|
||||
const batches: BatchInfo[] = [];
|
||||
const vertices: number[] = [];
|
||||
const indices: number[] = [];
|
||||
let indexOffset = 0;
|
||||
|
||||
for (const prim of plan.primitives) {
|
||||
if (prim.kind === "polygon") {
|
||||
const { verts, inds } = tessellatePolygon(prim.pts);
|
||||
const color = parseColor(prim.fill);
|
||||
batches.push({
|
||||
kind: "polygon",
|
||||
indexStart: indexOffset,
|
||||
indexCount: inds.length,
|
||||
color,
|
||||
hasHatch: prim.hatch.pattern !== "none",
|
||||
hatchPattern: prim.hatch.pattern,
|
||||
});
|
||||
vertices.push(...verts);
|
||||
indices.push(...inds.map((i) => i + indexOffset));
|
||||
indexOffset += verts.length / 2;
|
||||
} else if (prim.kind === "line") {
|
||||
const { verts, inds } = tessellateLineQuad(prim.a, prim.b);
|
||||
const color = parseColor(prim.color || "black");
|
||||
batches.push({
|
||||
kind: "line",
|
||||
indexStart: indexOffset,
|
||||
indexCount: inds.length,
|
||||
color,
|
||||
strokeWidthMm: prim.weightMm,
|
||||
});
|
||||
vertices.push(...verts);
|
||||
indices.push(...inds.map((i) => i + indexOffset));
|
||||
indexOffset += verts.length / 2;
|
||||
}
|
||||
// arc → polyline → quads (deferred for MVP)
|
||||
}
|
||||
|
||||
const vbo = gl.createBuffer()!;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
|
||||
|
||||
const ibo = gl.createBuffer()!;
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), gl.STATIC_DRAW);
|
||||
|
||||
const vao = gl.createVertexArray()!;
|
||||
gl.bindVertexArray(vao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0); // position
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
|
||||
|
||||
return { vertexBuffer: vbo, indexBuffer: ibo, vao, batches, vertexCount: vertices.length, indexCount: indices.length };
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Render Phase** (glPlanRender.ts)
|
||||
|
||||
```typescript
|
||||
export function renderPlan(
|
||||
gl: WebGL2RenderingContext,
|
||||
state: GLPlanRenderState,
|
||||
batch: GLGeometryBatch
|
||||
): void {
|
||||
gl.clearColor(1, 1, 1, 1); // white background
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
gl.useProgram(state.solidFillProgram);
|
||||
const mvpLoc = gl.getUniformLocation(state.solidFillProgram, "viewProjection");
|
||||
const mvp = mat4.multiply(state.projMatrix, state.viewMatrix);
|
||||
gl.uniformMatrix4fv(mvpLoc, false, mvp);
|
||||
|
||||
gl.bindVertexArray(batch.vao);
|
||||
|
||||
for (const b of batch.batches) {
|
||||
const colorLoc = gl.getUniformLocation(state.solidFillProgram, "vertexColor");
|
||||
gl.uniform4f(colorLoc, b.color[0], b.color[1], b.color[2], b.color[3]);
|
||||
|
||||
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.indexStart * 4);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tessellation Details
|
||||
|
||||
### Earcut (Polygon Triangulation)
|
||||
|
||||
**Inlined earcut logic (no npm):**
|
||||
```typescript
|
||||
function tessellatePolygon(pts: Vec2[]): { verts: number[]; inds: number[] } {
|
||||
// Convert Vec2[] → flat float array
|
||||
const coords = pts.flatMap((p) => [p.x, p.y]);
|
||||
|
||||
// Earcut2D: robust polygon triangulation
|
||||
// → Returns index array (triplets = triangles)
|
||||
const triangles = earcut(coords);
|
||||
|
||||
// Vertex buffer: just positions (x, y) in world space (meters)
|
||||
const verts = coords;
|
||||
|
||||
return { verts, inds: triangles };
|
||||
}
|
||||
|
||||
// Simplified earcut (full version ~200 LOC; reference libtess2 or earcut.js)
|
||||
function earcut(data: number[], hole?: number[], dim?: number): number[] {
|
||||
// ... iterative ear clipping, complexity O(n²) worst-case
|
||||
// Returns Uint32Array of triangle indices
|
||||
}
|
||||
```
|
||||
|
||||
### Line Quad Expansion
|
||||
|
||||
```typescript
|
||||
function tessellateLineQuad(
|
||||
a: Vec2, b: Vec2,
|
||||
widthMm: number = 0.5
|
||||
): { verts: number[]; inds: number[] } {
|
||||
// World-space endpoints; width (mm) will be expanded in vertex shader
|
||||
|
||||
// Create a degenerate quad: 2 triangles
|
||||
// Vertices: [a_left, a_right, b_left, b_right]
|
||||
// (normal expansion happens in VS)
|
||||
|
||||
const verts = [
|
||||
a.x, a.y, 0.0, // vertex 0: a, left flag
|
||||
a.x, a.y, 1.0, // vertex 1: a, right flag
|
||||
b.x, b.y, 0.0, // vertex 2: b, left flag
|
||||
b.x, b.y, 1.0, // vertex 3: b, right flag
|
||||
];
|
||||
|
||||
// Two triangles: (0, 1, 2) and (1, 3, 2)
|
||||
const inds = [0, 1, 2, 1, 3, 2];
|
||||
|
||||
return { verts, inds };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pan/Zoom Matrix Transform
|
||||
|
||||
### View Box → Clip Space
|
||||
|
||||
```typescript
|
||||
function buildViewMatrix(
|
||||
viewBox: { x, y, w, h },
|
||||
canvasSize: { w, h }
|
||||
): Matrix4 {
|
||||
// 1. World space (meters, origin at model 0,0) → viewBox units (PX_PER_M=90)
|
||||
const scale = PX_PER_M; // 1 meter → 90 viewBox units
|
||||
|
||||
// 2. ViewBox viewport: x,y,w,h in viewBox units → NDC [-1,+1]²
|
||||
// Orthographic projection (no perspective).
|
||||
const ortho = mat4.ortho(
|
||||
viewBox.x,
|
||||
viewBox.x + viewBox.w,
|
||||
viewBox.y,
|
||||
viewBox.y + viewBox.h,
|
||||
-1, 1
|
||||
);
|
||||
|
||||
// 3. Scale from viewBox units → world (invert PX_PER_M)
|
||||
const scaleMatrix = mat4.scale(mat4.identity(), [1/scale, 1/scale, 1]);
|
||||
|
||||
return mat4.multiply(ortho, scaleMatrix);
|
||||
}
|
||||
```
|
||||
|
||||
Whenever PlanView calls `setView(viewBox)` or `onWheel()` → call `setViewMatrix()` → GPU re-renders with new matrix uniform (no tessellation).
|
||||
|
||||
---
|
||||
|
||||
## Fallback Strategy: SVG Renderer Flag
|
||||
|
||||
**Global flag** in PlanView or app state:
|
||||
```typescript
|
||||
const [useGpuRenderer, setUseGpuRenderer] = useState(true);
|
||||
```
|
||||
|
||||
**Render path branching:**
|
||||
```typescript
|
||||
return useGpuRenderer && rendererRef.current?.isGpuReady()
|
||||
? <canvas ref={canvasRef} />
|
||||
: <svg ref={svgRef}>{/* existing SVG rendering */}</svg>;
|
||||
```
|
||||
|
||||
**When GL fails** (e.g., no WebGL2 support, Out-Of-Memory):
|
||||
1. Renderer catches error in `compilePlan()`
|
||||
2. Sets internal `fallbackActive = true`
|
||||
3. Returns gracefully (app renders SVG path instead)
|
||||
4. User sees same plan, slower but functional
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order (MVP → Iteration)
|
||||
|
||||
### Phase 1: Core (Week 1)
|
||||
1. **glPlanTypes.ts** — TypeScript interfaces for GPU state
|
||||
2. **glPlanShaders.ts** — Compile vertex/fragment shaders, handle GL errors
|
||||
3. **glPlanCompile.ts** — Tessellation (earcut inlined), buffer upload
|
||||
4. **glPlanRender.ts** — Draw loop, matrix uniform, clear/present
|
||||
5. **PlanRenderer.ts** — Main class, dispatcher (GPU vs SVG fallback)
|
||||
6. **PlanView.tsx** — Wire renderer, canvas overlay, canvas lifecycle
|
||||
|
||||
### Phase 2: Hatches & Lines (Week 2)
|
||||
- Improve line tessellation: proper screen-space width (dFdx/dFdy or pre-computed normals)
|
||||
- Hatch patterns: texture-based or procedural fragment shader (diagonal/insulation)
|
||||
- Arc tessellation: polyline → quads
|
||||
|
||||
### Phase 3: Polish (Week 3)
|
||||
- Stroke dashing via geometry or fragment shader
|
||||
- Greyed opacity blending
|
||||
- Hit testing integration (point-in-triangle for GPU)
|
||||
- Performance profiling, batch merging
|
||||
|
||||
---
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Operation | SVG (Current) | GPU (Target) | Notes |
|
||||
|-----------|---------------|--------------|-------|
|
||||
| **Tessellation** | — | 10–50 ms | Once per plan |
|
||||
| **Pan/Zoom 60 Hz** | 16 ms (re-render SVG) | <1 ms (matrix uniform) | Matrix upload negligible |
|
||||
| **Pan/Zoom 144 Hz** | 7 ms (bottleneck) | <0.5 ms | 28× speedup expected |
|
||||
| **Geometry: 1000 polygons** | 50–100 ms SVG render | 1–5 ms GPU draw | CPU tessellation pipelined |
|
||||
|
||||
User: AMD RX 7800 XT → easily capable of 4K+ geometry at 144 Hz.
|
||||
|
||||
---
|
||||
|
||||
## Known Deferred Items (Post-MVP)
|
||||
|
||||
- **Hatches**: Solid fill only MVP; insulation/diagonal/crosshatch in Phase 2 via texture or procedural shader
|
||||
- **Dashing**: Not in MVP (complex with screen-space strokes); either CPU pre-tessellation or fragment shader alpha-discard
|
||||
- **Arcs**: Fallback to SVG for MVP; GPU polyline expansion in Phase 2
|
||||
- **Text, Grips, Snaps**: Stay in SVG overlay indefinitely (no GPU benefit; text rendering nontrivial)
|
||||
- **Hit Testing**: Keep in CPU/SVG for MVP; GPU pick-buffer deferred
|
||||
- **Color/Opacity Blending**: Basic for MVP; advanced (multiply, screen, dodge) deferred
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Earcut.js**: https://github.com/mapbox/earcut — polygon triangulation (logic to inline)
|
||||
- **three.js line expansion**: https://github.com/mrdoob/three.js/blob/master/src/renderers/webgl/WebGLGeometries.js
|
||||
- **OpenGL Perspective Division**: https://en.wikibooks.org/wiki/OpenGL_Programming/Modern_OpenGL_Tutorial_Polygon_offset
|
||||
- **Screen-Space Stroke Width**: https://forum.libcinder.org/topic/smooth-line-rendering-using-geometry-shaders
|
||||
- PlanView source: `/home/karim/cad/src/plan/PlanView.tsx` (2500 LOC)
|
||||
- Primitive types: `/home/karim/cad/src/plan/generatePlan.ts:133`
|
||||
@@ -0,0 +1,81 @@
|
||||
# Briefing — Nativer wgpu-2D-Renderer (parallele Instanz)
|
||||
|
||||
> Für die Instanz/den Agenten, der den nativen GPU-Renderer baut. Isoliert vom
|
||||
> Web-Renderer; koordiniert über dieses Dokument.
|
||||
|
||||
## COMMIT-REGEL (verbindlich)
|
||||
Dieses Repo darf **keinerlei Hinweise auf KI-Werkzeuge** enthalten — nicht im Code,
|
||||
in Kommentaren oder in der Git-Historie. Keine `Co-Authored-By`-Trailer, keine
|
||||
„Generated with"-Zeilen, keine Modell-/Agenten-Namen. Kommentare deutsch,
|
||||
Identifier englisch. **Kein Commit ohne Ansage der Hauptinstanz.**
|
||||
|
||||
## Warum
|
||||
Der 2D-Plan wird aktuell in einem WebGL-Canvas gerendert (Web-Stack). In Chromium
|
||||
ist das 144-Hz-flüssig, aber **Tauris Linux-Webview = WebKitGTK bremst** (langsamer
|
||||
Compositor, vermutlich 60-Hz-rAF-Cap). Bewiesen: dieselbe App im `chromium --app`-
|
||||
Fenster = butterweich, im Tauri-Fenster = zäh — trotz imperativem Pan, CSS-transform-
|
||||
Overlay, DMABUF-Tweaks. Siehe Memo `webkitgtk-bottleneck`.
|
||||
|
||||
**Lösung:** die schwere Grafik **nativ mit wgpu** rendern (Rust), die Webview macht
|
||||
nur noch UI-Chrome (Panels/Buttons). So bleibt Tauri (natives Rust-Backend, winziges
|
||||
Bundle) UND wir umgehen den Webview-Compositor komplett.
|
||||
|
||||
## Referenz-Implementierung (NICHT wegwerfen — 1:1 portierbar)
|
||||
Der WebGL-Renderer unter `src/plan/glPlan/` ist die **arbeitende Referenz**. Die
|
||||
harte Arbeit ist dort schon gelöst und portiert konzeptuell direkt nach wgpu:
|
||||
- `glPlanCompile.ts` — **Earcut-Triangulierung** (konkav-fähig), Linien→Quads mit
|
||||
Normale+Seiten-Flag, Batch-Merging, Farb-Parsing. Unit-Tests: `glPlanCompile.test.ts`.
|
||||
- `glPlanShaders.ts` — Vertex/Fragment (GLSL 300 es). **WGSL ≈ GLSL** — direkt übersetzbar.
|
||||
- Füll-VS: Bildschirm-Position → Clip via `viewProj`-Matrix.
|
||||
- Linien-VS: bildschirmkonstante bzw. papier-mm-Breite via Normalen-Offset im Clip
|
||||
(siehe `strokeScale`/`strokePx`-Logik — Papier-mm × Massstab N × meet-Skala).
|
||||
- `glPlanRender.ts` — Ortho-Matrix (aspekt-korrekt = SVG `xMidYMid meet`), Fill-/Line-
|
||||
Pass, `computeOrthoMatrix`.
|
||||
- Koordinaten-Konvention: BILDSCHIRM-Raum `sx = mx·PX_PER_M, sy = -my·PX_PER_M`
|
||||
(`PX_PER_M=90`), Modell-Y hoch → Bildschirm-Y runter.
|
||||
- Strichbreite = **echte Papier-mm im Massstab**: `Breite_px = mm · N/1000 · PX_PER_M ·
|
||||
meetSkala` (repliziert SVG `printStrokeVb`). Am Beispiel 0.35 mm @ 1:100 = 0.35 mm.
|
||||
|
||||
Die **Daten** kommen aus `src/plan/generatePlan.ts` → `Plan.primitives` (Union-Typ
|
||||
`Primitive`: polygon/line/arc/text). Text bleibt Overlay (nicht wgpu).
|
||||
|
||||
## Die EINE harte Frage (zuerst spiken/recherchieren)
|
||||
**Wie rendert wgpu in das Tauri-Fenster neben der Webview?** Optionen recherchieren:
|
||||
1. Natives Child-Surface unter der Webview (raw-window-handle), Webview transparent
|
||||
drüber für UI-Chrome. Vermutlich der Zielweg.
|
||||
2. Eigenes wgpu-Fenster (entkoppelt) — nur für den Spike, um Rendering von der
|
||||
Fenster-Integration zu trennen.
|
||||
3. wgpu→Textur→Webview — verwirft den Zweck (Copy-Overhead), nur Notnagel.
|
||||
|
||||
**Empfehlung:** Rendering-Spike ZUERST entkoppelt (Option 2, standalone `winit`+wgpu-
|
||||
Fenster), das die `Primitive` als gefüllte Polygone + Striche zeichnet und Pan/Zoom
|
||||
per Matrix-Uniform macht. Fenster-Integration in Tauri ist der zweite, separate Schritt.
|
||||
|
||||
## Meilensteine
|
||||
- **M0 (Research):** kurzer Bericht `docs/design/wgpu-integration-findings.md` — wie
|
||||
wgpu-Surface + Tauri/webview koexistieren (raw-window-handle, Transparenz, Z-Order,
|
||||
Input-Routing). Quellen verlinken.
|
||||
- **M1 (Rendering-Spike, standalone):** neue Crate `src-tauri/render2d/` (serde-Input
|
||||
= geflachte Primitive), wgpu + WGSL:
|
||||
- Earcut-Port (oder `lyon`/`earcutr` Crate evaluieren) → Dreiecke.
|
||||
- Füll-Pipeline (gefüllte Polygone, Ortho-Matrix-Uniform).
|
||||
- Linien-Pipeline (Quad-Expansion, echte Papier-mm-Breite).
|
||||
- `cargo test` für die Tessellierung (Muster: `src-tauri/geometry` hat 4/4 Tests —
|
||||
z. B. konkaves L flächentreu, wie `glPlanCompile.test.ts`).
|
||||
- `cargo build` grün. (Visuelle Fenster-Verifikation braucht Display → an Mensch/
|
||||
Display-Session übergeben; im Bericht vermerken.)
|
||||
- **M2 (Tauri-Integration):** Surface unter die Webview, Pan/Zoom-Input aus der
|
||||
Webview an den Renderer, Parität mit dem WebGL-Pfad messen (144 Hz).
|
||||
- **M3:** Schraffur, Bögen, Auswahl-Highlight; danach 3D (three.js→wgpu).
|
||||
|
||||
## Koordination (WICHTIG)
|
||||
- **Eigener Branch** (z. B. `feature/wgpu-renderer`), NICHT auf `master`/
|
||||
`feature/parametric-walls` committen. Isolierter Worktree.
|
||||
- **Nur** `src-tauri/` + neue Rust-Module + `docs/`. **NICHT** `src/App.tsx`,
|
||||
`src/plan/*`, `types.ts` anfassen (die Hauptinstanz arbeitet dort an Features).
|
||||
- Der Web-WebGL-Renderer bleibt bestehen (Browser-Lauffähigkeit + Referenz).
|
||||
- Ergebnisse/Diffs zurückliefern; die Hauptinstanz committet und pflegt das Memory.
|
||||
|
||||
## Gates
|
||||
`cargo check`/`cargo test`/`cargo build` grün. Trace-Scan sauber (COMMIT-REGEL).
|
||||
`npx tsc -b` + `npm run build` müssen unberührt grün bleiben (keine Web-Änderungen).
|
||||
@@ -0,0 +1,265 @@
|
||||
# Briefing — Nativer wgpu-3D-Renderer (M0 Design + M1 Spike)
|
||||
|
||||
> Schwester-Dokument zu `wgpu-2d-renderer-briefing.md`. Beschreibt den Weg vom
|
||||
> jetzigen three.js-3D-View (WebGL im WebKitGTK-Webview) zu einer nativen
|
||||
> wgpu-Engine (Rust). Dieses Dokument ist der ANFANG: M0 (Bestandsaufnahme +
|
||||
> Port-Plan) und M1 (entkoppelter Standalone-Spike). Noch NICHT die Migration.
|
||||
|
||||
## Commit-/Spuren-Regel
|
||||
Wie im ganzen Repo: keine Hinweise auf KI-Werkzeuge — nicht im Code, in Kommentaren
|
||||
oder in der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne
|
||||
Ansage der Hauptinstanz.
|
||||
|
||||
## Warum
|
||||
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
|
||||
2D-Plan bremst dieser Compositor unter Linux (siehe `wgpu-2d-renderer-briefing.md`
|
||||
und Memo `webkitgtk-bottleneck`). Die schwere 3D-Grafik soll daher **nativ mit wgpu**
|
||||
gerendert werden (Rust), die Webview macht nur noch UI-Chrome. Das umgeht den
|
||||
Webview-Compositor komplett und teilt sich die Toolchain mit dem 2D-Renderer
|
||||
(`src-tauri/render2d/`, gleiche Feature-Stufung, gleiche Test-Muster).
|
||||
|
||||
---
|
||||
|
||||
## 1. Bestandsaufnahme — was der three.js-View rendert
|
||||
|
||||
Referenz: `src/viewport/Viewport3D.tsx` (analysiert), plus die Geometrie-Grundlage
|
||||
in `src/model/geometry.ts`, `src/model/wall.ts`, `src/model/joins.ts`,
|
||||
`src/geometry/opening.ts`, `src/geometry/stair.ts`. Zeilenangaben beziehen sich auf
|
||||
den Stand der Analyse.
|
||||
|
||||
### Koordinaten-Konvention (verbindlich)
|
||||
Das Modell ist 2D in Metern (`x`, `y`) plus Hoehe `z`. Die 3D-Welt ist **Y-up**:
|
||||
|
||||
```
|
||||
world.x = model.x
|
||||
world.y = Hoehe (z)
|
||||
world.z = model.y
|
||||
```
|
||||
|
||||
D.h. **der Grundriss liegt in der XZ-Ebene, die Extrusion laeuft entlang +Y.**
|
||||
Belegt u.a. in `Viewport3D.tsx`:
|
||||
- Kommentar (~Z. 473): „Modell (x,y,z) → Three (x, z, y) (Z = Hoehe nach oben)".
|
||||
- Kontext-Mesh (~Z. 1679–1684): `verts[i]=pos.x; verts[i+1]=pos.z (Hoehe); verts[i+2]=pos.y`.
|
||||
- Wand-Griffe (~Z. 764): `new THREE.Vector3(wall.start.x, zBottom, wall.start.y)`.
|
||||
- Workplane-Raycast (~Z. 627): `{ x: hit.x, y: hit.z }` (Three → Modell).
|
||||
|
||||
Diese Konvention ist in `render3d` 1:1 uebernommen (`types.rs`, `mesh.rs`).
|
||||
|
||||
### Waende (der Kern)
|
||||
- Funktion `addWallMeshes()` / `addLayerPrism()` (~Z. 1939–2039, 2237–2271).
|
||||
- **Mesh-Weg:** `THREE.ExtrudeGeometry` (~Z. 2248) ueber die 2D-Bandform, die
|
||||
`clippedBand(p1, p2, offA, offB, startCut, endCut)` liefert (~Z. 2237; Funktion in
|
||||
`src/model/geometry.ts:87`). Extrudiert wird um `depth = zTop - zBottom`.
|
||||
- Die Bandform kommt aus Achse + Dicke: `wallBand`/`wallCorners`
|
||||
(`geometry.ts:53`/`:70`) versetzen die Achse um `thickness/2` entlang der
|
||||
**Links-Normale** `leftNormal(u) = (-u.y, u.x)` (`geometry.ts:17`), CCW-Umlauf.
|
||||
- `ExtrudeGeometry` liegt in der XY-Ebene und waechst entlang +Z; three.js dreht das
|
||||
Prisma daher um +90 Grad um X und setzt es auf `topY` (~Z. 2266–2271). In wgpu
|
||||
extrudieren wir direkt in world (XZ-Grundriss, +Y-Hoehe) und sparen die Drehung.
|
||||
- **Hoehe/Basis:** `wallVerticalExtent(project, wall)` (`wall.ts:56`) liefert
|
||||
absolute `zBottom`/`zTop` (aus `wall.bottom`/`wall.top`-Ankern bzw. Geschoss-
|
||||
`baseElevation + wall.height`).
|
||||
- **Mehrschichtig:** je `wt.layers`-Schicht ein eigenes Prisma mit Dicken-Offset
|
||||
(~Z. 1991–2015). M1 extrudiert vereinfacht EINE Schicht (Gesamtdicke).
|
||||
- **Ecken/Gehrung:** `computeJoins()` (`joins.ts:45`) berechnet Schnittlinien
|
||||
(`startCut`/`endCut`), die `clippedBand` an L-Ecken auf Gehrung zieht
|
||||
(`miterLine`, `joins.ts:101`). M1 laesst das noch weg (stumpfe Enden).
|
||||
|
||||
### Oeffnungen (Fenster/Tueren)
|
||||
- `addOpeningMeshes()` (~Z. 2056–2169) + Segmentierung in `addWallMeshes` (~Z.
|
||||
1968–2035). **Kein CSG/Boolean:** die Wand wird entlang der Achse in Segmente
|
||||
zerlegt (`openingInterval`, `geometry/opening.ts:31`), und je Oeffnung entstehen
|
||||
bis zu drei Prismen: Wand DAVOR, **Bruestung** unter dem Fenster (`sillRel`),
|
||||
**Sturz** ueber der Oeffnung (`headRel`). Rahmen/Fluegel als `BoxGeometry`;
|
||||
Glas semitransparent, Tuerfluegel um `swingAngle` gedreht.
|
||||
|
||||
### Treppen
|
||||
- `addStairMeshes()` (~Z. 2357–2409). `stairGeometry()` (`geometry/stair.ts`)
|
||||
liefert Trittflaechen (Footprint + Steig-Hoehe) + optionalen Podest-Umriss; jede
|
||||
Stufe als extrudierter Block (`ExtrudeGeometry`), Hoehe = `stairVerticalExtent`.
|
||||
|
||||
### Decken/Platten
|
||||
- `addCeilingMesh()` (~Z. 2290–2347). `ceiling.outline` als `ExtrudeGeometry`, Tiefe
|
||||
= Deckenstaerke, waechst nach unten von `zTop` (`ceilingVerticalExtent`, `wall.ts:80`).
|
||||
|
||||
### Raeume
|
||||
- Nicht als eigenstaendige 3D-Koerper gerendert (2D-Grundriss-Repraesentation).
|
||||
|
||||
### Kontext/Gelaende
|
||||
- `buildContext()` (~Z. 1651–1718). Terrain/importierte Meshes als rohe
|
||||
`BufferGeometry` (Positions/Indices, Koordinaten-Swap wie oben); Hoehenlinien als
|
||||
`LineSegments`. Dazu ein `GridHelper` (~Z. 421) auf OKFF-Hoehe.
|
||||
|
||||
### Materialien
|
||||
- `MeshLambertMaterial` (Waende/Oeffnungen/Treppen, per Komponente eingefaerbt,
|
||||
~Z. 2176–2206), `MeshStandardMaterial` (Weiss-/Textur-Modus + Terrain, PBR:
|
||||
`roughness`/`metalness`/`aoMap`, ~Z. 445–491, 2001–2015), `MeshBasicMaterial`
|
||||
(Hidden-Line-Flaechen + immer-oben-Marker), `LineBasicMaterial` (Kanten/2D-
|
||||
Zeichnungen). Render-Modi: shaded / white / textured / wireframe / hidden-line.
|
||||
|
||||
### Beleuchtung
|
||||
- `AmbientLight(0xffffff, 0.6)` (~Z. 416) + `DirectionalLight(0xffffff, 1.1)` bei
|
||||
`(6, 12, 4)` (~Z. 417). **Keine Schatten** konfiguriert. Keine Hemisphere/Point-
|
||||
Lights.
|
||||
|
||||
### Kamera + Presets
|
||||
- Zwei Kameras: `PerspectiveCamera(fov, 1, 0.1, 1000)` (~Z. 367) und
|
||||
`OrthographicCamera(-1,1,1,-1, 0.1, 5000)` (~Z. 374). `applyView3d()` (~Z.
|
||||
1566–1633) setzt fuenf Presets:
|
||||
- **front** — Richtung `(0,0,1)`, orthografisch.
|
||||
- **side** — Richtung `(1,0,0)`, orthografisch.
|
||||
- **top** — Richtung `(0,1,~0)`, orthografisch (Rotation gesperrt).
|
||||
- **iso** — Richtung `(1,1,1)` normiert, orthografisch.
|
||||
- **perspective** — Richtung `(0.62,0.5,0.7)` normiert, perspektivisch.
|
||||
- Umschalten perspektiv/ortho ueber `active = perspective ? camera : orthoCamera`
|
||||
(~Z. 1602); Ortho-Frustum aus den Modell-Bounds (`updateOrthoFrustum`, ~Z. 1522).
|
||||
- **OrbitControls** (~Z. 391–413): Mitteltaste orbit, Shift+Mitte pan, Rad zoom
|
||||
(linke/rechte Taste fuer Auswahl/Kontextmenue umgewidmet).
|
||||
|
||||
### Griffe / Gizmos
|
||||
- Editier-Griffe (`SphereGeometry`, ~Z. 711–814): Endpunkt (orange), Hoehe (blau),
|
||||
Verschieben (gruen); `depthTest:false` (immer sichtbar). Drag ueber Workplane-
|
||||
Raycast. Fuer den nativen Renderer spaeter relevant (eigener Overlay-Pass).
|
||||
|
||||
### Schnittebene
|
||||
- **Nicht implementiert:** keine `renderer.clippingPlanes` / `localClippingEnabled`.
|
||||
Schnitte laufen aktuell 2D. Fuer wgpu ein eigenständiger spaeterer Milestone
|
||||
(Clip-Distances im Shader oder Stencil-Capping).
|
||||
|
||||
### Tiefe / Culling
|
||||
- Tiefentest three.js-Standard aktiv. Backface-Culling per Default (Ausnahme:
|
||||
Terrain/Import `DoubleSide`). Diverse Overlays mit `depthTest:false`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Port-Plan nach wgpu
|
||||
|
||||
### Datenfluss
|
||||
Web-Modell → **geflachte Eingabe** (`WallInput`, spaeter Oeffnungen/Treppen/Decken)
|
||||
→ `render3d`-Mesh-Erzeugung → GPU-Buffers → Draw. Analog zum 2D-Pfad (`Scene` →
|
||||
Tessellierung → Buffers). Die Eingabe ist bewusst serde-only und GPU-frei, damit die
|
||||
Mesh-Logik headless testbar bleibt.
|
||||
|
||||
### Mesh-Erzeugung (Waende extrudieren)
|
||||
- Band aus Achse + Dicke ueber die Links-Normale (`(-u.y, u.x) * thickness/2`,
|
||||
CCW), exakt wie `wallCorners`. Extrusion in world: XZ-Grundriss, +Y von
|
||||
`base_elevation` bis `+height`.
|
||||
- Ein Quader = 6 Seiten, je eigene Vertices mit Flaechen-Normale (flaches Shading,
|
||||
korrektes Backface-Culling). 24 Vertices / 36 Indizes je Wand.
|
||||
- Spaeter: mehrschichtige Waende (je Schicht ein Prisma), Gehrung
|
||||
(`computeJoins`/`clippedBand`-Port), Oeffnungs-Segmentierung (Bruestung/Sturz).
|
||||
|
||||
### Kamera (View/Projektion, Presets)
|
||||
- `look_at` (right-handed, Kamera blickt entlang -Z im View-Raum), `perspective`
|
||||
und `orthographic` — beide auf **Clip-Z in [0,1]** (wgpu-Konvention, NICHT [-1,1]).
|
||||
- Fuenf Presets (`preset_camera`): front/top/side orthografisch achsparallel,
|
||||
iso/persp perspektivisch. `top` mit up=-Z, damit Modell-Y im Bild nach unten
|
||||
zeigt (wie die 2D-Sicht).
|
||||
- Orbit-Kamera aus Yaw/Pitch/Distanz (`orbit_eye`, Pitch geklemmt gegen Pol-Flip).
|
||||
|
||||
### Beleuchtung
|
||||
- Zunaechst EIN Directional-Light (Richtung ZUM Licht) + ambienter Sockel im
|
||||
Fragment-Shader (WGSL) — das GPU-Aequivalent zu `AmbientLight(0.6)` +
|
||||
`DirectionalLight(1.1)@(6,12,4)`. Diffuses Lambert. PBR (Rauheit/Metallik/
|
||||
Texturen/AO) spaeter.
|
||||
|
||||
### Tiefenpuffer + Culling
|
||||
- `Depth32Float`-Attachment, `depth_compare = Less`, `depth_write = true`.
|
||||
- `front_face = Ccw`, `cull_mode = Back` (die Extrusion liefert konsistent nach
|
||||
aussen zeigende CCW-Flaechen).
|
||||
|
||||
### Matrix-Mathematik
|
||||
- Handgerechnet (kein `glam`) in der serde-only Schicht — begruendet in `math.rs`:
|
||||
die Standard-Schicht soll wie in render2d ohne Zusatz-Crates headless test-/baubar
|
||||
bleiben; der Umfang (perspective/ortho/look_at + Orbit) ist klein und exakt
|
||||
testbar. Ein spaeterer Wechsel zu `glam` (nur in der GPU-Schicht) bleibt moeglich,
|
||||
ohne die Kamera-Tests anzufassen. Alles spalten-major, direkt als Uniform ladbar.
|
||||
|
||||
### Milestones M2..Mn
|
||||
- **M2 — Mehrschichtige Waende + Gehrung:** Port von `computeJoins`/`miterLine` +
|
||||
`clippedBand` → gehrte Bandformen je Schicht; Farben/Materialien je Komponente.
|
||||
- **M3 — Oeffnungen:** Achsen-Segmentierung (Bruestung/Sturz) + Rahmen/Glas/Fluegel
|
||||
als eigene Meshes; Tuerschwenk-Winkel.
|
||||
- **M4 — Treppen + Decken:** Port von `stairGeometry`/`ceilingVerticalExtent`.
|
||||
- **M5 — Kontext/Gelaende:** rohe Terrain-/Import-Meshes + Hoehenlinien + Grid.
|
||||
- **M6 — Materialien (PBR):** `MeshStandardMaterial`-Aequivalent (roughness/
|
||||
metalness/albedo/AO-Textur); Render-Modi shaded/white/textured/wireframe/hidden.
|
||||
- **M7 — Schnittebene:** Clip-Distances im Shader oder Stencil-Capping (Feature, das
|
||||
der three.js-View gar nicht hat — echter Mehrwert).
|
||||
- **M8 — Griffe/Gizmos + Picking:** Overlay-Pass (immer-oben) + GPU-/Ray-Picking.
|
||||
- **M9 — Tauri-Integration:** Surface unter der Webview (raw-window-handle,
|
||||
Z-Order, Input-Routing) — siehe `wgpu-2d-renderer-briefing.md` M2 (gleiches
|
||||
Integrations-Problem; einmal loesen, fuer 2D+3D nutzen). Kamera-Presets/Orbit-
|
||||
Input aus der Webview an den Renderer.
|
||||
|
||||
---
|
||||
|
||||
## 3. Was in `src-tauri/render3d/` steht (M1)
|
||||
|
||||
Neue, eigenstaendige Crate (eigener leerer `[workspace]`-Block, wie render2d), damit
|
||||
`cargo test`/`build` unabhaengig vom Tauri-Workspace laufen. Feature-Stufung 1:1 wie
|
||||
render2d:
|
||||
|
||||
- `Cargo.toml` — Features `default` (serde-only) / `render` (wgpu) / `window`
|
||||
(winit-Spike). `[[bin]] spike3d` mit `required-features = ["window"]`.
|
||||
- `src/types.rs` — serde-only Eingabe: `WallInput { start, end, thickness, height,
|
||||
base_elevation, color }`, `Camera` (+ `Projection`), `CameraPreset`; Ausgabe
|
||||
`Mesh` (interleaved `[pos.xyz, normal.xyz, color.rgb]` + Indizes) mit
|
||||
`vertex_count`/`triangle_count`/`bounds`. Koordinaten-Konvention dokumentiert.
|
||||
- `src/mesh.rs` — Wand-Extrusion: `extrude_wall`/`build_walls_mesh`. Band ueber
|
||||
Links-Normale, Quader mit sechs eigenen Seiten, nach aussen zeigende Normalen.
|
||||
- `src/math.rs` — `Mat4` (spalten-major), `perspective`/`orthographic` (Clip-Z
|
||||
[0,1]), `look_at`, `view_projection`, `orbit_eye`, `preset_camera` (fuenf Presets).
|
||||
- `src/shaders.rs` — WGSL (`MESH_WGSL`): View-Projektion-Uniform + Directional-
|
||||
Light + ambienter Sockel im Fragment-Shader.
|
||||
- `src/gpu.rs` (Feature `render`) — `Renderer`: eine Pipeline mit Tiefenpuffer,
|
||||
View-Projektions-Uniform, Backface-Culling. `upload_walls` → GPU-Buffers,
|
||||
`render(camera, viewport)`.
|
||||
- `src/bin/spike3d.rs` (Feature `window`) — winit-Fenster mit Demo-Raum (5
|
||||
extrudierte Waende) + **Orbit-Kamera** (linke Maustaste dreht Yaw/Pitch, Rad
|
||||
zoomt Abstand). Matrix-getrieben, kein Re-Meshing beim Kamera-Wechsel.
|
||||
- `src/lib.rs` — Modul-Deklarationen, Re-Exports, Tests.
|
||||
|
||||
### Tests (`cargo test`, default-Feature)
|
||||
Muster wie render2d/`glPlanCompile.test.ts`:
|
||||
- Quader-Zaehlung (eine Wand → 24 Vertices / 36 Indizes / 12 Dreiecke).
|
||||
- Mehrere Waende addieren sich.
|
||||
- Bounding-Box deckt Laenge/Dicke/Hoehe ab; `base_elevation` verschiebt in Y.
|
||||
- Deckel-Normale = +Y; **alle Mantel-Normalen zeigen nach aussen** (Dot mit
|
||||
„Vertex − Zentrum" ≥ 0 → Backface-Culling korrekt).
|
||||
- Diagonale Wand; degenerierte Wand (Start==Ende) erzeugt nichts (kein Absturz).
|
||||
- Kamera: `look_at` setzt Ziel auf view-z=-dist; Perspektive klemmt z in [0,1];
|
||||
`orbit_eye` haelt den Abstand; Presets setzen die richtige Projektionsart.
|
||||
- Mit `--features render`: WGSL headless via `naga` (Parser + Validator) validiert.
|
||||
|
||||
---
|
||||
|
||||
## 4. Build-/Test-Ergebnis
|
||||
|
||||
Alle Gates gruen (Toolchain: cargo 1.96, wgpu 22, winit 0.30):
|
||||
- `cargo test` (default) — **12/12** gruen (Mesh + Kamera).
|
||||
- `cargo test --features render` — **13/13** gruen (inkl. WGSL-naga-Validierung).
|
||||
- `cargo build` (default), `--features render`, `--features window` — je gruen,
|
||||
**keine Warnungen**.
|
||||
- Trace-Scan sauber (keine KI-Spuren).
|
||||
- Web-Gates unberuehrt (nur `src-tauri/` + `docs/` angefasst; `src/` nur gelesen).
|
||||
|
||||
**Visuelle Fenster-Verifikation** ist headless NICHT moeglich. Auf einer aktiven
|
||||
Display-Session pruefbar mit:
|
||||
|
||||
```
|
||||
cargo run --features window --bin spike3d
|
||||
```
|
||||
|
||||
Erwartet: ein Raum aus extrudierten Waenden mit diffuser Beleuchtung; linke
|
||||
Maustaste dreht die Orbit-Kamera, das Rad zoomt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Naechste Schritte
|
||||
1. **M2** starten: `computeJoins`/`clippedBand`-Port für gehrte, mehrschichtige
|
||||
Waende (die Bandmath ist im Web bereits verifiziert — gleiche Tests portieren).
|
||||
2. Oeffnungs-Segmentierung (M3) auf demselben Extrusions-Kern.
|
||||
3. Die **Tauri-Integration (M9)** gemeinsam mit dem 2D-Renderer loesen (ein Surface-
|
||||
Unterbau, ein Input-Routing) — das ist der eigentliche Engpass, nicht das
|
||||
Rendering. Erst standalone spiken (dieser Stand), dann unter die Webview.
|
||||
@@ -0,0 +1,566 @@
|
||||
# Swisstopo-Geodaten & SIA-Flächenstandards im Browser-BIM
|
||||
|
||||
> Stand: 2026-06-29 · Recherche für das Standalone-Browser-BIM (React + TS + Three.js),
|
||||
> Port von **DOSSIER** (Rhino-Plugin). Ziel: (A) Schweizer Geodaten (Höhenmodell,
|
||||
> Orthofoto, 3D-Gebäude, Parzellen) direkt im Browser laden, (B) Standort-Kontext
|
||||
> (Gelände + Parzelle + Nachbargebäude) für ein Projekt importieren, (C) SIA-416-
|
||||
> Flächen/Volumen + Raumschemata berechnen wie in DOSSIER.
|
||||
>
|
||||
> Bezug zur ROADMAP: Swisstopo/Terrain/OSM = **Phase 4** (Kontext/Daten); SIA-416-
|
||||
> Räume + Bilanz-CSV = **Phase 2**. Beide sind dort bereits als ⭐-Features gelistet.
|
||||
|
||||
**Alle in diesem Dokument genannten geo.admin.ch-Endpunkte wurden am 2026-06-29 live
|
||||
gegen die echte API getestet** (curl + CORS-Header-Check). Wo „verifiziert" steht,
|
||||
liegt eine echte Antwort vor.
|
||||
|
||||
---
|
||||
|
||||
## Teil A — Swisstopo-APIs & Dienste aus dem Browser
|
||||
|
||||
### A.0 Das Wichtigste vorweg: CORS & Lizenz
|
||||
|
||||
Zwei Fragen entscheiden, ob ein Dienst *ohne Backend-Proxy* aus einer reinen
|
||||
Browser-App nutzbar ist: CORS und Lizenz. Beide sind hier günstig.
|
||||
|
||||
**CORS (live verifiziert):** Alle relevanten Hosts senden `access-control-allow-origin: *`:
|
||||
|
||||
| Host | Dienst | CORS | Range-Requests |
|
||||
|---|---|---|---|
|
||||
| `api3.geo.admin.ch` | REST (height, profile, identify, find, search) | ✅ `*` | — |
|
||||
| `data.geo.admin.ch` | STAC-API + Daten-Assets (COG-GeoTIFF, XYZ.zip) | ✅ `*` | ✅ `206 Partial Content`, `accept-ranges`/`content-range` vorhanden |
|
||||
| `wmts.geo.admin.ch` | WMTS-Kacheln | ✅ `*` | — |
|
||||
| `3d.geo.admin.ch` | 3D-Tiles (`tileset.json` + glTF) | ✅ `*` | — |
|
||||
|
||||
→ **Konsequenz:** Höhenabfrage, Geocoding, Parzellen-Identify, Karten-/Orthofoto-
|
||||
Kacheln, **COG-GeoTIFF-Höhenmodell per Range-Request** und 3D-Tiles sind **direkt aus
|
||||
dem Browser ohne eigenen Proxy** abrufbar. Das ist ein großer Vorteil gegenüber vielen
|
||||
anderen nationalen Geodiensten.
|
||||
|
||||
**Lizenz:** swisstopo/geo.admin.ch ist **Open Government Data**: „The acquisition and
|
||||
use of data or services is free of charge, subject to the provisions on fair use."
|
||||
Kommerzielle Nutzung ist erlaubt, Einbindung in (auch kommerzielle) Web-Apps explizit
|
||||
gedeckt. Pflicht-Attribution: **`© swisstopo`** (bzw. „© Data: swisstopo"). „Fair use"
|
||||
= z.B. Web-App mit Ø 20'000 Nutzern/Tag ok; aggressives Bot-Scraping vermeiden. Haftung
|
||||
ausgeschlossen, ~98% Verfügbarkeit. [Terms of use FSDI](https://www.geo.admin.ch/en/general-terms-of-use-fsdi)
|
||||
|
||||
> ⚠️ **Korrektur zu DOSSIER & zur Doku:** Die offizielle REST-Doku notiert beim
|
||||
> *Height*-Service „This service is not freely accessible (fee required)". Das ist
|
||||
> **in der Praxis falsch / veraltet**: Der Endpunkt antwortet anonym, ohne Key, mit
|
||||
> `200` und CORS `*` (verifiziert, siehe A.1). DOSSIERs Aussage „alle APIs offen, ohne
|
||||
> Auth, ohne Key" deckt sich mit der gemessenen Realität. Wir verlassen uns aber nicht
|
||||
> blind darauf, sondern behandeln 402/429 defensiv (Retry/Backoff, Cache).
|
||||
|
||||
### A.1 Höhenabfrage — Height-Service (Einzelpunkt)
|
||||
|
||||
Punkt-Höhe (DTM) aus swissALTI3D/DTM. **Verifiziert:**
|
||||
|
||||
```
|
||||
GET https://api3.geo.admin.ch/rest/services/height?easting=2600000&northing=1200000&sr=2056
|
||||
→ {"height":"555.5"}
|
||||
```
|
||||
|
||||
Parameter:
|
||||
- `easting`, `northing` — LV95 (`sr=2056`) oder LV03 (`sr=21781`). **Pflicht.**
|
||||
- `sr` — `2056` (LV95) angeben, sonst Default `21781`.
|
||||
- `elevation_model` — `DTM2` (= swissALTI3D, 2 m), `DTM25` (Default), `COMB`.
|
||||
(Im Tal lieferten DTM2/DTM25/COMB denselben Wert; im Steilgelände kann DTM2 genauer sein.)
|
||||
- `callback` — JSONP (brauchen wir wegen CORS nicht).
|
||||
|
||||
Nutzung im Tool: **Projekt-Nullpunkt-Z** bzw. „Gebäude auf Gelände setzen" — eine
|
||||
einzelne Höhe an der Projekt-Koordinate. Antwortzeit ~50–150 ms. Quelle:
|
||||
[GeoAdmin REST – Height](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html)
|
||||
|
||||
### A.2 Höhenprofil — Profile-Service (Schnittlinie)
|
||||
|
||||
Höhen entlang einer Polylinie — ideal für **Geländeschnitt** unter einem
|
||||
Gebäude-Schnitt. **Verifiziert** (echte Werte zurück):
|
||||
|
||||
```
|
||||
GET https://api3.geo.admin.ch/rest/services/profile.json
|
||||
?geom={"type":"LineString","coordinates":[[2600000,1200000],[2600200,1200000]]}
|
||||
&sr=2056&nb_points=3
|
||||
→ [{"alts":{"COMB":555.5,"DTM2":555.5,"DTM25":555.5},"dist":0,"easting":2600000,"northing":1200000},
|
||||
{"alts":{...},"dist":100,...}, {"dist":200,...}]
|
||||
```
|
||||
|
||||
Parameter: `geom` (GeoJSON-LineString, max 6'000 Punkte), `sr`, `nb_points` (Anzahl
|
||||
Stützpunkte, Default 200), `elevation_models`, `offset` (Glättung). Auch als
|
||||
`profile.csv`. → Für 2D-Geländeschnitte **ohne** Mesh-Download. Quelle:
|
||||
[GeoAdmin REST – Profile](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html)
|
||||
|
||||
### A.3 swissALTI3D — Höhenmodell als COG-GeoTIFF (Mesh-Quelle) ⭐
|
||||
|
||||
Das ist der **Schlüssel für das Gelände-Mesh im Browser**. swissALTI3D ist das präzise
|
||||
DTM der Schweiz (ohne Vegetation/Bebauung), Auflösung 0.5 m / 2 m, alle 6 Jahre
|
||||
aktualisiert ([swissALTI3D](https://www.swisstopo.admin.ch/en/height-model-swissalti3d)).
|
||||
Bezug über die **STAC-API** (verifiziert — Tile `swissalti3d_2019_2599-1198`):
|
||||
|
||||
```
|
||||
GET https://data.geo.admin.ch/api/stac/v1/collections/ch.swisstopo.swissalti3d/items
|
||||
?bbox=<lonMin,latMin,lonMax,latMax>&limit=...
|
||||
```
|
||||
|
||||
Jedes 1×1-km-Tile liefert pro Auflösung **zwei** Asset-Typen:
|
||||
|
||||
| Asset | Typ | Browser-tauglich? |
|
||||
|---|---|---|
|
||||
| `..._0.5_2056_5728.tif` / `..._2_2056_5728.tif` | **Cloud-Optimized GeoTIFF**, **EPSG:2056** | ✅ **direkt** via `geotiff.js` + Range |
|
||||
| `..._0.5_2056_5728.xyz.zip` / `..._2_..._xyz.zip` | ASCII-XYZ (E N Z) in ZIP | ✅ via `fflate` entpacken (so macht es DOSSIER) |
|
||||
|
||||
**Verifiziert:** `data.geo.admin.ch` liefert auf das `.tif` ein `206 Partial Content`
|
||||
mit `content-range` bei `Range:`-Header und CORS `*`. Das bedeutet: **`geotiff.js`
|
||||
liest nur den benötigten Ausschnitt eines COG per HTTP-Range, ohne das ganze File zu
|
||||
laden** — perfekt für eine Browser-App. Bbox in WGS84 für STAC, Tile-Daten dann in
|
||||
LV95-Metern (kein Reprojizieren der Z-Werte nötig). Quelle:
|
||||
[STAC tech docs](https://docs.geo.admin.ch/) · COG-Tile live geprüft.
|
||||
|
||||
### A.4 SWISSIMAGE / Karten — WMTS-Kacheln
|
||||
|
||||
Orthofoto (10 cm) und Landeskarten als Kacheln. RESTful-URL-Template:
|
||||
|
||||
```
|
||||
https://wmts.geo.admin.ch/1.0.0/<Layer>/default/<Time>/<TileMatrixSet>/<z>/<TileCol>/<TileRow>.<ext>
|
||||
```
|
||||
|
||||
Beispiel-Layer:
|
||||
- `ch.swisstopo.swissimage` — Orthofoto, `.jpeg`
|
||||
- `ch.swisstopo.pixelkarte-farbe` — Landeskarte farbig, `.jpeg`
|
||||
- `ch.kantone.cadastralwebmap-farbe` — **Katasterplan (AV)**, `.png`
|
||||
|
||||
TileMatrixSets: **`2056` (LV95)**, `21781`, `3857` (Web-Mercator), `4326`. Zoom 0–28
|
||||
(4000 m → 0.1 m); Zoom 27/28 nur für wenige Layer (swissimage, Kataster). Für 3D in
|
||||
Three.js am einfachsten **`3857`** (Standard-Slippy-Map-Schema, z/x/y), z.B.
|
||||
|
||||
```
|
||||
https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg
|
||||
```
|
||||
|
||||
Für planimetrisch exakte 2D-Arbeit besser **`2056`**. CORS `*` (verifiziert). Quellen:
|
||||
[WMTS docs](https://docs.geo.admin.ch/visualize-data/wmts.html) ·
|
||||
[WMTS service](https://wmts.geo.admin.ch/) ·
|
||||
[WMTS EPSG:2056 CodePen](https://codepen.io/geoadmin/pen/GZKEam).
|
||||
|
||||
> Es gibt zusätzlich klassisches **WMS** (`https://wms.geo.admin.ch/`, GetMap mit
|
||||
> beliebiger BBox/Größe, ebenfalls EPSG:2056). Für ein einzelnes georeferenziertes
|
||||
> Orthofoto-Rechteck unter dem Modell ist ein WMS-GetMap manchmal praktischer als
|
||||
> WMTS-Kacheln zu stitchen. [WMS docs](https://docs.geo.admin.ch/visualize-data/wms.html)
|
||||
|
||||
### A.5 swissBUILDINGS3D — Nachbargebäude (3D)
|
||||
|
||||
Zwei Wege:
|
||||
|
||||
**(a) 3D-Tiles (Streaming, Cesium-Format)** — `glTF`/`tileset.json`, für große Gebiete:
|
||||
```
|
||||
https://3d.geo.admin.ch/<Layer>/<Version>/<Time>/tileset.json
|
||||
```
|
||||
Layer u.a. `ch.swisstopo.swissbuildings3d.3d`, `ch.swisstopo.swisstlm3d.3d`,
|
||||
`ch.swisstopo.swissnames3d.3d`, `ch.swisstopo.vegetation.3d`. `Version` = `v1`, `Time`
|
||||
optional (ISO `YYYYMMDD`, weglassen = aktuellste). CORS `*` (verifiziert auf
|
||||
`tileset.json`). Direkt für CesiumJS gedacht; in **reinem Three.js** über
|
||||
`@loaders.gl/3d-tiles` oder den `3DTilesRendererJS` (NASA-AMMOS/`three.js`-Community)
|
||||
ladbar. [3D-Tiles docs](https://docs.geo.admin.ch/visualize-data/3d-tiles.html) ·
|
||||
[Switzerland in 3D](https://www.swisstopo.admin.ch/en/switzerland-in-3d)
|
||||
|
||||
**(b) STAC-Tiles als CAD/Mesh-Datei (Download pro Tile)** — so macht es DOSSIER:
|
||||
Collections `ch.swisstopo.swissbuildings3d_3_0` (neu; in Städten z.T. >700 MB Tiles)
|
||||
und `ch.swisstopo.swissbuildings3d_2` (1-km-Tiles, ~50 MB, stabil). Assets in
|
||||
`.dxf/.dwg/.obj/.ifc` (+ `.zip`), Varianten `solid`/`separated`. Für den **Import als
|
||||
echte, editierbare Massen** ins eigene Modell ist der OBJ/IFC-Tile-Weg besser als
|
||||
3D-Tiles (die sind read-only Visualisierung). swissBUILDINGS3D: >3 Mio Gebäude,
|
||||
Lage-/Höhengenauigkeit 30–50 cm.
|
||||
|
||||
→ **Empfehlung:** Für „Nachbarschaft als Kontext anzeigen" (Phase 4 Start) **3D-Tiles
|
||||
streamen** (kein Download, kein Parsing). Wenn der Nutzer Nachbargebäude als Geometrie
|
||||
*braucht* (Verschattung, Abstand), **STAC-OBJ-Tile** laden und als Mesh importieren.
|
||||
|
||||
### A.6 Parzelle / Kataster (AV) — Identify-Service ⭐
|
||||
|
||||
**Verifiziert** — Parzellen-Polygon aus einer Koordinate, in LV95:
|
||||
|
||||
```
|
||||
GET https://api3.geo.admin.ch/rest/services/api/MapServer/identify
|
||||
?geometry=2600423,1199521&geometryType=esriGeometryPoint
|
||||
&imageDisplay=100,100,96&mapExtent=2600323,1199421,2600523,1199621
|
||||
&tolerance=2&layers=all:ch.kantone.cadastralwebmap-farbe
|
||||
&returnGeometry=true&geometryFormat=geojson&sr=2056
|
||||
→ {"results":[{"type":"Feature","bbox":[...],
|
||||
"geometry":{"type":"Polygon","coordinates":[[ [2600377.1,1199523.7], ... ]]},
|
||||
"attributes":{"number":"698","egris_egrid":"CH507635214670","ak":"BE", ...}}]}
|
||||
```
|
||||
|
||||
- Parzellen-Layer: **`ch.kantone.cadastralwebmap-farbe`** (liefert `number`,
|
||||
`egris_egrid` = EGRID, Kanton; mit `returnGeometry=true&geometryFormat=geojson` das
|
||||
**Parzellen-Polygon in LV95-Metern** → direkt als Grundstücksgrenze importierbar).
|
||||
- Gebäudeadressen: `ch.swisstopo.amtliches-gebaeudeadressverzeichnis`; Gebäude-/
|
||||
Wohnungsregister `ch.bfs.gebaeude_wohnungs_register` (EGID).
|
||||
- Pflichtparameter: `geometry`, `geometryType` (`esriGeometryPoint|...Polygon|...Envelope`),
|
||||
`mapExtent`, `imageDisplay`, `tolerance`; `sr=2056`; max 50 Features/Request.
|
||||
|
||||
Quellen: [Identify features](https://docs.geo.admin.ch/access-data/identify-features.html) ·
|
||||
[GeoAdmin REST – Identify/Find](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html) ·
|
||||
Live-Antwort oben.
|
||||
|
||||
### A.7 Geocoding — SearchServer (Adresse → LV95)
|
||||
|
||||
**Verifiziert** (Adresse → Koordinate, deckt sich mit DOSSIERs `geocode()`):
|
||||
|
||||
```
|
||||
GET https://api3.geo.admin.ch/rest/services/api/SearchServer
|
||||
?searchText=Bundesplatz 3 Bern&type=locations&origins=address&sr=2056&limit=1
|
||||
→ results[0].attrs: { label:"Bundesplatz 3 <b>3011 Bern</b>", lat:46.94677, lon:7.44419,
|
||||
geom_st_box2d:"BOX(2600423.26 1199521.11, ...)", origin:"address", ... }
|
||||
```
|
||||
|
||||
- `type=locations`, `origins` aus `{address, parcel, gg25, gazetteer, zipcode, district,
|
||||
kantone}`, `sr=2056`. **Im LV95-Modus liefert die Geo-Admin-Konvention `y`=East,
|
||||
`x`=North** (DOSSIER liest genau so: `e=attrs.y`, `n=attrs.x`). Labels enthalten
|
||||
`<b>`-Tags (strippen).
|
||||
- `type=featuresearch` + `features=<layer>` durchsucht Attribute (z.B. Parzellennummer).
|
||||
|
||||
Quelle: [Search](https://docs.geo.admin.ch/access-data/search.html) · Live-Antwort oben.
|
||||
|
||||
### A.8 Koordinatensystem LV95 / EPSG:2056 & Transformationen
|
||||
|
||||
Intern rechnet das BIM-Tool in **Metern** (ROADMAP-Konvention) und verschiebt den
|
||||
Projekt-Ursprung nahe (0,0,0); LV95-Koordinaten sind ~2.6 Mio / 1.2 Mio Meter groß und
|
||||
würden bei `float32` (Three.js) zu **Jitter** führen → **Origin-Shift Pflicht** (siehe B.3).
|
||||
DOSSIER macht genau das (`origin_shift`/`shift_lv95`, typ. bbox-Center → 0/0/0).
|
||||
|
||||
Transformations-Optionen:
|
||||
|
||||
1. **`proj4` (npm `proj4@2.20.9`)** — universell, exakt. EPSG:2056-Definition:
|
||||
```js
|
||||
proj4.defs("EPSG:2056",
|
||||
"+proj=somerc +lat_0=46.9524055555556 +lon_0=7.43958333333333 +k_0=1 "+
|
||||
"+x_0=2600000 +y_0=1200000 +ellps=bessel "+
|
||||
"+towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs +type=crs");
|
||||
const [e,n] = proj4("EPSG:4326","EPSG:2056",[lon,lat]); // WGS84→LV95
|
||||
```
|
||||
Genauigkeit mit dieser 3-Parameter-`towgs84` ~1 m (für Kontext-Import völlig
|
||||
ausreichend). Types: `@types/proj4`. Quellen:
|
||||
[epsg.io/2056](https://epsg.io/2056) · [proj4js](https://github.com/proj4js/proj4js).
|
||||
|
||||
2. **Näherungsformeln (CH1903→WGS84, swisstopo)** — DOSSIERs Ansatz, ~1 m genau,
|
||||
**0 Dependencies** (zwei kleine Funktionen `lv95_to_wgs84`/`wgs84_to_lv95`).
|
||||
1:1 nach TS portierbar; gut, wenn man `proj4` nicht ziehen will. Reicht, weil
|
||||
STAC-Queries ohnehin nur eine grobe WGS84-Bbox brauchen und alle *Daten* schon in
|
||||
LV95 kommen.
|
||||
|
||||
3. **swisstopo REFRAME Web-API** — cm-genaue offizielle Umrechnung (LV95↔WGS84,
|
||||
LN02↔Bessel). Nur nötig, wenn Vermessungs-Genauigkeit verlangt wird. REST, online.
|
||||
[REFRAME Web](https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web)
|
||||
|
||||
**Empfehlung:** `proj4` mit fester EPSG:2056-Def (eine Abhängigkeit, exakt genug,
|
||||
wartungsarm) — oder, wenn Dependency-Geiz, DOSSIERs Formeln portieren. REFRAME nur bei
|
||||
Bedarf nachrüsten.
|
||||
|
||||
### A.9 Endpoint-Übersicht (Spickzettel)
|
||||
|
||||
| Zweck | Endpoint | Frei/CORS | Format |
|
||||
|---|---|---|---|
|
||||
| Punkt-Höhe | `api3…/rest/services/height` | ✅ ✅ | JSON |
|
||||
| Höhenprofil (Schnitt) | `api3…/rest/services/profile.json` | ✅ ✅ | JSON/CSV |
|
||||
| Gelände-Mesh (COG) | STAC `…/swissalti3d/items` → `.tif` (COG, 2056) | ✅ ✅ Range | GeoTIFF |
|
||||
| Gelände (ASCII) | STAC `…/swissalti3d` → `.xyz.zip` | ✅ ✅ | XYZ in ZIP |
|
||||
| Orthofoto/Karte | `wmts…/1.0.0/<layer>/…/{z}/{x}/{y}.jpeg` | ✅ ✅ | Kacheln |
|
||||
| 3D-Nachbargebäude (stream) | `3d…/ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` | ✅ ✅ | 3D-Tiles/glTF |
|
||||
| 3D-Gebäude (Datei) | STAC `…/swissbuildings3d_2` → `.obj/.ifc` | ✅ ✅ | OBJ/IFC |
|
||||
| Parzelle/Kataster | `api3…/MapServer/identify` `layers=all:ch.kantone.cadastralwebmap-farbe` | ✅ ✅ | GeoJSON |
|
||||
| Geocoding | `api3…/SearchServer?type=locations` | ✅ ✅ | JSON |
|
||||
|
||||
---
|
||||
|
||||
## Teil B — Standort-Kontext importieren (Terrain + Parzelle + Nachbargebäude)
|
||||
|
||||
So bekommt ein Projekt seinen realen Kontext „auf Knopfdruck". Der Ablauf folgt
|
||||
DOSSIER (`rhino/swisstopo.py`), übersetzt auf Browser-Libs.
|
||||
|
||||
### B.1 Pipeline (End-to-End)
|
||||
|
||||
```
|
||||
Adresse/Parzelle ──SearchServer──▶ Zentrum (E,N) in LV95
|
||||
│
|
||||
├─ radius r ──▶ bbox_LV95 (E±r, N±r) ──proj4/Formeln──▶ bbox_WGS84
|
||||
│
|
||||
├─[Parzelle] identify(cadastralwebmap, point) ─▶ Polygon (LV95) ─▶ Grundstücksgrenze (Ebene 01 Vermessung)
|
||||
│
|
||||
├─[Gelände] STAC(swissalti3d, bbox_WGS84) ─▶ COG .tif(2056)
|
||||
│ └─ geotiff.js readRasters(window) ─▶ Höhen-Grid (E,N,Z, m)
|
||||
│ └─ Three.js BufferGeometry (Grid→Mesh) [optional: TIN, Höhenlinien, Volumen]
|
||||
│
|
||||
├─[Orthofoto] WMTS swissimage ─▶ Textur auf Gelände-Mesh ODER georef. Plane
|
||||
│
|
||||
└─[Nachbarn] 3D-Tiles streamen (Anzeige) ODER STAC swissbuildings3d_2 .obj ─▶ Mesh-Import
|
||||
(Weltweit/ausserhalb CH: OSM-Overpass als Fallback, siehe B.5)
|
||||
──▶ alle Geometrien um origin_shift (bbox-Center→0/0/0) verschoben, Z aus ALTI3D
|
||||
```
|
||||
|
||||
### B.2 Gelände-Mesh aus swissALTI3D (Kern, Phase 4)
|
||||
|
||||
**Empfohlener Browser-Weg (COG + geotiff.js):**
|
||||
|
||||
1. STAC-Query mit `bbox_WGS84` → Liste der überlappenden Tiles; pro Tile das
|
||||
gewünschte COG-Asset (`_2_2056_` für 2 m, `_0.5_2056_` für 0.5 m).
|
||||
2. `geotiff.js`: `const tiff = await fromUrl(href)` → COG; `image.readRasters({window})`
|
||||
liest **nur den Ausschnitt** (Range-Requests, da CORS+Range bestätigt). Ergebnis ist
|
||||
ein reguläres Z-Raster mit bekanntem Origin/PixelScale (LV95-Meter) aus den
|
||||
GeoKeys/`image.getOrigin()`/`image.getResolution()`.
|
||||
3. Raster → **`THREE.BufferGeometry`**: ein Vertex pro Rasterpunkt
|
||||
`(E−shiftE, N−shiftN, Z−shiftZ)`, Faces als zwei Dreiecke pro Zelle (DOSSIER:
|
||||
`mesh_from_grid`, gleiche Logik), `computeVertexNormals()`. Bei 0.5 m wird das Mesh
|
||||
groß → bei Bedarf raumräumlich sub-samplen (DOSSIER macht ganzzahliges Sub-Sampling
|
||||
auf dem **globalen** LV95-Raster, damit Nachbar-Tiles nahtlos zusammenpassen).
|
||||
4. Mehrere Tiles: erst zu **einem** Grid mergen (gemeinsamer Origin/Step), dann meshen —
|
||||
sonst entstehen Nähte (DOSSIER: `merge_grids`).
|
||||
|
||||
**Alternativweg (XYZ, exakt wie DOSSIER):** `.xyz.zip` laden → mit **`fflate`**
|
||||
(npm, schnellster Inflate im Browser) entpacken → ASCII `E N Z` parsen → gleiches Grid.
|
||||
Robust, aber überträgt mehr Bytes als der COG-Range-Weg. Für den Port empfehle ich
|
||||
**COG primär, XYZ als Fallback**.
|
||||
|
||||
Bibliotheken: `geotiff@3.0.5`, `fflate` (XYZ-Variante), `three@0.185.0`.
|
||||
[geotiff.js](https://github.com/geotiffjs/geotiff.js/) ·
|
||||
[3D-Terrain aus GeoTIFF mit Three.js](https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/).
|
||||
|
||||
**Ableitungen wie in DOSSIER** (alle aus dem Grid, in Phase 4 portierbar):
|
||||
- **Höhenlinien** (Marching-Squares auf dem Grid; npm `d3-contour` oder
|
||||
`marchingsquares`) — für 2D-Plan.
|
||||
- **TIN / Patch** (Delaunay aus den Punkten; npm `delaunator`) — alternatives Mesh.
|
||||
- **Geschlossenes Gelände-Volumen** (Boden N m unter tiefstem Punkt) → gefüllte
|
||||
Querschnitte beim Schnitt-Cut (DOSSIER `terrainVolume`/`terrainVolumeDepth`).
|
||||
|
||||
### B.3 Origin-Shift (Pflicht)
|
||||
|
||||
LV95-Koordinaten (~2.6e6) sprengen `float32`. Beim Import einmal
|
||||
`shift = (eCenter, nCenter, zRef)` festlegen, **alle** Geometrien `−shift` rechnen, und
|
||||
`shift` am Projekt persistieren (für Re-Import / Geo-Referenz / Norden). DOSSIER:
|
||||
`origin_shift`/`shift_lv95`, plus „Auto-Zoom auf Import" (ROADMAP §11). Damit bleibt das
|
||||
Modell metergenau und der Rückweg in echte LV95-Koordinaten (Export, weitere
|
||||
swisstopo-Abfragen) ist `+ shift`.
|
||||
|
||||
### B.4 Parzelle + Orthofoto
|
||||
|
||||
- **Parzelle:** `identify(...cadastralwebmap..., returnGeometry=true, geometryFormat=geojson)`
|
||||
→ Polygon (LV95) → `−shift` → als geschlossene Polylinie auf Ebene **`01 Vermessung`**.
|
||||
Attribute `number`/`egris_egrid` am Objekt/Projekt speichern.
|
||||
- **Orthofoto:** WMTS `swissimage`-Kacheln über die Modell-Bbox stitchen → eine Textur,
|
||||
als Material auf das Gelände-Mesh **oder** auf eine georeferenzierte Plane (DOSSIER:
|
||||
`add_ortho_plane`, mit UV-Shift gegen Tile-Nähte). Für 3D ist `3857` einfacher, für
|
||||
exakte 2D-Lage `2056`.
|
||||
|
||||
### B.5 OSM-Overpass als weltweiter Fallback (Phase 4)
|
||||
|
||||
Ausserhalb der Schweiz (oder wenn nur 2D-Footprints/Straßen reichen): DOSSIER hat einen
|
||||
**Overpass-Importer** (`https://overpass-api.de/api/interpreter`, POST) mit 7 Kategorien
|
||||
(Straßen/Gebäude/Wasser/Wasserläufe/Grün/Wege). Liefert OSM-Ways → Polylinien. Im
|
||||
Browser identisch nutzbar (`fetch` POST). Overpass koordiniert in WGS84 → mit
|
||||
`proj4`→LV95→`−shift`. Hinweis: Overpass-CORS ist beim Haupt-Server meist offen, kann
|
||||
aber je nach Mirror variieren; ggf. anderen Mirror wählen.
|
||||
[Overpass API](https://overpass-api.de/).
|
||||
|
||||
### B.6 Was sich von DOSSIER **nicht** 1:1 portieren lässt
|
||||
|
||||
- **Rhino-`_-Import`** für DXF/DWG/OBJ und **`_-MeshPatch`/Delaunay**-Commands gibt es im
|
||||
Browser nicht → ersetzen durch JS-Parser/Algorithmen (OBJ: `three`-`OBJLoader`;
|
||||
Delaunay: `delaunator`; Contours: `d3-contour`).
|
||||
- **Filesystem-Cache neben der `.3dm`** → Browser: **IndexedDB**-Cache (Cache-API für
|
||||
Kacheln). Passt zur ROADMAP-Phase 5 (IndexedDB-Persistenz).
|
||||
- IFC-Import von swissBUILDINGS3D 3.0 → über **web-ifc** (ohnehin im Stack, Phase 4).
|
||||
|
||||
---
|
||||
|
||||
## Teil C — SIA 416 (Flächen/Volumen) & SIA 421 + DOSSIER-Logik
|
||||
|
||||
### C.1 SIA 416 — Flächen- und Volumenhierarchie
|
||||
|
||||
**SIA 416:2003** „Flächen und Volumen von Gebäuden" ist die in der CH gültige Norm und
|
||||
Berechnungsbasis für Kostenplanung/Flächennachweise. Sie kennt vier Bereiche:
|
||||
**GSF** (Grundstück), **GF** (Geschossflächen), **AGF** (Aussengeschossflächen), **GV**
|
||||
(Volumen). Maßgeblich ist die effektive Geometrie (keine fiktiven Zuschläge mehr).
|
||||
Quellen: [SIA 416 Übersicht (siworks/DBV)](https://diebauherrenvertretung.ch/sia146/) ·
|
||||
[SIA-Shop 416/2003](https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product) ·
|
||||
[Flächenkennzahlen SIA 416 (Ginesta, PDF)](https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf).
|
||||
|
||||
**Hierarchie & Formeln (verifiziert):**
|
||||
|
||||
```
|
||||
GSF Grundstücksfläche
|
||||
GF Geschossfläche = KF + NGF
|
||||
├─ KF Konstruktionsfläche (Wände/Stützen; tragend KFT + nicht tragend KFN)
|
||||
└─ NGF Nettogeschossfläche = NF + VF + FF
|
||||
├─ NF Nutzfläche = HNF + NNF
|
||||
│ ├─ HNF Hauptnutzfläche (zweckbestimmte Hauptnutzung: Wohnen, Büro …)
|
||||
│ └─ NNF Nebennutzfläche (Lager, Bad/WC, Abstell-, Nebenräume)
|
||||
├─ VF Verkehrsfläche (Erschließung: Flure, Treppen, Lifte)
|
||||
└─ FF Funktionsfläche (Gebäudetechnik: Heizung, Lüftung, Technik)
|
||||
AGF Aussengeschossfläche (Balkone, Terrassen, gedeckte Aussenflächen)
|
||||
GV Gebäudevolumen [m³]
|
||||
```
|
||||
|
||||
| Abk. | Deutsch | Inhalt (Kurz) |
|
||||
|---|---|---|
|
||||
| GSF | Grundstücksfläche | Parzellenfläche (aus Kataster, Teil A.6) |
|
||||
| GF | Geschossfläche | allseits umschlossene + überdeckte Grundrissflächen, geschossweise |
|
||||
| KF | Konstruktionsfläche | Bauteile (Wände/Stützen), nicht begehbar; KFT tragend / KFN nicht tragend |
|
||||
| NGF | Nettogeschossfläche | begehbare Fläche innerhalb der Umschließung = NF+VF+FF |
|
||||
| NF | Nutzfläche | tatsächlich nutzbar = HNF+NNF |
|
||||
| HNF | Hauptnutzfläche | zweckbestimmte Hauptnutzung |
|
||||
| NNF | Nebennutzfläche | dienende Nebenräume (Lager, Bad, WC) |
|
||||
| VF | Verkehrsfläche | horizontale/vertikale Erschließung |
|
||||
| FF | Funktionsfläche | Gebäudetechnik |
|
||||
| AGF | Aussengeschossfläche | Balkone/Terrassen u.ä. |
|
||||
| GV | Gebäudevolumen | umbauter Raum [m³] |
|
||||
|
||||
> **Versionshinweis:** Es kursiert eine Revision **SIA 416:2017** mit präzisierten
|
||||
> Begriffen, in der Praxis wird aber breit weiter **416:2003** referenziert. Für unser
|
||||
> Tool reicht die Klassifikation **HNF/NNF/VF/FF/GF/AGF + Bilanz**; die genaue Auflage
|
||||
> nur als Label dokumentieren. Verbindliche Definitionen stehen im kostenpflichtigen
|
||||
> Normtext (SIA-Shop) — die hier zitierten freien Quellen stimmen in der Struktur überein.
|
||||
|
||||
### C.2 SIA 421 — Flächengliederung für Bewirtschaftung/Vermietung
|
||||
|
||||
**SIA 421:2006** „Flächengliederung und Mengenangaben" (Korrigenda C1:2014) baut auf der
|
||||
SIA-416-Systematik auf und **gliedert Flächen für Immobilien-Bewirtschaftung und
|
||||
Vermietung** (Mietflächen, Nutzungseinheiten, Zuordnung von VF/FF zu Mietern). Relevant,
|
||||
sobald wir **Mietflächen-/Bewirtschaftungs-Auswertungen** wollen (über den reinen
|
||||
Architektur-Nachweis hinaus). Für den ersten Wurf **nicht zwingend** — SIA 416 reicht
|
||||
für Flächennachweis und Raumschema. SIA 421 ist die natürliche Erweiterung, wenn
|
||||
Property-Management-Features kommen. Quellen:
|
||||
[SIA 421:2006 (PDF Inhalt)](https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang) ·
|
||||
[Korrigenda C1:2014 (PDF)](https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf).
|
||||
|
||||
### C.3 Wie DOSSIER SIA macht (Vorlage für den Port)
|
||||
|
||||
Quellcode: `rhino/elemente.py` (Räume) + `rhino/elemente_uebersicht.py` (Bilanz/CSV).
|
||||
Kernpunkte, die wir 1:1 übernehmen:
|
||||
|
||||
**Datenmodell pro Raum.** Ein Raum ist eine **geschlossene Outline-Curve** + ein
|
||||
**Text-Stempel**. Klassifikation über das Feld `dossier_raum_sia` mit Werten
|
||||
`{"", hnf, nnf, vf, ff, gf, agf}` (`_RAUM_SIA_KINDS`). Weitere Felder: `name`, `nummer`,
|
||||
`funktion` (`wohnen|schlafen|bad|kueche|essen|flur|…`), `personen` (für
|
||||
Personenbelegung/Brandschutz), Rundung, Stempel-Layout.
|
||||
|
||||
**Flächen-/Umfangsberechnung** (`_raum_amp`): Fläche aus `AreaMassProperties` (≙ in JS
|
||||
**Shoelace-Formel** über das Polygon), Umfang = Kurvenlänge, plus Zentroid für den
|
||||
Stempel. Im Browser: Polygon-Fläche selbst rechnen (Shoelace), kein Mesh nötig — exakt
|
||||
und schnell. Rundungsstufen (`_format_area`): `exakt|0.01|0.1|0.5|1` (z.B. `0.5` =
|
||||
`round(a*2)/2`).
|
||||
|
||||
**SIA-Bilanz** (`compute_sia_bilanz`, `scope = total | geschoss:<id>`): summiert
|
||||
Raumflächen je Klasse, dann:
|
||||
```
|
||||
NF = HNF + NNF
|
||||
NGF = NF + VF + FF (GF/AGF separat aggregiert, zählen nicht in NGF)
|
||||
```
|
||||
(genau die Formeln aus C.1). Ergebnis je Geschoss + Total.
|
||||
|
||||
**Farb-/Darstellungs-Konvention** (`_SIA_COLORS_HEX`, Pastell): HNF rot `#e8a8a8`,
|
||||
NNF orange `#e8c498`, VF gelb `#e8d878`, FF hellblau `#a8c8e0`, GF grau `#d0d0d0`,
|
||||
AGF hellgrün `#c0d8c0`. Umgesetzt als **regelbasierte Overrides** (`_build_sia_preset_rules`,
|
||||
Preset „SIA-Raeume"): Bedingung `user_string == code` → Outline-Farbe + Solid-Hatch.
|
||||
→ Passt 1:1 zur geplanten **Overrides-Engine** (ROADMAP §2c/§11).
|
||||
|
||||
**Export** (`_cmd_export_raeume`, `_export_bilanz`): CSV, **Semikolon + UTF-8-BOM**
|
||||
(CH/DE-Excel), Dezimal-Komma. Raumliste: Nummer; Name; Geschoss; Funktion; SIA; Fläche;
|
||||
Fläche gerundet; Umfang. Bilanz: eine Spalte je Geschoss + Total, Zeilen je Kategorie.
|
||||
→ Im Browser: Blob + Download (kein SaveFileDialog), gleiche CSV-Struktur. Optional
|
||||
direkt `.xlsx` via `sheetjs`/`exceljs`.
|
||||
|
||||
**Layer-Routing:** GF→`61_GF`, AGF→`62_AGF`, Rest→`60_RAEUME` (`_layer_path_for_raum_sia`)
|
||||
— damit Geschossflächen-Outlines getrennt schalt-/exportierbar sind. Übersetzt sich auf
|
||||
unsere Ebenen-Codes (`60 Räume`).
|
||||
|
||||
**Was wir im Port besser/anders machen:**
|
||||
- Fläche per **Shoelace** statt Rhino-Mass-Props (0 Deps).
|
||||
- Bilanz **reaktiv** aus dem semantischen Modell (Zustand-Store) statt Doc-Scan.
|
||||
- **Space = Slab-/Raum-Polygon mit `siaClass`** im Datenmodell (ROADMAP:
|
||||
`Space { boundary, name }`), Bilanz als abgeleitete Sicht.
|
||||
|
||||
---
|
||||
|
||||
## Teil D — Umsetzungsplan (Endpunkte · Libs · Phase)
|
||||
|
||||
Reihenfolge orientiert sich an der ROADMAP (SIA = Phase 2, Geo = Phase 4) und an „größter
|
||||
Nutzen zuerst, geringste Abhängigkeit zuerst".
|
||||
|
||||
### Phase 2 — SIA-Räume (kein Netz, reine Logik) ⭐
|
||||
**Endpunkte:** keine. **Libs:** keine (Shoelace selbst), optional `exceljs`/`sheetjs` für
|
||||
.xlsx.
|
||||
1. `Space`-Modell: `{ boundary[], geschossId, name, nummer, funktion, siaClass∈{hnf,nnf,vf,ff,gf,agf}, personen }`.
|
||||
2. `computeArea` (Shoelace) + Umfang; Rundungsstufen (`exakt|0.01|0.1|0.5|1`) wie DOSSIER `_format_area`.
|
||||
3. `computeSiaBilanz(scope)` → `{hnf,nnf,nf,vf,ff,ngf,gf,agf,count,personen}` mit
|
||||
`nf=hnf+nnf`, `ngf=nf+vf+ff`.
|
||||
4. SIA-Farbpalette + Stil-Override (Outline-Farbe/Solid-Fill) in der Overrides-Engine.
|
||||
5. Raumstempel-Renderer (Felder-Layout) + **CSV-Export** (Semikolon, UTF-8-BOM, Komma)
|
||||
für Raumliste **und** Bilanz.
|
||||
*Ergebnis:* SIA-416-Flächennachweis + Raumschema, Excel-kompatibel — vor jeder Geo-Arbeit nutzbar.
|
||||
|
||||
### Phase 4a — Geo-Grundlage: Koordinaten + Standortabfrage
|
||||
**Endpunkte:** `SearchServer` (Geocoding), `height` (Punkt-Z), `identify`
|
||||
(Parzelle, `cadastralwebmap-farbe`). **Libs:** `proj4@2.20.9` (+ `@types/proj4`).
|
||||
1. `proj4`-EPSG:2056-Def + Helfer `lv95↔wgs84`, `bboxLv95→bboxWgs84` (DOSSIER-Logik).
|
||||
2. **Origin-Shift**-Mechanik + Persistenz am Projekt (`shift = bbox-Center`), Auto-Zoom.
|
||||
3. Adresssuche → Zentrum; „Gelände-Höhe holen" (height); „Parzelle holen" → Polygon auf
|
||||
Ebene `01 Vermessung` (+ EGRID/Nummer am Projekt).
|
||||
*Ergebnis:* Projekt ist georeferenziert; Parzelle + Adresse + Geländehöhe vorhanden.
|
||||
|
||||
### Phase 4b — Gelände-Mesh + Orthofoto
|
||||
**Endpunkte:** STAC `swissalti3d` (COG `.tif`, 2056) + `profile.json`; WMTS `swissimage`.
|
||||
**Libs:** `geotiff@3.0.5`, `three@0.185.0`, optional `fflate` (XYZ-Fallback),
|
||||
`d3-contour`/`marchingsquares` (Höhenlinien), `delaunator` (TIN).
|
||||
1. STAC-Query (bbox) → COG-Tiles; `geotiff.js` Range-Read → Grid; Tiles mergen.
|
||||
2. Grid → `THREE.BufferGeometry` (DOSSIER `mesh_from_grid`/`merge_grids`); Sub-Sampling
|
||||
auf globalem LV95-Raster; Normalen.
|
||||
3. Optional: Höhenlinien (2D-Plan), TIN, geschlossenes Volumen (Schnitt-Füllung).
|
||||
4. WMTS-`swissimage`-Kacheln → Textur auf Mesh/Plane (DOSSIER `add_ortho_plane`).
|
||||
5. Geländeschnitt im 2D-Plan via `profile.json` entlang der Schnittlinie.
|
||||
*Ergebnis:* echtes Gelände mit Orthofoto unter dem Gebäude; Geländeschnitte.
|
||||
|
||||
### Phase 4c — Nachbargebäude + weltweiter Fallback
|
||||
**Endpunkte:** 3D-Tiles `ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` (Anzeige)
|
||||
**oder** STAC `swissbuildings3d_2` `.obj/.ifc` (Import); OSM `overpass-api.de`.
|
||||
**Libs:** `@loaders.gl/3d-tiles@4.4.3` **oder** `3DTilesRendererJS`; `three`-`OBJLoader`;
|
||||
`web-ifc` (für 3.0-IFC); `proj4`.
|
||||
1. Kontext-Anzeige: 3D-Tiles in den Three-Scenegraph streamen (kein Download).
|
||||
2. Bedarf an echter Geometrie (Verschattung/Abstand): STAC-OBJ-Tile → Mesh-Import → `−shift`.
|
||||
3. Ausserhalb CH / nur 2D: Overpass-POST → Ways → Polylinien (Ebene `70 OSM`).
|
||||
*Ergebnis:* Nachbarschaftskontext (CH 3D, weltweit OSM).
|
||||
|
||||
### Querschnitt (alle Geo-Phasen)
|
||||
- **Caching:** IndexedDB für STAC-Antworten + COG-Bytes + Kacheln (ersetzt DOSSIERs
|
||||
Disk-Cache); Cache-Schlüssel = Tile-ID/URL.
|
||||
- **Defensive HTTP:** Timeouts, Retry/Backoff, 402/429 abfangen, Größen-Limit pro Tile
|
||||
(DOSSIER: 200-MB-Guard).
|
||||
- **Attribution:** „© swisstopo" sichtbar einblenden, „© OpenStreetMap-Mitwirkende" bei OSM.
|
||||
- **Worker:** GeoTIFF-Parsing + Mesh-Bau im Web-Worker (Comlink, ROADMAP-Stack), UI bleibt flüssig.
|
||||
|
||||
### Empfohlener Library-Satz (npm, aktuell)
|
||||
`proj4@2.20.9` · `geotiff@3.0.5` · `three@0.185.0` · `fflate` (XYZ) ·
|
||||
`@loaders.gl/3d-tiles@4.4.3` *oder* `3DTilesRendererJS` · `delaunator` ·
|
||||
`d3-contour` · `web-ifc` (im Stack) · `exceljs`/`sheetjs` (optional, .xlsx).
|
||||
|
||||
---
|
||||
|
||||
## Quellen
|
||||
|
||||
- GeoAdmin REST (height/profile/identify/find/search): https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html
|
||||
- GeoAdmin Tech-Docs (Hub): https://docs.geo.admin.ch/
|
||||
- Identify Features: https://docs.geo.admin.ch/access-data/identify-features.html
|
||||
- Search: https://docs.geo.admin.ch/access-data/search.html
|
||||
- WMTS: https://docs.geo.admin.ch/visualize-data/wmts.html · https://wmts.geo.admin.ch/
|
||||
- WMS: https://docs.geo.admin.ch/visualize-data/wms.html
|
||||
- 3D-Tiles: https://docs.geo.admin.ch/visualize-data/3d-tiles.html
|
||||
- swissALTI3D: https://www.swisstopo.admin.ch/en/height-model-swissalti3d
|
||||
- Switzerland in 3D / swissBUILDINGS3D: https://www.swisstopo.admin.ch/en/switzerland-in-3d
|
||||
- Terms of use (FSDI / OGD): https://www.geo.admin.ch/en/general-terms-of-use-fsdi
|
||||
- REFRAME Web-API: https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web
|
||||
- EPSG:2056 Definition: https://epsg.io/2056
|
||||
- proj4js: https://github.com/proj4js/proj4js
|
||||
- geotiff.js: https://github.com/geotiffjs/geotiff.js/
|
||||
- Three.js-Terrain aus GeoTIFF: https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/
|
||||
- WMTS EPSG:2056 Beispiel: https://codepen.io/geoadmin/pen/GZKEam
|
||||
- Overpass API: https://overpass-api.de/
|
||||
- SIA 416 (Übersicht): https://diebauherrenvertretung.ch/sia146/ · https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product
|
||||
- SIA 416 Flächenkennzahlen (PDF): https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf
|
||||
- SIA 421:2006 (PDF) + Korrigenda C1:2014: https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang · https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf
|
||||
- DOSSIER-Quellcode (Vorlage): `rhino/swisstopo.py`, `rhino/osm.py`, `rhino/elemente.py` (Räume/SIA), `rhino/elemente_uebersicht.py` (Bilanz/CSV)
|
||||
@@ -0,0 +1,197 @@
|
||||
# Technologie-Auswahl: Browser-basiertes BIM-Tool (DOSSIER-Port)
|
||||
|
||||
> **Kontext:** Standalone, browser-basiertes BIM-Werkzeug (React + TypeScript + Three.js) als Port des DOSSIER Rhino-Plugins. Keine Server-Abhaengigkeit gewuenscht (alles client-side). Recherchestand: Juni 2026.
|
||||
>
|
||||
> **Leitprinzip:** Wo immer moeglich auf einem echten B-Rep-Geometriekernel (OCCT) aufbauen, weil ein BIM-Werkzeug exakte 2D-Ableitungen (Schnitte, verdeckte Kanten, Bemassung) braucht — das ist mit reinen Dreiecksnetzen nicht sauber loesbar. Mesh-Booleans (Manifold) als schnelle Ergaenzung fuer Importgeometrie und Vorschau.
|
||||
|
||||
---
|
||||
|
||||
## 1. Geometriekernel im Browser (Solids + Booleans)
|
||||
|
||||
Das ist das schwierigste und zugleich wichtigste Problem. Drei ernsthafte Optionen, die alle im Browser (WASM) laufen.
|
||||
|
||||
### Optionen
|
||||
|
||||
**A) opencascade.js (OCCT als WASM)**
|
||||
Port des vollstaendigen OpenCASCADE-Kernels (OCCT) nach WebAssembly via Emscripten. Voller B-Rep-Kernel: NURBS-Flaechen, exakte boolesche Operationen, Fillets/Chamfers, STEP/IGES-Import/-Export, Meshing. TypeScript-Bindings vorhanden. Die neueren Versionen (V3-Linie) zielen explizit auf moderne Bundler.
|
||||
- Repo: <https://github.com/donalffons/opencascade.js/>
|
||||
- Doku: <https://opencascade-js.vercel.app/>
|
||||
- npm: <https://www.npmjs.com/package/opencascade.js>
|
||||
- **Lizenz:** OCCT steht unter **LGPL-2.1 mit OCCT-Exception** (seit 6.7.0). Kommerzielle Nutzung ohne Lizenzgebuehren/Royalties erlaubt, sofern man (a) sichtbar darauf hinweist, dass die Software OCCT nutzt, und (b) eine Kopie der OCCT-Lizenz mitliefert. Die Exception entschaerft das statische-Linking-Problem fuer Header/Templates. Quellen: <https://dev.opencascade.org/resources/licensing>, <https://spdx.org/licenses/OCCT-exception-1.0.html>
|
||||
- **Trade-offs:** Sehr grosse WASM-Binaries (zweistelliger MB-Bereich je nach Custom-Build), steile Lern- und API-Kurve (rohe OCCT-C++-API durchgereicht), Build-Pflege aufwendig.
|
||||
|
||||
**B) replicad (Abstraktion ueber opencascade.js)** — *empfohlene Basis*
|
||||
replicad ist eine schlanke, idiomatische TypeScript-Schicht ueber opencascade.js. Es liefert genau die High-Level-Bausteine, die ein BIM-Tool braucht: Sketches/Blueprints, Extrude/Revolve/Loft, Booleans, Fillet/Chamfer — und entscheidend: **HLR-Projektionen** (`drawProjection`) und 2D-Drawings mit SVG-Export. Laeuft per Design im **Web Worker** und gibt Dreiecksnetze an den Main-Thread fuer Three.js zurueck.
|
||||
- Doku/Library-Guide: <https://replicad.xyz/docs/use-as-a-library/>
|
||||
- API: <https://replicad.xyz/docs/api/>
|
||||
- **Lizenz:** **MIT** (eigene Schicht) — der OCCT/LGPL-Hinweis gilt weiterhin fuer das eingebettete WASM. Repo: <https://github.com/sgenoud/replicad>
|
||||
- **Trade-offs:** Erbt OCCT-WASM-Groesse und -Robustheitsgrenzen; kleineres Team/Bus-Faktor als OCCT selbst. Man kann jederzeit „unter die Haube" auf rohes opencascade.js durchgreifen, wenn die Abstraktion nicht ausreicht.
|
||||
|
||||
**C) Manifold (manifold-3d, WASM)** — *empfohlen als schnelle Mesh-Ergaenzung*
|
||||
Geometrie-Bibliothek fuer topologisch robuste **Dreiecksnetze**. Bietet den (laut Autor) ersten garantiert mannigfaltigen Mesh-Boolean-Algorithmus — extrem schnell und robust gegen Randfaelle. Stark parallelisiert.
|
||||
- Repo: <https://github.com/elalish/manifold>
|
||||
- npm: <https://www.npmjs.com/package/manifold-3d> (aktuell v3.5.x, Juni 2026)
|
||||
- **Lizenz:** **Apache-2.0** (sehr permissiv, ideal). Bestaetigt: <https://github.com/elalish/manifold>
|
||||
- **Trade-offs:** **Nur Meshes, kein B-Rep** — keine exakten NURBS-Flaechen, keine echten Fillets auf Krümmungen, kein STEP. Fuer ein BIM-Tool, das exakte Plaene/Schnitte ableiten will, alleine nicht ausreichend, aber unschlagbar fuer schnelle Booleans auf importierter Mesh-Geometrie und Live-Vorschau. **Wichtig:** unterstuetzt `slice(z)` und `project()` (siehe Abschnitt 3).
|
||||
|
||||
**D) three-bvh-csg (nur erwähnt, nicht empfohlen als Kernel)**
|
||||
Sehr schnelle CSG direkt auf Three.js-BufferGeometry (auf three-mesh-bvh). >100x schneller als BSP-basierte Three.js-CSG-Libs. Aber: erklaert selbst, dass Resultate „aufgrund numerischer Praezision nicht garantiert 2-mannigfaltig" sind und verweist fuer CAD-Robustheit ausdruecklich auf Manifold.
|
||||
- Repo: <https://github.com/gkjohnson/three-bvh-csg>
|
||||
- Forum: <https://discourse.threejs.org/t/three-bvh-csg-a-library-for-performing-fast-csg-operations/42713>
|
||||
- **Trade-offs:** Gut fuer Live-Vorschau/visuelles Schneiden, ungeeignet als verlaesslicher Modellierkernel.
|
||||
|
||||
### Empfehlung (Kernel)
|
||||
**replicad (= opencascade.js) als primaerer B-Rep-Kernel im Web Worker; Manifold als schneller Mesh-Boolean-Pfad fuer Import-/Vorschaugeometrie.** Diese Zweiteilung deckt sowohl „exakte BIM-Geometrie + 2D-Ableitung" (OCCT) als auch „schnell + robust auf beliebigen Meshes" (Manifold) ab. three-bvh-csg nur, falls man interaktives Echtzeit-Schneiden visuell braucht.
|
||||
|
||||
---
|
||||
|
||||
## 2. 2D-Ableitung aus 3D: verdeckte Kanten (HLR) + Schnittgenerierung
|
||||
|
||||
Kernfrage des DOSSIER-Ports: aus 3D-Solids saubere 2D-Zeichnungen (sichtbare/verdeckte Kanten, Schnitte) erzeugen — im Browser.
|
||||
|
||||
### Optionen
|
||||
|
||||
**A) OCC HLRBRep via replicad `drawProjection` — empfohlen**
|
||||
OCCT enthaelt zwei HLR-Algorithmen: `HLRBRep_Algo` (exakt, auf der echten B-Rep) und `HLRBRep_PolyAlgo` (auf polyederisierter Naeherung, schneller, aber polygonal). Quellen: <https://dev.opencascade.org/doc/refman/html/class_h_l_r_b_rep.html>, <https://dev.opencascade.org/doc/occt-7.7.0/refman/html/class_h_l_r_b_rep___poly_algo.html>
|
||||
|
||||
replicad macht genau das im Browser nutzbar: `drawProjection(shape, camera)` liefert ein Objekt mit **`{ visible, hidden }`** — getrennte sichtbare und verdeckte Kantenzuege, die man unterschiedlich stylen kann (z.B. verdeckt = gestrichelt). Konkretes Beispiel aus der Doku:
|
||||
|
||||
```js
|
||||
const { drawProjection, ProjectionCamera } = replicad;
|
||||
const camera = new ProjectionCamera(corner).lookAt(center);
|
||||
const { visible, hidden } = drawProjection(shape, camera);
|
||||
// visible/hidden sind Drawings -> .toSVG()
|
||||
```
|
||||
- Beispiel: <https://replicad.xyz/docs/examples/projections/>
|
||||
- Verwandte API: `makeProjectedEdges`, `ProjectionCamera`, `Drawing.toSVG()` / `toSVGPaths()` (<https://replicad.xyz/docs/api/classes/Drawing/>)
|
||||
- **Das ist der entscheidende Grund, replicad/OCCT zu nehmen:** exakte verdeckte-Kanten-Berechnung auf echtem B-Rep ist mit Mesh-Tools nicht serioes machbar.
|
||||
- **Trade-offs:** HLR ist rechenintensiv (deshalb Worker + Caching pro Ansicht/Kamera); `HLRBRep_Algo` exakt aber langsam, `PolyAlgo` schneller aber genaehert.
|
||||
|
||||
**B) Schnitte (Sections) via OCCT**
|
||||
Echte Schnitte ueber Schnitt mit einer Ebene/Halbraum (`BRepAlgoAPI_Section` bzw. Boolean mit Schnittkoerper) ergeben exakte Schnittkanten als B-Rep-Edges, die wiederum nach SVG/DXF gehen. In replicad ueber Booleans + Projektion abbildbar.
|
||||
|
||||
**C) Manifold `slice()` / `project()` — schnelle Mesh-Variante**
|
||||
`Manifold.slice(z)` gibt den Querschnitt parallel zur X-Y-Ebene auf Hoehe `z` als `CrossSection` (2D-Polygone, intern Clipper2); `Manifold.project()` die projizierte Aussenkontur. Quellen: <https://manifoldcad.org/docs/jsapi/>, <https://manifoldcad.org/docs/html/classmanifold_1_1_cross_section.html>
|
||||
- **Trade-offs:** liefert **keine** verdeckte/sichtbare-Kanten-Trennung und keine Innenkanten-Semantik wie HLR — nur die geometrische Schnitt-/Projektionskontur des Meshes. Gut fuer Plan-Schnittkonturen (siehe Abschnitt 3), ungenuegend fuer vollwertige Ansichts-Zeichnungen mit verdeckten Kanten.
|
||||
|
||||
### Empfehlung (2D-Ableitung)
|
||||
**HLR und Ansichts-Zeichnungen ueber replicad `drawProjection` (OCC HLRBRep) im Worker, mit Caching pro Kamera/Ansicht. Echte Schnitte ueber OCCT-Section-Boolean. Manifold `slice()` als schneller Pfad nur fuer reine Schnittkonturen (z.B. Plan-Cut auf Mesh-Importen).**
|
||||
|
||||
---
|
||||
|
||||
## 3. Plan-Ansicht: Clipping bei `cutHeight`
|
||||
|
||||
Verhalten: horizontaler Schnitt auf einstellbarer Hoehe (Grundriss), darüber Abschneiden, Schnittflaechen markieren.
|
||||
|
||||
### Optionen
|
||||
|
||||
**A) Three.js Clipping Planes (`clippingPlanes` / `localClippingEnabled`)**
|
||||
Three.js bietet globale (`renderer.clippingPlanes`) und material-lokale (`material.clippingPlanes`) Schnittebenen; `renderer.localClippingEnabled` ist standardmaessig aus (Null-Kosten, bis aktiviert). Quellen: <https://threejs.org/docs/#api/en/materials/Material.clippingPlanes>, <https://threejs.org/examples/webgl_clipping.html>
|
||||
- **Pro:** GPU-seitig, dynamisch (Slider auf `cutHeight` = `plane.constant` aendern, kein Geometrie-Rebuild), sehr fluessig.
|
||||
- **Contra:** Clipping schneidet nur visuell — es entstehen **offene** Querschnitte (keine Deckflaeche). Fuer „Schnittflaeche fuellen/markieren" braucht man entweder einen Stencil-Cap-Trick oder eine echte Schnittkontur (Abschnitt 2). `clipIntersection = true` kann Material-Reinitialisierung pro Frame und FPS-Einbrueche verursachen — moeglichst vermeiden. Quelle: <https://github.com/mrdoob/three.js/issues/18675>
|
||||
|
||||
**B) Object Culling / Sichtbarkeit nach Hoehe**
|
||||
Elemente oberhalb `cutHeight` per Bounding-Box/Etagen-Metadaten ausblenden (`object.visible = false`).
|
||||
- **Pro:** trivial, keine Shader-Kosten, nutzt BIM-Etagensemantik.
|
||||
- **Contra:** grobkoernig (ganze Objekte, kein praeziser Schnitt mitten durch ein Bauteil).
|
||||
|
||||
**C) Echte Schnittgeometrie pro Etage (OCCT/Manifold) fuer den 2D-Plan**
|
||||
Fuer die exportierbare 2D-Grundriss-Zeichnung den echten Schnitt auf `cutHeight` rechnen (OCCT-Section bzw. `Manifold.slice(z)`), Schnittflaechen schraffieren (Abschnitt 6).
|
||||
|
||||
### Empfehlung (Plan-Clipping)
|
||||
**Hybrid:** Im 3D-Viewport **Three.js Clipping Planes** fuer das interaktive Abschneiden (Slider direkt auf `plane.constant`), kombiniert mit **Object-Culling** ueber Etagen-Metadaten fuer Grob-Performance. Schnittflaechen-Caps via Stencil-Technik. Fuer die **exportierbare** 2D-Grundriss-Zeichnung die **echte** Schnittkontur ueber OCCT/Manifold berechnen statt nur GPU-Clipping. `clipIntersection` meiden.
|
||||
|
||||
---
|
||||
|
||||
## 4. Import: DWG/DXF, IFC, STL, OBJ, XYZ-Punktwolken
|
||||
|
||||
### DXF / DWG
|
||||
- **DXF lesen:** `dxf-parser` (gdsestimating) — robust, weit verbreitet, parst DXF-Strings zu JS-Objekten. Repo: <https://github.com/gdsestimating/dxf-parser>. Zum reinen 2D-Anzeigen: `dxf-viewer` (vagran). <https://github.com/vagran/dxf-viewer>
|
||||
- **DWG lesen (binaer!):** `@mlightcad/libredwg-web` — LibreDWG nach WASM, parst **DWG** (und DXF) direkt im Browser/Node ohne Backend. Aktuell v3.x. Repo: <https://github.com/mlightcad/libredwg-web>, npm: <https://www.npmjs.com/package/@mlightcad/libredwg-web>
|
||||
- **Achtung Qualitaet/Limits:** DWG-Parsing ist speicherintensiv (kann >2 GB RAM ziehen); libredwg-web erzwingt WASM-Heap-Limits, sehr grosse DWGs koennen scheitern. Im Default-Build sind DXF-Parsing und DWG-Schreiben deaktiviert, um die WASM-Groesse zu senken — DXF also lieber mit `dxf-parser` lesen. Quelle: <https://medium.com/@mlightcad/parsing-autocad-dwg-files-in-the-browser-without-relying-on-the-backend-9067c5d9abf0>
|
||||
- **Lizenz:** LibreDWG ist **GPL-3.0** — das ist fuer ein proprietaeres Produkt heikel. Wenn das Produkt nicht GPL sein soll, DWG-Import entweder ueber einen separaten Out-of-Process-Konverter kapseln oder Nutzer bitten, vorab nach DXF zu exportieren. **DWG-Lizenzfrage vor Integration klaeren.**
|
||||
- **Referenz-Implementierung:** `cad-viewer` (mlightcad) zeigt vollstaendigen browser-only DXF/DWG-Viewer/Editor. <https://github.com/mlightcad/cad-viewer>
|
||||
|
||||
### IFC (BIM-Kern)
|
||||
- **web-ifc (ThatOpen/engine_web-ifc):** IFC lesen/schreiben in JS „at native speeds" via WASM. De-facto-Standard fuer Open-BIM im Browser. Repo: <https://github.com/ThatOpen/engine_web-ifc>, Doku: <https://thatopen.github.io/engine_web-ifc/docs/>. **Lizenz: MPL-2.0** (datei-basiertes Copyleft, fuer proprietaere Apps i.d.R. unkritisch). Quelle: <https://spdx.org/licenses/MPL-2.0.html>
|
||||
- **ThatOpen Components + Fragments:** Hoehere Ebene — `components` (Tools für BIM-Apps), `fragments` (kompaktes Binaerformat auf Google FlatBuffers). Typisch: ~100 MB IFC -> ~10 MB Fragments, >10x schnelleres Laden; Konvertierung lauft worker-basiert. Doku: <https://docs.thatopen.com/Tutorials/Fragments/Fragments/IfcImporter/>, Repo: <https://github.com/ThatOpen/engine_fragment>. IfcImporter setzt web-ifc (>=0.0.72) voraus.
|
||||
- **Empfehlung:** IFC einmal mit web-ifc parsen, in **Fragments** cachen, danach aus Fragments laden.
|
||||
|
||||
### STL / OBJ / XYZ-Punktwolken
|
||||
- **Standard-Three.js-Loader** decken alles ab: `STLLoader` (ASCII+Binaer), `OBJLoader`, `XYZLoader` (XYZ/XYZRGB -> BufferGeometry), `PCDLoader`. Quellen: <https://threejs.org/docs/#examples/en/loaders/PCDLoader>, <https://deepwiki.com/mrdoob/three.js/4.2-model-format-loaders>. Three.js ist **MIT**.
|
||||
- **Punktwolken-Performance:** Naive Darstellung skaliert nicht — ~17 Mio. Punkte ruckeln deutlich. Strategien: Downsampling, **LOD/Culling**, Hintergrund-/Streaming-Laden. Fuer sehr grosse Wolken Out-of-Core-Octree-Renderer (z.B. Potree-Ansatz) erwaegen statt eines einzelnen `Points`-Objekts. Quellen: <https://discourse.threejs.org/t/render-large-point-cloud-data-in-threejs/57331>, <https://discourse.threejs.org/t/performance-issues-rendering-large-ply-point-cloud-in-three-js-downsampling-and-background-loading/69135>
|
||||
|
||||
### Empfehlung (Import)
|
||||
**IFC: web-ifc + Fragments (ThatOpen).** **DXF: dxf-parser.** **DWG: libredwg-web — aber GPL-Lizenz vorab klaeren / kapseln.** **STL/OBJ/XYZ/PCD: native Three.js-Loader, mit LOD/Downsampling fuer grosse Punktwolken (Potree-Pattern bei Bedarf).**
|
||||
|
||||
---
|
||||
|
||||
## 5. Vektor-Export: SVG -> PDF (Print) und DXF
|
||||
|
||||
### SVG -> PDF
|
||||
- **svg2pdf.js (yWorks) + jsPDF — empfohlen.** Reine JS-Loesung, laeuft im Browser, erhaelt **echte Vektoren** (kein Rasterisieren via html2canvas!), was fuer druckfaehige Plaene entscheidend ist. Integriert sich ueber `doc.svg(element, ...)`. Kompatibel mit jsPDF v2/v3/v4. Repo: <https://github.com/yWorks/svg2pdf.js/>. Lizenz: svg2pdf.js **MIT**, jsPDF **MIT**.
|
||||
- **Trade-off:** SVG-Feature-Abdeckung ist sehr gut, aber nicht 100% — exotische Filter/Pattern koennen abweichen; Schraffuren als explizite Linien (statt CSS-Filter) exportieren erhoeht Treffsicherheit.
|
||||
- **Alternative/ergaenzend:** `pdf-lib` (MIT) fuer Seitenmontage, Mehrseitigkeit, Metadaten, Zusammenfuehren — kann mit jsPDF-Output kombiniert werden. (`html2canvas`+jsPDF bewusst **vermeiden**, da Raster statt Vektor.)
|
||||
|
||||
### DXF-Export
|
||||
- **@tarikjabiri/dxf (dxfjs/writer) — empfohlen.** Moderner, in TypeScript geschriebener DXF-Generator fuer Node + Browser. Unterstuetzt u.a. **Blocks, Hatches, Insert, Image** — d.h. Schraffuren lassen sich als echte DXF-Hatches exportieren (wichtig fuer CAD-Weiterverarbeitung). npm: <https://www.npmjs.com/package/@tarikjabiri/dxf>, Doku: <https://dxf.vercel.app/>, Repo: <https://github.com/tarikjabiri/js-dxf>. Lizenz: MIT.
|
||||
- Einfachere Alternative: `dxf-writer` (Vorlaeufer, weniger Features).
|
||||
|
||||
### Empfehlung (Export)
|
||||
**SVG -> PDF: svg2pdf.js + jsPDF (Vektor, nicht Raster), optional pdf-lib fuer Seitenmontage. DXF-Export: @tarikjabiri/dxf mit echten Hatch-Entities.** Interner Zwischenschritt: 2D-Geometrie als SVG-Paths halten (replicad `Drawing.toSVGPaths()`), daraus sowohl PDF als auch DXF erzeugen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Schraffuren / Muster in SVG/Canvas bei Massstab
|
||||
|
||||
### Optionen & Erkenntnisse
|
||||
- **SVG `<pattern>` mit `patternUnits="userSpaceOnUse"`** ist der richtige Weg fuer massstaebliche Schraffuren: das Muster skaliert NICHT mit der Form (im Gegensatz zu `objectBoundingBox`), sondern bleibt im Weltkoordinaten-Raster — exakt, was man fuer „mm pro Linie im Plan" braucht. Dichte/Winkel ueber `patternTransform`. Quellen: <https://www.w3.org/TR/2015/WD-SVG2-20150915/pservers.html>, <https://www.codegenes.net/blog/simple-fill-pattern-in-svg-diagonal-hatching/>
|
||||
- **Performance:** Pattern-Layer werden bei jedem Layout-/Zoom-Schritt neu gerechnet; `userSpaceOnUse` vermeidet teures Rescaling und ist hier zugleich der schnellere und der korrekte Weg. Quelle: <https://oreillymedia.github.io/Using_SVG/extras/ch19-performance.html>
|
||||
- **SVG vs. Canvas:** Fuer technische Zeichnungen ist SVG qualitativ klar ueberlegen (Vektor, exporttauglich nach PDF/DXF). Canvas wird erst bei sehr vielen einfachen Elementen schneller. Quellen: <https://felt.com/blog/from-svg-to-canvas-part-1-making-felt-faster>, <https://www.yworks.com/blog/svg-canvas-webgl>
|
||||
- **Skalierungs-Strategie:** Bei sehr dichten Schraffuren ueber grosse Flaechen kann die Linienzahl explodieren -> entweder als Pattern-Kachel rendern (eine Definition, vielfach referenziert, statt tausende Einzellinien) **oder** beim Export Schraffuren in echte Linien/Hatch-Entities aufloesen (DXF-Hatch, Abschnitt 5).
|
||||
|
||||
### Empfehlung (Schraffuren)
|
||||
**SVG `<pattern>` mit `patternUnits="userSpaceOnUse"` als primaerer Renderpfad** (massstabskorrekt, exportierbar, performant durch Kachel-Referenzierung). Bei extrem grossen/dichten Flaechen Canvas-Overlay nur fuer die reine Bildschirm-Vorschau erwaegen; fuer Export immer SVG -> svg2pdf.js bzw. echte DXF-Hatches.
|
||||
|
||||
---
|
||||
|
||||
## 7. WebGPU vs. WebGL + Worker-Offloading der WASM-Geometrie
|
||||
|
||||
### Rendering: WebGPU vs. WebGL
|
||||
- **Reifegrad:** Seit Three.js r171 (Sept. 2025) ist der **WebGPURenderer produktionsreif** mit `import * as THREE from 'three/webgpu'` und **automatischem WebGL2-Fallback** — kein eigener Fallback-Code noetig. Quelle: <https://www.utsubo.com/blog/webgpu-threejs-migration-guide>
|
||||
- **Browser-Abdeckung:** Chrome/Edge 113 (Mai 2023), Safari 26.0 (Sept. 2025), Firefox 141 (Juli 2025). ~95% der Nutzer WebGPU-faehig, restliche ~5% bekommen WebGL2-Fallback. Quelle: <https://vr.org/articles/webgpu-baseline-2026-three-js-webxr-default>
|
||||
- **Performance (nuanciert):** Bei draw-call-lastigen Szenen (viele Bauteile/Etagen) gewinnt WebGPU deutlich (bei ~10'000 Draw-Calls ~50 FPS WebGPU vs. ~30 FPS WebGL); bei wenigen grossen Meshes kann WebGL noch gleichauf oder schneller sein. Compute-Shader (Punktwolken, Culling) sind ein WebGPU-Alleinstellungsmerkmal. Quellen: <https://medium.com/@sudenurcevik/upgrading-performance-moving-from-webgl-to-webgpu-in-three-js-4356e84e4702>, <https://altersquare.io/three-js-vs-webgpu-2026-large-scale-construction-viewers/>
|
||||
- **Vorsicht:** Es gibt weiterhin Szenarien, in denen WebGPU langsamer ist als WebGL — daher messen, nicht blind migrieren. Quelle: <https://github.com/mrdoob/three.js/issues/31055>
|
||||
|
||||
### Worker-Offloading der WASM-Geometrie
|
||||
- **Pflicht, nicht optional:** OCCT/replicad-Berechnungen (Booleans, HLR, Section) gehoeren in einen **Web Worker**, sonst blockiert die UI. replicad ist genau dafuer gebaut (WASM im Worker, Mesh zurueck an den Main-Thread). Quelle: <https://replicad.xyz/docs/use-as-a-library/>
|
||||
- **Muster:** Worker laedt das (grosse) WASM einmal; Kommunikation via Comlink o.ae.; Geometrie als Transferable (ArrayBuffer) zuruecksenden, um Kopierkosten zu sparen. Manifold (WASM) ebenso im Worker betreiben; Manifold ist intern stark parallelisiert.
|
||||
|
||||
### Empfehlung (Performance)
|
||||
**Three.js `three/webgpu`-Renderer mit automatischem WebGL2-Fallback** (gratis Abwaertskompatibilitaet, Vorteil bei vielen Draw-Calls/Etagen). **Alle WASM-Geometrie (OCCT/replicad + Manifold) konsequent in Web Worker(n)**, Ergebnis als Transferables. WebGPU-Compute fuer Punktwolken-/Culling-Beschleunigung als spaeteres Optimierungs-Upside. Vor groesserer WebGPU-Optimierung mit der echten Szene benchmarken.
|
||||
|
||||
---
|
||||
|
||||
## Empfehlung (Zusammenfassung)
|
||||
|
||||
| Thema | Wahl | Warum |
|
||||
|---|---|---|
|
||||
| **Geometriekernel (Solids/Booleans)** | **replicad** (= opencascade.js/OCCT) im Worker; **Manifold** als Mesh-Boolean-Ergaenzung | Echter B-Rep-Kernel noetig fuer exakte BIM-Geometrie + 2D-Ableitung; Manifold (Apache-2.0) schnell+robust fuer Mesh-Importe/Vorschau |
|
||||
| **2D-Ableitung (HLR/Schnitt)** | **replicad `drawProjection`** (OCC HLRBRep, liefert `{visible, hidden}`); OCCT-Section fuer Schnitte | Einziger seriöser Weg fuer verdeckte/sichtbare Kanten auf echtem B-Rep im Browser; Manifold `slice()` nur fuer reine Konturen |
|
||||
| **Plan-Clipping (`cutHeight`)** | **Three.js Clipping Planes** + **Object-Culling** (Etagen); echte Schnittkontur (OCCT/Manifold `slice`) fuer Export | GPU-Clipping fluessig & dynamisch fuer Viewport; Culling fuer Grob-Performance; exakte Kontur nur fuer druckbaren Plan |
|
||||
| **Import IFC** | **web-ifc + Fragments** (ThatOpen) | De-facto Open-BIM-Standard, native Speed, ~10x kleineres/schnelleres Fragments-Caching; MPL-2.0 |
|
||||
| **Import DXF / DWG** | **dxf-parser** (DXF) / **libredwg-web** (DWG) | Bewaehrt & browser-only; **DWG = GPL-3.0 -> Lizenz vorab klaeren/kapseln**, RAM-Limits bei grossen Dateien |
|
||||
| **Import STL/OBJ/XYZ/PCD** | **Native Three.js-Loader** + LOD/Downsampling | Out of the box (MIT); grosse Punktwolken brauchen Octree/Potree-Pattern |
|
||||
| **SVG -> PDF (Print)** | **svg2pdf.js + jsPDF** (+ pdf-lib optional) | Echte Vektoren statt Raster -> druckfaehig; MIT-Lizenzen |
|
||||
| **DXF-Export** | **@tarikjabiri/dxf** | TS, Browser-faehig, echte Hatch-/Block-Entities fuer CAD-Weiterverarbeitung; MIT |
|
||||
| **Schraffuren/Muster** | **SVG `<pattern>` mit `userSpaceOnUse`** | Massstabskorrekt (skaliert nicht mit Form), exportierbar, performant via Kachel-Referenz |
|
||||
| **Rendering** | **Three.js `three/webgpu`** mit WebGL2-Fallback | Produktionsreif seit r171, ~95% Abdeckung, Vorteil bei vielen Draw-Calls; gratis Fallback |
|
||||
| **WASM-Offloading** | **Web Worker** fuer OCCT/replicad + Manifold, Transferables | UI bleibt reaktiv; replicad ist dafuer gebaut; spart Kopierkosten |
|
||||
|
||||
---
|
||||
|
||||
### Wichtigste Risiken / offene Punkte
|
||||
1. **DWG-Lizenz (LibreDWG = GPL-3.0):** Vor Integration klaeren — sonst proprietaeres Produkt gefaehrdet. Option: separater Konvertierungs-Service oder DXF-Pflicht beim Import.
|
||||
2. **OCCT-WASM-Groesse & Build-Pflege:** zweistellige MB; Custom-Build/Tree-Shaking und Lazy-Loading im Worker einplanen.
|
||||
3. **HLR-Kosten:** `drawProjection` pro Ansicht cachen; ggf. `PolyAlgo` fuer schnelle Vorschau, `HLRBRep_Algo` fuer den finalen Plan.
|
||||
4. **WebGPU nicht blind:** mit echter Szene benchmarken — es gibt Faelle, in denen WebGL noch fuehrt.
|
||||
@@ -0,0 +1,590 @@
|
||||
# UX/UI-Patterns moderner Browser-CAD/BIM-Tools — Research & Leitplanken
|
||||
|
||||
> Recherche für unser Browser-BIM (React + TS + Three.js, DOSSIER-Port, Wohnbau,
|
||||
> CH/SIA-Kontext). Ziel: konkrete, **übernehmbare** Interaktions- und UI-Muster.
|
||||
> Stand: 2026-06-29.
|
||||
>
|
||||
> Untersucht: **Arcol**, **Snaptrude**, **TestFit**, **Onshape** (Browser-CAD),
|
||||
> **Vectorworks** (Resource/Navigation-Modell), **Figma** (Canvas-Interaktion,
|
||||
> Inspector). Querschnitt: Snapping/Inferencing, Grip-Editing, perceived
|
||||
> performance, Command-Palette, Onboarding.
|
||||
>
|
||||
> Jeder externe Claim ist mit Quelle verlinkt. Am Ende:
|
||||
> **„Leitplanken für unsere UI"** — priorisierte Empfehlungen.
|
||||
|
||||
---
|
||||
|
||||
## 0. Kurzfazit (TL;DR)
|
||||
|
||||
Die ganze Klasse moderner Browser-CAD/BIM-Tools konvergiert auf ein **gemeinsames
|
||||
Set von Mustern**, das wir fast 1:1 übernehmen sollten:
|
||||
|
||||
1. **Ein Modell, viele synchrone Sichten** (2D-Plan ⇄ 3D ⇄ Daten/Sheets), Änderung
|
||||
in einer Sicht propagiert sofort in alle anderen. (Arcol, Snaptrude)
|
||||
2. **Kontextuelle UI** statt voller Werkzeugleisten: Buttons/Felder erscheinen nur,
|
||||
wenn die aktuelle Auswahl sie erlaubt. (Arcol, Onshape, Figma)
|
||||
3. **Drei-Zonen-Layout**: links Navigator/Layer-Baum, Mitte Canvas + schwebende
|
||||
Tool-Palette, rechts Inspector. (Figma, Vectorworks, Onshape)
|
||||
4. **Aggressives Snapping/Inferencing** mit Live-Glyphen + Modifier zum
|
||||
Unterdrücken. (Onshape) — für Maus-im-Browser unverzichtbar.
|
||||
5. **Direkte Manipulation per Grips** statt Dialogen. (Figma, DOSSIER-Backlog)
|
||||
6. **Perceived performance** über Skeletons, optimistic UI und gescopte
|
||||
Ladezustände — im Browser-3D-Kontext ein Differenzierungs-Hebel.
|
||||
7. **Command-Palette (Ctrl/Cmd-K)** als Discovery- und Speed-Layer.
|
||||
8. **Onboarding via „learn by doing"** an einem mitgelieferten Sample-Projekt +
|
||||
progressive disclosure.
|
||||
|
||||
Unsere bestehende Architektur (semantisches Modell als Single Source of Truth,
|
||||
abgeleitete Sichten, Vectorworks-Terminologie) ist exakt der richtige Unterbau für
|
||||
diese Muster — die meiste Arbeit liegt in der **UI-Schicht**, nicht im Datenmodell.
|
||||
|
||||
---
|
||||
|
||||
## 1. Gesamtlayout & Navigator-/Layer-Panels
|
||||
|
||||
### 1.1 Was die Tools machen
|
||||
|
||||
**Figma** strukturiert die Fläche in vier Zonen: eine Toolbar, **zwei Panels** und
|
||||
einen scrollbaren Canvas. Links das **Navigation-Panel** mit Layern und Pages,
|
||||
rechts das **Properties-Panel**; der Layer-Baum „enthält und organisiert alle
|
||||
Elemente auf dem Canvas … und zeigt, wie Elemente verbunden sind"
|
||||
([Figma: left sidebar](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar),
|
||||
[Figma: Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface)).
|
||||
|
||||
**Vectorworks** trennt sauber zwei Konzepte, die für uns 1:1 relevant sind:
|
||||
- Die **Navigation Palette** gibt Zugriff auf *Classes, Design Layers, Sheet
|
||||
Layers, Viewports, Saved Views, References* — jeweils als eigener Tab mit Liste.
|
||||
Sichtbarkeit wird per Klick in einer **Visibility-Spalte** gesetzt; Doppelklick
|
||||
**aktiviert** einen Layer/eine Class. Die Zeichenfläche bleibt nutzbar, während
|
||||
die Palette offen ist
|
||||
([VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm)).
|
||||
- Paletten sind **andockbar/ein-/ausblendbar pro Workspace**
|
||||
([VW Palettes & Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm)).
|
||||
|
||||
**Arcol** baut beim Modellieren einen **Model Tree** im Menü auf — pro
|
||||
BIM-Komponente wächst der Baum mit
|
||||
([AEC Magazine: Arcol BIM in browser](https://aecmag.com/bim/arcol-bim-cloud-browser/)).
|
||||
|
||||
**Onshape** zeigt links den **Feature-/Assembly-Baum** (parametrische Historie:
|
||||
Sketches, Features, Mates) — die Bauhistorie ist die primäre Navigation
|
||||
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)).
|
||||
|
||||
### 1.2 Übernahme für uns
|
||||
|
||||
Unser Dokumentmodell hat **zwei unabhängige Achsen** (siehe ROADMAP §2c):
|
||||
**Zeichnungsebenen** (Geschosse + Schnitte/Ansichten) und **Ebenen**
|
||||
(Grafik-Kategorien-Baum `00 Raster … 80 Plangrafik`). Das mappt fast wörtlich auf
|
||||
das Vectorworks-Navigations-Modell:
|
||||
|
||||
- **Linke Sidebar, getabbt** wie die VW-Navigation-Palette:
|
||||
- Tab **„Geschosse / Ansichten"** (= unsere Zeichnungsebenen): EG/1OG/…,
|
||||
Schnitte, Ansichten. Mit **Visibility-Toggle**, **Lock**, und **Doppelklick =
|
||||
aktiv setzen** (welches Geschoss editiert wird).
|
||||
- Tab **„Ebenen"** (= Grafik-Kategorien-Baum, in *jedem* Geschoss gleich): Baum
|
||||
mit Code, Name, Farb-Swatch, Linienstärke, Visibility, Hatch. Pro Ansicht
|
||||
schaltbar (das ist genau VWs „Sichtbarkeit pro Viewport/Saved View").
|
||||
- Tab **„BIM-Tree / Elemente"** (aus DOSSIER-Backlog §11: *Element-Übersicht
|
||||
Geschoss→Kind→Element, Suche, Shift-Klick = Zoom*). Das ist unser Pendant zu
|
||||
Arcols Model Tree + Onshapes Feature-Baum.
|
||||
- **Visibility-Spalte als Erstklass-Interaktion** (VW-Muster): Auge-Icon je Zeile,
|
||||
Klick togglet sofort, kein Dialog. (Wir haben bereits `EyeIcon.tsx` — das ist die
|
||||
Keimzelle.)
|
||||
- **Wichtig:** Geschoss wird **im 3D *oder* Plan** betrachtet, *keine* getrennten
|
||||
Daten — der View-Umschalter (siehe §2) gehört in die Geschoss-Auswahl, nicht in
|
||||
separate Dokumente.
|
||||
|
||||
> **Anti-Pattern vermeiden:** Vectorworks selbst leidet unter
|
||||
> **Paletten-Wildwuchs** (viele frei schwebende Fenster). Für ein fokussiertes
|
||||
> Wohnbau-Tool: **feste 3-Zonen-Shell** (links Navigator, rechts Inspector), nicht
|
||||
> N frei schwebende Palettenfenster. Figmas Striktheit schlägt VWs Flexibilität für
|
||||
> unsere Zielgruppe (Architekt:innen, die schnell ein EFH zeichnen wollen).
|
||||
|
||||
---
|
||||
|
||||
## 2. 3D ⇄ Plan-View-Umschaltung (das Herzstück)
|
||||
|
||||
### 2.1 Was die Tools machen
|
||||
|
||||
- **Snaptrude:** Nutzer arbeiten **in 2D *und* 3D gleichzeitig**; Änderung in einer
|
||||
Sicht spiegelt sich automatisch in der anderen. Objekte sind **nach Geschossen
|
||||
klassifiziert**, was es erlaubt, „3D-Geometrie zu zeichnen, während man in einer
|
||||
2D-Grundriss-Ansicht arbeitet". Push-an-einer-Fläche „fühlt sich an wie eine
|
||||
Linie ziehen", berechnet aber sofort Flächen/BIM-Daten neu
|
||||
([ArchDaily: Snaptrude](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work),
|
||||
[Snaptrude](https://www.snaptrude.com/)).
|
||||
- **Arcol:** „Every view is 3D in Arcol" — und **Boards** (Präsentations-Layouts)
|
||||
sind **live-synced**: ändert sich das Gebäude, aktualisieren sich die Layouts
|
||||
automatisch (kein statischer PDF-Export)
|
||||
([AEC Magazine: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
|
||||
- **TestFit:** explizites **Expand/Collapse von Optionen** — man klappt Varianten
|
||||
auf zum Vergleichen und wieder zu, um sich aufs Detail-Editieren im Canvas zu
|
||||
konzentrieren („smoother flow between setting up a site, reviewing options, and
|
||||
refining a design")
|
||||
([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)).
|
||||
|
||||
### 2.2 Übernahme für uns
|
||||
|
||||
Das ist **genau unsere Kern-Architektur** (ROADMAP §2c: „Ansichtstyp = Kamera-
|
||||
Projektion + optionaler Schnitt"). Konkrete UI-Muster:
|
||||
|
||||
- **View-Switcher als segmented control** direkt am Canvas (oben links oder
|
||||
oben mittig): `3D | Grundriss | Schnitt | Ansicht`. Plan-View = orthogonale
|
||||
Top-Kamera + Clipping-Ebene auf `okff + schnitthöhe` (steht bereits so im Modell).
|
||||
- **Kein** Moduswechsel der *Daten*, nur der **Kamera + Schnitt** — visuell als
|
||||
weicher Übergang (Kamera-Animation) kommunizieren, damit Nutzer Orientierung
|
||||
behalten (Snaptrude/Arcol-Gefühl: „dieselbe Sache aus anderem Winkel").
|
||||
- **Optional, stark differenzierend:** **Split-View** (3D links, Plan rechts) wie
|
||||
Snaptrudes „2D + 3D simultan". Da unsere Sichten ohnehin reaktiv aus *einem*
|
||||
Modell abgeleitet werden (Zustand-Store → SVG-Plan + Three-Scene), ist Split-View
|
||||
technisch billig und ein Wow-Moment im Onboarding.
|
||||
- **Kamera-Presets** (aus DOSSIER-Backlog): Kardinal N/O/S/W, Iso-Oktanten,
|
||||
**Norden-Rotation** (CH/Swisstopo-Georeferenz) — als kleine Würfel-/Kompass-Gizmo
|
||||
oben rechts im 3D (ViewCube-Muster, bekannt aus Onshape/CAD allgemein
|
||||
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm))).
|
||||
- **Drawing Layers (Schnitte/Ansichten) sind „Saved Views"**: Auswahl in der linken
|
||||
Sidebar = Kamera + Schnitt + Maßstab + Layer-Kombination springt an (VW „Saved
|
||||
Views" / DOSSIER „Ausschnitte"). Das ersetzt Ordner-Wildwuchs bei 50+ Ansichten.
|
||||
|
||||
---
|
||||
|
||||
## 3. Zeichnen & Editieren: Snapping, Inferencing, Grips, Tool-Paletten
|
||||
|
||||
### 3.1 Snapping / Inferencing — das wichtigste Detail im Browser
|
||||
|
||||
**Onshape** ist hier die Referenz (sehr konkret dokumentiert,
|
||||
[Onshape Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm)):
|
||||
|
||||
- **Inference-Typen:** horizontal, vertikal, **midpoint**, parallel, coincident,
|
||||
Ausrichtung zum Origin/zu anderen Entities, Tangente, Perpendikular.
|
||||
- **Visuelles Feedback:**
|
||||
- **gelbe Highlights** auf Vertices/Mittelpunkten beim Hovern,
|
||||
- **orange gestrichelte Linie** zeigt eine vorgeschlagene H/V-Ausrichtung,
|
||||
- **orange Highlight** verwandter Geometrie beim Ziehen (relationales Feedback).
|
||||
- **Steuerung:** Linksklick **akzeptiert** die vorgeschlagene Bedingung; **Shift
|
||||
gedrückt halten unterdrückt** Inferencing temporär (loslassen = wieder an).
|
||||
- **„Wake-up"-Inferences:** kurzes Verweilen über einer Geometrie „weckt" deren
|
||||
Bezugslinien (z. B. erst Mittelpunkt antippen, dann woanders zeichnen → bekommt
|
||||
Ausrichtung zu diesem Mittelpunkt).
|
||||
- **Post-hoc:** vorhandene Geometrie ziehen triggert erneut Inferencing (Center
|
||||
eines Kreises vertikal zum Origin ziehen → fügt automatisch vertikale Constraint).
|
||||
|
||||
**Constraint-Sichtbarkeit:** Constraint-Icons sind farbcodiert (blau =
|
||||
externe/Referenz, weiß = intern) und ein-/ausblendbar
|
||||
([Onshape Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)).
|
||||
|
||||
### 3.2 Übernahme für uns (Snapping)
|
||||
|
||||
Für ein Maus-bedientes Browser-Tool ist **gutes Snapping der Unterschied zwischen
|
||||
„Spielzeug" und „Werkzeug".** Konkret:
|
||||
|
||||
- **Snap-Targets** (Wohnbau-relevant): Wand-Endpunkte/Achsen, Wand-Mittelpunkte,
|
||||
Rechtwinklig/Parallel zu bestehender Wand, **Raster** (Achsraster `00 Raster`),
|
||||
Öffnungs-Achsen, vorhandene 2D-Linien-Endpunkte, Schnittpunkte.
|
||||
- **Feedback exakt wie Onshape übernehmen:** Snap-Glyph am Cursor (●
|
||||
Endpunkt, △ Mitte, ⟂ rechtwinklig), **gestrichelte Hilfslinie** für
|
||||
Achsen-Alignment, Hover-Highlight des Snap-Ziels.
|
||||
- **Modifier:** **Shift unterdrückt Snapping** (Onshape-Konvention) — Nutzer
|
||||
erwarten das bereits aus anderen Tools. Zusätzlich **Ortho-Modus** (z. B. Shift
|
||||
für 0/45/90° beim Linienziehen — Figma-Konvention) sauber davon trennen oder per
|
||||
Toggle.
|
||||
- **Live-Maßeingabe beim Zeichnen** (CAD-Standard, auch Onshape): während des
|
||||
Ziehens Länge/Winkel tippbar (Tab zwischen Feldern). Das ersetzt nachträgliches
|
||||
Dimensionieren und ist für Architekt:innen Pflicht.
|
||||
- Wir brauchen **kein** volles Constraint-Solver-System wie Onshape (mechanisches
|
||||
parametrisches CAD). BIM-Wände sind achs-basiert; **Inferencing beim Setzen**
|
||||
genügt, persistente geometrische Constraints sind Overkill für Wohnbau.
|
||||
|
||||
### 3.3 Grip-Editing / direkte Manipulation
|
||||
|
||||
- **Figma:** Auswahl zeigt Bounding-Box mit **Resize-Handles**; ziehen
|
||||
manipuliert direkt; Smart-Guides/Maße erscheinen relativ zu Nachbarn beim Bewegen
|
||||
([Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)).
|
||||
- **Snaptrude:** Push/Pull an Flächen als primäre 3D-Edit-Geste
|
||||
([ArchDaily](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work)).
|
||||
- **DOSSIER-Backlog (§11)** listet **Grip-Editing** (Wand-Endpunkte,
|
||||
Schnitt-Symbole im Plan) explizit als Aufwand L, Phase 3–4 — und das **9-Punkt-
|
||||
Objekt-Info** (lesen + verschieben/skalieren/rotieren direkt).
|
||||
|
||||
**Übernahme:** Grips sind der wichtigste „pro feel"-Hebel.
|
||||
- **Wand-Endpunkt-Grips** im Plan *und* 3D, mit Snapping (s. o.) und Live-Maß.
|
||||
- **Öffnungs-Grips** (Position entlang Wand, Breite) — Host-Beziehung bleibt erhalten.
|
||||
- **Schnittlinien-Grips im Plan** (Schnittlinie ziehen → Schnitt-Ansicht
|
||||
re-deriviert) — das ist Grip-Editing über die Sicht-Grenze hinweg, ein starkes
|
||||
Differenzierungsmerkmal.
|
||||
- Selektion → **bounding handles** (Figma-Muster) für 2D-Plangrafik (Linien,
|
||||
Rechtecke, Text).
|
||||
|
||||
### 3.4 Tool-Palette & kontextuelle Werkzeuge
|
||||
|
||||
**Kontextuelle UI ist das durchgehende Muster:**
|
||||
- **Arcol:** „buttons appear only when they can be used" — Loft/Sweep erscheinen bei
|
||||
Auswahl zweier Sketches, Boolean bei zwei Extrusions
|
||||
([AEC: Arcol sneak peek](https://aecmag.com/bim/arcol-a-sneak-peek/)).
|
||||
- **Onshape:** Sketch-Toolbar erscheint beim Betreten des Sketch-Modus; Tools in
|
||||
Gruppen (vertikale Trennlinien), Dropdown-Pfeile für Varianten; Dialoge mit
|
||||
**blau hinterlegtem Feld**, das eine Auswahl im Graphics-Bereich verlangt
|
||||
([Onshape sketch toolbar](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm),
|
||||
[Onshape Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)).
|
||||
- **Figma:** schmale Bottom-/Top-Toolbar mit den Kern-Tools; alles Weitere
|
||||
kontextuell rechts.
|
||||
|
||||
**Übernahme:**
|
||||
- **Schlanke Tool-Palette** (Figma-artig), gruppiert nach unserer Domäne:
|
||||
*Wand · Tür/Fenster · Decke · Treppe · Dach · Raum* (BIM) und *Linie · Polylinie
|
||||
· Rechteck · Kreis · Bogen · Text · Bemaßung* (2D auf `80 Plangrafik`).
|
||||
- **Modus-bewusste Tools:** im Grundriss andere Defaults als im 3D; im
|
||||
Schnitt/Ansicht nur Annotation/2D-Tools.
|
||||
- **Contextual action bar bei Auswahl** (Arcol-Muster): selektiere eine Wand →
|
||||
schwebende Mini-Toolbar „Tür einsetzen / Fenster / Wandtyp / verschneiden".
|
||||
Selektiere zwei Wände → „verschneiden / verlängern".
|
||||
- **Aktives Tool sticky + ESC bricht ab**, Leertaste = Pan, Scroll = Zoom
|
||||
(Figma/CAD-Konventionen — Nutzer bringen Muskelgedächtnis mit).
|
||||
- **LoD-bewusste Tool-/Stil-UI** (DOSSIER-Backlog: *zeigt nur passende Controls je
|
||||
Geometrie-Typ*) — keine Füll-Optionen bei 3D-Auswahl. Deckt sich mit Figmas
|
||||
„controls appear based on layer type".
|
||||
|
||||
---
|
||||
|
||||
## 4. Property-/Inspector-Panel
|
||||
|
||||
### 4.1 Was die Tools machen
|
||||
|
||||
**Figma — rechte Sidebar** (für uns das Vorbild,
|
||||
[Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)):
|
||||
- Tabs **Design / Prototype** (bei Edit), **Inspect / Properties** (bei View-only).
|
||||
- Kategorien: **Alignment/Rotation/Position → Dimensions → Constraints/Layout →
|
||||
Appearance (Fill, Stroke, Effects) → Export**.
|
||||
- **Controls erscheinen je Layer-Typ** (kontextuell).
|
||||
- **Dev-Mode/Inspect** liefert konkrete Werte + Abstände zwischen Objekten + Code.
|
||||
|
||||
**Arcol — rechtes Panel** zeigt **Gebäude-Metriken** kontextuell: Geschossfläche,
|
||||
Anzahl Geschosse, GFZ/FAR, Unit-Count, Standortfläche, Kostenschätzung
|
||||
([Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
|
||||
|
||||
**TestFit — ein zentrales Parameter-Panel:** „alle Schlüsselparameter an einem
|
||||
Ort" (Unit-Counts, Parkplatz-Ziele, Gebäudegrößen-Limits) → sofortige Wirkung auf
|
||||
generierte Optionen
|
||||
([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)).
|
||||
|
||||
**Onshape — Feature-Dialoge:** Erstellen/Editieren über Dialoge mit Pflicht-
|
||||
Selektionsfeldern (blau)
|
||||
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)).
|
||||
|
||||
### 4.2 Übernahme für uns
|
||||
|
||||
- **Rechter Inspector, kontextuell nach Element-Typ** (Figma-Muster):
|
||||
- **Wand** → Wandtyp (→ WallType-Bibliothek), Referenzlage mid/left/right
|
||||
(DOSSIER-Backlog), Höhe, Achs-Endpunkte (9-Punkt/Maße), Stil-Overrides.
|
||||
- **Tür/Fenster** → Breite/Höhe, Brüstung, Schwenkbogen/Anschlag, Detailgrad,
|
||||
Symbol.
|
||||
- **Decke/Slab** → SlabType, UK/OK-Override, Aussparungen.
|
||||
- **Raum** → SIA-416-Kategorie (HNF/NNF/VF/FF/GF/AGF), Fläche (read-only,
|
||||
berechnet), Stempel-Felder.
|
||||
- **2D-Element** → Linienstil (→ Line Manager), Hatch (→ Hatch Manager), Farbe.
|
||||
- **Sektionen kollabierbar** (Figma) — Wohnbau-Inspector kann lang werden;
|
||||
Default-Sektionen offen, Fortgeschrittenes (Overrides) zugeklappt
|
||||
(= progressive disclosure, s. §7).
|
||||
- **Mixed-value-Handling bei Mehrfachauswahl** (Figma): bei Mehrfachauswahl
|
||||
abweichende Werte als „Mixed/—" zeigen, gemeinsames Editieren erlauben. Wichtig
|
||||
z. B. „alle EG-Wände auf Wandtyp X".
|
||||
- **Live-Metriken-Block** (Arcol-Muster) — selbst im Wohnbau wertvoll:
|
||||
Bruttogeschossfläche, **SIA-416-Bilanz**, Raumzahl, Volumen. Im Inspector wenn
|
||||
nichts selektiert ist = „Projekt-Übersicht" (vgl. Figmas Canvas-Level-Optionen
|
||||
bei leerer Auswahl).
|
||||
- **Read-only-Felder klar markieren** (berechnete Flächen/Volumen) vs. editierbar.
|
||||
|
||||
---
|
||||
|
||||
## 5. Resource-Manager (Bibliotheken)
|
||||
|
||||
### 5.1 Was Vectorworks macht — unser direktes Vorbild
|
||||
|
||||
Der **Resource Manager** ist „ein zentraler Ort für Assets" (Symbole, Linientypen,
|
||||
Texturen, Materialien …)
|
||||
([VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm)):
|
||||
- **Zwei-/Drei-Pane-Modell:** **File-Browser** (offene Dateien, Favoriten,
|
||||
VW-Libraries, User-/Workgroup-Libraries) → **Resource-Viewer** (Ressourcen der
|
||||
gewählten Datei) → optional **Preview/Metadaten**
|
||||
([VW File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm),
|
||||
[VW Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm)).
|
||||
- **Organisation mehrdimensional:** nach Quelle, **nach Typ** (Dropdown-Filter),
|
||||
nach Ordnerstruktur.
|
||||
- **Ansichten:** Thumbnails / List / Thumbnails-List; **Suchfeld** mit Filtern.
|
||||
- **Resource Selector**: dieselbe Bibliothek erscheint **in Dialogen** und zeigt
|
||||
dort nur **kontextuell passende** Ressourcen.
|
||||
|
||||
### 5.2 Übernahme für uns
|
||||
|
||||
Unsere ROADMAP §2d definiert bereits **Line Manager / Hatch Manager / Component
|
||||
Manager**, mit Verweis-per-id-Architektur (Components → Hatches → LineStyles). Das
|
||||
Vectorworks-Modell passt perfekt:
|
||||
|
||||
- **Ein gemeinsames Resource-Browser-Pattern** für alle Bibliotheken (Linienstile,
|
||||
Schraffuren, Components/Baustoffe, später Wand-/Öffnungs-Stil-Kataloge,
|
||||
Material-PBR, Raumstempel-Layouts). Eine wiederverwendbare React-Komponente,
|
||||
parametrisiert nach Ressourcentyp.
|
||||
- **Zwei Erscheinungsformen** (wie VW):
|
||||
1. **Manager-Ansicht** (großes Panel/Modal) zum Anlegen/Editieren/Duplizieren.
|
||||
2. **Inline-Resource-Selector** im Inspector — beim Setzen eines Wandtyps/Hatch
|
||||
öffnet sich ein kompakter Picker mit Thumbnails, gefiltert auf den passenden
|
||||
Typ. (Figma macht das analog mit „Styles/Variables".)
|
||||
- **Thumbnails sind im CAD-Kontext kritisch**: Hatch-Vorschau, Component-
|
||||
Schichtaufbau, Linienstil-Strich als gerenderte Mini-Previews.
|
||||
- **Zentrale Änderung propagiert** (unsere id-Referenz-Architektur): Component-Farbe
|
||||
ändern → alle Wände mit diesem Component aktualisieren live. Das ist Arcols
|
||||
„single source of truth" auf Ressourcen-Ebene.
|
||||
- **Cross-Projekt-Presets/Favoriten** (DOSSIER-Backlog, LocalStorage; VW-Favoriten):
|
||||
„einmal speichern, überall nutzen".
|
||||
|
||||
---
|
||||
|
||||
## 6. Perceived Performance (gefühlte Geschwindigkeit)
|
||||
|
||||
Browser-3D + WASM-Geometrie (web-ifc, OpenCascade) + HLR-Projektion = **echte
|
||||
Latenz** an mehreren Stellen. Gefühlte Performance ist hier ein
|
||||
Differenzierungs-Hebel gegenüber schwerfälligem Revit/ArchiCAD.
|
||||
|
||||
### 6.1 Belegte Muster
|
||||
|
||||
- **Skeleton-Screens** lassen Apps **20–30 % schneller** wirken als Spinner bei
|
||||
identischer realer Ladezeit
|
||||
([LogRocket: skeleton screens](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/),
|
||||
[UI Deploy](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance)).
|
||||
- **Indikator zur Situation passen:** Spinner für kurze Waits, Skeleton für
|
||||
Content, Progress-Bar für messbare Operationen, **optimistic UI** für
|
||||
„instant-feeling" Aktionen
|
||||
([Onething: skeleton vs spinner](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)).
|
||||
- **Optimistic UI**: UI sofort aktualisieren, Server-Bestätigung abwarten, nur bei
|
||||
Fehler zurückrollen — ideal für häufige, risikoarme Aktionen
|
||||
([Smart Interface Design Patterns](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/)).
|
||||
- **Verzögerung vor Indikator (100–200 ms):** schließt die Operation vorher ab,
|
||||
gar kein Indikator → kein Flackern
|
||||
([Onething](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)).
|
||||
- **Gescopte Ladezustände** (React Suspense / Next loading.tsx): nur der betroffene
|
||||
Bereich lädt, der Rest bleibt interaktiv; `aria-busy`, Live-Regions,
|
||||
reduced-motion respektieren
|
||||
([LogRocket](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/)).
|
||||
|
||||
### 6.2 Übernahme für uns (konkret)
|
||||
|
||||
- **Optimistic Model-Edits:** Geometrie-Mutation (Wand ziehen, Tür setzen) **sofort**
|
||||
im Zustand-Store + 3D anzeigen; **schwere Booleans/HLR im Web Worker** (Comlink,
|
||||
bereits geplant) nachziehen. Wand erscheint sofort, die *exakte* verschnittene
|
||||
Öffnung/Schnittlinie „schärft nach". UI bleibt flüssig.
|
||||
- **Progressive Plan-Generierung:** SVG-Grundriss zuerst grob (Achsen/Linien),
|
||||
Schraffuren/Symbole nachladen — Skeleton/„low-detail first" statt Spinner.
|
||||
- **HLR-Schnitte (Risiko #4):** Worker + Caching (ROADMAP §6). UI: **Skeleton der
|
||||
Schnitt-Ansicht** + „berechne verdeckte Kanten…" mit Progress, restliche App
|
||||
bleibt nutzbar (gescopter Ladezustand).
|
||||
- **Delay-then-show** für alle Worker-Tasks (150 ms-Schwelle), sonst Flicker beim
|
||||
schnellen Editieren.
|
||||
- **Three.js-Disziplin:** stabile 60 fps beim Orbit/Pan ist selbst „perceived
|
||||
performance" — instanziertes Rendering, Frustum-Culling, LoD für ferne Geometrie
|
||||
(deckt sich mit ROADMAP-Phase-7-Performance-Härtung). Lieber 60 fps bei grober
|
||||
Geometrie als ruckelnde Präzision.
|
||||
- **Auto-Save-Status klar, unaufdringlich** kommunizieren („Gespeichert"/„Speichern…"),
|
||||
optimistic — nie blockierend (Figma-Muster).
|
||||
|
||||
---
|
||||
|
||||
## 7. Onboarding & Discoverability
|
||||
|
||||
### 7.1 Belegte Muster
|
||||
|
||||
- **Progressive Disclosure:** zuerst nur Essenzielles zeigen, Komplexität schrittweise
|
||||
enthüllen — reduziert kognitive Last; drei Typen: step-by-step, conditional,
|
||||
contextual
|
||||
([IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure),
|
||||
[UXPin](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/)).
|
||||
- **„Learn by doing" an Sample-Dokument:** Grammarly startet Nutzer mit einem
|
||||
Beispiel-Dokument mit Fehlern; Hotspots/Tooltips führen durch Features
|
||||
([Userpilot: onboarding examples](https://userpilot.com/blog/user-onboarding-examples/)).
|
||||
- **Stufenweises Aufdecken fortgeschrittener Features** (Asana: erst Projekt
|
||||
anlegen, später Dependencies/Kanban/Gantt)
|
||||
([Userpilot: progressive disclosure](https://userpilot.com/blog/progressive-disclosure-examples/)).
|
||||
- **Command-Palette als Discovery-Layer:** durchsuchbare Befehlsliste hilft, Features
|
||||
zu entdecken — „incredible effect on exploration and feature discoverability",
|
||||
besonders für neue/seltene Nutzer
|
||||
([Mobbin: command palette](https://mobbin.com/glossary/command-palette),
|
||||
[Untitled UI: command menus](https://www.untitledui.com/components/command-menus)).
|
||||
- **Arcol** wirbt explizit mit „low barrier to entry, gentle learning curve … clean,
|
||||
intuitive, requires minimal training"
|
||||
([AEC: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
|
||||
|
||||
### 7.2 Übernahme für uns
|
||||
|
||||
- **Mitgeliefertes Sample-Projekt** (wir haben bereits `sampleProject.ts`!) als
|
||||
Onboarding-Bühne: ein kleines EFH, fertig modelliert. Nutzer **manipuliert echtes
|
||||
Modell** statt leerem Canvas (Grammarly-Muster). Erste Geste: „zieh diese Wand"
|
||||
→ sieht 3D + Plan live mitlaufen (unser Kern-Wow).
|
||||
- **Progressive Disclosure im Inspector & Tool-Palette:** Default zeigt
|
||||
Wohnbau-Essenz (Wand/Tür/Fenster/Decke/Raum). Fortgeschrittenes (Prioritäts-
|
||||
Verschneidung, Overrides, Detailgrade, Section-Styles) **zugeklappt / hinter
|
||||
„Erweitert"**. Das passt zu unserem radikalen Wohnbau-Fokus.
|
||||
- **Contextual coachmarks** statt langem Tutorial: beim ersten Selektieren einer
|
||||
Wand ein kleiner Tooltip „Endpunkt ziehen zum Verlängern, Doppelklick für Wandtyp".
|
||||
- **Command-Palette (Ctrl/Cmd-K)** — siehe §8 — doppelt als Onboarding: alle
|
||||
Werkzeuge/Befehle durchsuchbar = lebende Feature-Liste.
|
||||
- **Tastatur-Kürzel sichtbar machen** (in Tooltips, in der Palette) — schult
|
||||
beiläufig pro Workflows.
|
||||
|
||||
---
|
||||
|
||||
## 8. Command-Palette & Tastatur
|
||||
|
||||
### 8.1 Belegte Muster
|
||||
|
||||
- **Ctrl/Cmd-K** ist die De-facto-Konvention (Linear, Figma [Cmd-P], Notion,
|
||||
Vercel, Raycast, Slack, Superhuman); VS Code nutzt Cmd-Shift-P
|
||||
([Mobbin](https://mobbin.com/glossary/command-palette),
|
||||
[Superhuman: command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/)).
|
||||
- Zwei Haupt-Use-Cases: **Navigation/Suche** und **Shortcuts/Quick Actions**
|
||||
([Outdraw Academy: command palette](https://outdraw-academy.gitbook.io/ux-patterns/command-palette)).
|
||||
- Trigger kann **sichtbar** (Button/Suchleiste) oder nur per Shortcut sein —
|
||||
für Discovery besser **auch sichtbar**
|
||||
([Mobbin](https://mobbin.com/glossary/command-palette)).
|
||||
|
||||
### 8.2 Übernahme für uns
|
||||
|
||||
- **Cmd/Ctrl-K-Palette** für: Werkzeug aktivieren („Wand", „Tür"), Ansicht
|
||||
springen („Grundriss EG", „Schnitt A-A"), Ressource öffnen („Hatch Manager"),
|
||||
globale Aktionen („Norden rotieren", „PDF exportieren", „SIA-Bilanz").
|
||||
- **Sichtbarer Trigger** (Such-/Befehlsfeld in der Top-Bar) für Entdeckung +
|
||||
Shortcut für Speed.
|
||||
- **Konsistente, dokumentierte Shortcuts** (Figma-Disziplin): W=Wand, T=Tür,
|
||||
L=Linie, Space=Pan, Scroll=Zoom, Shift=Snap aus, Esc=Abbrechen, G=Grundriss-Toggle.
|
||||
In Tooltips + Palette anzeigen.
|
||||
|
||||
---
|
||||
|
||||
## 9. Leitplanken für unsere UI (priorisierte Empfehlungen)
|
||||
|
||||
> Sortiert nach **Hebel × Aufwand**. „P#" = grobe Phasen-Zuordnung zur ROADMAP.
|
||||
|
||||
### A. Sofort / Fundament (Phase 0–1) — billig, prägt alles
|
||||
|
||||
1. **Feste 3-Zonen-Shell.** Links **Navigator** (Tabs: *Geschosse/Ansichten · Ebenen
|
||||
· BIM-Tree*, je mit Visibility-Toggle wie VW Navigation Palette). Mitte **Canvas**
|
||||
mit schwebender Tool-Palette + View-Switcher. Rechts **Inspector** (kontextuell).
|
||||
*Keine* frei schwebenden Palettenfenster (Anti-VW-Wildwuchs).
|
||||
2. **View-Switcher als segmented control** `3D | Grundriss | Schnitt | Ansicht`
|
||||
direkt am Canvas; weiche Kamera-Übergänge; *eine* Datenquelle (kein Daten-
|
||||
Moduswechsel). Nutzt unsere bestehende „abgeleitete Sichten"-Architektur.
|
||||
3. **Visibility/Lock pro Zeile** als Erstklass-Interaktion (1 Klick, kein Dialog) —
|
||||
`EyeIcon.tsx` ausbauen.
|
||||
4. **Kontextueller Inspector**: Controls nur für den selektierten Element-Typ
|
||||
(Figma/Arcol); berechnete Felder read-only markiert; Sektionen kollabierbar.
|
||||
5. **Tastatur-Grundlagen + Konventionen festnageln**: Space=Pan, Scroll=Zoom,
|
||||
Esc=Abbrechen, Shift=Snap aus. Früh festlegen → Muskelgedächtnis.
|
||||
|
||||
### B. Kern-„Pro-Feel" (Phase 1–2) — der eigentliche Wert
|
||||
|
||||
6. **Snapping/Inferencing nach Onshape-Vorbild**: Snap-Glyphen am Cursor,
|
||||
gestrichelte Achsen-Hilfslinien, Hover-Highlight, **Shift = unterdrücken**,
|
||||
Wake-up-Inferences. Targets: Wand-Enden/Achsen/Mitten, Raster, Rechtwinklig/
|
||||
Parallel, Öffnungs-Achsen. **Höchste Priorität für „Werkzeug-Gefühl".**
|
||||
7. **Live-Maßeingabe beim Zeichnen** (Länge/Winkel tippbar, Tab zwischen Feldern).
|
||||
8. **Kontextuelle Action-Bar bei Auswahl** (Arcol): Wand selektiert → „Tür/Fenster
|
||||
einsetzen / Wandtyp / verschneiden". Buttons erscheinen nur, wenn anwendbar.
|
||||
9. **Schlanke domänen-gruppierte Tool-Palette** (BIM-Bauteile + 2D-Zeichnen),
|
||||
modus-bewusst je Ansicht.
|
||||
10. **Optimistic Edits + Worker-Nachzug**: Mutation sofort sichtbar, schwere
|
||||
Booleans/HLR im Worker (Comlink); Delay-then-show (150 ms) statt Spinner-Flicker.
|
||||
|
||||
### C. Differenzierung (Phase 3) — hier gewinnen wir
|
||||
|
||||
11. **Grip-Editing** (Wand-Endpunkte, Öffnungs-Position/-Breite, **Schnittlinie im
|
||||
Plan**) mit Snapping + Live-Maß, in 2D *und* 3D. (DOSSIER-Backlog, Aufwand L —
|
||||
aber Kern-Differenzierer.)
|
||||
12. **Saved Views / Ausschnitte** in der linken Sidebar = Kamera + Schnitt + Maßstab
|
||||
+ Layer-Kombination per Klick (VW „Saved Views" / DOSSIER). Skaliert auf 50+
|
||||
Ansichten.
|
||||
13. **Wiederverwendbares Resource-Browser-Pattern** für Line/Hatch/Component-Manager:
|
||||
Zwei-Pane (File-Browser → Viewer mit Thumbnails) als Manager *und* als inline
|
||||
Picker im Inspector (VW-Modell). Zentrale Änderung propagiert (id-Referenzen).
|
||||
14. **Perceived-performance-Politik festschreiben**: Skeletons für Plan-/Schnitt-
|
||||
Generierung, gescopte Ladezustände (restliche App bleibt nutzbar),
|
||||
reduced-motion/`aria-busy` respektieren.
|
||||
15. **Split-View 3D|Plan** (Snaptrude-Muster) — billig dank reaktiver Sichten,
|
||||
starker Wow-Effekt; auch fürs Onboarding.
|
||||
|
||||
### D. Adoption & Politur (Phase 1 fortlaufend → 7)
|
||||
|
||||
16. **Onboarding via Sample-Projekt** (`sampleProject.ts` als fertiges EFH);
|
||||
„learn by doing", erste Geste zeigt 3D⇄Plan-Live-Sync. Progressive Disclosure:
|
||||
Wohnbau-Essenz default, Fortgeschrittenes (Prioritäts-Verschneidung, Overrides,
|
||||
Detailgrade) zugeklappt.
|
||||
17. **Command-Palette (Cmd/Ctrl-K)** + sichtbarer Trigger: Werkzeuge/Ansichten/
|
||||
Ressourcen/Aktionen durchsuchbar; doppelt als Discovery-Layer.
|
||||
18. **Live-Metriken-Block** im Inspector (Arcol): BGF, **SIA-416-Bilanz**, Räume,
|
||||
Volumen — bei leerer Auswahl als Projekt-Übersicht.
|
||||
19. **CH-Spezifika UI-seitig vorsehen**: **Norden-Rotation**-Gizmo (ViewCube/Kompass),
|
||||
SIA-Raumkategorien im Raum-Inspector, später Swisstopo-Import-Flow mit Auto-Zoom
|
||||
+ Nullpunkt-Verschiebung.
|
||||
|
||||
### Übergreifende Designprinzipien (gelten immer)
|
||||
|
||||
- **Kontextualität vor Vollständigkeit** — zeige nur, was jetzt anwendbar ist
|
||||
(Arcol/Onshape/Figma). Direkt verzahnt mit unserem „LoD-bewusste Stil-UI"-Backlog.
|
||||
- **Direkte Manipulation vor Dialogen** — Grips/Drag/Inline-Edit schlägt
|
||||
Properties-Dialog (Figma/Snaptrude/DOSSIER).
|
||||
- **Eine Wahrheit, viele Sichten** — niemals Sicht-spezifische Daten; alles aus dem
|
||||
semantischen Modell ableiten (deckt sich exakt mit unserem Architektur-Prinzip).
|
||||
- **Konventionen respektieren** — Pan/Zoom/Snap/Esc/Cmd-K wie die etablierten Tools;
|
||||
Nutzer bringen Muskelgedächtnis aus Figma/CAD mit.
|
||||
- **Gefühlte > tatsächliche Geschwindigkeit** — optimistic + Skeletons + 60 fps;
|
||||
im schweren Browser-3D-Geometrie-Kontext ein echter Wettbewerbsvorteil.
|
||||
|
||||
---
|
||||
|
||||
## Quellen
|
||||
|
||||
**Arcol**
|
||||
- [Arcol unleashed – BIM 2.0 (AEC Magazine)](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)
|
||||
- [Arcol – BIM in a browser (AEC Magazine)](https://aecmag.com/bim/arcol-bim-cloud-browser/)
|
||||
- [Arcol: a sneak peek (AEC Magazine)](https://aecmag.com/bim/arcol-a-sneak-peek/)
|
||||
- [The Arcol Manifesto](https://arcol.io/blog/the-arcol-manifesto)
|
||||
|
||||
**Snaptrude**
|
||||
- [Snaptrude – browser-based BIM tool (ArchDaily)](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work)
|
||||
- [Snaptrude (offiziell)](https://www.snaptrude.com/)
|
||||
|
||||
**TestFit**
|
||||
- [TestFit 5.19: A New Generative Design Workflow](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)
|
||||
- [TestFit (offiziell)](https://www.testfit.io/)
|
||||
|
||||
**Onshape**
|
||||
- [Onshape: User Interface Basics](https://cad.onshape.com/help/Content/ui-basics.htm)
|
||||
- [Onshape: Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm)
|
||||
- [Onshape: Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)
|
||||
- [Onshape: Sketch Basics](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm)
|
||||
|
||||
**Vectorworks**
|
||||
- [VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm)
|
||||
- [VW Resource Manager: File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm)
|
||||
- [VW Resource Manager: Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm)
|
||||
- [VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm)
|
||||
- [VW Palettes and Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm)
|
||||
|
||||
**Figma**
|
||||
- [Figma: right sidebar / layer properties](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)
|
||||
- [Figma: left sidebar (layers & pages)](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar)
|
||||
- [Figma for Everyone: The Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface)
|
||||
|
||||
**Perceived Performance**
|
||||
- [LogRocket: Skeleton loading screen design](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/)
|
||||
- [UI Deploy: Skeleton Screens vs. Spinners](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance)
|
||||
- [Onething: Skeleton Screens vs Loading Spinners](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)
|
||||
- [Smart Interface Design Patterns: Loading & Progress UX](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/)
|
||||
|
||||
**Command Palette**
|
||||
- [Mobbin: Command Palette](https://mobbin.com/glossary/command-palette)
|
||||
- [Untitled UI: Command menus (Cmd-K)](https://www.untitledui.com/components/command-menus)
|
||||
- [Superhuman: How to build a remarkable command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/)
|
||||
- [Outdraw Academy: Command Palette UX pattern](https://outdraw-academy.gitbook.io/ux-patterns/command-palette)
|
||||
|
||||
**Onboarding / Progressive Disclosure**
|
||||
- [IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure)
|
||||
- [UXPin: What Is Progressive Disclosure](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/)
|
||||
- [Userpilot: User onboarding examples](https://userpilot.com/blog/user-onboarding-examples/)
|
||||
- [Userpilot: Progressive disclosure examples](https://userpilot.com/blog/progressive-disclosure-examples/)
|
||||
@@ -0,0 +1,124 @@
|
||||
# Welle C — HLR-Spike: Feasibility-Report
|
||||
|
||||
> 2D-Vektor-Schnitte/-Ansichten aus dem 3D-Modell via Hidden-Line-Removal (HLR)
|
||||
> mit opencascade.js (OCCT-WASM). Isolierter De-Risking-Spike, NICHT in die App
|
||||
> verdrahtet. Einheiten: Meter.
|
||||
|
||||
## Ergebnis (kurz)
|
||||
|
||||
**HLR funktioniert in diesem Browser-Projekt.** opencascade.js initialisiert im
|
||||
Vite-Build, und der HLR-Lauf liefert korrekte, getrennte sichtbare/verdeckte
|
||||
2D-Kanten. Verifiziert im echten Browser (Chromium via Vite-Dev-Server) an einer
|
||||
L-Wand + Bodenplatte, Front-Ansicht:
|
||||
|
||||
| Messwert | Wert |
|
||||
|---|---|
|
||||
| Sichtbare Kanten (sharp) | **9** |
|
||||
| Verdeckte Kanten | **16** |
|
||||
| Reine HLR-Rechenzeit | **~21 ms** |
|
||||
| WASM-Größe (unkomprimiert) | **62.8 MB** (65 864 037 Bytes) |
|
||||
| WASM-Größe (gzip) | **~19.6 MB** |
|
||||
| WASM-Fetch (lokal, Dev) | ~113 ms |
|
||||
| Modul-Init (Emscripten instanziieren) | ~450 ms (Browser) / ~680 ms (Node) |
|
||||
|
||||
Beweis-Artefakte: `hlr-elevation-proof.svg` / `.png` in diesem Ordner — sichtbare
|
||||
Kanten durchgezogen, verdeckte gestrichelt. Die Zeichnung liest sich als korrekte
|
||||
Hidden-Line-Ansicht (Silhouette + sichtbare Front-Kanten voll, verdeckte hinten
|
||||
gestrichelt).
|
||||
|
||||
## Der genutzte API-Pfad (wichtig!)
|
||||
|
||||
Der geplante klassische Pfad (`HLRBRep_Algo`/`HLRBRep_PolyAlgo` +
|
||||
`HLRBRep_HLRToShape`/`HLRBRep_PolyHLRToShape`) ist im **prebuilt Vollbuild
|
||||
1.1.1 NICHT verfügbar**: diese Klassen sind nur als `Handle_…`-Smart-Pointer
|
||||
gebunden, ohne konstruierbare Roh-Klasse und ohne den `HLRToShape`-Extraktor.
|
||||
Ein direkter Aufbau darüber ist damit nicht möglich.
|
||||
|
||||
**Verwendeter, funktionierender Pfad:** `HLRAppli_ReflectLines` — der High-Level-
|
||||
OCCT-Wrapper, der intern GENAU den exakten HLR-Algorithmus (`HLRBRep_Algo`)
|
||||
fährt. Er ist als Klasse gebunden und liefert getrennt sichtbare/verdeckte
|
||||
Kanten nach Kanten-Typ:
|
||||
|
||||
```
|
||||
const rl = new oc.HLRAppli_ReflectLines(shape);
|
||||
rl.SetAxes(dirX, dirY, dirZ, atX, atY, atZ, upX, upY, upZ); // Ortho-Projektion
|
||||
rl.Perform();
|
||||
const T = oc.HLRBRep_TypeOfResultingEdge;
|
||||
// (typ, visible, in3d) → in3d=false liefert 2D-projizierte Kanten (Z≈0)
|
||||
const visSharp = rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, true, false);
|
||||
const visOutline = rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, true, false);
|
||||
const hidSharp = rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, false, false);
|
||||
```
|
||||
|
||||
Kanten-Extraktion: `TopExp_Explorer_2(compound, TopAbs_EDGE, TopAbs_SHAPE)` →
|
||||
`TopoDS.Edge_1(current)` → `new BRepAdaptor_Curve_2(edge)` →
|
||||
`FirstParameter/LastParameter/Value(u)` (`gp_Pnt` mit `.X() .Y() .Z()`).
|
||||
|
||||
**embind-Überladungen sind versioniert** (`_1`, `_2`, …) und die Nummerierung im
|
||||
Vollbuild folgt NICHT der Argumentzahl. Empirisch verifiziert:
|
||||
`BRepPrimAPI_MakeBox_1(dx,dy,dz)`, `BRepPrimAPI_MakeBox_3(gp_Pnt, gp_Pnt)`,
|
||||
`BRepAlgoAPI_Fuse_3(a, b)`, `gp_Pnt_3(x,y,z)`. Bei einem Versionswechsel des
|
||||
Pakets müssen diese Suffixe neu geprüft werden (Fehlermeldung nennt die
|
||||
erwartete Parameterzahl).
|
||||
|
||||
## Vite-/package.json-Änderungen (genau)
|
||||
|
||||
- `package.json`: Dependency `opencascade.js@^1.1.1` (Vollbuild).
|
||||
- `vite.config.ts` (additiv, analog zum bestehenden LibreDWG-Muster):
|
||||
- Zwei Aliase → virtuelle Module auf die echten Paket-Dateien:
|
||||
`virtual:occt-glue` → `dist/opencascade.wasm.js`,
|
||||
`virtual:occt-wasm-url` → `dist/opencascade.wasm.wasm?url`.
|
||||
- `optimizeDeps.exclude: ["opencascade.js"]` (esbuild kommt mit dem
|
||||
UMD-Wrapper + Node-Shims der Glue nicht klar → Vor-Bündeln ausschließen).
|
||||
- `src/section/occt-wasm.d.ts`: Ambient-Stubs für die zwei virtuellen Module.
|
||||
- Geladen wird per **dynamischem Import** in `src/section/occt.ts` → die 62-MB-
|
||||
WASM landet NICHT im Haupt-Bundle, sondern als separater Lazy-Chunk + Asset.
|
||||
|
||||
Verifiziert: `vite build` der App bleibt grün und unverändert groß (kein
|
||||
OCCT-Chunk, da der Spike nicht verdrahtet ist). Ein Lib-Build, der `hlr.ts`
|
||||
referenziert, splittet OCCT sauber in einen eigenen Glue-Chunk + WASM-Asset ab.
|
||||
`tsc` ist für die neuen Dateien grün. (`npm run build` schlägt aktuell in
|
||||
`tsc -b` fehl — ausschließlich wegen fehlender i18n-Keys in den parallel
|
||||
bearbeiteten Dateien `commands/cmds/stair.ts` / `state/*`, NICHT wegen dieses
|
||||
Spikes.)
|
||||
|
||||
## Dateien dieses Spikes
|
||||
|
||||
- `src/section/hlr.ts` — Kernmodul: Box-Specs → Fuse → HLR → 2D-Polylinien
|
||||
(`hlrFromBoxes`, `hlrShape`, `VIEWS`, `hlrToSvg`).
|
||||
- `src/section/occt.ts` — Lazy-Loader + schmale Typ-Fassade + Lade-Metriken.
|
||||
- `src/section/occt-wasm.d.ts` — Ambient-Deklarationen der virtuellen Module.
|
||||
- `docs/welle-c-hlr-spike/` — dieser Report + Proof-SVG/-PNG.
|
||||
|
||||
## Bewertung für Welle C
|
||||
|
||||
**Viabel.** Der exakte HLR liefert saubere, normgerechte Vektor-Kanten mit
|
||||
korrekter Sichtbarkeit — genau das, was Schnitt/Ansicht brauchen und was reines
|
||||
three.js-Kanten-Projizieren nicht robust liefert (dort fehlt echte
|
||||
Flächen-Verdeckung). Die 2D-Polylinien passen direkt in die bestehende
|
||||
SVG-Plan-Pipeline (`Pt2`/`Vec2`-kompatibel).
|
||||
|
||||
**Der Kostenpunkt ist die WASM-Größe (62.8 MB / ~19.6 MB gzip).** Für einen
|
||||
Spike akzeptabel; für Produktion zu groß, um sie eager zu laden.
|
||||
|
||||
### Empfohlener Integrations- + Größenreduktions-Plan
|
||||
|
||||
1. **Lazy laden** (bereits so gebaut): OCCT erst bei erster Schnitt-/Ansichts-
|
||||
Erzeugung dynamisch importieren; Ladezustand in der UI anzeigen. Der
|
||||
Grundriss läuft weiter ohne OCCT (parametrisch, wie bisher).
|
||||
2. **Web-Worker**: HLR im Worker fahren, damit der UI-Thread frei bleibt (die
|
||||
WASM ist groß, aber der HLR-Lauf selbst ist mit ~20 ms günstig).
|
||||
3. **Größe reduzieren — lohnt sich klar**: ein **Custom-Build** von
|
||||
opencascade.js (das Paket unterstützt `make.py`/Docker-Build mit einer
|
||||
Symbol-Whitelist). Für Welle C reicht ein minimaler Satz:
|
||||
`BRepPrimAPI_*` (bzw. der spätere Solid-Erzeuger), `BRepAlgoAPI_Fuse/Common`,
|
||||
`HLRAppli_ReflectLines` + `HLRBRep_TypeOfResultingEdge`, `TopExp_Explorer`,
|
||||
`TopoDS`, `BRepAdaptor_Curve`, `gp_*`. Erfahrungswerte solcher Minimal-Builds
|
||||
liegen bei **~5–15 MB WASM** statt 62 MB — Aufwand: ein reproduzierbarer
|
||||
Docker-Build in CI, der die `.wasm`/`.js` als Projekt-Asset eincheckt.
|
||||
Alternativ die **beta 2.0** prüfen (moderneres Build-System, evtl. bereits
|
||||
schlankere Module + die klassischen HLR-Klassen konstruierbar).
|
||||
4. **Fallback, falls die Größe untragbar bleibt**: three.js-basierte
|
||||
Kanten-Extraktion (`EdgesGeometry`) + eigene Sichtbarkeit via Depth-Peeling /
|
||||
GPU-Occlusion. Deutlich mehr Eigenaufwand, weniger robust bei Verschneidungen
|
||||
— daher nur zweite Wahl; OCCT-HLR bleibt der empfohlene Weg.
|
||||
@@ -0,0 +1,90 @@
|
||||
# M2 — nativer wgpu-2D-Renderer im Tauri-Fenster: Ansatzwahl
|
||||
|
||||
> Ziel: den bereits fluessig laufenden standalone `render2d`-Renderer aus dem
|
||||
> ECHTEN Tauri-Prozess heraus mit nativer GPU-Performance anzeigen — nicht in der
|
||||
> WebKitGTK-Webview (Perf-Flaschenhals), nicht in einem Chromium-Workaround.
|
||||
> Maschine: Linux, Wayland, AMD.
|
||||
|
||||
## Gewaehlter Ansatz: **B — separates natives Fenster (winit) im Tauri-Prozess**
|
||||
|
||||
Der Tauri-Hauptthread haelt weiterhin die GTK-Hauptschleife + das Webview-Fenster
|
||||
(HTML-Chrome). Aus dem Tauri-`setup`-Hook startet der Prozess auf einem
|
||||
Hintergrund-Thread ein **eigenes natives winit-Fenster** mit eigener wgpu-Surface,
|
||||
das die `render2d`-Demo-Szene (konkaves L + Raum + Papier-mm-Linien) rendert,
|
||||
pan-/zoombar. Renderer/Szene werden NICHT reimplementiert — es ist exakt der Code
|
||||
des verifizierten standalone `spike`-Bins (`render2d::gpu::Renderer` +
|
||||
`render2d::demo_scene`).
|
||||
|
||||
### Warum B (und was gegen A/C spricht)
|
||||
|
||||
- **Kein geteilter Wayland-Surface → keine Contention/Flicker.** winit spricht auf
|
||||
Linux DIREKT Wayland/X11 (Crates `wayland-client`/`x11rb`), es benutzt **kein
|
||||
GTK**. Das native Fenster bekommt eine eigene, von WebKitGTK voellig getrennte
|
||||
Wayland-Surface. Genau das umgeht das dokumentierte Problem aus der Vorrecherche
|
||||
(Roh-wgpu-Surface UNTER der Webview im selben Fenster → Flicker/Schwarzbild auf
|
||||
Wayland).
|
||||
- **Zwei Fenster-Stacks, aber KEIN Event-Loop-Konflikt.** tao (Tauri) fuehrt eine
|
||||
GTK-Hauptschleife auf dem Hauptthread; winit fuehrt eine eigene Schleife.
|
||||
winit 0.30 erlaubt die Event-Loop explizit auf einem Nicht-Haupt-Thread via
|
||||
`EventLoopBuilderExtWayland::with_any_thread(true)` (X11-Pendant analog). Der
|
||||
native Renderer laeuft daher auf `std::thread` „cad-native2d", der Tauri-/GTK-
|
||||
Hauptthread bleibt frei. Da winit nicht an GTK gebunden ist, kollidieren die
|
||||
beiden Schleifen nicht (getrennte Stacks, getrennte fds).
|
||||
|
||||
- **Ansatz A (wgpu-Surface im SELBEN Tauri-Fenster, Unter-/Overlay):** verworfen.
|
||||
taos `raw_window_handle_rwh_06()` liefert auf Wayland den `WaylandWindowHandle`
|
||||
der GTK-`ApplicationWindow` — also DIESELBE Surface, in die WebKitGTK
|
||||
komponiert. Eine zweite wgpu-Surface darauf ist genau die Contention, die die
|
||||
Vorrecherche als flackernd/schwarz auf diesem Setup markiert hat. Hoechstes
|
||||
Wayland-Risiko, fuer den Spike ungeeignet.
|
||||
- **Ansatz C (wgpu als Haupt-Surface, HTML nur duennes Overlay):** verworfen fuer
|
||||
den Spike. Sauberste Perf-Story, aber groesster Umbau: die ganze App muesste auf
|
||||
eine winit/tao-getriebene Haupt-Schleife umziehen und die Tauri-Webview zur
|
||||
Nebenrolle degradiert werden. Kein „minimaler realer Spike" mehr. Bleibt die
|
||||
Ziel-Endarchitektur-Option, aber nach B.
|
||||
|
||||
## Crates / Versionen (verifiziert aus der Lock-Datei)
|
||||
|
||||
| Rolle | Crate | Version |
|
||||
|---|---|---|
|
||||
| Tauri | `tauri` / `tauri-runtime-wry` | 2.11.5 / 2.11.4 |
|
||||
| Fenster (Webview-Seite) | `tao` | 0.35.3 |
|
||||
| Webview | `wry` | 0.55.1 |
|
||||
| Natives Renderfenster | `winit` | 0.30.13 |
|
||||
| GPU | `wgpu` / `wgpu-hal` | 22.1.0 / 22.0.0 |
|
||||
| Handle-Bruecke | `raw-window-handle` | **0.6.2 (identisch fuer tao UND wgpu/winit)** |
|
||||
| Renderer | `render2d` (Pfad) | 0.1.0, Feature `window` |
|
||||
|
||||
Der entscheidende Kompatibilitaets-Glueckstreffer: tao 0.35 und wgpu 22/winit 0.30
|
||||
teilen sich `raw-window-handle 0.6.2` — keine rwh-Versionsbruecke noetig. (Fuer
|
||||
Ansatz B wird der tao-Handle gar nicht gebraucht, weil das native Fenster eine
|
||||
eigene winit-Surface hat; die Versions-Parität ist aber die Voraussetzung fuer ein
|
||||
spaeteres A/Compositing, falls je gewuenscht.)
|
||||
|
||||
## Wayland-Caveats (fuer den Start auf dieser Maschine)
|
||||
|
||||
- **`WEBKIT_DISABLE_DMABUF_RENDERER=1`** bleibt fuer die Webview-Sichtbarkeit auf
|
||||
diesem Wayland/AMD-Setup noetig (Vorrecherche). Betrifft die WebKitGTK-Seite,
|
||||
nicht das wgpu-Fenster.
|
||||
- Das native winit-Fenster laeuft ueber Vulkan; GLES/EGL-Fallback wurde standalone
|
||||
ebenfalls funktionierend gesehen. `WGPU_BACKEND=gl` erzwingt bei Vulkan-Zicken
|
||||
den GL-Pfad.
|
||||
- `with_any_thread(true)` ist zwingend — ohne die Freigabe panict winit, weil es
|
||||
die Event-Loop sonst nur auf dem Hauptthread zulaesst (den haelt hier GTK).
|
||||
|
||||
## Bau-Isolierung
|
||||
|
||||
Alles hinter Cargo-Feature **`native2d`** in `cad-tauri` (opt-in). Der normale
|
||||
Build (`cargo build`, `npm run tauri:dev`) zieht weder winit noch wgpu und bleibt
|
||||
unveraendert. `render2d` bleibt ohne Tauri-/GTK-Abhaengigkeiten headless baubar
|
||||
und testbar.
|
||||
|
||||
## Naechster Schritt Richtung Vollbild-App (2D-Viewport + HTML-Chrome)
|
||||
|
||||
1. Szene aus dem echten Modell speisen: `Plan.primitives` (TS) → serde-`Scene` →
|
||||
ueber einen Tauri-`emit`/Command an den native2d-Thread (statt `demo_scene`).
|
||||
2. Pan/Zoom-State zwischen Webview-Chrome und native2d-Fenster synchronisieren
|
||||
(Tauri-Events beidseitig).
|
||||
3. Fenster-Kopplung: das native Fenster als Kind/als angedockten Bereich neben der
|
||||
Webview positionieren (Layout), spaeter ggf. Umstieg auf Ansatz C, wenn ein
|
||||
einziges Fenster gefordert ist.
|
||||
@@ -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>Dossier</title>
|
||||
<title>cad — Phase 0 Spike</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
{
|
||||
"name": "dossier",
|
||||
"name": "cad",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dossier",
|
||||
"name": "cad",
|
||||
"version": "0.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@mlightcad/libredwg-web": "^0.7.7",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"delaunator": "^5.1.0",
|
||||
"dxf-parser": "^1.1.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"opencascade.js": "^1.1.1",
|
||||
"polygon-clipping": "^0.15.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -875,271 +868,6 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz",
|
||||
"integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.0.3",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.3",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.3",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.3",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.3",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.3",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.3",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.3",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.3",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
@@ -2114,60 +1842,6 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
|
||||
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-http": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
|
||||
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-process": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
|
||||
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
@@ -3520,6 +3194,12 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/opencascade.js": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/opencascade.js/-/opencascade.js-1.1.1.tgz",
|
||||
"integrity": "sha512-lw6/vOl86+CkJ8d3V01mlbGAC0A49gc1HbwGcqGeKjk5SGRLiF15jyUuA8aYEvizcPNTu4Ta4A+Ut2DJgsa7AQ==",
|
||||
"license": "LGPL-2.1-only"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||
@@ -3543,18 +3223,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "6.2.108",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
|
||||
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
{
|
||||
"name": "dossier",
|
||||
"name": "cad",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Karim Gabriele Varano",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
@@ -18,25 +16,16 @@
|
||||
"shell": "scripts/chromium-shell.sh",
|
||||
"electron": "scripts/electron-shell.sh",
|
||||
"build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web",
|
||||
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
|
||||
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
|
||||
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web",
|
||||
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
|
||||
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mlightcad/libredwg-web": "^0.7.7",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"delaunator": "^5.1.0",
|
||||
"dxf-parser": "^1.1.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"jszip": "^3.10.1",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"opencascade.js": "^1.1.1",
|
||||
"polygon-clipping": "^0.15.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -56,12 +45,5 @@
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^4.1.9",
|
||||
"wasm-pack": "^0.15.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"core-js@3.49.0": true,
|
||||
"esbuild@0.21.5": true,
|
||||
"fsevents@2.3.3": true,
|
||||
"fsevents@2.3.2": true,
|
||||
"wasm-pack@0.15.0": true
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 702 KiB |
|
Before Width: | Height: | Size: 551 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"source": "ambientCG (ambientcg.com)",
|
||||
"license": "CC0 1.0 Universal (Public Domain)",
|
||||
"resolution": "2K",
|
||||
"resolution": "1K",
|
||||
"materials": [
|
||||
{
|
||||
"id": "Concrete048",
|
||||
@@ -61,18 +61,6 @@
|
||||
"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",
|
||||
@@ -156,4 +144,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
[workspace]
|
||||
members = ["."]
|
||||
# render2d/render3d sind eigenstaendige Crates, damit sie headless ohne
|
||||
# Tauri-Toolchain UND per wasm-pack (Feature "web") zu WASM baubar bleiben. Sie
|
||||
# liegen zwar im src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace —
|
||||
# hier ausschliessen, damit `cad-tauri` sie dennoch per Pfad als Abhaengigkeit
|
||||
# nutzen kann. kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von
|
||||
# kernel2d.ts), per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des
|
||||
# cad-tauri-Workspace.
|
||||
exclude = ["render2d", "render3d", "kernel2d", "dwgimport", "trucksolid"]
|
||||
members = [".", "geometry"]
|
||||
# render2d/render3d sind eigenstaendige Crates (jeweils eigener [workspace]), damit
|
||||
# sie headless ohne Tauri-Toolchain baubar/testbar bleiben. Sie liegen zwar im
|
||||
# src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace — hier ausschliessen,
|
||||
# damit `cad-tauri` sie dennoch per Pfad als (optionale) Abhaengigkeit nutzen kann.
|
||||
exclude = ["render2d", "render3d"]
|
||||
|
||||
[package]
|
||||
name = "cad-tauri"
|
||||
@@ -36,18 +33,9 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# OS-Advisory-Lock (gepflegter fs2-Nachfolger) fuer die Projektdatei-Lock-Datei
|
||||
# (src/lock.rs): exklusiver Lock auf einer Sidecar-Datei, faellt automatisch
|
||||
# beim Prozessende/Absturz weg (kein manuelles Aufraeumen noetig).
|
||||
fs4 = { version = "0.9", default-features = false, features = ["sync"] }
|
||||
geometry = { path = "geometry" }
|
||||
|
||||
# --- M2-Spike (nur mit Feature "native2d") -----------------------------------
|
||||
# render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region). Menu: fuer die native macOS-Systemmenueleiste (src/native/appMenu.ts). Webview/Event: fuer die nativen Ressourcen-/Einstellungs-/Zeichnungsebenen-/Ebeneneinstellungs-Fenster + ihre Sync-Bruecken (src/native/*Window.ts) - gilt fuer alle Fensterlabels, da jedes Events senden/empfangen muss. Dialog/Fs: nativer Oeffnen- UND Speichern-Dialog fuer Projektdateien (.obp/.json) plus Lesen/Schreiben der gewaehlten Datei (io/projectFile.ts, io/saveFile.ts) - open+read fuer Projekt laden, save+write fuer Projekt/Export speichern. Updater/Process: Selbst-Update-Check beim Start (src/update/checkForUpdate.ts) + Neustart nach Installation. Http: Versionsverlauf (versions.json vom Gitea-Release) ohne Browser-CORS, Scope auf den eigenen Gitea-Host begrenzt. Shell:allow-open: oeffnet den DMG-Download einer aelteren Version im System-Browser (Rollback). allow-set-size/allow-center: kompaktes Splash-/Startbildschirm-Fenster vor dem Editor (App.tsx bootPhase).",
|
||||
"windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-center",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"core:menu:default",
|
||||
"core:event:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
"updater:default",
|
||||
"process:allow-restart",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [{ "url": "https://git.openbureau.ch/*" }]
|
||||
},
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "153498e7c9b058326ff0fae11bc9fd870cf2ea0aa9111875d03625fa9018c218"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"nalgebra",
|
||||
"nom",
|
||||
"once_cell",
|
||||
"ryu",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dwgimport"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"console_error_panic_hook",
|
||||
"getrandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nalgebra"
|
||||
version = "0.32.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"matrixmultiply",
|
||||
"nalgebra-macros",
|
||||
"num-complex",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"simba",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nalgebra-macros"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "safe_arch"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simba"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"wide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"safe_arch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -1,36 +0,0 @@
|
||||
# Eigener leerer Workspace-Block: entkoppelt dwgimport vollstaendig vom
|
||||
# cad-tauri-Workspace (src-tauri/Cargo.toml). Muster: kernel2d/render2d.
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "dwgimport"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "DXF-Import-Probe: beweist, dass acadrust (MPL-2.0) aus einem Byte-Buffer parst und nach wasm32-unknown-unknown kompiliert."
|
||||
|
||||
# cdylib: fuer wasm-pack (Feature \"web\"). rlib: fuer cargo test + Pfad-Dep.
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
# Standard: headless, kein WASM-Overhead — per `cargo test` pruefbar.
|
||||
default = []
|
||||
# Browser-Bindings: wasm-bindgen-Fassade; nur fuer wasm32-unknown-unknown.
|
||||
web = ["dep:wasm-bindgen", "dep:serde_json", "dep:console_error_panic_hook"]
|
||||
|
||||
[dependencies]
|
||||
# DXF/DWG-Parser (MPL-2.0), von crates.io.
|
||||
acadrust = "0.4"
|
||||
|
||||
# getrandom wird transitiv via ahash→acadrust gezogen. Fuer wasm32 muss das
|
||||
# Feature "wasm_js" explizit aktiviert werden (sonst Compile-Fehler).
|
||||
# Direkte Dep mit Feature-Aktivierung unifiziert das Flag fuer alle Abhaengigen.
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
|
||||
# Serialisierung: immer vorhanden (fuer den headless parse_dxf_summary-Kern).
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
||||
# WASM-Bindings — nur im Feature "web" aktiv.
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
@@ -1,284 +0,0 @@
|
||||
// dwgimport — SPIKE: beweist, dass acadrust (MPL-2.0) aus einem Byte-Buffer
|
||||
// ein DXF parst und nach wasm32-unknown-unknown kompiliert.
|
||||
//
|
||||
// Aufbau:
|
||||
// - `parse_dxf_summary`: headless, feature-frei testbar.
|
||||
// - `#[cfg(feature="web")]` wasm-bindgen-Fassade darueber.
|
||||
//
|
||||
// API-Stuetzpunkt acadrust:
|
||||
// `DxfReader::from_reader<R: Read + Seek + 'static>(reader: R)`
|
||||
// → std::io::Cursor<&[u8]> implementiert Read + Seek → WASM-tauglich.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
use acadrust::DxfReader;
|
||||
|
||||
// serde_json nur bei Feature "web" als optionale Dep, aber serde ist immer da.
|
||||
// Fuer den headless-Kern bauen wir das JSON mit einem einfachen Format-String,
|
||||
// damit keine Compile-Time-Dep auf serde_json im Default-Feature noetig ist.
|
||||
|
||||
/// Parst ein DXF aus einem Byte-Slice und gibt ein JSON-Summary zurueck.
|
||||
///
|
||||
/// Rueckgabe-JSON-Schema:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "version": "AC1015",
|
||||
/// "entityCount": 5,
|
||||
/// "entityTypeCounts": { "LINE": 3, "CIRCLE": 2 },
|
||||
/// "layerNames": ["0", "Wand"]
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Fehler: String mit Fehlerbeschreibung.
|
||||
pub fn parse_dxf_summary(bytes: &[u8]) -> Result<String, String> {
|
||||
// Cursor<Vec<u8>> implementiert Read + Seek + 'static — kein std::fs noetig.
|
||||
// (from_reader verlangt R: 'static; Cursor<&[u8]> wuerde die Lifetime verletzen.)
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let reader = DxfReader::from_reader(cursor)
|
||||
.map_err(|e| format!("DxfReader::from_reader fehlgeschlagen: {e}"))?;
|
||||
|
||||
let doc = reader
|
||||
.read()
|
||||
.map_err(|e| format!("DxfReader::read fehlgeschlagen: {e}"))?;
|
||||
|
||||
// Versions-String aus dem Enum (Debug-Format, z. B. "AC1015").
|
||||
let version = format!("{:?}", doc.version);
|
||||
|
||||
// Entitaeten zaehlen und nach Typ aufschluesseln.
|
||||
let mut type_counts: HashMap<&str, usize> = HashMap::new();
|
||||
for entity in doc.entities() {
|
||||
let typ = entity.as_entity().entity_type();
|
||||
*type_counts.entry(typ).or_insert(0) += 1;
|
||||
}
|
||||
let entity_count = type_counts.values().sum::<usize>();
|
||||
|
||||
// Layer-Namen aus der Layer-Tabelle.
|
||||
let mut layer_names: Vec<String> = doc
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.name.clone())
|
||||
.collect();
|
||||
layer_names.sort();
|
||||
|
||||
// JSON-Serialisierung ohne optionale serde_json-Dep im Default-Feature.
|
||||
// Fuer den headless-Test reicht ein sauberer Format-String.
|
||||
let type_counts_json = {
|
||||
let mut pairs: Vec<String> = type_counts
|
||||
.iter()
|
||||
.map(|(k, v)| format!(" \"{k}\": {v}"))
|
||||
.collect();
|
||||
pairs.sort(); // deterministisch fuer Tests
|
||||
format!("{{\n{}\n }}", pairs.join(",\n"))
|
||||
};
|
||||
|
||||
let layer_names_json = {
|
||||
let quoted: Vec<String> = layer_names
|
||||
.iter()
|
||||
.map(|n| format!("\"{}\"", n.replace('"', "\\\"")))
|
||||
.collect();
|
||||
format!("[{}]", quoted.join(", "))
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"{{\n \"version\": \"{version}\",\n \"entityCount\": {entity_count},\n \"entityTypeCounts\": {type_counts_json},\n \"layerNames\": {layer_names_json}\n}}"
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WASM-Fassade (nur bei Feature "web" compiliert)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
mod wasm {
|
||||
use super::parse_dxf_summary;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Initialisiert den Panic-Hook fuer bessere Fehlermeldungen im Browser.
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init_panic_hook() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// WASM-Fassade: nimmt einen Byte-Buffer (Uint8Array aus JS) und gibt ein
|
||||
/// JSON-String zurueck, das das DXF-Summary beschreibt.
|
||||
///
|
||||
/// JS-Aufruf:
|
||||
/// ```js
|
||||
/// import init, { parse_dxf_summary_json } from './pkgDwgImport/dwgimport.js';
|
||||
/// await init();
|
||||
/// const json = parse_dxf_summary_json(new Uint8Array(buffer));
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub fn parse_dxf_summary_json(bytes: &[u8]) -> Result<String, JsValue> {
|
||||
parse_dxf_summary(bytes)
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimales ASCII-DXF mit 3 LINE- und 2 CIRCLE-Entitaeten auf Layer "WAND"
|
||||
/// und Layer "0". Wird direkt als Byte-Slice eingebettet — kein Datei-IO.
|
||||
const MINI_DXF: &str = r#" 0
|
||||
SECTION
|
||||
2
|
||||
HEADER
|
||||
9
|
||||
$ACADVER
|
||||
1
|
||||
AC1015
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
TABLES
|
||||
0
|
||||
TABLE
|
||||
2
|
||||
LAYER
|
||||
70
|
||||
2
|
||||
0
|
||||
LAYER
|
||||
2
|
||||
0
|
||||
70
|
||||
0
|
||||
62
|
||||
7
|
||||
6
|
||||
Continuous
|
||||
0
|
||||
LAYER
|
||||
2
|
||||
WAND
|
||||
70
|
||||
0
|
||||
62
|
||||
3
|
||||
6
|
||||
Continuous
|
||||
0
|
||||
ENDTAB
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
ENTITIES
|
||||
0
|
||||
LINE
|
||||
8
|
||||
WAND
|
||||
10
|
||||
0.0
|
||||
20
|
||||
0.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
100.0
|
||||
21
|
||||
0.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
LINE
|
||||
8
|
||||
WAND
|
||||
10
|
||||
100.0
|
||||
20
|
||||
0.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
100.0
|
||||
21
|
||||
50.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
LINE
|
||||
8
|
||||
0
|
||||
10
|
||||
100.0
|
||||
20
|
||||
50.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
0.0
|
||||
21
|
||||
0.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
CIRCLE
|
||||
8
|
||||
WAND
|
||||
10
|
||||
50.0
|
||||
20
|
||||
25.0
|
||||
30
|
||||
0.0
|
||||
40
|
||||
10.0
|
||||
0
|
||||
CIRCLE
|
||||
8
|
||||
0
|
||||
10
|
||||
20.0
|
||||
20
|
||||
10.0
|
||||
30
|
||||
0.0
|
||||
40
|
||||
5.0
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
EOF
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn test_parse_mini_dxf() {
|
||||
let result = parse_dxf_summary(MINI_DXF.as_bytes());
|
||||
assert!(result.is_ok(), "parse_dxf_summary schlug fehl: {:?}", result);
|
||||
|
||||
let json = result.unwrap();
|
||||
println!("DXF-Summary:\n{json}");
|
||||
|
||||
// Version muss AC1015 sein.
|
||||
assert!(json.contains("AC1015"), "Version AC1015 nicht im JSON: {json}");
|
||||
|
||||
// Genau 5 Entitaeten: 3 LINE + 2 CIRCLE.
|
||||
assert!(
|
||||
json.contains("\"entityCount\": 5"),
|
||||
"entityCount soll 5 sein: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"LINE\": 3"),
|
||||
"LINE-Zaehler soll 3 sein: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"CIRCLE\": 2"),
|
||||
"CIRCLE-Zaehler soll 2 sein: {json}"
|
||||
);
|
||||
|
||||
// Layer "WAND" und "0" muessen erscheinen.
|
||||
assert!(json.contains("\"WAND\""), "Layer WAND fehlt: {json}");
|
||||
assert!(json.contains("\"0\""), "Layer 0 fehlt: {json}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "geometry"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "geometry"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,16 @@
|
||||
// Paritaets-Harness: liest ein JoinInput-JSON von stdin, rechnet compute_joins
|
||||
// und schreibt das WallCuts-JSON nach stdout. Dient dem TS<->Rust-Vergleich
|
||||
// (siehe src/compute/parity.test.ts) — identische Eingabe, identische Ausgabe.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
fn main() {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.expect("stdin lesen");
|
||||
let input: geometry::JoinInput = serde_json::from_str(&buf).expect("JoinInput parsen");
|
||||
let cuts = geometry::compute_joins(input);
|
||||
let out = serde_json::to_string(&cuts).expect("WallCuts serialisieren");
|
||||
println!("{out}");
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
// Wand-Verschneidung (Gehrung): an einer Ecke, wo zwei Waende aufeinander-
|
||||
// treffen, sollen sich die Schicht-Baender nicht ueberlappen, sondern an einer
|
||||
// gemeinsamen Gehrungslinie sauber stossen. Dieses Modul berechnet pro Wand
|
||||
// die optionalen Schnittlinien an Start- und Endpunkt.
|
||||
//
|
||||
// Reine 2D-Mathematik, kein externer Kernel noetig. Portiert aus dem
|
||||
// TS-Modell (joins.ts / geometry.ts); die Vektor-Helfer add/sub/scale/
|
||||
// normalize/leftNormal/len/lineIntersect sind unten dupliziert.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy)]
|
||||
pub struct Vec2 {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// Eine unendliche Gerade als Stuetzpunkt + Richtung.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy)]
|
||||
pub struct Line {
|
||||
pub point: Vec2,
|
||||
pub dir: Vec2,
|
||||
}
|
||||
|
||||
/// Eine Schicht einer Wand, bereits aus dem WallType aufgeloest (Komponenten-Id
|
||||
/// + Prioritaet + Dicke) -- fuer die materialbewusste T-Stoss-Erweiterung
|
||||
/// (siehe `resolve_join_priority`/`apply_layer_cuts`). ADDITIV: fehlt der Key
|
||||
/// `layers` in der JSON-Eingabe, liefert `#[serde(default)]` eine leere Liste,
|
||||
/// sodass die bestehende Paritaets-Eingabe (ohne Schichten) unveraendert
|
||||
/// funktioniert.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct LayerInput {
|
||||
#[serde(rename = "componentId")]
|
||||
pub component_id: String,
|
||||
#[serde(rename = "joinPriority")]
|
||||
pub join_priority: f64,
|
||||
pub thickness: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct WallInput {
|
||||
pub id: String,
|
||||
pub start: Vec2,
|
||||
pub end: Vec2,
|
||||
pub thickness: f64,
|
||||
#[serde(rename = "referenceOffset")]
|
||||
pub reference_offset: f64,
|
||||
#[serde(default)]
|
||||
pub layers: Vec<LayerInput>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct JoinInput {
|
||||
pub walls: Vec<WallInput>,
|
||||
}
|
||||
|
||||
/// Pro-Schicht-Override der Nahflaechen-Cuts (materialbewusster T-Stoss).
|
||||
/// Index = Schicht-Index in `WallInput.layers` der Abzweig-Wand. Spiegelt
|
||||
/// `LayerCuts` aus `model/joins.ts` 1:1.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct LayerCuts {
|
||||
pub start: Vec<Option<Line>>,
|
||||
pub end: Vec<Option<Line>>,
|
||||
#[serde(rename = "startSide")]
|
||||
pub start_side: Vec<Option<Line>>,
|
||||
#[serde(rename = "endSide")]
|
||||
pub end_side: Vec<Option<Line>>,
|
||||
}
|
||||
|
||||
/// Schnittlinien einer Wand an ihren beiden Achsenden.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct WallCuts {
|
||||
#[serde(rename = "wallId")]
|
||||
pub wall_id: String,
|
||||
#[serde(rename = "startCut")]
|
||||
pub start_cut: Option<Line>,
|
||||
#[serde(rename = "endCut")]
|
||||
pub end_cut: Option<Line>,
|
||||
/// ADDITIV: nur gesetzt, wenn an einem T-Stoss mindestens eine Schicht
|
||||
/// materialbewusst verschmilzt (siehe `apply_layer_cuts`). Fehlt sie in der
|
||||
/// Ausgabe (`None` -> von serde bei Serialisierung weggelassen), gilt
|
||||
/// weiterhin `start_cut`/`end_cut` fuer alle Schichten.
|
||||
#[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")]
|
||||
pub layer_cuts: Option<LayerCuts>,
|
||||
}
|
||||
|
||||
/// Verschneidungs-Entscheidung zweier Bauteile am Stoss -- Rust-Spiegel von
|
||||
/// `JoinPriorityResolution`/`resolveJoinPriority` aus `model/joins.ts`.
|
||||
#[derive(PartialEq)]
|
||||
enum JoinPriorityResolution {
|
||||
Merge,
|
||||
TrimA,
|
||||
TrimB,
|
||||
Coexist,
|
||||
}
|
||||
|
||||
fn resolve_join_priority(
|
||||
a_id: &str,
|
||||
a_prio: f64,
|
||||
b_id: &str,
|
||||
b_prio: f64,
|
||||
) -> JoinPriorityResolution {
|
||||
if a_prio == b_prio {
|
||||
if a_id == b_id {
|
||||
JoinPriorityResolution::Merge
|
||||
} else {
|
||||
JoinPriorityResolution::Coexist
|
||||
}
|
||||
} else if a_prio > b_prio {
|
||||
JoinPriorityResolution::TrimB
|
||||
} else {
|
||||
JoinPriorityResolution::TrimA
|
||||
}
|
||||
}
|
||||
|
||||
/// Materialbewusste Pro-Schicht-Erweiterung eines T-Stoss-Cuts -- Rust-Spiegel
|
||||
/// von `applyLayerCuts` aus `model/joins.ts`. Vergleicht jede Schicht der
|
||||
/// Abzweig-Wand mit dem Rueckgrat (hoechste `joinPriority`) der Durchgangswand;
|
||||
/// verschmilzt sie (gleiches Rueckgrat-Material, oder die Abzweig-Schicht ist
|
||||
/// sogar prioritaerer), bekommt sie einen Cut an der NAHFLAECHE DES RUECKGRAT-
|
||||
/// KERNS der Durchgangswand -- der verschmelzende Kern sticht nur durch den
|
||||
/// Nah-Putz und stoppt am Durchgangs-Kern, statt bis zur Achse durchzulaufen.
|
||||
/// Getrimmte Schichten neben einer verschmelzenden Nachbarschicht bekommen
|
||||
/// zusaetzlich die seitliche L-Linie an eben dieser Rueckgrat-Nahflaeche.
|
||||
/// `sign`/`off_t` = Nahseite bzw. Referenzversatz der Durchgangswand.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply_layer_cuts(
|
||||
result: &mut [WallCuts],
|
||||
branch_wall_idx: usize,
|
||||
branch_end: WallEndKind,
|
||||
branch_layers: &[LayerInput],
|
||||
through_layers: &[LayerInput],
|
||||
face_cut: Line,
|
||||
axis_point: Vec2,
|
||||
through_dir: Vec2,
|
||||
sign: f64,
|
||||
off_t: f64,
|
||||
) {
|
||||
if branch_layers.is_empty() || through_layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rueckgrat der Durchgangswand: die Schicht mit der hoechsten joinPriority
|
||||
// (Index mitgefuehrt fuer die Nah-Putz-Dicke davor).
|
||||
let mut backbone = &through_layers[0];
|
||||
let mut backbone_idx = 0usize;
|
||||
for (i, l) in through_layers.iter().enumerate() {
|
||||
if l.join_priority > backbone.join_priority {
|
||||
backbone = l;
|
||||
backbone_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
let n = branch_layers.len();
|
||||
let mut merged = vec![false; n];
|
||||
for (i, layer) in branch_layers.iter().enumerate() {
|
||||
let res = resolve_join_priority(
|
||||
&layer.component_id,
|
||||
layer.join_priority,
|
||||
&backbone.component_id,
|
||||
backbone.join_priority,
|
||||
);
|
||||
merged[i] = matches!(res, JoinPriorityResolution::Merge | JoinPriorityResolution::TrimB);
|
||||
}
|
||||
if !merged.iter().any(|&m| m) {
|
||||
return; // keine Materialuebereinstimmung -> Default (kein layer_cuts)
|
||||
}
|
||||
|
||||
let cuts = &mut result[branch_wall_idx];
|
||||
if cuts.layer_cuts.is_none() {
|
||||
cuts.layer_cuts = Some(LayerCuts {
|
||||
start: vec![None; n],
|
||||
end: vec![None; n],
|
||||
start_side: vec![None; n],
|
||||
end_side: vec![None; n],
|
||||
});
|
||||
}
|
||||
let lc = cuts.layer_cuts.as_mut().unwrap();
|
||||
if lc.start.len() != n {
|
||||
return; // Schicht-Anzahl passt nicht zusammen (sollte nicht vorkommen)
|
||||
}
|
||||
|
||||
// Nahflaechen-Cut des verschmelzenden Kerns: von der Durchgangswand-Nah-
|
||||
// flaeche (off_t + sign*t_t/2) um die Nah-Putz-Dicke nach innen versetzt =
|
||||
// die dem Abzweig zugewandte Flaeche des Rueckgrat-Kerns. Nah-Putz = Summe
|
||||
// der Durchgangswand-Schichten zwischen Nahseite (sign) und Rueckgrat: auf
|
||||
// der +nT-Nahseite (sign>0) die Schichten hinter dem Rueckgrat
|
||||
// (Index > backbone_idx), auf der -nT-Nahseite die davor.
|
||||
let t_t: f64 = through_layers.iter().map(|l| l.thickness).sum();
|
||||
let mut near_plaster = 0.0;
|
||||
for (i, l) in through_layers.iter().enumerate() {
|
||||
let near = if sign > 0.0 { i > backbone_idx } else { i < backbone_idx };
|
||||
if near {
|
||||
near_plaster += l.thickness;
|
||||
}
|
||||
}
|
||||
let n_t = left_normal(through_dir);
|
||||
let merge_point = add(axis_point, scale(n_t, off_t + sign * (t_t / 2.0 - near_plaster)));
|
||||
let merge_cut = Line { point: merge_point, dir: through_dir };
|
||||
|
||||
// Getrimmte Nachbarschichten schliessen mit ihrer L-Seitenlinie an dieser
|
||||
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse).
|
||||
let side_line = merge_cut;
|
||||
let (face_arr, side_arr) = match branch_end {
|
||||
WallEndKind::Start => (&mut lc.start, &mut lc.start_side),
|
||||
WallEndKind::End => (&mut lc.end, &mut lc.end_side),
|
||||
};
|
||||
for i in 0..n {
|
||||
face_arr[i] = if merged[i] { Some(merge_cut) } else { Some(face_cut) };
|
||||
let adj_merged =
|
||||
!merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1]));
|
||||
side_arr[i] = if adj_merged { Some(side_line) } else { None };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Vektor-Helfer -----------------------------------------------------------
|
||||
|
||||
fn sub(a: Vec2, b: Vec2) -> Vec2 {
|
||||
Vec2 { x: a.x - b.x, y: a.y - b.y }
|
||||
}
|
||||
|
||||
fn add(a: Vec2, b: Vec2) -> Vec2 {
|
||||
Vec2 { x: a.x + b.x, y: a.y + b.y }
|
||||
}
|
||||
|
||||
fn scale(a: Vec2, s: f64) -> Vec2 {
|
||||
Vec2 { x: a.x * s, y: a.y * s }
|
||||
}
|
||||
|
||||
fn len(a: Vec2) -> f64 {
|
||||
a.x.hypot(a.y)
|
||||
}
|
||||
|
||||
fn normalize(a: Vec2) -> Vec2 {
|
||||
let l = len(a);
|
||||
let l = if l == 0.0 { 1.0 } else { l };
|
||||
Vec2 { x: a.x / l, y: a.y / l }
|
||||
}
|
||||
|
||||
/// Linke Normale (90 Grad gegen den Uhrzeigersinn gedreht).
|
||||
fn left_normal(a: Vec2) -> Vec2 {
|
||||
Vec2 { x: -a.y, y: a.x }
|
||||
}
|
||||
|
||||
/// Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren.
|
||||
fn cross(p: Vec2, q: Vec2) -> f64 {
|
||||
p.x * q.y - p.y * q.x
|
||||
}
|
||||
|
||||
/// Skalarprodukt zweier 2D-Vektoren.
|
||||
fn dot(p: Vec2, q: Vec2) -> f64 {
|
||||
p.x * q.x + p.y * q.y
|
||||
}
|
||||
|
||||
/// Schnittpunkt der Geraden (a + t*da) mit (b + s*db).
|
||||
/// Liefert None bei (nahezu) parallelen Richtungen.
|
||||
fn line_intersect(a: Vec2, da: Vec2, b: Vec2, db: Vec2) -> Option<Vec2> {
|
||||
let denom = cross(da, db);
|
||||
if denom.abs() < 1e-9 {
|
||||
return None; // parallel -> kein Schnitt
|
||||
}
|
||||
let t = cross(sub(b, a), db) / denom;
|
||||
Some(add(a, scale(da, t)))
|
||||
}
|
||||
|
||||
// --- Verschneidung -----------------------------------------------------------
|
||||
|
||||
/// Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren.
|
||||
fn round_key(p: Vec2) -> String {
|
||||
let r = |v: f64| (v * 1e4).round() / 1e4;
|
||||
format!("{},{}", r(p.x), r(p.y))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum WallEndKind {
|
||||
Start,
|
||||
End,
|
||||
}
|
||||
|
||||
struct WallEnd {
|
||||
wall_id: String,
|
||||
end: WallEndKind,
|
||||
}
|
||||
|
||||
/// Achsrichtung start->end, normalisiert.
|
||||
fn dir_of(w: &WallInput) -> Vec2 {
|
||||
normalize(sub(w.end, w.start))
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet fuer jede Wand die Gehrungs-Schnittlinien.
|
||||
* Behandelt werden: L-Ecken (genau zwei Wandenden treffen sich), T-Knoten
|
||||
* (drei Wandenden treffen sich, zwei davon kollinear) und Mittelspannen-
|
||||
* T-Stoesse (ein freies Wandende trifft auf die Seite einer anderen Wand).
|
||||
* X-Stoesse (vier und mehr Enden) bleiben vorerst rechtwinklig.
|
||||
*/
|
||||
pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
|
||||
let walls = input.walls;
|
||||
|
||||
// Ergebnis: pro Wand ein Eintrag, Reihenfolge wie in der Eingabe.
|
||||
let mut result: Vec<WallCuts> = walls
|
||||
.iter()
|
||||
.map(|w| WallCuts {
|
||||
wall_id: w.id.clone(),
|
||||
start_cut: None,
|
||||
end_cut: None,
|
||||
layer_cuts: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Index Wand-Id -> Position im Ergebnis/Eingabe.
|
||||
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
for (i, w) in walls.iter().enumerate() {
|
||||
index.insert(w.id.as_str(), i);
|
||||
}
|
||||
|
||||
// Knotenkarte: gerundeter Endpunkt -> Liste der dort endenden Wandenden.
|
||||
// BTreeMap fuer deterministische Reihenfolge.
|
||||
let mut junctions: std::collections::BTreeMap<String, Vec<WallEnd>> =
|
||||
std::collections::BTreeMap::new();
|
||||
let push = |p: Vec2, we: WallEnd, m: &mut std::collections::BTreeMap<String, Vec<WallEnd>>| {
|
||||
m.entry(round_key(p)).or_default().push(we);
|
||||
};
|
||||
for w in &walls {
|
||||
push(
|
||||
w.start,
|
||||
WallEnd { wall_id: w.id.clone(), end: WallEndKind::Start },
|
||||
&mut junctions,
|
||||
);
|
||||
push(
|
||||
w.end,
|
||||
WallEnd { wall_id: w.id.clone(), end: WallEndKind::End },
|
||||
&mut junctions,
|
||||
);
|
||||
}
|
||||
|
||||
for (_key, ends) in &junctions {
|
||||
// Freies Ende -> kein Schnitt hier (wird unten separat auf Mittelspannen-T
|
||||
// geprueft, Fall 2).
|
||||
if ends.len() == 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ends.len() == 2 {
|
||||
let a_idx = match index.get(ends[0].wall_id.as_str()) {
|
||||
Some(i) => *i,
|
||||
None => continue,
|
||||
};
|
||||
let b_idx = match index.get(ends[1].wall_id.as_str()) {
|
||||
Some(i) => *i,
|
||||
None => continue,
|
||||
};
|
||||
let a = &walls[a_idx];
|
||||
let b = &walls[b_idx];
|
||||
|
||||
let cut = match miter_line(a, ends[0].end, b) {
|
||||
Some(c) => c,
|
||||
None => continue, // kollinear -> kein Schnitt
|
||||
};
|
||||
|
||||
set_cut(&mut result[a_idx], ends[0].end, cut);
|
||||
set_cut(&mut result[b_idx], ends[1].end, cut);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ends.len() == 3 {
|
||||
apply_t_junction(&walls, &index, ends, &mut result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// X-Stoesse u. ae. (>3 Enden): vorerst rechtwinklig lassen (Folge-Arbeit).
|
||||
}
|
||||
|
||||
// Fall 2: Mittelspannen-T-Stoss. Ein freies Wandende (kein anderes Wandende
|
||||
// teilt sich seinen Knoten) kann trotzdem auf die Seite einer anderen Wand
|
||||
// treffen (nicht an deren Endpunkten). In dem Fall bekommt das freie Ende
|
||||
// einen Schnitt entlang der zugewandten Flaeche dieser Wand.
|
||||
for (_key, ends) in &junctions {
|
||||
if ends.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
apply_mid_span_tee(&walls, &index, &ends[0], &mut result);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Traegt eine Schnittlinie am passenden Ende einer Wand ein.
|
||||
fn set_cut(cuts: &mut WallCuts, end: WallEndKind, cut: Line) {
|
||||
match end {
|
||||
WallEndKind::Start => cuts.start_cut = Some(cut),
|
||||
WallEndKind::End => cuts.end_cut = Some(cut),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fall 1 -- T-Knoten: drei Wandenden treffen sich im selben (gerundeten)
|
||||
* Knoten. Typisch sind zwei davon kollinear (die zwei Haelften der
|
||||
* Durchgangswand, die geometrisch als eine Achse durchlaeuft) und das dritte
|
||||
* zweigt ab. Die kollinearen zwei bleiben ungeschnitten; der Abzweig bekommt
|
||||
* einen Schnitt entlang der ihm zugewandten Flaeche der Durchgangswand.
|
||||
* Findet sich kein eindeutig kollineares Paar (z. B. echter Y-/X-Knoten),
|
||||
* bleibt der Knoten unangetastet (rechtwinklig).
|
||||
*/
|
||||
fn apply_t_junction(
|
||||
walls: &[WallInput],
|
||||
index: &std::collections::HashMap<&str, usize>,
|
||||
ends: &[WallEnd],
|
||||
result: &mut [WallCuts],
|
||||
) {
|
||||
let idx_of = |id: &str| index.get(id).copied();
|
||||
|
||||
let dirs: Vec<Vec2> = match ends
|
||||
.iter()
|
||||
.map(|e| idx_of(e.wall_id.as_str()).map(|i| dir_of(&walls[i])))
|
||||
.collect::<Option<Vec<Vec2>>>()
|
||||
{
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Suche das (naeherungsweise) kollineare Paar: |cos(Winkel der Achsen)| ~ 1.
|
||||
let mut through_pair: Option<(usize, usize)> = None;
|
||||
'outer: for i in 0..3 {
|
||||
for k in (i + 1)..3 {
|
||||
if 1.0 - dot(dirs[i], dirs[k]).abs() < 1e-6 {
|
||||
through_pair = Some((i, k));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (ti, tk) = match through_pair {
|
||||
Some(p) => p,
|
||||
None => return, // kein eindeutiger Durchgang erkennbar
|
||||
};
|
||||
|
||||
let branch_idx = match (0..3).find(|&i| i != ti && i != tk) {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
};
|
||||
let branch_end = &ends[branch_idx];
|
||||
let through_end = &ends[ti];
|
||||
let branch_wall_idx = match idx_of(branch_end.wall_id.as_str()) {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
};
|
||||
let through_wall_idx = match idx_of(through_end.wall_id.as_str()) {
|
||||
Some(i) => i,
|
||||
None => return,
|
||||
};
|
||||
let branch_wall = &walls[branch_wall_idx];
|
||||
let through_wall = &walls[through_wall_idx];
|
||||
|
||||
let j = match through_end.end {
|
||||
WallEndKind::Start => through_wall.start,
|
||||
WallEndKind::End => through_wall.end,
|
||||
};
|
||||
let t_t = through_wall.thickness;
|
||||
let u_t = dir_of(through_wall);
|
||||
let n_t = left_normal(u_t);
|
||||
let off_t = through_wall.reference_offset;
|
||||
|
||||
// Auslaufrichtung des Abzweigs vom Knoten weg, in seinen eigenen Koerper
|
||||
// (gleiche Konvention wie d_a/d_b in miter_line).
|
||||
let d_branch = match branch_end.end {
|
||||
WallEndKind::Start => dir_of(branch_wall),
|
||||
WallEndKind::End => scale(dir_of(branch_wall), -1.0),
|
||||
};
|
||||
|
||||
// Zugewandte Flaeche: die Seite der Durchgangsachse, zu der der Abzweig
|
||||
// zeigt (positive n_t-Seite, wenn der Abzweig dorthin auslaeuft).
|
||||
let sign = if dot(n_t, d_branch) >= 0.0 { 1.0 } else { -1.0 };
|
||||
let face_point = add(j, scale(n_t, off_t + sign * (t_t / 2.0)));
|
||||
let face_cut = Line { point: face_point, dir: u_t };
|
||||
|
||||
set_cut(&mut result[branch_wall_idx], branch_end.end, face_cut);
|
||||
apply_layer_cuts(
|
||||
result,
|
||||
branch_wall_idx,
|
||||
branch_end.end,
|
||||
&branch_wall.layers,
|
||||
&through_wall.layers,
|
||||
face_cut,
|
||||
j,
|
||||
u_t,
|
||||
sign,
|
||||
off_t,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fall 2 -- Mittelspannen-T-Stoss: prueft, ob das freie Wandende `free_end`
|
||||
* auf die innere Spanne einer anderen Wand trifft (projizierter Parameter im
|
||||
* Inneren, nicht an deren Endpunkten) und dabei senkrecht nah genug an deren
|
||||
* Achse liegt (<= halbe Dicke + Toleranz). Bei Treffer bekommt das freie Ende
|
||||
* einen Schnitt entlang der zugewandten Flaeche der getroffenen Wand; bei
|
||||
* mehreren Treffern gewinnt die naechstliegende Wand.
|
||||
*/
|
||||
fn apply_mid_span_tee(
|
||||
walls: &[WallInput],
|
||||
index: &std::collections::HashMap<&str, usize>,
|
||||
free_end: &WallEnd,
|
||||
result: &mut [WallCuts],
|
||||
) {
|
||||
let free_wall_idx = match index.get(free_end.wall_id.as_str()) {
|
||||
Some(i) => *i,
|
||||
None => return,
|
||||
};
|
||||
let free_wall = &walls[free_wall_idx];
|
||||
let p = match free_end.end {
|
||||
WallEndKind::Start => free_wall.start,
|
||||
WallEndKind::End => free_wall.end,
|
||||
};
|
||||
|
||||
let end_gap = 1e-4; // Mindestabstand zu den Wandenden fuer "innere Spanne"
|
||||
let tol = 1e-3; // Toleranz fuer den senkrechten Abstand zur Wandflaeche
|
||||
|
||||
// (Index in `walls`, perp-Abstand vorzeichenbehaftet, absoluter Abstand).
|
||||
let mut best: Option<(usize, f64, f64)> = None;
|
||||
for (i, w) in walls.iter().enumerate() {
|
||||
if w.id == free_wall.id {
|
||||
continue;
|
||||
}
|
||||
let u = dir_of(w);
|
||||
let w_len = len(sub(w.end, w.start));
|
||||
if w_len < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rel = sub(p, w.start);
|
||||
let dist_along = dot(rel, u);
|
||||
if dist_along <= end_gap || dist_along >= w_len - end_gap {
|
||||
continue; // an Endpunkt, kein Mittelspann
|
||||
}
|
||||
|
||||
let perp = dot(rel, left_normal(u));
|
||||
let tw = w.thickness;
|
||||
let dist = perp.abs();
|
||||
if dist > tw / 2.0 + tol {
|
||||
continue;
|
||||
}
|
||||
|
||||
if best.map_or(true, |(_, _, best_dist)| dist < best_dist) {
|
||||
best = Some((i, perp, dist));
|
||||
}
|
||||
}
|
||||
let (w_idx, perp, _dist) = match best {
|
||||
Some(b) => b,
|
||||
None => return, // kein Treffer -> rechtwinklig
|
||||
};
|
||||
|
||||
let w = &walls[w_idx];
|
||||
let tw = w.thickness;
|
||||
let u_w = dir_of(w);
|
||||
let n_w = left_normal(u_w);
|
||||
let off_w = w.reference_offset;
|
||||
let sign = if perp >= 0.0 { 1.0 } else { -1.0 };
|
||||
let face_point = add(w.start, scale(n_w, off_w + sign * (tw / 2.0)));
|
||||
let face_cut = Line { point: face_point, dir: u_w };
|
||||
|
||||
set_cut(&mut result[free_wall_idx], free_end.end, face_cut);
|
||||
// Achsenpunkt auf der getroffenen Wand, auf Hoehe der Projektion des
|
||||
// freien Endes (nicht zwingend w.start) -- Anker fuer die
|
||||
// materialbewusste Pro-Schicht-Erweiterung (analog zum T-Knoten-Fall).
|
||||
let dist_along = dot(sub(p, w.start), u_w);
|
||||
let axis_point = add(w.start, scale(u_w, dist_along));
|
||||
apply_layer_cuts(
|
||||
result,
|
||||
free_wall_idx,
|
||||
free_end.end,
|
||||
&free_wall.layers,
|
||||
&w.layers,
|
||||
face_cut,
|
||||
axis_point,
|
||||
u_w,
|
||||
sign,
|
||||
off_w,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsame Gehrungslinie zweier Waende A, B, die sich im Knoten J treffen.
|
||||
* Robust gegen beliebige Wicklung, ungleiche Dicken und JEDEN Oeffnungswinkel
|
||||
* (inkl. spitzer): Die Wandflaechen werden orientierungsbasiert (vorzeichen-
|
||||
* richtig) gepaart -- A's Aussenflaeche verschneidet B's Aussenflaeche zum
|
||||
* aeusseren Apex, A's Innenflaeche B's Innenflaeche zum inneren Apex. "Aussen"
|
||||
* ist jeweils die vom Koerper der anderen Wand ABGEWANDTE Flaeche. Die Gerade
|
||||
* durch beide Apexe ist die Gehrung; sie trennt beide Wandbaender ueberlappungs-
|
||||
* frei.
|
||||
*
|
||||
* Die fruehere Distanz-Heuristik ("naechstgelegene B-Flaeche") kippt bei spitzen
|
||||
* Winkeln -- dort wird die falsche Flaeche zur naeheren, sodass Aussen mit Innen
|
||||
* gepaart wird und die Poche-Baender sich kreuzweise ueberlappen.
|
||||
*
|
||||
* Dicke und Referenzversatz kommen direkt aus WallInput (bereits flach).
|
||||
*/
|
||||
fn miter_line(a: &WallInput, a_end: WallEndKind, b: &WallInput) -> Option<Line> {
|
||||
let j = match a_end {
|
||||
WallEndKind::Start => a.start,
|
||||
WallEndKind::End => a.end,
|
||||
};
|
||||
|
||||
let t_a = a.thickness;
|
||||
let t_b = b.thickness;
|
||||
let u_a = dir_of(a);
|
||||
let u_b = dir_of(b);
|
||||
let n_a = left_normal(u_a);
|
||||
let n_b = left_normal(u_b);
|
||||
|
||||
// Referenzlinien-Versatz: liegt die Achse nicht mittig, sind die beiden
|
||||
// Wandflaechen um diesen Betrag entlang +n verschoben. Fuer "center"
|
||||
// (Default) ist off=0 -> unveraendert.
|
||||
let off_a = a.reference_offset;
|
||||
let off_b = b.reference_offset;
|
||||
|
||||
// Flaechen-Stuetzpunkte am Knoten (linke/rechte Wandseite), inkl. Versatz.
|
||||
let p_la = add(j, scale(n_a, off_a + t_a / 2.0));
|
||||
let p_ra = add(j, scale(n_a, off_a - t_a / 2.0));
|
||||
let p_lb = add(j, scale(n_b, off_b + t_b / 2.0));
|
||||
let p_rb = add(j, scale(n_b, off_b - t_b / 2.0));
|
||||
|
||||
// Auslauf-Richtungen der Achsen vom Knoten weg, in den jeweiligen Wandkoerper.
|
||||
let d_a = match a_end {
|
||||
WallEndKind::Start => u_a,
|
||||
WallEndKind::End => scale(u_a, -1.0),
|
||||
};
|
||||
let b_at_start = len(sub(b.start, j)) <= len(sub(b.end, j));
|
||||
let d_b = if b_at_start { u_b } else { scale(u_b, -1.0) };
|
||||
|
||||
// Orientierungsbasierte, vorzeichenrichtige Flaechen-Paarung. "Aussen" ist die
|
||||
// vom Koerper der anderen Wand ABGEWANDTE Flaeche: A's +nA-Flaeche (p_la) liegt
|
||||
// aussen, wenn nA*dB < 0; B's +nB-Flaeche (p_lb) aussen, wenn nB*dA < 0. Gepaart
|
||||
// wird Aussen-mit-Aussen und Innen-mit-Innen -- nie ueber Kreuz. (nA*dB = 0 tritt
|
||||
// nur bei kollinearen Achsen auf; dann sind die Flaechen parallel und
|
||||
// line_intersect liefert unten ohnehin None.)
|
||||
let b_outer = if dot(n_b, d_a) < 0.0 { p_lb } else { p_rb };
|
||||
let b_inner = if dot(n_b, d_a) < 0.0 { p_rb } else { p_lb };
|
||||
let a_left_is_outer = dot(n_a, d_b) < 0.0;
|
||||
let b_for_left = if a_left_is_outer { b_outer } else { b_inner };
|
||||
let b_for_right = if a_left_is_outer { b_inner } else { b_outer };
|
||||
|
||||
// c1 an A's linke Flaeche (p_la), c2 an A's rechte (p_ra) gebunden -- Reihenfolge
|
||||
// wie zuvor, damit rechte/stumpfe Ecken bit-identisch bleiben; nur die
|
||||
// B-Partnerwahl ist jetzt orientierungs- statt distanzbasiert.
|
||||
let c1 = line_intersect(p_la, u_a, b_for_left, u_b)?;
|
||||
let c2 = line_intersect(p_ra, u_a, b_for_right, u_b)?;
|
||||
|
||||
let dir = sub(c2, c1);
|
||||
if len(dir) < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
Some(Line { point: c1, dir })
|
||||
}
|
||||
|
||||
// --- Tests -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn w(id: &str, sx: f64, sy: f64, ex: f64, ey: f64, thickness: f64) -> WallInput {
|
||||
WallInput {
|
||||
id: id.to_string(),
|
||||
start: Vec2 { x: sx, y: sy },
|
||||
end: Vec2 { x: ex, y: ey },
|
||||
thickness,
|
||||
reference_offset: 0.0,
|
||||
layers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn find<'a>(cuts: &'a [WallCuts], id: &str) -> &'a WallCuts {
|
||||
cuts.iter().find(|c| c.wall_id == id).expect("wall id present")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l_corner_shared_miter() {
|
||||
// Wand A (0,0)-(5,0) und Wand B (5,0)-(5,4) treffen sich in (5,0).
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
|
||||
w("B", 5.0, 0.0, 5.0, 4.0, 0.2),
|
||||
],
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
assert_eq!(out.len(), 2);
|
||||
|
||||
let ca = find(&out, "A");
|
||||
let cb = find(&out, "B");
|
||||
|
||||
// A endet im Knoten -> endCut gesetzt; B startet dort -> startCut gesetzt.
|
||||
let a_cut = ca.end_cut.expect("A endCut set");
|
||||
let b_cut = cb.start_cut.expect("B startCut set");
|
||||
assert!(ca.start_cut.is_none(), "A startCut is free end");
|
||||
assert!(cb.end_cut.is_none(), "B endCut is free end");
|
||||
|
||||
// Beide Waende teilen sich dieselbe Gehrungslinie.
|
||||
assert!((a_cut.point.x - b_cut.point.x).abs() < 1e-9);
|
||||
assert!((a_cut.point.y - b_cut.point.y).abs() < 1e-9);
|
||||
assert!((a_cut.dir.x - b_cut.dir.x).abs() < 1e-9);
|
||||
assert!((a_cut.dir.y - b_cut.dir.y).abs() < 1e-9);
|
||||
|
||||
// Sanity: die Gehrung einer 90-Grad-Ecke gleicher Dicke ist die
|
||||
// Diagonale durch (5,0), Richtung parallel zu (1,1) oder (-1,-1).
|
||||
let d = normalize(a_cut.dir);
|
||||
assert!(
|
||||
(d.x.abs() - d.y.abs()).abs() < 1e-6,
|
||||
"45-Grad-Gehrung erwartet"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acute_corner_pairs_outer_with_outer() {
|
||||
// Symmetrische V-Ecke, Knoten (0,0), Oeffnungswinkel 60 Grad (spitz),
|
||||
// nach oben. Beide Wandkoerper laufen unter +/-30 Grad zur +y-Achse aus.
|
||||
// WA endet im Knoten, WB startet dort. Erwartet: vertikale Gehrung durch
|
||||
// Aussen-Apex (0,-0.4) und Innen-Apex (0,0.4). Die alte Distanz-Heuristik
|
||||
// lieferte hier eine um 90 Grad verdrehte, horizontale Gehrung.
|
||||
let s3 = 3.0_f64.sqrt() / 2.0;
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w("WA", -1.0, 2.0 * s3, 0.0, 0.0, 0.4),
|
||||
w("WB", 0.0, 0.0, 1.0, 2.0 * s3, 0.4),
|
||||
],
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
let ca = find(&out, "WA");
|
||||
let cb = find(&out, "WB");
|
||||
let cut = ca.end_cut.or(cb.start_cut).expect("Gehrung gesetzt");
|
||||
|
||||
// Abstand Punkt->Gerade: |cross(dir, q - point)| / len(dir).
|
||||
let dist = |q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir);
|
||||
assert!(dist(Vec2 { x: 0.0, y: -0.4 }) < 1e-6, "Aussen-Apex auf Gehrung");
|
||||
assert!(dist(Vec2 { x: 0.0, y: 0.4 }) < 1e-6, "Innen-Apex auf Gehrung");
|
||||
// Gehrung vertikal: dir.x ~ 0 (die falsche, horizontale Gehrung haette
|
||||
// stattdessen dir.y ~ 0 gehabt).
|
||||
assert!(cut.dir.x.abs() / len(cut.dir) < 1e-6, "vertikale Gehrung erwartet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_end_no_cut() {
|
||||
let input = JoinInput {
|
||||
walls: vec![w("A", 0.0, 0.0, 5.0, 0.0, 0.2)],
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
assert_eq!(out.len(), 1);
|
||||
let ca = find(&out, "A");
|
||||
assert!(ca.start_cut.is_none());
|
||||
assert!(ca.end_cut.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_junction_branch_gets_face_cut() {
|
||||
// Drei Enden treffen sich in (5,0): A/C sind kollinear (Durchgangswand),
|
||||
// B zweigt ab. A/C bleiben ungeschnitten; B bekommt einen Schnitt entlang
|
||||
// der ihm zugewandten Flaeche der Durchgangswand (hier: y = +0.1, da B
|
||||
// nach +y auslaeuft und die Durchgangswand entlang +x verlaeuft, also
|
||||
// liegt deren linke Flaeche bei +0.1).
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
|
||||
w("B", 5.0, 0.0, 5.0, 4.0, 0.2),
|
||||
w("C", 5.0, 0.0, 10.0, 0.0, 0.2),
|
||||
],
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
assert_eq!(out.len(), 3);
|
||||
|
||||
let ca = find(&out, "A");
|
||||
let cc = find(&out, "C");
|
||||
assert!(ca.start_cut.is_none(), "A startCut ist freies Ende");
|
||||
assert!(ca.end_cut.is_none(), "A durchgehend -> kein Schnitt");
|
||||
assert!(cc.start_cut.is_none(), "C durchgehend -> kein Schnitt");
|
||||
assert!(cc.end_cut.is_none(), "C endCut ist freies Ende");
|
||||
|
||||
let cb = find(&out, "B");
|
||||
let cut = cb.start_cut.expect("B startCut gesetzt");
|
||||
assert!(cb.end_cut.is_none(), "B endCut ist freies Ende");
|
||||
assert!((cut.point.x - 5.0).abs() < 1e-9);
|
||||
assert!((cut.point.y - 0.1).abs() < 1e-9, "Flaeche bei y=+0.1 erwartet");
|
||||
// Schnittlinie verlaeuft entlang der Durchgangsachse (parallel zu +x).
|
||||
assert!(cut.dir.y.abs() / len(cut.dir) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collinear_pair_no_cut() {
|
||||
// A (0,0)-(5,0) und B (5,0)-(10,0): parallel -> None.
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
|
||||
w("B", 5.0, 0.0, 10.0, 0.0, 0.2),
|
||||
],
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
assert_eq!(out.len(), 2);
|
||||
for id in ["A", "B"] {
|
||||
let c = find(&out, id);
|
||||
assert!(c.start_cut.is_none(), "{id} startCut none");
|
||||
assert!(c.end_cut.is_none(), "{id} endCut none");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_span_tee_free_end_hits_wall_side() {
|
||||
// Freie Wand B endet bei (5,0) exakt auf der Achse der durchgehenden
|
||||
// Wand A (0,0)-(10,0), mittig auf deren Spanne (nicht an deren Enden).
|
||||
// B's anderes Ende (5,2) bleibt frei (zu weit von A entfernt). Der
|
||||
// Treffer liegt exakt auf A's Achse (perp=0) -> die zugewandte Flaeche
|
||||
// ist A's linke Flaeche bei y=+tw/2=+0.1 (sign=+1 per Konvention bei
|
||||
// perp>=0). Die Schnittlinie ist als Punkt+Richtung auf A's Anker
|
||||
// (A.start) verankert, verlaeuft aber unabhaengig davon entlang y=0.1.
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w("A", 0.0, 0.0, 10.0, 0.0, 0.2),
|
||||
w("B", 5.0, 2.0, 5.0, 0.0, 0.2),
|
||||
],
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
assert_eq!(out.len(), 2);
|
||||
|
||||
let ca = find(&out, "A");
|
||||
assert!(ca.start_cut.is_none(), "A durchgehend -> kein Schnitt");
|
||||
assert!(ca.end_cut.is_none(), "A durchgehend -> kein Schnitt");
|
||||
|
||||
let cb = find(&out, "B");
|
||||
assert!(cb.start_cut.is_none(), "B startCut ist freies Ende bei (5,2)");
|
||||
let cut = cb.end_cut.expect("B endCut (Mittelspann-Treffer) gesetzt");
|
||||
|
||||
// Verankert an A.start = (0,0), verschoben um A's halbe Dicke entlang
|
||||
// ihrer linken Normale (0,1) -> Punkt (0, 0.1), Richtung parallel zu A.
|
||||
assert!((cut.point.x - 0.0).abs() < 1e-9);
|
||||
assert!((cut.point.y - 0.1).abs() < 1e-9);
|
||||
assert!(cut.dir.y.abs() / len(cut.dir) < 1e-9, "Schnittlinie parallel zu A");
|
||||
|
||||
// Robuster: die Gerade verlaeuft exakt bei y=0.1, unabhaengig vom x.
|
||||
let dist = |q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir);
|
||||
assert!(dist(Vec2 { x: 5.0, y: 0.1 }) < 1e-9, "Treffpunkt (5,0.1) auf Gehrung");
|
||||
}
|
||||
|
||||
/// W9-artiger Wandtyp: Innenputz (0.015) / Backstein-Kern (0.12) / Innenputz
|
||||
/// (0.015), joinPriority 10 bzw. 50 -- Spiegel von `layeredWallProject` aus
|
||||
/// `model/joins.test.ts`.
|
||||
fn w_iw(id: &str, sx: f64, sy: f64, ex: f64, ey: f64) -> WallInput {
|
||||
WallInput {
|
||||
id: id.to_string(),
|
||||
start: Vec2 { x: sx, y: sy },
|
||||
end: Vec2 { x: ex, y: ey },
|
||||
thickness: 0.15,
|
||||
reference_offset: 0.0,
|
||||
layers: vec![
|
||||
LayerInput { component_id: "render-int".into(), join_priority: 10.0, thickness: 0.015 },
|
||||
LayerInput { component_id: "brick".into(), join_priority: 50.0, thickness: 0.12 },
|
||||
LayerInput { component_id: "render-int".into(), join_priority: 10.0, thickness: 0.015 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_junction_layer_cuts_merge_and_trim() {
|
||||
// Wie t_junction_branch_gets_face_cut, aber alle drei Waende vom
|
||||
// W9-artigen Wandtyp: der Backstein-Kern (Index 1) sticht durch den
|
||||
// Nah-Putz und stoppt an der Kern-Nahflaeche (y=0.06), die beiden
|
||||
// Innenputz-Schichten (Index 0/2) werden getrimmt und bekommen
|
||||
// zusaetzlich die seitliche L-Linie an derselben Flaeche.
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w_iw("A", 0.0, 0.0, 5.0, 0.0),
|
||||
w_iw("B", 5.0, 0.0, 5.0, 3.0),
|
||||
w_iw("C", 5.0, 0.0, 10.0, 0.0),
|
||||
],
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
let cb = find(&out, "B");
|
||||
|
||||
// Aggregat-Cut bleibt unveraendert (bit-identisch zum bisherigen Verhalten).
|
||||
let agg = cb.start_cut.expect("B startCut gesetzt");
|
||||
assert!((agg.point.x - 5.0).abs() < 1e-9);
|
||||
assert!((agg.point.y - 0.075).abs() < 1e-9);
|
||||
|
||||
let lc = cb.layer_cuts.as_ref().expect("layerCuts gesetzt (Backstein verschmilzt)");
|
||||
assert_eq!(lc.start.len(), 3);
|
||||
|
||||
let dist = |cut: Line, q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir);
|
||||
|
||||
// Schicht 1 (Backstein-Kern): Cut an der Rueckgrat-Nahflaeche y=0.06
|
||||
// (Halbdicke 0.075 minus Nah-Putz 0.015), NICHT null/Achse; keine
|
||||
// Seitenlinie (verschmilzt).
|
||||
let l1 = lc.start[1].expect("Schicht 1 (Kern) hat Merge-Cut");
|
||||
assert!(dist(l1, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||
assert!(dist(l1, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
|
||||
assert!(dist(l1, Vec2 { x: 5.0, y: 0.0 }) > 1e-6); // nicht bis zur Achse
|
||||
assert!(lc.start_side[1].is_none());
|
||||
|
||||
// Schichten 0/2 (Innenputz): derselbe Nahflaechen-Cut wie das Aggregat...
|
||||
let l0 = lc.start[0].expect("Schicht 0 getrimmt");
|
||||
let l2 = lc.start[2].expect("Schicht 2 getrimmt");
|
||||
assert!((l0.point.y - 0.075).abs() < 1e-9);
|
||||
assert!((l2.point.y - 0.075).abs() < 1e-9);
|
||||
// ... UND die seitliche L-Linie an der Rueckgrat-Nahflaeche (y=0.06,
|
||||
// parallel zur x-Achse).
|
||||
let s0 = lc.start_side[0].expect("Schicht 0 hat L-Seitenlinie");
|
||||
let s2 = lc.start_side[2].expect("Schicht 2 hat L-Seitenlinie");
|
||||
assert!(dist(s0, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||
assert!(dist(s0, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
|
||||
assert!(dist(s2, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||
|
||||
// Die kollineare Durchgangswand bleibt unangetastet (kein layerCuts).
|
||||
let ca = find(&out, "A");
|
||||
let cc = find(&out, "C");
|
||||
assert!(ca.layer_cuts.is_none());
|
||||
assert!(cc.layer_cuts.is_none());
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 879 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 103 B |
@@ -1,195 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "kernel2d"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"robust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "robust"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -1,40 +0,0 @@
|
||||
# Eigener leerer Workspace-Block: entkoppelt kernel2d vollstaendig vom
|
||||
# cad-tauri-Workspace (src-tauri/Cargo.toml), damit `cargo test`/`wasm-pack`
|
||||
# aus diesem Verzeichnis heraus nicht faelschlich dessen Workspace erben.
|
||||
# Muster: render2d/render3d. (Parent-`exclude` allein greift beim Bauen aus
|
||||
# dem Unterverzeichnis nicht zuverlaessig.)
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "kernel2d"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "2D-Geometrie-Kern (Port von src/geometry/kernel2d.ts) — handgeschriebene f64-Mathematik, headless per `cargo test` UND per wasm-pack (Feature \"web\") zu WASM baubar. Hinter identischer TS-Fassade; TS-Legacy bleibt Differential-Referenz."
|
||||
|
||||
# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul. rlib: als
|
||||
# Pfad-Abhaengigkeit und fuer den Test-/Example-Build (parity). Muster:
|
||||
# render2d/render3d/geometry.
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
# Standard: reiner f64-Rechenkern, headless per `cargo test` pruefbar.
|
||||
default = []
|
||||
# Browser-Bindings: dieselbe Geometrie hinter wasm-bindgen-Batch-Fassaden,
|
||||
# aus TS via wasm-pack aufgerufen. Nur fuer wasm32-unknown-unknown sinnvoll.
|
||||
web = ["dep:wasm-bindgen", "dep:serde_json", "dep:console_error_panic_hook"]
|
||||
# ADDITIV, NICHT im web-Default: exakte Orientierungs-Praedikate (robust::orient2d)
|
||||
# nur intern fuer Korrektheits-Golden-Cases (detectRooms/point-in-polygon). Der
|
||||
# Zufalls-Diff-Test gegen TS-naiv laeuft OHNE dieses Feature (siehe PORT_PLAN §3).
|
||||
robust-predicates = ["dep:robust"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
robust = { version = "1", optional = true }
|
||||
|
||||
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
@@ -32,10 +32,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
height: 1.0,
|
||||
}],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
WallInput {
|
||||
start: [0.0, 3.0],
|
||||
@@ -46,10 +42,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
|
||||
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
|
||||
@@ -65,10 +57,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
];
|
||||
let slabs = vec![SlabInput {
|
||||
@@ -76,8 +64,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
z_bottom: -0.2,
|
||||
z_top: 0.0,
|
||||
color: [0.86, 0.86, 0.88],
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}];
|
||||
(walls, slabs)
|
||||
}
|
||||
|
||||
@@ -13,14 +13,13 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use render3d::gpu::{RenderStyle, Renderer};
|
||||
use render3d::gpu::Renderer;
|
||||
use render3d::math::orbit_eye;
|
||||
use render3d::types::{Camera, Projection, WallInput};
|
||||
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
/// Demo-Szene: ein rechteckiger Raum (4 Aussenwaende) plus eine Innenwand. Achsen
|
||||
@@ -39,10 +38,6 @@ fn demo_walls() -> Vec<WallInput> {
|
||||
color: grey,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
// Raum 6 x 4 m.
|
||||
vec![
|
||||
@@ -200,9 +195,6 @@ struct App {
|
||||
orbit: Orbit,
|
||||
dragging: bool,
|
||||
last_cursor: (f64, f64),
|
||||
/// Darstellung: false = Shaded (Default, unveraendert), true = Textured
|
||||
/// (prozedurales Schachbrett auf den Wandflaechen). Per Taste `T` umschaltbar.
|
||||
textured: bool,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
@@ -230,27 +222,6 @@ impl ApplicationHandler for App {
|
||||
state.resize(size.width, size.height);
|
||||
state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::KeyboardInput {
|
||||
event:
|
||||
KeyEvent {
|
||||
physical_key: PhysicalKey::Code(KeyCode::KeyT),
|
||||
state: ElementState::Pressed,
|
||||
repeat: false,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => {
|
||||
// `T` schaltet Shaded <-> Textured um (Laufzeit, kein Re-Meshing:
|
||||
// der Renderer haelt beide Vertex-Puffer bereit).
|
||||
self.textured = !self.textured;
|
||||
let style = if self.textured {
|
||||
RenderStyle::Textured
|
||||
} else {
|
||||
RenderStyle::Shaded
|
||||
};
|
||||
state.renderer.set_render_style(style);
|
||||
state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::MouseInput { state: s, button, .. } => {
|
||||
if button == MouseButton::Left {
|
||||
self.dragging = s == ElementState::Pressed;
|
||||
|
||||
@@ -91,21 +91,11 @@ fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
if mesh.indices.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -147,83 +137,20 @@ pub fn build_edge_adjacency(mesh: &Mesh) -> Vec<EdgeAdj> {
|
||||
}
|
||||
}
|
||||
|
||||
edges
|
||||
.into_values()
|
||||
.map(|rec| EdgeAdj {
|
||||
p0: rec.p0,
|
||||
p1: rec.p1,
|
||||
reps: representatives(&rec.normals),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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]);
|
||||
};
|
||||
|
||||
/// 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);
|
||||
for rec in edges.values() {
|
||||
if should_draw(&rec.normals) {
|
||||
push(rec.p0);
|
||||
push(rec.p1);
|
||||
}
|
||||
}
|
||||
verts
|
||||
@@ -232,54 +159,15 @@ pub fn build_silhouette_edges(
|
||||
/// Entscheidet, ob eine Kante gezeichnet wird: Randkante (nur ein Dreieck) oder
|
||||
/// Knickkante (zwei angrenzende Dreiecke stehen ueber dem Crease-Winkel zuein-
|
||||
/// ander). Koplanare geteilte Kanten (Flaechendiagonalen) werden unterdrueckt.
|
||||
///
|
||||
/// DOPPELSEITIGE MESHES (Kontext/Dach/Fenster-Glas, s. `mesh.rs::push_ctx_tri` —
|
||||
/// jedes Dreieck wird dort MIT gespiegelter Rueckseite [-Normale] dupliziert,
|
||||
/// weil die Mesh-Pipeline Backface-Culling aktiv hat): jedes Original-Dreieck
|
||||
/// liefert dadurch IMMER ein exaktes +n/-n-Paar an jeder seiner Kanten. Ohne
|
||||
/// Beruecksichtigung wuerde das faelschlich als extremer Knick (dot ≈ -1)
|
||||
/// gewertet und JEDE Flaechen-Innendiagonale eines doppelseitigen Meshes
|
||||
/// gezeichnet (Nutzer-Report: Dreiecks-Diagonalen sichtbar auf Dach/Glas).
|
||||
/// Fix: zuerst Rueckseiten-Partner (dot ≈ -1 zueinander) einander zuordnen und
|
||||
/// 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.
|
||||
/// 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;
|
||||
reps.push(normals[i]);
|
||||
for j in (i + 1)..normals.len() {
|
||||
if used[j] {
|
||||
continue;
|
||||
}
|
||||
if dot(normals[i], normals[j]) < -CREASE_COS {
|
||||
used[j] = true; // Rueckseiten-Duplikat desselben Dreiecks
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
fn should_draw(normals: &[[f32; 3]]) -> bool {
|
||||
match normals.len() {
|
||||
0 => false,
|
||||
1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck
|
||||
1 => true, // Silhouette/Rand: gehoert nur einem Dreieck
|
||||
_ => {
|
||||
for i in 0..reps.len() {
|
||||
for j in (i + 1)..reps.len() {
|
||||
if dot(reps[i], reps[j]) < CREASE_COS {
|
||||
// Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt.
|
||||
for i in 0..normals.len() {
|
||||
for j in (i + 1)..normals.len() {
|
||||
if dot(normals[i], normals[j]) < CREASE_COS {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -359,186 +247,4 @@ 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)]
|
||||
mod double_sided_tests {
|
||||
// Deckt den Nutzer-Report ab (Dreiecks-Diagonalen sichtbar auf Dach/Glas):
|
||||
// Kontext-Meshes werden von `mesh.rs::push_ctx_tri` IMMER doppelseitig
|
||||
// aufgebaut (jedes Dreieck + gespiegelte Rueckseite [-Normale], weil die
|
||||
// Mesh-Pipeline Backface-Culling aktiv hat). Diese Tests bauen genau dieses
|
||||
// Muster nach (nicht die einfachen einseitigen Test-Meshes oben).
|
||||
use super::*;
|
||||
|
||||
fn mesh_single_sided(positions: &[[f32; 3]], indices: &[u32]) -> Mesh {
|
||||
let mut verts = Vec::new();
|
||||
for p in positions {
|
||||
verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]);
|
||||
}
|
||||
Mesh { verts, indices: indices.to_vec() }
|
||||
}
|
||||
|
||||
/// Baut ein Mesh wie `mesh_single_sided`, aber verdoppelt JEDES Dreieck mit
|
||||
/// umgekehrter Wicklung (a,c,b statt a,b,c) — exakt das Muster von
|
||||
/// `mesh.rs::push_ctx_tri` (Vorder- + Rueckseite fuer doppelseitiges Rendering).
|
||||
fn mesh_double_sided(positions: &[[f32; 3]], indices: &[u32]) -> Mesh {
|
||||
let mut verts = Vec::new();
|
||||
for p in positions {
|
||||
verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]);
|
||||
}
|
||||
let mut doubled = Vec::with_capacity(indices.len() * 2);
|
||||
for tri in indices.chunks_exact(3) {
|
||||
doubled.extend_from_slice(&[tri[0], tri[1], tri[2]]);
|
||||
doubled.extend_from_slice(&[tri[0], tri[2], tri[1]]); // gespiegelte Rueckseite
|
||||
}
|
||||
Mesh { verts, indices: doubled }
|
||||
}
|
||||
|
||||
fn segment_count(v: &[f32]) -> usize {
|
||||
v.len() / EDGE_FLOATS_PER_VERTEX / 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_coplanar_quad_still_drops_shared_diagonal() {
|
||||
// Dasselbe ebene Quad wie coplanar_quad_drops_shared_diagonal, aber
|
||||
// doppelseitig aufgebaut — VOR dem Fix haette dies faelschlich 6 statt
|
||||
// 4 Segmente geliefert (Diagonale faelschlich als Knick erkannt).
|
||||
let m = mesh_double_sided(
|
||||
&[
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
],
|
||||
&[0, 1, 2, 0, 2, 3],
|
||||
);
|
||||
let e = build_mesh_edges(&m);
|
||||
assert_eq!(segment_count(&e), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_single_triangle_still_has_three_boundary_edges() {
|
||||
// Silhouette-Kanten eines EINZELNEN Dreiecks muessen trotz Verdopplung
|
||||
// (Vorder-/Rueckseite) weiterhin als Rand erkannt und gezeichnet werden.
|
||||
let m = mesh_double_sided(
|
||||
&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
&[0, 1, 2],
|
||||
);
|
||||
let e = build_mesh_edges(&m);
|
||||
assert_eq!(segment_count(&e), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_folded_quad_still_keeps_crease() {
|
||||
// Echter 90°-Knick bleibt auch doppelseitig als Kante erhalten (keine
|
||||
// Ueberkompensation, die auch echte Knicke unterdruecken wuerde).
|
||||
let m = mesh_double_sided(
|
||||
&[
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
],
|
||||
&[0, 1, 2, 0, 3, 1],
|
||||
);
|
||||
let e = build_mesh_edges(&m);
|
||||
assert_eq!(segment_count(&e), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_opening_box_topology_suppresses_face_diagonals_doubled() {
|
||||
// Exakte Ecken-Reihenfolge/Indizes aus toWalls3d.ts::OPENING_BOX_TRIS,
|
||||
// diesmal ECHT doppelseitig wie im tatsaechlichen Render-Pfad (die
|
||||
// Fenster-Glas-/Rahmen-Boxen laufen ueber append_context_mesh).
|
||||
let from = 0.0f32;
|
||||
let to = 1.2f32;
|
||||
let z_bottom = 0.9f32;
|
||||
let z_top = 2.1f32;
|
||||
let n_min = -0.03f32;
|
||||
let n_max = 0.03f32;
|
||||
let mut positions = Vec::new();
|
||||
for i in 0..8u32 {
|
||||
let s = if i & 1 != 0 { to } else { from };
|
||||
let z = if i & 2 != 0 { z_top } else { z_bottom };
|
||||
let t = if i & 4 != 0 { n_max } else { n_min };
|
||||
positions.push([s, -t, z]);
|
||||
}
|
||||
let indices: Vec<u32> = vec![
|
||||
0, 1, 3, 0, 3, 2,
|
||||
4, 5, 7, 4, 7, 6,
|
||||
0, 1, 5, 0, 5, 4,
|
||||
2, 3, 7, 2, 7, 6,
|
||||
0, 2, 6, 0, 6, 4,
|
||||
1, 3, 7, 1, 7, 5,
|
||||
];
|
||||
let m = mesh_double_sided(&positions, &indices);
|
||||
let e = build_mesh_edges(&m);
|
||||
// Ein Quader hat 12 echte Kanten (Silhouette); bei korrekter Diagonalen-
|
||||
// Unterdrueckung sollten es GENAU 12 sein (nicht 12+6 Diagonalen=18).
|
||||
assert_eq!(segment_count(&e), 12, "erwartet 12 Kanten (Quader-Silhouette), keine Flaechendiagonalen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_sided_helper_matches_module_level_mesh_helper() {
|
||||
// Absicherung: der lokale mesh_single_sided-Helfer verhaelt sich
|
||||
// identisch zum bestehenden `tests::mesh` (keine versehentliche
|
||||
// Abweichung beim Kopieren).
|
||||
let m = mesh_single_sided(
|
||||
&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
&[0, 1, 2],
|
||||
);
|
||||
let e = build_mesh_edges(&m);
|
||||
assert_eq!(segment_count(&e), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ pub mod math;
|
||||
pub mod mesh;
|
||||
pub(crate) mod openings;
|
||||
pub mod section;
|
||||
pub mod section_boolean;
|
||||
pub mod section_fill;
|
||||
pub mod shaders;
|
||||
pub mod types;
|
||||
@@ -39,30 +38,23 @@ pub use math::{
|
||||
view_matrix, view_projection, Mat4,
|
||||
};
|
||||
pub use mesh::{
|
||||
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh,
|
||||
build_walls_mesh_textured, extrude_slab, extrude_wall, textured_from_mesh, triangulate,
|
||||
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh, extrude_slab,
|
||||
extrude_wall, triangulate,
|
||||
};
|
||||
pub use section::{
|
||||
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
|
||||
};
|
||||
pub use types::{
|
||||
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput,
|
||||
TexturedMesh, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, WallInput,
|
||||
FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mesh::{
|
||||
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::{
|
||||
Hatch, Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX,
|
||||
TEXTURED_FLOATS_PER_VERTEX,
|
||||
};
|
||||
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX};
|
||||
use super::types::{Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX};
|
||||
|
||||
/// Bequemer Bau einer achsparallelen Wand entlang +X.
|
||||
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
|
||||
@@ -75,10 +67,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,67 +79,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texturierter_pfad_ist_geometrisch_deckungsgleich_mit_shaded() {
|
||||
// Der texturierte Wand-Pfad (`build_walls_mesh_textured`, [pos,normal,uv])
|
||||
// muss GEOMETRISCH bitgleich zum Alt-Pfad (`build_walls_mesh`,
|
||||
// [pos,normal,color]) sein: gleiche Vertex-/Index-Zahl und -Reihenfolge,
|
||||
// identische Positionen und Normalen. Nur der letzte Kanal (Farbe -> UV)
|
||||
// unterscheidet sich. So ist belegt, dass der additive UV-Kanal den
|
||||
// Alt-Pfad nicht beruehrt.
|
||||
let walls = [wall_x(3.0, 0.2, 2.5)];
|
||||
let shaded = build_walls_mesh(&walls);
|
||||
let tex = build_walls_mesh_textured(&walls);
|
||||
assert_eq!(tex.indices, shaded.indices, "Index-Puffer identisch");
|
||||
assert_eq!(
|
||||
tex.vertex_count(),
|
||||
shaded.vertex_count(),
|
||||
"gleiche Vertex-Zahl"
|
||||
);
|
||||
for i in 0..shaded.vertex_count() {
|
||||
let sb = i * FLOATS_PER_VERTEX;
|
||||
let tb = i * TEXTURED_FLOATS_PER_VERTEX;
|
||||
// Position (0..3) und Normale (3..6) bitgleich uebernommen.
|
||||
for k in 0..6 {
|
||||
assert_eq!(
|
||||
shaded.verts[sb + k],
|
||||
tex.verts[tb + k],
|
||||
"Vertex {i} Kanal {k}: pos/normal muss identisch sein"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texturierte_uv_ist_weltmassstaeblich() {
|
||||
// UV-Massstab: `u`/`v` in Metern (1 Kachel = 1 m). Fuer eine Wand entlang +X
|
||||
// liegt die +Z-Mantelflaeche bei Normale (0,0,1); dort ist die Tangente
|
||||
// t=(-n.z,0,n.x)=(-1,0,0), also u = -x und v = Hoehe. Die Deckflaeche
|
||||
// (Normale +Y) projiziert auf u=x, v=z. Wir pruefen, dass es UEBERHAUPT
|
||||
// Vertices mit v == Wandhoehe (2.5) bzw. v == 0 gibt (Hoehe wandert als v
|
||||
// mit) — belegt den weltmassstaeblichen Hoehen-Kanal.
|
||||
let tex = build_walls_mesh_textured(&[wall_x(3.0, 0.2, 2.5)]);
|
||||
let n = tex.vertex_count();
|
||||
let mut saw_top = false;
|
||||
let mut saw_bottom = false;
|
||||
for i in 0..n {
|
||||
let b = i * TEXTURED_FLOATS_PER_VERTEX;
|
||||
let ny = tex.verts[b + 4];
|
||||
let v = tex.verts[b + 7];
|
||||
// Nur Mantelflaechen (ny ~ 0) tragen die Hoehe als v.
|
||||
if ny.abs() < 0.5 {
|
||||
if (v - 2.5).abs() < 1e-5 {
|
||||
saw_top = true;
|
||||
}
|
||||
if v.abs() < 1e-5 {
|
||||
saw_bottom = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(saw_top, "Mantel-Vertex mit v == Wandhoehe (2.5 m) erwartet");
|
||||
assert!(saw_bottom, "Mantel-Vertex mit v == 0 (Sockel) erwartet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eine_wand_hat_quader_zaehlung() {
|
||||
// Eine Wand -> ein Quader: 24 Vertices, 36 Indizes (12 Dreiecke).
|
||||
@@ -164,65 +91,6 @@ 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(&[
|
||||
@@ -236,10 +104,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
]);
|
||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
|
||||
@@ -318,10 +182,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mut mesh = Mesh::default();
|
||||
extrude_wall(&mut mesh, &w);
|
||||
@@ -343,10 +203,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
@@ -450,97 +306,6 @@ mod tests {
|
||||
assert_eq!(mesh.indices.len(), INDICES_PER_BOX);
|
||||
}
|
||||
|
||||
// --- Echte Loecher in EINEM Wandkoerper (WallInput::holes) -----------------
|
||||
|
||||
#[test]
|
||||
fn zwei_versetzte_ueberlappende_loecher_in_einem_koerper() {
|
||||
// KERNFALL: EINE Wand (Laenge 4, Hoehe 2.5) mit ZWEI Loechern, deren
|
||||
// Achsen-Intervalle sich UEBERLAPPEN, aber auf UNTERSCHIEDLICHER Hoehe
|
||||
// liegen — Fenster A u=[1.0,2.0] z=[0.9,2.1], Fenster B u=[1.5,2.5]
|
||||
// z=[0.3,0.7]. Das kann die Segment-Zerlegung nicht abbilden; als Loecher
|
||||
// in EINEM Koerper muss es sauber zerfallen (keine Geometrie in den
|
||||
// Loechern, Aussenmasse erhalten, beide Laibungen vollstaendig).
|
||||
let mut w = wall_x(4.0, 0.2, 2.5);
|
||||
w.holes = vec![
|
||||
Hole { from: 1.0, to: 2.0, z_bottom: 0.9, z_top: 2.1 },
|
||||
Hole { from: 1.5, to: 2.5, z_bottom: 0.3, z_top: 0.7 },
|
||||
];
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
|
||||
// Mehr als ein Vollkoerper (Loecher fuegen Teilrechtecke + Laibungen hinzu).
|
||||
assert!(mesh.vertex_count() > VERTS_PER_BOX);
|
||||
// Index-Integritaet.
|
||||
let max_idx = *mesh.indices.iter().max().unwrap();
|
||||
assert!((max_idx as usize) < mesh.vertex_count());
|
||||
|
||||
// Aussenmasse unveraendert (die soliden Teilrechtecke decken den Rand ab).
|
||||
let (min, max) = mesh.bounds();
|
||||
assert!((min[0]).abs() < 1e-5 && (max[0] - 4.0).abs() < 1e-5, "u 0..4");
|
||||
assert!((min[1]).abs() < 1e-5 && (max[1] - 2.5).abs() < 1e-5, "z 0..2.5");
|
||||
assert!((min[2] + 0.1).abs() < 1e-5 && (max[2] - 0.1).abs() < 1e-5, "Dicke +/-0.1");
|
||||
|
||||
// KEIN Vertex liegt (mit Sicherheitsabstand) im Inneren eines der Loecher.
|
||||
let holes = [(1.0f32, 2.0f32, 0.9f32, 2.1f32), (1.5, 2.5, 0.3, 0.7)];
|
||||
let m = 0.05;
|
||||
let n = mesh.vertex_count();
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
for (a0, a1, zb, zt) in holes {
|
||||
let inside =
|
||||
p[0] > a0 + m && p[0] < a1 - m && p[1] > zb + m && p[1] < zt - m;
|
||||
assert!(!inside, "Vertex {i} bei {p:?} liegt im Loch [{a0},{a1}]x[{zb},{zt}]");
|
||||
}
|
||||
}
|
||||
|
||||
// Beide Loecher besitzen ihre vier inneren Laibungen: fuer jede Loch-Kante
|
||||
// muss mindestens ein Vertex GENAU auf der Kante (in u bzw. z) UND im
|
||||
// jeweils anderen Loch-Intervall liegen (die Laibungsflaeche).
|
||||
let has_vert = |pred: &dyn Fn([f32; 3]) -> bool| (0..n).any(|i| pred(vert(&mesh, i).0));
|
||||
for (a0, a1, zb, zt) in holes {
|
||||
// linke/rechte Laibung: Vertex bei u==a0 bzw. u==a1, z im Loch.
|
||||
assert!(
|
||||
has_vert(&|p| (p[0] - a0).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4),
|
||||
"linke Laibung fehlt fuer Loch [{a0},{a1}]"
|
||||
);
|
||||
assert!(
|
||||
has_vert(&|p| (p[0] - a1).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4),
|
||||
"rechte Laibung fehlt fuer Loch [{a0},{a1}]"
|
||||
);
|
||||
// untere/obere Laibung: Vertex bei z==zb bzw. z==zt, u im Loch.
|
||||
assert!(
|
||||
has_vert(&|p| (p[1] - zb).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4),
|
||||
"untere Laibung fehlt fuer Loch [{a0},{a1}]"
|
||||
);
|
||||
assert!(
|
||||
has_vert(&|p| (p[1] - zt).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4),
|
||||
"obere Laibung fehlt fuer Loch [{a0},{a1}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuer_loch_bis_zum_boden_ohne_untere_laibung() {
|
||||
// Tuer-Loch reicht bis zum Boden (z_bottom == base_elevation 0): die
|
||||
// untere Kante liegt am Wandrand -> KEINE untere Laibung, stattdessen eine
|
||||
// Luecke im Boden-Streifen. Unter der Kopfhoehe (z_top 2.1) darf im
|
||||
// Tuerbereich keine Geometrie liegen.
|
||||
let mut w = wall_x(4.0, 0.2, 2.5);
|
||||
w.holes = vec![Hole { from: 1.0, to: 1.8, z_bottom: 0.0, z_top: 2.1 }];
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
let n = mesh.vertex_count();
|
||||
let m = 0.05;
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
let in_span = p[0] > 1.0 + m && p[0] < 1.8 - m;
|
||||
if in_span {
|
||||
assert!(
|
||||
p[1] < m || p[1] > 2.1 - 1e-4,
|
||||
"Vertex {i} bei {p:?} liegt unter der Tuer-Kopfhoehe im Loch"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ecken-Verschneidung (Gehrung) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -560,10 +325,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let b = WallInput {
|
||||
start: [0.0, 0.0],
|
||||
@@ -574,10 +335,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mesh = build_walls_mesh(&[a, b]);
|
||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
|
||||
@@ -637,10 +394,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let walls = vec![
|
||||
mk([0.0, 0.0], [3.0, 0.0]),
|
||||
@@ -669,10 +422,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let walls = vec![
|
||||
mk([0.0, 0.0], [3.0, 0.0], 0.0),
|
||||
@@ -761,10 +510,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: layers(),
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mut b = a.clone();
|
||||
b.end = [0.0, 3.0];
|
||||
@@ -795,8 +540,6 @@ mod tests {
|
||||
z_bottom,
|
||||
z_top,
|
||||
color: [0.86, 0.86, 0.88],
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -853,8 +596,6 @@ mod tests {
|
||||
z_bottom: 0.0,
|
||||
z_top: 0.3,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mut mesh = Mesh::default();
|
||||
extrude_slab(&mut mesh, &slab);
|
||||
@@ -883,7 +624,7 @@ mod tests {
|
||||
fn entarteter_slab_erzeugt_nichts() {
|
||||
let mut mesh = Mesh::default();
|
||||
// <3 Ecken.
|
||||
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8], hatch: None, cut: None });
|
||||
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8] });
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
// Nullhoehe.
|
||||
extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6));
|
||||
@@ -902,8 +643,6 @@ mod tests {
|
||||
indices: vec![0, 1, 2],
|
||||
kind,
|
||||
color,
|
||||
material_index: None,
|
||||
vertex_colors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,73 +684,6 @@ 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();
|
||||
@@ -1023,8 +695,6 @@ 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);
|
||||
@@ -1036,8 +706,6 @@ 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);
|
||||
@@ -1129,51 +797,6 @@ mod tests {
|
||||
assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_kardinal_back_left_sind_orthografisch_und_gegenrichtung() {
|
||||
use super::math::preset_camera;
|
||||
use super::types::{CameraPreset, Projection};
|
||||
let t = [0.0, 0.0, 0.0];
|
||||
let front = preset_camera(CameraPreset::Front, t, 10.0);
|
||||
let back = preset_camera(CameraPreset::Back, t, 10.0);
|
||||
let side = preset_camera(CameraPreset::Side, t, 10.0);
|
||||
let left = preset_camera(CameraPreset::Left, t, 10.0);
|
||||
assert_eq!(back.projection, Projection::Orthographic);
|
||||
assert_eq!(left.projection, Projection::Orthographic);
|
||||
// Back/Left sind exakt die Gegenrichtung von Front/Side (gespiegelte Achse).
|
||||
assert!((back.eye[2] - (-front.eye[2])).abs() < 1e-4, "Back == -Front (Z)");
|
||||
assert!((left.eye[0] - (-side.eye[0])).abs() < 1e-4, "Left == -Side (X)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_iso_oktanten_sind_orthografisch_gleich_gross() {
|
||||
use super::math::preset_camera;
|
||||
use super::types::{CameraPreset, Projection};
|
||||
let t = [0.0, 0.0, 0.0];
|
||||
let dist = 10.0;
|
||||
let iso = preset_camera(CameraPreset::Iso, t, dist);
|
||||
let fl = preset_camera(CameraPreset::IsoFrontLeft, t, dist);
|
||||
let br = preset_camera(CameraPreset::IsoBackRight, t, dist);
|
||||
let bl = preset_camera(CameraPreset::IsoBackLeft, t, dist);
|
||||
for cam in [&iso, &fl, &br, &bl] {
|
||||
assert_eq!(cam.projection, Projection::Orthographic);
|
||||
// Alle vier oberen Iso-Oktanten haben denselben eye-target-Abstand
|
||||
// (Modell erscheint in jedem Oktanten gleich gross).
|
||||
let d = ((cam.eye[0] - t[0]).powi(2)
|
||||
+ (cam.eye[1] - t[1]).powi(2)
|
||||
+ (cam.eye[2] - t[2]).powi(2))
|
||||
.sqrt();
|
||||
assert!((d - dist).abs() < 1e-3, "Iso-Oktant-Abstand == dist");
|
||||
// Alle vier blicken von OBEN (Y > 0) — untere Oktanten bleiben ungenutzt.
|
||||
assert!(cam.eye[1] > t[1], "Iso-Oktant ueber dem Ziel");
|
||||
}
|
||||
// Vier unterschiedliche horizontale Vorzeichen-Kombinationen (X,Z).
|
||||
assert!(iso.eye[0] > t[0] && iso.eye[2] > t[2]); // vorne-rechts
|
||||
assert!(fl.eye[0] < t[0] && fl.eye[2] > t[2]); // vorne-links
|
||||
assert!(br.eye[0] > t[0] && br.eye[2] < t[2]); // hinten-rechts
|
||||
assert!(bl.eye[0] < t[0] && bl.eye[2] < t[2]); // hinten-links
|
||||
}
|
||||
|
||||
/// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt
|
||||
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
|
||||
/// sonst nicht mitgebaut wird (Muster: render2d).
|
||||
@@ -1204,23 +827,5 @@ mod tests {
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&cap_module)
|
||||
.unwrap_or_else(|e| panic!("cap: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
|
||||
// Texturierter Wand-Shader (RenderStyle::Textured, Bild-Textur in group 1)
|
||||
// ebenso headless validieren — faengt Textur-/Sampler-Bindungsfehler ab.
|
||||
let tex_src = super::shaders::MESH_TEXTURED_WGSL;
|
||||
let tex_module = naga::front::wgsl::parse_str(tex_src)
|
||||
.unwrap_or_else(|e| panic!("textured: WGSL-Parse-Fehler: {e:?}"));
|
||||
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:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,26 +185,15 @@ pub fn orbit_eye(target: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> [f32; 3]
|
||||
]
|
||||
}
|
||||
|
||||
/// Baut eine `Camera` fuer eines der Kardinal-/Iso-Presets (ROADMAP §11).
|
||||
/// `target` ist das Blickziel (Modell-Mitte), `dist` der Abstand. Alle Presets
|
||||
/// ausser `Persp` sind orthografisch (Front/Back/Top/Side/Left achsparallel,
|
||||
/// die Iso-Varianten diagonal); nur Persp ist perspektivisch.
|
||||
///
|
||||
/// Achs-Konvention (wie die three.js-Sicht, s. `applyView3d` in Viewport3D.tsx):
|
||||
/// +Z = vorne (Front blickt von +Z entlang -Z), +X = rechts (Side blickt von
|
||||
/// +X entlang -X). Back/Left sind exakt die Gegenrichtungen (-Z/-X); es gibt
|
||||
/// noch KEINEN Nordwinkel — die Kardinalrichtungen sind rein modellrelativ
|
||||
/// (Vorne/Rechts/Hinten/Links im UI, keine Himmelsrichtungen).
|
||||
/// Baut eine `Camera` fuer eines der fuenf Presets der three.js-Sicht. `target`
|
||||
/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side/Iso
|
||||
/// sind orthografisch (Front/Top/Side achsparallel, Iso diagonal); nur Persp
|
||||
/// ist perspektivisch.
|
||||
pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera {
|
||||
let mut cam = Camera {
|
||||
target,
|
||||
..Camera::default()
|
||||
};
|
||||
// Iso-Distanz-Helfer: Augenabstand-Komponente je Achse, sodass der
|
||||
// tatsaechliche eye-target-Abstand exakt `dist` bleibt (d*sqrt(3) == dist,
|
||||
// wie bei den bisherigen Front/Top/Side-Presets), damit alle Iso-Varianten
|
||||
// das Modell gleich gross zeigen.
|
||||
let iso_d = dist / 3.0_f32.sqrt();
|
||||
match preset {
|
||||
// Blick entlang -Z (von vorne), orthografisch.
|
||||
CameraPreset::Front => {
|
||||
@@ -213,13 +202,6 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
|
||||
cam.up = [0.0, 1.0, 0.0];
|
||||
cam.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Blick entlang +Z (von hinten) — Gegenrichtung von Front.
|
||||
CameraPreset::Back => {
|
||||
cam.projection = Projection::Orthographic;
|
||||
cam.eye = [target[0], target[1], target[2] - dist];
|
||||
cam.up = [0.0, 1.0, 0.0];
|
||||
cam.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit
|
||||
// Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht).
|
||||
CameraPreset::Top => {
|
||||
@@ -228,48 +210,24 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
|
||||
cam.up = [0.0, 0.0, -1.0];
|
||||
cam.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Blick entlang -X (von der rechten Seite), orthografisch.
|
||||
// Blick entlang -X (von der Seite), orthografisch.
|
||||
CameraPreset::Side => {
|
||||
cam.projection = Projection::Orthographic;
|
||||
cam.eye = [target[0] + dist, target[1], target[2]];
|
||||
cam.up = [0.0, 1.0, 0.0];
|
||||
cam.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Blick entlang +X (von der linken Seite) — Gegenrichtung von Side.
|
||||
CameraPreset::Left => {
|
||||
cam.projection = Projection::Orthographic;
|
||||
cam.eye = [target[0] - dist, target[1], target[2]];
|
||||
cam.up = [0.0, 1.0, 0.0];
|
||||
cam.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Isometrischer Ueberblick von vorne-oben-rechts (Default-Iso).
|
||||
// Isometrischer Ueberblick (orthografisch, Blick von schraeg oben).
|
||||
// Echte Isometrie ist per Definition parallelprojiziert (keine
|
||||
// perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu).
|
||||
CameraPreset::Iso => {
|
||||
cam.projection = Projection::Orthographic;
|
||||
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] + iso_d];
|
||||
cam.up = [0.0, 1.0, 0.0];
|
||||
cam.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Isometrischer Ueberblick von vorne-oben-links.
|
||||
CameraPreset::IsoFrontLeft => {
|
||||
cam.projection = Projection::Orthographic;
|
||||
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] + iso_d];
|
||||
cam.up = [0.0, 1.0, 0.0];
|
||||
cam.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Isometrischer Ueberblick von hinten-oben-rechts.
|
||||
CameraPreset::IsoBackRight => {
|
||||
cam.projection = Projection::Orthographic;
|
||||
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] - iso_d];
|
||||
cam.up = [0.0, 1.0, 0.0];
|
||||
cam.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Isometrischer Ueberblick von hinten-oben-links.
|
||||
CameraPreset::IsoBackLeft => {
|
||||
cam.projection = Projection::Orthographic;
|
||||
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] - iso_d];
|
||||
let d = dist / 3.0_f32.sqrt();
|
||||
cam.eye = [target[0] + d, target[1] + d, target[2] + d];
|
||||
cam.up = [0.0, 1.0, 0.0];
|
||||
// eye-target-Abstand ist hier (wie bei Front/Top/Side) exakt
|
||||
// `dist` (d*sqrt(3) == dist), darum dieselbe Skalierung wie dort,
|
||||
// damit das Modell im Bild aehnlich gross erscheint.
|
||||
cam.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
|
||||
|
||||
@@ -59,8 +59,7 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::openings::{split_range_by_voids, MIN_SPAN};
|
||||
use crate::types::{
|
||||
Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, TexturedMesh, WallInput, WallLayer,
|
||||
FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
/// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2
|
||||
@@ -110,16 +109,7 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter
|
||||
|
||||
let z0 = wall.base_elevation;
|
||||
let z1 = wall.base_elevation + wall.height;
|
||||
// Mit echten Loechern (`holes`) wird die Wand NICHT an Oeffnungen segmentiert,
|
||||
// sondern als EIN voller Achsen-Abschnitt extrudiert; die Loecher stanzt
|
||||
// `extrude_layer_segment_with_holes` in die Langseiten (holes/openings
|
||||
// schliessen sich aus, siehe `types::WallInput::holes`).
|
||||
let has_holes = !wall.holes.is_empty();
|
||||
let segments = if has_holes {
|
||||
vec![(0.0, length, z0, z1)]
|
||||
} else {
|
||||
wall_solid_segments(length, &wall.openings, z0, z1)
|
||||
};
|
||||
let segments = wall_solid_segments(length, &wall.openings, z0, z1);
|
||||
|
||||
// Schicht-Stapel: ohne `layers` (oder leer) EIN synthetisches Layer aus
|
||||
// der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten.
|
||||
@@ -149,20 +139,10 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter
|
||||
// Wandende grenzen (innere Laibungs-Teilstuecke bleiben eckig).
|
||||
let start_cut = if a0 <= 1e-6 { miters.start } else { None };
|
||||
let end_cut = if a1 >= length - 1e-6 { miters.end } else { None };
|
||||
if has_holes {
|
||||
// Voller Achsen-Abschnitt (a0=0, a1=length) mit echten Loechern
|
||||
// in den Langseiten — pro Schicht-Band dieselben Loecher (quer
|
||||
// gestapelt bilden ihre Laibungen die volle Loch-Tiefe).
|
||||
extrude_layer_segment_with_holes(
|
||||
mesh, wall, n, length, y0, y1, off_a, off_b, start_cut, end_cut, layer.color,
|
||||
&wall.holes,
|
||||
);
|
||||
} else {
|
||||
extrude_layer_segment(
|
||||
mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut,
|
||||
layer.color,
|
||||
);
|
||||
}
|
||||
extrude_layer_segment(
|
||||
mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut,
|
||||
layer.color,
|
||||
);
|
||||
}
|
||||
off_a = off_b;
|
||||
}
|
||||
@@ -304,328 +284,6 @@ fn extrude_layer_segment(
|
||||
push_quad(mesh, b3, b0, t0, t3, start_normal, color);
|
||||
}
|
||||
|
||||
/// Haengt ein Quad (vier Ecken als Ring, beliebige Reihenfolge) als zwei
|
||||
/// orientierte Dreiecke an — Winding via `push_tri_oriented` an `want_normal`
|
||||
/// ausgerichtet (robust gegen die Eck-Reihenfolge, anders als `push_quad`).
|
||||
/// Genutzt fuer die Loch-Zerlegung (Langseiten-Teilrechtecke, Deckel-/Boden-
|
||||
/// Streifen, Laibungen), wo die Ecken aus Gitter-Koordinaten kommen.
|
||||
fn push_quad_oriented(
|
||||
mesh: &mut Mesh,
|
||||
a: [f32; 3],
|
||||
b: [f32; 3],
|
||||
c: [f32; 3],
|
||||
d: [f32; 3],
|
||||
want_normal: [f32; 3],
|
||||
color: Rgb,
|
||||
) {
|
||||
push_tri_oriented(mesh, a, b, c, want_normal, color);
|
||||
push_tri_oriented(mesh, a, c, d, want_normal, color);
|
||||
}
|
||||
|
||||
/// Zerlegt das Rechteck `[u_lo,u_hi] x [z_lo,z_hi]` MINUS der `holes`
|
||||
/// (`(u0,u1,z0,z1)`) in achsparallele, paarweise disjunkte Teilrechtecke (die
|
||||
/// SOLIDEN Bereiche). Verfahren: Koordinaten-Kompression — alle Loch-Kanten in u
|
||||
/// und z bilden ein Gitter; jede Gitterzelle, deren Mittelpunkt in KEINEM Loch
|
||||
/// liegt, ist ein solides Teilrechteck. Dadurch koennen sich Loecher in u
|
||||
/// ueberlappen und auf unterschiedlicher Hoehe liegen (Kernfall zweier versetzt
|
||||
/// uebereinanderliegender Fenster), ohne dass degenerierte oder ueberlappende
|
||||
/// Teilrechtecke entstehen.
|
||||
fn solid_subrects(
|
||||
u_lo: f32,
|
||||
u_hi: f32,
|
||||
z_lo: f32,
|
||||
z_hi: f32,
|
||||
holes: &[(f32, f32, f32, f32)],
|
||||
) -> Vec<(f32, f32, f32, f32)> {
|
||||
let sort_dedup = |mut v: Vec<f32>| -> Vec<f32> {
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
v.dedup_by(|a, b| (*a - *b).abs() <= MIN_SPAN);
|
||||
v
|
||||
};
|
||||
let mut us = vec![u_lo, u_hi];
|
||||
let mut zs = vec![z_lo, z_hi];
|
||||
for (a0, a1, hb, ht) in holes {
|
||||
us.push(*a0);
|
||||
us.push(*a1);
|
||||
zs.push(*hb);
|
||||
zs.push(*ht);
|
||||
}
|
||||
let us = sort_dedup(us);
|
||||
let zs = sort_dedup(zs);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..us.len().saturating_sub(1) {
|
||||
let (ua, ub) = (us[i], us[i + 1]);
|
||||
if ub - ua <= MIN_SPAN {
|
||||
continue;
|
||||
}
|
||||
for j in 0..zs.len().saturating_sub(1) {
|
||||
let (za, zb) = (zs[j], zs[j + 1]);
|
||||
if zb - za <= MIN_SPAN {
|
||||
continue;
|
||||
}
|
||||
let cu = (ua + ub) * 0.5;
|
||||
let cz = (za + zb) * 0.5;
|
||||
let in_hole = holes
|
||||
.iter()
|
||||
.any(|(a0, a1, hb, ht)| cu > *a0 && cu < *a1 && cz > *hb && cz < *ht);
|
||||
if !in_hole {
|
||||
out.push((ua, ub, za, zb));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Zieht die (ggf. ueberlappenden) `gaps` vom Intervall `[lo,hi]` ab und liefert
|
||||
/// die verbleibenden Teilintervalle (sortiert, disjunkt). Fuer die Deckel-/
|
||||
/// Boden-/Kappen-Flaechen, in denen ein randstaendiges Loch eine Luecke reisst.
|
||||
fn subtract_intervals(lo: f32, hi: f32, gaps: &[(f32, f32)]) -> Vec<(f32, f32)> {
|
||||
let mut gs: Vec<(f32, f32)> = gaps
|
||||
.iter()
|
||||
.map(|(a, b)| (a.max(lo), b.min(hi)))
|
||||
.filter(|(a, b)| b - a > MIN_SPAN)
|
||||
.collect();
|
||||
gs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = lo;
|
||||
for (a, b) in gs {
|
||||
if a > cursor + MIN_SPAN {
|
||||
out.push((cursor, a));
|
||||
}
|
||||
cursor = cursor.max(b);
|
||||
}
|
||||
if hi > cursor + MIN_SPAN {
|
||||
out.push((cursor, hi));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extrudiert EIN Schicht-Band (volle Achse `0..length`, Hoehe `[y0,y1]`,
|
||||
/// Dicken-Versatz `[off_a,off_b]`) mit ECHTEN rechteckigen LOECHERN (`holes`,
|
||||
/// siehe `types::Hole`) statt der Oeffnungs-Segmentierung. Aufbau:
|
||||
/// • Langseiten (+n bei `off_a`, −n bei `off_b`): das Flaechen-Rechteck MINUS
|
||||
/// der Loecher, per `solid_subrects` in Teilrechtecke zerlegt (je 2 Dreiecke).
|
||||
/// • Deckel (`y1`) / Boden (`y0`): voller Streifen minus der u-Intervalle der
|
||||
/// Loecher, die die jeweilige Kante beruehren (z. B. eine Tuer am Boden).
|
||||
/// • End-Kappen (`a=0`/`a=length`, gemitert): volle Hoehe minus der z-Intervalle
|
||||
/// randstaendiger Loecher (Loecher liegen normalerweise im Inneren → volle
|
||||
/// Kappe, unveraendert gegenueber der Gehrung).
|
||||
/// • Laibungen je Loch: vier Quads (links/rechts/unten/oben) — aber nur fuer die
|
||||
/// Seiten, die im Wand-INNEREN liegen (die randstaendige Seite hat stattdessen
|
||||
/// die Luecke in Deckel/Boden/Kappe, keine Laibung).
|
||||
/// Winding/Normalen konsistent ueber `push_quad_oriented`/`push_tri_oriented`
|
||||
/// (Backface-Culling + Beleuchtung korrekt).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn extrude_layer_segment_with_holes(
|
||||
mesh: &mut Mesh,
|
||||
wall: &WallInput,
|
||||
n: Point2,
|
||||
length: f32,
|
||||
y0: f32,
|
||||
y1: f32,
|
||||
off_a: f32,
|
||||
off_b: f32,
|
||||
start_cut: Option<MiterLine>,
|
||||
end_cut: Option<MiterLine>,
|
||||
color: Rgb,
|
||||
holes: &[Hole],
|
||||
) {
|
||||
let ux = wall.end[0] - wall.start[0];
|
||||
let uz = wall.end[1] - wall.start[1];
|
||||
let ul = (ux * ux + uz * uz).sqrt().max(1e-9);
|
||||
let u: Point2 = [ux / ul, uz / ul];
|
||||
|
||||
let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s, p[1] + n[1] * s] };
|
||||
let clip = |base: Point2, cut: Option<MiterLine>| -> Point2 {
|
||||
match cut {
|
||||
None => base,
|
||||
Some(c) => line_intersect_2(base, u, c.point, c.dir).unwrap_or(base),
|
||||
}
|
||||
};
|
||||
// Plan-Punkt bei Achsen-Position `a`, Dicken-Versatz `offv`; NUR an den echten
|
||||
// Wandenden (a≈0 / a≈length) gegen die Gehrungslinie verschnitten — innere
|
||||
// Loch-Kanten bleiben rechtwinklig (unabhaengig von der Gehrung).
|
||||
let g_at = |a: f32, offv: f32| -> Point2 {
|
||||
let base = off(axis_point(wall, length, a), offv);
|
||||
if a <= 1e-6 {
|
||||
clip(base, start_cut)
|
||||
} else if a >= length - 1e-6 {
|
||||
clip(base, end_cut)
|
||||
} else {
|
||||
base
|
||||
}
|
||||
};
|
||||
let w = |g: Point2, y: f32| -> [f32; 3] { [g[0], y, g[1]] };
|
||||
|
||||
// Loecher auf [0,length] x [y0,y1] klemmen, entartete verwerfen.
|
||||
let mut hs: Vec<(f32, f32, f32, f32)> = Vec::new();
|
||||
for h in holes {
|
||||
let a0 = h.from.max(0.0);
|
||||
let a1 = h.to.min(length);
|
||||
let zb = h.z_bottom.max(y0);
|
||||
let zt = h.z_top.min(y1);
|
||||
if a1 - a0 > MIN_SPAN && zt - zb > MIN_SPAN {
|
||||
hs.push((a0, a1, zb, zt));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Langseiten (+n / −n): Rechteck-Gitter minus Loecher. ──────────────────
|
||||
let np = [n[0], 0.0, n[1]];
|
||||
let nn = [-n[0], 0.0, -n[1]];
|
||||
for (ua, ub, za, zb) in solid_subrects(0.0, length, y0, y1, &hs) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(ua, off_a), za),
|
||||
w(g_at(ub, off_a), za),
|
||||
w(g_at(ub, off_a), zb),
|
||||
w(g_at(ua, off_a), zb),
|
||||
np,
|
||||
color,
|
||||
);
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(ua, off_b), za),
|
||||
w(g_at(ub, off_b), za),
|
||||
w(g_at(ub, off_b), zb),
|
||||
w(g_at(ua, off_b), zb),
|
||||
nn,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Deckel (y1) und Boden (y0): voller Streifen minus randstaendiger Loecher.
|
||||
let top_gaps: Vec<(f32, f32)> = hs
|
||||
.iter()
|
||||
.filter(|(_, _, _, zt)| *zt >= y1 - MIN_SPAN)
|
||||
.map(|(a0, a1, _, _)| (*a0, *a1))
|
||||
.collect();
|
||||
for (ua, ub) in subtract_intervals(0.0, length, &top_gaps) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(ua, off_a), y1),
|
||||
w(g_at(ub, off_a), y1),
|
||||
w(g_at(ub, off_b), y1),
|
||||
w(g_at(ua, off_b), y1),
|
||||
[0.0, 1.0, 0.0],
|
||||
color,
|
||||
);
|
||||
}
|
||||
let bot_gaps: Vec<(f32, f32)> = hs
|
||||
.iter()
|
||||
.filter(|(_, _, zb, _)| *zb <= y0 + MIN_SPAN)
|
||||
.map(|(a0, a1, _, _)| (*a0, *a1))
|
||||
.collect();
|
||||
for (ua, ub) in subtract_intervals(0.0, length, &bot_gaps) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(ua, off_a), y0),
|
||||
w(g_at(ub, off_a), y0),
|
||||
w(g_at(ub, off_b), y0),
|
||||
w(g_at(ua, off_b), y0),
|
||||
[0.0, -1.0, 0.0],
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
// ── End-Kappen (a=0 / a=length): volle Hoehe minus randstaendiger Loecher. ─
|
||||
// Aussen-Normale je Kappe geometrisch aus den vollen Kappen-Ecken (wie im
|
||||
// rechtwinkligen/gehrten Fall in `extrude_layer_segment`).
|
||||
let start_normal = quad_normal(
|
||||
w(g_at(0.0, off_b), y0),
|
||||
w(g_at(0.0, off_a), y0),
|
||||
w(g_at(0.0, off_b), y1),
|
||||
);
|
||||
let start_gaps: Vec<(f32, f32)> = hs
|
||||
.iter()
|
||||
.filter(|(a0, _, _, _)| *a0 <= MIN_SPAN)
|
||||
.map(|(_, _, zb, zt)| (*zb, *zt))
|
||||
.collect();
|
||||
for (za, zb) in subtract_intervals(y0, y1, &start_gaps) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(0.0, off_b), za),
|
||||
w(g_at(0.0, off_a), za),
|
||||
w(g_at(0.0, off_a), zb),
|
||||
w(g_at(0.0, off_b), zb),
|
||||
start_normal,
|
||||
color,
|
||||
);
|
||||
}
|
||||
let end_normal = quad_normal(
|
||||
w(g_at(length, off_a), y0),
|
||||
w(g_at(length, off_b), y0),
|
||||
w(g_at(length, off_a), y1),
|
||||
);
|
||||
let end_gaps: Vec<(f32, f32)> = hs
|
||||
.iter()
|
||||
.filter(|(_, a1, _, _)| *a1 >= length - MIN_SPAN)
|
||||
.map(|(_, _, zb, zt)| (*zb, *zt))
|
||||
.collect();
|
||||
for (za, zb) in subtract_intervals(y0, y1, &end_gaps) {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(length, off_a), za),
|
||||
w(g_at(length, off_b), za),
|
||||
w(g_at(length, off_b), zb),
|
||||
w(g_at(length, off_a), zb),
|
||||
end_normal,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Laibungen je Loch: nur die im Wand-Inneren liegenden Seiten. ──────────
|
||||
for &(a0, a1, zb, zt) in &hs {
|
||||
// Linke Laibung (bei a0), Normale +u (in die Oeffnung).
|
||||
if a0 > MIN_SPAN {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(a0, off_a), zb),
|
||||
w(g_at(a0, off_b), zb),
|
||||
w(g_at(a0, off_b), zt),
|
||||
w(g_at(a0, off_a), zt),
|
||||
[u[0], 0.0, u[1]],
|
||||
color,
|
||||
);
|
||||
}
|
||||
// Rechte Laibung (bei a1), Normale −u.
|
||||
if a1 < length - MIN_SPAN {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(a1, off_a), zb),
|
||||
w(g_at(a1, off_b), zb),
|
||||
w(g_at(a1, off_b), zt),
|
||||
w(g_at(a1, off_a), zt),
|
||||
[-u[0], 0.0, -u[1]],
|
||||
color,
|
||||
);
|
||||
}
|
||||
// Untere Laibung (bei zb = Bruestungsoberkante), Normale +Y.
|
||||
if zb > y0 + MIN_SPAN {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(a0, off_a), zb),
|
||||
w(g_at(a1, off_a), zb),
|
||||
w(g_at(a1, off_b), zb),
|
||||
w(g_at(a0, off_b), zb),
|
||||
[0.0, 1.0, 0.0],
|
||||
color,
|
||||
);
|
||||
}
|
||||
// Obere Laibung (bei zt = Sturzunterkante), Normale −Y.
|
||||
if zt < y1 - MIN_SPAN {
|
||||
push_quad_oriented(
|
||||
mesh,
|
||||
w(g_at(a0, off_a), zt),
|
||||
w(g_at(a1, off_a), zt),
|
||||
w(g_at(a1, off_b), zt),
|
||||
w(g_at(a0, off_b), zt),
|
||||
[0.0, -1.0, 0.0],
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aussen-Normale eines planaren, CCW-von-aussen georderten Quads `(a,b,_,d)`
|
||||
/// (nur die drei ersten Ecken werden gebraucht: `d` ist die vierte, ueber `a`
|
||||
/// benachbarte Ecke). `cross(b-a, d-a)` — Herleitung/Verifikation siehe
|
||||
@@ -835,160 +493,6 @@ 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
|
||||
@@ -1071,131 +575,6 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
|
||||
mesh
|
||||
}
|
||||
|
||||
/// TEXTURIERTER Wand-Pfad (`RenderStyle::Textured`): liefert DIESELBE Geometrie wie
|
||||
/// `build_walls_mesh` — Positionen und Normalen bitgleich, gleiche Vertex-/Dreiecks-
|
||||
/// Reihenfolge — aber mit einer planaren, WELTMASSSTAEBLICHEN UV je Vertex statt der
|
||||
/// Bauteilfarbe. Bewusst ALS ABLEITUNG aus dem fertigen `Mesh` implementiert (nicht
|
||||
/// als parallele Re-Extrusion): so bleibt die paritaets-/reihenfolgesensible
|
||||
/// Geometrie (Gehrungen, Oeffnungen, Loecher, Schichten) die EINE Quelle der
|
||||
/// Wahrheit und der Alt-Pfad `[pos, normal, color]` bleibt bitgleich unangetastet —
|
||||
/// wir tauschen nur den color-Kanal gegen den aus Position+Normale abgeleiteten
|
||||
/// UV-Kanal aus.
|
||||
///
|
||||
/// UV-PROJEKTION (planar, in Metern, damit das Textur-Raster weltmassstaeblich ist,
|
||||
/// z. B. 1 Kachel = 1 m; kein Verzug an Gehrungen/Ecken, weil jede Flaeche planar
|
||||
/// projiziert wird und benachbarte Waende dieselbe Welt-Projektion fortsetzen —
|
||||
/// die Kacheln stossen im Weltraum sauber aneinander):
|
||||
/// - Senkrechte Flaechen (Wand-Mantel, Normale ~ horizontal): `u` = Weglaenge
|
||||
/// entlang der horizontalen Flaechen-Tangente `t = (-n.z, 0, n.x)`
|
||||
/// (`u = dot(pos.xz, t.xz)`), `v` = Hoehe `pos.y`.
|
||||
/// - Waagerechte Flaechen (Deckel/Boden, Normale ~ +/-Y): direkt aus dem
|
||||
/// Grundriss `u = pos.x`, `v = pos.z`.
|
||||
/// Die Wahl der Achse allein aus der Flaechen-Normale (statt aus der Wand-Achse)
|
||||
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
|
||||
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
|
||||
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
|
||||
// Ueber den Szenen-Builder (ohne Decken/Kontext) — so traegt der Puffer auch
|
||||
// die Material-Ebenen je Wand (`layers`), waehrend Geometrie/UV bitgleich zu
|
||||
// `textured_from_mesh(&build_walls_mesh(walls))` bleiben.
|
||||
build_scene_mesh_textured(walls, &[], &[])
|
||||
}
|
||||
|
||||
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
|
||||
/// (`[pos, normal, uv]`) um: Positionen/Normalen/Indizes werden 1:1 uebernommen,
|
||||
/// die Farbe je Vertex wird durch die planare Welt-UV ersetzt (siehe
|
||||
/// `build_walls_mesh_textured` fuer die Achswahl). Indizes bleiben unveraendert,
|
||||
/// weil sich die Vertex-Anzahl/-Reihenfolge nicht aendert. `pub`, damit die
|
||||
/// GPU-Schicht (`gpu.rs`) das texturierte Pendant AUS DEMSELBEN `Mesh` ableiten
|
||||
/// kann, das sie ohnehin schon gebaut/hochgeladen hat (eine Geometrie-Quelle).
|
||||
pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
|
||||
let n = mesh.vertex_count();
|
||||
let mut verts: Vec<f32> = Vec::with_capacity(n * TEXTURED_FLOATS_PER_VERTEX);
|
||||
for i in 0..n {
|
||||
let b = i * FLOATS_PER_VERTEX;
|
||||
let px = mesh.verts[b];
|
||||
let py = mesh.verts[b + 1];
|
||||
let pz = mesh.verts[b + 2];
|
||||
let nx = mesh.verts[b + 3];
|
||||
let ny = mesh.verts[b + 4];
|
||||
let nz = mesh.verts[b + 5];
|
||||
// Waagerecht (Deckel/Boden) vs. senkrecht (Mantel) an der Vertikal-
|
||||
// Komponente der Normale unterscheiden. Waende sind vertikal extrudiert,
|
||||
// ihre Mantelnormale ist streng horizontal (ny == 0) — die Schwelle 0.5
|
||||
// trennt sauber Deckel/Boden (|ny| == 1) von Mantelflaechen (ny == 0).
|
||||
let (u, v) = if ny.abs() > 0.5 {
|
||||
// Grundriss-Projektion: u = x, v = z (Meter).
|
||||
(px, pz)
|
||||
} else {
|
||||
// Horizontale Flaechen-Tangente t = (-n.z, 0, n.x): u = Weglaenge
|
||||
// entlang t, v = Hoehe. `t` ist ein Einheitsvektor, weil (n.x, n.z)
|
||||
// fuer Mantelflaechen normiert ist.
|
||||
let u = px * (-nz) + pz * nx;
|
||||
(u, py)
|
||||
};
|
||||
verts.extend_from_slice(&[px, py, pz, nx, ny, nz, u, v]);
|
||||
}
|
||||
TexturedMesh {
|
||||
verts,
|
||||
indices: mesh.indices.clone(),
|
||||
// Ohne Material-Zuordnung: leer -> die GPU-Schicht bindet einen konstanten
|
||||
// `-1`-Ebenenpuffer (Fallback-Schachbrett fuer alle Vertices).
|
||||
layers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// TEXTURIERTES Szenen-Mesh MIT Material-Ebenen: baut dieselbe Szene wie
|
||||
/// `build_scene_mesh` (Waende -> Decken -> Kontext-Meshes, gleiche Reihenfolge/
|
||||
/// Geometrie) und fuellt zusaetzlich den parallelen `layers`-Puffer (ein Eintrag
|
||||
/// je Vertex): fuer jede Wand ihre `material_index`-Textur-Ebene (`None` -> `-1`),
|
||||
/// fuer Decken und Kontext-Meshes `-1` (Fallback-Schachbrett). Die Vertex-Ranges
|
||||
/// werden waehrend des Baus je Bauteil aus `Mesh::vertex_count()` abgegriffen —
|
||||
/// dieselben `extrude_*`/`append_*`-Funktionen wie der Shaded-Pfad bleiben die
|
||||
/// EINE Geometrie-Quelle (Paritaet garantiert), es kommt nur die Ebenen-Zuordnung
|
||||
/// dazu. Genutzt vom `Textured`-Stil (`gpu::upload_*`).
|
||||
pub fn build_scene_mesh_textured(
|
||||
walls: &[WallInput],
|
||||
slabs: &[SlabInput],
|
||||
meshes: &[MeshInput],
|
||||
) -> TexturedMesh {
|
||||
let mut mesh = Mesh::default();
|
||||
let mut layers: Vec<f32> = Vec::new();
|
||||
let fill = |mesh: &Mesh, layers: &mut Vec<f32>, before: usize, layer: f32| {
|
||||
let added = mesh.vertex_count().saturating_sub(before);
|
||||
layers.extend(std::iter::repeat(layer).take(added));
|
||||
};
|
||||
|
||||
let miters = compute_wall_miters(walls);
|
||||
for (w, m) in walls.iter().zip(miters.iter()) {
|
||||
let before = mesh.vertex_count();
|
||||
extrude_wall_with_miters(&mut mesh, w, *m);
|
||||
// Material-Ebene der Wand (1-basiert; 0 ist das Fallback-Schachbrett).
|
||||
let layer = w.material_index.map(|i| i as f32).unwrap_or(-1.0);
|
||||
fill(&mesh, &mut layers, before, layer);
|
||||
}
|
||||
for s in slabs {
|
||||
let before = mesh.vertex_count();
|
||||
extrude_slab(&mut mesh, s);
|
||||
fill(&mesh, &mut layers, before, -1.0);
|
||||
}
|
||||
for m in meshes {
|
||||
let before = mesh.vertex_count();
|
||||
append_context_mesh(&mut mesh, m);
|
||||
// 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);
|
||||
debug_assert_eq!(
|
||||
layers.len(),
|
||||
tex.vertex_count(),
|
||||
"ein Ebenen-Eintrag je Vertex"
|
||||
);
|
||||
tex.layers = layers;
|
||||
tex
|
||||
}
|
||||
|
||||
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
|
||||
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
|
||||
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
|
||||
@@ -1220,20 +599,6 @@ 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
|
||||
@@ -1256,21 +621,6 @@ 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;
|
||||
@@ -1285,7 +635,6 @@ 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]];
|
||||
@@ -1300,8 +649,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, [ca, cb, cc]);
|
||||
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], [ca, cc, cb]);
|
||||
push_ctx_tri(mesh, a, b, c, n, color);
|
||||
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1314,10 +663,10 @@ fn push_ctx_tri(
|
||||
b: [f32; 3],
|
||||
c: [f32; 3],
|
||||
normal: [f32; 3],
|
||||
colors: [Rgb; 3],
|
||||
color: Rgb,
|
||||
) {
|
||||
let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32;
|
||||
for (p, color) in [a, b, c].iter().zip(colors.iter()) {
|
||||
for p in [a, b, c] {
|
||||
mesh.verts.extend_from_slice(&[
|
||||
p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2],
|
||||
]);
|
||||
|
||||
@@ -24,9 +24,12 @@
|
||||
//! - **Ursprung:** `SectionPlane::point`, projiziert.
|
||||
//! - **u (horizontal in der Zeichnung):** Strecke entlang der Schnittebene,
|
||||
//! senkrecht zur Blickrichtung, in der Horizontalen. Berechnet als
|
||||
//! `u_axis = normalize(cross(world_up, normal))` — das Blickrichtungs-RECHTS
|
||||
//! des Betrachters (wer nach Norden blickt, hat Osten rechts). Die fruehere
|
||||
//! look_at-Konvention `cross(normal, up)` spiegelte die Zeichnung.
|
||||
//! `u_axis = normalize(cross(normal, world_up))` — dieselbe rechtshaendige
|
||||
//! Kamera-Konvention wie `math::look_at` (dort `s = cross(f, up)`), damit die
|
||||
//! Vorzeichen-Konvention modulübergreifend konsistent bleibt. Bei den
|
||||
//! Standard-Konstruktoren entspricht `u` direkt der jeweils ANDEREN
|
||||
//! Grundriss-Achse (Schnitt entlang X -> u = Modell-Y, Schnitt entlang Y ->
|
||||
//! u = -Modell-X).
|
||||
//! - **v (vertikal in der Zeichnung, "Hoehe"):** `v = world.y`, ABSOLUT (nicht
|
||||
//! relativ zur Schnittebene). `world.y` ist in dieser Codebasis durchgaengig
|
||||
//! die Hoehen-Achse (Y-up, siehe `types.rs`/`mesh.rs`/`math.rs`: "world.y =
|
||||
@@ -130,7 +133,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::math;
|
||||
use crate::types::{CutBandMeta, Hatch, Opening, Point2, Rgb, SlabInput, WallInput};
|
||||
use crate::types::{Opening, Point2, Rgb, SlabInput, WallInput};
|
||||
|
||||
/// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe).
|
||||
const EPS: f32 = 1e-4;
|
||||
@@ -186,13 +189,8 @@ impl SectionPlane {
|
||||
/// `pub(crate)`, damit `section_fill` die (u,v)->Welt-Ruecktransformation
|
||||
/// mit EXAKT derselben Achsdefinition (inkl. Entartungs-Fallback) rechnet.
|
||||
pub(crate) fn u_axis(&self) -> [f32; 3] {
|
||||
// Blickrichtungs-RECHTS des Betrachters: cross(up, normal). Die alte
|
||||
// Formel cross(normal, up) (die `look_at`-right-Konvention fuer Blick
|
||||
// entlang -z) SPIEGELTE den Schnitt — wer nach Norden blickt, hat
|
||||
// Osten rechts, die Ausgabe zeigte ihn links. Gleiche Entspiegelung
|
||||
// wie `elevationFrame` (toElevation.ts) auf der TS-Seite.
|
||||
let up = [0.0, 1.0, 0.0];
|
||||
let u = math::cross(up, self.normal);
|
||||
let u = math::cross(self.normal, up);
|
||||
if math::length(u) > 1e-6 {
|
||||
math::normalize(u)
|
||||
} else {
|
||||
@@ -270,19 +268,8 @@ pub struct ComponentRef {
|
||||
pub struct CutPolygon {
|
||||
pub component: ComponentRef,
|
||||
pub color: Rgb,
|
||||
/// Schnitt-Schraffur des Ursprungs-Bauteils (siehe `WallInput::hatch`). `None`
|
||||
/// -> der Cap-Shader zeichnet das Fallback-Diagonalmuster (Alt-Verhalten).
|
||||
#[serde(default)]
|
||||
pub hatch: Option<Hatch>,
|
||||
/// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen).
|
||||
pub pts: Vec<[f32; 2]>,
|
||||
/// Schnitt-Verschneidungs-/Linienstil-Metadaten des Ursprungs-Bandes (siehe
|
||||
/// `WallInput::cut`). Treibt die Boolean-Dominanz in `section_boolean::
|
||||
/// subtract_dominant_bands` (die staerkere Schicht schneidet die schwaechere)
|
||||
/// und die Schnitt-Umrisslinien (`section_fill::build_cut_cap_lines`). `None`
|
||||
/// -> das Band nimmt an keiner Subtraktion teil (Alt-Verhalten).
|
||||
#[serde(default)]
|
||||
pub cut: Option<CutBandMeta>,
|
||||
}
|
||||
|
||||
/// Ein Liniensegment in Schnittkoordinaten (u, v), Meter.
|
||||
@@ -327,16 +314,9 @@ struct Prism {
|
||||
z0: f32,
|
||||
z1: f32,
|
||||
color: Rgb,
|
||||
/// Schnitt-Schraffur des Bauteils (siehe `WallInput::hatch`). Wandert in die
|
||||
/// Cut-Polygone, damit `section_fill`/`CAP_WGSL` das richtige Muster zeichnen.
|
||||
hatch: Option<Hatch>,
|
||||
/// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer
|
||||
/// Decken (Slabs kennen keine Oeffnungen).
|
||||
wall_axis: Option<WallAxis>,
|
||||
/// Schnitt-Metadaten (Prioritaet/Identitaet/Linienstil), 1:1 aus der
|
||||
/// `WallInput`/`SlabInput`-Eingabe (`cut`) uebernommen und an jedes erzeugte
|
||||
/// `CutPolygon` weitergereicht.
|
||||
cut: Option<CutBandMeta>,
|
||||
}
|
||||
|
||||
/// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut-
|
||||
@@ -419,14 +399,12 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option<Prism> {
|
||||
z0: wall.base_elevation,
|
||||
z1: wall.base_elevation + wall.height,
|
||||
color: wall.color,
|
||||
hatch: wall.hatch,
|
||||
wall_axis: Some(WallAxis {
|
||||
start: wall.start,
|
||||
dir,
|
||||
length,
|
||||
openings: wall.openings.clone(),
|
||||
}),
|
||||
cut: wall.cut.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -447,9 +425,7 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option<Prism> {
|
||||
z0,
|
||||
z1,
|
||||
color: slab.color,
|
||||
hatch: slab.hatch,
|
||||
wall_axis: None,
|
||||
cut: slab.cut.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -998,9 +974,7 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput
|
||||
index: prism.index,
|
||||
},
|
||||
color: prism.color,
|
||||
hatch: prism.hatch,
|
||||
pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]],
|
||||
cut: prism.cut.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1042,10 +1016,6 @@ mod tests {
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
WallInput {
|
||||
start: [0.0, 3.0],
|
||||
@@ -1056,10 +1026,6 @@ mod tests {
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
];
|
||||
let slabs = vec![SlabInput {
|
||||
@@ -1067,8 +1033,6 @@ mod tests {
|
||||
z_bottom: -0.2,
|
||||
z_top: 0.0,
|
||||
color: [0.86, 0.86, 0.88],
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}];
|
||||
(walls, slabs)
|
||||
}
|
||||
@@ -1101,9 +1065,8 @@ mod tests {
|
||||
math::dot(u, plane.normal).abs() < 1e-6,
|
||||
"u-Achse orthogonal zur Blickrichtung"
|
||||
);
|
||||
// Fuer looking_plus_x (Blick nach Osten) zeigt das Betrachter-RECHTS
|
||||
// nach Sueden: u = world −Z (Entspiegelung, s. u_axis-Doc).
|
||||
assert!((u[2] + 1.0).abs() < 1e-6);
|
||||
// Fuer looking_plus_x ist u = Modell-Y (world +Z).
|
||||
assert!((u[2] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1143,8 +1106,8 @@ mod tests {
|
||||
let vs: Vec<f32> = wall_cut.pts.iter().map(|p| p[1]).collect();
|
||||
let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
||||
let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
||||
assert!((u_lo + 3.1).abs() < 1e-4, "u_lo={u_lo}");
|
||||
assert!((u_hi + 2.9).abs() < 1e-4, "u_hi={u_hi}");
|
||||
assert!((u_lo - 2.9).abs() < 1e-4, "u_lo={u_lo}");
|
||||
assert!((u_hi - 3.1).abs() < 1e-4, "u_hi={u_hi}");
|
||||
assert!((v_lo - 0.0).abs() < 1e-4, "v_lo={v_lo}");
|
||||
assert!((v_hi - 2.5).abs() < 1e-4, "v_hi={v_hi}");
|
||||
|
||||
@@ -1157,8 +1120,8 @@ mod tests {
|
||||
let vs: Vec<f32> = slab_cut.pts.iter().map(|p| p[1]).collect();
|
||||
let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
||||
let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
||||
assert!((u_lo + 3.5).abs() < 1e-4, "u_lo={u_lo}");
|
||||
assert!((u_hi - 0.5).abs() < 1e-4, "u_hi={u_hi}");
|
||||
assert!((u_lo + 0.5).abs() < 1e-4, "u_lo={u_lo}");
|
||||
assert!((u_hi - 3.5).abs() < 1e-4, "u_hi={u_hi}");
|
||||
assert!((v_lo + 0.2).abs() < 1e-4, "v_lo={v_lo}");
|
||||
assert!((v_hi - 0.0).abs() < 1e-4, "v_hi={v_hi}");
|
||||
}
|
||||
@@ -1214,30 +1177,30 @@ mod tests {
|
||||
let embedded_corner_hidden = out.hidden_edges.iter().any(|e| {
|
||||
e.component.kind == ComponentKind::Wall
|
||||
&& e.component.index == 1
|
||||
&& (e.a[0] + 2.9).abs() < 1e-3
|
||||
&& (e.b[0] + 2.9).abs() < 1e-3
|
||||
&& (e.a[0] - 2.9).abs() < 1e-3
|
||||
&& (e.b[0] - 2.9).abs() < 1e-3
|
||||
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
|
||||
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
|
||||
});
|
||||
assert!(embedded_corner_hidden, "Eckkante von Schenkel B bei u=-2.9 muss hidden sein");
|
||||
assert!(embedded_corner_hidden, "Eckkante von Schenkel B bei u=2.9 muss hidden sein");
|
||||
|
||||
// Die AEUSSERE Eckkante von Schenkel B (u=3.1, ragt ueber Schenkel A
|
||||
// hinaus) bleibt dagegen sichtbar.
|
||||
let outer_corner_visible = out.visible_edges.iter().any(|e| {
|
||||
e.component.kind == ComponentKind::Wall
|
||||
&& e.component.index == 1
|
||||
&& (e.a[0] + 3.1).abs() < 1e-3
|
||||
&& (e.b[0] + 3.1).abs() < 1e-3
|
||||
&& (e.a[0] - 3.1).abs() < 1e-3
|
||||
&& (e.b[0] - 3.1).abs() < 1e-3
|
||||
});
|
||||
assert!(outer_corner_visible, "Aeussere Eckkante von Schenkel B (u=3.1) bleibt sichtbar");
|
||||
|
||||
// Die partiell gesplittete Bodenkante (u: -3.1 -> -2.9 bei v=0,
|
||||
// gespiegelte u-Achse) zerfaellt in einen sichtbaren Anteil ab u=-3.0.
|
||||
// Die partiell gesplittete Bodenkante (u: 2.9 -> 3.1 bei v=0) zerfaellt
|
||||
// in einen sichtbaren Anteil ab u=3.0.
|
||||
let split_visible_present = out.visible_edges.iter().any(|e| {
|
||||
e.component.kind == ComponentKind::Wall
|
||||
&& e.component.index == 1
|
||||
&& (e.a[1].abs() < 1e-3 && e.b[1].abs() < 1e-3)
|
||||
&& ((e.a[0] + 3.0).abs() < 1e-3 || (e.b[0] + 3.0).abs() < 1e-3)
|
||||
&& ((e.a[0] - 3.0).abs() < 1e-3 || (e.b[0] - 3.0).abs() < 1e-3)
|
||||
});
|
||||
assert!(split_visible_present, "Sichtbarer Teil der gesplitteten Bodenkante ab u=3.0 fehlt");
|
||||
}
|
||||
@@ -1289,14 +1252,14 @@ mod tests {
|
||||
};
|
||||
let a = wall_cuts.iter().find(|c| c.component.index == 0).expect("Schenkel A");
|
||||
let b = wall_cuts.iter().find(|c| c.component.index == 1).expect("Schenkel B");
|
||||
let (a_lo, _a_hi) = u_bounds(a);
|
||||
let (_b_lo, b_hi) = u_bounds(b);
|
||||
let (_a_lo, a_hi) = u_bounds(a);
|
||||
let (b_lo, _b_hi) = u_bounds(b);
|
||||
|
||||
// Beide enden/beginnen an der Gehrungslinie (u ~ -2.95, gespiegelte
|
||||
// u-Achse) statt sich zu ueberlappen.
|
||||
assert!((a_lo + 2.95).abs() < 1e-3, "Schenkel-A-Querschnitt endet an der Gehrung, u_lo={a_lo}");
|
||||
assert!((b_hi + 2.95).abs() < 1e-3, "Schenkel-B-Querschnitt beginnt an der Gehrung, u_hi={b_hi}");
|
||||
assert!(b_hi <= a_lo + 1e-4, "keine u-Ueberlappung der beiden Querschnitte");
|
||||
// Beide enden/beginnen an der Gehrungslinie (u ~ 2.95) statt sich zu
|
||||
// ueberlappen (Schenkel A reichte ungehrt bis u=3.0, Schenkel B ab 2.9).
|
||||
assert!((a_hi - 2.95).abs() < 1e-3, "Schenkel-A-Querschnitt endet an der Gehrung, u_hi={a_hi}");
|
||||
assert!((b_lo - 2.95).abs() < 1e-3, "Schenkel-B-Querschnitt beginnt an der Gehrung, u_lo={b_lo}");
|
||||
assert!(a_hi <= b_lo + 1e-4, "keine u-Ueberlappung der beiden Querschnitte");
|
||||
}
|
||||
|
||||
// --- Oeffnungen (Tueren/Fenster) -----------------------------------------
|
||||
@@ -1319,10 +1282,6 @@ mod tests {
|
||||
height: 1.0,
|
||||
}],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,10 +1367,6 @@ mod tests {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
|
||||
let plane = SectionPlane::looking_plus_y(-2.0);
|
||||
@@ -1430,8 +1385,8 @@ mod tests {
|
||||
&& (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3
|
||||
})
|
||||
};
|
||||
assert!(jamb_visible(1.5), "linke Leibung (u=1.5) muss sichtbar sein");
|
||||
assert!(jamb_visible(2.5), "rechte Leibung (u=2.5) muss sichtbar sein");
|
||||
assert!(jamb_visible(-1.5), "linke Leibung (u=-1.5) muss sichtbar sein");
|
||||
assert!(jamb_visible(-2.5), "rechte Leibung (u=-2.5) muss sichtbar sein");
|
||||
// Bruestungs- und Sturzkante (horizontale Rahmenkanten bei v=1.0/2.0).
|
||||
let horizontal_edge_visible = |v: f32| {
|
||||
out.visible_edges.iter().any(|e| {
|
||||
@@ -1439,8 +1394,8 @@ mod tests {
|
||||
&& e.component.index == 0
|
||||
&& (e.a[1] - v).abs() < 1e-3
|
||||
&& (e.b[1] - v).abs() < 1e-3
|
||||
&& (e.a[0].min(e.b[0]) - 1.5).abs() < 1e-3
|
||||
&& (e.a[0].max(e.b[0]) - 2.5).abs() < 1e-3
|
||||
&& (e.a[0].min(e.b[0]) + 2.5).abs() < 1e-3
|
||||
&& (e.a[0].max(e.b[0]) + 1.5).abs() < 1e-3
|
||||
})
|
||||
};
|
||||
assert!(horizontal_edge_visible(1.0), "Bruestungskante (v=1.0) muss sichtbar sein");
|
||||
@@ -1450,21 +1405,21 @@ mod tests {
|
||||
let frame_hidden = out.hidden_edges.iter().any(|e| {
|
||||
e.component.kind == ComponentKind::Wall
|
||||
&& e.component.index == 0
|
||||
&& ((e.a[0] - 1.5).abs() < 1e-3 || (e.a[0] - 2.5).abs() < 1e-3)
|
||||
&& ((e.a[0] + 1.5).abs() < 1e-3 || (e.a[0] + 2.5).abs() < 1e-3)
|
||||
&& e.a[1] >= 1.0 - 1e-3
|
||||
&& e.a[1] <= 2.0 + 1e-3
|
||||
});
|
||||
assert!(!frame_hidden, "Fensterrahmen darf nicht (teilweise) hidden sein");
|
||||
|
||||
// -- Durchblick: die (naeherliegende) Vorderkante von Wand 1 bei
|
||||
// u=2.0 (Wandstart x=2, liegt IM Fenster-u-Bereich 1.5..2.5) muss
|
||||
// u=-2.0 (Wandstart x=2, liegt IM Fenster-u-Bereich -2.5..-1.5) muss
|
||||
// GENAU im Fensterband (v: 1.0..2.0) sichtbar sein, darueber/darunter
|
||||
// (verdeckt durch Bruestung/Sturz der Wand 0) hidden. --
|
||||
let w1_visible_window = out.visible_edges.iter().any(|e| {
|
||||
e.component.kind == ComponentKind::Wall
|
||||
&& e.component.index == 1
|
||||
&& (e.a[0] - 2.0).abs() < 1e-3
|
||||
&& (e.b[0] - 2.0).abs() < 1e-3
|
||||
&& (e.a[0] + 2.0).abs() < 1e-3
|
||||
&& (e.b[0] + 2.0).abs() < 1e-3
|
||||
&& (e.a[1].min(e.b[1]) - 1.0).abs() < 1e-3
|
||||
&& (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3
|
||||
});
|
||||
@@ -1473,8 +1428,8 @@ mod tests {
|
||||
let w1_hidden_below = out.hidden_edges.iter().any(|e| {
|
||||
e.component.kind == ComponentKind::Wall
|
||||
&& e.component.index == 1
|
||||
&& (e.a[0] - 2.0).abs() < 1e-3
|
||||
&& (e.b[0] - 2.0).abs() < 1e-3
|
||||
&& (e.a[0] + 2.0).abs() < 1e-3
|
||||
&& (e.b[0] + 2.0).abs() < 1e-3
|
||||
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
|
||||
&& (e.a[1].max(e.b[1]) - 1.0).abs() < 1e-3
|
||||
});
|
||||
@@ -1483,20 +1438,20 @@ mod tests {
|
||||
let w1_hidden_above = out.hidden_edges.iter().any(|e| {
|
||||
e.component.kind == ComponentKind::Wall
|
||||
&& e.component.index == 1
|
||||
&& (e.a[0] - 2.0).abs() < 1e-3
|
||||
&& (e.b[0] - 2.0).abs() < 1e-3
|
||||
&& (e.a[0] + 2.0).abs() < 1e-3
|
||||
&& (e.b[0] + 2.0).abs() < 1e-3
|
||||
&& (e.a[1].min(e.b[1]) - 2.0).abs() < 1e-3
|
||||
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
|
||||
});
|
||||
assert!(w1_hidden_above, "Wand 1 oberhalb des Sturzes (v 2.0..2.5) muss hidden sein");
|
||||
|
||||
// Kante hinter der GESCHLOSSENEN Wandflaeche (u=4.0, ausserhalb des
|
||||
// Kante hinter der GESCHLOSSENEN Wandflaeche (u=-4.0, ausserhalb des
|
||||
// Fensters, x=4-Ende von Wand 1) bleibt vollstaendig verdeckt.
|
||||
let w1_closed_wall_hidden = out.hidden_edges.iter().any(|e| {
|
||||
e.component.kind == ComponentKind::Wall
|
||||
&& e.component.index == 1
|
||||
&& (e.a[0] - 4.0).abs() < 1e-3
|
||||
&& (e.b[0] - 4.0).abs() < 1e-3
|
||||
&& (e.a[0] + 4.0).abs() < 1e-3
|
||||
&& (e.b[0] + 4.0).abs() < 1e-3
|
||||
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
|
||||
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
|
||||
});
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
//! Schnitt-Boolean-Dominanz fuer den 3D-Live-Schnitt — die 1:1-Portierung von
|
||||
//! `src/plan/toSection.ts::subtractDominantBands`/`subtractRect` nach Rust.
|
||||
//!
|
||||
//! GRUND: der 2D-Schnitt (`toSection.ts`) ist die Referenz-Implementierung. Er
|
||||
//! sammelt alle Cut-Baender (Wand- und Deckenschichten) als achsparallele
|
||||
//! (u,v)-Rechtecke, taggt jedes mit seiner `Component.joinPriority` und laesst
|
||||
//! dann die STAERKERE Schicht die schwaechere per Rechteck-Subtraktion wegschneiden
|
||||
//! (elementuebergreifend). Der 3D-Rust-Cut (`section.rs`) produziert bereits genau
|
||||
//! dasselbe Format — achsparallele (u,v)-Rechteck-`CutPolygon`s — also laesst sich
|
||||
//! die Dominanz-Zerlegung hier identisch nachbauen, damit der 3D-Live-Schnitt fuer
|
||||
//! Wand+Decken-Schichten EXAKT wie der 2D-Schnitt aussieht.
|
||||
//!
|
||||
//! Die Prioritaet/Identitaet je Band kommt aus `CutPolygon::cut` (`CutBandMeta`,
|
||||
//! aus der TS-Emission `toWalls3d`). Baender ohne `join_priority` (unaufloesbares
|
||||
//! Bauteil) nehmen weder als Basis noch als Cutter teil und bleiben unveraendert.
|
||||
|
||||
use crate::section::CutPolygon;
|
||||
|
||||
/// Toleranz fuer Ueberlappungs-/Rest-Rechtecke (Meter): Null-/Negativflaechen und
|
||||
/// Rest-Streifen duenner als EPS werden verworfen (Float-Rauschen der (u,v)-Meter).
|
||||
/// Identisch zu `RECT_EPS` in `toSection.ts`.
|
||||
const RECT_EPS: f32 = 1e-6;
|
||||
|
||||
/// Achsparalleles Rechteck in (u, v)-Metern — das Rust-Gegenstueck zu `Rect` in
|
||||
/// `toSection.ts`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub u_min: f32,
|
||||
pub u_max: f32,
|
||||
pub v_min: f32,
|
||||
pub v_max: f32,
|
||||
}
|
||||
|
||||
/// Bounding-Box eines (achsparallelen) Cut-Bands als `Rect`, oder `None` bei <3
|
||||
/// Ecken (analog `rectOfBand`).
|
||||
pub fn rect_of_poly(poly: &CutPolygon) -> Option<Rect> {
|
||||
if poly.pts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let mut u_min = f32::INFINITY;
|
||||
let mut u_max = f32::NEG_INFINITY;
|
||||
let mut v_min = f32::INFINITY;
|
||||
let mut v_max = f32::NEG_INFINITY;
|
||||
for p in &poly.pts {
|
||||
u_min = u_min.min(p[0]);
|
||||
u_max = u_max.max(p[0]);
|
||||
v_min = v_min.min(p[1]);
|
||||
v_max = v_max.max(p[1]);
|
||||
}
|
||||
Some(Rect {
|
||||
u_min,
|
||||
u_max,
|
||||
v_min,
|
||||
v_max,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rechteck-minus-Rechteck (beide achsparallel): `base` ohne die Ueberlappung mit
|
||||
/// `cutter`, zerlegt in 0–4 disjunkte Rest-Rechtecke (links/rechts ueber die volle
|
||||
/// Hoehe, dann oben/unten im mittleren u-Streifen — ueberlappungsfrei). Kein echter
|
||||
/// Ueberlapp (Flaeche ≤ EPS in u oder v) ⇒ `base` bleibt ganz. Vollstaendige
|
||||
/// Ueberdeckung ⇒ leere Liste. Rest-Streifen duenner als EPS werden verworfen.
|
||||
/// 1:1 aus `toSection.ts::subtractRect`.
|
||||
pub fn subtract_rect(base: Rect, cutter: Rect) -> Vec<Rect> {
|
||||
let o_min_u = base.u_min.max(cutter.u_min);
|
||||
let o_max_u = base.u_max.min(cutter.u_max);
|
||||
let o_min_v = base.v_min.max(cutter.v_min);
|
||||
let o_max_v = base.v_max.min(cutter.v_max);
|
||||
// Kein (nennenswerter) Ueberlapp — `base` unveraendert.
|
||||
if o_max_u - o_min_u <= RECT_EPS || o_max_v - o_min_v <= RECT_EPS {
|
||||
return vec![base];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut push = |r: Rect| {
|
||||
if r.u_max - r.u_min > RECT_EPS && r.v_max - r.v_min > RECT_EPS {
|
||||
out.push(r);
|
||||
}
|
||||
};
|
||||
// Links / rechts der Ueberlappung, jeweils ueber die volle Basis-Hoehe.
|
||||
push(Rect {
|
||||
u_min: base.u_min,
|
||||
u_max: o_min_u,
|
||||
v_min: base.v_min,
|
||||
v_max: base.v_max,
|
||||
});
|
||||
push(Rect {
|
||||
u_min: o_max_u,
|
||||
u_max: base.u_max,
|
||||
v_min: base.v_min,
|
||||
v_max: base.v_max,
|
||||
});
|
||||
// Unten / oben im mittleren u-Streifen (nur der von der Ueberlappung befreite Rest).
|
||||
push(Rect {
|
||||
u_min: o_min_u,
|
||||
u_max: o_max_u,
|
||||
v_min: base.v_min,
|
||||
v_max: o_min_v,
|
||||
});
|
||||
push(Rect {
|
||||
u_min: o_min_u,
|
||||
u_max: o_max_u,
|
||||
v_min: o_max_v,
|
||||
v_max: base.v_max,
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// `join_priority` eines Bands (aus `CutBandMeta`), oder `None`.
|
||||
fn priority_of(poly: &CutPolygon) -> Option<i32> {
|
||||
poly.cut.as_ref().and_then(|c| c.join_priority)
|
||||
}
|
||||
|
||||
/// `component_id` eines Bands (aus `CutBandMeta`), oder `None`.
|
||||
fn component_of(poly: &CutPolygon) -> Option<&str> {
|
||||
poly.cut.as_ref().and_then(|c| c.component_id.as_deref())
|
||||
}
|
||||
|
||||
/// Erzeugt aus einem Rest-`Rect` ein Cut-Band, das Stil/Referenz/Metadaten von
|
||||
/// `src` erbt (analog `bandFromRect`). Ecken-Reihenfolge wie in `section.rs`
|
||||
/// (CCW-analog, hier v_max oben zuerst — fuer die Fan-Triangulierung irrelevant).
|
||||
fn poly_from_rect(src: &CutPolygon, r: Rect) -> CutPolygon {
|
||||
CutPolygon {
|
||||
component: src.component,
|
||||
color: src.color,
|
||||
hatch: src.hatch,
|
||||
cut: src.cut.clone(),
|
||||
pts: vec![
|
||||
[r.u_min, r.v_max],
|
||||
[r.u_max, r.v_max],
|
||||
[r.u_max, r.v_min],
|
||||
[r.u_min, r.v_min],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Globale Schnitt-Dominanz: fuer jedes Band wird die Rechteck-Ueberlappung ALLER
|
||||
/// Baender mit STRIKT hoeherer `join_priority` subtrahiert (elementuebergreifend —
|
||||
/// z. B. schneidet ein Decken-Beton-Band 100 ein ueberlappendes Wand-Putz-Band 10
|
||||
/// weg). Gleiche Prioritaet schneidet NICHT (koexistiert → durchgehende Flaeche).
|
||||
/// Baender ohne `join_priority` nehmen weder als Basis noch als Cutter teil und
|
||||
/// bleiben unveraendert erhalten. 1:1 aus `toSection.ts::subtractDominantBands`
|
||||
/// (inklusive der abschliessenden MERGE-Phase).
|
||||
pub fn subtract_dominant_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
|
||||
let rects: Vec<Option<Rect>> = bands.iter().map(rect_of_poly).collect();
|
||||
let prios: Vec<Option<i32>> = bands.iter().map(priority_of).collect();
|
||||
let mut out: Vec<CutPolygon> = Vec::new();
|
||||
|
||||
for i in 0..bands.len() {
|
||||
// Nicht-rechteckig oder ohne Prioritaet: unveraendert durchreichen.
|
||||
let (base, prio) = match (rects[i], prios[i]) {
|
||||
(Some(b), Some(p)) => (b, p),
|
||||
_ => {
|
||||
out.push(bands[i].clone());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut pieces = vec![base];
|
||||
for j in 0..bands.len() {
|
||||
if pieces.is_empty() {
|
||||
break;
|
||||
}
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
let cutter = match (rects[j], prios[j]) {
|
||||
(Some(c), Some(op)) if op > prio => c,
|
||||
_ => continue,
|
||||
};
|
||||
let mut next = Vec::new();
|
||||
for p in &pieces {
|
||||
next.extend(subtract_rect(*p, cutter));
|
||||
}
|
||||
pieces = next;
|
||||
}
|
||||
for p in pieces {
|
||||
out.push(poly_from_rect(&bands[i], p));
|
||||
}
|
||||
}
|
||||
merge_same_component_bands(out)
|
||||
}
|
||||
|
||||
/// Union zweier achsparalleler Rechtecke, ABER NUR wenn das Ergebnis EXAKT wieder
|
||||
/// ein Rechteck ist — sonst `None`. Enthaltensein oder Achs-Deckung + Beruehrung
|
||||
/// liefern eine rechteckige Union; Teil-/Eck-Ueberlappungen (L-Form) und Baender
|
||||
/// mit Spalt liefern `None`. 1:1 aus `toSection.ts::rectUnionIfRect`.
|
||||
fn rect_union_if_rect(a: Rect, b: Rect) -> Option<Rect> {
|
||||
const E: f32 = RECT_EPS;
|
||||
// Enthaltensein (inkl. Deckungsgleichheit): Union = umschliessendes Rechteck.
|
||||
let a_in_b = b.u_min - a.u_min <= E
|
||||
&& a.u_max - b.u_max <= E
|
||||
&& b.v_min - a.v_min <= E
|
||||
&& a.v_max - b.v_max <= E;
|
||||
if a_in_b {
|
||||
return Some(b);
|
||||
}
|
||||
let b_in_a = a.u_min - b.u_min <= E
|
||||
&& b.u_max - a.u_max <= E
|
||||
&& a.v_min - b.v_min <= E
|
||||
&& b.v_max - a.v_max <= E;
|
||||
if b_in_a {
|
||||
return Some(a);
|
||||
}
|
||||
let u_aligned = (a.u_min - b.u_min).abs() <= E && (a.u_max - b.u_max).abs() <= E;
|
||||
let v_aligned = (a.v_min - b.v_min).abs() <= E && (a.v_max - b.v_max).abs() <= E;
|
||||
// Beruehrung/Ueberlappung in der jeweils ANDEREN Achse (kein Spalt groesser EPS).
|
||||
let v_touch = a.v_max >= b.v_min - E && b.v_max >= a.v_min - E;
|
||||
let u_touch = a.u_max >= b.u_min - E && b.u_max >= a.u_min - E;
|
||||
if (u_aligned && v_touch) || (v_aligned && u_touch) {
|
||||
return Some(Rect {
|
||||
u_min: a.u_min.min(b.u_min),
|
||||
u_max: a.u_max.max(b.u_max),
|
||||
v_min: a.v_min.min(b.v_min),
|
||||
v_max: a.v_max.max(b.v_max),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// MERGE-Phase der Schnitt-Dominanz: Baender mit GLEICHER `component_id` UND
|
||||
/// gleicher `join_priority`, deren achsparallele Rechtecke sich beruehren/ueberlappen
|
||||
/// und eine rechteckige Union bilden (`rect_union_if_rect`), werden zu EINEM Band
|
||||
/// zusammengefasst — so entsteht an der Beruehrkante keine innere Trennlinie durch
|
||||
/// gleichartiges Material. Baender ohne `component_id`/`join_priority` oder ohne
|
||||
/// Rechteck-Form nehmen nicht teil. Iterativ bis zum Fixpunkt. 1:1 aus
|
||||
/// `toSection.ts::mergeSameComponentBands`.
|
||||
fn merge_same_component_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
|
||||
let mut out = bands;
|
||||
let mut merged = true;
|
||||
while merged {
|
||||
merged = false;
|
||||
'outer: for i in 0..out.len() {
|
||||
let (ci, pi) = match (component_of(&out[i]), priority_of(&out[i])) {
|
||||
(Some(c), Some(p)) => (c.to_string(), p),
|
||||
_ => continue,
|
||||
};
|
||||
let ri = match rect_of_poly(&out[i]) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
for j in (i + 1)..out.len() {
|
||||
if component_of(&out[j]) != Some(ci.as_str()) || priority_of(&out[j]) != Some(pi) {
|
||||
continue;
|
||||
}
|
||||
let rj = match rect_of_poly(&out[j]) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
if let Some(u) = rect_union_if_rect(ri, rj) {
|
||||
out[i] = poly_from_rect(&out[i], u);
|
||||
out.remove(j);
|
||||
merged = true; // Fixpunkt-Neustart
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::section::{ComponentKind, ComponentRef, CutPolygon};
|
||||
use crate::types::CutBandMeta;
|
||||
|
||||
/// Achsparalleles Cut-Band mit Prioritaet (und optionaler Komponenten-Id) —
|
||||
/// Ecken wie `section.rs` (Rechteck).
|
||||
fn band(
|
||||
u_min: f32,
|
||||
u_max: f32,
|
||||
v_min: f32,
|
||||
v_max: f32,
|
||||
join_priority: i32,
|
||||
component_id: Option<&str>,
|
||||
) -> CutPolygon {
|
||||
CutPolygon {
|
||||
component: ComponentRef {
|
||||
kind: ComponentKind::Wall,
|
||||
index: 0,
|
||||
},
|
||||
color: [0.5, 0.5, 0.5],
|
||||
hatch: None,
|
||||
pts: vec![
|
||||
[u_min, v_max],
|
||||
[u_max, v_max],
|
||||
[u_max, v_min],
|
||||
[u_min, v_min],
|
||||
],
|
||||
cut: Some(CutBandMeta {
|
||||
join_priority: Some(join_priority),
|
||||
component_id: component_id.map(|s| s.to_string()),
|
||||
line_color: None,
|
||||
line_weight: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_of(p: &CutPolygon) -> Rect {
|
||||
rect_of_poly(p).unwrap()
|
||||
}
|
||||
|
||||
fn area(bands: &[CutPolygon]) -> f32 {
|
||||
bands
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let r = rect_of(b);
|
||||
(r.u_max - r.u_min) * (r.v_max - r.v_min)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn approx(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 1e-5
|
||||
}
|
||||
|
||||
// --- subtractRect (Referenz: toSection.boolean.test.ts) ------------------
|
||||
|
||||
#[test]
|
||||
fn subtract_rect_kein_overlap_base_unveraendert() {
|
||||
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
|
||||
let cut = Rect { u_min: 2.0, u_max: 3.0, v_min: 2.0, v_max: 3.0 };
|
||||
assert_eq!(subtract_rect(base, cut), vec![base]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtract_rect_kantenberuehrung_base_unveraendert() {
|
||||
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
|
||||
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 0.0, v_max: 1.0 };
|
||||
assert_eq!(subtract_rect(base, cut), vec![base]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtract_rect_cutter_enthaelt_base_leer() {
|
||||
let base = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
|
||||
let cut = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
|
||||
assert!(subtract_rect(base, cut).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtract_rect_cutter_mittig_vier_reste() {
|
||||
let base = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
|
||||
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
|
||||
let rest = subtract_rect(base, cut);
|
||||
assert_eq!(rest.len(), 4);
|
||||
let a: f32 = rest
|
||||
.iter()
|
||||
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
|
||||
.sum();
|
||||
assert!(approx(a, 8.0), "9 (base) - 1 (overlap) = 8, ist {a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtract_rect_eck_ueberlappung_zwei_reste() {
|
||||
// cutter deckt die obere-rechte Ecke von base: overlap [1,2]x[1,2].
|
||||
let base = Rect { u_min: 0.0, u_max: 2.0, v_min: 0.0, v_max: 2.0 };
|
||||
let cut = Rect { u_min: 1.0, u_max: 3.0, v_min: 1.0, v_max: 3.0 };
|
||||
let rest = subtract_rect(base, cut);
|
||||
let a: f32 = rest
|
||||
.iter()
|
||||
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
|
||||
.sum();
|
||||
assert!(approx(a, 3.0), "4 - 1 = 3, ist {a}");
|
||||
}
|
||||
|
||||
// --- subtractDominantBands: die geometrische Kernaussage ------------------
|
||||
|
||||
#[test]
|
||||
fn decke_100_schneidet_wand_50_im_ueberlappungs_z_intervall_weg() {
|
||||
// Nutzer-Sample-analog: eine Wandschicht (Backstein, Prio 50, u in [0,0.2],
|
||||
// volle Hoehe v in [0,3]) und ein Decken-Beton-Band (Prio 100, u in [0,0.2],
|
||||
// v in [2.6,2.8]) ueberlappen in (u,v). Nach der Subtraktion darf die Wand
|
||||
// im Decken-z-Intervall KEINE Flaeche mehr haben; Beton bleibt voll.
|
||||
let brick = band(0.0, 0.2, 0.0, 3.0, 50, Some("brick"));
|
||||
let concrete = band(0.0, 0.2, 2.6, 2.8, 100, Some("concrete"));
|
||||
let out = subtract_dominant_bands(vec![brick, concrete]);
|
||||
|
||||
// Beton unveraendert (kein staerkeres Band).
|
||||
let concrete_out: Vec<&CutPolygon> = out
|
||||
.iter()
|
||||
.filter(|b| component_of(b) == Some("concrete"))
|
||||
.collect();
|
||||
assert_eq!(concrete_out.len(), 1);
|
||||
assert!(approx(
|
||||
(rect_of(concrete_out[0]).v_max - rect_of(concrete_out[0]).v_min) as f32,
|
||||
0.2
|
||||
));
|
||||
|
||||
// Backstein: in v in [2.6,2.8] KEINE Flaeche mehr -> zwei Reste [0,2.6] und
|
||||
// [2.8,3.0]. Kein Rest-Rechteck ragt ins Beton-Intervall.
|
||||
let brick_out: Vec<&CutPolygon> = out
|
||||
.iter()
|
||||
.filter(|b| component_of(b) == Some("brick"))
|
||||
.collect();
|
||||
assert!(!brick_out.is_empty());
|
||||
for b in &brick_out {
|
||||
let r = rect_of(b);
|
||||
// Kein Backstein-Rest ueberlappt (2.6, 2.8) echt.
|
||||
let overlaps = r.v_min < 2.8 - 1e-4 && r.v_max > 2.6 + 1e-4;
|
||||
assert!(!overlaps, "Backstein-Rest {r:?} ragt ins Beton-z-Intervall");
|
||||
}
|
||||
// Flaechenbilanz: Backstein 0.2*3 = 0.6, minus Overlap 0.2*0.2 = 0.04 -> 0.56.
|
||||
assert!(
|
||||
approx(area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()), 0.56),
|
||||
"Backstein-Restflaeche"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wand_50_schneidet_estrich_30() {
|
||||
// Estrich (Decke, Prio 30) verliert die Ueberlappung mit der Wand
|
||||
// (Backstein, Prio 50). Referenz-Zahlen wie im 2D-Test.
|
||||
let screed = band(0.0, 2.0, 0.0, 3.0, 30, Some("screed"));
|
||||
let brick = band(0.0, 2.0, 2.0, 4.0, 50, Some("brick")); // Overlap v in [2,3]
|
||||
let out = subtract_dominant_bands(vec![screed, brick]);
|
||||
|
||||
// Brick voll.
|
||||
assert!(approx(
|
||||
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
|
||||
4.0
|
||||
));
|
||||
// Estrich: [0,3] minus Overlap [2,3] = [0,2] -> Rest genau v in [0,2].
|
||||
let screed_out: Vec<&CutPolygon> = out
|
||||
.iter()
|
||||
.filter(|b| component_of(b) == Some("screed"))
|
||||
.collect();
|
||||
assert_eq!(screed_out.len(), 1);
|
||||
let r = rect_of(screed_out[0]);
|
||||
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 2.0), "Estrich-Rest {r:?}");
|
||||
}
|
||||
|
||||
/// SAMPLE-ANALOG (Nutzer-Bug): der Schnitt durch Aussenwand + Geschossdecke.
|
||||
/// Deckenschichten (u-breit, gestapelt in v nahe OK): Estrich (30, [2.54,2.6]),
|
||||
/// Dämmung (20, [2.50,2.54]), Beton (100, [2.30,2.50]). Wandschichten (schmal in
|
||||
/// u, an ihrer Dicken-Position): Backstein (50) läuft als ZWEI Boxen [0,2.3] +
|
||||
/// [2.5,2.6] durch (Beton kappt das Mittelstück — genau die per-Schicht-Decken-
|
||||
/// Dominanz aus emitWall). ERWARTUNG: nach der Verschneidung haben Estrich UND
|
||||
/// Dämmung im u-Bereich des Backsteins KEINE Fläche mehr (Backstein 50 > 30, 20);
|
||||
/// der Beton bleibt voll und läuft durch. Das ist die Richtung, die der Nutzer als
|
||||
/// kaputt meldete ("dämmung und zementboden laufen raus").
|
||||
#[test]
|
||||
fn sample_analog_backstein_schneidet_estrich_und_daemmung() {
|
||||
let brick_u = (0.0075_f32, 0.1575_f32); // Backstein-Dickenposition (W4-analog)
|
||||
let ceil_u = (0.0_f32, 5.0_f32); // Deckenschichten über die volle Raumbreite
|
||||
let bands = vec![
|
||||
band(ceil_u.0, ceil_u.1, 2.54, 2.6, 30, Some("screed")),
|
||||
band(ceil_u.0, ceil_u.1, 2.50, 2.54, 20, Some("insulation-ceiling")),
|
||||
band(ceil_u.0, ceil_u.1, 2.30, 2.50, 100, Some("concrete")),
|
||||
band(brick_u.0, brick_u.1, 0.0, 2.3, 50, Some("brick")),
|
||||
band(brick_u.0, brick_u.1, 2.5, 2.6, 50, Some("brick")),
|
||||
];
|
||||
let out = subtract_dominant_bands(bands);
|
||||
|
||||
// Kein Estrich-/Dämmung-Rest überlappt den Backstein-u-Bereich ECHT.
|
||||
for name in ["screed", "insulation-ceiling"] {
|
||||
for b in out.iter().filter(|b| component_of(b) == Some(name)) {
|
||||
let r = rect_of(b);
|
||||
let u_overlap = r.u_min < brick_u.1 - 1e-4 && r.u_max > brick_u.0 + 1e-4;
|
||||
assert!(
|
||||
!u_overlap,
|
||||
"{name}-Rest {r:?} ragt noch in den Backstein-u-Bereich (müsste weg sein)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Beton bleibt voll (kein stärkeres Band): Fläche 5*0.2 = 1.0.
|
||||
assert!(approx(
|
||||
area(&out.iter().filter(|b| component_of(b) == Some("concrete")).cloned().collect::<Vec<_>>()),
|
||||
1.0
|
||||
));
|
||||
// Backstein bleibt in beiden Boxen erhalten (nichts Stärkeres überlappt).
|
||||
assert!(approx(
|
||||
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
|
||||
0.15 * 2.3 + 0.15 * 0.1
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gleiche_prioritaet_koexistiert() {
|
||||
// Beton-Wand + Beton-Decke, gleiche Prio 100, VERSCHIEDENE Komponenten:
|
||||
// schneiden sich NICHT und verschmelzen NICHT (bleiben beide voll).
|
||||
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton-wand"));
|
||||
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton-decke")); // Overlap v[2,3]
|
||||
let out = subtract_dominant_bands(vec![wand, decke]);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(approx(
|
||||
area(&out.iter().filter(|b| component_of(b) == Some("beton-wand")).cloned().collect::<Vec<_>>()),
|
||||
3.0
|
||||
));
|
||||
assert!(approx(
|
||||
area(&out.iter().filter(|b| component_of(b) == Some("beton-decke")).cloned().collect::<Vec<_>>()),
|
||||
2.0
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ohne_prioritaet_keine_teilnahme() {
|
||||
let stark = band(0.0, 2.0, 0.0, 2.0, 100, None);
|
||||
let mut ohne = band(0.0, 2.0, 0.0, 2.0, 0, None);
|
||||
ohne.cut = None; // keine Prioritaet
|
||||
let out = subtract_dominant_bands(vec![stark, ohne]);
|
||||
// Beide unveraendert (4 + 4).
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(approx(area(&out), 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_gleiche_komponente_und_prioritaet() {
|
||||
// Beton-Wand trifft Beton-Decke (u-deckungsgleich), gleiche componentId +
|
||||
// Prio -> verschmelzen zu EINEM Rechteck v in [0,4] (keine innere Kante).
|
||||
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton"));
|
||||
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton"));
|
||||
let out = subtract_dominant_bands(vec![wand, decke]);
|
||||
assert_eq!(out.len(), 1);
|
||||
let r = rect_of(&out[0]);
|
||||
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 4.0), "verschmolzen {r:?}");
|
||||
}
|
||||
}
|
||||
@@ -21,68 +21,19 @@
|
||||
//!
|
||||
//! ## Ausgabeformat
|
||||
//!
|
||||
//! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je
|
||||
//! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern
|
||||
//! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein
|
||||
//! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet
|
||||
//! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der
|
||||
//! Bauteil-Schraffur (`CutPolygon::hatch` -> `types::Hatch`) und waehlen im Shader
|
||||
//! das MUSTER je geschnittener Schicht (statt eines fixen 45°-Musters). Sie sind je
|
||||
//! Cut-Polygon konstant, wandern aber als (redundantes) Vertex-Attribut mit — das
|
||||
//! haelt die Cap-Pipeline ohne zusaetzliche per-Draw-Uniform-Verwaltung (die Caps
|
||||
//! werden in EINEM `draw` gezeichnet). Ein Cut-Polygon ist stets ein konvexes
|
||||
//! Rechteck (4 Ecken, siehe `section.rs`) — Fan-Triangulierung genuegt.
|
||||
//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32,
|
||||
//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als
|
||||
//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges,
|
||||
//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im
|
||||
//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken,
|
||||
//! siehe `section.rs`) — Fan-Triangulierung genuegt.
|
||||
|
||||
use crate::section::{cut_section, CutPolygon, SectionPlane};
|
||||
use crate::section_boolean::{rect_of_poly, subtract_dominant_bands};
|
||||
use crate::section::{cut_section, SectionPlane};
|
||||
use crate::types::{SlabInput, WallInput};
|
||||
|
||||
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale,
|
||||
/// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des
|
||||
/// `VsIn` in `CAP_WGSL` (shaders.rs). `line_weight_mm` steuert die Musterlinien-
|
||||
/// Breite (mm-Papier -> Welt) je Schraffur.
|
||||
pub const CAP_FLOATS_PER_VERTEX: usize = 9;
|
||||
|
||||
/// Default-Musterlinien-Staerke (mm-Papier), wenn ein Cut-Polygon keine `hatch`
|
||||
/// traegt (Fallback-Diagonale) — Haarlinie, wie `HatchRender.lineWeight`-Default.
|
||||
const FALLBACK_HATCH_LINE_WEIGHT_MM: f32 = 0.13;
|
||||
|
||||
/// f32 pro Cut-Linien-Vertex: [pos.x, pos.y, pos.z, r, g, b] — dasselbe Layout wie
|
||||
/// die Grid-/Highlight-LineList (`GRID_WGSL`), damit die Schnitt-Umrisslinien ohne
|
||||
/// eigenes Vertex-Format ueber eine (Dreiecks-)Ribbon-Pipeline laufen.
|
||||
pub const CUT_LINE_FLOATS_PER_VERTEX: usize = 6;
|
||||
|
||||
/// Default-Schnittkanten-Farbe (dunkle Tinte), wenn ein Band keinen eigenen
|
||||
/// `line_color` traegt — passend zur 2D-Schnittkonvention (dunkle Fugenlinie).
|
||||
const DEFAULT_CUT_LINE_COLOR: [f32; 3] = [0.10, 0.10, 0.10];
|
||||
|
||||
/// Default-Strichstaerke der Schnitt-Umrisslinie in mm-Papier (Haarlinie), wenn
|
||||
/// ein Band keinen eigenen `line_weight` traegt.
|
||||
const DEFAULT_CUT_LINE_WEIGHT_MM: f32 = 0.35;
|
||||
|
||||
/// mm-Papier -> Welt-Meter fuer die Ribbon-BREITE der Schnitt-Umrisslinien.
|
||||
///
|
||||
/// EHRLICHE EINSCHRAENKUNG: die 2D-Strichstaerke ist in mm-Papier (masstabsabhaengig).
|
||||
/// Der 3D-Schnitt kennt keinen Papiermasstab; die Linie wird hier als flaches Ribbon
|
||||
/// mit KONSTANTER Welt-Breite in die Schnittebene gelegt (blickwinkel-unabhaengig,
|
||||
/// skaliert also mit der Kamera-Distanz wie gemalte Geometrie — NICHT bildschirmfest).
|
||||
/// Der Faktor ist bewusst grob gewaehlt (0.35 mm -> ~3.5 mm Welt), damit die Fuge
|
||||
/// ueberhaupt lesbar bleibt; eine bildschirmfeste Strichstaerke braeuchte eine
|
||||
/// Screen-Space-Expansion in einer eigenen Pipeline (bekannte Luecke, siehe
|
||||
/// Schlussbericht/Moduldoc).
|
||||
const CUT_LINE_MM_TO_WORLD: f32 = 0.01;
|
||||
|
||||
/// Kleiner Versatz (Meter) der Umrisslinien-Ribbons ZUR KAMERA (entgegen der
|
||||
/// Ebenennormale), damit sie (a) nicht durch die Schnittebenen-Kappung von
|
||||
/// `GRID_WGSL` (`depth > 0` = hinter der Ebene) weggeschnitten werden und (b)
|
||||
/// sichtbar VOR den Cut-Caps liegen (die exakt auf der Ebene sitzen).
|
||||
const CUT_LINE_LIFT: f32 = 0.002;
|
||||
|
||||
/// Fallback-Muster-Id, wenn ein Cut-Polygon keine `hatch` traegt: 2 = diagonal
|
||||
/// (45°) — reproduziert mit `angle_rad = 0`, `scale = 1` exakt das fruehere fest
|
||||
/// verdrahtete 45°-Muster (Rueckwaertskompatibilitaet fuer Waende/Decken ohne
|
||||
/// aufgeloeste Bauteil-Schraffur).
|
||||
const FALLBACK_PATTERN: f32 = 2.0;
|
||||
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts
|
||||
/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs).
|
||||
pub const CAP_FLOATS_PER_VERTEX: usize = 5;
|
||||
|
||||
/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale
|
||||
/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die
|
||||
@@ -93,23 +44,12 @@ const FALLBACK_PATTERN: f32 = 2.0;
|
||||
/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die
|
||||
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
|
||||
/// nicht von der Normale ab.
|
||||
/// Berechnet die (u,v)-Cut-Polygone der Schnittebene MIT angewandter Boolean-
|
||||
/// Dominanz (`section_boolean::subtract_dominant_bands`): die staerkere Schicht
|
||||
/// schneidet die schwaechere weg — elementuebergreifend, exakt wie der 2D-Schnitt
|
||||
/// (`toSection.ts::attachCutStyles` Phase 2). Gemeinsame Basis der Cut-Cap-Flaechen
|
||||
/// (`build_cut_caps`) UND der Cut-Umrisslinien (`build_cut_cap_lines`), damit beide
|
||||
/// auf DERSELBEN, ueberlappungsfreien Rechteck-Menge arbeiten.
|
||||
fn cut_cap_polys(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> Vec<CutPolygon> {
|
||||
let out = cut_section(plane, walls, slabs);
|
||||
subtract_dominant_bands(out.cut_polygons)
|
||||
}
|
||||
|
||||
pub fn build_cut_caps(
|
||||
plane: &SectionPlane,
|
||||
walls: &[WallInput],
|
||||
slabs: &[SlabInput],
|
||||
) -> Vec<f32> {
|
||||
let polys = cut_cap_polys(plane, walls, slabs);
|
||||
let out = cut_section(plane, walls, slabs);
|
||||
let point = plane.point;
|
||||
let u_axis = plane.u_axis();
|
||||
// (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v.
|
||||
@@ -122,28 +62,14 @@ pub fn build_cut_caps(
|
||||
};
|
||||
|
||||
let mut verts: Vec<f32> = Vec::new();
|
||||
for poly in &polys {
|
||||
for poly in &out.cut_polygons {
|
||||
let pts = &poly.pts;
|
||||
if pts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
// Muster/Winkel/Skalierung aus der Bauteil-Schraffur (oder Fallback-
|
||||
// Diagonale). Winkel von Grad in Radiant, damit der Shader direkt
|
||||
// cos/sin rechnen kann.
|
||||
let (pattern, angle_rad, scale, line_weight) = match poly.hatch {
|
||||
Some(h) => (
|
||||
h.pattern as f32,
|
||||
h.angle.to_radians(),
|
||||
h.scale.max(0.05),
|
||||
h.line_weight.max(0.0),
|
||||
),
|
||||
None => (FALLBACK_PATTERN, 0.0, 1.0, FALLBACK_HATCH_LINE_WEIGHT_MM),
|
||||
};
|
||||
let mut push_vertex = |uv: [f32; 2]| {
|
||||
let w = to_world(uv);
|
||||
verts.extend_from_slice(&[
|
||||
w[0], w[1], w[2], uv[0], uv[1], pattern, angle_rad, scale, line_weight,
|
||||
]);
|
||||
verts.extend_from_slice(&[w[0], w[1], w[2], uv[0], uv[1]]);
|
||||
};
|
||||
// Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
|
||||
for i in 1..pts.len() - 1 {
|
||||
@@ -154,429 +80,3 @@ pub fn build_cut_caps(
|
||||
}
|
||||
verts
|
||||
}
|
||||
|
||||
/// Erzeugt die Schnitt-UMRISSLINIEN (P2): fuer JEDES ueberlebende Cut-Cap-Rechteck
|
||||
/// seine vier Kanten als flache Ribbon-Dreiecke auf der Schnittebene, in Welt-
|
||||
/// Koordinaten (dieselbe (u,v)->Welt-Ruecktransformation wie `build_cut_caps`).
|
||||
/// Rueckgabe = interleaved `[px,py,pz, r,g,b]` (`CUT_LINE_FLOATS_PER_VERTEX`,
|
||||
/// TriangleList), leer wenn die Ebene das Modell nicht schneidet.
|
||||
///
|
||||
/// STIL je Kante: Farbe/Strichstaerke aus dem `line_color`/`line_weight` des Bandes
|
||||
/// (aus dem `jointLineStyleId`-LineStyle, TS-seitig aufgeloest), sonst der Default-
|
||||
/// Schnittkanten-Stil. Die Strichstaerke (mm-Papier) wird ueber `CUT_LINE_MM_TO_WORLD`
|
||||
/// in eine konstante Welt-Breite umgesetzt (siehe dortige Doku — bewusste
|
||||
/// Vereinfachung, keine bildschirmfeste Strichstaerke). Jede Kante wird als Quad
|
||||
/// (2 Dreiecke) um ihre Mittellinie gelegt, mit der Ribbon-Breite senkrecht zur
|
||||
/// Kante IN der Ebene; alle Ribbons um `CUT_LINE_LIFT` zur Kamera versetzt, damit
|
||||
/// sie nicht an der Ebene weggeclippt werden und vor den Caps liegen.
|
||||
pub fn build_cut_cap_lines(
|
||||
plane: &SectionPlane,
|
||||
walls: &[WallInput],
|
||||
slabs: &[SlabInput],
|
||||
) -> Vec<f32> {
|
||||
let polys = cut_cap_polys(plane, walls, slabs);
|
||||
let point = plane.point;
|
||||
let u_axis = plane.u_axis();
|
||||
let normal = plane.normal;
|
||||
// Kamera-waertiger Lift entlang -normal (normal zeigt vom Betrachter ins Modell).
|
||||
let lift = [
|
||||
-normal[0] * CUT_LINE_LIFT,
|
||||
-normal[1] * CUT_LINE_LIFT,
|
||||
-normal[2] * CUT_LINE_LIFT,
|
||||
];
|
||||
let to_world = |uv: [f32; 2]| -> [f32; 3] {
|
||||
[
|
||||
point[0] + uv[0] * u_axis[0] + lift[0],
|
||||
uv[1] + lift[1],
|
||||
point[2] + uv[0] * u_axis[2] + lift[2],
|
||||
]
|
||||
};
|
||||
|
||||
let mut verts: Vec<f32> = Vec::new();
|
||||
for poly in &polys {
|
||||
let Some(r) = rect_of_poly(poly) else {
|
||||
continue;
|
||||
};
|
||||
// Stil aus den Band-Metadaten (oder Default).
|
||||
let (color, weight_mm) = match &poly.cut {
|
||||
Some(meta) => (
|
||||
meta.line_color.unwrap_or(DEFAULT_CUT_LINE_COLOR),
|
||||
meta.line_weight.unwrap_or(DEFAULT_CUT_LINE_WEIGHT_MM),
|
||||
),
|
||||
None => (DEFAULT_CUT_LINE_COLOR, DEFAULT_CUT_LINE_WEIGHT_MM),
|
||||
};
|
||||
let half_w = (weight_mm.max(0.0) * CUT_LINE_MM_TO_WORLD * 0.5).max(1e-4);
|
||||
|
||||
// Die vier Rechteck-Ecken (u,v) im Umlauf; je Kante ein Ribbon.
|
||||
let corners = [
|
||||
[r.u_min, r.v_min],
|
||||
[r.u_max, r.v_min],
|
||||
[r.u_max, r.v_max],
|
||||
[r.u_min, r.v_max],
|
||||
];
|
||||
for k in 0..4 {
|
||||
let a = to_world(corners[k]);
|
||||
let b = to_world(corners[(k + 1) % 4]);
|
||||
push_line_ribbon(&mut verts, a, b, normal, half_w, color);
|
||||
}
|
||||
}
|
||||
verts
|
||||
}
|
||||
|
||||
/// Legt ein flaches Ribbon-Quad (2 Dreiecke) der halben Breite `half_w` um die
|
||||
/// Kante `a`->`b`, verbreitert IN der Schnittebene (senkrecht zur Kante ueber
|
||||
/// `cross(normal, dir)`), und schreibt es als 6 Vertices `[pos, color]` in `out`.
|
||||
fn push_line_ribbon(
|
||||
out: &mut Vec<f32>,
|
||||
a: [f32; 3],
|
||||
b: [f32; 3],
|
||||
normal: [f32; 3],
|
||||
half_w: f32,
|
||||
color: [f32; 3],
|
||||
) {
|
||||
let dir = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
|
||||
if len < 1e-9 {
|
||||
return;
|
||||
}
|
||||
let d = [dir[0] / len, dir[1] / len, dir[2] / len];
|
||||
// In-Ebene-Senkrechte zur Kante: cross(normal, dir) liegt in der Ebene und
|
||||
// steht senkrecht auf der Kante.
|
||||
let mut perp = [
|
||||
normal[1] * d[2] - normal[2] * d[1],
|
||||
normal[2] * d[0] - normal[0] * d[2],
|
||||
normal[0] * d[1] - normal[1] * d[0],
|
||||
];
|
||||
let plen = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt();
|
||||
if plen < 1e-9 {
|
||||
return;
|
||||
}
|
||||
perp = [perp[0] / plen * half_w, perp[1] / plen * half_w, perp[2] / plen * half_w];
|
||||
let p0 = [a[0] - perp[0], a[1] - perp[1], a[2] - perp[2]];
|
||||
let p1 = [a[0] + perp[0], a[1] + perp[1], a[2] + perp[2]];
|
||||
let p2 = [b[0] + perp[0], b[1] + perp[1], b[2] + perp[2]];
|
||||
let p3 = [b[0] - perp[0], b[1] - perp[1], b[2] - perp[2]];
|
||||
let mut push = |p: [f32; 3]| {
|
||||
out.extend_from_slice(&[p[0], p[1], p[2], color[0], color[1], color[2]]);
|
||||
};
|
||||
// Zwei Dreiecke: p0,p1,p2 und p0,p2,p3.
|
||||
push(p0);
|
||||
push(p1);
|
||||
push(p2);
|
||||
push(p0);
|
||||
push(p2);
|
||||
push(p3);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::Hatch;
|
||||
|
||||
/// Eine Wand entlang der X-Achse, senkrecht von einer +X-Ebene geschnitten:
|
||||
/// die Muster-Id/Winkel/Skalierung der Bauteil-Schraffur muessen in JEDEM
|
||||
/// Cap-Vertex an den Positionen 5/6/7 landen (Vertex-Stride = 8).
|
||||
#[test]
|
||||
fn hatch_muster_id_landet_in_den_cap_vertices() {
|
||||
let wall = WallInput {
|
||||
start: [0.0, 0.0],
|
||||
end: [3.0, 0.0],
|
||||
thickness: 0.3,
|
||||
height: 2.5,
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: Some(Hatch {
|
||||
pattern: 3, // crosshatch
|
||||
angle: 90.0,
|
||||
scale: 2.0,
|
||||
line_weight: 0.5,
|
||||
}),
|
||||
cut: None,
|
||||
};
|
||||
let plane = SectionPlane::looking_plus_x(1.5);
|
||||
let verts = build_cut_caps(&plane, &[wall], &[]);
|
||||
|
||||
assert!(!verts.is_empty(), "Schnitt liefert Cap-Geometrie");
|
||||
assert_eq!(
|
||||
verts.len() % CAP_FLOATS_PER_VERTEX,
|
||||
0,
|
||||
"Vertexpuffer ist ein Vielfaches des Strides ({CAP_FLOATS_PER_VERTEX})"
|
||||
);
|
||||
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
|
||||
for i in 0..n {
|
||||
let base = i * CAP_FLOATS_PER_VERTEX;
|
||||
assert_eq!(verts[base + 5], 3.0, "pattern-Id je Vertex");
|
||||
assert!(
|
||||
(verts[base + 6] - 90.0_f32.to_radians()).abs() < 1e-5,
|
||||
"angle in Radiant"
|
||||
);
|
||||
assert_eq!(verts[base + 7], 2.0, "scale je Vertex");
|
||||
assert_eq!(verts[base + 8], 0.5, "line_weight (mm) je Vertex");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ohne `hatch` faellt jedes Cap-Polygon auf das Diagonalmuster (Id 2,
|
||||
/// angle 0, scale 1) zurueck — das reproduziert das fruehere fixe 45°-Muster.
|
||||
#[test]
|
||||
fn ohne_hatch_fallback_auf_diagonale() {
|
||||
let wall = WallInput {
|
||||
start: [0.0, 0.0],
|
||||
end: [3.0, 0.0],
|
||||
thickness: 0.3,
|
||||
height: 2.5,
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let plane = SectionPlane::looking_plus_x(1.5);
|
||||
let verts = build_cut_caps(&plane, &[wall], &[]);
|
||||
assert!(!verts.is_empty());
|
||||
assert_eq!(verts[5], FALLBACK_PATTERN, "Fallback = Diagonale (2)");
|
||||
assert_eq!(verts[6], 0.0, "Fallback-Winkel 0");
|
||||
assert_eq!(verts[7], 1.0, "Fallback-Skalierung 1");
|
||||
assert_eq!(verts[8], FALLBACK_HATCH_LINE_WEIGHT_MM, "Fallback-Musterlinienstaerke 0.13 mm");
|
||||
}
|
||||
|
||||
use crate::types::CutBandMeta;
|
||||
|
||||
/// Ein Wand-Band (Prio 50, volle Hoehe) und ein u-deckungsgleiches Decken-Band
|
||||
/// (Prio 100) ueberlappen in einem z-Intervall. Nach `build_cut_caps` (das die
|
||||
/// Boolean-Dominanz anwendet) darf im Ueberlappungs-z-Intervall KEINE Wand-
|
||||
/// Cap-Geometrie mehr liegen — die geometrische Kernaussage von P1, direkt auf
|
||||
/// den erzeugten Welt-Vertices geprueft (nicht bloss „laeuft").
|
||||
#[test]
|
||||
fn build_cut_caps_wendet_dominanz_an_wand_verliert_ueberlappung() {
|
||||
// Wand entlang +X bei x in [0,3], Dicke 0.4, volle Hoehe [0,2.5]; Prio 50.
|
||||
let wall = WallInput {
|
||||
start: [0.0, 0.0],
|
||||
end: [3.0, 0.0],
|
||||
thickness: 0.4,
|
||||
height: 2.5,
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||
cut: Some(CutBandMeta {
|
||||
join_priority: Some(50),
|
||||
component_id: Some("brick".into()),
|
||||
line_color: None,
|
||||
line_weight: None,
|
||||
}),
|
||||
};
|
||||
// Decken-Slab, deren Umriss die Wand an der Schnittstelle (x=1.5) ueberdeckt,
|
||||
// z in [2.0, 2.4]; Prio 100 (Beton laeuft durch, schneidet die Wand).
|
||||
let slab = SlabInput {
|
||||
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
|
||||
z_bottom: 2.0,
|
||||
z_top: 2.4,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
hatch: Some(Hatch { pattern: 1, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||
cut: Some(CutBandMeta {
|
||||
join_priority: Some(100),
|
||||
component_id: Some("concrete".into()),
|
||||
line_color: None,
|
||||
line_weight: None,
|
||||
}),
|
||||
};
|
||||
let plane = SectionPlane::looking_plus_x(1.5);
|
||||
let verts = build_cut_caps(&plane, &[wall], &[slab]);
|
||||
assert!(!verts.is_empty());
|
||||
|
||||
// Die Wand-Caps tragen pattern==2 (diagonal), die Beton-Caps pattern==1
|
||||
// (solid). Fuer JEDES Wand-Vertex (pattern 2) muss die Hoehe (v = pos.y,
|
||||
// Index 1) AUSSERHALB des Beton-Intervalls [2.0,2.4] liegen.
|
||||
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
|
||||
let mut wall_verts = 0;
|
||||
let mut concrete_verts = 0;
|
||||
for i in 0..n {
|
||||
let base = i * CAP_FLOATS_PER_VERTEX;
|
||||
let y = verts[base + 1];
|
||||
let pattern = verts[base + 5];
|
||||
if (pattern - 2.0).abs() < 0.5 {
|
||||
wall_verts += 1;
|
||||
assert!(
|
||||
y <= 2.0 + 1e-3 || y >= 2.4 - 1e-3,
|
||||
"Wand-Cap-Vertex bei v={y} liegt im Beton-z-Intervall (muesste weggeschnitten sein)"
|
||||
);
|
||||
} else if (pattern - 1.0).abs() < 0.5 {
|
||||
concrete_verts += 1;
|
||||
}
|
||||
}
|
||||
assert!(wall_verts > 0, "Wand-Caps vorhanden (ober-/unterhalb des Betons)");
|
||||
assert!(concrete_verts > 0, "Beton-Caps vorhanden (voll durchlaufend)");
|
||||
}
|
||||
|
||||
/// Reziprok zum vorigen Test (Nutzer-Report 2026-07-08): eine Backstein-WAND
|
||||
/// (Prio 50) muss die SCHWAECHEREN Decken-Schichten Estrich (30) und Daemmung
|
||||
/// (20) in der u/v-Ueberlappung wegschneiden — wie im 2D-Schnitt. Wir bauen die
|
||||
/// Wand als EIN Backstein-Band (Prio 50) und den Boden als zwei duenne Slabs
|
||||
/// (Estrich oben, Daemmung darunter), deren z-Intervall die Wand ueberlappt.
|
||||
/// Nach der Dominanz darf im Wand-u-Intervall KEINE Estrich-/Daemmung-Cap mehr
|
||||
/// liegen (die Wand-Cap dagegen bleibt — Backstein ist dort dominant).
|
||||
#[test]
|
||||
fn wand_schneidet_schwaechere_decken_schichten() {
|
||||
// Backstein-Wand entlang +X, x in [0,3], Dicke 0.4 → u (=y) in [-0.2,0.2],
|
||||
// volle Hoehe [0,2.5]; Prio 50, Muster 2 (diagonal).
|
||||
let wall = WallInput {
|
||||
start: [0.0, 0.0],
|
||||
end: [3.0, 0.0],
|
||||
thickness: 0.4,
|
||||
height: 2.5,
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||
cut: Some(CutBandMeta {
|
||||
join_priority: Some(50),
|
||||
component_id: Some("brick".into()),
|
||||
line_color: None,
|
||||
line_weight: None,
|
||||
}),
|
||||
};
|
||||
// Estrich-Slab (Prio 30, Muster 3 crosshatch), z in [1.0,1.06], deckt die
|
||||
// Wand in u ab (Umriss ueber y in [-1,1]).
|
||||
let screed = SlabInput {
|
||||
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
|
||||
z_bottom: 1.0,
|
||||
z_top: 1.06,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
hatch: Some(Hatch { pattern: 3, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||
cut: Some(CutBandMeta {
|
||||
join_priority: Some(30),
|
||||
component_id: Some("screed".into()),
|
||||
line_color: None,
|
||||
line_weight: None,
|
||||
}),
|
||||
};
|
||||
// Daemmung-Slab (Prio 20, Muster 4), z in [0.96,1.0], gleiche u-Deckung.
|
||||
let insulation = SlabInput {
|
||||
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
|
||||
z_bottom: 0.96,
|
||||
z_top: 1.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
hatch: Some(Hatch { pattern: 4, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||
cut: Some(CutBandMeta {
|
||||
join_priority: Some(20),
|
||||
component_id: Some("insulation".into()),
|
||||
line_color: None,
|
||||
line_weight: None,
|
||||
}),
|
||||
};
|
||||
let plane = SectionPlane::looking_plus_x(1.5);
|
||||
let verts = build_cut_caps(&plane, &[wall], &[screed, insulation]);
|
||||
assert!(!verts.is_empty());
|
||||
|
||||
// Wand-u-Intervall (=y) ist [-0.2,0.2]. Fuer JEDES Estrich- (Muster 3) oder
|
||||
// Daemmung-Vertex (Muster 4) muss u AUSSERHALB [-0.2,0.2] liegen — sonst hat
|
||||
// der Backstein die schwaechere Schicht dort NICHT weggeschnitten.
|
||||
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
|
||||
let mut weak_verts = 0;
|
||||
for i in 0..n {
|
||||
let base = i * CAP_FLOATS_PER_VERTEX;
|
||||
let u = verts[base]; // pos.x = y-Welt (Schnitt entlang +X)
|
||||
let pattern = verts[base + 5];
|
||||
if (pattern - 3.0).abs() < 0.5 || (pattern - 4.0).abs() < 0.5 {
|
||||
weak_verts += 1;
|
||||
assert!(
|
||||
u <= -0.2 + 1e-3 || u >= 0.2 - 1e-3,
|
||||
"Estrich/Daemmung-Vertex bei u={u} liegt im Backstein-u-Intervall \
|
||||
[-0.2,0.2] (muesste weggeschnitten sein)"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(weak_verts > 0, "Estrich/Daemmung-Caps existieren (ausserhalb der Wand)");
|
||||
}
|
||||
|
||||
/// P2: je ueberlebendem Cut-Cap-Rechteck entstehen 4 Umriss-Kanten als Ribbon-
|
||||
/// Dreiecke (4 Kanten * 6 Vertices = 24 Vertices je Rechteck), und Farbe +
|
||||
/// Strichstaerke kommen aus dem Band-Stil durch (Farbe je Vertex, Staerke ueber
|
||||
/// die Ribbon-Breite messbar).
|
||||
#[test]
|
||||
fn build_cut_cap_lines_kantenzahl_und_stil() {
|
||||
let color = [0.2, 0.4, 0.9];
|
||||
let wall = WallInput {
|
||||
start: [0.0, 0.0],
|
||||
end: [3.0, 0.0],
|
||||
thickness: 0.4,
|
||||
height: 2.5,
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: Some(CutBandMeta {
|
||||
join_priority: Some(50),
|
||||
component_id: Some("brick".into()),
|
||||
line_color: Some(color),
|
||||
line_weight: Some(2.0),
|
||||
}),
|
||||
};
|
||||
let plane = SectionPlane::looking_plus_x(1.5);
|
||||
let verts = build_cut_cap_lines(&plane, &[wall.clone()], &[]);
|
||||
assert!(!verts.is_empty());
|
||||
assert_eq!(
|
||||
verts.len() % CUT_LINE_FLOATS_PER_VERTEX,
|
||||
0,
|
||||
"Vielfaches des Cut-Linien-Strides"
|
||||
);
|
||||
// EIN Rechteck (keine Dominanz-Subtraktion, lone band) -> 4 Kanten * 6 = 24 Vertices.
|
||||
assert_eq!(
|
||||
verts.len() / CUT_LINE_FLOATS_PER_VERTEX,
|
||||
24,
|
||||
"4 Kanten * 6 Vertices je Ribbon"
|
||||
);
|
||||
// Farbe je Vertex aus dem Stil (Index 3/4/5).
|
||||
let n = verts.len() / CUT_LINE_FLOATS_PER_VERTEX;
|
||||
for i in 0..n {
|
||||
let base = i * CUT_LINE_FLOATS_PER_VERTEX;
|
||||
assert!((verts[base + 3] - color[0]).abs() < 1e-6);
|
||||
assert!((verts[base + 4] - color[1]).abs() < 1e-6);
|
||||
assert!((verts[base + 5] - color[2]).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// Strichstaerke kommt durch: eine DICKERE Linie erzeugt ein breiteres Ribbon.
|
||||
// Messbar an der (u,v)-Ausdehnung senkrecht zur Kante — hier ueber die
|
||||
// world-Bounding-Box-Hoehe der beiden horizontalen Kanten (v-Ausdehnung der
|
||||
// gesamten Geometrie) im Vergleich duenn vs. dick.
|
||||
let bbox_y = |vs: &[f32]| -> f32 {
|
||||
let n = vs.len() / CUT_LINE_FLOATS_PER_VERTEX;
|
||||
let mut lo = f32::INFINITY;
|
||||
let mut hi = f32::NEG_INFINITY;
|
||||
for i in 0..n {
|
||||
let y = vs[i * CUT_LINE_FLOATS_PER_VERTEX + 1];
|
||||
lo = lo.min(y);
|
||||
hi = hi.max(y);
|
||||
}
|
||||
hi - lo
|
||||
};
|
||||
let mut thin = wall.clone();
|
||||
thin.cut = Some(CutBandMeta {
|
||||
join_priority: Some(50),
|
||||
component_id: Some("brick".into()),
|
||||
line_color: Some(color),
|
||||
line_weight: Some(0.2),
|
||||
});
|
||||
let thin_verts = build_cut_cap_lines(&plane, &[thin], &[]);
|
||||
assert!(
|
||||
bbox_y(&verts) > bbox_y(&thin_verts) + 1e-4,
|
||||
"dickere Strichstaerke -> groessere v-Ausdehnung der Ribbons"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,185 +125,6 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
"#;
|
||||
|
||||
/// WGSL des TEXTURIERTEN Wand-Pfades (`RenderStyle::Textured`, gpu::textured_pipeline).
|
||||
/// GLEICHE Beleuchtung wie `MESH_WGSL` (hemisphaerisches Ambient Himmel/Boden +
|
||||
/// Lambert-Directional + Gegen-Fuelllicht + Kantenbetonung) — nur die ALBEDO kommt
|
||||
/// aus einer Bild-Textur (`textureSample`) statt aus der Vertexfarbe. Vertex-Layout
|
||||
/// [pos vec3, normal vec3, uv vec2]; `Globals` bleibt group(0) binding(0) unveraendert,
|
||||
/// die Textur haengt in group(1). Die Weiss-/Hidden-Modi (`mode.x`) sind hier nicht
|
||||
/// relevant: `Textured` reicht `mode.x == 0` (Material) durch (siehe gpu.rs).
|
||||
pub const MESH_TEXTURED_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;
|
||||
|
||||
// Material-Textur-ARRAY + Sampler (group 1). Sampler mit Repeat/Linear (siehe
|
||||
// gpu.rs) -> das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
|
||||
// Ebene 0 = Fallback-Schachbrett; Ebenen 1..N = per `set_material_textures`
|
||||
// hochgeladene Material-Farbkarten. Die Ebene je Vertex kommt aus einem ZWEITEN
|
||||
// Vertex-Buffer (@location(3)); `mode.z` traegt die Anzahl gueltiger Ebenen.
|
||||
@group(1) @binding(0) var tex : texture_2d_array<f32>;
|
||||
@group(1) @binding(1) var samp : sampler;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position : vec3<f32>,
|
||||
@location(1) normal : vec3<f32>,
|
||||
@location(2) uv : vec2<f32>,
|
||||
// Material-Textur-Ebene dieses Vertex (`-1` = kein Material -> Schachbrett).
|
||||
@location(3) layer : f32,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos : vec4<f32>,
|
||||
@location(0) world_normal : vec3<f32>,
|
||||
@location(1) uv : vec2<f32>,
|
||||
@location(2) world_pos : vec3<f32>,
|
||||
@location(3) @interpolate(flat) layer : 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.uv = in.uv;
|
||||
out.world_pos = in.position;
|
||||
out.layer = in.layer;
|
||||
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);
|
||||
|
||||
// Gerichteter Lambert-Anteil (Sonne).
|
||||
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb;
|
||||
|
||||
// Gegen-Fuelllicht (identisch zu MESH_WGSL).
|
||||
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);
|
||||
|
||||
// Albedo aus dem Material-Textur-Array statt Vertexfarbe. Ebene je Vertex:
|
||||
// `layer < 0` (kein Material) -> Ebene 0 (Schachbrett). Sonst auf den
|
||||
// gueltigen Bereich [0, mode.z-1] geklemmt (Sicherheit gegen ueberzaehlige
|
||||
// Indizes, falls die Material-Menge kleiner ist als erwartet).
|
||||
let max_layer = max(i32(globals.mode.z) - 1, 0);
|
||||
let li = clamp(i32(round(in.layer)), 0, max_layer);
|
||||
let albedo = textureSample(tex, samp, in.uv, li).rgb;
|
||||
|
||||
var shaded = albedo * (ambient + diffuse + fill);
|
||||
|
||||
// Sanfte Kantenbetonung (identisch zu MESH_WGSL).
|
||||
let edge = 0.90 + 0.10 * abs(n.y);
|
||||
shaded = shaded * edge;
|
||||
|
||||
return vec4<f32>(shaded, 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// 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.
|
||||
@@ -357,16 +178,11 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
"#;
|
||||
|
||||
/// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline).
|
||||
/// Eigene TriangleList-Pipeline: Vertex-Layout [pos vec3, uv vec2, hatch vec3],
|
||||
/// Eigene TriangleList-Pipeline: schlankes Vertex-Layout [pos vec3, uv vec2],
|
||||
/// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps
|
||||
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: PROZEDURALE
|
||||
/// Bauteil-Schraffur je geschnittener Schicht — Muster/Winkel/Skalierung kommen aus
|
||||
/// dem `hatch`-Vertex-Attribut (pattern, angle_rad, scale; siehe
|
||||
/// `section_fill.rs`/`types::Hatch`). MONOCHROM (dunkle Tinte auf hellem Papier,
|
||||
/// passend zur 2D-Schnittkonvention `HATCH_INK`): es variiert das MUSTER, nicht die
|
||||
/// Farbe. Muster-Ids: 0 = none (leer/weiss), 1 = solid (Vollton),
|
||||
/// 2 = diagonal (Linienschar, `angle` steuert die Neigung — 0° = horizontal, wie
|
||||
/// im 2D), 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung).
|
||||
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: prozedurale
|
||||
/// 45-Grad-Diagonalschraffur aus den Ebenen-Koordinaten (u,v) in Modell-Metern —
|
||||
/// dunkle Tinte auf hellem Papier, passend zur 2D-Schnittkonvention.
|
||||
pub const CAP_WGSL: &str = r#"
|
||||
struct Globals {
|
||||
view_proj : mat4x4<f32>,
|
||||
@@ -382,18 +198,11 @@ struct Globals {
|
||||
struct VsIn {
|
||||
@location(0) position : vec3<f32>,
|
||||
@location(1) uv : vec2<f32>,
|
||||
// hatch: x = Muster-Id, y = Winkel (Radiant), z = Skalierung, w = Musterlinien-
|
||||
// Staerke (mm-Papier). Je Cut-Polygon konstant (redundant je Vertex, siehe
|
||||
// section_fill.rs).
|
||||
@location(2) hatch : vec4<f32>,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos : vec4<f32>,
|
||||
@location(0) uv : vec2<f32>,
|
||||
// flat: die Muster-Parameter sind je Flaeche konstant und duerfen nicht
|
||||
// interpoliert werden (Muster-Id ist eine Ganzzahl-Auswahl).
|
||||
@location(1) @interpolate(flat) hatch : vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
@@ -401,85 +210,27 @@ fn vs_main(in : VsIn) -> VsOut {
|
||||
var out : VsOut;
|
||||
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
|
||||
out.uv = in.uv;
|
||||
out.hatch = in.hatch;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ein AA-gekantetes Linienband entlang der Koordinate `s` (in Einheiten von
|
||||
// `spacing`): 1 auf der Linie, 0 dazwischen. `fwidth` glaettet blickwinkelrichtig.
|
||||
fn line_band(s : f32, half_s : f32) -> f32 {
|
||||
let frac = fract(s);
|
||||
let dist = min(frac, 1.0 - frac);
|
||||
let aa = max(fwidth(s), 1e-5);
|
||||
return 1.0 - smoothstep(half_s, half_s + aa, dist);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
// 45-Grad-Diagonalschraffur in Modell-Metern: Linien bei ganzzahligen
|
||||
// Vielfachen von `spacing` entlang (u + v). `dist` = Abstand (in Einheiten
|
||||
// von `spacing`) zur naechsten Linie; `fwidth` liefert eine bildschirm-
|
||||
// groessenrichtige Kantenglaettung (kein Aliasing bei flachem Blickwinkel).
|
||||
let spacing = 0.08; // Linienabstand ~8 cm
|
||||
let half = 0.004; // halbe Linienbreite ~4 mm (Strich ~8 mm)
|
||||
let ink = vec3<f32>(0.06, 0.06, 0.06);
|
||||
let paper = vec3<f32>(0.94, 0.94, 0.94);
|
||||
|
||||
let pattern = i32(round(in.hatch.x));
|
||||
let angle = in.hatch.y;
|
||||
let scale = max(in.hatch.z, 0.05);
|
||||
|
||||
// none (0): leere (weisse) Schnittflaeche — wie „keine Schraffur" im 2D.
|
||||
if (pattern == 0) {
|
||||
return vec4<f32>(paper, 1.0);
|
||||
}
|
||||
// solid (1): Vollton (Poché) — z. B. Beton.
|
||||
if (pattern == 1) {
|
||||
return vec4<f32>(ink, 1.0);
|
||||
}
|
||||
|
||||
// Muster-Koordinaten: (u,v) in Modell-Metern, um `angle` gedreht. Konvention
|
||||
// wie die 2D-Schraffur (`hatchPreview.parallelLines`): `angle == 0` ergibt
|
||||
// HORIZONTALE Linien (Phasen-Achse = dv), `+angle` dreht die Schar gegen den
|
||||
// Uhrzeigersinn wie im 2D-Swatch. `du` laeuft ENTLANG der Linienrichtung, `dv`
|
||||
// ist die Quer-/Phasen-Achse. (Frueher lag hier ein fixer 45°-Versatz, weil das
|
||||
// Diagonalmuster ueber `du+dv` lief — dadurch war alles 45° verdreht.)
|
||||
let ca = cos(angle);
|
||||
let sa = sin(angle);
|
||||
let du = in.uv.x * ca + in.uv.y * sa; // entlang der Linienrichtung
|
||||
let dv = -in.uv.x * sa + in.uv.y * ca; // quer (Phase)
|
||||
|
||||
let spacing = 0.08 * scale; // Grund-Linienabstand ~8 cm * Skalierung
|
||||
// Musterlinien-Staerke PRO Schraffur aus `hatch.w` (mm-Papier, aus dem LineStyle
|
||||
// des Hatch — wie die 2D-Schnitt-Schraffur: SIA `hatch-hair` 0.02 mm vs. `thin`
|
||||
// 0.13 mm). Dieselbe mm→Welt-Kalibrierung wie die Schicht-Trennlinien
|
||||
// (`section_fill::CUT_LINE_MM_TO_WORLD` = 0.01, halbe Breite ×0.5 → Faktor 0.005),
|
||||
// damit Schraffur- und Trennlinien-Staerke konsistent skalieren. Kleiner Floor,
|
||||
// damit eine Haarlinie nicht ganz verschwindet.
|
||||
let half = max(in.hatch.w * 0.005, 0.0001);
|
||||
let half_s = half / spacing;
|
||||
|
||||
var line = 0.0;
|
||||
if (pattern == 2) {
|
||||
// diagonal: EINE Linienschar quer zur Phasen-Achse (dv). angle==0 →
|
||||
// horizontal; `hatch.angle` (z. B. 45° bei Backstein) dreht sie wie im 2D.
|
||||
line = line_band(dv / spacing, half_s);
|
||||
} else if (pattern == 3) {
|
||||
// crosshatch: zwei Scharen ueber Kreuz (Phase dv + Phase du = +90°). Bei
|
||||
// angle==0 horizontal+vertikal, bei 45° (Beton) das gekippte Kreuz — wie 2D.
|
||||
line = max(
|
||||
line_band(dv / spacing, half_s),
|
||||
line_band(du / spacing, half_s)
|
||||
);
|
||||
} else {
|
||||
// insulation (4, Daemmung): Zickzack. Die Quer-Position (dv) folgt einer
|
||||
// Dreieckswelle entlang der Lauf-Achse (du) — ein durchgehender Zickzack.
|
||||
let cell = spacing * 2.0;
|
||||
let a = du / cell;
|
||||
let b = dv / cell;
|
||||
let tri = abs(fract(a) - 0.5) * 2.0; // 0..1 Dreieckswelle
|
||||
let fb = fract(b);
|
||||
let d = min(abs(fb - tri), min(abs(fb - tri + 1.0), abs(fb - tri - 1.0)));
|
||||
let aa = max(fwidth(fb) + fwidth(tri), 1e-5);
|
||||
let hw = half / cell;
|
||||
line = 1.0 - smoothstep(hw, hw + aa, d);
|
||||
}
|
||||
|
||||
let color = mix(paper, ink, clamp(line, 0.0, 1.0));
|
||||
let s = (in.uv.x + in.uv.y) / spacing;
|
||||
let frac = fract(s);
|
||||
let dist = min(frac, 1.0 - frac); // 0 auf der Linie
|
||||
let half_s = half / spacing;
|
||||
let aa = max(fwidth(s), 1e-5);
|
||||
let line = 1.0 - smoothstep(half_s, half_s + aa, dist);
|
||||
let color = mix(paper, ink, line);
|
||||
return vec4<f32>(color, 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -20,79 +20,6 @@ pub type Point2 = [f32; 2];
|
||||
/// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht.
|
||||
pub type Rgb = [f32; 3];
|
||||
|
||||
/// Schnitt-Schraffur eines Bauteils (fuer den 3D-Live-Schnitt). Rust-seitiges
|
||||
/// Minimal-Aequivalent zu `HatchStyle` (src/model/types.ts): nur die drei Felder,
|
||||
/// die der prozedurale Cap-Shader (`shaders::CAP_WGSL`) auswertet — Muster-Id,
|
||||
/// Winkel und Skalierung. Die Muster-Farbe bleibt MONOCHROM (Tinte auf Papier,
|
||||
/// siehe 2D-Direktive `HATCH_INK`) und wandert deshalb bewusst NICHT mit.
|
||||
///
|
||||
/// `pattern`: 0 = none (leer/weiss), 1 = solid (Vollton), 2 = diagonal (45°),
|
||||
/// 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung). Die Zuordnung ist
|
||||
/// mit der TS-Seite (`toWalls3d::hatchPatternId`) und dem Shader synchron zu halten.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct Hatch {
|
||||
/// Muster-Id (siehe oben). Default 0 (none).
|
||||
#[serde(default)]
|
||||
pub pattern: u32,
|
||||
/// Musterdrehung in Grad (Bildschirm-/Schnittebenen-Konvention). Default 0.
|
||||
#[serde(default)]
|
||||
pub angle: f32,
|
||||
/// Grundmassstab (1 = Standardteilung). Default 1.
|
||||
#[serde(default = "default_hatch_scale")]
|
||||
pub scale: f32,
|
||||
/// Strichstaerke der Musterlinien in mm-Papier (aus dem LineStyle des Hatch,
|
||||
/// `HatchRender.lineWeight`). Der Cap-Shader (`CAP_WGSL`) setzt die Muster-
|
||||
/// linienbreite daraus (mm->Welt wie die Trennlinien), sodass die Schraffur-
|
||||
/// Staerke pro Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs.
|
||||
/// `thin` 0.13 mm). Default 0.13 (Alt-Verhalten/Haarlinie).
|
||||
#[serde(default = "default_hatch_line_weight", rename = "lineWeight")]
|
||||
pub line_weight: f32,
|
||||
}
|
||||
|
||||
fn default_hatch_scale() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn default_hatch_line_weight() -> f32 {
|
||||
0.13
|
||||
}
|
||||
|
||||
/// Verschneidungs-/Stil-Metadaten EINES geschnittenen Bauteil-Bandes fuer den
|
||||
/// 3D-Live-Schnitt — das Rust-Gegenstueck zu den Feldern, mit denen `toSection.ts`
|
||||
/// die Schnitt-Boolean-Dominanz (`subtractDominantBands`) rechnet. Rein additiv
|
||||
/// (`#[serde(default)]` an jedem Feld); fehlt der ganze Block (`WallInput::cut ==
|
||||
/// None`), verhaelt sich der Schnitt wie zuvor (Band nimmt an keiner Subtraktion
|
||||
/// teil).
|
||||
///
|
||||
/// Da der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`/`SlabInput`-Box
|
||||
/// emittiert (`toWalls3d`, layered), traegt jede geschnittene Schicht hier IHRE
|
||||
/// eigene Prioritaet/Identitaet/Linienstil — die Dominanz-Verschneidung
|
||||
/// (`section_boolean.rs`) laeuft dann elementuebergreifend auf den erzeugten
|
||||
/// (u,v)-Rechtecken, exakt wie im 2D-Schnitt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CutBandMeta {
|
||||
/// `Component.joinPriority` der Schicht: ein Band mit STRIKT hoeherer
|
||||
/// Prioritaet schneidet ein ueberlappendes schwaecheres per Rechteck-
|
||||
/// Subtraktion weg (elementuebergreifend). `None` -> keine Teilnahme.
|
||||
#[serde(default)]
|
||||
pub join_priority: Option<i32>,
|
||||
/// `Component.id` der Schicht — steuert die MERGE-Regel (gleiche Komponente +
|
||||
/// Prioritaet verschmelzen zu einem Koerper, keine innere Trennlinie). `None`
|
||||
/// -> keine Verschmelzung.
|
||||
#[serde(default)]
|
||||
pub component_id: Option<String>,
|
||||
/// Farbe (RGB 0..1) der Schnitt-Umrisslinie dieses Bandes (P2, aus dem
|
||||
/// `jointLineStyleId`-LineStyle). `None` -> Default-Schnittkanten-Stil.
|
||||
#[serde(default)]
|
||||
pub line_color: Option<Rgb>,
|
||||
/// Strichstaerke der Schnitt-Umrisslinie in mm-Papier (aus dem LineStyle).
|
||||
/// `None` -> Default. Siehe `section_fill::build_cut_cap_lines` fuer die (ehrlich
|
||||
/// dokumentierte) mm->Welt-Umsetzung.
|
||||
#[serde(default)]
|
||||
pub line_weight: Option<f32>,
|
||||
}
|
||||
|
||||
/// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke,
|
||||
/// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh
|
||||
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
|
||||
@@ -139,70 +66,6 @@ pub struct WallInput {
|
||||
/// Verhalten unveraendert).
|
||||
#[serde(default)]
|
||||
pub layers: Option<Vec<WallLayer>>,
|
||||
/// Rechteckige LOECHER (Fenster/Tueren) als ECHTE Aussparungen in EINEM
|
||||
/// Wandkoerper (siehe `Hole` + `mesh::extrude_wall*`). Default leer.
|
||||
///
|
||||
/// Anders als `openings` zerlegt das die Wand NICHT in Pfeiler/Bruestung/
|
||||
/// Sturz-Teilquader, sondern stanzt die Loecher in die beiden Langseiten
|
||||
/// (Rechteck-Gitter-Zerlegung + Laibungsflaechen). So bildet EIN Koerper
|
||||
/// beliebig (auch versetzt uebereinander) angeordnete Oeffnungen ab — das
|
||||
/// kann die Segment-Zerlegung nicht. Genutzt vom 3D-Viewer-Pfad der
|
||||
/// TS-Ableitung (`toWalls3d`, `layeredWalls:true`); der Schnitt-Pfad
|
||||
/// (`layeredWalls:false`) bleibt bei `openings`/Segmentierung.
|
||||
///
|
||||
/// `holes` und `openings` schliessen sich im TS-Emitter gegenseitig aus; ist
|
||||
/// `holes` gesetzt, ueberspringt `mesh.rs` die Oeffnungs-Segmentierung und
|
||||
/// extrudiert den vollen Achsen-Abschnitt mit Loechern.
|
||||
#[serde(default)]
|
||||
pub holes: Vec<Hole>,
|
||||
/// Optionaler Material-Textur-Index fuer den Stil `Textured`: verweist auf eine
|
||||
/// per `Renderer::set_material_textures` hochgeladene Material-Farbkarte
|
||||
/// (Textur-Array-Ebene, 1-basiert; Ebene 0 ist das Fallback-Schachbrett). Da
|
||||
/// der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
|
||||
/// (`toWalls3d`, `layeredWalls:true`), traegt jede Schicht so IHRE Material-
|
||||
/// Textur. `None` -> Fallback-Schachbrett (bisheriges Verhalten). Vom
|
||||
/// Schnitt/Serde-Pfad (`section.rs`) ignoriert.
|
||||
#[serde(default, rename = "materialIndex")]
|
||||
pub material_index: Option<u32>,
|
||||
/// Optionale Schnitt-Schraffur dieses Wand-Bandes (siehe `Hatch`). Nur der
|
||||
/// 3D-Live-Schnitt (`section.rs`/`section_fill.rs`) wertet sie aus, um die
|
||||
/// Schnittflaeche im MUSTER des Bauteils zu schraffieren (statt eines fixen
|
||||
/// 45°-Musters). `None` -> Fallback-Diagonalmuster (Alt-Verhalten). Da der
|
||||
/// 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
|
||||
/// (`toWalls3d`, `layeredWalls:true`), traegt jede geschnittene Schicht so
|
||||
/// ihre eigene Schraffur.
|
||||
#[serde(default)]
|
||||
pub hatch: Option<Hatch>,
|
||||
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe
|
||||
/// `CutBandMeta`). Nur der 3D-Live-Schnitt (`section_fill`/`section_boolean`)
|
||||
/// wertet sie aus; fehlt sie, bleibt das Alt-Verhalten (keine Dominanz-
|
||||
/// Subtraktion, Default-Schnittkante).
|
||||
#[serde(default)]
|
||||
pub cut: Option<CutBandMeta>,
|
||||
}
|
||||
|
||||
/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe
|
||||
/// `WallInput::holes`.
|
||||
///
|
||||
/// KONVENTION: `from`/`to` sind Achsen-Meter ab `WallInput::start` (0..Wandlaenge,
|
||||
/// dieselbe Achse wie `Opening::from`/`to`); `z_bottom`/`z_top` sind ABSOLUTE
|
||||
/// Weltmeter (NICHT relativ zu `base_elevation` — anders als `Opening::sill`).
|
||||
/// Eine Tuer ist der Sonderfall `z_bottom == base_elevation` (Loch bis zur
|
||||
/// Wand-Unterkante). Loecher liegen definitionsgemaess im Wand-INNEREN und
|
||||
/// beruehren die gemiterten Enden nicht — die Gehrung (`compute_wall_miters`)
|
||||
/// bleibt davon unberuehrt (siehe `mesh`-Moduldoc).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct Hole {
|
||||
/// Start des Loch-Intervalls entlang der Wandachse (Meter ab `start`).
|
||||
pub from: f32,
|
||||
/// Ende des Loch-Intervalls entlang der Wandachse (Meter ab `start`).
|
||||
pub to: f32,
|
||||
/// Absolute Unterkante des Lochs (Weltmeter).
|
||||
#[serde(default, rename = "zBottom")]
|
||||
pub z_bottom: f32,
|
||||
/// Absolute Oberkante des Lochs (Weltmeter).
|
||||
#[serde(default, rename = "zTop")]
|
||||
pub z_top: f32,
|
||||
}
|
||||
|
||||
/// Eine Wandschicht: Dicke + eigene Albedo-Farbe. Siehe `WallInput::layers`.
|
||||
@@ -259,13 +122,6 @@ pub struct SlabInput {
|
||||
/// Albedo-Farbe (RGB 0..1). Default heller Deckenton.
|
||||
#[serde(default = "default_slab_color")]
|
||||
pub color: Rgb,
|
||||
/// Optionale Schnitt-Schraffur der Decke (siehe `Hatch`/`WallInput::hatch`).
|
||||
#[serde(default)]
|
||||
pub hatch: Option<Hatch>,
|
||||
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe
|
||||
/// `CutBandMeta`/`WallInput::cut`).
|
||||
#[serde(default)]
|
||||
pub cut: Option<CutBandMeta>,
|
||||
}
|
||||
|
||||
fn default_slab_color() -> Rgb {
|
||||
@@ -283,9 +139,6 @@ pub enum MeshKind {
|
||||
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
|
||||
#[default]
|
||||
Imported,
|
||||
/// Per truck (Profil-Extrusion, `src-tauri/trucksolid`) erzeugter Koerper —
|
||||
/// warmes Orange, damit er sich klar von Wand/Decke/Kontext abhebt.
|
||||
Extrusion,
|
||||
}
|
||||
|
||||
impl MeshKind {
|
||||
@@ -298,8 +151,6 @@ impl MeshKind {
|
||||
MeshKind::Terrain => [0.663, 0.690, 0.635],
|
||||
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
|
||||
MeshKind::Imported => [0.624, 0.651, 0.682],
|
||||
// Warmes Orange fuer truck-Profil-Extrusionen (Phase-1-Viewer-Beweis).
|
||||
MeshKind::Extrusion => [0.85, 0.55, 0.25],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,20 +187,6 @@ 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 {
|
||||
@@ -412,15 +249,8 @@ fn default_ortho_half_height() -> f32 {
|
||||
fn default_near() -> f32 {
|
||||
0.05
|
||||
}
|
||||
// `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
|
||||
fn default_far() -> f32 {
|
||||
1000.0
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
@@ -438,24 +268,14 @@ impl Default for Camera {
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Kamera-Presets der Kardinal-/Iso-Sicht (ROADMAP §11). `Persp` ist frei
|
||||
/// perspektivisch; alle anderen sind achsparallel bzw. diagonal orthografisch.
|
||||
/// `Front`/`Back`/`Side`/`Left` sind die vier Kardinalrichtungen (Vorne/Hinten/
|
||||
/// Rechts/Links im UI, s. `View3d` in TopBar.tsx — keine Himmelsrichtungen, da
|
||||
/// es noch keinen Nordwinkel gibt); `Iso`/`IsoFrontLeft`/`IsoBackRight`/
|
||||
/// `IsoBackLeft` sind die vier OBEREN Iso-Oktanten (untere vier bleiben im
|
||||
/// Hochbau ungenutzt) — `Iso` (vorne-oben-rechts) ist der bestehende Default.
|
||||
/// Die fuenf Kamera-Presets der three.js-Sicht. `Iso` und `Persp` sind
|
||||
/// perspektivisch/frei; `Front`/`Top`/`Side` sind achsparallel (orthografisch).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CameraPreset {
|
||||
Front,
|
||||
Back,
|
||||
Top,
|
||||
Side,
|
||||
Left,
|
||||
Iso,
|
||||
IsoFrontLeft,
|
||||
IsoBackRight,
|
||||
IsoBackLeft,
|
||||
Persp,
|
||||
}
|
||||
|
||||
@@ -473,38 +293,6 @@ pub struct Mesh {
|
||||
/// Anzahl f32 je Vertex im interleaved Puffer: 3 Position + 3 Normale + 3 Farbe.
|
||||
pub const FLOATS_PER_VERTEX: usize = 9;
|
||||
|
||||
/// Ausgabe der TEXTURIERTEN Mesh-Erzeugung (`RenderStyle::Textured`): interleaved
|
||||
/// [pos.xyz, normal.xyz, uv.xy] je Vertex plus Indexpuffer. Ein ZWEITER, additiver
|
||||
/// Vertex-Pfad neben `Mesh` — der bestehende `[pos, normal, color]`-Pfad (`Mesh`)
|
||||
/// bleibt dadurch bitgleich unangetastet. Genutzt nur mit den GPU-Features
|
||||
/// (`render`/`window`); serde-frei (reine GPU-Zwischenform), damit der Default-Bau
|
||||
/// ohne GPU nichts davon zieht.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TexturedMesh {
|
||||
/// Interleaved Vertices: je 8 f32 = [px,py,pz, nx,ny,nz, u,v].
|
||||
pub verts: Vec<f32>,
|
||||
/// Dreiecks-Indizes (0-basiert auf die Vertices).
|
||||
pub indices: Vec<u32>,
|
||||
/// Optionaler PARALLELER Puffer: EIN f32 je Vertex = die Material-Textur-
|
||||
/// Ebene (Textur-Array-Index) dieses Vertex, oder `-1` fuer „kein Material"
|
||||
/// (Fallback-Schachbrett, Ebene 0). Leer, wenn ohne Material-Zuordnung gebaut
|
||||
/// (`textured_from_mesh`) — dann bindet die GPU-Schicht einen konstanten
|
||||
/// `-1`-Puffer. Als ZWEITER Vertex-Buffer gehalten (nicht in `verts`
|
||||
/// interleaved), damit das bestehende `[pos,normal,uv]`-Layout (und die
|
||||
/// Geometrie-Paritaets-Tests) bitgleich bleiben.
|
||||
pub layers: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
|
||||
pub const TEXTURED_FLOATS_PER_VERTEX: usize = 8;
|
||||
|
||||
impl TexturedMesh {
|
||||
/// Anzahl Vertices im Puffer.
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
self.verts.len() / TEXTURED_FLOATS_PER_VERTEX
|
||||
}
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Anzahl Vertices im Puffer.
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
|
||||
@@ -15,13 +15,12 @@
|
||||
// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
|
||||
// (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention),
|
||||
// damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt.
|
||||
// `set_view_preset` wendet stattdessen eines der `View3d`-Praesete (front/back/
|
||||
// top/side/left/iso/isoFrontLeft/isoBackRight/isoBackLeft/perspective) direkt
|
||||
// ueber `math::preset_camera` an — alle ausser Perspective werden dabei ECHT
|
||||
// orthografisch (`preset_camera`; die Iso-Varianten = echte Isometrie ohne
|
||||
// perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung nicht leisten
|
||||
// kann. Nach einem Praeset-Sprung bleibt die weitere Navigation frei ueber
|
||||
// `set_camera` (kein Lock).
|
||||
// `set_view_preset` wendet stattdessen eines der fuenf `View3d`-Praesete
|
||||
// (front/top/side/iso/perspective) direkt ueber `math::preset_camera` an — Front/
|
||||
// Top/Side/Iso werden dabei ECHT orthografisch (`preset_camera`; Iso = echte
|
||||
// Isometrie ohne perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung
|
||||
// nicht leisten kann. Nach einem Praeset-Sprung bleibt die
|
||||
// weitere Navigation frei ueber `set_camera` (kein Lock).
|
||||
//
|
||||
// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
|
||||
// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts).
|
||||
@@ -114,11 +113,7 @@ 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],
|
||||
meshes: &[MeshInput],
|
||||
) -> Option<([f32; 3], [f32; 3])> {
|
||||
fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> 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| {
|
||||
@@ -151,17 +146,6 @@ fn model_bounds(
|
||||
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;
|
||||
}
|
||||
@@ -262,7 +246,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, &model.meshes);
|
||||
self.bounds = model_bounds(&model.walls, &model.slabs);
|
||||
self.renderer
|
||||
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
|
||||
// Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer
|
||||
@@ -315,15 +299,6 @@ 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,
|
||||
@@ -340,32 +315,6 @@ 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],
|
||||
@@ -377,30 +326,23 @@ impl WebModelRenderer {
|
||||
},
|
||||
fov_y,
|
||||
ortho_half_height,
|
||||
far,
|
||||
..Camera::default()
|
||||
};
|
||||
}
|
||||
|
||||
/// Wendet eines der Kardinal-/Iso-Kamera-Praesets der three.js-Sicht
|
||||
/// (`front`/`back`/`top`/`side`/`left`/`iso`/`isoFrontLeft`/`isoBackRight`/
|
||||
/// `isoBackLeft`/`perspective`, s. `View3d` in TopBar.tsx) auf das aktuelle
|
||||
/// Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend (mirror der
|
||||
/// TS-`fitTargetDist`); alle Presets ausser Perspective werden orthografisch,
|
||||
/// Wendet eines der fuenf Kamera-Praesets der three.js-Sicht
|
||||
/// (`front`/`top`/`side`/`iso`/`perspective`, s. `View3d` in TopBar.tsx) auf
|
||||
/// das aktuelle Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend
|
||||
/// (mirror der TS-`fitTargetDist`); Front/Top/Side/Iso werden orthografisch,
|
||||
/// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim
|
||||
/// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles
|
||||
/// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx).
|
||||
pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> {
|
||||
let preset = match preset {
|
||||
"front" => CameraPreset::Front,
|
||||
"back" => CameraPreset::Back,
|
||||
"top" => CameraPreset::Top,
|
||||
"side" => CameraPreset::Side,
|
||||
"left" => CameraPreset::Left,
|
||||
"iso" => CameraPreset::Iso,
|
||||
"isoFrontLeft" => CameraPreset::IsoFrontLeft,
|
||||
"isoBackRight" => CameraPreset::IsoBackRight,
|
||||
"isoBackLeft" => CameraPreset::IsoBackLeft,
|
||||
"perspective" => CameraPreset::Persp,
|
||||
other => {
|
||||
return Err(JsValue::from_str(&format!(
|
||||
@@ -435,14 +377,11 @@ impl WebModelRenderer {
|
||||
/// - `"shaded"` — beleuchtete Bauteilfarben (Default).
|
||||
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
|
||||
/// (identisch zum bisherigen `set_render_mode_white(true)`).
|
||||
/// - `"textured"` — Material-Farbkarten je Wand-Band aus dem Textur-Array
|
||||
/// (`set_material_textures`), Fallback-Schachbrett fuer Baender ohne Material.
|
||||
/// - `"textured"` — mangels Textur-Pipeline VORERST identisch zu `"shaded"`
|
||||
/// behandelt (KEIN Fake-Stub); Platzhalter fuer eine spaetere Textur-Schicht.
|
||||
/// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen.
|
||||
/// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth-
|
||||
/// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare).
|
||||
/// - `"shaded-edges"` — "Schattiert mit Kanten" (typischer BIM-Look): wie
|
||||
/// `"shaded"` (echte Bauteilfarben), ZUSAETZLICH dunkle Modell-Kanten
|
||||
/// obenauf wie bei `"hidden"`.
|
||||
/// Unbekannte Werte fallen auf `"shaded"` zurueck. Wirkt erst beim naechsten
|
||||
/// `render`. Loest `set_render_mode_white` ab (das bleibt kompatibel).
|
||||
pub fn set_render_style(&mut self, style: &str) -> Result<(), JsValue> {
|
||||
@@ -492,53 +431,6 @@ impl WebModelRenderer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-Array hoch
|
||||
/// (`Renderer::set_material_textures`). `rgba` = dicht gepackte RGBA8-Bytes von
|
||||
/// `layer_count` Ebenen, JEDE exakt 256×256 (der JS-Aufrufer dekodiert/skaliert
|
||||
/// die Material-Bilder, siehe `viewport/materialTextures.ts`). Ebene 0 bleibt
|
||||
/// intern das Fallback-Schachbrett; die hochgeladenen Karten belegen die Ebenen
|
||||
/// 1..N, auf die `WallInput::materialIndex` (1-basiert) zeigt. `layer_count == 0`
|
||||
/// oder zu kleine Daten setzen auf das reine Schachbrett zurueck. Wirkt erst beim
|
||||
/// naechsten `render`.
|
||||
pub fn set_material_textures(&mut self, rgba: &[u8], layer_count: u32) -> Result<(), JsValue> {
|
||||
self.renderer
|
||||
.set_material_textures(&self.device, &self.queue, rgba, layer_count);
|
||||
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,34 +1,17 @@
|
||||
// Tauri-v2-Einstieg: die Befehls-Bruecke und der App-Start.
|
||||
// Tauri-v2-Einstieg. Die eigentliche Geometrie liegt im serde-only Crate
|
||||
// `geometry`; hier nur die Befehls-Bruecke und der App-Start.
|
||||
|
||||
// M2: native wgpu-Viewports (2D und/oder 3D) im Tauri-Prozess. EIN Modul, EINE
|
||||
// winit-Event-Loop fuer beide Fenster (winit erlaubt nur eine Loop pro Prozess).
|
||||
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
||||
mod native;
|
||||
|
||||
// Lock-Datei gegen gleichzeitiges Oeffnen derselben Projektdatei aus zwei
|
||||
// App-Instanzen (OS-Advisory-Lock via `fs4`, siehe lock.rs).
|
||||
mod lock;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
|
||||
/// Fordert den exklusiven Lock fuer eine Projektdatei an. `Ok(())` wenn frei
|
||||
/// (oder schon von uns gehalten), sonst `Err(Some(LockInfo))` mit Angaben zur
|
||||
/// haltenden Instanz (pid/hostname/timestamp), oder `Err(None)` falls die
|
||||
/// Sidecar-Datei gesperrt, aber ihr Info-Block nicht lesbar war.
|
||||
/// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend.
|
||||
#[tauri::command]
|
||||
fn acquire_project_lock(
|
||||
path: String,
|
||||
state: State<lock::LockState>,
|
||||
) -> Result<(), Option<lock::LockInfo>> {
|
||||
lock::acquire(&state, &PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Gibt einen von dieser Instanz gehaltenen Lock frei. No-op, wenn der Pfad
|
||||
/// nicht gelockt war.
|
||||
#[tauri::command]
|
||||
fn release_project_lock(path: String, state: State<lock::LockState>) {
|
||||
lock::release(&state, &PathBuf::from(path));
|
||||
async fn compute_joins(
|
||||
input: geometry::JoinInput,
|
||||
) -> Result<Vec<geometry::WallCuts>, String> {
|
||||
Ok(geometry::compute_joins(input))
|
||||
}
|
||||
|
||||
// Live-Spiegelung: die Webview schiebt bei jeder Modellaenderung (debounced)
|
||||
@@ -57,27 +40,7 @@ fn push_native_walls(walls: serde_json::Value) {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
// Native „Speichern unter"-Dialog + Datei-Schreiben aus der Webview.
|
||||
// Immer aktiv (unabhaengig von native2d/native3d), damit Datei-Exporte
|
||||
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
// Selbst-Update: prueft beim Start gegen `endpoints` aus tauri.conf.json
|
||||
// (Gitea-Release-Asset `latest.json`), Installation via JS-Seite
|
||||
// (@tauri-apps/plugin-updater). `tauri_plugin_process` liefert `relaunch()`
|
||||
// fuer den Neustart nach der Installation.
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
// HTTP-Requests aus der Webview (Versionsverlauf, versions.json vom
|
||||
// selben Gitea-Release) ohne Browser-CORS-Einschraenkung; Shell: oeffnet
|
||||
// den DMG-Download einer aelteren Version im System-Browser (Rollback,
|
||||
// s. src/update/checkForUpdate.ts).
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
// Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs).
|
||||
.manage(lock::LockState::default())
|
||||
.setup(|_app| {
|
||||
let builder = tauri::Builder::default().setup(|_app| {
|
||||
// Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen
|
||||
// Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt.
|
||||
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
||||
@@ -87,16 +50,12 @@ pub fn run() {
|
||||
|
||||
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
||||
let builder = builder.invoke_handler(tauri::generate_handler![
|
||||
compute_joins,
|
||||
push_native_scene,
|
||||
push_native_walls,
|
||||
acquire_project_lock,
|
||||
release_project_lock
|
||||
push_native_walls
|
||||
]);
|
||||
#[cfg(not(any(feature = "native2d", feature = "native3d")))]
|
||||
let builder = builder.invoke_handler(tauri::generate_handler![
|
||||
acquire_project_lock,
|
||||
release_project_lock
|
||||
]);
|
||||
let builder = builder.invoke_handler(tauri::generate_handler![compute_joins]);
|
||||
|
||||
builder
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
// Lock-Datei gegen gleichzeitiges Öffnen derselben Projektdatei aus zwei
|
||||
// App-Instanzen. Prinzip: ein OS-Advisory-Lock (Crate `fs4`, gepflegter
|
||||
// `fs2`-Nachfolger) auf einer Sidecar-Datei `<projektdatei>.lock`, gehalten
|
||||
// über ein offenes `File`-Handle im Tauri-`State`. Der OS-Lock fällt beim
|
||||
// Prozessende AUTOMATISCH weg (auch bei Absturz) — kein manuelles Aufräumen
|
||||
// nötig, keine verwaiste Sperre blockiert dauerhaft: ohne aktiven OS-Lock kann
|
||||
// die nächste Instanz dieselbe Sidecar-Datei problemlos wieder exklusiv locken,
|
||||
// auch wenn die Datei selbst liegen bleibt.
|
||||
//
|
||||
// Zusätzlich schreiben wir einen kleinen JSON-Info-Block (pid/hostname/
|
||||
// timestamp) in die Sidecar-Datei, damit eine zweite Instanz bei einem
|
||||
// Konflikt eine freundliche Meldung zeigen kann ("bereits geöffnet auf …").
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use fs4::fs_std::FileExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Info-Block in der Lock-Sidecar-Datei — wer hält den Lock gerade.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LockInfo {
|
||||
pub pid: u32,
|
||||
pub hostname: String,
|
||||
/// Unix-Zeitstempel (Sekunden) als String — reicht für eine Anzeige,
|
||||
/// keine Notwendigkeit für eine Datumsbibliothek.
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
impl LockInfo {
|
||||
fn current() -> Self {
|
||||
LockInfo {
|
||||
pid: std::process::id(),
|
||||
hostname: hostname(),
|
||||
timestamp: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs().to_string())
|
||||
.unwrap_or_else(|_| "0".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bester-Versuch-Hostname ohne zusätzliche Abhängigkeit: erst Umgebungs-
|
||||
/// variablen (Windows/Unix), sonst das `hostname`-Kommando, sonst "unbekannt".
|
||||
fn hostname() -> String {
|
||||
for var in ["COMPUTERNAME", "HOSTNAME"] {
|
||||
if let Ok(h) = std::env::var(var) {
|
||||
if !h.is_empty() {
|
||||
return h;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(out) = std::process::Command::new("hostname").output() {
|
||||
if out.status.success() {
|
||||
if let Ok(s) = String::from_utf8(out.stdout) {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"unbekannt".to_string()
|
||||
}
|
||||
|
||||
/// Alle von DIESER Instanz gehaltenen Locks: Projektdatei-Pfad → offenes
|
||||
/// File-Handle auf die Sidecar-Lock-Datei. Das Handle hält den OS-Lock, solange
|
||||
/// es lebt (Drop/Prozessende gibt ihn automatisch frei).
|
||||
#[derive(Default)]
|
||||
pub struct LockState(pub Mutex<HashMap<PathBuf, File>>);
|
||||
|
||||
fn sidecar_path(project_path: &Path) -> PathBuf {
|
||||
let mut s = project_path.as_os_str().to_os_string();
|
||||
s.push(".lock");
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Fordert den exklusiven Lock für `project_path` an.
|
||||
///
|
||||
/// - Bereits von UNS gehalten (dieselbe Datei nochmal geöffnet) → `Ok(())`.
|
||||
/// - Frei → OS-Lock holen, eigenen Info-Block reinschreiben, Handle im State
|
||||
/// behalten, `Ok(())`.
|
||||
/// - Von einer ANDEREN Instanz gehalten → `Err(Some(info))` mit deren
|
||||
/// Info-Block (falls lesbar), sonst `Err(None)`.
|
||||
pub fn acquire(state: &LockState, project_path: &Path) -> Result<(), Option<LockInfo>> {
|
||||
let mut held = state.0.lock().expect("lock state poisoned");
|
||||
|
||||
if held.contains_key(project_path) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sidecar = sidecar_path(project_path);
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&sidecar)
|
||||
.map_err(|_| None)?;
|
||||
|
||||
match FileExt::try_lock_exclusive(&file) {
|
||||
Ok(()) => {
|
||||
write_info(&file);
|
||||
held.insert(project_path.to_path_buf(), file);
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => {
|
||||
// Gesperrt von einer anderen Instanz -> deren Info-Block lesen (best effort).
|
||||
Err(read_info(&sidecar))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gibt den von uns gehaltenen Lock für `project_path` frei (schliesst das
|
||||
/// File-Handle → OS-Lock fällt weg). No-op, wenn wir diesen Pfad nicht halten.
|
||||
pub fn release(state: &LockState, project_path: &Path) {
|
||||
let mut held = state.0.lock().expect("lock state poisoned");
|
||||
held.remove(project_path);
|
||||
}
|
||||
|
||||
/// Schreibt den aktuellen Info-Block (pid/hostname/timestamp dieser Instanz)
|
||||
/// in die bereits gelockte Sidecar-Datei. Fehler hier sind nicht fatal — der
|
||||
/// Lock selbst (das OS-Lock) steht unabhängig davon schon.
|
||||
fn write_info(file: &File) {
|
||||
let info = LockInfo::current();
|
||||
let Ok(json) = serde_json::to_string(&info) else {
|
||||
return;
|
||||
};
|
||||
let mut f = file;
|
||||
let _ = f.set_len(0);
|
||||
let _ = f.seek(SeekFrom::Start(0));
|
||||
let _ = f.write_all(json.as_bytes());
|
||||
let _ = f.flush();
|
||||
}
|
||||
|
||||
/// Liest den Info-Block aus einer (von einer anderen Instanz gesperrten)
|
||||
/// Sidecar-Datei. `None` wenn die Datei fehlt/kein gültiges JSON enthält.
|
||||
fn read_info(sidecar: &Path) -> Option<LockInfo> {
|
||||
let mut contents = String::new();
|
||||
File::open(sidecar).ok()?.read_to_string(&mut contents).ok()?;
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
@@ -344,12 +344,6 @@ fn demo_walls() -> Vec<WallInput> {
|
||||
color: grey,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
// Additive Felder (Loecher/Material/Schnitt) — die Demo-Waende nutzen sie
|
||||
// nicht; Defaults wie im Serde-Pfad.
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
vec![
|
||||
mk([0.0, 0.0], [6.0, 0.0]),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "dossier",
|
||||
"productName": "cad",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ch.dossier.cad",
|
||||
"build": {
|
||||
@@ -10,14 +10,9 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "dossier",
|
||||
"title": "cad",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"dragDropEnabled": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": { "x": 20, "y": 22 }
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -27,21 +22,6 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYyRDk3NkZGODQwRUJBNTQKUldSVXVnNkUvM2JaOHV2QWp0L05qZnpEUmt2TkN3ZWJBeFQwb1I0cExWNEpuZWU0UjZiazdQNU0K",
|
||||
"endpoints": [
|
||||
"https://git.openbureau.ch/karim/DOSSIER-STANDALONE/releases/download/latest/latest.json"
|
||||
]
|
||||
}
|
||||
"icon": ["icons/icon.png"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "trucksolid"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Profil-Extrusion via truck B-Rep → tesselliertes Mesh für render3d"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
web = [
|
||||
"dep:wasm-bindgen",
|
||||
"dep:serde_json",
|
||||
"dep:console_error_panic_hook",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
# truck-modeling bringt truck-geometry/topology/polymesh transitiv mit (alle
|
||||
# auf derselben kompatiblen Version). Nur truck-modeling explizit pinnen —
|
||||
# keine separaten geometry/topology-Einträge, die einen Versions-Konflikt erzeugen.
|
||||
truck-modeling = "0.3"
|
||||
# Mesh-Ebenen-CSG (Boolesche Operationen) — bewusst NICHT truck-modeling-Booleans
|
||||
# (instabil bei koinzidenten Flächen, siehe PENDENZEN.md truck-Integration Phase 4).
|
||||
# csgrs arbeitet auf Dreiecks-Ebene (BSP-Baum), robust genau in diesem Fall.
|
||||
# Gepinnt auf einen Git-Commit (crates.io-Veröffentlichungen 0.16–0.20.1 sind alle
|
||||
# wegen einer harten, zurückgezogenen core2-Abhängigkeit nicht installierbar; auf
|
||||
# dem unveröffentlichten main-Branch bereits behoben). Nutzerautorisiert (Spike).
|
||||
csgrs = { git = "https://github.com/timschmidt/csgrs", rev = "5e7a37a8803d4e56617734687edc9b98f4ebeed7", default-features = false, features = ["f64", "bmesh", "mesh", "earcut"] }
|
||||
nalgebra = "0.34"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = false
|
||||
@@ -1,212 +0,0 @@
|
||||
// Boolesche Operationen (Union/Differenz/Schnitt) zwischen zwei Dreiecks-Meshes.
|
||||
// Mesh-Ebenen-CSG via csgrs (BSP-Baum) statt truck-modeling-Booleans — letztere
|
||||
// sind bei koinzidenten/tangentialen Flächen instabil (siehe PENDENZEN.md,
|
||||
// truck-Integration Phase 4, monstertruck-solid-Spike). csgrs erwies sich im
|
||||
// Spike an genau diesem Fall (Extrusion bündig/eingebunden in eine Wand,
|
||||
// deckungsgleicher Querschnitt) als exakt korrekt.
|
||||
|
||||
use csgrs::csg::CSG;
|
||||
use csgrs::mesh::Mesh as CsgMesh;
|
||||
use csgrs::polygon::Polygon;
|
||||
use csgrs::triangulated::Triangulated3D;
|
||||
use csgrs::vertex::Vertex;
|
||||
use nalgebra::Point3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Eingabe-Mesh für eine boolesche Operation: flache f64-Positionen (Modell-
|
||||
/// Meter) + Dreiecks-Indizes. Getrennt von `MeshOutput` (dort f32, da Render-
|
||||
/// Ausgabe) — hier f64, da Eingabe aus Modelldaten und Präzision für die BSP-
|
||||
/// Klassifikation an koinzidenten Flächen zählt.
|
||||
#[derive(Deserialize)]
|
||||
pub struct BooleanMeshInput {
|
||||
pub a_positions: Vec<f64>,
|
||||
pub a_indices: Vec<u32>,
|
||||
pub b_positions: Vec<f64>,
|
||||
pub b_indices: Vec<u32>,
|
||||
/// "union" | "difference" | "intersection"
|
||||
pub op: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BooleanMeshOutput {
|
||||
pub positions: Vec<f32>,
|
||||
pub indices: Vec<u32>,
|
||||
/// Ob das Ergebnis leer ist (z. B. Schnitt zweier sich nur berührender
|
||||
/// Körper) — csgrs liefert das als Trimesh-Fehler statt eines leeren
|
||||
/// Meshes zurück, hier auf einen sauberen Fall normalisiert.
|
||||
pub empty: bool,
|
||||
}
|
||||
|
||||
fn mesh_from_triangles(positions: &[f64], indices: &[u32]) -> Result<CsgMesh<()>, String> {
|
||||
if indices.len() % 3 != 0 {
|
||||
return Err("indices müssen Dreiecke sein (Vielfaches von 3)".into());
|
||||
}
|
||||
let n_verts = positions.len() / 3;
|
||||
let mut polygons = Vec::with_capacity(indices.len() / 3);
|
||||
for tri in indices.chunks(3) {
|
||||
let mut pts = [Point3::origin(); 3];
|
||||
for (k, &i) in tri.iter().enumerate() {
|
||||
let idx = i as usize;
|
||||
if idx >= n_verts {
|
||||
return Err(format!("Index {idx} außerhalb der Positions-Liste"));
|
||||
}
|
||||
let o = idx * 3;
|
||||
pts[k] = Point3::new(positions[o], positions[o + 1], positions[o + 2]);
|
||||
}
|
||||
let normal = (pts[1] - pts[0]).cross(&(pts[2] - pts[0]));
|
||||
let verts = vec![
|
||||
Vertex::new(pts[0], normal),
|
||||
Vertex::new(pts[1], normal),
|
||||
Vertex::new(pts[2], normal),
|
||||
];
|
||||
polygons.push(Polygon::new(verts, ()));
|
||||
}
|
||||
Ok(CsgMesh::from_polygons(&polygons, ()))
|
||||
}
|
||||
|
||||
fn triangles_from_mesh(m: &CsgMesh<()>) -> (Vec<f32>, Vec<u32>) {
|
||||
let mut positions: Vec<f32> = Vec::new();
|
||||
let mut indices: Vec<u32> = Vec::new();
|
||||
let mut next = 0u32;
|
||||
m.visit_triangles(|tri| {
|
||||
for v in &tri {
|
||||
positions.push(v.position.x as f32);
|
||||
positions.push(v.position.y as f32);
|
||||
positions.push(v.position.z as f32);
|
||||
}
|
||||
indices.push(next);
|
||||
indices.push(next + 1);
|
||||
indices.push(next + 2);
|
||||
next += 3;
|
||||
});
|
||||
(positions, indices)
|
||||
}
|
||||
|
||||
pub fn boolean_mesh_core(
|
||||
a_positions: &[f64],
|
||||
a_indices: &[u32],
|
||||
b_positions: &[f64],
|
||||
b_indices: &[u32],
|
||||
op: &str,
|
||||
) -> Result<BooleanMeshOutput, String> {
|
||||
let a = mesh_from_triangles(a_positions, a_indices)?;
|
||||
let b = mesh_from_triangles(b_positions, b_indices)?;
|
||||
let result = match op {
|
||||
"union" => a.union(&b),
|
||||
"difference" => a.difference(&b),
|
||||
"intersection" => a.intersection(&b),
|
||||
other => return Err(format!("unbekannte boolesche Operation: {other}")),
|
||||
};
|
||||
let (positions, indices) = triangles_from_mesh(&result);
|
||||
Ok(BooleanMeshOutput {
|
||||
empty: indices.is_empty(),
|
||||
positions,
|
||||
indices,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cuboid(x: f64, y: f64, z: f64, w: f64, l: f64, h: f64) -> (Vec<f64>, Vec<u32>) {
|
||||
let corners = [
|
||||
(x, y, z),
|
||||
(x + w, y, z),
|
||||
(x + w, y + l, z),
|
||||
(x, y + l, z),
|
||||
(x, y, z + h),
|
||||
(x + w, y, z + h),
|
||||
(x + w, y + l, z + h),
|
||||
(x, y + l, z + h),
|
||||
];
|
||||
let mut positions = Vec::with_capacity(24);
|
||||
for (cx, cy, cz) in corners {
|
||||
positions.push(cx);
|
||||
positions.push(cy);
|
||||
positions.push(cz);
|
||||
}
|
||||
// 12 Dreiecke, konsistent nach außen orientiert (Rechte-Hand-Regel).
|
||||
let indices: Vec<u32> = vec![
|
||||
0, 2, 1, 0, 3, 2, // unten (-z)
|
||||
4, 5, 6, 4, 6, 7, // oben (+z)
|
||||
0, 5, 4, 0, 1, 5, // -y
|
||||
1, 6, 5, 1, 2, 6, // +x
|
||||
2, 7, 6, 2, 3, 7, // +y
|
||||
3, 4, 7, 3, 0, 4, // -x
|
||||
];
|
||||
(positions, indices)
|
||||
}
|
||||
|
||||
fn volume_of(positions: &[f32], indices: &[u32]) -> f64 {
|
||||
// Divergenztheorem (Tetraeder vom Ursprung), robust für beliebige geschlossene Dreiecksmeshes.
|
||||
let mut vol = 0.0f64;
|
||||
for tri in indices.chunks(3) {
|
||||
let mut p = [[0.0f64; 3]; 3];
|
||||
for (k, &i) in tri.iter().enumerate() {
|
||||
let o = i as usize * 3;
|
||||
p[k] = [
|
||||
positions[o] as f64,
|
||||
positions[o + 1] as f64,
|
||||
positions[o + 2] as f64,
|
||||
];
|
||||
}
|
||||
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
|
||||
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
|
||||
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
|
||||
/ 6.0;
|
||||
}
|
||||
vol.abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_of_overlapping_cuboids() {
|
||||
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
|
||||
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "union").unwrap();
|
||||
assert!(!r.empty);
|
||||
assert!((volume_of(&r.positions, &r.indices) - 1.875).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersection_of_overlapping_cuboids() {
|
||||
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
|
||||
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "intersection").unwrap();
|
||||
assert!(!r.empty);
|
||||
assert!((volume_of(&r.positions, &r.indices) - 0.125).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Praxisfall: Extrusion 0.1m in eine Wand eingebunden, deckungsgleicher
|
||||
/// Querschnitt — genau der Fall, an dem monstertruck-solid scheiterte.
|
||||
#[test]
|
||||
fn wall_minus_embedded_extrusion_matching_cross_section() {
|
||||
let wall = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let embedded = cuboid(0.9, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let r = boolean_mesh_core(&wall.0, &wall.1, &embedded.0, &embedded.1, "difference").unwrap();
|
||||
assert!(!r.empty);
|
||||
assert!((volume_of(&r.positions, &r.indices) - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_touching_union_is_exact() {
|
||||
let a = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let b = cuboid(1.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let r = boolean_mesh_core(&a.0, &a.1, &b.0, &b.1, "union").unwrap();
|
||||
assert!(!r.empty);
|
||||
assert!((volume_of(&r.positions, &r.indices) - 2.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_triangle_indices() {
|
||||
let (ap, _) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
let bad_indices = vec![0u32, 1, 2, 3];
|
||||
assert!(boolean_mesh_core(&ap, &bad_indices, &ap, &bad_indices, "union").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_op() {
|
||||
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
|
||||
assert!(boolean_mesh_core(&ap, &ai, &ap, &ai, "xor").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
// Profil-Extrusion via truck B-Rep. Erzeugt ein tesselliertes Mesh aus einem
|
||||
// 2D-Polygon-Querschnitt (XY-Ebene, Modell-Meter) durch lineare Extrusion
|
||||
// entlang +Z. Ausgabe ist kompatibel mit render3d::types::MeshInput.
|
||||
//
|
||||
// truck-Architektur:
|
||||
// truck_modeling::builder — Vertex/Edge/Wire/Face bauen + tsweep (Validierung)
|
||||
// Tessellierung — direkt aus den Eingangskoordinaten (Prismen-Geometrie)
|
||||
// Bewusst NICHT genutzt: truck-modeling Booleans (instabil upstream).
|
||||
// truck-polymesh wird nicht benötigt (keine Tess.-API für Solids in 0.3).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use truck_modeling::*;
|
||||
|
||||
mod boolean;
|
||||
pub use boolean::{boolean_mesh_core, BooleanMeshInput, BooleanMeshOutput};
|
||||
|
||||
/// Flaches [x0,y0, x1,y1, …] Array + Höhe → Extrusion.
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExtrudePolyInput {
|
||||
pub points: Vec<f64>,
|
||||
pub height: f64,
|
||||
/// Verjüngung 0.0 (Prisma, Default) … 1.0 (Spitze/Kegel-Pyramide). Fehlt
|
||||
/// das Feld (ältere Aufrufer), greift serde-Default 0.0 — unverändertes
|
||||
/// Prisma-Verhalten.
|
||||
#[serde(default)]
|
||||
pub taper: f64,
|
||||
}
|
||||
|
||||
/// Tesselliertes Mesh, kompatibel mit render3d::types::MeshInput.
|
||||
/// positions: flat [x,y,z, …] in Modell-Metern; indices: Dreiecks-Indizes.
|
||||
#[derive(Serialize)]
|
||||
pub struct MeshOutput {
|
||||
pub positions: Vec<f32>,
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Extrudiert ein geschlossenes Polygon (≥3 Punkte) um `height` Meter nach +Z,
|
||||
/// optional verjüngt (`taper` 0.0 Prisma … 1.0 Spitze/Kegel-Pyramide — linear
|
||||
/// zum Schwerpunkt skaliert). Nutzt truck::builder zur Validierung der
|
||||
/// Grundfläche; tesselliert dann direkt aus den Koordinaten.
|
||||
pub fn extrude_polygon_core(
|
||||
pts: &[(f64, f64)],
|
||||
height: f64,
|
||||
taper: f64,
|
||||
) -> std::result::Result<MeshOutput, String> {
|
||||
if pts.len() < 3 {
|
||||
return Err("min 3 Punkte".into());
|
||||
}
|
||||
if height <= 0.0 {
|
||||
return Err("height muss > 0 sein".into());
|
||||
}
|
||||
if !(0.0..=1.0).contains(&taper) {
|
||||
return Err("taper muss zwischen 0 und 1 liegen".into());
|
||||
}
|
||||
|
||||
// Letzten Punkt entfernen falls er den ersten wiederholt (geschlossener Ring).
|
||||
let ring: Vec<(f64, f64)> = {
|
||||
let mut r = pts.to_vec();
|
||||
if r.len() >= 2 {
|
||||
let first = r[0];
|
||||
let last = *r.last().unwrap();
|
||||
if (first.0 - last.0).abs() < 1e-10 && (first.1 - last.1).abs() < 1e-10 {
|
||||
r.pop();
|
||||
}
|
||||
}
|
||||
r
|
||||
};
|
||||
if ring.len() < 3 {
|
||||
return Err("min 3 eindeutige Punkte".into());
|
||||
}
|
||||
let n = ring.len();
|
||||
|
||||
// ── truck B-Rep: try_attach_plane validiert Planarität und Degenerierung ──
|
||||
let verts: Vec<Vertex> = ring
|
||||
.iter()
|
||||
.map(|&(x, y)| builder::vertex(Point3::new(x, y, 0.0)))
|
||||
.collect();
|
||||
let edges: Vec<Edge> = (0..n)
|
||||
.map(|i| builder::line(&verts[i], &verts[(i + 1) % n]))
|
||||
.collect();
|
||||
let wire = Wire::from_iter(edges);
|
||||
// Gibt Err zurück bei degeneriertem / nicht-ebenem Profil.
|
||||
let face = builder::try_attach_plane(&[wire]).map_err(|e| format!("{e}"))?;
|
||||
// tsweep erzeugt den Solid — wir nutzen ihn zur Vollständigkeit, auch wenn
|
||||
// wir ihn für die Tessellierung nicht direkt traversieren (nur fürs
|
||||
// ungetaperte Prisma sinnvoll validiert; Verjüngung ist reine Tessellierung).
|
||||
let _solid: Solid = builder::tsweep(&face, Vector3::new(0.0, 0.0, height));
|
||||
|
||||
let centroid = polygon_centroid(&ring);
|
||||
|
||||
// Deckel-/Boden-Triangulierung fuer BELIEBIGE (auch konkave) Profile per
|
||||
// Ohr-Clipping — NICHT per Fächer-ab-Vertex-0 (der nur fuer konvexe bzw.
|
||||
// von Vertex 0 aus sternfoermige Polygone korrekt ist; bei einem T-Traeger
|
||||
// o.ae. erzeugt ein Fächer Phantom-Dreiecke quer durch die konkave Kerbe).
|
||||
// `cap_tris` ist bereits in der Konvention orientiert, die der bisherige
|
||||
// "Fächer ab Vertex 0" fuer KONVEXE Ringe erzeugt hat (a,b,c mit
|
||||
// aufsteigendem Index), sodass die Deck-/Boden-Zuordnung unten unveraendert
|
||||
// bleibt.
|
||||
let cap_tris = ear_triangulate(&ring);
|
||||
|
||||
// Volle Verjüngung (taper ≈ 1): Spitze statt entartetem Deck-Ring — Kegel/
|
||||
// Pyramide als n Boden-Vertices + 1 Spitzen-Vertex, sonst gäbe es
|
||||
// Nulldreiecke (Deckfläche + halbe Seitenflächen) mit Kreuzprodukt-Normale
|
||||
// (0,0,0) am Deck.
|
||||
if taper >= 1.0 - 1e-9 {
|
||||
let mut positions: Vec<f32> = Vec::with_capacity((n + 1) * 3);
|
||||
for &(x, y) in &ring {
|
||||
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
|
||||
}
|
||||
positions.extend_from_slice(&[centroid.0 as f32, centroid.1 as f32, height as f32]);
|
||||
let apex = n as u32;
|
||||
|
||||
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() + n) * 3);
|
||||
// Bodenfläche: wie im Prisma-Fall die zu "oben" umgekehrte Wicklung
|
||||
// (CCW von unten).
|
||||
for &(a, b, c) in &cap_tris {
|
||||
indices.extend_from_slice(&[a as u32, c as u32, b as u32]);
|
||||
}
|
||||
// Seitenflächen: ein Dreieck je Kante zur Spitze.
|
||||
for i in 0..n as u32 {
|
||||
let j = (i + 1) % n as u32;
|
||||
indices.extend_from_slice(&[i, j, apex]);
|
||||
}
|
||||
return Ok(MeshOutput { positions, indices });
|
||||
}
|
||||
|
||||
// ── Tessellierung (Prisma bei taper=0, sonst Pyramidenstumpf) ──
|
||||
// Für ein n-Eck ergeben sich:
|
||||
// Bodenfläche: len(cap_tris) Dreiecke (Ohr-Clipping, konkav-sicher)
|
||||
// Deckfläche: len(cap_tris) Dreiecke
|
||||
// Seitenflächen: n * 2 Dreiecke (je Kante ein Rechteck → 2 Dreiecke)
|
||||
|
||||
let mut positions: Vec<f32> = Vec::with_capacity(2 * n * 3);
|
||||
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() * 2 + n * 2) * 3);
|
||||
|
||||
// Vertices: Boden (0..n-1) gefolgt von Dach (n..2n-1) — Dach zum
|
||||
// Schwerpunkt hin um (1-taper) skaliert (taper=0 ⇒ Prisma, unveränderte
|
||||
// Kontur; 0<taper<1 ⇒ Pyramidenstumpf).
|
||||
let scale = 1.0 - taper;
|
||||
for &(x, y) in &ring {
|
||||
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
|
||||
}
|
||||
for &(x, y) in &ring {
|
||||
let tx = centroid.0 + (x - centroid.0) * scale;
|
||||
let ty = centroid.1 + (y - centroid.1) * scale;
|
||||
positions.extend_from_slice(&[tx as f32, ty as f32, height as f32]);
|
||||
}
|
||||
|
||||
let base = 0u32;
|
||||
let top = n as u32;
|
||||
|
||||
// Bodenfläche: umgekehrte Wicklung relativ zur Deckfläche (CCW von unten).
|
||||
for &(a, b, c) in &cap_tris {
|
||||
indices.extend_from_slice(&[base + a as u32, base + c as u32, base + b as u32]);
|
||||
}
|
||||
|
||||
// Deckfläche: gleiche Wicklung wie `cap_tris` (CCW von oben).
|
||||
for &(a, b, c) in &cap_tris {
|
||||
indices.extend_from_slice(&[top + a as u32, top + b as u32, top + c as u32]);
|
||||
}
|
||||
|
||||
// Seitenflächen: Rechteck pro Kante → 2 Dreiecke
|
||||
for i in 0..n as u32 {
|
||||
let j = (i + 1) % n as u32;
|
||||
// Unteres Dreieck
|
||||
indices.extend_from_slice(&[base + i, base + j, top + i]);
|
||||
// Oberes Dreieck
|
||||
indices.extend_from_slice(&[base + j, top + j, top + i]);
|
||||
}
|
||||
|
||||
Ok(MeshOutput { positions, indices })
|
||||
}
|
||||
|
||||
/// Signierte Fläche (Shoelace) — positiv bei CCW-Umlauf.
|
||||
fn signed_area_f64(ring: &[(f64, f64)]) -> f64 {
|
||||
let n = ring.len();
|
||||
let mut a = 0.0;
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
a += ring[j].0 * ring[i].1 - ring[i].0 * ring[j].1;
|
||||
j = i;
|
||||
}
|
||||
a * 0.5
|
||||
}
|
||||
|
||||
/// Flächen-gewichteter Schwerpunkt eines einfachen Polygons (nicht der reine
|
||||
/// Vertex-Mittelwert, der bei asymmetrischen/konkaven Profilen verzerrt ist).
|
||||
/// Fallback auf Vertex-Mittelwert bei (nahezu) entarteter Fläche.
|
||||
fn polygon_centroid(ring: &[(f64, f64)]) -> (f64, f64) {
|
||||
let n = ring.len();
|
||||
let a = signed_area_f64(ring);
|
||||
if a.abs() < 1e-12 {
|
||||
let (sx, sy) = ring.iter().fold((0.0, 0.0), |(sx, sy), &(x, y)| (sx + x, sy + y));
|
||||
return (sx / n as f64, sy / n as f64);
|
||||
}
|
||||
let mut cx = 0.0;
|
||||
let mut cy = 0.0;
|
||||
for i in 0..n {
|
||||
let (x0, y0) = ring[i];
|
||||
let (x1, y1) = ring[(i + 1) % n];
|
||||
let cross = x0 * y1 - x1 * y0;
|
||||
cx += (x0 + x1) * cross;
|
||||
cy += (y0 + y1) * cross;
|
||||
}
|
||||
(cx / (6.0 * a), cy / (6.0 * a))
|
||||
}
|
||||
|
||||
/// Kreuzprodukt (b-a) x (c-a) im Grundriss.
|
||||
#[inline]
|
||||
fn cross2_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
|
||||
(b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
|
||||
}
|
||||
|
||||
/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert).
|
||||
fn point_in_tri_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64), p: (f64, f64)) -> bool {
|
||||
let d1 = cross2_f64(a, b, p);
|
||||
let d2 = cross2_f64(b, c, p);
|
||||
let d3 = cross2_f64(c, a, p);
|
||||
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
|
||||
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
|
||||
!(has_neg && has_pos)
|
||||
}
|
||||
|
||||
/// Ear-Clipping-Triangulierung eines einfachen (lochfreien) Rings — robust fuer
|
||||
/// konvexe UND konkave Profile (z. B. T-Traeger, L-Profil, Freiform). Portiert
|
||||
/// von `render3d::mesh::triangulate` (dort fuer Decken/Slabs genutzt) auf f64.
|
||||
/// O(n^2), fuer Extrusionsprofile mit wenigen Ecken voellig ausreichend.
|
||||
/// Liefert Dreiecke als (a,b,c)-Indextripel (0-basiert auf `ring`), in der
|
||||
/// Wicklung des (intern auf CCW normalisierten) Rings.
|
||||
fn ear_triangulate(ring: &[(f64, f64)]) -> Vec<(usize, usize, usize)> {
|
||||
let n = ring.len();
|
||||
if n < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut idx: Vec<usize> = (0..n).collect();
|
||||
if signed_area_f64(ring) < 0.0 {
|
||||
idx.reverse();
|
||||
}
|
||||
|
||||
let mut tris: Vec<(usize, usize, usize)> = Vec::new();
|
||||
let mut guard = 0usize;
|
||||
let max_guard = n * n + 16;
|
||||
|
||||
while idx.len() > 3 && guard < max_guard {
|
||||
guard += 1;
|
||||
let mut clipped = false;
|
||||
let m = idx.len();
|
||||
for i in 0..m {
|
||||
let i_prev = idx[(i + m - 1) % m];
|
||||
let i_cur = idx[i];
|
||||
let i_next = idx[(i + 1) % m];
|
||||
let a = ring[i_prev];
|
||||
let b = ring[i_cur];
|
||||
let c = ring[i_next];
|
||||
if cross2_f64(a, b, c) <= 0.0 {
|
||||
continue; // konkav/kollinear -> kein Ohr
|
||||
}
|
||||
let mut contains = false;
|
||||
for &vi in &idx {
|
||||
if vi == i_prev || vi == i_cur || vi == i_next {
|
||||
continue;
|
||||
}
|
||||
if point_in_tri_f64(a, b, c, ring[vi]) {
|
||||
contains = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if contains {
|
||||
continue;
|
||||
}
|
||||
tris.push((i_prev, i_cur, i_next));
|
||||
idx.remove(i);
|
||||
clipped = true;
|
||||
break;
|
||||
}
|
||||
if !clipped {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if idx.len() == 3 {
|
||||
tris.push((idx[0], idx[1], idx[2]));
|
||||
}
|
||||
tris
|
||||
}
|
||||
|
||||
/// Zylinder-Extrusion (Kreis-Querschnitt), optional verjüngt (Kegel bei
|
||||
/// `taper=1.0`). Tesselliert den Kreis in N Segmente und ruft
|
||||
/// `extrude_polygon_core` auf.
|
||||
pub fn extrude_circle_core(
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
r: f64,
|
||||
height: f64,
|
||||
taper: f64,
|
||||
) -> std::result::Result<MeshOutput, String> {
|
||||
if r <= 0.0 {
|
||||
return Err("Radius muss > 0 sein".into());
|
||||
}
|
||||
let n = ((2.0 * std::f64::consts::PI * r / 0.02).ceil() as usize).max(16);
|
||||
let pts: Vec<(f64, f64)> = (0..n)
|
||||
.map(|i| {
|
||||
let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
|
||||
(cx + r * a.cos(), cy + r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
extrude_polygon_core(&pts, height, taper)
|
||||
}
|
||||
|
||||
// ── WASM-Bindings (nur mit Feature "web") ────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
mod web {
|
||||
use super::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// Extrudiert ein Polygon-Profil.
|
||||
/// Input JSON: `{ "points": [x0,y0,…], "height": 2.5 }`
|
||||
/// Output JSON: `{ "positions": […], "indices": […] }` oder JsError.
|
||||
#[wasm_bindgen]
|
||||
pub fn extrude_polygon(input_json: &str) -> std::result::Result<String, JsError> {
|
||||
let input: ExtrudePolyInput =
|
||||
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
if input.points.len() % 2 != 0 {
|
||||
return Err(JsError::new("points muss x/y-Paare enthalten"));
|
||||
}
|
||||
let pts: Vec<(f64, f64)> = input
|
||||
.points
|
||||
.chunks(2)
|
||||
.map(|c| (c[0], c[1]))
|
||||
.collect();
|
||||
let mesh = extrude_polygon_core(&pts, input.height, input.taper)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Extrudiert einen Kreis-Querschnitt (Zylinder, oder Kegel bei `taper=1`).
|
||||
/// Input JSON: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0, "taper": 0.0 }`
|
||||
#[wasm_bindgen]
|
||||
pub fn extrude_circle(input_json: &str) -> std::result::Result<String, JsError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct In {
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
r: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
taper: f64,
|
||||
}
|
||||
let i: In =
|
||||
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height, i.taper)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Boolesche Operation zwischen zwei Dreiecks-Meshes (Mesh-Ebenen-CSG via
|
||||
/// csgrs). Input JSON: `{ "a_positions": […], "a_indices": […],
|
||||
/// "b_positions": […], "b_indices": […], "op": "union"|"difference"|"intersection" }`
|
||||
/// Output JSON: `{ "positions": […], "indices": […], "empty": bool }`
|
||||
#[wasm_bindgen]
|
||||
pub fn boolean_mesh(input_json: &str) -> std::result::Result<String, JsError> {
|
||||
let input: BooleanMeshInput =
|
||||
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let mesh = boolean_mesh_core(
|
||||
&input.a_positions,
|
||||
&input.a_indices,
|
||||
&input.b_positions,
|
||||
&input.b_indices,
|
||||
&input.op,
|
||||
)
|
||||
.map_err(|e| JsError::new(&e))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quad_extrusion_vertex_count() {
|
||||
let pts = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
|
||||
let m = extrude_polygon_core(&pts, 2.0, 0.0).unwrap();
|
||||
// 2 * 4 = 8 Vertices → 24 Floats
|
||||
assert_eq!(m.positions.len(), 8 * 3, "Vertex-Anzahl");
|
||||
assert_eq!(m.indices.len() % 3, 0, "unvollständige Dreiecke");
|
||||
// Quader: 2*(4-2) + 4*2 = 4 + 8 = 12 Dreiecke
|
||||
assert_eq!(m.indices.len(), 12 * 3, "Dreieck-Anzahl für Quader");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l_profile_extrusion() {
|
||||
let pts = vec![
|
||||
(0.0, 0.0),
|
||||
(0.3, 0.0),
|
||||
(0.3, 0.1),
|
||||
(0.1, 0.1),
|
||||
(0.1, 0.3),
|
||||
(0.0, 0.3),
|
||||
];
|
||||
let m = extrude_polygon_core(&pts, 3.0, 0.0).unwrap();
|
||||
assert_eq!(m.indices.len() % 3, 0);
|
||||
assert!(!m.positions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_extrusion() {
|
||||
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0, 0.0).unwrap();
|
||||
assert_eq!(m.indices.len() % 3, 0);
|
||||
assert!(!m.positions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_few_points() {
|
||||
assert!(extrude_polygon_core(&[(0.0, 0.0), (1.0, 0.0)], 1.0, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_height() {
|
||||
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
|
||||
assert!(extrude_polygon_core(&pts, 0.0, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_taper_out_of_range() {
|
||||
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
|
||||
assert!(extrude_polygon_core(&pts, 1.0, 1.5).is_err());
|
||||
assert!(extrude_polygon_core(&pts, 1.0, -0.1).is_err());
|
||||
}
|
||||
|
||||
/// Kegel-Volumen (taper=1, Kreis-Querschnitt) via Divergenzsatz gegen die
|
||||
/// analytische Formel V = π·r²·h/3 geprüft.
|
||||
#[test]
|
||||
fn cone_taper_one_has_correct_volume() {
|
||||
let r = 0.5;
|
||||
let h = 2.0;
|
||||
let m = extrude_circle_core(0.0, 0.0, r, h, 1.0).unwrap();
|
||||
// Spitze ist der letzte Vertex — Boden-Vertices sind alle bei z=0, kein
|
||||
// separater Deck-Ring mehr (kein Nulldreieck-Risiko).
|
||||
assert_eq!(m.positions.len() % 3, 0);
|
||||
let n_verts = m.positions.len() / 3;
|
||||
// Boden-N-Eck + 1 Spitze.
|
||||
let apex_z = m.positions[(n_verts - 1) * 3 + 2];
|
||||
assert!((apex_z as f64 - h).abs() < 1e-6, "Spitze muss bei z=height liegen");
|
||||
|
||||
let mut vol = 0.0f64;
|
||||
for tri in m.indices.chunks(3) {
|
||||
let mut p = [[0.0f64; 3]; 3];
|
||||
for (k, &i) in tri.iter().enumerate() {
|
||||
let o = i as usize * 3;
|
||||
p[k] = [m.positions[o] as f64, m.positions[o + 1] as f64, m.positions[o + 2] as f64];
|
||||
}
|
||||
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
|
||||
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
|
||||
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
|
||||
/ 6.0;
|
||||
}
|
||||
let expected = std::f64::consts::PI * r * r * h / 3.0;
|
||||
// Kreis ist n-eck-tesselliert (nicht analytisch exakt) → grobe Toleranz.
|
||||
assert!((vol.abs() - expected).abs() / expected < 0.01, "Kegel-Volumen: {} vs {}", vol.abs(), expected);
|
||||
}
|
||||
|
||||
/// Pyramidenstumpf (taper=0.5, Quadrat-Querschnitt): Deckfläche muss auf
|
||||
/// die Hälfte der Kantenlänge geschrumpft sein, zentriert um den
|
||||
/// Schwerpunkt der Grundfläche.
|
||||
#[test]
|
||||
fn frustum_taper_half_shrinks_top_toward_centroid() {
|
||||
let pts = vec![(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)];
|
||||
let m = extrude_polygon_core(&pts, 1.0, 0.5).unwrap();
|
||||
// Prisma-Topologie bleibt erhalten (2n Vertices, kein Spitzen-Sonderfall).
|
||||
assert_eq!(m.positions.len(), 8 * 3);
|
||||
// Deck-Ring: Vertices 4..7. Schwerpunkt der Grundfläche ist (1,1).
|
||||
// scale=0.5 ⇒ Ecke (0,0)→(1,1)+0.5*((0,0)-(1,1))=(0.5,0.5).
|
||||
let top0 = (m.positions[4 * 3] as f64, m.positions[4 * 3 + 1] as f64);
|
||||
assert!((top0.0 - 0.5).abs() < 1e-6 && (top0.1 - 0.5).abs() < 1e-6, "Deck-Ecke: {:?}", top0);
|
||||
}
|
||||
|
||||
/// Punkt-in-Polygon (Ray-Casting) für die Korrektheits-Prüfung unten.
|
||||
fn point_in_polygon(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
|
||||
let n = poly.len();
|
||||
let mut inside = false;
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let (xi, yi) = poly[i];
|
||||
let (xj, yj) = poly[j];
|
||||
if ((yi > p.1) != (yj > p.1)) && (p.0 < (xj - xi) * (p.1 - yi) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// T-Träger-Querschnitt (konkav, NICHT von Vertex 0 aus sternförmig) —
|
||||
/// genau das Zielprofil aus PENDENZEN/truck-plan.md. Eine Fächer-Triangu-
|
||||
/// lierung ab Vertex 0 erzeugt hier Phantom-Dreiecke quer durch die
|
||||
/// konkave Kerbe (nachgerechnet: 2 von 6 Fächer-Dreiecken liegen mit ihrem
|
||||
/// Schwerpunkt ausserhalb des Profils). Regressionstest fürs Ohr-Clipping.
|
||||
#[test]
|
||||
fn t_beam_caps_stay_inside_polygon() {
|
||||
let pts = vec![
|
||||
(1.0, 0.0),
|
||||
(2.0, 0.0),
|
||||
(2.0, 1.0),
|
||||
(3.0, 1.0),
|
||||
(3.0, 2.0),
|
||||
(0.0, 2.0),
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
];
|
||||
let m = extrude_polygon_core(&pts, 1.0, 0.0).unwrap();
|
||||
let mut checked = 0;
|
||||
for tri in m.indices.chunks(3) {
|
||||
let p: Vec<[f32; 3]> = tri
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let o = i as usize * 3;
|
||||
[m.positions[o], m.positions[o + 1], m.positions[o + 2]]
|
||||
})
|
||||
.collect();
|
||||
// Nur Deckel/Boden (alle 3 Ecken auf derselben Höhe) prüfen, nicht
|
||||
// die Seitenwand-Quads (die spannen z zwischen 0 und height).
|
||||
if (p[0][2] - p[1][2]).abs() > 1e-6 || (p[1][2] - p[2][2]).abs() > 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let centroid = (
|
||||
(p[0][0] + p[1][0] + p[2][0]) as f64 / 3.0,
|
||||
(p[0][1] + p[1][1] + p[2][1]) as f64 / 3.0,
|
||||
);
|
||||
assert!(
|
||||
point_in_polygon(centroid, &pts),
|
||||
"Deckel-/Boden-Dreieck-Schwerpunkt {:?} liegt ausserhalb des T-Profils",
|
||||
centroid
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 0, "kein Deckel-/Boden-Dreieck gefunden");
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// Arc — Mittelpunkt + Startpunkt + Endpunkt. Schritte:
|
||||
// 1) „Mittelpunkt:" → Punkt
|
||||
// 2) „Startpunkt:" → Punkt (setzt Radius r = |P1−Center| UND Startwinkel a0)
|
||||
// 3) „Endpunkt:" → Punkt (setzt Endwinkel a1; Radius bleibt) → commit
|
||||
//
|
||||
// Der Bogen läuft CCW von a0 nach a1 (wie DXF-ARC und das `{shape:"arc"}`-
|
||||
// Rendering in PlanView). Der Endpunkt wird nur für seinen WINKEL genutzt (auf
|
||||
// den Kreis mit Radius r projiziert). In generatePlan/PlanView als glatter
|
||||
// SVG-Bogen (`drawingArc`) gerendert.
|
||||
|
||||
import type { Drawing2D } from "../../model/types";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
interface ArcCenter extends CommandState {
|
||||
phase: "center";
|
||||
}
|
||||
interface ArcStart extends CommandState {
|
||||
phase: "start";
|
||||
center: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
interface ArcEnd extends CommandState {
|
||||
phase: "end";
|
||||
center: Vec2;
|
||||
r: number;
|
||||
a0: number;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type ArcState = ArcCenter | ArcStart | ArcEnd;
|
||||
|
||||
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const angle = (c: Vec2, p: Vec2): number => Math.atan2(p.y - c.y, p.x - c.x);
|
||||
|
||||
/** CCW-Spanne von a0 nach a1, normiert auf (0, 2π]. */
|
||||
function sweepOf(a0: number, a1: number): number {
|
||||
const TAU = Math.PI * 2;
|
||||
const s = ((a1 - a0) % TAU + TAU) % TAU;
|
||||
return s < EPS ? TAU : s;
|
||||
}
|
||||
|
||||
/** Punkte eines Bogens (CCW a0→a1) für die Vorschau. */
|
||||
function arcPts(center: Vec2, r: number, a0: number, a1: number): Vec2[] {
|
||||
const sweep = sweepOf(a0, a1);
|
||||
const N = Math.max(2, Math.ceil(sweep / (Math.PI / 32)));
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = a0 + (sweep * i) / N;
|
||||
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Punkte einer vollen Kreis-Approximation (Radius-Vorschau im „start"-Schritt). */
|
||||
function circlePts(center: Vec2, r: number): Vec2[] {
|
||||
const N = 48;
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t = (i / N) * Math.PI * 2;
|
||||
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Vorschau (offener Bogen) + HUD (Radius als L, Spanne als W — L/W-Kästchen). */
|
||||
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
|
||||
if (r < EPS) return { preview: [], vertices: [center] };
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
|
||||
const draft: ToolDraft = { preview, vertices: [center] };
|
||||
if (at) {
|
||||
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
|
||||
draft.hud = { at, length: r, angleDeg: deg };
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function appendArc(p: Project, center: Vec2, r: number, a0: number, a1: number, ctx: CommandContext): Project {
|
||||
if (r < EPS) return p;
|
||||
const d: Drawing2D = {
|
||||
id: uniqueId("dr2d"),
|
||||
type: "drawing2d",
|
||||
levelId: ctx.level.id,
|
||||
categoryCode: ctx.defaultCategoryCode,
|
||||
geom: { shape: "arc", center, r, a0, a1 },
|
||||
};
|
||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "center", lastPoint: null },
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const arcCommand: Command = {
|
||||
name: "arc",
|
||||
labelKey: "cmd.arc.label",
|
||||
prompt: (s) => {
|
||||
const phase = (s as ArcState).phase;
|
||||
return phase === "start" ? "cmd.arc.start" : phase === "end" ? "cmd.arc.end" : "cmd.arc.center";
|
||||
},
|
||||
accepts: () => ["point"],
|
||||
options: () => [],
|
||||
init: (): ArcCenter => ({ phase: "center", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as ArcState;
|
||||
if (input.kind !== "point") return [s, { draft: null }];
|
||||
const pt = input.point;
|
||||
if (s.phase === "center") {
|
||||
const ns: ArcStart = { phase: "start", center: pt, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: { preview: [], vertices: [pt] } }];
|
||||
}
|
||||
if (s.phase === "start") {
|
||||
const r = dist(s.center, pt);
|
||||
if (r < EPS) return [s, { draft: null }];
|
||||
const a0 = angle(s.center, pt);
|
||||
const ns: ArcEnd = { phase: "end", center: s.center, r, a0, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: arcDraft(s.center, r, a0, a0, pt) }];
|
||||
}
|
||||
// Endpunkt: Endwinkel setzen, Bogen committen.
|
||||
const a1 = angle(s.center, pt);
|
||||
const { center, r, a0 } = s;
|
||||
return [
|
||||
{ phase: "center", lastPoint: null },
|
||||
{ draft: null, done: true, commit: (p) => appendArc(p, center, r, a0, a1, ctx) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as ArcState;
|
||||
if (s.phase === "start") {
|
||||
const r = dist(s.center, point);
|
||||
const ns: ArcStart = { ...s, cursor: point };
|
||||
return [
|
||||
ns,
|
||||
{
|
||||
draft: {
|
||||
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
|
||||
vertices: [s.center],
|
||||
hud: { at: point, text: `R: ${r.toFixed(3)}m` },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (s.phase === "end") {
|
||||
const a1 = angle(s.center, point);
|
||||
const ns: ArcEnd = { ...s, cursor: point };
|
||||
return [ns, { draft: arcDraft(s.center, s.r, s.a0, a1, point) }];
|
||||
}
|
||||
return [s, { draft: null }];
|
||||
},
|
||||
|
||||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* `arrayCommand` — dupliziert die Auswahl `count`-mal entlang eines Vektors
|
||||
* in einer Geste. Baut auf dem bestehenden `mode:"array"` von
|
||||
* `tools/transform.ts` auf (bisher ohne eigenen Befehl erreichbar).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { arrayCommand } from "./array";
|
||||
import type { CommandContext, Project } from "../types";
|
||||
import type { Drawing2D } from "../../model/types";
|
||||
|
||||
function project(extra: Drawing2D[] = []): 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: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||
walls: [],
|
||||
doors: [],
|
||||
openings: [],
|
||||
ceilings: [],
|
||||
stairs: [],
|
||||
rooms: [],
|
||||
drawings2d: extra,
|
||||
context: [],
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(p: Project, drawingId: string | null): CommandContext {
|
||||
return {
|
||||
project: p,
|
||||
level: p.drawingLevels[0],
|
||||
defaultCategoryCode: "20",
|
||||
activeLineStyleId: "solid",
|
||||
activeWallTypeId: "aw",
|
||||
lastPoint: null,
|
||||
selection: { wallIds: [], drawingId },
|
||||
};
|
||||
}
|
||||
|
||||
const source: Drawing2D = {
|
||||
id: "src",
|
||||
type: "drawing2d",
|
||||
levelId: "eg",
|
||||
categoryCode: "20",
|
||||
geom: { shape: "circle", center: { x: 0, y: 0 }, r: 1 },
|
||||
};
|
||||
|
||||
describe("arrayCommand", () => {
|
||||
it("4 Kopien entlang +x im Abstand 3m (Anzahl per Option auf 4 gesetzt)", () => {
|
||||
const p = project([source]);
|
||||
const ctx = makeCtx(p, "src");
|
||||
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||
// Anzahl-Option 3× drücken: 3(default egal)→…→4 (zyklt 2..12, wir setzen sie
|
||||
// deterministisch, indem wir bis 4 zyklen unabhängig vom Startwert testen wir
|
||||
// stattdessen direkt über den committeten Output).
|
||||
for (let i = 0; i < 12; i++) {
|
||||
[state] = arrayCommand.onInput(state, { kind: "option", id: "count" }, ctx);
|
||||
const opts = arrayCommand.options(state);
|
||||
if (opts[0].value === "4") break;
|
||||
}
|
||||
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 3, y: 0 } }, ctx);
|
||||
expect(result.commit).toBeDefined();
|
||||
const next = result.commit!(p);
|
||||
// Original bleibt + 4 neue Kopien.
|
||||
expect(next.drawings2d.length).toBe(5);
|
||||
const centers = next.drawings2d
|
||||
.map((d) => d.geom)
|
||||
.filter((g) => g.shape === "circle")
|
||||
.map((g) => (g as { center: { x: number; y: number } }).center.x)
|
||||
.sort((a, b) => a - b);
|
||||
expect(centers).toEqual([0, 3, 6, 9, 12]);
|
||||
});
|
||||
|
||||
it("leere Auswahl committet nichts", () => {
|
||||
const p = project();
|
||||
const ctx = makeCtx(p, null);
|
||||
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx);
|
||||
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 5, y: 0 } }, ctx);
|
||||
expect(result.commit).toBeUndefined();
|
||||
expect(result.done).toBe(true);
|
||||
});
|
||||
|
||||
it("Zielpunkt gleich Basispunkt (Nullvektor) committet nichts", () => {
|
||||
const p = project([source]);
|
||||
const ctx = makeCtx(p, "src");
|
||||
let [state] = arrayCommand.onInput(arrayCommand.init(), { kind: "point", point: { x: 2, y: 2 } }, ctx);
|
||||
const [, result] = arrayCommand.onInput(state, { kind: "point", point: { x: 2, y: 2 } }, ctx);
|
||||
expect(result.commit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
// Array (Feld) — dupliziert die AKTUELLE Auswahl `count`-mal entlang eines
|
||||
// Vektors, in EINER Geste (baut auf dem bestehenden Transform-Modus
|
||||
// `"array"` auf, der bisher nie über einen Befehl erreichbar war — s.
|
||||
// `tools/transform.ts::copyTs`). Schritte:
|
||||
// 1) „Basispunkt:" → Punkt
|
||||
// 2) „Zielpunkt ( Anzahl ):" → Punkt/Zahl (Tab length/angle) → committet
|
||||
// ALLE `count` Kopien auf einmal (Original bleibt). „Anzahl" (Option,
|
||||
// persistent über Aufrufe wie bei Multiline) zyklt 2..12.
|
||||
//
|
||||
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl,
|
||||
// wie move/copy). Leere Auswahl → No-op.
|
||||
|
||||
import { commitTransform, transformPreview } from "../../tools/transform";
|
||||
import type { TransformSelection } from "../../tools/transform";
|
||||
import type {
|
||||
CmdOption,
|
||||
Command,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandSelection,
|
||||
CommandState,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const MIN_COUNT = 2;
|
||||
const MAX_COUNT = 12;
|
||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
|
||||
/** Persistente Anzahl über Aufrufe hinweg (wie `lastCount` bei Multiline). */
|
||||
let lastCount = 3;
|
||||
|
||||
const ARRAY_FIELDS: CommandField[] = [
|
||||
{ id: "length", labelKey: "cmd.field.length" },
|
||||
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||
];
|
||||
const COUNT_OPTION: CmdOption = { id: "count", labelKey: "cmd.array.count" };
|
||||
|
||||
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||
const ref = cursor ?? base;
|
||||
const length = "length" in locks ? locks.length : segLen(base, ref);
|
||||
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
|
||||
const ang = angleDeg * DEG;
|
||||
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
|
||||
}
|
||||
|
||||
function hasSelection(sel: CommandSelection): boolean {
|
||||
return (
|
||||
sel.wallIds.length > 0 ||
|
||||
sel.drawingId !== null ||
|
||||
(sel.drawingIds?.length ?? 0) > 0 ||
|
||||
!!sel.extrudedSolidId ||
|
||||
!!sel.columnId
|
||||
);
|
||||
}
|
||||
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||
return {
|
||||
wallIds: sel.wallIds,
|
||||
drawingId: sel.drawingId,
|
||||
drawingIds: sel.drawingIds,
|
||||
extrudedSolidId: sel.extrudedSolidId,
|
||||
columnId: sel.columnId,
|
||||
};
|
||||
}
|
||||
|
||||
interface ArrBase extends CommandState {
|
||||
phase: "base";
|
||||
}
|
||||
interface ArrTarget extends CommandState {
|
||||
phase: "target";
|
||||
sel: CommandSelection;
|
||||
base: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type ArrState = ArrBase | ArrTarget;
|
||||
|
||||
/** Vorschau ALLER `lastCount` Kopien bei der aktuellen Cursor-Lage. */
|
||||
function arrayDraft(project: Project, sel: CommandSelection, base: Vec2, target: Vec2): ToolDraft {
|
||||
const preview = transformPreview(project, toTransformSel(sel), "move", [base, target], "array", lastCount);
|
||||
return {
|
||||
preview,
|
||||
vertices: [base],
|
||||
hud: {
|
||||
at: target,
|
||||
text: `${segLen(base, target).toFixed(2)} m × ${lastCount}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "base", lastPoint: null } as ArrBase,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const arrayCommand: Command = {
|
||||
name: "array",
|
||||
labelKey: "cmd.array.label",
|
||||
prompt: (s) => ((s as ArrState).phase === "target" ? "cmd.array.target" : "cmd.array.base"),
|
||||
accepts: (s) => ((s as ArrState).phase === "target" ? ["point", "number", "option"] : ["point"]),
|
||||
options: (s) =>
|
||||
(s as ArrState).phase === "target" ? [{ ...COUNT_OPTION, value: String(lastCount) }] : [],
|
||||
init: (): ArrBase => ({ phase: "base", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as ArrState;
|
||||
if (!hasSelection(ctx.selection)) return idle();
|
||||
|
||||
if (s.phase !== "target") {
|
||||
if (input.kind !== "point") return [s, { draft: null }];
|
||||
const next: ArrTarget = {
|
||||
phase: "target",
|
||||
sel: ctx.selection,
|
||||
base: input.point,
|
||||
cursor: input.point,
|
||||
lastPoint: input.point,
|
||||
};
|
||||
return [next, { draft: { preview: [], vertices: [input.point] } }];
|
||||
}
|
||||
|
||||
if (input.kind === "option") {
|
||||
if (input.id === "count") {
|
||||
lastCount = lastCount >= MAX_COUNT ? MIN_COUNT : lastCount + 1;
|
||||
return [s, { draft: s.cursor ? arrayDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
|
||||
}
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
|
||||
let target: Vec2 | null = null;
|
||||
if (input.kind === "point") target = input.point;
|
||||
else if (input.kind === "number") target = { x: s.base.x + input.value, y: s.base.y };
|
||||
if (!target || segLen(s.base, target) < 1e-6) return idle();
|
||||
|
||||
const { base, sel } = s;
|
||||
return [
|
||||
idle()[0],
|
||||
{ draft: null, done: true, commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, target!], "array", lastCount) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as ArrState;
|
||||
if (s.phase !== "target") return [s, { draft: null }];
|
||||
const ns: ArrTarget = { ...s, cursor: point };
|
||||
return [ns, { draft: arrayDraft(ctx.project, s.sel, s.base, point) }];
|
||||
},
|
||||
|
||||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
|
||||
fields: (state) => ((state as ArrState).phase === "target" ? ARRAY_FIELDS : []),
|
||||
pointFromFields: (state, locks, cursor) => {
|
||||
const s = state as ArrState;
|
||||
if (s.phase !== "target") return null;
|
||||
return targetFromFields(s.base, locks, cursor);
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as ArrState;
|
||||
if (s.phase !== "target" || !cursor) return {};
|
||||
return {
|
||||
length: segLen(s.base, cursor),
|
||||
angle: ((segAngleDeg(s.base, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
},
|
||||
};
|
||||