Compare commits
97 Commits
3402e6ac96
...
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 |
+89
-968
File diff suppressed because it is too large
Load Diff
@@ -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,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,93 @@
|
||||
Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Jost Project Authors (https://github.com/indestructible-type)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2019 The Karla Project Authors (https://github.com/googlefonts/karla)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 The Public Sans Project Authors (https://github.com/uswds/public-sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The Source Serif 4 Project Authors (https://github.com/adobe-fonts/source-serif)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,10 +4,14 @@
|
||||
// (render2d-WASM-Engine, ?engine=wasm) zuverlässig läuft. Kein Rust-Backend
|
||||
// nötig: compute_joins hat einen TS-Fallback (src/compute/index.ts).
|
||||
|
||||
const { app, BrowserWindow, Menu } = require("electron");
|
||||
const { app, BrowserWindow, Menu, ipcMain } = require("electron");
|
||||
const path = require("node:path");
|
||||
|
||||
// Randloses App-Fenster wie chromium-shell.sh (--app=…): keine native
|
||||
// Menüleiste (File/Edit/View/Window), kein Fensterrahmen.
|
||||
// Menüleiste (File/Edit/View/Window), kein Fensterrahmen. Minimieren/
|
||||
// Maximieren/Schliessen laufen darum über eine eigene Fenstersteuerung im
|
||||
// Renderer (WindowControls.tsx) + IPC hier unten — ohne frame gibt es sonst
|
||||
// gar keine Möglichkeit, das Fenster zu steuern.
|
||||
Menu.setApplicationMenu(null);
|
||||
|
||||
// Gleiche Flags wie scripts/chromium-shell.sh — WebGPU liegt unter Linux
|
||||
@@ -27,9 +31,16 @@ function createWindow() {
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, "electron-preload.cjs"),
|
||||
},
|
||||
});
|
||||
|
||||
const notifyMaximized = () => {
|
||||
win.webContents.send("window:maximized-changed", win.isMaximized());
|
||||
};
|
||||
win.on("maximize", notifyMaximized);
|
||||
win.on("unmaximize", notifyMaximized);
|
||||
|
||||
if (isDev) {
|
||||
win.loadURL(DEV_URL);
|
||||
} else {
|
||||
@@ -37,6 +48,24 @@ function createWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fenstersteuerung vom Renderer (WindowControls.tsx über electron-preload.cjs).
|
||||
// Wirkt auf das Fenster, aus dem das Event kam (mehrfachfenster-sicher).
|
||||
ipcMain.on("window:minimize", (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize();
|
||||
});
|
||||
ipcMain.on("window:toggle-maximize", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
if (win.isMaximized()) win.unmaximize();
|
||||
else win.maximize();
|
||||
});
|
||||
ipcMain.on("window:close", (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.close();
|
||||
});
|
||||
ipcMain.handle("window:is-maximized", (event) => {
|
||||
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false;
|
||||
});
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// electron-preload.cjs — Bruecke zwischen dem isolierten Renderer (contextIsolation)
|
||||
// und dem Electron-Hauptprozess. Braucht es NUR fuer die custom Fenstersteuerung
|
||||
// (Minimieren/Maximieren/Schliessen) im randlosen Fenster (frame:false, siehe
|
||||
// electron-main.cjs) — sonst gaebe es keine Moeglichkeit, das Fenster ueberhaupt
|
||||
// zu steuern.
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("dossierWindow", {
|
||||
minimize: () => ipcRenderer.send("window:minimize"),
|
||||
toggleMaximize: () => ipcRenderer.send("window:toggle-maximize"),
|
||||
close: () => ipcRenderer.send("window:close"),
|
||||
isMaximized: () => ipcRenderer.invoke("window:is-maximized"),
|
||||
onMaximizedChange: (callback) => {
|
||||
const listener = (_event, isMaximized) => callback(isMaximized);
|
||||
ipcRenderer.on("window:maximized-changed", listener);
|
||||
return () => ipcRenderer.removeListener("window:maximized-changed", listener);
|
||||
},
|
||||
});
|
||||
+577
-21
@@ -22,6 +22,21 @@ pub struct Line {
|
||||
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,
|
||||
@@ -30,6 +45,8 @@ pub struct WallInput {
|
||||
pub thickness: f64,
|
||||
#[serde(rename = "referenceOffset")]
|
||||
pub reference_offset: f64,
|
||||
#[serde(default)]
|
||||
pub layers: Vec<LayerInput>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -37,6 +54,19 @@ 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 {
|
||||
@@ -46,6 +76,141 @@ pub struct WallCuts {
|
||||
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 -----------------------------------------------------------
|
||||
@@ -82,6 +247,11 @@ 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> {
|
||||
@@ -119,8 +289,10 @@ fn dir_of(w: &WallInput) -> Vec2 {
|
||||
|
||||
/**
|
||||
* Berechnet fuer jede Wand die Gehrungs-Schnittlinien.
|
||||
* Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie
|
||||
* Enden und T-/X-Stoesse bleiben rechtwinklig.
|
||||
* 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;
|
||||
@@ -132,6 +304,7 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
|
||||
wall_id: w.id.clone(),
|
||||
start_cut: None,
|
||||
end_cut: None,
|
||||
layer_cuts: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -162,15 +335,13 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
|
||||
}
|
||||
|
||||
for (_key, ends) in &junctions {
|
||||
// Freies Ende -> kein Schnitt.
|
||||
// Freies Ende -> kein Schnitt hier (wird unten separat auf Mittelspannen-T
|
||||
// geprueft, Fall 2).
|
||||
if ends.len() == 1 {
|
||||
continue;
|
||||
}
|
||||
// T-/X-Stoesse (>2 Enden): vorerst rechtwinklig lassen.
|
||||
if ends.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ends.len() == 2 {
|
||||
let a_idx = match index.get(ends[0].wall_id.as_str()) {
|
||||
Some(i) => *i,
|
||||
None => continue,
|
||||
@@ -189,6 +360,26 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
|
||||
|
||||
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
|
||||
@@ -202,11 +393,204 @@ fn set_cut(cuts: &mut WallCuts, end: WallEndKind, cut: Line) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 und ungleiche Dicken: A's Aussenflaeche wird
|
||||
* mit der NAECHSTGELEGENEN Flaeche von B verschnitten, A's Innenflaeche mit der
|
||||
* jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung.
|
||||
* 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).
|
||||
*/
|
||||
@@ -235,11 +619,29 @@ fn miter_line(a: &WallInput, a_end: WallEndKind, b: &WallInput) -> Option<Line>
|
||||
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));
|
||||
|
||||
// Fuer A's linke Flaeche die naehere B-Flaeche waehlen; A's rechte die andere.
|
||||
let lb_closer = len(sub(p_la, p_lb)) <= len(sub(p_la, p_rb));
|
||||
let b_for_left = if lb_closer { p_lb } else { p_rb };
|
||||
let b_for_right = if lb_closer { p_rb } else { p_lb };
|
||||
// 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)?;
|
||||
|
||||
@@ -263,6 +665,7 @@ mod tests {
|
||||
end: Vec2 { x: ex, y: ey },
|
||||
thickness,
|
||||
reference_offset: 0.0,
|
||||
layers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +709,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -319,8 +750,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_junction_no_cut() {
|
||||
// Drei Enden treffen sich in (5,0): rechtwinklig lassen.
|
||||
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),
|
||||
@@ -330,11 +765,21 @@ mod tests {
|
||||
};
|
||||
let out = compute_joins(input);
|
||||
assert_eq!(out.len(), 3);
|
||||
for id in ["A", "B", "C"] {
|
||||
let c = find(&out, id);
|
||||
assert!(c.start_cut.is_none(), "{id} startCut none");
|
||||
assert!(c.end_cut.is_none(), "{id} endCut none");
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -354,4 +799,115 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ struct Globals {
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position : vec2<f32>, // Bildschirm-Raum-Endpunkt
|
||||
@location(1) bisector : vec2<f32>, // Bildschirm-Raum-Miter-Bisektor (Einheit)
|
||||
@location(1) bisector : vec2<f32>, // Bildschirm-Raum-Versatzrichtung (Einheit; Normale bei Quads, Radialrichtung bei Rundkappen/-ecken)
|
||||
@location(2) side : f32, // +1.0 oder -1.0
|
||||
@location(3) miter : f32, // 1/cos(theta/2)-Laengenfaktor (Gehrung)
|
||||
};
|
||||
|
||||
@@ -252,22 +252,25 @@ fn cross(a: Point, b: Point, c: Point) -> f32 {
|
||||
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||||
}
|
||||
|
||||
/// Maximaler Miter-Laengenfaktor; darueber wird geklemmt (kein Spike an spitzen Ecken).
|
||||
const MITER_LIMIT: f32 = 8.0;
|
||||
|
||||
/// Einheits-Links-Normale von `from` nach `to` (Bildschirm-Raum); None bei Nulllaenge.
|
||||
/// Einheits-Richtung von `from` nach `to` (Bildschirm-Raum); None bei Nulllaenge.
|
||||
#[inline]
|
||||
fn left_normal(from: Point, to: Point) -> Option<Point> {
|
||||
fn unit_dir(from: Point, to: Point) -> Option<Point> {
|
||||
let dx = to[0] - from[0];
|
||||
let dy = to[1] - from[1];
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len < 1e-6 {
|
||||
if len < 1e-9 {
|
||||
None
|
||||
} else {
|
||||
Some([-dy / len, dx / len])
|
||||
Some([dx / len, dy / len])
|
||||
}
|
||||
}
|
||||
|
||||
/// 90-Grad-Linksdrehung einer Einheits-Richtung -> Einheits-Links-Normale.
|
||||
#[inline]
|
||||
fn left_normal_of(d: Point) -> Point {
|
||||
[-d[1], d[0]]
|
||||
}
|
||||
|
||||
/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert).
|
||||
fn point_in_tri(a: Point, b: Point, c: Point, p: Point) -> bool {
|
||||
let d1 = cross(a, b, p);
|
||||
@@ -390,7 +393,10 @@ pub struct GpuGeometry {
|
||||
pub fill_idx: Vec<u32>,
|
||||
pub fill_batches: Vec<FillBatch>,
|
||||
/// Linien-Vertices, interleaved [x,y, bx,by, side, miter] in Bildschirm-Raum
|
||||
/// (bx,by = Miter-Bisektor, miter = 1/cos(theta/2)-Laengenfaktor).
|
||||
/// (bx,by = Einheits-Versatzrichtung — Segment-Normale bei Quad-Raendern,
|
||||
/// Radialrichtung bei Rundkappen-/Rundecken-Faechern; side = 0 im Faecher-
|
||||
/// Zentrum, sonst +-1; miter ist stets 1 — die Strichbreite wird vollstaendig
|
||||
/// im Vertex-Shader angewandt, s. `shaders.rs`).
|
||||
pub line_verts: Vec<f32>,
|
||||
pub line_idx: Vec<u32>,
|
||||
pub line_batches: Vec<LineBatch>,
|
||||
@@ -461,11 +467,23 @@ impl GpuGeometry {
|
||||
.push((BatchKind::Line, (self.line_batches.len() - 1) as u32));
|
||||
}
|
||||
|
||||
/// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) als EINEN
|
||||
/// gehrten Streifen: an jedem Stuetzpunkt wird der Versatz entlang des Miter-
|
||||
/// Bisektors verlaengert (1/cos(theta/2)), sodass benachbarte Segmente buendig
|
||||
/// verschmelzen -> gehrte Ecke statt Butt-Cap-Stufe. `closed` schliesst den Ring
|
||||
/// (letzter<->erster Punkt). Vertex-Layout: [x,y, bx,by, side, miter] (6 floats).
|
||||
/// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) mit ECHTEN
|
||||
/// RUNDEN Kappen/Ecken (wie SVG `stroke-linecap/linejoin: round` und der
|
||||
/// Vektor-PDF-Pfad) statt eckigem Butt-Cap/Gehrung:
|
||||
/// - jedes Segment ist ein eigenstaendiges Quad mit BUTT-Enden (eigene
|
||||
/// Segment-Normale, keine Miter-Verlaengerung);
|
||||
/// - an jedem inneren Stuetzpunkt fuellt ein Dreiecksfaecher (Radius = halbe
|
||||
/// Strichbreite) die Aussenseite der Ecke rund auf (Innenseite ueberlappt
|
||||
/// unsichtbar, wie bei jedem Disjoint-Segment-Linienbreiten-Ansatz);
|
||||
/// - an offenen Enden (nicht `closed`) sitzt ein Halbkreis-Faecher als runde
|
||||
/// Kappe.
|
||||
/// Die tatsaechliche Pixel-Breite bleibt bildschirmkonstant: alle Faecher-/
|
||||
/// Quad-Vertices tragen nur eine EINHEITS-Richtung (`bx,by`) + `side`-Skalar;
|
||||
/// der Vertex-Shader multipliziert im Clip-Raum mit der aktuellen
|
||||
/// Strichbreite (`stroke_px*stroke_scale`) — die Tessellierung selbst kennt
|
||||
/// keine Pixelmasse. `closed` schliesst den Ring (letzter<->erster Punkt).
|
||||
/// Vertex-Layout unveraendert: [x,y, bx,by, side, miter] (miter bleibt hier
|
||||
/// immer 1, s. Shader-Vertrag in `shaders.rs`).
|
||||
///
|
||||
/// 1:1-Port von `strokePolyline` in `src/plan/glPlan/glPlanCompile.ts`.
|
||||
fn stroke_polyline(
|
||||
@@ -501,69 +519,135 @@ impl GpuGeometry {
|
||||
return;
|
||||
}
|
||||
|
||||
let base = (self.line_verts.len() / 6) as u32;
|
||||
for i in 0..k {
|
||||
let has_in = closed || i > 0;
|
||||
let has_out = closed || i < k - 1;
|
||||
let n_in = if has_in {
|
||||
left_normal(s[(i + k - 1) % k], s[i])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let n_out = if has_out {
|
||||
left_normal(s[i], s[(i + 1) % k])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let start_idx_len = self.line_idx.len();
|
||||
|
||||
let (bx, by, miter): (f32, f32, f32);
|
||||
match (n_in, n_out) {
|
||||
(Some(a), Some(b)) => {
|
||||
let sx = a[0] + b[0];
|
||||
let sy = a[1] + b[1];
|
||||
let slen = (sx * sx + sy * sy).sqrt();
|
||||
if slen < 1e-3 {
|
||||
// ~180-Grad-Umkehr -> kein sinnvoller Bisektor, gerade weiter.
|
||||
bx = b[0];
|
||||
by = b[1];
|
||||
miter = 1.0;
|
||||
} else {
|
||||
bx = sx / slen;
|
||||
by = sy / slen;
|
||||
let denom = bx * b[0] + by * b[1]; // cos(theta/2)
|
||||
miter = if denom > 1e-3 {
|
||||
(1.0 / denom).min(MITER_LIMIT)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
}
|
||||
(Some(n), None) | (None, Some(n)) => {
|
||||
bx = n[0];
|
||||
by = n[1];
|
||||
miter = 1.0;
|
||||
}
|
||||
(None, None) => {
|
||||
// Bei k>=2 unerreichbar; sicherheitshalber gerade lassen.
|
||||
bx = 0.0;
|
||||
by = 0.0;
|
||||
miter = 1.0;
|
||||
}
|
||||
}
|
||||
self.line_verts
|
||||
.extend_from_slice(&[s[i][0], s[i][1], bx, by, 1.0, miter]);
|
||||
self.line_verts
|
||||
.extend_from_slice(&[s[i][0], s[i][1], bx, by, -1.0, miter]);
|
||||
// Vertex fuer Quad-Rand ODER Faecher (Zentrum bei side=0, Rand bei side=1)
|
||||
// anhaengen; gibt den neuen Index zurueck.
|
||||
#[inline]
|
||||
fn push_vert(verts: &mut Vec<f32>, p: Point, n: Point, side: f32) -> u32 {
|
||||
verts.extend_from_slice(&[p[0], p[1], n[0], n[1], side, 1.0]);
|
||||
(verts.len() / 6 - 1) as u32
|
||||
}
|
||||
|
||||
let segs = if closed { k } else { k - 1 };
|
||||
let mut seg_dir: Vec<Option<Point>> = Vec::with_capacity(segs);
|
||||
for i in 0..segs {
|
||||
let a = base + 2 * i as u32;
|
||||
let b = base + 2 * (((i + 1) % k) as u32);
|
||||
self.line_idx
|
||||
.extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]);
|
||||
seg_dir.push(unit_dir(s[i], s[(i + 1) % k]));
|
||||
}
|
||||
self.add_line_batch((segs * 6) as u32, color, stroke_mm, width_screen);
|
||||
|
||||
// Segment-Quads: buttendig, jedes mit seiner EIGENEN Normale (kein
|
||||
// gemeinsamer Bisektor mehr — Ecken werden separat durch Faecher geschlossen).
|
||||
for i in 0..segs {
|
||||
let Some(d) = seg_dir[i] else {
|
||||
continue; // entartetes (Laenge-0) Segment
|
||||
};
|
||||
let n = left_normal_of(d);
|
||||
let a = s[i];
|
||||
let b = s[(i + 1) % k];
|
||||
let i0 = push_vert(&mut self.line_verts, a, n, 1.0);
|
||||
let i1 = push_vert(&mut self.line_verts, a, n, -1.0);
|
||||
let i2 = push_vert(&mut self.line_verts, b, n, 1.0);
|
||||
let i3 = push_vert(&mut self.line_verts, b, n, -1.0);
|
||||
self.line_idx
|
||||
.extend_from_slice(&[i0, i1, i2, i1, i3, i2]);
|
||||
}
|
||||
|
||||
// Kreisbogen-Faecher (Zentrum = Vertex, Radius = halbe Strichbreite, per
|
||||
// Shader skaliert): `side=0` am Zentrum (kein Versatz), `side=1` an den
|
||||
// Randpunkten (voller Versatz in Richtung (cos(t),sin(t))). Segmentzahl
|
||||
// skaliert mit dem ueberstrichenen Winkel (18 Grad je Schritt).
|
||||
#[inline]
|
||||
fn add_fan_arc(verts: &mut Vec<f32>, idx: &mut Vec<u32>, p: Point, a_from: f32, a_to: f32) {
|
||||
let delta = a_to - a_from;
|
||||
let steps = ((delta.abs() / (std::f32::consts::PI / 10.0)).ceil() as u32).max(1);
|
||||
let i_center = push_vert(verts, p, [1.0, 0.0], 0.0);
|
||||
let mut prev = push_vert(verts, p, [a_from.cos(), a_from.sin()], 1.0);
|
||||
for step in 1..=steps {
|
||||
let t = a_from + delta * (step as f32) / (steps as f32);
|
||||
let cur = push_vert(verts, p, [t.cos(), t.sin()], 1.0);
|
||||
idx.extend_from_slice(&[i_center, prev, cur]);
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
// Runder Join an einem inneren Stuetzpunkt: Faecher NUR auf der konvexen
|
||||
// (aeusseren) Seite der Ecke — die konkave Seite ueberlappt bereits durch
|
||||
// die beiden Segment-Quads (kein Loch, keine zusaetzliche Geometrie noetig).
|
||||
#[inline]
|
||||
fn add_round_join(verts: &mut Vec<f32>, idx: &mut Vec<u32>, p: Point, d1: Point, d2: Point) {
|
||||
let turn = d1[0] * d2[1] - d1[1] * d2[0];
|
||||
let dot = d1[0] * d2[0] + d1[1] * d2[1];
|
||||
if turn.abs() < 1e-6 && dot > 0.0 {
|
||||
return; // praktisch gerade
|
||||
}
|
||||
let n1 = left_normal_of(d1);
|
||||
let n2 = left_normal_of(d2);
|
||||
if dot < -0.9999 {
|
||||
// ~180-Grad-Umkehr: Aussenseite mehrdeutig -> voller Kreis (robust,
|
||||
// entspricht zwei gestapelten Rund-Kappen an derselben Stelle).
|
||||
let a0 = n1[1].atan2(n1[0]);
|
||||
add_fan_arc(verts, idx, p, a0, a0 + 2.0 * std::f32::consts::PI);
|
||||
return;
|
||||
}
|
||||
let outer = if turn > 0.0 { -1.0 } else { 1.0 };
|
||||
let u1 = [outer * n1[0], outer * n1[1]];
|
||||
let u2 = [outer * n2[0], outer * n2[1]];
|
||||
let a1 = u1[1].atan2(u1[0]);
|
||||
let a2 = u2[1].atan2(u2[0]);
|
||||
let mut delta = a2 - a1;
|
||||
while delta <= -std::f32::consts::PI {
|
||||
delta += 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
while delta > std::f32::consts::PI {
|
||||
delta -= 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
if delta.abs() < 1e-4 {
|
||||
return;
|
||||
}
|
||||
add_fan_arc(verts, idx, p, a1, a1 + delta);
|
||||
}
|
||||
|
||||
// Runde Kappe an einem offenen Ende: Halbkreis, der auf der Aussenseite
|
||||
// (weg von der Linie) ausbaucht.
|
||||
#[inline]
|
||||
fn add_round_cap(verts: &mut Vec<f32>, idx: &mut Vec<u32>, p: Point, n: Point, sweep_sign: f32) {
|
||||
let a0 = n[1].atan2(n[0]);
|
||||
add_fan_arc(verts, idx, p, a0, a0 + sweep_sign * std::f32::consts::PI);
|
||||
}
|
||||
|
||||
for v in 0..k {
|
||||
let has_in = closed || v > 0;
|
||||
let has_out = closed || v < k - 1;
|
||||
let d_in = if has_in {
|
||||
seg_dir[(v + segs - 1) % segs]
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let d_out = if has_out { seg_dir[v % segs] } else { None };
|
||||
match (d_in, d_out) {
|
||||
(Some(di), Some(do_)) => {
|
||||
add_round_join(&mut self.line_verts, &mut self.line_idx, s[v], di, do_)
|
||||
}
|
||||
(None, Some(do_)) => {
|
||||
// Start-Kappe: baucht rueckwaerts (weg vom ersten Segment) aus.
|
||||
let n = left_normal_of(do_);
|
||||
add_round_cap(&mut self.line_verts, &mut self.line_idx, s[v], n, 1.0)
|
||||
}
|
||||
(Some(di), None) => {
|
||||
// End-Kappe: baucht vorwaerts (weg vom letzten Segment) aus.
|
||||
let n = left_normal_of(di);
|
||||
add_round_cap(&mut self.line_verts, &mut self.line_idx, s[v], n, -1.0)
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.add_line_batch(
|
||||
(self.line_idx.len() - start_idx_len) as u32,
|
||||
color,
|
||||
stroke_mm,
|
||||
width_screen,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
sill: 0.8,
|
||||
height: 1.0,
|
||||
}],
|
||||
layers: None,
|
||||
},
|
||||
WallInput {
|
||||
start: [0.0, 3.0],
|
||||
@@ -40,6 +41,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
base_elevation: 0.0,
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
},
|
||||
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
|
||||
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
|
||||
@@ -54,6 +56,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
base_elevation: 0.0,
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
},
|
||||
];
|
||||
let slabs = vec![SlabInput {
|
||||
|
||||
@@ -37,6 +37,7 @@ fn demo_walls() -> Vec<WallInput> {
|
||||
base_elevation: 0.0,
|
||||
color: grey,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
};
|
||||
// Raum 6 x 4 m.
|
||||
vec![
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// Modell-Kanten (Feature-Edges) fuer die Draufsicht-Stile "wireframe" und
|
||||
// "hidden" des 3D-Renderers.
|
||||
//
|
||||
// Leitet aus einem fertigen Dreiecks-`Mesh` (types::Mesh, interleaved
|
||||
// [pos, normal, color]) eine schlanke Kantenliste ab und liefert sie im
|
||||
// GLEICHEN Vertex-Format wie das Bodengitter (grid.rs): interleaved
|
||||
// [px,py,pz, r,g,b], je 2 Vertices = 1 Liniensegment (LineList). Dadurch teilt
|
||||
// sich die Kanten-Zeichnung Shader (GRID_WGSL) und Vertex-Layout mit dem Grid;
|
||||
// die GPU-Schicht (gpu.rs) laedt die Vertices einmalig bei `set_model` in einen
|
||||
// Linien-Vertex-Buffer und cached sie (nicht je Frame).
|
||||
//
|
||||
// FEATURE-EDGE-STATT-ALLE-DREIECKSKANTEN: Wuerde man jede Dreieckskante zeichnen,
|
||||
// erschienen auch die Triangulierungs-Diagonalen jeder rechteckigen Flaeche
|
||||
// (jede Wand-/Deckenseite ist aus 2 Dreiecken) — ein unruhiges Gitter. Statt
|
||||
// dessen dedupliziert diese Schicht geteilte Kanten UND unterdrueckt Kanten
|
||||
// zwischen zwei koplanaren Dreiecken (Flaechen-Innendiagonalen): gezeichnet
|
||||
// werden nur Silhouetten-/Randkanten (Kante gehoert nur einem Dreieck) und
|
||||
// Knickkanten ("Crease": die beiden angrenzenden Dreiecke stehen deutlich
|
||||
// zueinander gewinkelt). Ergebnis ist ein sauberer Architektur-Drahtgitter-/
|
||||
// Hidden-Line-Look statt einer Dreiecks-Suppe.
|
||||
//
|
||||
// GPU-frei (nur `Vec<f32>`), damit headless per `cargo test` pruefbar (Muster:
|
||||
// grid.rs / mesh.rs / section.rs).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::types::{Mesh, FLOATS_PER_VERTEX};
|
||||
|
||||
/// f32 je Kanten-Vertex im interleaved Puffer: 3 Position (world) + 3 Farbe.
|
||||
/// Bewusst identisch zu `grid::GRID_FLOATS_PER_VERTEX`, damit dieselbe
|
||||
/// LineList-Pipeline/derselbe Shader (GRID_WGSL) beide zeichnen kann.
|
||||
pub const EDGE_FLOATS_PER_VERTEX: usize = 6;
|
||||
|
||||
/// Farbe der Modell-Kanten (RGB 0..1). Dezentes, dunkles Anthrazit — deutlich
|
||||
/// genug fuer den Hidden-Line-Look, ohne hart schwarz zu wirken.
|
||||
pub const EDGE_COLOR: [f32; 3] = [0.16, 0.16, 0.18];
|
||||
|
||||
/// Quantisierungsfaktor fuer den Kanten-Schluessel: Positionen werden auf 0.1 mm
|
||||
/// (1e4 pro Meter) gerundet, damit geometrisch zusammenfallende Endpunkte aus
|
||||
/// verschiedenen (pro Flaeche duplizierten) Vertices denselben Schluessel teilen.
|
||||
const QUANT: f32 = 1.0e4;
|
||||
|
||||
/// Kosinus des Crease-Winkels: liegen die Normalen zweier angrenzender Dreiecke
|
||||
/// enger beieinander (dot > COS), gelten sie als koplanar → die geteilte Kante
|
||||
/// wird NICHT gezeichnet (Flaechen-Innendiagonale). Darunter = Knickkante →
|
||||
/// zeichnen. cos(15°) ≈ 0.966: Ebenen-Diagonalen (dot≈1) fallen weg, echte
|
||||
/// Bauteil-Kanten (≥ ~90°) bleiben.
|
||||
const CREASE_COS: f32 = 0.966;
|
||||
|
||||
/// Ganzzahliger Positions-Schluessel (quantisiert) fuer die Kanten-Deduplizierung.
|
||||
type Key = [i64; 3];
|
||||
|
||||
fn key_of(p: [f32; 3]) -> Key {
|
||||
[
|
||||
(p[0] * QUANT).round() as i64,
|
||||
(p[1] * QUANT).round() as i64,
|
||||
(p[2] * QUANT).round() as i64,
|
||||
]
|
||||
}
|
||||
|
||||
/// Sammlung je Kante: die (erste gesehene) Position beider Endpunkte plus die
|
||||
/// Normalen aller angrenzenden Dreiecke — daraus entscheidet sich Rand/Knick.
|
||||
struct EdgeRec {
|
||||
p0: [f32; 3],
|
||||
p1: [f32; 3],
|
||||
normals: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
]
|
||||
}
|
||||
|
||||
fn normalize(v: [f32; 3]) -> [f32; 3] {
|
||||
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if l <= 1e-12 {
|
||||
[0.0, 0.0, 0.0]
|
||||
} else {
|
||||
[v[0] / l, v[1] / l, v[2] / l]
|
||||
}
|
||||
}
|
||||
|
||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc).
|
||||
///
|
||||
/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment.
|
||||
/// Leerer Vektor bei leerem/degeneriertem Mesh.
|
||||
pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
||||
if mesh.indices.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
let pos = |i: u32| -> [f32; 3] {
|
||||
let b = i as usize * FLOATS_PER_VERTEX;
|
||||
[mesh.verts[b], mesh.verts[b + 1], mesh.verts[b + 2]]
|
||||
};
|
||||
|
||||
// Kanten-Schluessel (kanonisch sortiertes Endpunkt-Paar) -> Datensatz.
|
||||
let mut edges: HashMap<(Key, Key), EdgeRec> = HashMap::new();
|
||||
|
||||
for tri in mesh.indices.chunks_exact(3) {
|
||||
let (a, b, c) = (pos(tri[0]), pos(tri[1]), pos(tri[2]));
|
||||
// Geometrische Flaechennormale (robuster als die pro-Vertex-Normalen).
|
||||
let n = normalize(cross(sub(b, a), sub(c, a)));
|
||||
if n == [0.0, 0.0, 0.0] {
|
||||
continue; // entartetes Dreieck ueberspringen
|
||||
}
|
||||
for (u, v) in [(a, b), (b, c), (c, a)] {
|
||||
let (ku, kv) = (key_of(u), key_of(v));
|
||||
if ku == kv {
|
||||
continue; // Null-Laengen-Kante
|
||||
}
|
||||
// Kanonische Reihenfolge, damit u->v und v->u denselben Schluessel haben.
|
||||
let (key, p0, p1) = if ku <= kv {
|
||||
((ku, kv), u, v)
|
||||
} else {
|
||||
((kv, ku), v, u)
|
||||
};
|
||||
edges
|
||||
.entry(key)
|
||||
.or_insert_with(|| EdgeRec {
|
||||
p0,
|
||||
p1,
|
||||
normals: Vec::new(),
|
||||
})
|
||||
.normals
|
||||
.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
let mut verts: Vec<f32> = Vec::new();
|
||||
let mut push = |p: [f32; 3]| {
|
||||
verts.push(p[0]);
|
||||
verts.push(p[1]);
|
||||
verts.push(p[2]);
|
||||
verts.push(EDGE_COLOR[0]);
|
||||
verts.push(EDGE_COLOR[1]);
|
||||
verts.push(EDGE_COLOR[2]);
|
||||
};
|
||||
|
||||
for rec in edges.values() {
|
||||
if should_draw(&rec.normals) {
|
||||
push(rec.p0);
|
||||
push(rec.p1);
|
||||
}
|
||||
}
|
||||
verts
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn should_draw(normals: &[[f32; 3]]) -> bool {
|
||||
match normals.len() {
|
||||
0 => false,
|
||||
1 => true, // Silhouette/Rand: gehoert nur einem Dreieck
|
||||
_ => {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Hilfsbau: Mesh aus (pos, dummy-normal, dummy-color) + Indizes.
|
||||
fn mesh(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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_count(v: &[f32]) -> usize {
|
||||
v.len() / EDGE_FLOATS_PER_VERTEX / 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_triangle_has_three_boundary_edges() {
|
||||
let m = mesh(
|
||||
&[[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 coplanar_quad_drops_shared_diagonal() {
|
||||
// Ebenes Quad aus 2 Dreiecken in der XZ-Ebene: geteilte Diagonale ist
|
||||
// koplanar -> weg; es bleiben die 4 Umriss-Kanten.
|
||||
let m = mesh(
|
||||
&[
|
||||
[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 folded_quad_keeps_crease() {
|
||||
// Zwei Dreiecke im 90°-Knick teilen die Kante (0,0,0)-(1,0,0):
|
||||
// Normalen stehen senkrecht -> Knickkante bleibt erhalten.
|
||||
let m = mesh(
|
||||
&[
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0], // erstes Dreieck in XZ (Normale +Y)
|
||||
[1.0, 1.0, 0.0], // zweites Dreieck kippt nach oben (Normale +Z)
|
||||
],
|
||||
&[0, 1, 2, 0, 3, 1],
|
||||
);
|
||||
let e = build_mesh_edges(&m);
|
||||
// geteilte Knickkante + je 2 Randkanten pro Dreieck = 5 Segmente.
|
||||
assert_eq!(segment_count(&e), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_mesh_yields_no_edges() {
|
||||
let m = Mesh::default();
|
||||
assert!(build_mesh_edges(&m).is_empty());
|
||||
}
|
||||
}
|
||||
+586
-21
@@ -12,14 +12,76 @@
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
||||
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
||||
use crate::math::{view_projection, Mat4};
|
||||
use crate::mesh::{build_model_mesh, build_walls_mesh};
|
||||
use crate::shaders::MESH_WGSL;
|
||||
use crate::types::{Camera, Mesh, SlabInput, WallInput, FLOATS_PER_VERTEX};
|
||||
use crate::mesh::{build_scene_mesh, build_walls_mesh};
|
||||
use crate::section::SectionPlane;
|
||||
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX};
|
||||
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_WGSL};
|
||||
use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX};
|
||||
|
||||
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
|
||||
/// wireframe/hidden). Bestimmt, welche Pipelines je Frame gezeichnet werden und
|
||||
/// welchen `mode.x`-Wert der Mesh-Shader bekommt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderStyle {
|
||||
/// Beleuchtete Bauteilfarben (Default).
|
||||
Shaded,
|
||||
/// Clay-Look: einheitlich helles Material, beleuchtet.
|
||||
White,
|
||||
/// Mangels Textur-Pipeline VORERST identisch zu `Shaded` behandelt (kein Fake).
|
||||
Textured,
|
||||
/// Nur Modell-Kanten (Feature-Edges), keine gefuellten Flaechen.
|
||||
Wireframe,
|
||||
/// Hidden-Line: flach-weisse Flaechen (fuellen den Depth-Buffer) + dunkle
|
||||
/// Kanten obenauf, tiefengetestet -> nur sichtbare Kanten erscheinen.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl RenderStyle {
|
||||
/// Parst den API-String; unbekannte Werte fallen auf `Shaded` zurueck.
|
||||
pub fn from_str_or_shaded(s: &str) -> Self {
|
||||
match s {
|
||||
"white" => RenderStyle::White,
|
||||
"textured" => RenderStyle::Textured,
|
||||
"wireframe" => RenderStyle::Wireframe,
|
||||
"hidden" => RenderStyle::Hidden,
|
||||
_ => RenderStyle::Shaded,
|
||||
}
|
||||
}
|
||||
|
||||
/// `mode.x`-Wert fuer den Mesh-Shader (siehe shaders.rs): 0 = Material/shaded
|
||||
/// (auch textured), 1 = weiss/Clay, 2 = flach-weisse Hidden-Line-Flaechen.
|
||||
fn mesh_mode(self) -> f32 {
|
||||
match self {
|
||||
RenderStyle::White => 1.0,
|
||||
RenderStyle::Hidden => 2.0,
|
||||
// Shaded, Textured, Wireframe (Flaechen werden bei Wireframe eh nicht
|
||||
// gezeichnet) -> Material.
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Werden in diesem Stil gefuellte Flaechen gezeichnet?
|
||||
fn draws_faces(self) -> bool {
|
||||
!matches!(self, RenderStyle::Wireframe)
|
||||
}
|
||||
|
||||
/// Werden in diesem Stil Modell-Kanten gezeichnet?
|
||||
fn draws_edges(self) -> bool {
|
||||
matches!(self, RenderStyle::Wireframe | RenderStyle::Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar).
|
||||
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||||
|
||||
/// MSAA-Samples fuer Farb- und Tiefenziel. 4x ist auf allen gaengigen Backends
|
||||
/// (Vulkan/Metal/DX12/GL) fuer die ueblichen Oberflaechenformate verfuegbar und
|
||||
/// gleicht die Treppenstufen an Wandkanten/Silhouetten spuerbar aus.
|
||||
const SAMPLE_COUNT: u32 = 4;
|
||||
|
||||
/// Uniform-Block, 1:1 zum WGSL-`Globals`-Struct. std140-kompatibel (mat4/vec4 auf
|
||||
/// 16 Byte ausgerichtet).
|
||||
#[repr(C)]
|
||||
@@ -30,6 +92,13 @@ struct Globals {
|
||||
sky_color: [f32; 4],
|
||||
ground_color: [f32; 4],
|
||||
sun_color: [f32; 4],
|
||||
/// x = Darstellungsart (0 = shaded/Material, 1 = weiss/Clay, 2 = hidden);
|
||||
/// y = Live-Schnitt aktiv (0/1). Siehe shaders.rs.
|
||||
mode: [f32; 4],
|
||||
/// Live-Schnittebene: xyz = Ebenennormale (= Blickrichtung, normiert),
|
||||
/// w = -dot(normal, point). Ein Fragment mit `dot(world, xyz) + w > 0` liegt
|
||||
/// hinter der Ebene und wird (bei aktivem Schnitt, mode.y > 0.5) verworfen.
|
||||
section_plane: [f32; 4],
|
||||
}
|
||||
|
||||
impl Default for Globals {
|
||||
@@ -44,6 +113,9 @@ impl Default for Globals {
|
||||
ground_color: [0.30, 0.29, 0.27, 1.0],
|
||||
// Directional-Sonne: warmweiss, moderat.
|
||||
sun_color: [0.55, 0.53, 0.49, 1.0],
|
||||
mode: [0.0, 0.0, 0.0, 0.0],
|
||||
// Default: kein Schnitt (mode.y = 0). Neutrale Ebene.
|
||||
section_plane: [0.0, 0.0, 1.0, 0.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,15 +132,75 @@ struct MeshBuffers {
|
||||
index_count: u32,
|
||||
}
|
||||
|
||||
/// GPU-seitiger Vertex-Buffer einer LineList (kein Indexpuffer). Format
|
||||
/// [pos vec3, color vec3] — genutzt fuer das Bodengitter UND die Modell-Kanten
|
||||
/// (identisches Layout/Shader, siehe grid.rs / edges.rs).
|
||||
struct LineBuffers {
|
||||
vbo: wgpu::Buffer,
|
||||
vertex_count: u32,
|
||||
}
|
||||
|
||||
/// GPU-seitiger Vertex-Buffer der Schnittflaechen-Kappen (Cut-Caps, TriangleList,
|
||||
/// kein Indexpuffer). Format `[pos vec3, uv vec2]` (`CAP_FLOATS_PER_VERTEX`) —
|
||||
/// die (u,v)-Ebenenkoordinaten treiben die prozedurale Schraffur (CAP_WGSL).
|
||||
struct CapBuffers {
|
||||
vbo: wgpu::Buffer,
|
||||
vertex_count: u32,
|
||||
}
|
||||
|
||||
/// Der native 3D-Renderer: haelt die Pipeline, das Uniform (View-Projektion +
|
||||
/// Licht) und das aktuell hochgeladene Mesh. Ein Tiefenpuffer wird passend zur
|
||||
/// Ziel-Groesse (neu) angelegt.
|
||||
pub struct Renderer {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Wie `pipeline`, aber mit positivem Depth-Bias (Flaechen leicht nach hinten
|
||||
/// geschoben). Nur fuer den Hidden-Line-Stil: so gewinnen die danach an ihrer
|
||||
/// wahren Tiefe gezeichneten Kanten den Less-Test und liegen sauber obenauf,
|
||||
/// waehrend verdeckte Kanten weiterhin weggeschnitten werden (kein Z-Fighting).
|
||||
hidden_face_pipeline: wgpu::RenderPipeline,
|
||||
/// Eigene, schlanke Linien-Pipeline (LineList, unlit) fuer das Bodengitter
|
||||
/// UND die Modell-Kanten (wireframe/hidden — identisches Layout/Shader).
|
||||
/// Teilt sich das `Globals`-Uniform/Bind-Group (nur View-Projektion) mit der
|
||||
/// Mesh-Pipeline und rendert in denselben Pass/Tiefenpuffer.
|
||||
grid_pipeline: wgpu::RenderPipeline,
|
||||
/// Wie `grid_pipeline` (LineList, GRID_WGSL), aber OHNE Tiefentest
|
||||
/// (`depth_compare: Always`, kein Depth-Write): die Auswahl-Highlight-Linien
|
||||
/// liegen immer sichtbar obenauf und scheinen auch hinter Waenden durch.
|
||||
highlight_pipeline: wgpu::RenderPipeline,
|
||||
/// Schnittflaechen-Kappen (TriangleList, CAP_WGSL, eigenes Vertex-Layout
|
||||
/// [pos vec3, uv vec2]). Teilt sich die `Globals`-Bind-Group; leichter
|
||||
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
|
||||
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
|
||||
cap_pipeline: wgpu::RenderPipeline,
|
||||
bind_group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
mesh: Option<MeshBuffers>,
|
||||
/// Modell-Kanten (Feature-Edges, edges.rs) als LineList. Einmalig bei
|
||||
/// `set_model`/`upload_*` erzeugt und gecacht (nicht je Frame). Gezeichnet in
|
||||
/// den Stilen wireframe/hidden.
|
||||
edges: Option<LineBuffers>,
|
||||
/// Bodengitter-Vertices (None = nicht erzeugt). Wird nur gezeichnet, wenn
|
||||
/// zusaetzlich `grid_visible` gesetzt ist.
|
||||
grid: Option<LineBuffers>,
|
||||
/// Auswahl-Highlight-Linien (Kanten des selektierten Bauteils, LineList) als
|
||||
/// LineBuffers. None = keine Auswahl -> nichts wird gezeichnet. Ueber
|
||||
/// `set_highlight_lines` gesetzt/geloescht; im Render-Pass als ALLERLETZTES
|
||||
/// (ohne Tiefentest) gezeichnet.
|
||||
highlight: Option<LineBuffers>,
|
||||
/// Schnittflaechen-Kappen (Cut-Caps) des Live-Schnitts. None = kein Schnitt
|
||||
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
|
||||
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
|
||||
caps: Option<CapBuffers>,
|
||||
/// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false —
|
||||
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
|
||||
grid_visible: bool,
|
||||
/// Aktuelle Darstellungsart (Default `Shaded`). Einzige Quelle der Wahrheit;
|
||||
/// `set_white_mode` bildet auf `Shaded`/`White` ab.
|
||||
style: RenderStyle,
|
||||
depth: Option<DepthTarget>,
|
||||
msaa: Option<MsaaTarget>,
|
||||
/// Farbformat des Render-Ziels (Surface), fuer die MSAA-Zwischentextur.
|
||||
color_format: wgpu::TextureFormat,
|
||||
globals: Globals,
|
||||
/// Loeschfarbe (Hintergrund). Default heller Grauton wie die three.js-Sicht.
|
||||
pub clear_color: wgpu::Color,
|
||||
@@ -81,6 +213,14 @@ struct DepthTarget {
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Multisampled Farb-Zwischentextur samt View, an eine bestimmte Groesse
|
||||
/// gebunden. Wird jeden Frame in das eigentliche Ziel (Surface-View) aufgeloest.
|
||||
struct MsaaTarget {
|
||||
view: wgpu::TextureView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Erzeugt Pipeline + Layout fuer ein gegebenes Farbformat (Surface).
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
@@ -119,13 +259,17 @@ impl Renderer {
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3],
|
||||
};
|
||||
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("mesh.pipeline"),
|
||||
// Mesh-Pipeline-Fabrik: identische Beschreibung fuer die Standard-Flaechen
|
||||
// (kein Bias) und die Hidden-Line-Flaechen (positiver Depth-Bias, damit die
|
||||
// spaeter gezeichneten Kanten obenauf liegen). Nur `bias` unterscheidet sich.
|
||||
let make_mesh_pipeline = |label: &str, bias: wgpu::DepthBiasState| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[vertex_layout],
|
||||
buffers: &[vertex_layout.clone()],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
@@ -150,9 +294,170 @@ impl Renderer {
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
bias,
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: SAMPLE_COUNT,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipeline = make_mesh_pipeline("mesh.pipeline", wgpu::DepthBiasState::default());
|
||||
// Hidden-Line-Flaechen leicht nach hinten schieben (Depth-Bias auf
|
||||
// Dreiecke wirkt zuverlaessig — anders als bei Linien), damit die Kanten
|
||||
// an ihrer wahren Tiefe den Less-Test gewinnen.
|
||||
let hidden_face_pipeline = make_mesh_pipeline(
|
||||
"mesh.hidden.pipeline",
|
||||
wgpu::DepthBiasState {
|
||||
constant: 4,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
);
|
||||
|
||||
// Bodengitter-Pipeline: dieselbe Bind-Group (Globals/View-Projektion) und
|
||||
// dasselbe Depth-/MSAA-/Farbziel wie die Mesh-Pipeline, aber LineList-
|
||||
// Topologie, ein eigener unlit-Shader (konstante Vertexfarbe) und ein
|
||||
// schlankeres Vertex-Layout [pos vec3, color vec3].
|
||||
let grid_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("grid.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(GRID_WGSL.into()),
|
||||
});
|
||||
let grid_vertex_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: (GRID_FLOATS_PER_VERTEX * 4) as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3],
|
||||
};
|
||||
// Linien-Pipeline-Fabrik: identische Beschreibung (LineList, GRID_WGSL,
|
||||
// gemeinsame Bind-Group/Farbziel/MSAA) fuer das Bodengitter/die Kanten
|
||||
// (tiefengetestet) UND die Auswahl-Highlight-Linien (ohne Tiefentest, siehe
|
||||
// unten). Nur der `depth_stencil`-Zustand unterscheidet sich.
|
||||
let make_line_pipeline = |label: &str, depth: wgpu::DepthStencilState| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &grid_module,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[grid_vertex_layout.clone()],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &grid_module,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::LineList,
|
||||
// Linien haben keine Vorder-/Rueckseite -> kein Culling.
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(depth),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: SAMPLE_COUNT,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// Grid/Kanten: normaler Tiefentest (Less), schreibt Tiefe -> das Modell
|
||||
// verdeckt Gitter/verdeckte Kanten korrekt.
|
||||
let grid_pipeline = make_line_pipeline(
|
||||
"grid.pipeline",
|
||||
wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
},
|
||||
);
|
||||
// Auswahl-Highlight: KEIN Tiefentest (`Always`) und KEIN Tiefen-Write —
|
||||
// die Auswahl-Umrisslinien werden unabhaengig von der Szenentiefe immer
|
||||
// gezeichnet und scheinen so auch hinter Waenden durch (liegen optisch oben,
|
||||
// weil sie als letztes gezeichnet werden).
|
||||
let highlight_pipeline = make_line_pipeline(
|
||||
"highlight.pipeline",
|
||||
wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
},
|
||||
);
|
||||
|
||||
// Schnittflaechen-Kappen-Pipeline: eigener Shader (CAP_WGSL), eigenes
|
||||
// schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe
|
||||
// Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben.
|
||||
let cap_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("cap.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(CAP_WGSL.into()),
|
||||
});
|
||||
let cap_vertex_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
|
||||
};
|
||||
let cap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("cap.pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &cap_module,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[cap_vertex_layout],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &cap_module,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
// Die Cap ist eine flache Flaeche und soll aus BEIDEN Richtungen
|
||||
// sichtbar sein (das Winding der Rueckprojektion ist nicht
|
||||
// kamerakonsistent garantiert) -> kein Backface-Culling.
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
// Leicht ZUR Kamera ziehen (negativer Bias, Gegenteil des
|
||||
// hidden_face_pipeline-Bias): die Caps liegen exakt auf der
|
||||
// Schnittebene, an der die Wandflaechen weggeschnitten werden —
|
||||
// der negative Bias vermeidet Z-Fighting mit an der Grenze
|
||||
// gerade noch stehen gebliebenen Wandfragmenten.
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: -4,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: SAMPLE_COUNT,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
@@ -174,10 +479,22 @@ impl Renderer {
|
||||
|
||||
Self {
|
||||
pipeline,
|
||||
hidden_face_pipeline,
|
||||
grid_pipeline,
|
||||
highlight_pipeline,
|
||||
cap_pipeline,
|
||||
bind_group,
|
||||
uniform,
|
||||
mesh: None,
|
||||
edges: None,
|
||||
grid: None,
|
||||
highlight: None,
|
||||
caps: None,
|
||||
grid_visible: false,
|
||||
style: RenderStyle::Shaded,
|
||||
depth: None,
|
||||
msaa: None,
|
||||
color_format,
|
||||
globals: Globals::default(),
|
||||
// #f5f5f5 heller Hintergrund (wie der 2D-Grundriss).
|
||||
clear_color: wgpu::Color {
|
||||
@@ -194,17 +511,44 @@ impl Renderer {
|
||||
self.upload_mesh(device, build_walls_mesh(walls));
|
||||
}
|
||||
|
||||
/// Erzeugt das Mesh aus Waenden UND Deckenplatten und laedt die Puffer hoch.
|
||||
pub fn upload_model(&mut self, device: &wgpu::Device, walls: &[WallInput], slabs: &[SlabInput]) {
|
||||
self.upload_mesh(device, build_model_mesh(walls, slabs));
|
||||
/// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes
|
||||
/// (Terrain/importierte Volumen, siehe `MeshInput`) und laedt die Puffer hoch.
|
||||
pub fn upload_model(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
walls: &[WallInput],
|
||||
slabs: &[SlabInput],
|
||||
meshes: &[MeshInput],
|
||||
) {
|
||||
self.upload_mesh(device, build_scene_mesh(walls, slabs, meshes));
|
||||
}
|
||||
|
||||
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer).
|
||||
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und
|
||||
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
|
||||
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
|
||||
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
|
||||
if mesh.indices.is_empty() {
|
||||
self.mesh = None;
|
||||
self.edges = None;
|
||||
return;
|
||||
}
|
||||
|
||||
// Kanten aus der Mesh-Geometrie ableiten und (falls vorhanden) hochladen.
|
||||
let edge_verts = build_mesh_edges(&mesh);
|
||||
self.edges = if edge_verts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("edges.vbo"),
|
||||
contents: bytemuck::cast_slice(&edge_verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
Some(LineBuffers {
|
||||
vbo,
|
||||
vertex_count: (edge_verts.len() / EDGE_FLOATS_PER_VERTEX) as u32,
|
||||
})
|
||||
};
|
||||
|
||||
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mesh.vbo"),
|
||||
contents: bytemuck::cast_slice(&mesh.verts),
|
||||
@@ -236,6 +580,129 @@ impl Renderer {
|
||||
self.globals.sun_color = [sun[0], sun[1], sun[2], 1.0];
|
||||
}
|
||||
|
||||
/// Darstellungsart setzen (shaded/white/textured/wireframe/hidden). Einzige
|
||||
/// Quelle der Wahrheit; `render` leitet daraus Pipeline-Wahl und `mode.x` ab.
|
||||
pub fn set_render_style(&mut self, style: RenderStyle) {
|
||||
self.style = style;
|
||||
}
|
||||
|
||||
/// Rueckwaerts-kompatibler Schalter: `true` bildet auf `White`, `false` auf
|
||||
/// `Shaded` ab (kein separater Zustand mehr — vermeidet Widerspruch zum
|
||||
/// `style`-Feld). Neue Aufrufer nutzen `set_render_style`.
|
||||
pub fn set_white_mode(&mut self, white: bool) {
|
||||
self.style = if white {
|
||||
RenderStyle::White
|
||||
} else {
|
||||
RenderStyle::Shaded
|
||||
};
|
||||
}
|
||||
|
||||
/// Setzt/aktualisiert das Referenz-Bodengitter (grid.rs). Erzeugt den Linien-
|
||||
/// Vertex-Buffer aus den Parametern neu und merkt sich die Sichtbarkeit; der
|
||||
/// naechste `render` zeichnet das Gitter (bzw. nichts bei `visible == false`).
|
||||
/// Ohne Aufruf bleibt das Gitter unsichtbar (Default), damit bestehende
|
||||
/// Aufrufer unveraendert bleiben.
|
||||
pub fn set_ground_grid(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
visible: bool,
|
||||
elevation: f32,
|
||||
extent: f32,
|
||||
spacing: f32,
|
||||
) {
|
||||
self.grid_visible = visible;
|
||||
let verts = build_ground_grid(elevation, extent, spacing);
|
||||
if verts.is_empty() {
|
||||
self.grid = None;
|
||||
return;
|
||||
}
|
||||
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("grid.vbo"),
|
||||
contents: bytemuck::cast_slice(&verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
self.grid = Some(LineBuffers {
|
||||
vbo,
|
||||
vertex_count: (verts.len() / GRID_FLOATS_PER_VERTEX) as u32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Setzt/loescht die Auswahl-Highlight-Linien (Kanten des selektierten Bauteils).
|
||||
/// `verts` = interleaved LineList-Vertices `[px,py,pz, r,g,b, ...]` (world,
|
||||
/// je 2 Vertices = 1 Segment), exakt wie Grid-/Edge-Vertices
|
||||
/// (`GRID_FLOATS_PER_VERTEX`). Leeres Slice -> Highlight loeschen. Analog
|
||||
/// `set_ground_grid`, aber ohne Sichtbarkeits-Flag: vorhanden = gezeichnet.
|
||||
pub fn set_highlight_lines(&mut self, device: &wgpu::Device, verts: &[f32]) {
|
||||
if verts.is_empty() {
|
||||
self.highlight = None;
|
||||
return;
|
||||
}
|
||||
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("highlight.vbo"),
|
||||
contents: bytemuck::cast_slice(verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
self.highlight = Some(LineBuffers {
|
||||
vbo,
|
||||
vertex_count: (verts.len() / GRID_FLOATS_PER_VERTEX) as u32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Aktiviert/deaktiviert den Live-Schnitt und setzt die Schnittebene. `active`
|
||||
/// steuert `mode.y` (der Mesh-/Grid-Shader kappt die Geometrie nur bei > 0.5);
|
||||
/// `point`/`normal` (world-Meter, Normale = Blickrichtung) fuellen
|
||||
/// `section_plane = [n, -dot(n, point)]`. Nur die Uniform-Werte — die Cap-
|
||||
/// Geometrie setzt der Aufrufer separat via `set_cut_caps`.
|
||||
pub fn set_section_plane(&mut self, active: bool, point: [f32; 3], normal: [f32; 3]) {
|
||||
let n = crate::math::normalize(normal);
|
||||
let w = -(n[0] * point[0] + n[1] * point[1] + n[2] * point[2]);
|
||||
self.globals.section_plane = [n[0], n[1], n[2], w];
|
||||
self.globals.mode[1] = if active { 1.0 } else { 0.0 };
|
||||
}
|
||||
|
||||
/// Setzt/loescht die Schnittflaechen-Kappen (Cut-Caps). `verts` = interleaved
|
||||
/// `[px,py,pz, u,v, ...]` (world-Meter + Ebenenkoordinaten, TriangleList,
|
||||
/// `CAP_FLOATS_PER_VERTEX`), wie `section_fill::build_cut_caps` liefert. Leeres
|
||||
/// Slice loescht die Caps. Wirkt erst beim naechsten `render`.
|
||||
pub fn set_cut_caps(&mut self, device: &wgpu::Device, verts: &[f32]) {
|
||||
if verts.is_empty() {
|
||||
self.caps = None;
|
||||
return;
|
||||
}
|
||||
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("caps.vbo"),
|
||||
contents: bytemuck::cast_slice(verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
self.caps = Some(CapBuffers {
|
||||
vbo,
|
||||
vertex_count: (verts.len() / CAP_FLOATS_PER_VERTEX) as u32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Komfort: Schnittebene setzen UND die Cap-Geometrie aus den (gecachten)
|
||||
/// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und
|
||||
/// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` +
|
||||
/// `set_cut_caps`, damit `web.rs` nur EINEN Aufruf braucht.
|
||||
pub fn update_section(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
active: bool,
|
||||
point: [f32; 3],
|
||||
normal: [f32; 3],
|
||||
walls: &[WallInput],
|
||||
slabs: &[SlabInput],
|
||||
) {
|
||||
self.set_section_plane(active, point, normal);
|
||||
if active {
|
||||
let plane = SectionPlane::new(point, normal);
|
||||
let caps = build_cut_caps(&plane, walls, slabs);
|
||||
self.set_cut_caps(device, &caps);
|
||||
} else {
|
||||
self.set_cut_caps(device, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
|
||||
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) {
|
||||
let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h);
|
||||
@@ -250,7 +717,8 @@ impl Renderer {
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
// Muss zur Sample-Zahl der Farb-/Pipeline-Ziele passen (MSAA).
|
||||
sample_count: SAMPLE_COUNT,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DEPTH_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
@@ -264,14 +732,45 @@ impl Renderer {
|
||||
});
|
||||
}
|
||||
|
||||
/// Zeichnet einen Frame in `view` (Surface- oder Offscreen-Textur). Die Kamera
|
||||
/// liefert die View-Projektions-Matrix; `viewport` bestimmt das Seitenverhaeltnis
|
||||
/// und die Tiefenpuffer-Groesse.
|
||||
/// Stellt sicher, dass eine multisampled Farb-Zwischentextur passend zur
|
||||
/// Ziel-Groesse existiert (wird jeden Frame in die Surface-View aufgeloest).
|
||||
fn ensure_msaa(&mut self, device: &wgpu::Device, w: u32, h: u32) {
|
||||
let ok = matches!(&self.msaa, Some(m) if m.width == w && m.height == h);
|
||||
if ok {
|
||||
return;
|
||||
}
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("msaa.color"),
|
||||
size: wgpu::Extent3d {
|
||||
width: w.max(1),
|
||||
height: h.max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: SAMPLE_COUNT,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: self.color_format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
self.msaa = Some(MsaaTarget {
|
||||
view,
|
||||
width: w.max(1),
|
||||
height: h.max(1),
|
||||
});
|
||||
}
|
||||
|
||||
/// Zeichnet einen Frame und loest ihn in `target` auf (Surface- oder
|
||||
/// Offscreen-Textur). Intern wird in eine multisampled Zwischentextur (MSAA)
|
||||
/// gerendert und am Ende der Pass in `target` aufgeloest. Die Kamera liefert
|
||||
/// die View-Projektions-Matrix; `viewport` bestimmt das Seitenverhaeltnis und
|
||||
/// die Tiefen-/MSAA-Puffer-Groesse.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
view: &wgpu::TextureView,
|
||||
target: &wgpu::TextureView,
|
||||
camera: &Camera,
|
||||
viewport: (u32, u32),
|
||||
) {
|
||||
@@ -279,10 +778,15 @@ impl Renderer {
|
||||
let aspect = if h > 0 { w as f32 / h as f32 } else { 1.0 };
|
||||
let vp: Mat4 = view_projection(camera, aspect);
|
||||
self.globals.view_proj = vp;
|
||||
// `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/
|
||||
// Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal.
|
||||
self.globals.mode[0] = self.style.mesh_mode();
|
||||
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
|
||||
|
||||
self.ensure_depth(device, w, h);
|
||||
self.ensure_msaa(device, w, h);
|
||||
let depth_view = &self.depth.as_ref().unwrap().view;
|
||||
let msaa_view = &self.msaa.as_ref().unwrap().view;
|
||||
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("3d.encoder"),
|
||||
@@ -291,13 +795,17 @@ impl Renderer {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("3d.pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
// In die multisampled Zwischentextur rendern; wgpu loest sie am
|
||||
// Ende der Pass automatisch in `target` (Surface-View) auf.
|
||||
view: msaa_view,
|
||||
// Ziel ist eine 2D-Textur (kein 3D-Volumen) -> kein Depth-Slice.
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
resolve_target: Some(target),
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(self.clear_color),
|
||||
store: wgpu::StoreOp::Store,
|
||||
// Der multisampled Inhalt selbst wird nach dem Aufloesen nicht
|
||||
// mehr gebraucht — Discard spart Bandbreite.
|
||||
store: wgpu::StoreOp::Discard,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
@@ -313,14 +821,71 @@ impl Renderer {
|
||||
multiview_mask: None,
|
||||
});
|
||||
|
||||
// 1) Gefuellte Flaechen — in allen Stilen ausser "wireframe". Im
|
||||
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
|
||||
// die folgenden Kanten sauber obenauf liegen.
|
||||
if self.style.draws_faces() {
|
||||
if let Some(m) = &self.mesh {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
let face_pipeline = if self.style == RenderStyle::Hidden {
|
||||
&self.hidden_face_pipeline
|
||||
} else {
|
||||
&self.pipeline
|
||||
};
|
||||
pass.set_pipeline(face_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, m.vbo.slice(..));
|
||||
pass.set_index_buffer(m.ibo.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..m.index_count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
// 1b) Schnittflaechen-Kappen (Cut-Caps) NACH den Flaechen, VOR den
|
||||
// Kanten: sie fuellen die an der Schnittebene weggeschnittene Wunde
|
||||
// mit der Diagonalschraffur (CAP_WGSL). Werden selbst NICHT gekappt.
|
||||
// Nur vorhanden, wenn der Live-Schnitt aktiv ist (set_cut_caps).
|
||||
if let Some(c) = &self.caps {
|
||||
pass.set_pipeline(&self.cap_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, c.vbo.slice(..));
|
||||
pass.draw(0..c.vertex_count, 0..1);
|
||||
}
|
||||
|
||||
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid).
|
||||
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
|
||||
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
|
||||
// Flaechen -> alle Kanten sichtbar.
|
||||
if self.style.draws_edges() {
|
||||
if let Some(e) = &self.edges {
|
||||
pass.set_pipeline(&self.grid_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, e.vbo.slice(..));
|
||||
pass.draw(0..e.vertex_count, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Bodengitter NACH dem Modell zeichnen (gemeinsamer Tiefenpuffer ->
|
||||
// das Modell verdeckt das Gitter korrekt). Unabhaengig vom Stil, nur
|
||||
// wenn sichtbar + vorhanden.
|
||||
if self.grid_visible {
|
||||
if let Some(g) = &self.grid {
|
||||
pass.set_pipeline(&self.grid_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, g.vbo.slice(..));
|
||||
pass.draw(0..g.vertex_count, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Auswahl-Highlight-Linien als ALLERLETZTES — ohne Tiefentest
|
||||
// (`highlight_pipeline`: depth_compare Always, kein Depth-Write),
|
||||
// damit die Auswahl immer sichtbar obenauf liegt und auch hinter
|
||||
// Waenden durchscheint. Nur wenn per `set_highlight_lines` gesetzt.
|
||||
if let Some(hl) = &self.highlight {
|
||||
pass.set_pipeline(&self.highlight_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, hl.vbo.slice(..));
|
||||
pass.draw(0..hl.vertex_count, 0..1);
|
||||
}
|
||||
}
|
||||
queue.submit(std::iter::once(encoder.finish()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Referenz-Bodengitter (Ground-Grid) fuer den 3D-Renderer.
|
||||
//
|
||||
// Erzeugt CPU-seitig die Linien-Vertices eines quadratischen Referenzgitters in
|
||||
// der Welt-XZ-Ebene (Grundriss-Ebene), zentriert um den Welt-Ursprung
|
||||
// (x=0, z=0), auf konstanter Hoehe `y = elevation`. Damit hat der 3D-Viewport
|
||||
// einen dezenten Boden + raeumliche Orientierung.
|
||||
//
|
||||
// KOORDINATEN (wie types.rs): Welt-Y ist die Hoehe, XZ die Grundriss-Ebene. Das
|
||||
// Gitter liegt also flach in XZ auf konstantem y = elevation.
|
||||
//
|
||||
// Diese Schicht ist GPU-frei (nur `Vec<f32>`), damit sie headless per `cargo
|
||||
// test` pruefbar bleibt (Muster: mesh.rs/section.rs). Die GPU-Schicht (gpu.rs)
|
||||
// laedt die erzeugten Vertices in einen Linien-Vertex-Buffer und zeichnet sie
|
||||
// ueber eine eigene `LineList`-Pipeline (shaders::GRID_WGSL).
|
||||
|
||||
/// f32 je Gitter-Vertex im interleaved Puffer: 3 Position (world) + 3 Farbe (rgb).
|
||||
pub const GRID_FLOATS_PER_VERTEX: usize = 6;
|
||||
|
||||
/// Farbe einer normalen ("Minor") Gitterlinie (RGB 0..1). Gedecktes Neutralgrau,
|
||||
/// das sich vom hellen Hintergrund (#f5f5f5) und vom Modell (~0.8er Grau) abhebt,
|
||||
/// ohne zu dominieren.
|
||||
pub const MINOR_COLOR: [f32; 3] = [0.72, 0.72, 0.74];
|
||||
|
||||
/// Farbe einer "Major"-Linie (jede 5. Linie, inkl. Mittelachse): etwas kraeftiger
|
||||
/// (dunkler) als Minor, damit die Rasterweite ablesbar bleibt. Auf dem hellen
|
||||
/// Hintergrund liest sich "dunkler = betonter" am besten (CAD-uebliche Optik).
|
||||
pub const MAJOR_COLOR: [f32; 3] = [0.55, 0.55, 0.58];
|
||||
|
||||
/// Jede n-te Linie (vom Zentrum aus gezaehlt) wird als Major-Linie eingefaerbt.
|
||||
const MAJOR_EVERY: i32 = 5;
|
||||
|
||||
/// Anzahl der Gitterlinien PRO Richtung fuer gegebene Ausdehnung/Weite.
|
||||
///
|
||||
/// Linien sitzen bei `i * spacing` fuer `i` in `-steps..=steps`, wobei
|
||||
/// `steps = floor((extent/2) / spacing)`. Ergebnis ist damit immer ungerade
|
||||
/// (die Mittelachse `i = 0` ist stets dabei). 0, falls Parameter unbrauchbar.
|
||||
pub fn line_count(extent: f32, spacing: f32) -> usize {
|
||||
if !(extent > 0.0) || !(spacing > 0.0) {
|
||||
return 0;
|
||||
}
|
||||
let steps = ((extent * 0.5) / spacing).floor() as i32;
|
||||
if steps < 0 {
|
||||
return 0;
|
||||
}
|
||||
(2 * steps + 1) as usize
|
||||
}
|
||||
|
||||
/// Baut die Linien-Vertices (Positions + Farbe, interleaved) fuer das Bodengitter.
|
||||
///
|
||||
/// - Quadratisches Raster in der Welt-XZ-Ebene, zentriert um (0,0), auf `y = elevation`.
|
||||
/// - `extent` = Gesamt-Kantenlaenge (Meter); tatsaechlich genutzte Halb-Ausdehnung
|
||||
/// ist `steps * spacing` (auf ein ganzes Vielfaches der Weite gerundet), damit
|
||||
/// das Gitter ein sauberes Quadrat bildet, dessen Linien sich an den Ecken treffen.
|
||||
/// - `spacing` = Linienabstand (Meter).
|
||||
/// - Jede `MAJOR_EVERY`-te Linie (inkl. Mittelachse) bekommt `MAJOR_COLOR`, sonst `MINOR_COLOR`.
|
||||
///
|
||||
/// Ausgabe: `LineList`-Vertices (je 2 aufeinanderfolgende Vertices = 1 Segment),
|
||||
/// interleaved `[px,py,pz, r,g,b, ...]`. Leerer Vektor bei unbrauchbaren Parametern.
|
||||
pub fn build_ground_grid(elevation: f32, extent: f32, spacing: f32) -> Vec<f32> {
|
||||
let n = line_count(extent, spacing);
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let steps = (n as i32 - 1) / 2;
|
||||
let half = steps as f32 * spacing;
|
||||
|
||||
// 2 Richtungen * n Linien * 2 Endpunkte * 6 f32.
|
||||
let mut verts: Vec<f32> = Vec::with_capacity(2 * n * 2 * GRID_FLOATS_PER_VERTEX);
|
||||
|
||||
let mut push = |x: f32, z: f32, c: [f32; 3]| {
|
||||
verts.push(x);
|
||||
verts.push(elevation);
|
||||
verts.push(z);
|
||||
verts.push(c[0]);
|
||||
verts.push(c[1]);
|
||||
verts.push(c[2]);
|
||||
};
|
||||
|
||||
for i in -steps..=steps {
|
||||
let c = if i % MAJOR_EVERY == 0 {
|
||||
MAJOR_COLOR
|
||||
} else {
|
||||
MINOR_COLOR
|
||||
};
|
||||
let p = i as f32 * spacing;
|
||||
// Linie parallel zur X-Achse (konstantes z = p): von -half bis +half in x.
|
||||
push(-half, p, c);
|
||||
push(half, p, c);
|
||||
// Linie parallel zur Z-Achse (konstantes x = p): von -half bis +half in z.
|
||||
push(p, -half, c);
|
||||
push(p, half, c);
|
||||
}
|
||||
|
||||
verts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn line_count_inclusive_and_odd() {
|
||||
// extent 20, spacing 1 -> steps 10 -> 21 Linien je Richtung.
|
||||
assert_eq!(line_count(20.0, 1.0), 21);
|
||||
// extent 10, spacing 2 -> steps 2 (half=5, 5/2=2.5 floor 2) -> 5 Linien.
|
||||
assert_eq!(line_count(10.0, 2.0), 5);
|
||||
// Mittelachse immer dabei -> ungerade.
|
||||
assert_eq!(line_count(3.0, 1.0) % 2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_params_yield_empty() {
|
||||
assert_eq!(line_count(0.0, 1.0), 0);
|
||||
assert_eq!(line_count(20.0, 0.0), 0);
|
||||
assert_eq!(line_count(-5.0, 1.0), 0);
|
||||
assert!(build_ground_grid(0.0, 0.0, 1.0).is_empty());
|
||||
assert!(build_ground_grid(0.0, 20.0, 0.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_count_matches_lines() {
|
||||
let extent = 20.0;
|
||||
let spacing = 1.0;
|
||||
let n = line_count(extent, spacing); // 21
|
||||
let verts = build_ground_grid(0.0, extent, spacing);
|
||||
// 2 Richtungen * n Linien * 2 Endpunkte * 6 f32.
|
||||
assert_eq!(verts.len(), 2 * n * 2 * GRID_FLOATS_PER_VERTEX);
|
||||
// = 84 Vertices.
|
||||
assert_eq!(verts.len() / GRID_FLOATS_PER_VERTEX, 4 * n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_vertices_lie_on_elevation_plane_and_within_extent() {
|
||||
let elevation = 2.7;
|
||||
let extent = 20.0;
|
||||
let spacing = 1.0;
|
||||
let half = 10.0; // steps=10 * spacing=1
|
||||
let verts = build_ground_grid(elevation, extent, spacing);
|
||||
for v in verts.chunks(GRID_FLOATS_PER_VERTEX) {
|
||||
let (x, y, z) = (v[0], v[1], v[2]);
|
||||
assert_eq!(y, elevation, "alle Vertices auf y = elevation");
|
||||
assert!(x.abs() <= half + 1e-6);
|
||||
assert!(z.abs() <= half + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_endpoints_and_center_is_major() {
|
||||
let extent = 20.0;
|
||||
let spacing = 1.0;
|
||||
let half = 10.0;
|
||||
let verts = build_ground_grid(0.0, extent, spacing);
|
||||
// Erste Linie: i = -steps = -10 (kein Vielfaches von 5? -10 % 5 == 0 -> Major).
|
||||
// X-parallele Linie bei z = -10, von x=-half..+half.
|
||||
assert_eq!(&verts[0..3], &[-half, 0.0, -10.0]);
|
||||
assert_eq!(&verts[3..6], &MAJOR_COLOR); // -10 ist Vielfaches von 5 -> Major
|
||||
assert_eq!(&verts[6..9], &[half, 0.0, -10.0]);
|
||||
|
||||
// Mittelachse (i = 0) muss als Major vorkommen: suche Vertex bei z=0 X-Linie.
|
||||
let center_is_major = verts
|
||||
.chunks(GRID_FLOATS_PER_VERTEX)
|
||||
.any(|v| v[2] == 0.0 && v[3..6] == MAJOR_COLOR);
|
||||
assert!(center_is_major);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extent_snaps_to_spacing_multiple() {
|
||||
// extent 21, spacing 2 -> steps = floor(10.5/2) = 5 -> half = 10 (nicht 10.5).
|
||||
let verts = build_ground_grid(0.0, 21.0, 2.0);
|
||||
let max_abs = verts
|
||||
.chunks(GRID_FLOATS_PER_VERTEX)
|
||||
.flat_map(|v| [v[0].abs(), v[2].abs()])
|
||||
.fold(0.0_f32, f32::max);
|
||||
assert_eq!(max_abs, 10.0);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,13 @@
|
||||
// Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die
|
||||
// GPU-freien Schichten und ist damit unabhaengig von einer Display-Session.
|
||||
|
||||
pub mod edges;
|
||||
pub mod grid;
|
||||
pub mod math;
|
||||
pub mod mesh;
|
||||
pub(crate) mod openings;
|
||||
pub mod section;
|
||||
pub mod section_fill;
|
||||
pub mod shaders;
|
||||
pub mod types;
|
||||
|
||||
@@ -33,12 +37,16 @@ pub use math::{
|
||||
look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix,
|
||||
view_matrix, view_projection, Mat4,
|
||||
};
|
||||
pub use mesh::{build_model_mesh, build_walls_mesh, extrude_slab, extrude_wall, triangulate};
|
||||
pub use mesh::{
|
||||
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, Point2, Projection, Rgb, SlabInput, WallInput, FLOATS_PER_VERTEX,
|
||||
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, WallInput,
|
||||
FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
|
||||
@@ -46,7 +54,7 @@ pub use types::{
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX};
|
||||
use super::types::{Mesh, WallInput, FLOATS_PER_VERTEX};
|
||||
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 {
|
||||
@@ -58,6 +66,7 @@ mod tests {
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +103,7 @@ mod tests {
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
},
|
||||
]);
|
||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
|
||||
@@ -171,6 +181,7 @@ mod tests {
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
};
|
||||
let mut mesh = Mesh::default();
|
||||
extrude_wall(&mut mesh, &w);
|
||||
@@ -191,12 +202,332 @@ mod tests {
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
};
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
assert!(mesh.indices.is_empty());
|
||||
}
|
||||
|
||||
// --- Oeffnungen (Fenster/Tueren) in der Vollkoerper-Extrusion -------------
|
||||
|
||||
#[test]
|
||||
fn fenster_erzeugt_pfeiler_bruestung_sturz_und_sichtbare_luecke() {
|
||||
// Wand entlang +X, Laenge 4, ein Fenster bei x in [1.5,2.5], Bruestung
|
||||
// 0.9, Kopfhoehe 2.1 (< Wandhoehe 2.5) -> vier Teil-Quader: Pfeiler
|
||||
// 0..1.5 (voll), Bruestung 1.5..2.5 (0..0.9), Sturz 1.5..2.5 (2.1..2.5),
|
||||
// Pfeiler 2.5..4 (voll). Kein Vollkoerper mehr ueber die Oeffnung.
|
||||
let mut w = wall_x(4.0, 0.2, 2.5);
|
||||
w.openings = vec![Opening {
|
||||
from: 1.5,
|
||||
to: 2.5,
|
||||
sill: 0.9,
|
||||
height: 1.2,
|
||||
}];
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
assert_eq!(
|
||||
mesh.vertex_count(),
|
||||
4 * VERTS_PER_BOX,
|
||||
"vier Teil-Quader (2 Pfeiler + Bruestung + Sturz)"
|
||||
);
|
||||
assert_eq!(mesh.indices.len(), 4 * INDICES_PER_BOX);
|
||||
let max_idx = *mesh.indices.iter().max().unwrap();
|
||||
assert!((max_idx as usize) < mesh.vertex_count());
|
||||
|
||||
// Aussenmasse bleiben unveraendert (Pfeiler+Bruestung+Sturz decken
|
||||
// zusammen wieder die volle Bounding-Box ab).
|
||||
let (min, max) = mesh.bounds();
|
||||
assert!((min[0]).abs() < 1e-5 && (max[0] - 4.0).abs() < 1e-5);
|
||||
assert!((min[1]).abs() < 1e-5 && (max[1] - 2.5).abs() < 1e-5);
|
||||
|
||||
// Sichtbare Luecke: kein Vertex liegt (mit Sicherheitsabstand) im
|
||||
// Oeffnungs-Loch selbst (x in (1.5,2.5) UND y in (0.9,2.1)) — dort darf
|
||||
// keine Mesh-Flaeche mehr existieren.
|
||||
let margin = 0.05;
|
||||
let n = mesh.vertex_count();
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
let inside_hole = p[0] > 1.5 + margin
|
||||
&& p[0] < 2.5 - margin
|
||||
&& p[1] > 0.9 + margin
|
||||
&& p[1] < 2.1 - margin;
|
||||
assert!(!inside_hole, "Vertex {i} bei {p:?} liegt im Fensterloch");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuer_erreicht_den_boden_keine_bruestung() {
|
||||
// Tuer (sill == 0.0) reicht bis zum Boden -> keine Bruestung, nur zwei
|
||||
// volle Pfeiler + ein Sturz-Quader ueber der Tuer (drei Teil-Quader).
|
||||
let mut w = wall_x(4.0, 0.2, 2.5);
|
||||
w.openings = vec![Opening {
|
||||
from: 1.0,
|
||||
to: 1.8,
|
||||
sill: 0.0,
|
||||
height: 2.1,
|
||||
}];
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
assert_eq!(
|
||||
mesh.vertex_count(),
|
||||
3 * VERTS_PER_BOX,
|
||||
"zwei Pfeiler + ein Sturz, keine Bruestung"
|
||||
);
|
||||
assert_eq!(mesh.indices.len(), 3 * INDICES_PER_BOX);
|
||||
|
||||
// Im Tuerbereich (x in (1.0,1.8)) darf unterhalb der Kopfhoehe (2.1)
|
||||
// keine Geometrie liegen -> jeder Vertex dort liegt auf/ueber dem Sturz.
|
||||
let margin = 0.05;
|
||||
let n = mesh.vertex_count();
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
let in_door_span = p[0] > 1.0 + margin && p[0] < 1.8 - margin;
|
||||
if in_door_span {
|
||||
assert!(
|
||||
p[1] > 2.1 - 1e-4,
|
||||
"Vertex {i} bei {p:?} liegt unter der Tuer-Kopfhoehe"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oeffnung_ausserhalb_der_wandlaenge_erzeugt_vollkoerper() {
|
||||
// Oeffnung komplett ausserhalb [0,length] -> Regressionsfall: ein
|
||||
// einzelner Vollkoerper-Quader wie ohne Oeffnungen.
|
||||
let mut w = wall_x(4.0, 0.2, 2.5);
|
||||
w.openings = vec![Opening {
|
||||
from: 5.0,
|
||||
to: 6.0,
|
||||
sill: 0.9,
|
||||
height: 1.2,
|
||||
}];
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
assert_eq!(mesh.vertex_count(), VERTS_PER_BOX);
|
||||
assert_eq!(mesh.indices.len(), INDICES_PER_BOX);
|
||||
}
|
||||
|
||||
// --- Ecken-Verschneidung (Gehrung) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rechtwinklige_ecke_wird_gehrt_kein_ueberstand_kein_spalt() {
|
||||
// Zwei Waende entlang +X bzw. +Y, die sich exakt im Ursprung treffen
|
||||
// (L-Ecke, Dicke 0.4 -> Halbdicke 0.2). Ohne Gehrung ueberlappen sich die
|
||||
// Baender im Eckbereich (jede Wand ragt um die Halbdicke der jeweils
|
||||
// anderen ueber den Knoten hinaus); MIT Gehrung muessen alle Vertices IM
|
||||
// Eckbereich exakt auf der 45-Grad-Gehrungslinie |x| == |z| liegen —
|
||||
// weder darueber hinaus (Ueberstand) noch darunter (Spalt).
|
||||
let a = 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,
|
||||
};
|
||||
let b = WallInput {
|
||||
start: [0.0, 0.0],
|
||||
end: [0.0, 3.0],
|
||||
thickness: 0.4,
|
||||
height: 2.5,
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
};
|
||||
let mesh = build_walls_mesh(&[a, b]);
|
||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
|
||||
|
||||
// Jeder Vertex nahe dem Knoten (innerhalb der doppelten Halbdicke) muss
|
||||
// auf der Gehrungslinie liegen: world.x und world.z (== Grundriss-y)
|
||||
// betragsgleich (45-Grad-Winkelhalbierende der beiden Achsen).
|
||||
let near_corner_margin = 0.5; // deutlich groesser als die Halbdicke 0.2
|
||||
let n = mesh.vertex_count();
|
||||
let mut checked_any = false;
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
if p[0].abs() < near_corner_margin && p[2].abs() < near_corner_margin {
|
||||
checked_any = true;
|
||||
assert!(
|
||||
(p[0].abs() - p[2].abs()).abs() < 1e-4,
|
||||
"Eck-Vertex {i} bei {p:?} liegt nicht auf der 45-Grad-Gehrungslinie"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(checked_any, "Test hat keinen Eck-Vertex gefunden (Setup falsch)");
|
||||
|
||||
// Kein Vertex ragt ueber die Gehrungslinie hinaus: fuer Punkte mit x>0
|
||||
// UND z>0 (im "inneren" Quadranten der Ecke) muss min(x,z) <= Halbdicke
|
||||
// gelten -- sonst wuerde die Wand ueber die andere hinausragen.
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
if p[0] > 1e-4 && p[2] > 1e-4 && p[0] < near_corner_margin && p[2] < near_corner_margin {
|
||||
assert!(
|
||||
p[0].min(p[2]) <= 0.2 + 1e-4,
|
||||
"Vertex {i} bei {p:?} ragt ueber die Gehrung hinaus"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freies_wandende_bleibt_rechtwinklig() {
|
||||
// Eine einzelne Wand ohne Nachbarn: keine Gehrung, Enden bleiben exakt
|
||||
// senkrecht zur Achse -> identisch zum Einzel-Wand-Regressionsfall.
|
||||
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
|
||||
let (min, max) = mesh.bounds();
|
||||
assert!((min[0]).abs() < 1e-5 && (max[0] - 3.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_stoss_bleibt_rechtwinklig() {
|
||||
// Drei Wandenden treffen sich im selben Knoten (T-Stoss) -> gemaess
|
||||
// Moduldoc/`joins.ts` bewusst NICHT gehrt (mehrdeutig), alle drei bleiben
|
||||
// rechtwinklig. Regressions-Absicherung: keine Panik, stabile Zaehlung.
|
||||
let mk = |s: [f32; 2], e: [f32; 2]| WallInput {
|
||||
start: s,
|
||||
end: e,
|
||||
thickness: 0.2,
|
||||
height: 2.5,
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
};
|
||||
let walls = vec![
|
||||
mk([0.0, 0.0], [3.0, 0.0]),
|
||||
mk([0.0, 0.0], [0.0, 3.0]),
|
||||
mk([0.0, 0.0], [-3.0, 0.0]),
|
||||
];
|
||||
let mesh = build_walls_mesh(&walls);
|
||||
assert_eq!(mesh.vertex_count(), 3 * VERTS_PER_BOX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zwei_geschosse_mit_gleichem_grundriss_verschneiden_sich_nicht_ueber_geschosse() {
|
||||
// EG (Hoehe 0..2.5) und OG (Basis 2.5, Hoehe 2.5..5.0) mit identischem
|
||||
// L-Grundriss: die Hoehenbereiche BERUEHREN sich nur (bei 2.5), ueber-
|
||||
// lappen aber nicht -> jedes Geschoss wird NUR gegen sein eigenes
|
||||
// Pendant gehrt, nicht ueber die Geschossgrenze hinweg. Absicherung:
|
||||
// beide Geschosse zusammen liefern exakt dieselbe Gehrung wie ein
|
||||
// einzelnes Geschoss fuer sich (Vertex-Zaehlung + Eck-Vertices je
|
||||
// Geschoss weiterhin auf der 45-Grad-Linie relativ zu SEINER Basis).
|
||||
let mk = |s: [f32; 2], e: [f32; 2], base: f32| WallInput {
|
||||
start: s,
|
||||
end: e,
|
||||
thickness: 0.4,
|
||||
height: 2.5,
|
||||
base_elevation: base,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
};
|
||||
let walls = vec![
|
||||
mk([0.0, 0.0], [3.0, 0.0], 0.0),
|
||||
mk([0.0, 0.0], [0.0, 3.0], 0.0),
|
||||
mk([0.0, 0.0], [3.0, 0.0], 2.5),
|
||||
mk([0.0, 0.0], [0.0, 3.0], 2.5),
|
||||
];
|
||||
let mesh = build_walls_mesh(&walls);
|
||||
assert_eq!(mesh.vertex_count(), 4 * VERTS_PER_BOX);
|
||||
let n = mesh.vertex_count();
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
if p[0].abs() < 0.5 && p[2].abs() < 0.5 {
|
||||
assert!(
|
||||
(p[0].abs() - p[2].abs()).abs() < 1e-4,
|
||||
"Eck-Vertex {i} bei {p:?} (Geschoss-Basis {}) nicht auf der Gehrung",
|
||||
p[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Schichten (WallInput::layers) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ohne_layers_entsteht_weiterhin_ein_vollkoerper() {
|
||||
// Regressionsfall: `layers: None` extrudiert exakt wie zuvor.
|
||||
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
|
||||
assert_eq!(mesh.vertex_count(), VERTS_PER_BOX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_wand_liefert_ein_prisma_je_schicht() {
|
||||
// Drei Schichten (Putz/Daemmung/Mauerwerk) -> drei eigenstaendige
|
||||
// Quader, je VERTS_PER_BOX Vertices, ohne Oeffnungen.
|
||||
let mut w = wall_x(3.0, 0.3, 2.5);
|
||||
w.layers = Some(vec![
|
||||
WallLayer { thickness: 0.02, color: [1.0, 1.0, 1.0] },
|
||||
WallLayer { thickness: 0.18, color: [0.9, 0.85, 0.7] },
|
||||
WallLayer { thickness: 0.10, color: [0.6, 0.3, 0.2] },
|
||||
]);
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
assert_eq!(mesh.vertex_count(), 3 * VERTS_PER_BOX);
|
||||
assert_eq!(mesh.indices.len(), 3 * INDICES_PER_BOX);
|
||||
|
||||
// Die Schichten decken zusammen exakt die Gesamtdicke (0.3, Z in
|
||||
// -0.15..0.15) lueckenlos und ueberlappungsfrei ab.
|
||||
let (min, max) = mesh.bounds();
|
||||
assert!((min[2] + 0.15).abs() < 1e-4, "min z == -0.15");
|
||||
assert!((max[2] - 0.15).abs() < 1e-4, "max z == 0.15");
|
||||
|
||||
// Jede Schicht-Farbe taucht in den Vertex-Farben auf (Layer wurden
|
||||
// tatsaechlich mit ihrer eigenen Farbe extrudiert, nicht der Wandfarbe).
|
||||
let n = mesh.vertex_count();
|
||||
let has_color = |target: [f32; 3]| {
|
||||
(0..n).any(|i| {
|
||||
let b = i * FLOATS_PER_VERTEX;
|
||||
let c = [mesh.verts[b + 6], mesh.verts[b + 7], mesh.verts[b + 8]];
|
||||
(c[0] - target[0]).abs() < 1e-6
|
||||
&& (c[1] - target[1]).abs() < 1e-6
|
||||
&& (c[2] - target[2]).abs() < 1e-6
|
||||
})
|
||||
};
|
||||
assert!(has_color([1.0, 1.0, 1.0]), "Putz-Farbe fehlt");
|
||||
assert!(has_color([0.9, 0.85, 0.7]), "Daemmungs-Farbe fehlt");
|
||||
assert!(has_color([0.6, 0.3, 0.2]), "Mauerwerk-Farbe fehlt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_ecke_wird_ebenfalls_gehrt() {
|
||||
// Zwei mehrschichtige Waende an einer Ecke: JEDE Schicht nutzt dasselbe
|
||||
// gehrte Fussabdruck-Band -> auch hier landen alle Eck-Vertices exakt
|
||||
// auf der 45-Grad-Gehrungslinie (analog zum Vollkoerper-Test oben).
|
||||
let layers = || {
|
||||
Some(vec![
|
||||
WallLayer { thickness: 0.15, color: [0.9, 0.85, 0.7] },
|
||||
WallLayer { thickness: 0.15, color: [0.6, 0.3, 0.2] },
|
||||
])
|
||||
};
|
||||
let a = 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: layers(),
|
||||
};
|
||||
let mut b = a.clone();
|
||||
b.end = [0.0, 3.0];
|
||||
|
||||
let mesh = build_walls_mesh(&[a, b]);
|
||||
let n = mesh.vertex_count();
|
||||
assert_eq!(n, 4 * VERTS_PER_BOX, "2 Waende x 2 Schichten");
|
||||
for i in 0..n {
|
||||
let (p, _) = vert(&mesh, i);
|
||||
if p[0].abs() < 0.4 && p[2].abs() < 0.4 {
|
||||
assert!(
|
||||
(p[0].abs() - p[2].abs()).abs() < 1e-4,
|
||||
"Layer-Eck-Vertex {i} bei {p:?} nicht auf der Gehrung"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Deckenplatten (extrudierte Polygone) ---------------------------------
|
||||
|
||||
use super::mesh::{build_model_mesh, extrude_slab, triangulate};
|
||||
@@ -300,6 +631,98 @@ mod tests {
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
}
|
||||
|
||||
// --- Rohe Kontext-Meshes (Terrain / importierte Volumen) ------------------
|
||||
|
||||
use super::mesh::{append_context_mesh, build_scene_mesh};
|
||||
use super::types::{MeshInput, MeshKind};
|
||||
|
||||
/// Ein flaches Dreieck auf Modell-Hoehe `z` (Modell x,y,z=Hoehe).
|
||||
fn flat_context_tri(z: f32, kind: MeshKind, color: Option<[f32; 3]>) -> MeshInput {
|
||||
MeshInput {
|
||||
positions: vec![0.0, 0.0, z, 1.0, 0.0, z, 0.0, 1.0, z],
|
||||
indices: vec![0, 1, 2],
|
||||
kind,
|
||||
color,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mesh_ist_doppelseitig_und_mappt_hoehe_nach_y() {
|
||||
let mut mesh = Mesh::default();
|
||||
append_context_mesh(&mut mesh, &flat_context_tri(5.0, MeshKind::Terrain, None));
|
||||
// Ein Quell-Dreieck -> zwei (Vorder-/Rueckseite) = 6 Vertices.
|
||||
assert_eq!(mesh.triangle_count(), 2);
|
||||
assert_eq!(mesh.vertex_count(), 6);
|
||||
// model (x,y,z) -> world (x, z, y): die Hoehe z=5 liegt auf world.y=5.
|
||||
let (min, max) = mesh.bounds();
|
||||
assert!((min[1] - 5.0).abs() < 1e-5 && (max[1] - 5.0).abs() < 1e-5, "flach auf y=5");
|
||||
// Doppelseitig: eine Normale +Y, eine −Y.
|
||||
let mut saw_up = false;
|
||||
let mut saw_down = false;
|
||||
for i in 0..mesh.vertex_count() {
|
||||
let ny = mesh.verts[i * FLOATS_PER_VERTEX + 4];
|
||||
if ny > 0.5 {
|
||||
saw_up = true;
|
||||
}
|
||||
if ny < -0.5 {
|
||||
saw_down = true;
|
||||
}
|
||||
}
|
||||
assert!(saw_up && saw_down, "Vorder- UND Rueckseite (±Y)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mesh_default_farbe_je_kind() {
|
||||
// Ohne explizite Farbe greift die kind-Default-Farbe (three.js mats.*).
|
||||
let mut mesh = Mesh::default();
|
||||
append_context_mesh(&mut mesh, &flat_context_tri(0.0, MeshKind::Terrain, None));
|
||||
let c = [mesh.verts[6], mesh.verts[7], mesh.verts[8]];
|
||||
assert_eq!(c, MeshKind::Terrain.default_color());
|
||||
// Explizite Farbe schlaegt den Default.
|
||||
let mut mesh2 = Mesh::default();
|
||||
append_context_mesh(&mut mesh2, &flat_context_tri(0.0, MeshKind::Imported, Some([0.1, 0.2, 0.3])));
|
||||
assert_eq!([mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]], [0.1, 0.2, 0.3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() {
|
||||
let mut mesh = Mesh::default();
|
||||
// Index 9 out-of-range (nur 3 Vertices) -> Dreieck verworfen, kein Panic.
|
||||
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, 9],
|
||||
kind: MeshKind::Imported,
|
||||
color: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
// Kollineares (entartetes) Dreieck -> keine Normale, verworfen.
|
||||
append_context_mesh(
|
||||
&mut mesh,
|
||||
&MeshInput {
|
||||
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.0, 0.0],
|
||||
indices: vec![0, 1, 2],
|
||||
kind: MeshKind::Imported,
|
||||
color: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_scene_mesh_haengt_kontext_meshes_hinten_an() {
|
||||
let model = build_model_mesh(&[wall_x(4.0, 0.2, 2.6)], &[square_slab(4.0, 2.6, 2.85)]);
|
||||
let scene = build_scene_mesh(
|
||||
&[wall_x(4.0, 0.2, 2.6)],
|
||||
&[square_slab(4.0, 2.6, 2.85)],
|
||||
&[flat_context_tri(0.0, MeshKind::Terrain, None)],
|
||||
);
|
||||
// Szene = Modell (Wand+Slab) + 6 Kontext-Vertices (doppelseitiges Dreieck).
|
||||
assert_eq!(scene.vertex_count(), model.vertex_count() + 6);
|
||||
}
|
||||
|
||||
// --- Kamera / Matrizen ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -363,7 +786,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
preset_camera(CameraPreset::Iso, t, 10.0).projection,
|
||||
Projection::Perspective
|
||||
Projection::Orthographic
|
||||
);
|
||||
assert_eq!(
|
||||
preset_camera(CameraPreset::Persp, t, 10.0).projection,
|
||||
@@ -388,5 +811,21 @@ mod tests {
|
||||
validator
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("mesh: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
|
||||
// Bodengitter-Shader ebenso headless validieren.
|
||||
let grid_src = super::shaders::GRID_WGSL;
|
||||
let grid_module = naga::front::wgsl::parse_str(grid_src)
|
||||
.unwrap_or_else(|e| panic!("grid: WGSL-Parse-Fehler: {e:?}"));
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&grid_module)
|
||||
.unwrap_or_else(|e| panic!("grid: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
|
||||
// Schnittflaechen-Kappen-Shader (Live-Schnitt-Schraffur) ebenso.
|
||||
let cap_src = super::shaders::CAP_WGSL;
|
||||
let cap_module = naga::front::wgsl::parse_str(cap_src)
|
||||
.unwrap_or_else(|e| panic!("cap: WGSL-Parse-Fehler: {e:?}"));
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&cap_module)
|
||||
.unwrap_or_else(|e| panic!("cap: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,8 +186,9 @@ pub fn orbit_eye(target: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> [f32; 3]
|
||||
}
|
||||
|
||||
/// Baut eine `Camera` fuer eines der fuenf Presets der three.js-Sicht. `target`
|
||||
/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side sind
|
||||
/// orthografisch achsparallel; Iso/Persp sind perspektivisch.
|
||||
/// 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,
|
||||
@@ -216,12 +217,18 @@ 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;
|
||||
}
|
||||
// Isometrischer Ueberblick (perspektivisch, Blick von schraeg oben).
|
||||
// Isometrischer Ueberblick (orthografisch, Blick von schraeg oben).
|
||||
// Echte Isometrie ist per Definition parallelprojiziert (keine
|
||||
// perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu).
|
||||
CameraPreset::Iso => {
|
||||
cam.projection = Projection::Perspective;
|
||||
cam.projection = Projection::Orthographic;
|
||||
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).
|
||||
CameraPreset::Persp => {
|
||||
|
||||
+569
-41
@@ -8,14 +8,59 @@
|
||||
// Diese 2D-Band-Ecken werden nach world abgebildet (XZ-Ebene) und entlang +Y von
|
||||
// `base_elevation` bis `base_elevation + height` extrudiert — analog zur
|
||||
// three.js-`ExtrudeGeometry`, die den 2D-`clippedBand` hochzieht (Viewport3D.tsx
|
||||
// `addLayerPrism`). M1 nimmt die stumpfe (rechtwinklige) Band-Form; Gehrungen
|
||||
// (`computeJoins`/`clippedBand`) und Oeffnungen kommen in spaeteren Milestones.
|
||||
// `addLayerPrism`).
|
||||
//
|
||||
// ECKEN-VERSCHNEIDUNG (Gehrung): `compute_wall_miters` berechnet — analog zum
|
||||
// Web-Pendant `src/model/joins.ts::computeJoins` + `geometry.ts::clippedBand` —
|
||||
// fuer jede Wand die Gehrungslinien an ihren ECHTEN Achsenenden, rein aus der
|
||||
// Wandkonnektivitaet (Endpunkte, die sich bei genau ZWEI, nicht-parallelen
|
||||
// Waenden treffen). Anders als der Web-Pfad braucht das HIER keine eigenen
|
||||
// Eingabefelder: die Berechnung laeuft direkt auf `WallInput` (Start/Ende/
|
||||
// Dicke), robust auch gegenueber der Teilquader-Zerlegung an Oeffnungen (siehe
|
||||
// unten) — zwei Teilstuecke DERSELBEN Wand haben stets dieselbe (parallele)
|
||||
// Achsrichtung, ihre "Gehrung" waere kollinear und wird automatisch verworfen
|
||||
// (siehe `miter_line`). Mehrgeschossige Modelle mit deckungsgleichem Grundriss
|
||||
// werden durch einen Hoehen-Ueberlappungstest (`cluster_by_height_overlap`)
|
||||
// sauber getrennt (kein Verschneiden ueber Geschosse hinweg). T-/X-Stoesse
|
||||
// (mehr als zwei Enden an einem Knoten) bleiben — wie im Web-Pendant —
|
||||
// rechtwinklig. Das Schnitt-Modul (`section.rs`) nutzt fuer die WAND-CUT-Polygone
|
||||
// denselben gehrten Fussabdruck (`wall_mitered_footprints`), damit ein Schnitt an
|
||||
// einer Wandecke genau EINEN verschnittenen Querschnitt liefert statt zweier
|
||||
// ueberlappender Rechtecke; seine Verdeckungs-Naeherung bleibt achsparallel
|
||||
// (siehe dessen Moduldoc).
|
||||
//
|
||||
// SCHICHTEN (`WallInput::layers`): mehrschichtige Waende (Putz/Daemmung/
|
||||
// Mauerwerk/...) werden nicht als ein Vollkoerper, sondern als je EIN Prisma
|
||||
// pro Schicht extrudiert, quer zur Gesamtdicke gestapelt (gleiche Idee wie
|
||||
// Viewport3D.tsx `addWallMeshes`: `off` laeuft ueber die Schichtdicken).
|
||||
// Jede Schicht nutzt DASSELBE gehrte Fussabdruck-Band wie Gap 1 (nur mit ihrem
|
||||
// eigenen Dicken-Versatz), sodass auch mehrschichtige Waende an Ecken saeuberlich
|
||||
// stossen. Fehlt `layers` (None/leer), wird ein synthetisches Einzel-Layer aus
|
||||
// `thickness`/`color` gebildet — bitgenau dasselbe Ergebnis wie zuvor.
|
||||
//
|
||||
// OEFFNUNGEN (Tueren/Fenster, `WallInput::openings`): eine Wand mit Oeffnungen
|
||||
// wird NICHT als ein Vollkoerper-Quader extrudiert, sondern in mehrere
|
||||
// Teil-Quader zerlegt — volle Hoehe zwischen Oeffnungen (Pfeiler), Bruestung
|
||||
// unterhalb und Sturz oberhalb jeder Oeffnung (`wall_solid_segments`). Das ist
|
||||
// exakt dieselbe Bruestungs-/Sturz-Zerlegung wie `section::wall_cut_rectangles`
|
||||
// fuer die Schnitt-Cut-Polygone (gemeinsame Basis: `crate::openings::
|
||||
// split_range_by_voids`), nur direkt in Wandachsen-Metern statt in der
|
||||
// Projektions-u-Achse einer Schnittebene — damit zeigt die 3D-Vollansicht
|
||||
// (`?engine=wasm`) dieselbe Oeffnung wie der Schnitt. Die Gehrung (siehe oben)
|
||||
// greift dabei NUR an Teilstuecken, die tatsaechlich an ein echtes Wandende
|
||||
// grenzen (Achsposition 0 bzw. Wandlaenge) — innere Teilstuecke (Laibungen an
|
||||
// Oeffnungen) bleiben immer rechtwinklig.
|
||||
//
|
||||
// KOORDINATEN: model [x, y] -> world (x, elevation, y). Also Grundriss in der
|
||||
// XZ-Ebene, Extrusion entlang +Y (Y-up), exakt wie Viewport3D.tsx:
|
||||
// "Modell (x,y,z) -> Three (x, z, y) (Z = Hoehe nach oben)".
|
||||
|
||||
use crate::types::{Mesh, Point2, Rgb, SlabInput, WallInput, FLOATS_PER_VERTEX};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::openings::{split_range_by_voids, MIN_SPAN};
|
||||
use crate::types::{
|
||||
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
|
||||
/// Dreiecken = 12 Dreiecke, mit flachen Normalen also 24 Vertices (je Seite 4,
|
||||
@@ -35,31 +80,173 @@ fn left_normal(start: Point2, end: Point2) -> Point2 {
|
||||
[-dy / len, dx / len]
|
||||
}
|
||||
|
||||
/// Extrudiert EINE Wand zu einem Quader-Mesh und haengt sie an `mesh` an.
|
||||
///
|
||||
/// Aufbau: die vier Grundriss-Ecken des Bandes (CCW) bilden das Deckel-Rechteck.
|
||||
/// Boden = gleiche XZ auf `y0`, Deckel = auf `y1`. Jede der sechs Seiten bekommt
|
||||
/// eigene Vertices mit ihrer (nach aussen zeigenden) Flaechen-Normale — daher
|
||||
/// flaches Shading und korrektes Backface-Culling.
|
||||
/// Extrudiert EINE Wand (ohne Kenntnis von Nachbarn, also stets rechtwinklige
|
||||
/// Enden) zu einem oder mehreren Quader-Meshes. Fuer Szenen mit mehreren
|
||||
/// Waenden bitte `build_walls_mesh`/`build_model_mesh` nutzen — die berechnen
|
||||
/// zuerst die Eck-Gehrung ueber alle Waende (siehe `compute_wall_miters`) und
|
||||
/// wenden sie hier mit an. Diese Funktion bleibt der einfache Einzel-Wand-
|
||||
/// Einstieg (u. a. fuer bestehende Tests) und entspricht exakt dem Aufruf mit
|
||||
/// leeren Gehrungen.
|
||||
pub fn extrude_wall(mesh: &mut Mesh, wall: &WallInput) {
|
||||
extrude_wall_with_miters(mesh, wall, WallMiters::default());
|
||||
}
|
||||
|
||||
/// Wie `extrude_wall`, aber mit den (ggf. leeren) Gehrungslinien an den
|
||||
/// echten Wandenden. Ohne Oeffnungen und ohne `layers` entsteht wie zuvor
|
||||
/// GENAU ein Quader je Wand (`VERTS_PER_BOX`/`INDICES_PER_BOX`); mit
|
||||
/// Oeffnungen wird die Wand zuerst in Achsen-Teilstuecke (Pfeiler/Bruestung/
|
||||
/// Sturz) zerlegt, mit `layers` zusaetzlich in Schicht-Baender quer zur
|
||||
/// Dicke — siehe Moduldoc.
|
||||
fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiters) {
|
||||
let n = left_normal(wall.start, wall.end);
|
||||
// Degenerierte Wand (Start == Ende) ueberspringen.
|
||||
if n == [0.0, 0.0] {
|
||||
return;
|
||||
}
|
||||
let h = wall.thickness * 0.5;
|
||||
let dx = wall.end[0] - wall.start[0];
|
||||
let dy = wall.end[1] - wall.start[1];
|
||||
let length = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
// Vier Band-Ecken im Grundriss (CCW, wie `wallCorners(start,end,thickness)`):
|
||||
// p0 = start + n*h, p1 = end + n*h, p2 = end - n*h, p3 = start - n*h.
|
||||
let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s * h, p[1] + n[1] * s * h] };
|
||||
let g0 = off(wall.start, 1.0);
|
||||
let g1 = off(wall.end, 1.0);
|
||||
let g2 = off(wall.end, -1.0);
|
||||
let g3 = off(wall.start, -1.0);
|
||||
let z0 = wall.base_elevation;
|
||||
let z1 = wall.base_elevation + wall.height;
|
||||
let segments = wall_solid_segments(length, &wall.openings, z0, z1);
|
||||
|
||||
let y0 = wall.base_elevation;
|
||||
let y1 = wall.base_elevation + wall.height;
|
||||
let color = wall.color;
|
||||
// Schicht-Stapel: ohne `layers` (oder leer) EIN synthetisches Layer aus
|
||||
// der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten.
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
// Schichten von der +n-Seite (offA = +Gesamtdicke/2) zur -n-Seite stapeln,
|
||||
// damit offA stets > offB gilt — exakt die Konvention, unter der die
|
||||
// bisherige Ecken-/Flankennormalen-Herleitung (siehe `extrude_layer_segment`)
|
||||
// gilt. Fuer das synthetische Einzel-Layer ergibt sich offA=+h, offB=-h wie
|
||||
// zuvor.
|
||||
let total: f32 = layers.iter().map(|l| l.thickness).sum();
|
||||
let mut off_a = total * 0.5;
|
||||
for layer in layers {
|
||||
let off_b = off_a - layer.thickness;
|
||||
for &(a0, a1, y0, y1) in &segments {
|
||||
// Gehrung nur an Teilstuecken, die tatsaechlich an ein echtes
|
||||
// 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 };
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zerlegt eine Wandachse (`0..length`, volle Hoehe `[z0,z1]`) an ihren
|
||||
/// `openings` in Voll-/Bruestungs-/Sturz-Rechtecke `(achse0, achse1, y0, y1)` —
|
||||
/// direkt in Wandachsen-Metern, keine Schnittebenen-Projektion noetig (anders
|
||||
/// als `section::wall_cut_rectangles`, das dieselbe Zerlegung fuer eine
|
||||
/// Cut-Ebenen-u-Achse braucht). Ohne Oeffnungen (oder wenn alle ausserhalb
|
||||
/// `[0,length]` liegen) liefert das das eine Vollstueck `[0,length]x[z0,z1]`
|
||||
/// (Regressionsfall, identisch zur bisherigen Vollkoerper-Extrusion).
|
||||
fn wall_solid_segments(
|
||||
length: f32,
|
||||
openings: &[Opening],
|
||||
z0: f32,
|
||||
z1: f32,
|
||||
) -> Vec<(f32, f32, f32, f32)> {
|
||||
if openings.is_empty() {
|
||||
return vec![(0.0, length, z0, z1)];
|
||||
}
|
||||
let mut voids: Vec<(f32, f32, &Opening)> = Vec::new();
|
||||
for op in openings {
|
||||
let from = op.from.max(0.0);
|
||||
let to = op.to.min(length);
|
||||
if to - from > MIN_SPAN {
|
||||
voids.push((from, to, op));
|
||||
}
|
||||
}
|
||||
split_range_by_voids(0.0, length, z0, z1, voids)
|
||||
}
|
||||
|
||||
/// Punkt entlang der Wandachse bei Achsen-Position `a` (Meter ab `wall.start`),
|
||||
/// linear zwischen `wall.start` und `wall.end` interpoliert.
|
||||
fn axis_point(wall: &WallInput, length: f32, a: f32) -> Point2 {
|
||||
if length < 1e-9 {
|
||||
return wall.start;
|
||||
}
|
||||
let t = a / length;
|
||||
[
|
||||
wall.start[0] + (wall.end[0] - wall.start[0]) * t,
|
||||
wall.start[1] + (wall.end[1] - wall.start[1]) * t,
|
||||
]
|
||||
}
|
||||
|
||||
/// Extrudiert EIN Schicht-Achsen-Teilstueck (Achse `[a0,a1]`, Hoehe `[y0,y1]`,
|
||||
/// Dicken-Versatz `[off_a,off_b]` gegen die Achse entlang `n`) einer Wand zu
|
||||
/// einem Quader-Mesh und haengt ihn an `mesh` an. Fuer `a0=0, a1=length,
|
||||
/// y0=base_elevation, y1=base_elevation+height, off_a=+thickness/2,
|
||||
/// off_b=-thickness/2` und ohne Gehrung (`start_cut`/`end_cut` beide `None`)
|
||||
/// ist das exakt die bisherige Vollkoerper-Extrusion (Regressionsfall).
|
||||
///
|
||||
/// GEHRUNG: ist `start_cut`/`end_cut` gesetzt, werden die jeweiligen Band-Ecken
|
||||
/// NICHT rechtwinklig bei `a0`/`a1` gekappt, sondern mit der Gehrungslinie
|
||||
/// verschnitten (analog `clippedBand` im Web) — siehe `compute_wall_miters`.
|
||||
/// Die beiden End-Kappenflaechen bleiben dabei planar (siehe Beweis im
|
||||
/// Moduldoc von `compute_wall_miters`), ihre Normale wird deshalb geometrisch
|
||||
/// aus den tatsaechlichen Kantenvektoren hergeleitet (`quad_normal`) statt aus
|
||||
/// der festen Achsrichtung — das liefert im rechtwinkligen Fall exakt dieselbe
|
||||
/// Normale wie zuvor UND bleibt bei einer schraegen Gehrung korrekt.
|
||||
///
|
||||
/// Aufbau: die vier Grundriss-Ecken des Bandes (CCW) bilden das Deckel-Rechteck.
|
||||
/// Boden = gleiche XZ auf `y0`, Deckel = auf `y1`. Jede der sechs Seiten bekommt
|
||||
/// eigene Vertices mit ihrer (nach aussen zeigenden) Flaechen-Normale — daher
|
||||
/// flaches Shading und korrektes Backface-Culling.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn extrude_layer_segment(
|
||||
mesh: &mut Mesh,
|
||||
wall: &WallInput,
|
||||
n: Point2,
|
||||
length: f32,
|
||||
a0: f32,
|
||||
a1: f32,
|
||||
y0: f32,
|
||||
y1: f32,
|
||||
off_a: f32,
|
||||
off_b: f32,
|
||||
start_cut: Option<MiterLine>,
|
||||
end_cut: Option<MiterLine>,
|
||||
color: Rgb,
|
||||
) {
|
||||
let p0 = axis_point(wall, length, a0);
|
||||
let p1 = axis_point(wall, length, a1);
|
||||
|
||||
// Achsenrichtung der GESAMTEN Wand (fuer Gehrungs-Schnitt und Kappen) —
|
||||
// konstant ueber alle Teilstuecke, unabhaengig von `a0`/`a1`.
|
||||
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];
|
||||
|
||||
// Band-Eckpunkte, ggf. an der Gehrungslinie verschnitten statt rechtwinklig
|
||||
// bei a0/a1 gekappt (siehe Moduldoc oben / `clippedBand` im Web).
|
||||
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),
|
||||
}
|
||||
};
|
||||
let g0 = clip(off(p0, off_a), start_cut);
|
||||
let g1 = clip(off(p1, off_a), end_cut);
|
||||
let g2 = clip(off(p1, off_b), end_cut);
|
||||
let g3 = clip(off(p0, off_b), start_cut);
|
||||
|
||||
// world-Position aus Grundriss-XZ + Hoehe: (x, y, z=grundriss.y).
|
||||
let w = |g: Point2, y: f32| -> [f32; 3] { [g[0], y, g[1]] };
|
||||
@@ -80,27 +267,277 @@ pub fn extrude_wall(mesh: &mut Mesh, wall: &WallInput) {
|
||||
// Boden unten (-Y): b0,b3,b2,b1 (von unten gesehen CCW).
|
||||
push_quad(mesh, b0, b3, b2, b1, [0.0, -1.0, 0.0], color);
|
||||
|
||||
// Vier Mantelflaechen. Aussen-Normalen aus dem Grundriss:
|
||||
// Kante g0->g1 liegt auf der +n-Seite -> Normale +n.
|
||||
// Kante g2->g3 liegt auf der -n-Seite -> Normale -n.
|
||||
// Kappen an Start (g3->g0) und Ende (g1->g2) liegen an den Achsen-Enden.
|
||||
let np = [n[0], 0.0, n[1]]; // +n-Seite (world)
|
||||
let nn = [-n[0], 0.0, -n[1]]; // -n-Seite
|
||||
// Achsenrichtung (fuer die End-Kappen-Normalen).
|
||||
// Zwei Mantelflaechen (+n-/−n-Seite): bleiben bei Gehrung EXAKT planar mit
|
||||
// fester Normale, weil sich die Ecken nur entlang `u` verschieben (siehe
|
||||
// Moduldoc-Beweis) — der `off_a`/`off_b`-Abstand zur Achse bleibt konstant.
|
||||
let np = [n[0], 0.0, n[1]];
|
||||
let nn = [-n[0], 0.0, -n[1]];
|
||||
push_quad(mesh, b0, b1, t1, t0, np, color);
|
||||
push_quad(mesh, b2, b3, t3, t2, nn, color);
|
||||
|
||||
// End-Kappen bei a1/a0: Normale geometrisch hergeleitet (siehe Moduldoc) —
|
||||
// liefert im rechtwinkligen Fall exakt +u/-u, bei Gehrung die tatsaechliche
|
||||
// (schraege) Kappen-Normale.
|
||||
let end_normal = quad_normal(b1, b2, t1);
|
||||
let start_normal = quad_normal(b3, b0, t3);
|
||||
push_quad(mesh, b1, b2, t2, t1, end_normal, color);
|
||||
push_quad(mesh, b3, b0, t0, t3, start_normal, 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
|
||||
/// Moduldoc von `compute_wall_miters`. Degenerierte (nulllange) Flaechen liefern
|
||||
/// den Nullvektor statt NaN (Winkel spielt dort keine Rolle, siehe Aufrufer).
|
||||
fn quad_normal(a: [f32; 3], b: [f32; 3], d: [f32; 3]) -> [f32; 3] {
|
||||
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let ad = [d[0] - a[0], d[1] - a[1], d[2] - a[2]];
|
||||
let c = [
|
||||
ab[1] * ad[2] - ab[2] * ad[1],
|
||||
ab[2] * ad[0] - ab[0] * ad[2],
|
||||
ab[0] * ad[1] - ab[1] * ad[0],
|
||||
];
|
||||
let len = (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt().max(1e-9);
|
||||
[c[0] / len, c[1] / len, c[2] / len]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Ecken-Verschneidung (Gehrung): Wandkonnektivitaet -> Gehrungslinien je Wandende
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Eine Gehrungslinie (Punkt + Richtung, Modell-Meter) an einem Wandende.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct MiterLine {
|
||||
point: Point2,
|
||||
dir: Point2,
|
||||
}
|
||||
|
||||
/// Die (ggf. fehlenden) Gehrungslinien EINER Wand an Start/Ende.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct WallMiters {
|
||||
start: Option<MiterLine>,
|
||||
end: Option<MiterLine>,
|
||||
}
|
||||
|
||||
/// Schnittpunkt der Geraden `a + t*da` mit `b + s*db` (2D). `None` bei
|
||||
/// (nahezu) parallelen Richtungen — 2D-Pendant zu `math::cross`/`lineIntersect`
|
||||
/// im Web (`src/model/geometry.ts`).
|
||||
fn line_intersect_2(a: Point2, da: Point2, b: Point2, db: Point2) -> Option<Point2> {
|
||||
let denom = da[0] * db[1] - da[1] * db[0];
|
||||
if denom.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let bx = b[0] - a[0];
|
||||
let by = b[1] - a[1];
|
||||
let t = (bx * db[1] - by * db[0]) / denom;
|
||||
Some([a[0] + t * da[0], a[1] + t * da[1]])
|
||||
}
|
||||
|
||||
/// Rundungsschluessel eines 2D-Punkts (0.1 mm), zur robusten Gruppierung von
|
||||
/// Wandenden am selben Knoten — 1:1 das Muster von `joins.ts::roundKey`.
|
||||
fn round_key(p: Point2) -> (i64, i64) {
|
||||
(
|
||||
(p[0] * 1e4).round() as i64,
|
||||
(p[1] * 1e4).round() as i64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Ein Wandende, wie es fuer die Knoten-Gruppierung gebraucht wird.
|
||||
struct EndRef {
|
||||
wall: usize,
|
||||
is_start: bool,
|
||||
point: Point2,
|
||||
/// Normalisierte Achsenrichtung Start->Ende der GESAMTEN Wand (konstant,
|
||||
/// unabhaengig davon, ob es sich um das Start- oder Endende handelt).
|
||||
dir: Point2,
|
||||
z0: f32,
|
||||
z1: f32,
|
||||
}
|
||||
|
||||
/// Gruppiert `members` (Indizes in `ends`) nach echt ueberlappendem
|
||||
/// Hoehenbereich (Union-Find) — trennt uebereinanderliegende Geschosse mit
|
||||
/// deckungsgleichem Grundriss (deren Hoehenbereiche sich hoechstens BERUEHREN,
|
||||
/// nicht ueberlappen) von echten Knoten INNERHALB eines Geschosses (deren
|
||||
/// Hoehenbereiche sich ueberschneiden, auch wenn Oeffnungen ein Teilstueck auf
|
||||
/// Bruestung/Sturz reduzieren).
|
||||
fn cluster_by_height_overlap(ends: &[EndRef], members: &[usize]) -> Vec<Vec<usize>> {
|
||||
let n = members.len();
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
fn find(parent: &mut [usize], x: usize) -> usize {
|
||||
if parent[x] != x {
|
||||
let root = find(parent, parent[x]);
|
||||
parent[x] = root;
|
||||
}
|
||||
parent[x]
|
||||
}
|
||||
const OVERLAP_EPS: f32 = 1e-4;
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
let a = &ends[members[i]];
|
||||
let b = &ends[members[j]];
|
||||
let lo = a.z0.max(b.z0);
|
||||
let hi = a.z1.min(b.z1);
|
||||
if hi - lo > OVERLAP_EPS {
|
||||
let ri = find(&mut parent, i);
|
||||
let rj = find(&mut parent, j);
|
||||
if ri != rj {
|
||||
parent[ri] = rj;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut clusters: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for i in 0..n {
|
||||
let r = find(&mut parent, i);
|
||||
clusters.entry(r).or_default().push(members[i]);
|
||||
}
|
||||
clusters.into_values().collect()
|
||||
}
|
||||
|
||||
/// Gemeinsame Gehrungslinie zweier Wandenden `a`, `b`, die sich im selben
|
||||
/// Knoten treffen — 1:1-Port von `joins.ts::miterLine` (Referenzlinie "center",
|
||||
/// da `WallInput` keine `referenceLine`-Information mitfuehrt): `a`s linke
|
||||
/// Flaeche wird mit der naeher gelegenen Flaeche von `b` verschnitten, `a`s
|
||||
/// rechte mit der jeweils anderen. Liefert `None` bei (nahezu) kollinearen
|
||||
/// Richtungen — das deckt insbesondere zwei Teilstuecke DERSELBEN Wand ab
|
||||
/// (identische Richtung, siehe Moduldoc).
|
||||
fn miter_line(walls: &[WallInput], a: &EndRef, b: &EndRef) -> Option<MiterLine> {
|
||||
let j = a.point;
|
||||
let t_a = walls[a.wall].thickness;
|
||||
let t_b = walls[b.wall].thickness;
|
||||
let n_a: Point2 = [-a.dir[1], a.dir[0]];
|
||||
let n_b: Point2 = [-b.dir[1], b.dir[0]];
|
||||
|
||||
let add_scaled = |p: Point2, v: Point2, s: f32| -> Point2 { [p[0] + v[0] * s, p[1] + v[1] * s] };
|
||||
let p_la = add_scaled(j, n_a, t_a * 0.5);
|
||||
let p_ra = add_scaled(j, n_a, -t_a * 0.5);
|
||||
let p_lb = add_scaled(j, n_b, t_b * 0.5);
|
||||
let p_rb = add_scaled(j, n_b, -t_b * 0.5);
|
||||
|
||||
let dist2 = |p: Point2, q: Point2| {
|
||||
let dx = p[0] - q[0];
|
||||
let dy = p[1] - q[1];
|
||||
dx * dx + dy * dy
|
||||
};
|
||||
let lb_closer = dist2(p_la, p_lb) <= dist2(p_la, p_rb);
|
||||
let b_for_left = if lb_closer { p_lb } else { p_rb };
|
||||
let b_for_right = if lb_closer { p_rb } else { p_lb };
|
||||
|
||||
let c1 = line_intersect_2(p_la, a.dir, b_for_left, b.dir)?;
|
||||
let c2 = line_intersect_2(p_ra, a.dir, b_for_right, b.dir)?;
|
||||
let dir = [c2[0] - c1[0], c2[1] - c1[1]];
|
||||
if (dir[0] * dir[0] + dir[1] * dir[1]).sqrt() < 1e-9 {
|
||||
return None; // kollinear -> kein Schnitt
|
||||
}
|
||||
Some(MiterLine { point: c1, dir })
|
||||
}
|
||||
|
||||
fn apply_cut(out: &mut [WallMiters], e: &EndRef, cut: MiterLine) {
|
||||
if e.is_start {
|
||||
out[e.wall].start = Some(cut);
|
||||
} else {
|
||||
out[e.wall].end = Some(cut);
|
||||
}
|
||||
}
|
||||
|
||||
/// Berechnet fuer JEDE Wand in `walls` die Gehrungslinien an ihren echten
|
||||
/// Achsenenden — reine Funktion der Wandkonnektivitaet (Endpunkte + Dicke +
|
||||
/// Hoehenbereich), siehe Moduldoc "Ecken-Verschneidung". Nur Knoten, an denen
|
||||
/// sich (innerhalb desselben Hoehenbereichs) GENAU zwei, nicht-kollineare
|
||||
/// Wandenden treffen, werden gehrt; freie Enden, T-/X-Stoesse und mehrdeutige
|
||||
/// Knoten (z. B. eine Oeffnung direkt an einer echten Ecke, die das Ende in
|
||||
/// mehrere Hoehen-Teilstuecke zerlegt) bleiben konservativ rechtwinklig.
|
||||
fn compute_wall_miters(walls: &[WallInput]) -> Vec<WallMiters> {
|
||||
let mut out = vec![WallMiters::default(); 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, siehe extrude_wall
|
||||
}
|
||||
// Achsenrichtung aus der Links-Normale zurueckgedreht (90 Grad):
|
||||
// n = (-dir.y, dir.x) => dir = (n.y, -n.x). Siehe section.rs::wall_prism.
|
||||
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; // freies Ende
|
||||
}
|
||||
for cluster in cluster_by_height_overlap(&ends, &members) {
|
||||
if cluster.len() != 2 {
|
||||
continue; // T-/X-Stoss oder mehrdeutig -> rechtwinklig
|
||||
}
|
||||
let (ia, ib) = (cluster[0], cluster[1]);
|
||||
let (a, b) = (&ends[ia], &ends[ib]);
|
||||
if a.wall == b.wall {
|
||||
continue; // eine Wand kann sich nicht selbst verschneiden
|
||||
}
|
||||
let Some(cut) = miter_line(walls, a, b) else {
|
||||
continue;
|
||||
};
|
||||
apply_cut(&mut out, a, cut);
|
||||
apply_cut(&mut out, b, cut);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Oeffnung): `off(start/end, +/-thickness/2)`, an Start/Ende gegen die jeweilige
|
||||
/// Gehrungslinie verschnitten. `None` fuer entartete Waende (Start == Ende, wie
|
||||
/// `extrude_wall`). Reihenfolge: start+n, end+n, end-n, start-n.
|
||||
fn wall_mitered_footprint(wall: &WallInput, miters: WallMiters) -> Option<[Point2; 4]> {
|
||||
let n = left_normal(wall.start, wall.end);
|
||||
if n == [0.0, 0.0] {
|
||||
return None;
|
||||
}
|
||||
let h = wall.thickness * 0.5;
|
||||
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 = [ux / ul, 0.0, uz / ul];
|
||||
let neg_u = [-u[0], 0.0, -u[2]];
|
||||
let u: Point2 = [ux / ul, uz / ul];
|
||||
let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s * h, p[1] + n[1] * s * h] };
|
||||
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),
|
||||
}
|
||||
};
|
||||
Some([
|
||||
clip(off(wall.start, 1.0), miters.start),
|
||||
clip(off(wall.end, 1.0), miters.end),
|
||||
clip(off(wall.end, -1.0), miters.end),
|
||||
clip(off(wall.start, -1.0), miters.start),
|
||||
])
|
||||
}
|
||||
|
||||
// +n-Wand: aussen betrachtet CCW = b0, b1, t1, t0.
|
||||
push_quad(mesh, b0, b1, t1, t0, np, color);
|
||||
// -n-Wand: aussen betrachtet CCW = b2, b3, t3, t2.
|
||||
push_quad(mesh, b2, b3, t3, t2, nn, color);
|
||||
// End-Kappe (Achsen-Ende, Normale +u): b1, b2, t2, t1.
|
||||
push_quad(mesh, b1, b2, t2, t1, u, color);
|
||||
// Start-Kappe (Achsen-Anfang, Normale -u): b3, b0, t0, t3.
|
||||
push_quad(mesh, b3, b0, t0, t3, neg_u, color);
|
||||
/// Grundriss-Fussabdruecke ALLER Waende, an ihren echten Ecken verschnitten
|
||||
/// (siehe `wall_mitered_footprint`). Berechnet die Gehrung einmalig ueber die
|
||||
/// gesamte Wandkonnektivitaet (`compute_wall_miters`) und teilt so die
|
||||
/// Ecken-Verschneidungs-Wahrheit mit `section::cut_section` — damit der Schnitt
|
||||
/// an Wandecken denselben verschnittenen Wand-Querschnitt liefert wie die
|
||||
/// 3D-Extrusion, ohne die Gehrungslogik zu duplizieren. Reihenfolge/Anzahl
|
||||
/// deckungsgleich mit `walls` (`None` bei entarteter Wand).
|
||||
pub(crate) fn wall_mitered_footprints(walls: &[WallInput]) -> Vec<Option<[Point2; 4]>> {
|
||||
let miters = compute_wall_miters(walls);
|
||||
walls
|
||||
.iter()
|
||||
.zip(miters)
|
||||
.map(|(w, m)| wall_mitered_footprint(w, m))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Haengt ein Quad (vier Ecken in CCW von aussen) als zwei Dreiecke an, mit fester
|
||||
@@ -125,12 +562,15 @@ fn push_quad(
|
||||
}
|
||||
|
||||
/// Baut ein einzelnes Mesh aus mehreren Waenden (alle in EINEN Puffer). Die
|
||||
/// Reihenfolge der Vertices ist deterministisch (je Wand ein Quader), damit die
|
||||
/// Tests stabile Zaehlungen erwarten koennen.
|
||||
/// Reihenfolge der Vertices ist deterministisch (je Wand ein Quader, bzw. bei
|
||||
/// `layers`/Oeffnungen mehrere), damit die Tests stabile Zaehlungen erwarten
|
||||
/// koennen. Berechnet zuerst die Eck-Gehrung ueber ALLE Waende (siehe
|
||||
/// `compute_wall_miters`) und extrudiert dann jede Wand mit ihren Gehrungen.
|
||||
pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
|
||||
let mut mesh = Mesh::default();
|
||||
for w in walls {
|
||||
extrude_wall(&mut mesh, w);
|
||||
let miters = compute_wall_miters(walls);
|
||||
for (w, m) in walls.iter().zip(miters.iter()) {
|
||||
extrude_wall_with_miters(&mut mesh, w, *m);
|
||||
}
|
||||
mesh
|
||||
}
|
||||
@@ -146,6 +586,94 @@ pub fn build_model_mesh(walls: &[WallInput], slabs: &[SlabInput]) -> Mesh {
|
||||
mesh
|
||||
}
|
||||
|
||||
/// Baut die volle Szene: Waende + Deckenplatten (`build_model_mesh`) und haengt
|
||||
/// danach die rohen Kontext-Meshes (Terrain/importierte Volumen, siehe
|
||||
/// `MeshInput`) an — alles in EINEN Puffer. Die Wand-/Slab-Reihenfolge bleibt
|
||||
/// vorne (deterministische Zaehlung fuer die bestehenden Tests); die Kontext-
|
||||
/// Meshes kommen hinten dran.
|
||||
pub fn build_scene_mesh(walls: &[WallInput], slabs: &[SlabInput], meshes: &[MeshInput]) -> Mesh {
|
||||
let mut mesh = build_model_mesh(walls, slabs);
|
||||
for m in meshes {
|
||||
append_context_mesh(&mut mesh, m);
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
// ── Rohe Kontext-Meshes (Terrain / importierte Volumen) ──────────────────────
|
||||
|
||||
/// Haengt EIN rohes Dreiecks-Mesh (Terrain-TIN oder importiertes Volumen, siehe
|
||||
/// `MeshInput`) an `mesh` an. `positions` ist ein flaches (x,y,z)-Array in
|
||||
/// MODELL-Metern (z = Hoehe), `indices` sind Dreiecks-Indizes. Jede Dreiecks-
|
||||
/// flaeche bekommt eigene Vertices mit ihrer Flaechen-Normale (Flat Shading, wie
|
||||
/// Wand/Slab) — so ist das Ergebnis unabhaengig von geteilten Vertices im
|
||||
/// Quell-Mesh.
|
||||
///
|
||||
/// Koordinaten: model `(x, y, z)` -> world `(x, z, y)` (Hoehe entlang +Y), exakt
|
||||
/// wie `extrude_layer_segment`/`extrude_slab` und `Viewport3D.tsx`.
|
||||
///
|
||||
/// DOPPELSEITIG: jedes Dreieck wird ZWEIMAL emittiert (Vorderseite `a,b,c` mit
|
||||
/// Normale `+n`, Rueckseite `a,c,b` mit `-n`), weil das Quell-Winding von
|
||||
/// Terrain/Import nicht garantiert ist (three.js rendert sie mit `DoubleSide`).
|
||||
/// Da der Pfad Rueckseiten cullt (`gpu.rs`), traegt stets das zum Betrachter
|
||||
/// zeigende Dreieck die korrekte Normale, das andere wird verworfen — keine
|
||||
/// Z-Fighting (beide liegen deckungsgleich, nur eines ist front-facing).
|
||||
pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
|
||||
let pos = &input.positions;
|
||||
let vcount = pos.len() / 3;
|
||||
let color = input.effective_color();
|
||||
// world-Position aus Modell (x, y, z=Hoehe): (x, hoehe, y).
|
||||
let vworld = |i: usize| -> [f32; 3] {
|
||||
let b = i * 3;
|
||||
[pos[b], pos[b + 2], pos[b + 1]]
|
||||
};
|
||||
for t in input.indices.chunks_exact(3) {
|
||||
let (ia, ib, ic) = (t[0] as usize, t[1] as usize, t[2] as usize);
|
||||
// Fehlerhafte Indizes robust ueberspringen (kein Panic bei kaputter Eingabe).
|
||||
if ia >= vcount || ib >= vcount || ic >= vcount {
|
||||
continue;
|
||||
}
|
||||
let a = vworld(ia);
|
||||
let b = vworld(ib);
|
||||
let c = vworld(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]];
|
||||
let gn = [
|
||||
ab[1] * ac[2] - ab[2] * ac[1],
|
||||
ab[2] * ac[0] - ab[0] * ac[2],
|
||||
ab[0] * ac[1] - ab[1] * ac[0],
|
||||
];
|
||||
let len = (gn[0] * gn[0] + gn[1] * gn[1] + gn[2] * gn[2]).sqrt();
|
||||
if len < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let n = [gn[0] / len, gn[1] / len, gn[2] / len];
|
||||
// Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig).
|
||||
push_ctx_tri(mesh, a, b, c, n, color);
|
||||
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Haengt EIN Dreieck (drei world-Ecken) mit gegebener Normale + Farbe an —
|
||||
/// Winding wie uebergeben (im Gegensatz zu `push_tri_oriented`, das umsortiert).
|
||||
/// Wird von `append_context_mesh` fuer beide Seiten genutzt.
|
||||
fn push_ctx_tri(
|
||||
mesh: &mut Mesh,
|
||||
a: [f32; 3],
|
||||
b: [f32; 3],
|
||||
c: [f32; 3],
|
||||
normal: [f32; 3],
|
||||
color: Rgb,
|
||||
) {
|
||||
let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32;
|
||||
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],
|
||||
]);
|
||||
}
|
||||
mesh.indices.extend_from_slice(&[base, base + 1, base + 2]);
|
||||
}
|
||||
|
||||
// ── Deckenplatten (extrudierte Polygone) ─────────────────────────────────────
|
||||
|
||||
/// Signierte Flaeche eines Grundriss-Polygons (Shoelace) in Modell-Koordinaten.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Gemeinsame Rechteck-Zerlegung fuer Wand-Oeffnungen (Fenster/Tueren).
|
||||
//!
|
||||
//! Kern-Idee: ein Achsen-Intervall voller Hoehe (`[z0, z1]`) wird an den darin
|
||||
//! liegenden Oeffnungen in Voll-/Bruestungs-/Sturz-Rechtecke gesplittet —
|
||||
//! Bruestung `[z0, sill]` unterhalb, Sturz `[sill+height, z1]` oberhalb der
|
||||
//! Oeffnung, dazwischen eine Luecke (das Loch). Diese Zerlegung ist
|
||||
//! koordinatenraum-neutral: sie braucht nur ein 1D-Achsen-Intervall plus die
|
||||
//! Oeffnungen als bereits geclippte `(v_lo, v_hi, &Opening)`-Teilstuecke in
|
||||
//! DEMSELBEN Koordinatenraum wie `lo`/`hi`.
|
||||
//!
|
||||
//! Zwei Aufrufer, zwei Koordinatenraeume:
|
||||
//! - `section::wall_cut_rectangles`: Cut-Ebene — die Achsen-Koordinate ist die
|
||||
//! Projektions-u-Achse der Schnittebene (`axis_map` rechnet Wandachsen-Meter
|
||||
//! dorthin um).
|
||||
//! - `mesh::wall_solid_segments`: 3D-Solid-Extrusion — die Achsen-Koordinate
|
||||
//! ist direkt die Wandachse in Metern (0..Wandlaenge), keine Projektion
|
||||
//! noetig.
|
||||
//!
|
||||
//! Beide nutzen `split_range_by_voids` fuer die eigentliche Bruestungs-/
|
||||
//! Sturz-Logik, damit Schnitt-Ansicht und 3D-Solid-Mesh garantiert dasselbe
|
||||
//! Oeffnungsmodell (siehe `types::Opening`) auswerten statt zweier
|
||||
//! divergierender Implementierungen.
|
||||
|
||||
use crate::types::Opening;
|
||||
|
||||
/// Mindestlaenge eines Oeffnungs-/Rest-Teilstuecks, unterhalb derer es als
|
||||
/// vernachlaessigbar (Rand-/Rundungsfall) ignoriert wird.
|
||||
pub(crate) const MIN_SPAN: f32 = 1e-4;
|
||||
|
||||
/// Zerlegt EIN Achsen-Intervall `[lo, hi]` (volle Hoehe `[z0, z1]`) an den in
|
||||
/// `voids` uebergebenen, bereits auf `[lo,hi]` geclippten Oeffnungs-
|
||||
/// Teilstuecken (`(v_lo, v_hi, opening)`, Reihenfolge beliebig) in Rechtecke
|
||||
/// `(u0, u1, v0, v1)`: volle Hoehe ausserhalb von Oeffnungen, Bruestung
|
||||
/// darunter, Sturz darueber (siehe `Opening::sill`/`Opening::height`). Ohne
|
||||
/// Oeffnungen (leeres `voids`) liefert es das eine Vollrechteck `[lo,hi]x[z0,z1]`
|
||||
/// (Regressionsfall).
|
||||
pub(crate) fn split_range_by_voids(
|
||||
lo: f32,
|
||||
hi: f32,
|
||||
z0: f32,
|
||||
z1: f32,
|
||||
mut voids: Vec<(f32, f32, &Opening)>,
|
||||
) -> Vec<(f32, f32, f32, f32)> {
|
||||
if voids.is_empty() {
|
||||
return vec![(lo, hi, z0, z1)];
|
||||
}
|
||||
voids.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
|
||||
let mut rects = Vec::new();
|
||||
let mut cursor = lo;
|
||||
for (v_lo, v_hi, op) in &voids {
|
||||
if *v_lo > cursor + MIN_SPAN {
|
||||
rects.push((cursor, *v_lo, z0, z1)); // Pfeiler zwischen Oeffnungen
|
||||
}
|
||||
let sill_abs = z0 + op.sill.max(0.0);
|
||||
let head_abs = (sill_abs + op.height).min(z1);
|
||||
if sill_abs - z0 > MIN_SPAN {
|
||||
rects.push((*v_lo, *v_hi, z0, sill_abs)); // Bruestung
|
||||
}
|
||||
if z1 - head_abs > MIN_SPAN {
|
||||
rects.push((*v_lo, *v_hi, head_abs, z1)); // Sturz
|
||||
}
|
||||
cursor = cursor.max(*v_hi);
|
||||
}
|
||||
if hi > cursor + MIN_SPAN {
|
||||
rects.push((cursor, hi, z0, z1)); // Rest-Pfeiler nach der letzten Oeffnung
|
||||
}
|
||||
rects
|
||||
}
|
||||
@@ -101,17 +101,27 @@
|
||||
//! (Rechteck-Baender) und Decken (i.d.R. konvexe Rechtecke) ist das exakt;
|
||||
//! fuer stark diagonale oder konkave Grundrisse kann es zu Ueberverdeckung
|
||||
//! fuehren.
|
||||
//! - Keine Wandknoten-Verschneidung (T-/X-Stoesse): Waende werden wie in
|
||||
//! `mesh.rs` als eigenstaendige, stumpf abgeschlossene Quader behandelt, die
|
||||
//! sich an Ecken UEBERLAPPEN (kein Miter-Join). Das kann in Ecknaehe dazu
|
||||
//! fuehren, dass eine Wand als "in eine andere eingebettet" verdeckt erscheint
|
||||
//! (siehe Test `eck_ueberlappung_verdeckt_kante_am_wandknoten` unten) — real,
|
||||
//! aber ein Artefakt der fehlenden Verschneidungslogik, kein Bug dieses Moduls.
|
||||
//! - Oeffnungen werden hier (Cut + Projektion) ausgewertet, aber NICHT von der
|
||||
//! Vollkoerper-Extrusion in `mesh.rs` — die 3D-Solid-Mesh-Erzeugung extrudiert
|
||||
//! weiterhin die volle Wandflaeche (siehe `mesh.rs`-Moduldoc). Cut-Ansicht und
|
||||
//! 3D-Solid-Ansicht sind bis zu diesem Nachtrag also bewusst inkonsistent;
|
||||
//! das Nachziehen von `mesh.rs` ist eine bekannte, separate Luecke.
|
||||
//! - Wandknoten-Verschneidung (Gehrung) — zwei Wege, je nach Zweck:
|
||||
//! * CUT-Polygone (der eigentliche Schnitt-Querschnitt) nutzen den an den
|
||||
//! echten Wandecken GEHRTEN Fussabdruck aus `mesh::wall_mitered_footprints`
|
||||
//! — DIESELBE Ecken-Verschneidung wie die 3D-Vollkoerper-Extrusion
|
||||
//! (`mesh::compute_wall_miters`). Dadurch liefert ein Schnitt an einer
|
||||
//! Wandecke genau EINEN sauberen, verschnittenen Querschnitt statt zweier
|
||||
//! ueberlappender Rechtecke (siehe `Prism::cut_footprint`, `cut_section` und
|
||||
//! Test `schnitt_an_wandecke_liefert_verschnittene_querschnitte`).
|
||||
//! * Verdeckung/Projektion (Bounding-Box + Ansichtskanten) bleiben bei der
|
||||
//! ACHSPARALLELEN, ungehrten Grundflaeche (`Prism::footprint`): die
|
||||
//! Verdeckungs-Bounding-Box kennt ohnehin nur die achsparallele Ausdehnung
|
||||
//! (siehe naechster Punkt), eine gehrte Kante brächte dort keinen
|
||||
//! Praezisionsgewinn. In Ecknaehe kann eine Wand deshalb als "in eine
|
||||
//! andere eingebettet" verdeckt erscheinen (siehe Test
|
||||
//! `ansicht_vor_dem_modell_hat_keine_cut_polygone_aber_verdeckung` unten) —
|
||||
//! real, aber ein Artefakt der achsparallelen Verdeckungs-Naeherung, kein Bug.
|
||||
//! - Oeffnungen werden hier (Cut + Projektion) UND von der Vollkoerper-
|
||||
//! Extrusion in `mesh.rs` ausgewertet (`mesh::wall_solid_segments`) — beide
|
||||
//! nutzen dieselbe Bruestungs-/Sturz-Zerlegung (`crate::openings::
|
||||
//! split_range_by_voids`), damit Cut-Ansicht und 3D-Solid-Ansicht
|
||||
//! konsistent bleiben.
|
||||
//! - 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
|
||||
@@ -176,7 +186,9 @@ impl SectionPlane {
|
||||
|
||||
/// u-Achse der Projektion: horizontal, senkrecht zur Blickrichtung (siehe
|
||||
/// Moduldoc — dieselbe Konvention wie `math::look_at`s `right`-Vektor).
|
||||
fn u_axis(&self) -> [f32; 3] {
|
||||
/// `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] {
|
||||
let up = [0.0, 1.0, 0.0];
|
||||
let u = math::cross(self.normal, up);
|
||||
if math::length(u) > 1e-6 {
|
||||
@@ -289,7 +301,16 @@ pub struct SectionOutput {
|
||||
struct Prism {
|
||||
kind: ComponentKind,
|
||||
index: usize,
|
||||
/// Achsparalleles Band-/Umriss-Polygon (Modell-Meter). Basis fuer die
|
||||
/// Verdeckungs-Bounding-Box und die Projektions-Kanten (siehe Moduldoc
|
||||
/// "Bekannte Vereinfachungen").
|
||||
footprint: Vec<Point2>,
|
||||
/// Grundriss-Polygon fuer die CUT-Polygone. Fuer Decken identisch zu
|
||||
/// `footprint`; fuer Waende der an den Ecken GEHRTE Fussabdruck aus
|
||||
/// `mesh::wall_mitered_footprints` (dieselbe Verschneidung wie das 3D-Mesh),
|
||||
/// damit ein Schnitt an einer Wandecke genau EINEN verschnittenen Querschnitt
|
||||
/// liefert statt zweier ueberlappender Rechtecke. Siehe `cut_section`.
|
||||
cut_footprint: Vec<Point2>,
|
||||
z0: f32,
|
||||
z1: f32,
|
||||
color: Rgb,
|
||||
@@ -371,6 +392,9 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option<Prism> {
|
||||
Some(Prism {
|
||||
kind: ComponentKind::Wall,
|
||||
index,
|
||||
// Standardmaessig ungehrt; `cut_section` ersetzt ihn durch den gehrten
|
||||
// Fussabdruck aus `mesh::wall_mitered_footprints`.
|
||||
cut_footprint: footprint.clone(),
|
||||
footprint,
|
||||
z0: wall.base_elevation,
|
||||
z1: wall.base_elevation + wall.height,
|
||||
@@ -396,6 +420,7 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option<Prism> {
|
||||
Some(Prism {
|
||||
kind: ComponentKind::Slab,
|
||||
index,
|
||||
cut_footprint: slab.outline.clone(),
|
||||
footprint: slab.outline.clone(),
|
||||
z0,
|
||||
z1,
|
||||
@@ -508,28 +533,11 @@ fn wall_cut_rectangles(
|
||||
if voids.is_empty() {
|
||||
return vec![(u_lo, u_hi, prism.z0, prism.z1)];
|
||||
}
|
||||
voids.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
|
||||
let mut rects = Vec::new();
|
||||
let mut cursor = u_lo;
|
||||
for (v_lo, v_hi, op) in &voids {
|
||||
if *v_lo > cursor + MIN_SPAN {
|
||||
rects.push((cursor, *v_lo, prism.z0, prism.z1));
|
||||
}
|
||||
let sill_abs = prism.z0 + op.sill.max(0.0);
|
||||
let head_abs = (sill_abs + op.height).min(prism.z1);
|
||||
if sill_abs - prism.z0 > MIN_SPAN {
|
||||
rects.push((*v_lo, *v_hi, prism.z0, sill_abs)); // Bruestung
|
||||
}
|
||||
if prism.z1 - head_abs > MIN_SPAN {
|
||||
rects.push((*v_lo, *v_hi, head_abs, prism.z1)); // Sturz
|
||||
}
|
||||
cursor = cursor.max(*v_hi);
|
||||
}
|
||||
if u_hi > cursor + MIN_SPAN {
|
||||
rects.push((cursor, u_hi, prism.z0, prism.z1));
|
||||
}
|
||||
rects
|
||||
// Bruestungs-/Sturz-Zerlegung ist ab hier koordinatenraum-neutral (nur noch
|
||||
// u-Intervall + Hoehe + geclippte Voids) -> gemeinsame Basis mit
|
||||
// `mesh::wall_solid_segments` (siehe `crate::openings`-Moduldoc), damit
|
||||
// Cut-Ansicht und 3D-Solid-Mesh garantiert dasselbe Oeffnungsmodell nutzen.
|
||||
crate::openings::split_range_by_voids(u_lo, u_hi, prism.z0, prism.z1, voids)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -938,8 +946,14 @@ fn compute_view_edges(
|
||||
/// Decken) aus. Siehe Moduldoc fuer Format/Konventionen.
|
||||
pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> SectionOutput {
|
||||
let mut prisms: Vec<Prism> = Vec::new();
|
||||
// Gehrte Wand-Fussabdruecke einmalig ueber die gesamte Wandkonnektivitaet
|
||||
// (dieselbe Verschneidung wie das 3D-Mesh, siehe `mesh::wall_mitered_footprints`).
|
||||
let mitered = crate::mesh::wall_mitered_footprints(walls);
|
||||
for (i, w) in walls.iter().enumerate() {
|
||||
if let Some(p) = wall_prism(i, w) {
|
||||
if let Some(mut p) = wall_prism(i, w) {
|
||||
if let Some(fp) = mitered[i] {
|
||||
p.cut_footprint = fp.to_vec();
|
||||
}
|
||||
prisms.push(p);
|
||||
}
|
||||
}
|
||||
@@ -951,7 +965,7 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput
|
||||
|
||||
let mut cut_polygons = Vec::new();
|
||||
for prism in &prisms {
|
||||
for (u0, u1) in footprint_u_intervals(plane, &prism.footprint) {
|
||||
for (u0, u1) in footprint_u_intervals(plane, &prism.cut_footprint) {
|
||||
let (lo, hi) = (u0.min(u1), u0.max(u1));
|
||||
for (rlo, rhi, rz0, rz1) in wall_cut_rectangles(plane, prism, lo, hi) {
|
||||
cut_polygons.push(CutPolygon {
|
||||
@@ -1001,6 +1015,7 @@ mod tests {
|
||||
base_elevation: 0.0,
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
},
|
||||
WallInput {
|
||||
start: [0.0, 3.0],
|
||||
@@ -1010,6 +1025,7 @@ mod tests {
|
||||
base_elevation: 0.0,
|
||||
color,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
},
|
||||
];
|
||||
let slabs = vec![SlabInput {
|
||||
@@ -1206,6 +1222,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ecken-Gehrung im Schnitt --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn schnitt_an_wandecke_liefert_verschnittene_querschnitte() {
|
||||
// L-Ecke bei (0,3): Schenkel A (Achse (0,0)->(0,3)) und Schenkel B
|
||||
// (Achse (0,3)->(3,3)), beide Dicke 0.2. Schnittebene bei Grundriss-X =
|
||||
// 0.05 (knapp in der Ecke). Ohne Gehrung ueberlappten die beiden
|
||||
// Wand-Cut-Rechtecke im u-Bereich [2.9, 3.0]; mit dem gehrten Fussabdruck
|
||||
// (wie im 3D-Mesh) stossen sie exakt an der Gehrungslinie (u ~ 2.95)
|
||||
// sauber aneinander, ohne Ueberlappung.
|
||||
let (walls, _) = l_wall_scene();
|
||||
let plane = SectionPlane::looking_plus_x(0.05);
|
||||
let out = cut_section(&plane, &walls, &[]);
|
||||
|
||||
let wall_cuts: Vec<&CutPolygon> = out
|
||||
.cut_polygons
|
||||
.iter()
|
||||
.filter(|c| c.component.kind == ComponentKind::Wall)
|
||||
.collect();
|
||||
assert_eq!(wall_cuts.len(), 2, "je ein Querschnitt fuer Schenkel A und B");
|
||||
|
||||
let u_bounds = |c: &CutPolygon| -> (f32, f32) {
|
||||
let us: Vec<f32> = c.pts.iter().map(|p| p[0]).collect();
|
||||
(
|
||||
us.iter().cloned().fold(f32::INFINITY, f32::min),
|
||||
us.iter().cloned().fold(f32::NEG_INFINITY, f32::max),
|
||||
)
|
||||
};
|
||||
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);
|
||||
|
||||
// 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) -----------------------------------------
|
||||
|
||||
/// Eine Wand entlang +X (Achse (0,0)->(4,0)) mit einem Fenster: Intervall
|
||||
@@ -1225,6 +1281,7 @@ mod tests {
|
||||
sill: 1.0,
|
||||
height: 1.0,
|
||||
}],
|
||||
layers: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1309,6 +1366,7 @@ mod tests {
|
||||
base_elevation: 0.0,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
};
|
||||
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
|
||||
let plane = SectionPlane::looking_plus_y(-2.0);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Schnittflaechen-Geometrie fuer den Live-3D-Schnitt: wandelt die analytischen
|
||||
//! Cut-Polygone aus `section::cut_section` (achsparallele (u,v)-Rechtecke, siehe
|
||||
//! `section.rs`-Moduldoc) in kamerataugliche Dreiecks-Geometrie fuer die Cap-
|
||||
//! Pipeline (`gpu::cap_pipeline`, Schraffur in `shaders::CAP_WGSL`).
|
||||
//!
|
||||
//! ## Ruecktransformation (u,v) -> Welt
|
||||
//!
|
||||
//! `SectionPlane::project(p) = [dot(p - point, u_axis), p.y]` (nur vertikale
|
||||
//! Ebenen). Die Inverse fuer einen Punkt AUF der Ebene ist damit
|
||||
//!
|
||||
//! ```text
|
||||
//! world = point + u * u_axis (horizontale Lage), world.y = v
|
||||
//! ```
|
||||
//!
|
||||
//! `u_axis` ist horizontal (y = 0), also traegt `u * u_axis` nichts zur Hoehe
|
||||
//! bei — `world.y = v` gilt exakt, und der Punkt liegt auf der Ebene, weil
|
||||
//! `u_axis` und die Welt-Hoehenachse [0,1,0] beide senkrecht auf der (horizon-
|
||||
//! talen) Normale stehen. Wir nutzen `SectionPlane::u_axis()` (pub(crate)),
|
||||
//! damit die Achsdefinition (inkl. Entartungs-Fallback) mit `section.rs`
|
||||
//! identisch bleibt.
|
||||
//!
|
||||
//! ## Ausgabeformat
|
||||
//!
|
||||
//! 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, SectionPlane};
|
||||
use crate::types::{SlabInput, WallInput};
|
||||
|
||||
/// 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
|
||||
/// Welt zurueck. Rueckgabe = interleaved Vertexpuffer (`CAP_FLOATS_PER_VERTEX`),
|
||||
/// leer, wenn die Ebene das Modell nicht schneidet.
|
||||
///
|
||||
/// WINDING: bewusst nicht garantiert kamerakonsistent — die Cap-Pipeline
|
||||
/// 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.
|
||||
pub fn build_cut_caps(
|
||||
plane: &SectionPlane,
|
||||
walls: &[WallInput],
|
||||
slabs: &[SlabInput],
|
||||
) -> Vec<f32> {
|
||||
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.
|
||||
let to_world = |uv: [f32; 2]| -> [f32; 3] {
|
||||
[
|
||||
point[0] + uv[0] * u_axis[0],
|
||||
uv[1],
|
||||
point[2] + uv[0] * u_axis[2],
|
||||
]
|
||||
};
|
||||
|
||||
let mut verts: Vec<f32> = Vec::new();
|
||||
for poly in &out.cut_polygons {
|
||||
let pts = &poly.pts;
|
||||
if pts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
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]]);
|
||||
};
|
||||
// Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
|
||||
for i in 1..pts.len() - 1 {
|
||||
push_vertex(pts[0]);
|
||||
push_vertex(pts[i]);
|
||||
push_vertex(pts[i + 1]);
|
||||
}
|
||||
}
|
||||
verts
|
||||
}
|
||||
@@ -16,8 +16,16 @@
|
||||
// sky_color : vec4<f32> Himmels-Ambient (rgb; von oben)
|
||||
// ground_color : vec4<f32> Boden-Ambient (rgb; von unten)
|
||||
// sun_color : vec4<f32> Farbe/Staerke des Directional-Lights (rgb)
|
||||
// mode : vec4<f32> x = Darstellungsart (0 = shaded/Material,
|
||||
// 1 = weiss/Clay, 2 = hidden-Line-Flaechen/flach-weiss)
|
||||
//
|
||||
// Vertex-Attribute: position (world), normal (world), color (Albedo).
|
||||
//
|
||||
// FILL-LIGHT: Zusaetzlich zum Haupt-Directional (Sonne) wirkt ein schwaches
|
||||
// GEGEN-Fuelllicht aus der entgegengesetzten Azimut-Richtung (leicht von oben),
|
||||
// abgeleitet aus der Sonnenrichtung — es hebt die vom Hauptlicht abgewandten
|
||||
// Flaechen an, damit sie nicht in reinem Schwarz absaufen. Dezent (≈ 0.28 der
|
||||
// Sonne, leicht kuehl), physikalisch als Himmels-/Bounce-Fuellung plausibel.
|
||||
|
||||
/// Der einzige Shader (Vertex + Fragment). Hemisphaerisches Ambient (Himmel/Boden)
|
||||
/// + Lambert-Directional — GPU-Aequivalent eines Architektur-Massenmodell-Lichts.
|
||||
@@ -28,6 +36,8 @@ struct Globals {
|
||||
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;
|
||||
|
||||
@@ -41,6 +51,7 @@ struct VsOut {
|
||||
@builtin(position) clip_pos : vec4<f32>,
|
||||
@location(0) world_normal : vec3<f32>,
|
||||
@location(1) color : vec3<f32>,
|
||||
@location(2) world_pos : vec3<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
@@ -49,13 +60,24 @@ fn vs_main(in : VsIn) -> VsOut {
|
||||
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
|
||||
out.world_normal = in.normal;
|
||||
out.color = in.color;
|
||||
out.world_pos = in.position;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
// Live-Schnitt: mode.y > 0.5 aktiviert die Kappung. section_plane.xyz =
|
||||
// Ebenennormale (= Blickrichtung), .w = -dot(n, point). depth > 0 heisst
|
||||
// "hinter der Ebene" (in Blickrichtung) -> dieses Material wird weggeschnitten,
|
||||
// die Schnittflaechen-Caps (cap_pipeline) fuellen die entstehende Wunde.
|
||||
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);
|
||||
let style = globals.mode.x;
|
||||
|
||||
// Hemisphaerisches Ambient: Mischfaktor aus der Vertikal-Komponente der
|
||||
// Normalen (n.y = +1 -> voll Himmel, n.y = -1 -> voll Boden).
|
||||
@@ -65,13 +87,150 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
// Gerichteter Lambert-Anteil (Sonne); Rueckseiten (n.l < 0) tragen nichts bei.
|
||||
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb;
|
||||
|
||||
var shaded = in.color * (ambient + diffuse);
|
||||
// Schwaches Gegen-Fuelllicht: aus entgegengesetztem Azimut, leicht von oben,
|
||||
// aus der Sonnenrichtung abgeleitet — hebt abgewandte Flaechen an (kein
|
||||
// reines Schwarz). Dezent (0.28) und leicht kuehl getoent.
|
||||
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);
|
||||
|
||||
// Weiss-Modus (Clay, style==1) bzw. Hidden-Flaechen (style==2): Material-/
|
||||
// Vertexfarbe durch ein einheitliches Hell ersetzen. Clay = warmes Hellgrau,
|
||||
// Hidden = nahezu Weiss (damit die dunklen Kanten obenauf klar lesbar sind).
|
||||
let clay = vec3<f32>(0.82, 0.82, 0.80);
|
||||
let paper = vec3<f32>(0.97, 0.97, 0.97);
|
||||
var albedo = in.color;
|
||||
if (style > 1.5) {
|
||||
albedo = paper;
|
||||
} else if (style > 0.5) {
|
||||
albedo = clay;
|
||||
}
|
||||
|
||||
var shaded = albedo * (ambient + diffuse + fill);
|
||||
|
||||
// Sanfte Kantenbetonung: senkrechte Flaechen (n.y nahe 0) minimal abdunkeln,
|
||||
// damit sich Waende vom hellen Deckel/Boden abheben (ohne Postprocessing).
|
||||
let edge = 0.90 + 0.10 * abs(n.y);
|
||||
shaded = shaded * edge;
|
||||
|
||||
// Hidden-Line-Flaechen: sehr flach halten (fast unlit, nahezu weiss), damit
|
||||
// die dunklen Modell-Kanten die Form tragen und die Flaechen nur den Depth-
|
||||
// Buffer fuellen (verdeckte Kanten wegschneiden). Leichte Hemi-Toenung bleibt,
|
||||
// damit die Silhouette auch ohne Kante nicht voellig verschwindet.
|
||||
if (style > 1.5) {
|
||||
shaded = albedo * (0.88 + 0.12 * hemi_t);
|
||||
}
|
||||
|
||||
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.
|
||||
/// Vertex-Attribute: position (world), color (rgb). Depth-Test laeuft ueber den
|
||||
/// gemeinsamen Tiefenpuffer, sodass das Modell das Gitter korrekt verdeckt.
|
||||
pub const GRID_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;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position : vec3<f32>,
|
||||
@location(1) color : vec3<f32>,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos : vec4<f32>,
|
||||
@location(0) color : 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.color = in.color;
|
||||
out.world_pos = in.position;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
// Gleiche Kappung wie die Mesh-Pipeline: Grid-, Modell-Kanten- und
|
||||
// Highlight-Linien (alle teilen diesen Shader) werden an der Schnittebene
|
||||
// weggeschnitten, damit auf der abgeschnittenen Seite keine Kanten stehen
|
||||
// bleiben (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;
|
||||
}
|
||||
}
|
||||
return vec4<f32>(in.color, 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline).
|
||||
/// 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
|
||||
/// 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>,
|
||||
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;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position : vec3<f32>,
|
||||
@location(1) uv : vec2<f32>,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos : vec4<f32>,
|
||||
@location(0) uv : vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
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;
|
||||
return out;
|
||||
}
|
||||
|
||||
@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 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);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -25,13 +25,17 @@ pub type Rgb = [f32; 3];
|
||||
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
|
||||
///
|
||||
/// Vereinfachung des Web-`Wall` (siehe src/model/wall.ts): Ecken-Verschneidung
|
||||
/// (`computeJoins`/`wallCorners`) kommt in einem spaeteren Milestone; M1 extrudiert
|
||||
/// jede Wand als eigenstaendigen, an den Enden stumpf abgeschlossenen Quader.
|
||||
/// (Gehrung an echten Wandenden, analog `computeJoins`/`wallCorners` im Web)
|
||||
/// wird von `mesh.rs` SELBSTAENDIG aus der Wandkonnektivitaet berechnet (siehe
|
||||
/// `mesh::compute_wall_miters`) — keine eigenen Eingabefelder noetig, robust
|
||||
/// auch gegenueber der hier bereits in Teilquader zerlegten Darstellung (siehe
|
||||
/// `openings` unten). Das Schnitt-Modul (`section.rs`) nutzt weiterhin die
|
||||
/// (unveraenderte) achsparallele Bounding-Box-Naeherung ohne Gehrung — siehe
|
||||
/// dessen Moduldoc "Bekannte Vereinfachungen".
|
||||
/// `openings` (Tueren/Fenster, siehe `Opening`) werden vom Schnitt-Modul
|
||||
/// (`section.rs`) ausgewertet; die Vollkoerper-Extrusion (`mesh.rs`) kennt sie
|
||||
/// (noch) NICHT und extrudiert weiterhin die volle Wandflaeche — siehe
|
||||
/// `mesh.rs`-Moduldoc und `docs/design/engine-section-pipeline.md` fuer diese
|
||||
/// bekannte Diskrepanz.
|
||||
/// (`section.rs`) UND von der Vollkoerper-Extrusion (`mesh.rs`) ausgewertet
|
||||
/// (siehe `mesh::wall_solid_segments`) — beide nutzen dieselbe Bruestungs-/
|
||||
/// Sturz-Zerlegung (`crate::openings`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WallInput {
|
||||
/// Achsen-Startpunkt im Grundriss (Modell-Meter).
|
||||
@@ -39,6 +43,9 @@ pub struct WallInput {
|
||||
/// Achsen-Endpunkt im Grundriss (Modell-Meter).
|
||||
pub end: Point2,
|
||||
/// Wanddicke in Metern (das Band wird um `thickness/2` je Seite versetzt).
|
||||
/// Bleibt auch bei gesetztem `layers` die GESAMT-Dicke (Summe der
|
||||
/// Schichtdicken) — `section.rs`/`web::model_bounds` rechnen weiterhin
|
||||
/// allein mit diesem Feld, kennen `layers` nicht.
|
||||
pub thickness: f32,
|
||||
/// Wandhoehe in Metern (Extrusion entlang +Y).
|
||||
pub height: f32,
|
||||
@@ -46,11 +53,28 @@ pub struct WallInput {
|
||||
#[serde(default, rename = "baseElevation")]
|
||||
pub base_elevation: f32,
|
||||
/// Albedo-Farbe der Wand (RGB 0..1). Default helles Grau wie die three.js-Sicht.
|
||||
/// Nur wirksam, wenn `layers` fehlt/leer ist (Vollkoerper-Fallback).
|
||||
#[serde(default = "default_wall_color")]
|
||||
pub color: Rgb,
|
||||
/// Oeffnungen (Tueren/Fenster) entlang der Wandachse. Default leer (volle Wand).
|
||||
#[serde(default)]
|
||||
pub openings: Vec<Opening>,
|
||||
/// Optionaler Schichtaufbau (z. B. Putz/Daemmung/Mauerwerk), quer zur
|
||||
/// Gesamtdicke gestapelt (siehe `mesh::extrude_wall`). Fehlt dieses Feld
|
||||
/// (`None`) oder ist es leer, extrudiert `mesh.rs` weiterhin EINEN
|
||||
/// Vollkoerper aus `thickness`/`color` (Rueckwaertskompatibilitaet, M1-
|
||||
/// Verhalten unveraendert).
|
||||
#[serde(default)]
|
||||
pub layers: Option<Vec<WallLayer>>,
|
||||
}
|
||||
|
||||
/// Eine Wandschicht: Dicke + eigene Albedo-Farbe. Siehe `WallInput::layers`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct WallLayer {
|
||||
/// Schichtdicke in Metern.
|
||||
pub thickness: f32,
|
||||
/// Albedo-Farbe dieser Schicht (RGB 0..1).
|
||||
pub color: Rgb,
|
||||
}
|
||||
|
||||
/// Eine Oeffnung (Tuer/Fenster) in einer Wand: Intervall entlang der Wandachse
|
||||
@@ -104,6 +128,74 @@ fn default_slab_color() -> Rgb {
|
||||
[0.86, 0.86, 0.88]
|
||||
}
|
||||
|
||||
/// Art eines rohen Kontext-Meshes (aus `Project.context`) — bestimmt die
|
||||
/// Default-Einfaerbung, wenn kein explizites `color` mitkommt. Werte sind
|
||||
/// serde-kompatibel mit der TS-Seite (lowercase).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MeshKind {
|
||||
/// Gelaende-TIN (offene Flaeche) — gedecktes Lehm-/Gelaendegruen-grau.
|
||||
Terrain,
|
||||
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
|
||||
#[default]
|
||||
Imported,
|
||||
}
|
||||
|
||||
impl MeshKind {
|
||||
/// Default-Albedo (RGB 0..1), wenn `MeshInput::color` fehlt. Werte 1:1 aus
|
||||
/// der three.js-Sicht (`src/viewport/Viewport3D.tsx`, `mats.terrain`/
|
||||
/// `mats.mesh`), damit beide Renderer denselben Kontext-Farbton zeigen.
|
||||
pub fn default_color(self) -> Rgb {
|
||||
match self {
|
||||
// 0xa9b0a2 — gedaempftes Lehm-/Gelaendegruen-grau (Clay-Look).
|
||||
MeshKind::Terrain => [0.663, 0.690, 0.635],
|
||||
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
|
||||
MeshKind::Imported => [0.624, 0.651, 0.682],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein rohes Dreiecks-Mesh aus dem Geo-Kontext (`Project.context`): entweder ein
|
||||
/// Gelaende-TIN (`TerrainMesh`) oder ein importiertes Volumen (`ImportedMesh`,
|
||||
/// z. B. extrudierte OSM-Gebaeude/DXF). Anders als `WallInput`/`SlabInput` bringt
|
||||
/// es die Geometrie bereits FERTIG TRIANGULIERT mit — `mesh.rs` berechnet daraus
|
||||
/// nur noch die Flaechen-Normalen (Flat Shading, wie Wand/Slab) und speist es in
|
||||
/// denselben GPU-Pfad ein.
|
||||
///
|
||||
/// KOORDINATEN wie WallInput/SlabInput: die TS-Seite (`projectToModel3d`) liefert
|
||||
/// `positions` im MODELL-System als flaches `[x, y, z, ...]`-Array (z = Hoehe, in
|
||||
/// Metern); `mesh.rs` bildet jeden Vertex nach world ab: model `(x, y, z)` ->
|
||||
/// world `(x, z, y)`, d. h. Grundriss in der XZ-Ebene, Hoehe entlang +Y (exakt wie
|
||||
/// `Viewport3D.tsx` die rohen positions konsumiert).
|
||||
///
|
||||
/// DOPPELSEITIG: Terrain-/Import-Meshes tragen KEIN garantiertes Winding (die
|
||||
/// three.js-Sicht rendert sie mit `DoubleSide`, siehe `io/geoContext.ts`). Da der
|
||||
/// Nordstern-Pfad Rueckseiten cullt, emittiert `mesh.rs` jedes Dreieck ZWEIMAL
|
||||
/// (beide Windings, entgegengesetzte Normale) — so bleibt die Flaeche aus jeder
|
||||
/// Blickrichtung sichtbar und korrekt beleuchtet.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshInput {
|
||||
/// Flaches Positions-Array in MODELL-Metern: `[x0,y0,z0, x1,y1,z1, ...]`
|
||||
/// (z = Hoehe). Wird von `mesh.rs` nach world (x, z, y) abgebildet.
|
||||
pub positions: Vec<f32>,
|
||||
/// Dreiecks-Indizes (0-basiert auf die Positions-Vertices, je 3 ein Dreieck).
|
||||
pub indices: Vec<u32>,
|
||||
/// Art des Meshes (Default-Einfaerbung, siehe `MeshKind`). Default `Imported`.
|
||||
#[serde(default)]
|
||||
pub kind: MeshKind,
|
||||
/// Optionale explizite Albedo-Farbe (RGB 0..1). Fehlt sie, greift die
|
||||
/// Default-Farbe je `kind` (siehe `effective_color`).
|
||||
#[serde(default)]
|
||||
pub color: Option<Rgb>,
|
||||
}
|
||||
|
||||
impl MeshInput {
|
||||
/// Effektive Albedo-Farbe: explizit gesetzt (`color`) oder Default je `kind`.
|
||||
pub fn effective_color(&self) -> Rgb {
|
||||
self.color.unwrap_or_else(|| self.kind.default_color())
|
||||
}
|
||||
}
|
||||
|
||||
/// Projektionsart der Kamera. Die three.js-Sicht schaltet zwischen perspektivisch
|
||||
/// (freies Orbit) und orthografisch (die achsparallelen Presets front/top/side um).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -145,7 +237,9 @@ pub struct Camera {
|
||||
pub far: f32,
|
||||
}
|
||||
|
||||
fn default_fov() -> f32 {
|
||||
// `pub(crate)`, damit `web::set_view_preset` denselben Voreinstellungswert
|
||||
// fuers formatfuellende Fit nutzen kann (kein zweiter FOV-Wert zum Pflegen).
|
||||
pub(crate) fn default_fov() -> f32 {
|
||||
// 50 Grad, wie die three.js-PerspectiveCamera-Voreinstellung.
|
||||
50.0_f32.to_radians()
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
// up.x, up.y, up.z, perspective, fovY, orthoHalfHeight);
|
||||
// r.resize(px_w, px_h); r.render(); // pro Frame
|
||||
//
|
||||
// KAMERA: die Fassade nimmt eine FERTIGE Kamera (eye/target/up + Projektionsart)
|
||||
// entgegen — Orbit/Pan/Zoom-Interaktion (Yaw/Pitch/Distanz, Praesets) rechnet die
|
||||
// TS-Seite (dieselbe Konvention wie `math::orbit_eye`/`preset_camera`), damit die
|
||||
// Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgen kann, ohne
|
||||
// eine zweite Praeset-Tabelle in Rust pflegen zu muessen.
|
||||
// KAMERA: zwei Wege, dieselbe `Camera`. `set_camera` nimmt eine FERTIGE Kamera
|
||||
// (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 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).
|
||||
@@ -25,7 +30,9 @@ use wasm_bindgen::prelude::*;
|
||||
use web_sys::HtmlCanvasElement;
|
||||
|
||||
use crate::gpu::Renderer;
|
||||
use crate::types::{Camera, Projection, SlabInput, WallInput};
|
||||
use crate::math::preset_camera;
|
||||
use crate::section::{cut_section, SectionPlane};
|
||||
use crate::types::{Camera, CameraPreset, MeshInput, Projection, SlabInput, WallInput};
|
||||
|
||||
/// JSON-Eingabe des Modells: Waende (bereits in Teilquader zerlegt, siehe
|
||||
/// `projectToWalls3d`) + Deckenplatten. `slabs` optional (leeres Modell ohne
|
||||
@@ -35,6 +42,45 @@ struct ModelInput {
|
||||
walls: Vec<WallInput>,
|
||||
#[serde(default)]
|
||||
slabs: Vec<SlabInput>,
|
||||
/// Rohe Kontext-Meshes (Terrain/importierte Volumen). `#[serde(default)]`
|
||||
/// haelt aeltere Payloads ohne `meshes` gueltig.
|
||||
#[serde(default)]
|
||||
meshes: Vec<MeshInput>,
|
||||
}
|
||||
|
||||
/// Berechnet einen Schnitt/eine Ansicht (`section::cut_section`) gegen das Modell
|
||||
/// und gibt das `SectionOutput` als JSON-String zurueck. GPU-frei — reine
|
||||
/// Prismen-Arithmetik (siehe `section.rs`); braucht KEINEN `WebModelRenderer`
|
||||
/// (kein WebGPU-Kontext), nur das geladene WASM-Modul. Aufruf aus JS:
|
||||
///
|
||||
/// ```js
|
||||
/// const json = mod.cut_section_json(
|
||||
/// JSON.stringify({ walls, slabs }), // gleiches Format wie set_model
|
||||
/// px, py, pz, // Punkt auf der Schnittebene (world-Meter, Y-up)
|
||||
/// nx, ny, nz, // Ebenennormale = Blickrichtung (wird normalisiert)
|
||||
/// );
|
||||
/// const out = JSON.parse(json); // { cutPolygons, visibleEdges, hiddenEdges }
|
||||
/// ```
|
||||
///
|
||||
/// Koordinaten der Ausgabe: (u, v) in Metern — `u` horizontal entlang der
|
||||
/// Ebene, `v = world.y` (absolute Hoehe). Siehe `section.rs`-Moduldoc.
|
||||
#[wasm_bindgen]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn cut_section_json(
|
||||
model_json: &str,
|
||||
px: f32,
|
||||
py: f32,
|
||||
pz: f32,
|
||||
nx: f32,
|
||||
ny: f32,
|
||||
nz: f32,
|
||||
) -> Result<String, JsValue> {
|
||||
let model: ModelInput = serde_json::from_str(model_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
|
||||
let plane = SectionPlane::new([px, py, pz], [nx, ny, nz]);
|
||||
let out = cut_section(&plane, &model.walls, &model.slabs);
|
||||
serde_json::to_string(&out)
|
||||
.map_err(|e| JsValue::from_str(&format!("SectionOutput serialisieren: {e}")))
|
||||
}
|
||||
|
||||
/// Der Browser-Renderer: haelt Surface + Device/Queue + den geteilten `Renderer`
|
||||
@@ -47,6 +93,63 @@ pub struct WebModelRenderer {
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
renderer: Renderer,
|
||||
camera: Camera,
|
||||
/// Achsparallele Bounding-Box (min, max) des zuletzt gesetzten Modells,
|
||||
/// world-Meter (Y-up) — Grundlage fuer `set_view_preset` (formatfuellendes
|
||||
/// Ziel/Distanz, mirror der TS-`fitTargetDist` in Wasm3DViewport.tsx).
|
||||
/// `None` bei leerem Modell (kein Wall-/Slab-Vertex) oder vor `set_model`.
|
||||
bounds: Option<([f32; 3], [f32; 3])>,
|
||||
/// Gecachte Wand-/Decken-Eingabe des zuletzt gesetzten Modells. `set_model`
|
||||
/// verwirft sie nach dem GPU-Upload NICHT (anders als der Renderer selbst),
|
||||
/// weil der Live-Schnitt (`set_section_plane`) daraus jederzeit die
|
||||
/// Cut-Cap-Geometrie neu berechnen koennen muss (GPU-frei, `cut_section`).
|
||||
walls: Vec<WallInput>,
|
||||
slabs: Vec<SlabInput>,
|
||||
/// Aktueller Live-Schnitt-Zustand: aktiv? + Ebene (point/normal). Gemerkt,
|
||||
/// damit ein `set_model` bei aktivem Schnitt die Caps automatisch neu baut.
|
||||
section: Option<([f32; 3], [f32; 3])>,
|
||||
}
|
||||
|
||||
/// Achsparallele Bounding-Box des Modells in Welt-Metern (Y-up), aus Waenden +
|
||||
/// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx):
|
||||
/// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante.
|
||||
/// `None` bei leerem Modell.
|
||||
fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [f32; 3])> {
|
||||
let mut min = [f32::INFINITY; 3];
|
||||
let mut max = [f32::NEG_INFINITY; 3];
|
||||
let mut grow = |x: f32, y: f32, z: f32| {
|
||||
if x < min[0] {
|
||||
min[0] = x;
|
||||
}
|
||||
if x > max[0] {
|
||||
max[0] = x;
|
||||
}
|
||||
if y < min[1] {
|
||||
min[1] = y;
|
||||
}
|
||||
if y > max[1] {
|
||||
max[1] = y;
|
||||
}
|
||||
if z < min[2] {
|
||||
min[2] = z;
|
||||
}
|
||||
if z > max[2] {
|
||||
max[2] = z;
|
||||
}
|
||||
};
|
||||
for w in walls {
|
||||
grow(w.start[0], w.base_elevation, w.start[1]);
|
||||
grow(w.end[0], w.base_elevation + w.height, w.end[1]);
|
||||
}
|
||||
for s in slabs {
|
||||
for p in &s.outline {
|
||||
grow(p[0], s.z_bottom, p[1]);
|
||||
grow(p[0], s.z_top, p[1]);
|
||||
}
|
||||
}
|
||||
if !min[0].is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some((min, max))
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -129,17 +232,64 @@ impl WebModelRenderer {
|
||||
config,
|
||||
renderer,
|
||||
camera: Camera::default(),
|
||||
bounds: None,
|
||||
walls: Vec::new(),
|
||||
slabs: Vec::new(),
|
||||
section: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Setzt/aktualisiert das darzustellende Modell aus einem JSON-String
|
||||
/// (Format = `{ walls: WallInput[], slabs?: SlabInput[] }`, identisch zum
|
||||
/// nativen Push). Laedt die GPU-Puffer neu hoch; ruft NICHT selbst `render`.
|
||||
/// Merkt sich zusaetzlich die Bounding-Box (fuer `set_view_preset`).
|
||||
pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> {
|
||||
let model: ModelInput = serde_json::from_str(json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
|
||||
self.bounds = model_bounds(&model.walls, &model.slabs);
|
||||
self.renderer
|
||||
.upload_model(&self.device, &model.walls, &model.slabs);
|
||||
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
|
||||
// Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer
|
||||
// sie nur zu GPU-Puffern verarbeitet und verwirft).
|
||||
self.walls = model.walls;
|
||||
self.slabs = model.slabs;
|
||||
// Ist der Schnitt aktiv, die Caps am neuen Modell neu aufbauen.
|
||||
if let Some((point, normal)) = self.section {
|
||||
self.renderer.update_section(
|
||||
&self.device,
|
||||
true,
|
||||
point,
|
||||
normal,
|
||||
&self.walls,
|
||||
&self.slabs,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Live-Schnitt ein-/ausschalten und die Schnittebene setzen. `active=false`
|
||||
/// hebt den Schnitt auf (Modell wieder komplett, keine Caps). `p*` = Punkt auf
|
||||
/// der Ebene, `n*` = Ebenennormale = Blickrichtung (world-Meter, Y-up; wird
|
||||
/// normalisiert). NUR VERTIKALE Ebenen sind sinnvoll (Normale ohne Y-Anteil —
|
||||
/// Annahme von `section.rs`). Kappt Mesh/Kanten/Grid per Shader-`discard` und
|
||||
/// baut aus den gecachten Waenden/Decken die Schnittflaechen-Schraffur (Caps).
|
||||
/// Wirkt erst beim naechsten `render`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_section_plane(
|
||||
&mut self,
|
||||
active: bool,
|
||||
px: f32,
|
||||
py: f32,
|
||||
pz: f32,
|
||||
nx: f32,
|
||||
ny: f32,
|
||||
nz: f32,
|
||||
) -> Result<(), JsValue> {
|
||||
let point = [px, py, pz];
|
||||
let normal = [nx, ny, nz];
|
||||
self.section = if active { Some((point, normal)) } else { None };
|
||||
self.renderer
|
||||
.update_section(&self.device, active, point, normal, &self.walls, &self.slabs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -180,6 +330,107 @@ impl WebModelRenderer {
|
||||
};
|
||||
}
|
||||
|
||||
/// 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,
|
||||
"top" => CameraPreset::Top,
|
||||
"side" => CameraPreset::Side,
|
||||
"iso" => CameraPreset::Iso,
|
||||
"perspective" => CameraPreset::Persp,
|
||||
other => {
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"unbekanntes Kamera-Praeset: {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let (target, dist) = match self.bounds {
|
||||
Some((min, max)) => {
|
||||
let center = [
|
||||
(min[0] + max[0]) * 0.5,
|
||||
(min[1] + max[1]) * 0.5,
|
||||
(min[2] + max[2]) * 0.5,
|
||||
];
|
||||
let (dx, dy, dz) = (max[0] - min[0], max[1] - min[1], max[2] - min[2]);
|
||||
let radius = (dx * dx + dy * dy + dz * dz).sqrt() * 0.5;
|
||||
let radius = if radius > 0.0 { radius } else { 1.0 };
|
||||
// Abstand so, dass die Bounding-Kugel ins vertikale Sichtfeld
|
||||
// passt (+ Rand) — identische Formel wie `fitTargetDist`.
|
||||
let fov_y = crate::types::default_fov();
|
||||
let dist = ((radius / (fov_y * 0.5).tan()) * 1.2).max(4.0);
|
||||
(center, dist)
|
||||
}
|
||||
// Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start).
|
||||
None => ([2.5_f32, 1.0, 2.0], 14.0_f32),
|
||||
};
|
||||
self.camera = preset_camera(preset, target, dist);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Darstellungsart der Oberleiste setzen. Gueltige Werte:
|
||||
/// - `"shaded"` — beleuchtete Bauteilfarben (Default).
|
||||
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
|
||||
/// (identisch zum bisherigen `set_render_mode_white(true)`).
|
||||
/// - `"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).
|
||||
/// 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> {
|
||||
self.renderer
|
||||
.set_render_style(crate::gpu::RenderStyle::from_str_or_shaded(style));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Darstellungsart umschalten: `true` = weisses Clay-Material (beleuchtet,
|
||||
/// ohne Bauteil-Farben — Vectorworks „white"), `false` = normale Material-
|
||||
/// farbe (shaded, Default). Wirkt erst beim naechsten `render`. RUECKWAERTS-
|
||||
/// KOMPATIBEL — bildet intern auf denselben `render_style`-Zustand ab wie
|
||||
/// `set_render_style` (true→„white", false→„shaded"), sodass es keine zwei
|
||||
/// widersprechenden Zustaende gibt.
|
||||
pub fn set_render_mode_white(&mut self, white: bool) {
|
||||
self.renderer.set_white_mode(white);
|
||||
}
|
||||
|
||||
/// Schaltet das Referenz-Bodengitter (grid.rs) ein/aus und setzt seine
|
||||
/// Parameter. `elevation_m` = Hoehe der XZ-Gitterebene (world-Meter, i. d. R.
|
||||
/// die z=0-Ebene des aktiven Geschosses); `extent_m` = quadratische Kantenlaenge
|
||||
/// (auf ein Vielfaches von `spacing_m` gerundet); `spacing_m` = Linienabstand.
|
||||
/// Das Gitter ist zentriert um den Welt-Ursprung (x=0, z=0). Wirkt erst beim
|
||||
/// naechsten `render`. Default ohne Aufruf: unsichtbar (bestehende Aufrufer
|
||||
/// bleiben unveraendert). Jede 5. Linie (inkl. Mittelachse) ist eine Major-Linie.
|
||||
pub fn set_ground_grid(
|
||||
&mut self,
|
||||
visible: bool,
|
||||
elevation_m: f32,
|
||||
extent_m: f32,
|
||||
spacing_m: f32,
|
||||
) -> Result<(), JsValue> {
|
||||
self.renderer
|
||||
.set_ground_grid(&self.device, visible, elevation_m, extent_m, spacing_m);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setzt/loescht die Auswahl-Highlight-Linien (Umriss/Kanten des selektierten
|
||||
/// Bauteils). `vertices` = interleaved LineList-Vertices
|
||||
/// `[px,py,pz, r,g,b, ...]` (world-Meter, je 2 Vertices = 1 Segment) — exakt
|
||||
/// dasselbe Format wie die Grid-/Edge-Vertices. Ein leeres Slice loescht das
|
||||
/// Highlight (nichts wird gezeichnet). Die Linien liegen im Render-Pass ohne
|
||||
/// Tiefentest immer sichtbar obenauf (scheinen auch hinter Waenden durch).
|
||||
/// Wirkt erst beim naechsten `render`.
|
||||
pub fn set_highlight_lines(&mut self, vertices: &[f32]) -> Result<(), JsValue> {
|
||||
self.renderer.set_highlight_lines(&self.device, vertices);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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));
|
||||
|
||||
+11
-6
@@ -92,7 +92,7 @@ pub fn push_walls(value: serde_json::Value) {
|
||||
fn parse_model3d(value: serde_json::Value) -> Result<Model3d, serde_json::Error> {
|
||||
if value.is_array() {
|
||||
let walls = serde_json::from_value::<Vec<WallInput>>(value)?;
|
||||
Ok(Model3d { walls, slabs: Vec::new() })
|
||||
Ok(Model3d { walls, slabs: Vec::new(), meshes: Vec::new() })
|
||||
} else {
|
||||
serde_json::from_value::<Model3d>(value)
|
||||
}
|
||||
@@ -232,7 +232,7 @@ use render3d::gpu::Renderer as Renderer3d;
|
||||
#[cfg(feature = "native3d")]
|
||||
use render3d::math::orbit_eye;
|
||||
#[cfg(feature = "native3d")]
|
||||
use render3d::types::{Camera, Projection, SlabInput, WallInput};
|
||||
use render3d::types::{Camera, MeshInput, Projection, SlabInput, WallInput};
|
||||
#[cfg(feature = "native3d")]
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -245,6 +245,10 @@ struct Model3d {
|
||||
walls: Vec<WallInput>,
|
||||
#[serde(default)]
|
||||
slabs: Vec<SlabInput>,
|
||||
/// Rohe Kontext-Meshes (Terrain/importierte Volumen). `#[serde(default)]`
|
||||
/// haelt aeltere Payloads ohne `meshes` gueltig.
|
||||
#[serde(default)]
|
||||
meshes: Vec<MeshInput>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "native3d")]
|
||||
@@ -262,7 +266,7 @@ impl GpuState3d {
|
||||
fn new(window: Arc<Window>, model: &Model3d) -> Self {
|
||||
let (surface, device, queue, config) = configure_surface(&window, "3d.device");
|
||||
let mut renderer = Renderer3d::new(&device, config.format);
|
||||
renderer.upload_model(&device, &model.walls, &model.slabs);
|
||||
renderer.upload_model(&device, &model.walls, &model.slabs, &model.meshes);
|
||||
renderer.set_light([6.0, 12.0, 4.0]);
|
||||
Self { surface, device, queue, config, renderer, window }
|
||||
}
|
||||
@@ -315,12 +319,12 @@ fn load_model() -> Model3d {
|
||||
Ok(model) => model,
|
||||
Err(e) => {
|
||||
eprintln!("native3d: Modell-Parse-Fehler ({WALLS_PATH}): {e} — nutze Demo-Waende");
|
||||
Model3d { walls: demo_walls(), slabs: Vec::new() }
|
||||
Model3d { walls: demo_walls(), slabs: Vec::new(), meshes: Vec::new() }
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("native3d: Modell nicht ladbar ({WALLS_PATH}): {e} — nutze Demo-Waende");
|
||||
Model3d { walls: demo_walls(), slabs: Vec::new() }
|
||||
Model3d { walls: demo_walls(), slabs: Vec::new(), meshes: Vec::new() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,6 +343,7 @@ fn demo_walls() -> Vec<WallInput> {
|
||||
base_elevation: 0.0,
|
||||
color: grey,
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
};
|
||||
vec![
|
||||
mk([0.0, 0.0], [6.0, 0.0]),
|
||||
@@ -715,7 +720,7 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
self.pending3d = Some(model);
|
||||
return;
|
||||
};
|
||||
state.renderer.upload_model(&state.device, &model.walls, &model.slabs);
|
||||
state.renderer.upload_model(&state.device, &model.walls, &model.slabs, &model.meshes);
|
||||
if !self.nav3d {
|
||||
self.orbit = Some(Orbit::framed(&model));
|
||||
}
|
||||
|
||||
+831
-167
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
// Dokument-Tabs „Zeichnungen/Blätter" — Kopfzeile über der Zeichenfläche.
|
||||
//
|
||||
// Ersetzt die frühere Kopfzeile mit Geschosshöhe/Schnitthöhe (diese Werte
|
||||
// werden jetzt ausschliesslich in den Geschoss-Einstellungen gepflegt, siehe
|
||||
// den Editor `editor.floorHeight`/`editor.cutHeight` in App.tsx). Ein Tab
|
||||
// entspricht heute immer dem Grundriss eines Geschosses; ein Klick aktiviert
|
||||
// dessen Zeichnungsebene über denselben State, den auch das Panel
|
||||
// „Zeichnungsebenen" (DrawingLevelsPanel) verwendet — beide bleiben so
|
||||
// zwangsläufig synchron.
|
||||
//
|
||||
// Der Typ `Drawing` ist bewusst schlank gehalten, damit künftige Arten
|
||||
// (Schnitt-Blatt, Layout) sich später einhängen können, ohne dass die
|
||||
// Tab-Leiste selbst umgebaut werden muss — siehe `activate()` unten.
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { DrawingLevel } from "./model/types";
|
||||
import { t } from "./i18n";
|
||||
|
||||
/** Art einer Dokument-Zeichnung. Heute nur der Grundriss eines Geschosses;
|
||||
* Platz für künftige Arten (z. B. "section", "layout") bleibt offen. */
|
||||
export type DrawingKind = "plan";
|
||||
|
||||
/** Ein Tab der Dokument-Leiste — leichtgewichtiger Verweis auf die Quelle
|
||||
* (aktuell immer eine Zeichnungsebene vom Typ "floor"). */
|
||||
export interface Drawing {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: DrawingKind;
|
||||
/** Geschoss/Zeichnungsebene, deren Grundriss dieser Tab zeigt. */
|
||||
levelId: string;
|
||||
}
|
||||
|
||||
/** Leitet die Dokument-Tabs aus den Geschossen des Projekts ab — ein Tab je
|
||||
* Geschoss. Schnitte/Ansichten und freie Zeichnungen bleiben vorerst aussen
|
||||
* vor (weiterhin nur über das Panel „Zeichnungsebenen" erreichbar). */
|
||||
export function deriveDrawings(levels: DrawingLevel[]): Drawing[] {
|
||||
return levels
|
||||
.filter((level): level is DrawingLevel & { kind: "floor" } => level.kind === "floor")
|
||||
.map((level) => ({
|
||||
id: level.id,
|
||||
title: level.name,
|
||||
kind: "plan",
|
||||
levelId: level.id,
|
||||
}));
|
||||
}
|
||||
|
||||
export function DrawingTabs({
|
||||
drawings,
|
||||
activeLevelId,
|
||||
onSelectLevel,
|
||||
}: {
|
||||
drawings: Drawing[];
|
||||
/** Aktuell aktive Zeichnungsebene (globaler App-State, siehe App.tsx). */
|
||||
activeLevelId: string;
|
||||
/** Setter derselben Zeichnungsebene — identisch mit dem, den das Panel
|
||||
* „Zeichnungsebenen" verwendet (kein paralleler State). */
|
||||
onSelectLevel: (id: string) => void;
|
||||
}) {
|
||||
if (drawings.length === 0) return null;
|
||||
|
||||
// Aktivierung nach Art dispatchen. Heute gibt es nur "plan" (→ Geschoss
|
||||
// aktivieren); künftige Arten hängen hier ihr eigenes Ziel ein, ohne dass
|
||||
// sich an der Tab-Leiste selbst etwas ändert.
|
||||
const activate = (drawing: Drawing) => {
|
||||
switch (drawing.kind) {
|
||||
case "plan":
|
||||
onSelectLevel(drawing.levelId);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="drawing-tabs" role="tablist" aria-label={t("tabs.drawings")}>
|
||||
{drawings.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={d.levelId === activeLevelId}
|
||||
className={`drawing-tab${d.levelId === activeLevelId ? " active" : ""}`}
|
||||
onClick={() => activate(d)}
|
||||
title={d.title}
|
||||
>
|
||||
<span className="drawing-tab-label">{d.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { unionDrawings } from "./booleanOps";
|
||||
import type { Drawing2D } from "../model/types";
|
||||
|
||||
/** Geschlossenes Quadrat als Drawing2D mit optionalen Attributen. */
|
||||
function square(
|
||||
id: string,
|
||||
x: number,
|
||||
extra: Partial<Drawing2D> = {},
|
||||
): Drawing2D {
|
||||
return {
|
||||
id,
|
||||
type: "drawing2d",
|
||||
levelId: "L1",
|
||||
categoryCode: "10",
|
||||
geom: {
|
||||
shape: "polyline",
|
||||
pts: [
|
||||
{ x, y: 0 },
|
||||
{ x: x + 2, y: 0 },
|
||||
{ x: x + 2, y: 2 },
|
||||
{ x, y: 2 },
|
||||
],
|
||||
closed: true,
|
||||
},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("unionDrawings — Attribut-Vererbung vom ERST gewählten Element", () => {
|
||||
it("übernimmt Füllung/Ebene des ersten Elements, auch wenn das zweite keine hat", () => {
|
||||
// Element 1 (zuerst gewählt): rote Füllung via `background`, Schraffur, eigene
|
||||
// Ebene/Kategorie. Element 2: nackt. Die Formen überlappen.
|
||||
const first = square("a", 0, {
|
||||
background: "#ff0000",
|
||||
hatchId: "h1",
|
||||
color: "#333333",
|
||||
levelId: "L2",
|
||||
categoryCode: "20",
|
||||
foregroundSource: "layer",
|
||||
});
|
||||
const second = square("b", 1); // ohne Attribute, überlappt „a"
|
||||
|
||||
const { created, replacedIds } = unionDrawings([first, second]);
|
||||
|
||||
expect(replacedIds).toEqual(["a", "b"]);
|
||||
expect(created.length).toBeGreaterThanOrEqual(1);
|
||||
const outer = created[0];
|
||||
// Volles Erscheinungsbild des ersten Elements bleibt erhalten:
|
||||
expect(outer.background).toBe("#ff0000");
|
||||
expect(outer.hatchId).toBe("h1");
|
||||
expect(outer.color).toBe("#333333");
|
||||
expect(outer.levelId).toBe("L2");
|
||||
expect(outer.categoryCode).toBe("20");
|
||||
expect(outer.foregroundSource).toBe("layer");
|
||||
});
|
||||
|
||||
it("die Reihenfolge entscheidet: das ZWEITE Element diktiert nichts", () => {
|
||||
const red = square("a", 0, { background: "#ff0000" });
|
||||
const blue = square("b", 1, { background: "#0000ff" });
|
||||
// „blue" zuerst gewählt → Ergebnis blau.
|
||||
const out = unionDrawings([blue, red]).created[0];
|
||||
expect(out.background).toBe("#0000ff");
|
||||
});
|
||||
});
|
||||
@@ -85,12 +85,25 @@ function ringDrawing(src: Drawing2D, pts: Vec2[], filled: boolean): Drawing2D {
|
||||
categoryCode: src.categoryCode,
|
||||
geom: { shape: "polyline", pts, closed: true },
|
||||
};
|
||||
// Strich-/Ebenen-bezogene Attribute gelten für JEDEN Ring (Aussen wie Loch):
|
||||
// die zusammengesetzte Form erbt das volle Erscheinungsbild des Quell-
|
||||
// Elements (bei Boolean-Ops die des ERSTEN gewählten, s. unionDrawings).
|
||||
if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
|
||||
if (src.color !== undefined) out.color = src.color;
|
||||
if (src.weightMm !== undefined) out.weightMm = src.weightMm;
|
||||
if (src.strokeWeightSource !== undefined) out.strokeWeightSource = src.strokeWeightSource;
|
||||
if (filled) {
|
||||
// Füll-/Schraffur-bezogene Attribute nur auf dem gefüllten Aussenring (Löcher
|
||||
// bleiben ungefüllt). `background` ist der Nachfolger von `fillColor` und wird
|
||||
// mitgeführt, sonst ginge eine über das Attribut-Panel gesetzte Füllung (z. B.
|
||||
// rote Fläche) bei der Vereinigung verloren.
|
||||
if (src.hatchId !== undefined) out.hatchId = src.hatchId;
|
||||
if (src.fillColor !== undefined) out.fillColor = src.fillColor;
|
||||
if (src.foreground !== undefined) out.foreground = src.foreground;
|
||||
if (src.background !== undefined) out.background = src.background;
|
||||
if (src.foregroundSource !== undefined) out.foregroundSource = src.foregroundSource;
|
||||
if (src.backgroundSource !== undefined) out.backgroundSource = src.backgroundSource;
|
||||
if (src.hatchSource !== undefined) out.hatchSource = src.hatchSource;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Gemeinsamer Lade-Einstieg für das render3d-WASM-Modul (pkg3d, gebaut über
|
||||
// `npm run build:engine3d`). EINMAL geladen + initialisiert und memoisiert:
|
||||
// `mod.default()` (der wasm-bindgen-Init) darf nur EINMAL laufen — ein zweiter
|
||||
// Init würde die Closures des ersten verwerfen („closure invoked recursively or
|
||||
// after being dropped"). Sowohl der 3D-Viewport (useWasm3dRenderer) als auch die
|
||||
// Schnitt-/Ansichts-Pipeline (plan/toSection) teilen sich daher DIESE Instanz.
|
||||
//
|
||||
// Bewusst schwach typisiert (`any`): das echte `.d.ts` entsteht erst beim
|
||||
// wasm-Build; die Aufrufer legen ihre eigene Minimal-Schnittstelle darüber.
|
||||
|
||||
let enginePromise: Promise<any> | null = null;
|
||||
|
||||
/** Lädt das WASM-Modul einmalig und gibt das initialisierte Modul zurück. */
|
||||
export function loadEngine3d(): Promise<any> {
|
||||
if (!enginePromise) {
|
||||
enginePromise = (async () => {
|
||||
const mod: any = await import("./pkg3d/render3d.js");
|
||||
await mod.default(); // lädt render3d_bg.wasm
|
||||
return mod;
|
||||
})();
|
||||
}
|
||||
return enginePromise;
|
||||
}
|
||||
+67
-1
@@ -12,6 +12,7 @@ import type { Plan, Primitive } from "../plan/generatePlan";
|
||||
import type { LayerCategory, Project, Vec2 } from "../model/types";
|
||||
import { flattenCategories } from "../model/types";
|
||||
import { DxfWriter } from "./dxfWriter";
|
||||
import { buildRandomHatchRuns, zigzagPoints, motifPoints } from "../plan/glPlan/glPlanHatch";
|
||||
|
||||
export interface ExportDxfOptions {
|
||||
/** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */
|
||||
@@ -106,7 +107,18 @@ function emitPrimitive(
|
||||
): void {
|
||||
switch (p.kind) {
|
||||
case "line":
|
||||
if (p.zigzag) {
|
||||
// Zickzack-Linie → offene Polylinie (amplitude/wavelength Papier-mm →
|
||||
// Modell-Meter über dieselbe Kopplung wie das Strichmuster oben).
|
||||
const amp = p.zigzag.amplitude * DASH_MM_TO_M;
|
||||
const wav = p.zigzag.wavelength * DASH_MM_TO_M;
|
||||
dxf.addPolyline(layer, zigzagPoints(p.a, p.b, amp, wav), false);
|
||||
} else if (p.motif) {
|
||||
// Custom-Motiv → offene Polylinie (Papier-mm → Modell-Meter, wie oben).
|
||||
dxf.addPolyline(layer, motifPoints(p.a, p.b, p.motif, DASH_MM_TO_M), false);
|
||||
} else {
|
||||
dxf.addLine(layer, p.a, p.b);
|
||||
}
|
||||
break;
|
||||
|
||||
case "arc":
|
||||
@@ -121,15 +133,31 @@ function emitPrimitive(
|
||||
dxf.addPolyline(layer, pts, true);
|
||||
// Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine
|
||||
// sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus.
|
||||
// Bild-Schraffur (kind==="image"): DXF trägt keine Rasterbilder →
|
||||
// FALLBACK ohne Musterlinien (nur die Umriss-Polylinie oben). Als
|
||||
// Folge-Arbeit dokumentiert. Random-Vektor: deterministische Streu-Striche
|
||||
// (Modell-Meter, identischer Seed wie Bildschirm/PDF).
|
||||
if (
|
||||
includeHatches &&
|
||||
p.hatch.kind !== "image" &&
|
||||
p.hatch.pattern !== "none" &&
|
||||
p.hatch.pattern !== "solid"
|
||||
) {
|
||||
for (const seg of hatchSegments(pts, p.hatch)) {
|
||||
const cycle = dashCycleMeters(p.hatch.dash);
|
||||
const segs: Seg[] =
|
||||
p.hatch.lines === "random"
|
||||
? buildRandomHatchRuns(pts, p.hatch)
|
||||
.filter((r) => r.length >= 2)
|
||||
.map((r) => ({ a: r[0], b: r[r.length - 1] }))
|
||||
: hatchSegments(pts, p.hatch);
|
||||
for (const seg of segs) {
|
||||
if (cycle) {
|
||||
for (const d of dashSeg(seg, cycle)) dxf.addLine(LAYER_HATCH, d.a, d.b);
|
||||
} else {
|
||||
dxf.addLine(LAYER_HATCH, seg.a, seg.b);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -174,6 +202,44 @@ interface Seg {
|
||||
b: Vec2;
|
||||
}
|
||||
|
||||
// Strichmuster (`dash`, mm-Papier) → Zyklus in WELT-Metern. Gleiche Umrechnung
|
||||
// wie der Bildschirm/GL-Pfad: 1 mm ≙ (1/0.13) Kachel-Einheiten, 1 Einheit = 1/90 m.
|
||||
// `null` = durchgezogen (keine Zerlegung). Ungerade Musterlängen werden verdoppelt.
|
||||
const DASH_MM_TO_M = (1 / 0.13) * (1 / 90);
|
||||
function dashCycleMeters(dash: number[] | null): number[] | null {
|
||||
if (!dash || dash.length === 0) return null;
|
||||
const pat = dash.map((d) => Math.max(0, d) * DASH_MM_TO_M).filter((d) => d > 0);
|
||||
if (pat.length === 0) return null;
|
||||
return pat.length % 2 === 0 ? pat : pat.concat(pat);
|
||||
}
|
||||
|
||||
// Zerlegt EINE Schraffurlinie in ihre durchgezogenen Teilstücke gemäß `cycle`
|
||||
// ([an, aus, …], Meter). Die Phase startet je Linie neu — visuell identisch zum
|
||||
// SVG-`<pattern>`, das das Muster je Kachellinie zurücksetzt.
|
||||
function dashSeg(seg: Seg, cycle: number[]): Seg[] {
|
||||
const out: Seg[] = [];
|
||||
const len = Math.hypot(seg.b.x - seg.a.x, seg.b.y - seg.a.y);
|
||||
if (len < 1e-9) return out;
|
||||
const ux = (seg.b.x - seg.a.x) / len;
|
||||
const uy = (seg.b.y - seg.a.y) / len;
|
||||
let pos = 0;
|
||||
let idx = 0;
|
||||
let on = true;
|
||||
while (pos < len - 1e-9) {
|
||||
const step = Math.min(cycle[idx % cycle.length], len - pos);
|
||||
if (on && step > 1e-9) {
|
||||
out.push({
|
||||
a: { x: seg.a.x + ux * pos, y: seg.a.y + uy * pos },
|
||||
b: { x: seg.a.x + ux * (pos + step), y: seg.a.y + uy * (pos + step) },
|
||||
});
|
||||
}
|
||||
pos += step;
|
||||
idx++;
|
||||
on = !on;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hatchSegments(
|
||||
poly: Vec2[],
|
||||
hatch: Extract<Primitive, { kind: "polygon" }>["hatch"],
|
||||
|
||||
@@ -64,13 +64,14 @@ export async function buildPlanPdf(
|
||||
): Promise<jsPDF> {
|
||||
const { w: pageW, h: pageH } = pageSizeMm(opts.paper, opts.orientation);
|
||||
|
||||
// Render-Szene EXAKT wie der Viewport erzeugen (keine Zusatz-Optionen —
|
||||
// `planToRenderScene(plan)` ist der vollstaendige Aufruf, siehe
|
||||
// `useWasmPlanRenderer.ts`), dann als Papier-SVG serialisieren. Etwas mehr
|
||||
// unterer Rand, falls ein Schriftfeld gezeichnet wird (damit es nicht in
|
||||
// den Plan ragt).
|
||||
// Render-Szene wie der Viewport erzeugen, dann als Papier-SVG serialisieren.
|
||||
// Einziger Unterschied zum Viewport-Aufruf: der echte Export-Massstab wird an
|
||||
// `planToRenderScene` durchgereicht (derselbe `scaleDenominator` wie unten an
|
||||
// `sceneToPrintSvg`), damit der Zeilenabstand mehrzeiliger Stempeltexte auch
|
||||
// bei Nicht-1:100-Massstab papier-mm-genau stimmt. Etwas mehr unterer Rand,
|
||||
// falls ein Schriftfeld gezeichnet wird (damit es nicht in den Plan ragt).
|
||||
const margin = 10;
|
||||
const scene = planToRenderScene(plan);
|
||||
const scene = planToRenderScene(plan, opts.scaleDenominator);
|
||||
const print = sceneToPrintSvg(scene, {
|
||||
scaleDenominator: opts.scaleDenominator,
|
||||
pageWidthMm: pageW,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6).
|
||||
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände + 1 Decke).
|
||||
// • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet.
|
||||
// • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen.
|
||||
// • leeres Projekt ⇒ nur Kopfzeile (kein Crash).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { exportScheduleCsv, scheduleRows } from "./exportSchedule";
|
||||
import type { Project, Wall, Ceiling } from "../model/types";
|
||||
|
||||
/** Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2). */
|
||||
function fixtureProject(): Project {
|
||||
const walls: Wall[] = [
|
||||
{
|
||||
id: "W1",
|
||||
type: "wall",
|
||||
floorId: "eg",
|
||||
categoryCode: "20",
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 5, y: 0 }, // Länge 5
|
||||
wallTypeId: "aw",
|
||||
height: 2.6,
|
||||
},
|
||||
{
|
||||
id: "W2",
|
||||
type: "wall",
|
||||
floorId: "eg",
|
||||
categoryCode: "20",
|
||||
start: { x: 5, y: 0 },
|
||||
end: { x: 5, y: 4 }, // Länge 4
|
||||
wallTypeId: "aw",
|
||||
height: 2.6,
|
||||
},
|
||||
];
|
||||
const ceilings: Ceiling[] = [
|
||||
{
|
||||
id: "D1",
|
||||
type: "ceiling",
|
||||
floorId: "eg",
|
||||
categoryCode: "30",
|
||||
outline: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 5, y: 0 },
|
||||
{ x: 5, y: 4 },
|
||||
{ x: 0, y: 4 },
|
||||
], // 5×4 = 20 m²
|
||||
wallTypeId: "dt",
|
||||
ceilingTypeId: "dt",
|
||||
},
|
||||
];
|
||||
return {
|
||||
id: "t",
|
||||
name: "T",
|
||||
lineStyles: [],
|
||||
hatches: [],
|
||||
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
|
||||
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
|
||||
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
|
||||
drawingLevels: [
|
||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||
],
|
||||
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||
walls,
|
||||
doors: [],
|
||||
openings: [],
|
||||
ceilings,
|
||||
stairs: [],
|
||||
rooms: [],
|
||||
drawings2d: [],
|
||||
context: [],
|
||||
} as Project;
|
||||
}
|
||||
|
||||
describe("exportScheduleCsv — Bauteilliste", () => {
|
||||
it("liefert Kopfzeile + je Bauteil eine Zeile mit korrekten Kennwerten", () => {
|
||||
const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false });
|
||||
const lines = csv.split("\r\n");
|
||||
|
||||
// Kopfzeile + 3 Bauteil-Zeilen (2 Wände, 1 Decke).
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]");
|
||||
|
||||
// Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13.
|
||||
expect(lines[1]).toBe("Wand;W1;Aussenwand;EG;5.00;2.60;0.40;13.00");
|
||||
// Wand W2: Länge 4, Ansichtsfläche 10.40.
|
||||
expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40");
|
||||
// Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20.
|
||||
expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00");
|
||||
});
|
||||
|
||||
it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => {
|
||||
const csv = exportScheduleCsv(fixtureProject());
|
||||
const lines = csv.split("\r\n");
|
||||
// Kopf(1) + 3 Zeilen + Leerzeile + Titel + 2 Aggregate (Aussenwand, Betondecke) = 8.
|
||||
expect(lines).toHaveLength(8);
|
||||
expect(lines[4]).toBe("");
|
||||
expect(lines[5]).toContain("Zusammenfassung");
|
||||
// Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40.
|
||||
expect(lines[6]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
|
||||
// Decken-Aggregat: 1 Stk, keine Länge, Fläche 20.
|
||||
expect(lines[7]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
|
||||
});
|
||||
|
||||
it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => {
|
||||
const proj = fixtureProject();
|
||||
proj.wallTypes[0].name = 'Wand; "Spezial"';
|
||||
const csv = exportScheduleCsv(proj, { includeSummary: false });
|
||||
const line = csv.split("\r\n")[1];
|
||||
// Semikolon + verdoppelte Anführungszeichen, Feld in "..." eingefasst.
|
||||
expect(line).toContain('"Wand; ""Spezial"""');
|
||||
});
|
||||
|
||||
it("leeres Projekt ⇒ nur Kopfzeile (kein Crash)", () => {
|
||||
const proj = fixtureProject();
|
||||
proj.walls = [];
|
||||
proj.ceilings = [];
|
||||
const csv = exportScheduleCsv(proj);
|
||||
expect(csv.split("\r\n")).toHaveLength(1);
|
||||
expect(scheduleRows(proj)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user