Compare commits

...

97 Commits

Author SHA1 Message Date
karim 03f0c402b5 Nordstern-3D: Live-Schnittebene mit schraffierten Schnittflaechen
Der 3D-Viewport kann das Modell jetzt live an einer vertikalen Ebene
aufschneiden (Overlay-Button 'Schnittebene', MVP: Ebene durch die
Modellmitte, Blick +Y):
- Globals um section_plane vec4 erweitert (xyz=Normale, w=-n*p; Enable
  in mode.y); MESH_/GRID_WGSL discarden Fragmente vor der Ebene
  (world_pos als neuer Varying) - Flaechen, Kanten, Grid, Highlight.
- section_fill.rs (neu): build_cut_caps ruft cut_section und trianguliert
  jedes (u,v)-Rechteck zurueck in Weltkoordinaten ([x,y,z,u,v]).
- CAP_WGSL: prozedurale 45-Grad-Diagonalschraffur aus (u,v) in Modell-
  Metern (fwidth-AA), Tinte auf Papier wie die 2D-Konvention; eigene
  cap_pipeline (CullMode::None, Depth-Bias Richtung Kamera gegen
  Z-Fighting), gezeichnet nach den Flaechen, nicht geclippt.
- web.rs cached walls/slabs; set_section_plane(active, p, n) aktualisiert
  Uniform + Caps; set_model baut Caps bei aktivem Schnitt neu.
- useWasm3dRenderer.setSectionPlane + Toggle in Wasm3DViewport.

Bekannte Luecken (dokumentiert): Oeffnungen im 3D-Solid nicht ausgespart
(cut_section spart aus -> Mismatch bei Schnitt durchs Fenster; wird mit
dem Boolean-Loecher-Slice geloest), nur vertikale Ebenen, ein globales
Muster (per-Bauteil = Stufe 2). cargo 56 Tests + naga-WGSL-Validierung,
222 vitest gruen, WASM neu gebaut (pkg3d gitignored).
2026-07-04 15:08:41 +02:00
karim 82d354f5e7 Joins Phase 2: Merge-Regel im Schnitt (gleiche Komponente verschmilzt)
Die Schnitt-Dominanz kannte nur 'strikt hoeher schneidet schwaecher'.
Neu: Baender GLEICHER joinPriority UND GLEICHER Komponente, die sich
beruehren/ueberlappen, verschmelzen zu EINEM Rechteck (rectUnionIfRect:
Union nur, wenn das Ergebnis exakt ein Rechteck ist; iterativ bis zum
Fixpunkt) - keine innere Trennlinie mehr durch gleichartiges Material,
analog resolveJoinPriority 'merge' im Grundriss. SectionCutPolygon
traegt dafuer componentId (Schicht-Split + repraesentatives Bauteil bei
einschichtiger Poche). Verschiedene Komponenten gleicher Prioritaet
koexistieren unveraendert; Subtraktion bit-identisch. +6 Tests (222).
2026-07-04 15:06:19 +02:00
karim afd03f1808 HANDOVER: Session 3 (Prioritaets-Joins, Schnitt-Sanierung, in-flight Slices) 2026-07-04 15:00:55 +02:00
karim bf97d501be Joins Phase 1b: T-Stoss-Kern sticht nur durch den Nah-Putz (keine Achs-Ueberlappung)
Der verschmelzende Abzweig-Kern (Backstein) lief seit Phase 1 bis zur
ACHSE der Durchgangswand und ueberlappte deren Koerper. Jetzt stoppt er
an der Nahflaeche des Durchgangs-Rueckgrats: mergeCut bei
offT + sign*(tT/2 - nearPlaster), wobei nearPlaster die Summe der
Durchgangs-Schichten zwischen Nahseite und Rueckgrat-Kern ist. Die
L-Seitenlinien der Putzschichten liegen ebenfalls auf dieser Tiefe.
Naht zwischen den beiden Kernen entfaellt ueber die bestehenden
Mechanismen (stroke:none am Fuellband, noStrokeEdges an den Stirnkanten,
Zeichenreihenfolge bricht die Nahflaechenlinie auf) - kein
generatePlan-Umbau noetig. Rust-Paritaet in geometry/lib.rs analog
(Aggregat-Cuts unveraendert, parity.test gruen). 216 Tests + cargo 7/7.
2026-07-04 14:55:51 +02:00
karim c36a7e0d54 Schnitt: wandrelative Schraffuren drehen mit der geschnittenen Wand
splitWallLayers uebergab resolveHatch keinen Wandachsen-Winkel, wodurch
wandrelative Muster (relativeToWall: Daemmung, Backstein) im Schnitt
absolut blieben — die Daemmung lief vertikal statt quer zur Wanddicke.
Die geschnittene Wand steht im Schnitt vertikal (Bandachse entlang v,
Modellwinkel 90 Grad); dieser Winkel wird jetzt wie im Grundriss
(addWallPoche) mitgegeben -> Daemmung horizontal, wie an den Waenden
im Grundriss. 216 Tests gruen.
2026-07-04 14:54:10 +02:00
karim 6c473558ef Schnitt: materialspezifische SIA-Schraffuren wiederhergestellt
de7a26b lieferte projectToModel3d pro SCHICHT eine eigene Voll-Box; der
Schnitt bekam damit segments*layers Cut-Polygone, waehrend
wallSegmentOwners weiter nur oeffnungsbasiert segmentiert -> Index-
Versatz, falscher Owner, splitWallLayers zerteilte bereits einschichtige
Baender erneut -> einheitliches Diagonal-Muster statt SIA-Muster.

Fix: Model3dOptions { layeredWalls } an projectToModel3d (Default true,
3D-Viewer unveraendert); computeSection ruft mit layeredWalls:false ->
eine Voll-Box pro Wand, Owner-Mapping wieder 1:1, splitWallLayers
erzeugt die per-Material-Baender (Daemmung-Haarlinien, Backstein-
Diagonale, Beton-Kreuz) wie im Grundriss. 216 Tests gruen.
2026-07-04 14:44:36 +02:00
karim 5ed3549c66 Schraffuren monochrom: Vordergrund #0f0f0f, Hintergrund #f0f0f0
Alle Schraffuren (Schnitt- UND Oberflaechen-/Ansichtsschraffur) rendern
jetzt einheitlich in Tinte #0f0f0f auf Papier #f0f0f0 statt in der
Materialfarbe des Bauteils (Backstein braun, Beton grau). HATCH_INK/
HATCH_PAPER ersetzen POCHE_FILL_BLACK/WHITE; resolveHatch liefert immer
HATCH_INK als Linienfarbe. Der generische Schnitt-Fallback nutzte bisher
die rohe 3D-Albedo (Ursache des Backstein-Brauns bei nicht aufloesbarer
Schicht) -> jetzt HATCH_PAPER. Print-Mono-Modus unberuehrt. 216 Tests gruen.
2026-07-04 14:33:35 +02:00
karim da39f0c8d5 Fix Regression: 3D-Wandschichten auf linke Normale (Schnitt = Grundriss)
de7a26b versetzte die per-Schicht-Boxen entlang (uy,-ux) = RECHTE Normale,
waehrend Grundriss/clippedBand die LINKE Normale leftNormal(u)=(-uy,ux)
nutzt -> Schichten im 3D UND im davon abgeleiteten 2D-Schnitt gespiegelt
(Backstein aussen statt innen). pushSegment nutzt jetzt die linke Normale.
Wandlage unveraendert (Stack bleibt symmetrisch um die Achse), nur die
Schichtseiten stimmen wieder mit dem Grundriss. raycast3d unberuehrt
(symmetrische Box). 216 Tests gruen, kein WASM-Rebuild.
2026-07-04 14:22:12 +02:00
karim fc0a76feea Joins Phase 1: materialbewusster T-Stoss (Backstein verschmilzt, Putz-L)
Am T-Stoss wird der Abzweig nicht mehr naiv ueber die volle Dicke an der
Durchgangswand-Flaeche gekappt. Neu (joinPriority-basiert):
- resolveJoinPriority(a,b): merge/coexist/trim je nach Prioritaet+Komponente.
- WallCuts.layerCuts (additiv): pro Schicht eine Cut-Linie + optionale L-Seiten-
  linie. Gleiche/hoehere Prioritaet wie der Durchgangs-Backbone -> kein Cut
  (Schicht laeuft durch = Merge); schwaechere Schicht -> Nahflaechen-Cut + L.
- addWallPoche nutzt layerCuts pro Schicht (Fallback: alte Aggregat-Cuts);
  neue 'layer-joint-l'-Linie als L-Rueckschnitt.
- Rust-Paritaet in geometry/lib.rs additiv gespiegelt (LayerInput serde default,
  Aggregat-Cuts bit-identisch -> parity.test gruen).

Einschichtige/nicht-passende T-Stoesse pixel-identisch. +8 Tests (216 gesamt),
cargo 7/7.
2026-07-04 14:13:55 +02:00
karim e4b8df6ae8 3D: Wand an Deckenunterkante trimmen (Prioritaets-Trim, kein Z-Fighting)
Wo eine Decke (hoehere joinPriority, z.B. Beton 100) buendig auf der
Wandoberkante sitzt, durchdrangen sich Wand- und Deckenvolumen exakt
ueber die Deckendicke -> Z-Fighting im 3D-Viewport. emitWall trimmt jetzt
zTop der Wand auf die Deckenunterkante, wenn eine Decke gleichen
Geschosses mit Footprint-Ueberlapp hoehere Prioritaet hat. Footprint-Test
ist eine Bounding-Box-Naeherung (dokumentiert). Rein TS-seitig vor dem
RWall-Export -> kein WASM-Rebuild. +3 Tests (208 gesamt).
2026-07-04 14:02:22 +02:00
karim 32497bcfab TopBar: Massstab-Cluster-Hoehe angleichen + mehr vertikale Luft
Der Massstab/Zoom-Cluster war hoeher als die uebrigen zweizeiligen
Kombos: die Stat-Pille hatte grid-row:1/span 2, aber die rechte Seite
ist ein einzelner Flex-Container -> leere Phantom-Zeile + Gap (+6px).
Cluster von Grid auf einfaches Flex umgestellt (jetzt 51px, buendig mit
den 52px-Kombos). Topbar-Hoehe 56->60px, Padding 4->6px fuer etwas
ausgewogenere Luft oben/unten.
2026-07-04 13:56:19 +02:00
karim 18557170aa Renderer-Umschalter in die Settings; Nordstern als Default
Der WebGL2<->Nordstern-Umschalter wandert aus der Statusleiste in den
Settings-Dialog. Default ist jetzt Nordstern (WASM), sofern WebGPU
verfuegbar ist; WebGL2 nur noch als expliziter Fallback. So sieht man
nicht mehr versehentlich die Darstellungsfehler des WebGL2-Viewers.
styles.css: .sb-renderer-toggle align-self:flex-start, damit das Pill
im Settings-Dialog nicht auf volle Breite zieht.
2026-07-04 13:47:27 +02:00
karim d74dda1ab5 Sample: innere Querwand W9 (Mauerwerk verputzt) als T-Stoss-Demo
Neuer Innenwandtyp 'iw' (Innenputz/Backstein/Innenputz 15 cm) und eine
Querwand W9 (x=2.4) im EG, die den Raum teilt und mit beiden Enden mittig
auf die Innenflaechen von W1/W3 stoesst -> zwei Mittelspannen-T-Stoesse.
Tuer + EG-Fenster so positioniert, dass beidseits von W9 massives
Mauerwerk sichtbar bleibt (klarer Anschluss-Nachweis).
2026-07-04 13:33:00 +02:00
karim 0ddf99de08 Geometry-Crate: T-Stoss-Verschneidung nach Rust portiert (TS<->Rust-Paritaet)
compute_joins behandelt jetzt wie die TS-Referenz auch T-Knoten (3 Enden,
kollineares Paar = Durchgang, Abzweig an dessen Flaeche geschnitten) und
Mittelspannen-Stoesse (freies Ende trifft Wandseite). miter_line/L-Ecke
bit-identisch. Rust-Tests aktualisiert (t_junction_branch_gets_face_cut,
mid_span_tee_free_end_hits_wall_side). cargo test + parity.test.ts gruen.
2026-07-04 13:33:00 +02:00
karim e1698b7c3d Wand-T-Verbindungen im Grundriss: T-Knoten + Mittelspannen-Stoss
computeJoins behandelt jetzt neben L-Ecken auch T-Stoesse:
- T-Knoten (3 Wandenden am selben Punkt): das kollineare Paar bildet
  die Durchgangswand und bleibt ungeschnitten; das abzweigende Ende
  wird an der zugewandten Flaeche der Durchgangswand abgeschnitten.
- Mittelspannen-Stoss (freies Wandende trifft die Seite einer anderen
  Wand): das Ende wird an deren zugewandter Flaeche geschnitten.

L-Ecken bleiben bit-identisch (miterLine unveraendert, ends===2-Zweig
Zeile fuer Zeile gleich). Die Schnittlinien sind strukturell identisch
zu den bisherigen (Line|null) und werden von generatePlan/clippedBand
ohne Aenderung angewendet. +3 Tests (205 gesamt gruen), tsc clean.
2026-07-04 13:12:00 +02:00
karim f2b21d1f69 Boolean-Ops: vereinigte Form erbt volle Attribute des ersten Elements
ringDrawing kopierte nur color/hatchId/fillColor/lineStyleId/weightMm —
die neueren Attribut-Felder (background = Nachfolger von fillColor,
foreground + die By-Layer/By-Object-Quellen) gingen bei Union/Difference/
Intersection verloren. Dadurch verlor z. B. eine über das Attribut-Panel
rot gefüllte Fläche ihre Füllung nach dem Vereinigen. Jetzt erbt die
zusammengesetzte Form das volle Erscheinungsbild des ZUERST gewählten
Elements (Auswahl-Reihenfolge = Index 0). Füll-Attribute nur auf dem
gefüllten Aussenring, Strich-/Ebenen-Attribute auf jedem Ring.
2026-07-04 12:58:10 +02:00
karim 79d1187af7 HANDOVER: weitere Commits (z-Anordnen, Wand-Bänder, A6-CSV, Glasscheiben) ergänzt 2026-07-04 12:53:22 +02:00
karim 7dd324d55e 3D: Glasscheiben in Fenstern (AUDIT A5-Teilschritt)
Jede Fensteröffnung bekommt im 3D eine dünne, leicht bläuliche
Glasscheibe (RMesh, mittig in der Wanddicke, UK=baseElevation+sillHeight
bis OK), damit Fenster als Fenster lesbar sind statt als Löcher. Türen
bleiben offen. Nur meshes-Ebene → pickGeometry/Highlight unberührt.
2026-07-04 12:53:01 +02:00
karim e42c336478 AUDIT A6: Bauteil-Schedule als CSV exportieren
Neuer Datei-Menü-Eintrag „Bauteilliste (CSV)": eine Zeile je Wand/Decke
(Typ, ID, Bauteil, Geschoss, Länge, Höhe, Dicke, Fläche) plus Aggregat
je Bauteil-Typ, als CSV-Download. Kennwerte aus dem Modell abgeleitet
(Wandlänge aus Achse, wallTypeThickness, polygonArea), RFC-4180-Escaping.
2026-07-04 12:47:00 +02:00
karim de7a26b89e 3D: mehrschichtige Wände zeigen ihren Schichtaufbau
Statt eines Vollkörpers in der repräsentativen Farbe wird jede
Materiallage der Wand als eigener extrudierter Teilquader in ihrer
Component-Farbe emittiert (quer zur Wanddicke gestapelt, zentriert um
die Achse). Gilt auch für die Öffnungs-Teilquader (Pfeiler/Brüstung/
Sturz). Jede Schicht-Box trägt die wallId der Ursprungswand → Klick-
Auswahl + Highlight umfassen weiter die ganze Wand.
2026-07-04 12:40:25 +02:00
karim 8bc472a272 2D-Plan: z-Anordnen (nach vorn/hinten holen)
Selektierte 2D-Zeichnungselemente lassen sich jetzt in der Zeichen-
reihenfolge umsortieren — Kontextmenü „Ganz nach vorn / Nach vorn /
Nach hinten / Ganz nach hinten". Reine Array-Umsortierung in
project.drawings2d (spätere Position = optisch oben), Undo-fähig über
setProject. reorderDrawings(ids, front|forward|backward|back).
2026-07-04 12:40:25 +02:00
karim 2dc40cd788 HANDOVER: Session 2 (TopBar-Redesign + 3D-Editierbarkeit R1-R5) + 3D-Rest notiert 2026-07-04 06:32:48 +02:00
karim 02bd2616fc 3D: Bauteil-/Materialfarbe statt Einheitsgrau für Wände & Decken
projectToModel3d trug bisher für alle Wände WALL_RGB und alle Decken
SLAB_RGB (konstant grau), obwohl die Component-Materialfarbe verfügbar
ist. Jetzt: repräsentative Farbe der dicksten Schicht (tragende/
dominante Lage) je Wandtyp bzw. Deckentyp, Fallback auf die Konstanten.
Reine Albedo-Änderung — Geometrie, Picking und Highlight unberührt.
Schicht-Teilquader je Materiallage (echter 3D-Wandaufbau) bleibt als
Folgeschritt vermerkt.
2026-07-04 06:31:48 +02:00
karim fabf97a402 Nordstern-3D: Auswahl-Highlight (orange Outline, immer sichtbar)
Ein selektiertes Bauteil wird jetzt im 3D-Viewport mit einer orangen
Umrisslinie markiert, die dank No-Depth-Pipeline auch hinter Wänden
durchscheint (klare Selektions-Rückmeldung). Zusammen mit Objekt-Info
(numerisch) und Attribute-Panel ist die 3D-Auswahl damit vollständig.

- Engine: set_highlight_lines(vertices) + highlight_pipeline
  (LineList, depth_compare Always, kein Depth-Write), als letzter
  Draw-Call obenauf. Reuse der Grid-Linien-Infrastruktur.
- TS: selectionHighlightLines() baut die Quader-/Prisma-Kanten der
  Auswahl in Akzentfarbe; App berechnet sie per useMemo aus der
  Store-Auswahl und reicht sie durch.
2026-07-04 06:26:29 +02:00
karim ac0d9efc84 Nordstern-3D: Klick-Auswahl von Bauteilen (Raycast)
Links-Klick auf eine Wand/Decke im 3D-Viewport selektiert das Bauteil
(TS-Raycast gegen OBB der Wände + extrudierte Deckenprismen), setzt die
Store-Selektion (setSelectedWallIds/setSelectedCeilingIds, Shift/Ctrl =
additiv) → Attribute- und Objekt-Info-Panel zeigen das Bauteil. Erster
Schritt zur 3D-Editierbarkeit.

- raycast3d.ts: reine Kamera-Strahl-/Schnitt-Mathematik (10 Unit-Tests).
- toWalls3d.ts: wallId/ceilingId in die 3D-Records + pickGeometry();
  Serde-Pfad unberührt (keine deny_unknown_fields, Extrafelder werden
  Rust-seitig ignoriert).
- Klick-vs-Drag-Schwelle (4px) trennt Auswahl von Orbit; Leertreffer
  leert die Auswahl.
2026-07-04 06:17:45 +02:00
karim 6ef57b6b6b Nordstern-3D: View-Styles wireframe + hidden-line + schönere Schattierung
- Neue set_render_style(style)-Methode: shaded/white/wireframe/hidden
  (textured fällt vorerst auf shaded zurück, bis die Textur-Pipeline
  steht). Voll durchverdrahtet vom TopBar-Darstellungs-Dropdown zum
  Nordstern-Viewport (setRenderStyle statt nur setWhiteMode).
- Feature-Edge-Extraktion (edges.rs): Kanten aus dem Mesh, dedupliziert
  + Crease-Erkennung (nur Silhouette/Knickkanten, keine Triangulierungs-
  Diagonalen) → sauberes Architektur-Drahtgitter. Reuse der Grid-
  LineList-Pipeline.
- wireframe = nur Kanten; hidden = flach-weisse Flächen (Depth-Bias) +
  sichtbare Kanten obenauf (klassischer Hidden-Line-Look).
- Grundschattierung: dezentes Gegenlicht (Fill-Light), damit abgewandte
  Flächen nicht mehr in Schwarz absaufen.
2026-07-04 06:05:11 +02:00
karim 85a721f03b Nordstern-3D: Boden-Referenzraster + bessere Kamera-Steuerung
- Boden-Grid-Fläche auf y = baseElevation des aktiven Geschosses,
  ein-/ausschaltbar (Overlay-Button im Viewport, State in viewSlice
  grid3dVisible). Eigene LineList-Pipeline in der Engine (grid.rs,
  GRID_WGSL, geteilte View-Projection/Depth mit der Mesh-Pipeline),
  neue set_ground_grid(visible, elevation, extent, spacing)-Methode.
  Minor-Linien dezent, jede 5. als Major betont.
- Kamera-Steuerung überarbeitet (vorher nur Mitte=Orbit, Pan auf
  Shift+Mitte versteckt): Links=Orbit, Mitte/Rechts=Pan (bewegen),
  Rad=Zoom zum Cursor. Laptop-freundlich, konsistent mit der
  2D-Plan-Navigation (dort Mitte=Pan).
2026-07-04 05:54:16 +02:00
karim 7c02ea0498 Zeilenhöhe-Regler statt "+ Text"; Linienstil editierbar; DOSSIER-Audit gesichert
- "+ Text"-Knopf entfernt (war ohnehin immer deaktiviert, kein Werkzeug
  dahinter — Text wird ein eigenständiges Zeichenwerkzeug, AUDIT B1).
  An seiner Stelle ein Zeilenhöhe-Regler (Stepper, Vielfaches der
  Schriftgrösse) — neues Paragraph.lineHeight, durchgereicht bis in
  HTML-Vorschau (line-height) und SVG-Render (kumulierte Zeilen-
  Vorschübe statt festem lineGap).
- Linienstil im Attribute-Panel war rein informativ (kein Setter im
  Host-Kontrakt). onSetSelectionLineStyle ergänzt (nur Drawing2D),
  jetzt echtes Dropdown statt "—"-Anzeige.
- docs/design/dossier-feature-audit.md: der ausführliche A1-A6/B1-B4/
  C1-C3/D1-D3/E-Auditbericht aus einer alten Session gesichert (lag
  bisher nur im Transkript, nicht im Repo) — Quelle der HANDOVER.md-
  AUDIT-Kürzel.
2026-07-04 05:36:18 +02:00
karim 77a5940616 15 gebündelte Open-Source-Schriften statt Systemfonts
Text-Werkzeug nutzte bisher OS-abhängige Systemfonts (Helvetica/Arial/
Times/Georgia/Courier) — Darstellung und PDF-Export waren damit nicht
plattformunabhängig deterministisch. Jetzt 15 kuratierte SIL-OFL-
Schriften lokal als WOFF2 gebündelt (je mit OFL.txt-Lizenz):
Inter, Work Sans, IBM Plex Sans/Mono, Source Sans 3, Space Grotesk,
Manrope, Outfit, DM Sans, Public Sans, Karla, Rubik, Jost, Archivo,
Source Serif 4. Ein späterer Einstellungen-Dialog kann weitere
Schriften nachladen.
2026-07-04 05:19:53 +02:00
karim 0f0cf83a4b Hex-Feld breiter + helleren Text (voller Hex-Code sichtbar) 2026-07-04 05:17:29 +02:00
karim 3c3746d529 Farbfelder: Hex-Code separat editierbar, Swatch öffnet die Palette
ColorHexField (neu, geteilt) trennt die zwei Klickziele: Swatch/
natives Farb-Input öffnet weiter den Picker, der Hex-Text daneben ist
jetzt ein eigenes Eingabefeld (Enter übernimmt, Esc verwirft, ungültige
Eingabe fällt auf den letzten gültigen Wert zurück). Ersetzt die
bisherige reine Text-Anzeige in SettingsDialog + AttributesPanel.
2026-07-04 05:15:42 +02:00
karim d9937e5afa TopBar-Redesign: Marke, Zoom-Cluster, Datei-Menü, Fenstersteuerung, Dark-Theme fest
- Marke (DOSSIER-Wortmarke) + Ressourcen-Icon ganz links, wie im
  Rhino-Plugin; Settings-Icon folgt über den neuen Einstellungs-Dialog.
- Massstab/Zoom-Cluster bereinigt: doppelte Massstab-Dropdown entfernt,
  Zoom-Aktionen unter dem Massstab-Dropdown gestapelt (gleiche Breite),
  Ring bei "am Massstab" statt Flächen-Aufhellung.
- PDF/DXF-Export aus der Leiste raus, zusammen mit Speichern/Öffnen/
  Import in einem neuen Datei-Burger-Menü ganz rechts. Speichern/Öffnen
  sind echte JSON-Download/-Upload-Handler fürs ganze Projekt.
- Einstellungs-Fenster (neu): Accent-Palette, Auswahlrahmen-/Snap-Farbe
  editierbar (freier Picker + Presets), Projekt-Referenzhöhe (m ü. M.)
  als Feld.
- LayoutMenu auf die eigene Dropdown-Komponente umgestellt (war noch
  natives <select>).
- Custom Fenstersteuerung (_/□/X) für die randlose Electron-Shell via
  Preload+IPC, eigener Look statt OS-Chrome; Oberleiste als Drag-Region.
- Dark-Theme ist jetzt fester Standard (vorher an OS-Präferenz
  gekoppelt, zeigte je nach Umgebung fälschlich Light) — Light bleibt
  als Opt-in (`[data-theme="light"]`) für einen künftigen Umschalter.
  Alle Oberleisten-Pillen (Dropdown-Trigger, Segmente, Icon-Knöpfe,
  Ansichts-Icons) auf dieselbe dunkle Fläche wie das Kontextmenü
  vereinheitlicht. Panel-Köpfe nicht mehr separat aufgehellt, feste
  Höhe (40px) unabhängig von optionalem Darstellungsmodus-Dropdown.
- 2D-Zoom-Grenzen deutlich erweitert (20000x/0.005x statt 50x/0.2x).
2026-07-04 05:10:44 +02:00
karim 3d3bbdf64c Undo/Redo für Projekt-Änderungen
setProject wird jetzt von einer History gewrappt (undoStack/redoStack,
Limit 100). Echte Aenderungen (Referenzvergleich) landen auf dem
Undo-Stack, eine neue Aktion leert den Redo-Stack. Hochfrequente
Drag-Mutatoren (Griffe/Body-Move) bekommen einen coalesceKey, damit
ein Drag nicht hunderte Einzelschritte erzeugt. Tastatur-Bindung
(Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y) folgt im TopBar-Commit (App.tsx).
2026-07-04 05:01:15 +02:00
karim 576e6ba91b Native Selects auf einheitliche Dropdown-Komponente umgestellt
Mehrere Stellen (ResourceManager SelectField + Material-Browser,
ImportDialog, AttributesPanel, SitePanel, ToolsPanel,
DisplayModeSelect, RichTextEditor) nutzten noch native <select>, deren
Optionsliste vom Betriebssystem gerendert wird und optisch nicht zur
eigenen Dropdown-Komponente (dunkles Kontextmenü-Popover) passt. Jetzt
durchgaengig dieselbe Optik.
2026-07-04 05:01:03 +02:00
karim fd890a0d74 Nordstern: Geo-Kontext-Meshes (Terrain/Import) rendern
Bisher zeigte nur three.js importierte Gebaeude/DXF-Meshes und das
Terrain-TIN (Project.context). Neuer MeshInput-Typ + Kontext-Mesh-Pfad
in render3d (Flat-Normalen, doppelseitig gegen unbekanntes Winding),
projectToModel3d speist project.context jetzt in beide Renderer
(nativ + WASM) ein. Nebenbei: fehlendes layers-Feld in demo_walls()
behoben, das den native3d-Feature-Build zuvor schon brach.
2026-07-04 04:38:00 +02:00
karim 368a2ffcdb ResourceManager: Bauteile-Tab auf Master-Detail-Layout umgestellt
Wie bei Schraffuren/Linien/Wandstile: Liste links, Detail-Panel rechts.
Vorarbeit vor der eigentlichen Bauteil-Logik. Alte Tabellen-Bausteine
(ResTable/ResRow/ResCell/AddButton) entfernt, da nicht mehr genutzt.
2026-07-04 04:25:11 +02:00
karim 7b58745855 Nordstern: Iso-Praeset auf echte orthografische Projektion korrigiert
CameraPreset::Iso war faelschlich als Perspective definiert; three.js
zeigt bei Iso bereits korrekt orthografisch (parallele Projektion, wie
bei einer echten Isometrie definiert). Front/Top/Side/Iso sind jetzt
konsistent orthografisch, nur Perspective bleibt perspektivisch.
2026-07-04 04:24:02 +02:00
karim dcb6ed5d31 Ebene-Schraffur editierbar im Kategorie-Dialog
Nach-Ebene-Schraffur war in der Resolve-Logik bereits fertig, aber
LayerCategory.hatch liess sich im Kategorie-Editor nicht setzen.
Dropdown mit Vorschau ergaenzt, patcht ueber patchCategory.
2026-07-04 04:19:55 +02:00
karim 3b90c4c072 HANDOVER: 3 ungeklärte Punkte aus Session notieren (Isometrie, TopBar Detail, DOSSIER konkret) 2026-07-04 02:30:56 +02:00
karim c9cdb58275 HANDOVER: Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen, offener Backlog) 2026-07-04 02:23:33 +02:00
karim 339202b355 Snap an Wand-Schichttrennlinien
Die Grenzlinien zwischen den Wandschichten (achsparallel, aus der addWallPoche-
Offset-Logik gespiegelt: refOff-total/2, je Schicht +thickness, n+1 Linien inkl.
Aussenflaechen) werden als Snap-Segmente in collectSegments eingereiht. Damit
fangen die bestehenden Snap-Arten (Endpunkt/Mittelpunkt/Schnittpunkt/Lot)
automatisch daran — Decken/Elemente koennen an einzelnen Wandschichten fangen.
Kein neuer SnapKind/Toggle noetig (Default-aktiv). 4 neue Tests, 156 gruen.
2026-07-04 02:23:33 +02:00
karim d02781e8d2 Attribute: By-Layer/By-Object-Quelle fuer Farbe, Strichstaerke, Schraffur
Vordergrund, Hintergrund, Strichstaerke und Schraffur je Element (Wand/Decke/
Drawing2D) haben jetzt einen 3-Wege-Quellen-Dropdown: Nach Ebene / Nach
Bauteil / eigener Wert. Aufloesungsreihenfolge: expliziter Wert > (Quelle
'layer' => LayerCategory-Wert color/lw/hatch) > Bauteil/LineStyle-Default.
Neue *Source-Felder + strokeWeight/hatchId-Overrides am Modell (additiv,
optional), getLayerCategory-Accessor, resolveHatchId/resolveStrokeWeight;
resolveForeground/Background um category+source erweitert. Sample rendert
identisch (kein Override/Source gesetzt => Default 'object' = altes Verhalten).
Offen: LayerCategory-Schraffur noch nicht im Kategorie-Dialog editierbar.
2026-07-04 02:19:38 +02:00
karim 9695748fbc ResourceManager: Wandstile + Deckenstile im Master-Detail-Layout
Wand- und Deckenstile bekommen dasselbe Master-Detail wie Schraffuren/Linien:
Liste links mit Querschnitt-Thumbnail + Name, Detail rechts mit grossem
Querschnitt und Aufbau-Editor (Schichten: Bauteil/Dicke/Fugenlinie,
hinzufuegen/entfernen/umordnen), Inline-Rename, Trash-Delete, Neu anlegen.
Neuer WallTypeSwatch (hatchPreview) zeichnet die geschichtete Wand/Decke als
proportionale Baender mit den Bauteil-Schnittschraffuren. Add/Delete-Handler
(addWallType/deleteWallType/addCeilingType/deleteCeilingType, In-Use-Schutz)
in App.tsx + host ergaenzt.
2026-07-04 01:53:52 +02:00
karim 23d81bee7b Schnitt: Boolean-Dominanz der Schichten nach joinPriority
Wo sich Schicht-Baender verschiedener Elemente im Schnitt ueberlappen, gewinnt
die staerkere Schicht (hoehere joinPriority) und schneidet die schwaechere per
Rechteck-Subtraktion weg (elementuebergreifend: die Betondecke schneidet den
Wand-Putz). Gleiche Prioritaet koexistiert (durchgehende Betonflaeche Wand+
Decke). attachCutStyles sammelt jetzt alle Baender mit joinPriority und laesst
subtractDominantBands global gegen alle staerkeren Cutter subtrahieren; die
ueberlappungsfreie Zerlegung liefert exakt die Material-/Fugenlinien zwischen
den Schichten. Neuer subtractRect-Helfer, 11 neue Tests, 152 gruen.
2026-07-04 01:46:01 +02:00
karim 7569524d81 Schnitt: Wandschichten nach echter Aussen/Innen-Richtung orientieren
splitWallLayers legte die Schichtbaender bisher fix layers[0]->uMin, ohne die
tatsaechliche Wandorientierung — gegenueberliegende Waende sahen identisch aus
statt gespiegelt. Jetzt wird die Aufbaurichtung aus der Grundriss-Konvention
gespiegelt (Schichten stapeln entlang leftNormal der Wandachse), gegen die
Schnitt-u-Weltachse (aus der SectionPlane) projiziert; ist das Skalarprodukt
negativ, wird die Bandreihenfolge umgekehrt (layers[0]->uMax). Damit liegt die
Daemmung aussen, der Backstein innen, und gegenueberliegende Waende sind
spiegelbildlich. Neue Helfer sectionUAxisModel/wallLayersReversedInU, additiv
durch attachCutStyles durchgereicht. 6 neue Tests, 141 gruen.
2026-07-04 01:39:26 +02:00
karim f22970f321 swisstopo: Gebaeude als Volumen + Terrain-Mesh in die 3D-Ansicht
Gebaeude-Footprints (swisstopo vec25) werden jetzt zu Volumen extrudiert
(z=0 bis Hoehe; Hoehe aus OSM height/building:levels, sonst 9 m; Deckel/
Boden via Delaunay, konkave Grundrisse) und als importedMesh gerendert — der
flache Footprint-contourSet bleibt fuer den 2D-Plan erhalten. Neuer
Terrain-Abruf (swissALTI3D ueber den CORS-faehigen profile.json-Dienst,
zeilenweise parallel) liefert ein N-Raster, das terrainMeshFromGrid
trianguliert; Hoehen aufs Zentrum bezogen, lagerichtig zu den Gebaeuden.
Neue Quelle 'Swisstopo-Gelaende' im Import-Dialog. Viewport3D unveraendert
(bestehender importedMesh/terrainMesh-Pfad). 8 neue Tests.
2026-07-04 01:33:51 +02:00
karim ebed3f5311 ResourceManager: schwebendes, nicht-modales Fenster statt Modal
Der Ressourcen-Editor ist von einem blockierenden Modal (dunkler Backdrop,
Canvas gesperrt) zu einem frei schwebenden Fenster geworden: verschiebbar
ueber die Kopfzeile (Pointer-Capture-Drag), unten rechts resizable, KEIN
Backdrop — der Plan bleibt bedienbar, sodass Schraffur-/Linien-Aenderungen
live am Modell sichtbar sind. Der Ressourcen-Button bleibt Toggle, Esc
schliesst weiter. FloatingPanel-Bausteine wiederverwendet; die interne
Tab-/Master-Detail-Struktur unveraendert.
2026-07-04 01:30:13 +02:00
karim ae17900cb2 Statusleiste: Renderer-Umschalter 'Engine' -> 'Nordstern' 2026-07-04 01:28:26 +02:00
karim 25cebbd98c Linien: modulares Segment-System (Strich/Punkt/Luecke) + Loop-Vorschau
Der Linien-Editor ist modular: eine Linie ist eine geordnete, loopende Folge
aus Segmenten Strich (Laenge), Punkt (Dot) und Luecke (Laenge) — beliebige
Sequenzen (Volllinie/Strichlinie/Punktlinie/Strich-Punkt als Presets, plus
frei), die Schluss-Luecke ist die letzte Luecke. Datenbasis bleibt
LineStyle.dash (mm, alternierend); ein Punkt ist ein 0-Laengen-AN-Segment.
Enthaelt dash eine 0, wird die Linie mit runder Kappe gezeichnet, damit
Punkte als Dots erscheinen (Live + Print; GL/DXF Folgearbeit). Neue reine
Segment-Logik in ui/lineSegments.ts. LineSwatch zeigt den ersten Loop dunkel
und 2 weitere grau (Loop-Kontext). 14 neue Tests, 127 gruen.
2026-07-04 01:21:04 +02:00
karim 3d1fa40402 Random-Schraffur modellraum-verankert + Motiv-Editor fuer Custom-Linien
Random-Verankerung (Bugfix): die Streu-Striche haengen nicht mehr an der
Polygon-Bounding-Box, sondern an einem absoluten Modellraum-Gitter
(hashCell aus absoluten Zell-Indizes + hatch.seed). Beim Vergroessern der
Flaeche bleiben bestehende Striche stehen, am Rand kommen neue dazu, die
Dichte bleibt konstant, Verschieben wandert nicht; 'Neu wuerfeln' (neuer
Seed) verschiebt das ganze Feld. Alle Renderpfade ziehen aus derselben
Funktion. Verankerungs- und Verschiebe-Dichte-Test ergaenzt.

Motiv-Editor: neuer wiederverwendbarer MotifEditor (Punkte setzen/ziehen in
einer Einheitszelle, Live-Loop-Vorschau). LineStyle.kind 'custom' + motif
(points/length), additiv durchgereicht (analog zigzag) und in allen
Renderpfaden gekachelt (motifPoints, Verallgemeinerung von zigzagPoints).
ResourceManager-Umschalter Vollinie/Strich/Zickzack/Motiv, LineSwatch-
Vorschau. Insgesamt 5 neue Tests, 113 gruen.
2026-07-04 01:09:00 +02:00
karim b4dd396768 Linien-/Random-Editor: Strich-Segmente, Vollinie, Random-Kontrollen; Weight raus
Linien-Editor: Umschalter Vollinie (dash null) / Strich mit editierbarer
Segment-Liste (mm, alternierend Strich/Luecke, hinzufuegen/entfernen), Live-
Vorschau. Das Strichstaerken-Feld ist aus dem Linien-Editor entfernt —
LineStyle.weight ist @deprecated (bleibt Render-Fallback), die echte
per-Element-Aufloesung (Attribut->Ebene->Default) folgt separat.

Random-Schraffur: additive HatchStyle-Parameter seed/density/lengthMin/Max +
Neu-wuerfeln-Button (neuer Seed nur zur Editzeit). buildRandomHatchRuns mischt
den expliziten Seed in den Hash und nutzt Dichte/Laenge — Determinismus
gewahrt (gleicher Seed+Flaeche => gleiche Streuung), ueber alle Renderpfade
konsistent, da alle aus derselben Funktion ziehen. resolveHatch/HatchRender
reichen die Parameter rein additiv durch. 2 neue Tests.
2026-07-04 00:53:19 +02:00
karim f0e777efc5 ResourceManager: Master-Detail-Editoren fuer Schraffur- und Linien-Typen
Schraffuren- und Linien-Tab im Master-Detail-Layout (DOSSIER-Vorbild): Liste
links mit Live-SVG-Thumbnail + Name, Detail rechts mit grosser Live-Vorschau,
Inline-Rename, Trash-Delete, Typ-Umschalter. Schraffur: Vektor (parallel/
zufaellig) oder Bild (Upload -> Data-URL, scaleX/scaleY/rotation); kein
Farbfeld mehr (Farbe kommt von Bauteil/Attribut). Linie: Strich oder Zickzack
(Amplitude/Wellenlaenge). Bauteil-Tabelle: Vordergrund + Hintergrund statt
einer Farbe. Neuer eigenstaendiger Vorschau-Helfer ui/hatchPreview.tsx (kein
Renderer-Import).
2026-07-04 00:41:09 +02:00
karim 0613bb596b Schraffur/Linien: Rendering der neuen Typen (Bild, Random, Zickzack)
Bild-Schraffur als getiltes <pattern>/<image> (scaleX/scaleY/rotation) im
Live-SVG- und Print-Pfad; GL/WASM/DXF vorerst neutraler Fallback (Folgearbeit,
im Code vermerkt). Random-Vektor-Schraffur als deterministische Streu-Striche
(mulberry32-Seed aus Flaechen-Bounding-Box, kein Math.random) in allen Pfaden.
Zickzack-Linie als getilteter Pfad; LineStyle.kind/zigzag additiv durch die
Linien-Emission (generatePlan) bis zu den Renderern durchgereicht. Geteilte
Geometrie in glPlanHatch (scatterStrokes/buildRandomHatchRuns/zigzagPoints) —
eine Wahrheit fuer Live/Print/GL/DXF. 8 neue Tests.
2026-07-04 00:41:09 +02:00
karim f5923d302d Attribut-Panel: Vordergrund/Hintergrund mit 'Nach System'
Die Attribut-Sektion 'Fuellung' zeigt statt der einen Fuellfarbe jetzt zwei
Felder: Vordergrund (Muster-/Schraffurfarbe) und Hintergrund (Fuellung),
jeweils per Element ueberschreibbar. Leerer Override = 'Nach System' (erbt);
Farbe waehlen setzt den Override, x loescht ihn wieder. Schreibt
Wall/Ceiling/Drawing2D.foreground/background ueber neue Store-Mutationen
(setElementForeground/Background), gespiegelt zum bestehenden Fuellfarbe-Pfad.
Die Resolve-Schicht (generatePlan) liest diese Overrides bereits, wirkt also
sofort im Plan.
2026-07-04 00:26:32 +02:00
karim 87ebd91ba2 Schraffur/Linien: Typmodell-Fundament (additiv, backward-kompatibel)
Vorbereitung fuer Vektor-/Bild-Schraffuren, Strich-/Zickzack-Linien und
Vordergrund/Hintergrund-Farben ohne Verhaltensaenderung. Alle neuen Felder
optional, Sample rendert identisch:
- HatchStyle: kind (vector/image), lines (parallel/random), image
  (src/scaleX/scaleY/rotation); color deprecated, bleibt Resolve-Fallback.
- LineStyle: kind (dash/zigzag), zigzag (amplitude/wavelength).
- Component: foreground (Muster) + background (Fuellung); color bleibt
  Fallback und 3D-Albedo.
- Wall/Ceiling/Drawing2D: foreground/background-Overrides (undefined = Nach
  System).
- generatePlan: HatchRender um kind/lines/image erweitert; resolveForeground/
  resolveBackground mit dokumentierter Reihenfolge (Override -> Component ->
  Alt-Farbe); toSection reicht Vordergrund je Schicht durch.
2026-07-04 00:17:28 +02:00
karim bf3e8bf9fc Kantenverschieben faengt an aktiven Geometrie-Snaps
Beim Verschieben einer Kante ueber den Dreiecks-Griff wird der Cursor jetzt
durch die Snap-Engine (snapFor) gefuehrt: bei einem Geometrie-Snap
(Endpunkt/Mittelpunkt/Schnittpunkt) wird der gefangene Punkt statt des rohen
Cursors auf die Kanten-Normale projiziert — die Kante rastet an reale
Geometrie statt nur ans Raster. Ohne Geometrie-Snap bleibt die bisherige
Raster-Rundung. Der freie Delta-Fall bleibt unveraendert.
2026-07-04 00:17:28 +02:00
karim 7b65f3bce6 Verschiebe-Dreieck flacher (Apex ~124 statt ~60 Grad) 2026-07-04 00:07:41 +02:00
karim a33f124931 Tool-Shortcuts 1..0 (Vectorworks-Stil)
Nummerntasten waehlen Zeichenwerkzeuge: 2 Linie, 4 Rechteck, 5 Polylinie,
6 Wand, 7 Decke, 8 Fenster, 9 Tuere, 0 Raum. Nur auf Geschoss-Tabs, nicht
beim Tippen in Feldern und nicht mit Ctrl/Cmd/Alt. 1 (Text) und 3 (Kreis)
bleiben vorerst frei — dafuer fehlt noch ein Werkzeug.
2026-07-04 00:07:41 +02:00
karim 989e61c261 Zoom-Prozent relativ zum angewandten Massstab statt zur Einpassung
Die Zoom-Anzeige band 100% bisher an die eingepasste Default-Ansicht, nicht
an den angewandten Papier-Massstab — der Wert driftete willkürlich. Er wird
jetzt in App.tsx aus angewandtem Nenner und Live-Nenner abgeleitet
(scaleDenominator / liveScale * 100): im angewandten Massstab exakt 100%,
hineinzoomen >100%, herauszoomen <100%. Die fit-basierte onZoom-Meldekette
aus PlanView entfällt (Single Source).
2026-07-04 00:02:50 +02:00
karim 5d99701024 Gehrung (Rust): Flächenpaarung orientierungsbasiert (Parität zu joins.ts)
Der Rust-Port der Wandecken-Gehrung nutzte weiter die Distanz-Heuristik und
hatte damit denselben Spitzwinkel-Bug wie der TS-Pfad vor a2c8fcd. Die
Paarung erfolgt jetzt vorzeichenbasiert ueber die Achs-Auslaufrichtungen
(dot(n, d)) — Aussen mit Aussen, Innen mit Innen. Rechte/stumpfe Ecken
bleiben bit-identisch; native und WASM/TS-Renderpfad stimmen bei spitzen
Ecken wieder ueberein. Neuer Spitzwinkel-Test.
2026-07-03 23:57:08 +02:00
karim 940c38eb33 TopBar: Text-Gruppe zwischen Ebenen-Kombis und Detailgrad/Massstab 2026-07-03 23:57:08 +02:00
karim a2c8fcd6de Grundriss-Gehrung: Flächenpaarung orientierungsbasiert statt per Distanz
An spitzen Wandecken wählte die Distanz-Heuristik in miterLine die falsche
B-Fläche und lieferte eine um 90° verdrehte Gehrungslinie — die Poché-Bänder
überlappten kreuzweise. Die Paarung erfolgt jetzt orientierungsbasiert: über
die Auslaufrichtungen der Achsen (dot(n, d)) wird Aussenfläche mit Aussen-,
Innen mit Innenfläche verschnitten, nie über Kreuz — für jeden Winkel. Rechte
und stumpfe Ecken bleiben bit-identisch (Rust-Parität gewahrt). Kein
Miter-Limit; die Gehrungslinie ist geometrisch exakt. Neuer Test joins.test.ts
mit rechtwinkligem und spitzwinkligem Fall (fängt den alten Bug).
2026-07-03 23:53:25 +02:00
karim 0c82d01cd9 Schnitt: Wandquerschnitte an den gemitterten Fussabdruck koppeln
Der Schnitt-Extraktor schnitt die Wand-Cut-Polygone bisher gegen
achsparallele Einzelwand-Rechtecke ohne Eckverschneidung — an einer
Wandecke ueberlappten dadurch zwei Querschnitte. mesh.rs exponiert jetzt
den gemitterten Fussabdruck (wall_mitered_footprints, dieselbe Gehrung wie
das 3D-Mesh) als geteilte Wahrheit; section.rs bildet die Cut-Polygone
gegen diesen Fussabdruck. Die achsparallele Naeherung fuer Verdeckung/
Projektionskanten bleibt bewusst unveraendert.
2026-07-03 23:52:33 +02:00
karim 56bbcec2a8 TopBar/Footer: Referenzlinien in die Statusleiste, Ansichten bleiben sichtbar
Der Referenzlinien-Schalter wandert aus der TopBar in die Statusleiste
(neben Fang) — dort passt er besser zu den übrigen Anzeige-/Fang-Kontrollen.
Die doppelt gezeigten Werte Massstab und Zoom fallen aus der Statusleiste
weg; sie stehen weiterhin im TopBar-Cluster. Die Ansichts-Icons blenden sich
auf Nicht-Geschoss-Ebenen (Schnitt/Ansicht/2D) nicht mehr komplett aus,
sondern bleiben sichtbar und werden nur deaktiviert.
2026-07-03 23:52:26 +02:00
karim b6c1a9a3c6 Dockbare Panel-Tabs nur als Symbol
Die Tab-Leiste zeigt je Panel nur noch ein Inline-SVG-Icon; der Titel
wandert in Tooltip (title) und aria-label. PanelDef bekommt ein optionales
icon-Feld, die sieben eingebauten Panels je ein schlichtes stroke-Icon
(currentColor, erbt die Tab-Farbe). Fehlt ein Icon (Plugin), fällt der Tab
auf den Titeltext zurück. Tabs sind jetzt kompakt und quadratisch; Klick-,
Drag- und Dock-Logik unverändert.
2026-07-03 23:42:39 +02:00
karim b83fd81399 Mehrschichtige Wände im Schnitt als Schicht-Bänder
Analog zur Decke (splitSlabLayers) wird ein mehrschichtiger Wandtyp im
Schnitt nicht mehr mit einer repräsentativen Schraffur über die ganze
Dicke gefüllt, sondern in Schicht-Bänder zerlegt — quer zur Dicke entlang
u (volle Höhe je Band), jedes Band mit der Schnittschraffur seines
Bauteils. Einschicht-Wände laufen unverändert über resolveWallSectionStyle.
Seitenorientierung (aussen/innen) vorerst deterministisch layers[0]→uMin;
korrekte Orientierung via Wandnormale bleibt Folge-Iteration.
2026-07-03 23:36:06 +02:00
karim 633480252e Zeichnungsebenen-Panel: vier einklappbare Kategorien
Die flache Ebenenliste ist in vier einklappbare Kategorien gegliedert —
Geschosse/Schnitte/Ansichten/2D-Zeichnungen nach drawingLevel.kind. Jeder
Kopf trägt ein Chevron und rechtsbündig ein blankes +-Glyph (kein
Button-Chrome), das je Kategorie eine neue Ebene der passenden Art anlegt;
leere Kategorien bleiben mit Kopf und + sichtbar. Für Schnitt und Ansicht
sind dafür neue Platzhalter-Aktionen addSection/addElevation ergänzt
(analog addFloor/addDrawing, noch ohne Schnittlinie).
2026-07-03 23:31:39 +02:00
karim 5b70742efa Bauteil: Ansichtsschraffur neben Schnittschraffur
Bauteil trägt jetzt zwei Schraffuren: die bestehende hatchId als
Schnittschraffur (nur wo tatsächlich aufgeschnitten) und ein neues
optionales Feld viewHatchId als Ansichtsschraffur für die frontale,
ungeschnittene Sicht. Die Deckenpoché im Grundriss — die Decke liegt
über der horizontalen Schnittebene, wird also frontal gesehen — nutzt
nun die Ansichtsschraffur (Default weiss/leer) statt pauschal keiner.
Schnitt-Pfade und die echt geschnittene Wand-Grundriss-Poché bleiben
auf der Schnittschraffur. Editierbar im Ressourcen-Manager.
2026-07-03 23:30:48 +02:00
karim b0db956013 Deckenpoché im Grundriss ohne Schraffur
Die Decke wird in der Aufsicht frontal gesehen, nicht aufgeschnitten
— Bauteil-Schraffuren gehoeren nur auf Schnittflaechen. Die neue
3-schichtige Betondecke zeigte sonst ihre dichte Schraffur ueber dem
ganzen Raum im Grundriss.
2026-07-03 23:11:42 +02:00
karim d788cbb32b Deckenstile: solide oder mehrschichtige Decke
Analog zu den Wandstilen erhalten Decken jetzt einen eigenen
Deckentyp mit Schichtaufbau (CeilingType, ceilingTypeId). Der
Schichtaufbau erscheint nur im Schnitt als gestapelte Bänder von OK
bis UK; der Grundriss bleibt eine flächige Poché wie bisher, da die
Schichtung von oben ohnehin nicht sichtbar ist. Auswahl solide/
mehrschichtig im Objektinfo-Panel wie bei Wänden, neuer
"Deckenstile"-Tab im Ressourcen-Manager mit Fugenlinien je Schicht.
2026-07-03 22:09:02 +02:00
karim b1e7fdf8fc Zeichnungsebenen merken sich eigenen 2D/3D-Zustand
Bisher war viewType (Grundriss/Perspektive) ein einziger globaler
Wert. Beim Tab-Wechsel blieb er unverändert stehen, was wirkte, als
würde die Ansicht "durchsickern". Jede Ebene merkt sich jetzt ihren
zuletzt gewählten Ansichtstyp und stellt ihn beim Zurückwechseln
wieder her.
2026-07-03 21:51:51 +02:00
karim 3aee2e05ee render3d: Wandverschneidung an Ecken + sichtbare Schichten
mesh.rs berechnet die Ecken-Verschneidung jetzt selbst (portiert aus dem
Web-Kern computeJoins/clippedBand): Wandenden werden per Rundungs-Key
gruppiert, ueber Hoehen-Ueberlappung geclustert (gestapelte Geschosse mit
gleichem Grundriss verschneiden sich NICHT), und Zweier-Cluster ueber die
gemeinsame Gehrungslinie geschnitten. T-/X-Stoesse und freie Enden bleiben
stumpf, wie im Web-Kern. Endkappen-Normalen geometrisch aus dem Kreuzprodukt.

WallInput bekommt optional layers (WallLayer{thickness,color}, serde-default,
rueckwaertskompatibel): jede Schicht wird als eigenes Band ueber dieselbe
gehrte Grundflaeche extrudiert und materialgefaerbt. toWalls3d.ts loest die
WallType-Schichten je Wand auf (hexToRgb) und reicht sie durch.

39/39 Rust-Tests (7 neue: Gehrung, T-Stoss, freies Ende, Schichten, Kombi).
2026-07-03 21:45:01 +02:00
karim 88cc9df0b6 Kopfzeile der Zeichenflaeche: Dokument-Tabs statt Geschoss-Eigenschaften
Die alte Eigenschaften-Leiste (Geschosshoehe/Schnitthoehe/OKFF) weicht einer
Tab-Leiste 'Zeichnungen': ein Tab je Geschoss-Grundriss, Klick wechselt die
aktive Zeichnungsebene. Bindet an activeLevelId/setActiveLevelId — dieselbe
Store-Quelle wie das Panel 'Zeichnungsebenen', beide bleiben synchron.
Geschosshoehe/Schnitthoehe werden in den Geschoss-Einstellungen gepflegt.

Der Drawing-Typ (kind: 'plan') und der switch beim Aktivieren lassen spaeter
Schnitt-/Layout-Tabs zu, ohne die Leiste umzubauen.
2026-07-03 21:39:56 +02:00
karim e173978135 Raumstempel: Ausrichtung pro Zeile + Anker als Snappunkt
Jede Stempelzeile (Name, Name-2, Bodenflaeche, Nutzung) hat jetzt eine
eigene Ausrichtung links/mitte/rechts, im Stempel-Editor pro Zeile
waehlbar und in SVG- wie nativer Darstellung honoriert (text-anchor bzw.
align). Bodenflaeche/Nutzung fallen auf mitte zurueck (bisheriges Bild).

Der Stempel-Anker (stampAnchor bzw. Schwerpunkt) meldet sich zusaetzlich
als Endpunkt-Snapkandidat an — gefiltert nach Geschoss und sichtbarer
Kategorie; der ziehbare Griff war bereits verdrahtet.
2026-07-03 21:29:13 +02:00
karim 6b6c18b43d render2d: Rundungen an Strichen — Round-Caps und Round-Joins wie glPlan
stroke_polyline tesselliert Segmente jetzt einzeln (Butt-Enden) und
setzt an Innenknoten Fächer-Bögen auf der Aussenseite sowie an offenen
Enden Halbkreis-Kappen. Fächerdichte 18°/Schritt, identisch zur
WebGL2-Darstellung. Geschlossene Ringe: Bögen an allen Knoten, keine
Kappen; offene Polylinien inkl. Strich-Segmente: Kappen an den Enden.
2026-07-03 21:24:28 +02:00
karim 17523d422c Engine-3D-Presets ueber die TopBar + orthografisch (Top/Front/Seite), Iso korrekt
Die Kamera-Presets liefen zuvor als Viewport-Overlay mit eigenen JS-yaw/pitch-
Winkeln, rein perspektivisch (Iso falsch, keine Parallelprojektion). Jetzt
haengt der Engine-Viewport am app-weiten view3d-State (TopBar-CameraMenu, wie
three.js) und nutzt die vorhandene Rust-preset_camera:

- web.rs: set_view_preset(preset) + model_bounds/fit; Front/Top/Side echt
  orthografisch, Iso/Perspektive perspektivisch - wie das native Fenster.
  default_fov() pub(crate), damit Fit denselben FOV nutzt.
- useWasm3dRenderer: applyViewPreset; render(null) ueberschreibt die
  Preset-Kamera nicht.
- Wasm3DViewport: konsumiert view3d/renderMode als Props (Overlay + lokaler
  Umschalter entfernt, wasm3dOverlay.css geloescht); Orbit bleibt frei
  (Preset = Sprung, kein Lock), three.js-Verhalten gespiegelt.
- Viewport3D-Switcher reicht view3d/renderMode an den Engine-Viewport durch
  (kein three.js-Szenencode angefasst).
2026-07-03 21:13:53 +02:00
karim 7bcf69c16c Raeume: Umriss auf die Wand-Innenflaeche + neutral statt farbig
- Sample-Raum R1: boundary auf die echte Innenkante der Aussenwaende
  (Achse +/- halbe Wanddicke 0.1725 bei Wandtyp aw 0.345 m) statt 0.1-Inset -
  der Umriss lag bisher ~7 cm im Wandkoerper.
- Raumfarbe #5a7a9a -> neutrales Grau #6b7280 (Umriss + Stempel).
- ROOM_FILL_ALPHA 33 -> 00: keine transluzente Farbfuellung mehr
  (Plandarstellung schwarz/grau); das Polygon bleibt fuer die Auswahl
  erhalten (Hit-Test laeuft ueber pointInPolygon, nicht ueber die Fuellung).

Offen: automatische Ableitung der Raum-Boundary aus den Wand-Innenflaechen
fuer spaeter gezeichnete Raeume.
2026-07-03 21:07:11 +02:00
karim b6d14ca2b2 Engine-3D: Kamera-Presets + Weiss-Modus im render3d-Viewport
Der Engine-3D-Viewport (render3d/wgpu) war reines Ansehen (Orbit/Zoom). Jetzt:

- Kamera-Presets als Overlay: Top/Unten/Vorne/Hinten/Links/Rechts/Iso.
  Richtungen exakt wie der three.js-Pfad (applyView3d); framen die aktuelle
  Modell-Bounding-Box (fitTargetDist) und springen auf den Preset-Winkel,
  danach Orbit/Pan/Zoom weiter frei. Rein JS.
- Render-Modus Schattiert/Weiss: mode-Uniform in Globals (WGSL/Rust),
  Weiss = beleuchtetes Clay-Grau; wasm-Binding set_render_mode_white,
  durchgereicht via useWasm3dRenderer.
- Overlay-CSS self-contained (wasm3dOverlay.css), Theme-aware.

three.js unangetastet. Auswahl/Grips/Zeichnen + Wireframe/HLR bewusst noch
offen (naechste Scheibe).
2026-07-03 20:56:56 +02:00
karim bd80eb1c59 Wandstile: Schichtfuge pro Fuge als LineStyle waehlbar + ResourceManager-Abteilung
Mehrschichtige Waende zeichnen die Trennlinie an jeder Materialfuge jetzt mit
einem pro Fuge waehlbaren LineStyle statt einer festen Haarlinie.

- Layer.jointLineStyleId (optional): LineStyle der Fuge an der Innenkante
  dieser Schicht; fehlt er, gilt die Default-Haarlinie (0.02) in Wandfarbe.
- generatePlan: Schicht-Baender tragen nur noch Fuellung + Schraffur (kein
  Band-Umriss); jede innere Fuge wird als eigene Linie mit ihrem LineStyle
  (Gewicht/Farbe/Dash, x Detailfaktor) gezeichnet. Wand-Umriss (0.35) und
  grob-Pfad unveraendert.
- ResourceManager: neue Abteilung "Wandstile" - je Wandtyp die Schichten
  mit Fugen-LineStyle-Dropdown.
- Sample: LineStyle "Schichtfuge 0.13"; der Daemmung-Backstein-Fuge (massiv/
  massiv) zugewiesen, Putzfugen bleiben Haarlinie.
- 2 Joint-Tests ergaenzt.
2026-07-03 20:55:07 +02:00
karim 3a0652a337 glPlan: bildschirm-adaptive Bogen-Tessellierung (LOD)
Boegen (Tuer-Schwenkbogen) wurden mit fester Segmentzahl (16/90 Grad) beim
Kompilieren tesselliert und beim Zoom nur per Matrix neu gezeichnet - beim
Reinzoomen daher sichtbare Facetten. Jetzt skaliert die Segmentzahl mit der
Bildschirmgroesse des Bogens: Sagitta < 0.5 px (segs = clamp(ceil(|dPhi|/theta),
8, 2048)), theta = 2*acos(1 - 0.5/rPx). Weit weg wenige Segmente (Perf),
nah rund.

Anti-Thrash per LOD-Bucket round(log2(pxPerMeter)): Neu-Tessellierung nur
bei Bucket-Wechsel, sonst bleibt Zoom/Pan reiner Matrix-Update. Bogen laeuft
weiter durch strokePolyline - runde Caps/Joins bleiben erhalten.
2026-07-03 20:29:12 +02:00
karim 4211633e3e Plandarstellung schwarz: Tür/Fenster-Symbole als Haarlinie, Decke weiss hinterlegt
Plandarstellung ist schwarz/grau; Farbe bleibt spaeteren Schemaelementen
vorbehalten.

- Tuerblatt, Schwenkbogen, Fensterscheibe und Anschlagstriche: einheitlich
  #1a1a1a statt blau (#2b3039/#4a86c7/#6b7280), im render2d/PDF-Pfad
  (CLS_STROKE) und im SVG-CSS.
- Schwenkbogen als Volllinie (Strichelung entfernt).
- Tuer-/Fenstersymbol-Linien auf Haarlinie 0.02 mm (SYMBOL_HAIRLINE_MM).
- Decken-Poche: Schraffur auf weissem Grund wie mehrschichtige Waende
  (pocheFill), solid schwarz - nicht mehr die Bauteil-Fuellfarbe.
2026-07-03 20:28:03 +02:00
karim ac55233c1a PDF/Print: Haarlinien unter der Stift-Leiter exakt drucken
quantizePen hob bisher jede Breite <= 0.13 mm auf die unterste Stift-Stufe
(0.13 mm) an. Dadurch druckte die 0.02-mm-Schraffur/Schichtfuge im PDF und
im SVG-Print-Modus als 0.13 mm - im Viewport duenn, im Export dick.
Breiten unter MIN_PEN_MM gelten jetzt als bewusste Haarlinien und werden
exakt (unquantelt) durchgereicht; ab 0.13 mm rundet die Leiter wie gehabt.
2026-07-03 20:15:10 +02:00
karim 5496f8c007 Fang-Optionen und Display/Print-Umschalter in die Footerbar
Der Linien-Modus (Display/Print) wandert aus der Oberleiste und die
Fang-Optionen aus der Werkzeug-Palette in die Statusleiste unten:

- StatusBar: segmentierter Display/Print-Umschalter (Stil wie der
  Renderer-Umschalter) plus Fang-Pill mit nach oben öffnendem Popover
  (Master-Schalter + Endpunkt/Mittelpunkt/Schnittpunkt/Kante/Raster/
  Ortho + Rastermass).
- TopBar: Linien-Modus-Gruppe entfernt.
- ToolsPanel: Fang-Abschnitt entfernt.
- App reicht lineMode/snap an die StatusBar durch.
2026-07-03 20:08:03 +02:00
karim 694e666160 Schraffur + Schichtfugen als 0.02-mm-Haarlinien im einheitlichen Neutralschwarz
Schraffurlinien verlassen den screenPx-Sonderpfad und laufen wie jede
andere Linie durch die Papier-mm-Pipeline (glPlan, SVG, PDF): im Display
konstante Haarlinie (paperScaleForGl-Kehrwert von meet), im Print echte
0.02 mm - deckungsgleich mit den Schichtfugen in beiden Modi.

- POCHE_STROKE #2b3039 -> #1a1a1a: kein Blaustich mehr, gleicher
  Neutralton wie die Schraffur (Wände/Öffnungen/Decken/Treppen).
- Schichtfuge LAYER_LINE_MM 0.13 -> 0.02 (Haarlinie).
- Schraffur-Stift hatch-hair/hatch-dash 0.05 -> 0.02.
- Wand-Umriss Kategorie 20 lw 0.5 -> 0.35 (Standard-Aussenlinie war zu dick).
- SVG-Schraffur nutzt HAIRLINE_PX/printStrokeVb statt hatchStrokePx;
  PDF-Export echte mm statt widthScreen.
2026-07-03 20:02:18 +02:00
karim 47b9fa5aa7 3D-View: Engine (render3d/wgpu) als Standard-Renderer, three.js nur Fallback
WASM_ENGINE_ACTIVE nimmt die Engine, sofern nicht explizit WebGL2 gewählt ist
(?engine=webgl2 bzw. localStorage cad.rendererMode==="webgl2") oder WebGPU
fehlt. three.js bleibt der stille Fallback. Der Engine-3D-Viewport ist vorerst
nur Ansehen (Orbit/Zoom); Auswahl/Griffe/Zeichnen folgen.
2026-07-03 19:47:03 +02:00
karim f2d36632cb SIA-Wandschraffuren: Beton-Kreuz, Backstein/Dämmung wandrelativ, hairline
- HatchStyle.relativeToWall: der Wandwinkel (aus wall.start/end) wird in
  addWallPoche und resolveWallSectionStyle auf hatch.angle addiert (Vorzeichen
  zur SVG-CW-Konvention passend), sodass die Schraffur der Wandachse folgt.
- sampleProject: sia-concrete (crosshatch 45° absolut), sia-brick (diagonal
  wandrelativ), sia-insulation (diagonal 90° wandrelativ, solide Haarlinie);
  Komponenten Beton/Backstein/Dämmung darauf umgehängt. Dichte Muster (scale
  0.5/0.5/0.45), hairline (Strichgewicht 0.05 → 0.6px-Boden).
- Poché-Hinterlegung: mehrschichtige Wände (und einschichtige) weiss, solid
  schwarz (Fill + Schraffurfarbe), konsistent in SVG- und glPlan-Fill; Mono
  behält Schwarz für solid. Gilt auch für den Schnitt-Cut.
- ResourceManager: Wandbezug-Schalter im Hatch-Manager. exportDxf: Dash-
  Splitting für gestrichelte Schraffuren.
2026-07-03 19:47:03 +02:00
karim bd0e7195bb glPlan: runde Linien-Caps/Joins + Schraffurbreite bildschirm-konstant
- Runde Enden und Ecken statt Butt-Caps/Miter: jedes Segment als eigenes
  Quad, an Innenecken ein Dreiecks-Fächer über den Aussenwinkel (addRoundJoin),
  an offenen Enden ein Halbkreis-Fächer (addRoundCap). Gilt für Wandumrisse,
  Linien, Bögen und Schraffurläufe — Look wie das Vektor-PDF. Keine Shader-
  Änderung nötig (Radius weiterhin zur Render-Zeit aus strokePx*strokeScale).
- Schraffur-Dicke-Bug behoben: glPlan schickte die Schraffur-mm durch die
  Papier-mm-Pipeline (strokeMm*mmToDevicePx); im Display-Modus ist der darin
  steckende paperScaleN ein zoom-abgeleiteter, nicht auf 0.13/mm kalibrierter
  Wert → Breite explodierte. Jetzt hatchStrokeVb = max(0.6, hatchMm/0.13) als
  screenPx-Batch (skaliert nur mit meet/Zoom) — byte-genau wie SVG-<pattern>
  und der PDF-Export (widthScreen). Wandumrisse unverändert (echte mm).
2026-07-03 19:47:03 +02:00
karim 96628c5aaa render3d: 4× MSAA im wgpu-Renderer — glatte 3D-Kanten
Multisampled Farbtarget (SAMPLE_COUNT=4) + resolve_target in die Surface-View je
Frame; Depth-Textur-sample_count synchron auf 4; MSAA-/Depth-Textur werden bei
Resize gemeinsam neu erzeugt; multisample.count=4 auf der Render-Pipeline. Nur
gpu.rs — mesh/section/openings unberührt.

Gates: cargo check (window) + wasm32 --features web grün, cargo test 32/32,
--features render 33/33.
2026-07-03 19:23:21 +02:00
karim fc0cab9ec7 Vektor-PDF: Schraffurbreite skalenunabhängig (Audit-Fund #4)
appendStroke rechnete widthScreen-Schraffurbreiten über mmPerM=1000/
scaleDenominator um → die gedruckte mm-Breite hing vom Export-Massstab ab
(0.25mm bei 1:50 vs 0.13mm bei 1:200 für dieselbe Quellbreite). Jetzt
widthMm * HATCH_DENSITY_MM (0.13) — der exakte Kehrwert von toRenderScene
hatchPx = max(0.6, hatchMm/0.13) → identische Papier-mm bei jedem Massstab,
deckungsgleich zur Bildschirmvorschau. Ungenutzten mmPerM-Parameter entfernt.
2026-07-03 19:06:28 +02:00
karim b353ed34ae PDF-Export: echten Massstab an planToRenderScene durchreichen (Audit-Fund #3, Abschluss)
buildPlanPdf ruft planToRenderScene(plan, opts.scaleDenominator) statt des
Default 1:100 → der Stempel-Zeilenabstand mehrzeiliger Texte ist jetzt auch im
PDF bei Nicht-1:100-Massstab papier-mm-genau (identischer Nenner wie an
sceneToPrintSvg). Schliesst den in der vorigen Änderung offen gelassenen
Aufrufer-Punkt.
2026-07-03 18:56:08 +02:00
karim b6e902a809 Stempeltext: Zeilenabstand skalenfest statt fix 1:100 (Audit-Fund #3)
planToRenderScene(plan, paperScaleN=100) leitet den Zeilenabstand mehrzeiliger
Stempeltexte jetzt aus dem tatsächlichen Massstab-Nenner ab (mmToM = paperScaleN
/1000) statt aus der bisher fest verdrahteten 1:100-Konstante. Deckt sich mit dem
nRef der SVG-Text-Formel in PlanView. Viewport/WASM-Aufrufer behalten den Default
100 (deren Referenzmassstab). Der PDF-Export reicht den echten Massstab im
Folgeschritt durch.
2026-07-03 18:54:39 +02:00
karim 7d2c9803d6 Print-Vorschau: Strichbreiten auf die PEN_STEPS-Leiter quantisieren (Audit-Fund #2)
Die SVG-Print-Vorschau reichte rohe mm-Breiten in printStrokeVb, während der
PDF-Export jede Breite via quantizePen() auf die Stiftleiter (0.13/0.18/0.25/…)
rundete → Vorschau ≠ Druck. quantizePen ist jetzt aus sceneToPrintSvg.ts
exportiert und wird in den zwei Print-Zweigen von PlanView (DrawingRunShape,
renderPrimitive-weight) verwendet. Nur EINE Leiter, keine Kopie. Haarlinien-Pfad
und Dash-Arrays unangetastet (das PDF quantisiert Dashes ebenfalls nicht).
2026-07-03 18:54:39 +02:00
karim 7cf867a1a0 Schnitt: Cut-Polygone nutzen die echte Bauteil-Schraffur statt Albedo
Die geschnittenen Flächen erhielten bisher die rohe Albedo-Farbe der Engine plus
eine generische Diagonalschraffur. Jetzt löst attachCutStyles() jede Cut-Fläche
über den index-parallelen ComponentRef auf ihr Quell-Bauteil auf:
- toSection.ts: wallSegmentOwners()/slabSegmentOwners() bilden die exakte
  Segment-Zerlegung aus projectToModel3d nach (gleiche EPS-/Öffnungs-Logik) und
  liefern je emittiertem Segment das Quell-Wall/Ceiling — index-gleich zur
  WASM-Elementreihenfolge (per Test bewiesen: W1,W1,W1,W2).
- generatePlan.ts: resolveWallSectionStyle/resolveCeilingSectionStyle wählen die
  Komponente mit höchster joinPriority (wie die Grundriss-Poché) bzw. die erste
  Deckenschicht und liefern fill+HatchRender über resolveHatch. generateSectionPlan
  nutzt diese, im Mono-Modus weiss/SECTION_INK. Fallback bei fehlendem Bauteil =
  bisheriges Verhalten (Albedo + generische Schraffur).

Gates: tsc -b 0, build grün.
2026-07-03 18:50:31 +02:00
karim 0e1c69e316 PlanView: Haarlinien-Modus auch im GPU-Pfad zoom-konstant (Audit-Fund #1)
paperScaleForGl nutzt im hairline-Modus den live aus dem Ausschnitt abgeleiteten
scaleFromView(v) statt des stabilen paperScale. Der GL-Term mm_to_device_px =
N/1000·PX_PER_M·meet wuchs bisher über meet mit dem Zoom, obwohl Display/Haarlinie
konstante Strichbreiten verspricht. scaleFromView ist der exakte Kehrwert von meet
→ N·meet kürzt sich, die Breite in Geräte-px bleibt zoom-unabhängig (dieselbe
Wirkung wie non-scaling-stroke im SVG-Pfad). Print-Modus unverändert.

Restlücke (separat, bräuchte Shader-Änderung): noch nicht wörtlich uniform 1px wie
der SVG-Haarlinienpfad — Breiten bleiben gewichts-proportional, aber zoom-konstant.
2026-07-03 18:47:40 +02:00
karim 5a10ea5404 render3d: Öffnungen im 3D-Vollkörper (mesh.rs) — geteilte Zerlegung mit section.rs
- openings.rs (neu): split_range_by_voids() — die bisher nur in section.rs
  liegende Pfeiler/Brüstung/Sturz-Zerlegung, herausgezogen als koordinaten-
  neutraler Helfer. section.rs und mesh.rs teilen jetzt EIN Öffnungsmodell.
- section.rs: wall_cut_rectangles delegiert den Sortier-/Rechteck-Teil an
  openings::split_range_by_voids (plane-spezifisches Clipping bleibt lokal).
- mesh.rs: extrude_wall emittiert nicht mehr einen Vollblock je Wand, sondern
  Teilkörper (Pfeiler in voller Höhe + Brüstung/Sturz je Öffnung) → echtes Loch;
  Tür (sill==0) lässt die Brüstung weg und reicht bis zum Boden. Ohne Öffnung
  exakt EIN Block, byte-identische Geometrie zu vorher (Alt-Tests unverändert grün).

Gates: cargo test 32/32 (29+3 neue), wasm32 --features web check grün, nativ grün.
2026-07-03 18:47:40 +02:00
karim fd862c4a8b Doku: mm-Strichbreiten-Audit über den gesamten Engine-Pfad (Nordstern 1)
Traceanalyse der Strichbreiten von Modell → Bildschirm-bei-Massstab → Druck-mm
über alle vier Pfade (SVG-Display, SVG-Print, Vektor-PDF, render2d/WASM).
Befunde nach Schwere geordnet inkl. konkreter Fix-Vorschläge (Datei:Zeile) und
Verifikations-Rezept. Kernbefund: Hairline/Display-Modus wird von beiden
GPU-Renderern ignoriert; PEN_STEPS-Quantisierung existiert nur im PDF-Pfad.
2026-07-03 18:37:49 +02:00
karim 056ca2c288 Schnitt/Ansicht: SectionOutput aus render3d an die Zeichenebenen anbinden
- render3d/web.rs: GPU-freies WASM-Binding cut_section_json(model_json, p, n)
  → JSON {cutPolygons, visibleEdges, hiddenEdges} in (u,v)-Metern.
- src/plan/toSection.ts (neu): SectionOutput-Typen, sectionPlaneFromLevel()
  leitet die Schnittebene aus linePoints/directionSign ab, computeSection()
  flacht über projectToModel3d ab und ruft das Binding.
- src/engine/engine3d.ts (neu): geteilter memoisierter pkg3d-Loader, damit
  3D-Viewport und Schnittpfad EINE mod.default()-Init teilen.
- generatePlan.ts: generateSectionPlan() übersetzt SectionOutput in
  bestehende Primitive (Cut = polygon mit Poché-Schraffur, sichtbare Kante =
  line solid, verdeckte Kante = line dashed) — keine neue Primitive-Art nötig.
- App.tsx: SectionPlanView ersetzt den Stub für Ebenen vom Typ Schnitt/Ansicht;
  rendert dasselbe PlanView, mit lokalisierten Hinweisen (lädt/keine Linie/Fehler).
- i18n de/en: section.*-Schlüssel.
- Öffnungen erscheinen korrekt (Brüstung/Sturz-Teilrechtecke) über die
  projectToModel3d-Teilkörper-Zerlegung.

Gates: tsc -b 0, build grün (WASM = eigener Lazy-Chunk), render3d cargo test 29/29,
wasm32 --features web check grün.
2026-07-03 18:37:49 +02:00
180 changed files with 20397 additions and 3133 deletions
+89 -968
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
# DOSSIER-Feature-Audit (A1A6, B1B4, C1C3, D1D3, 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.
+511
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+31 -2
View File
@@ -4,10 +4,14 @@
// (render2d-WASM-Engine, ?engine=wasm) zuverlässig läuft. Kein Rust-Backend // (render2d-WASM-Engine, ?engine=wasm) zuverlässig läuft. Kein Rust-Backend
// nötig: compute_joins hat einen TS-Fallback (src/compute/index.ts). // 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 // 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); Menu.setApplicationMenu(null);
// Gleiche Flags wie scripts/chromium-shell.sh — WebGPU liegt unter Linux // Gleiche Flags wie scripts/chromium-shell.sh — WebGPU liegt unter Linux
@@ -27,9 +31,16 @@ function createWindow() {
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, 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) { if (isDev) {
win.loadURL(DEV_URL); win.loadURL(DEV_URL);
} else { } 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.whenReady().then(createWindow);
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
+19
View File
@@ -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
View File
@@ -22,6 +22,21 @@ pub struct Line {
pub dir: Vec2, pub dir: Vec2,
} }
/// Eine Schicht einer Wand, bereits aus dem WallType aufgeloest (Komponenten-Id
/// + Prioritaet + Dicke) -- fuer die materialbewusste T-Stoss-Erweiterung
/// (siehe `resolve_join_priority`/`apply_layer_cuts`). ADDITIV: fehlt der Key
/// `layers` in der JSON-Eingabe, liefert `#[serde(default)]` eine leere Liste,
/// sodass die bestehende Paritaets-Eingabe (ohne Schichten) unveraendert
/// funktioniert.
#[derive(Serialize, Deserialize, Clone)]
pub struct LayerInput {
#[serde(rename = "componentId")]
pub component_id: String,
#[serde(rename = "joinPriority")]
pub join_priority: f64,
pub thickness: f64,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct WallInput { pub struct WallInput {
pub id: String, pub id: String,
@@ -30,6 +45,8 @@ pub struct WallInput {
pub thickness: f64, pub thickness: f64,
#[serde(rename = "referenceOffset")] #[serde(rename = "referenceOffset")]
pub reference_offset: f64, pub reference_offset: f64,
#[serde(default)]
pub layers: Vec<LayerInput>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -37,6 +54,19 @@ pub struct JoinInput {
pub walls: Vec<WallInput>, 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. /// Schnittlinien einer Wand an ihren beiden Achsenden.
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct WallCuts { pub struct WallCuts {
@@ -46,6 +76,141 @@ pub struct WallCuts {
pub start_cut: Option<Line>, pub start_cut: Option<Line>,
#[serde(rename = "endCut")] #[serde(rename = "endCut")]
pub end_cut: Option<Line>, 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 ----------------------------------------------------------- // --- Vektor-Helfer -----------------------------------------------------------
@@ -82,6 +247,11 @@ fn cross(p: Vec2, q: Vec2) -> f64 {
p.x * q.y - p.y * q.x 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). /// Schnittpunkt der Geraden (a + t*da) mit (b + s*db).
/// Liefert None bei (nahezu) parallelen Richtungen. /// Liefert None bei (nahezu) parallelen Richtungen.
fn line_intersect(a: Vec2, da: Vec2, b: Vec2, db: Vec2) -> Option<Vec2> { 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. * Berechnet fuer jede Wand die Gehrungs-Schnittlinien.
* Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie * Behandelt werden: L-Ecken (genau zwei Wandenden treffen sich), T-Knoten
* Enden und T-/X-Stoesse bleiben rechtwinklig. * (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> { pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
let walls = input.walls; let walls = input.walls;
@@ -132,6 +304,7 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
wall_id: w.id.clone(), wall_id: w.id.clone(),
start_cut: None, start_cut: None,
end_cut: None, end_cut: None,
layer_cuts: None,
}) })
.collect(); .collect();
@@ -162,15 +335,13 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
} }
for (_key, ends) in &junctions { 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 { if ends.len() == 1 {
continue; 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()) { let a_idx = match index.get(ends[0].wall_id.as_str()) {
Some(i) => *i, Some(i) => *i,
None => continue, 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[a_idx], ends[0].end, cut);
set_cut(&mut result[b_idx], ends[1].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 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. * Gemeinsame Gehrungslinie zweier Waende A, B, die sich im Knoten J treffen.
* Robust gegen beliebige Wicklung und ungleiche Dicken: A's Aussenflaeche wird * Robust gegen beliebige Wicklung, ungleiche Dicken und JEDEN Oeffnungswinkel
* mit der NAECHSTGELEGENEN Flaeche von B verschnitten, A's Innenflaeche mit der * (inkl. spitzer): Die Wandflaechen werden orientierungsbasiert (vorzeichen-
* jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung. * 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). * 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_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)); 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. // Auslauf-Richtungen der Achsen vom Knoten weg, in den jeweiligen Wandkoerper.
let lb_closer = len(sub(p_la, p_lb)) <= len(sub(p_la, p_rb)); let d_a = match a_end {
let b_for_left = if lb_closer { p_lb } else { p_rb }; WallEndKind::Start => u_a,
let b_for_right = if lb_closer { p_rb } else { p_lb }; 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 c1 = line_intersect(p_la, u_a, b_for_left, u_b)?;
let c2 = line_intersect(p_ra, u_a, b_for_right, u_b)?; let c2 = line_intersect(p_ra, u_a, b_for_right, u_b)?;
@@ -263,6 +665,7 @@ mod tests {
end: Vec2 { x: ex, y: ey }, end: Vec2 { x: ex, y: ey },
thickness, thickness,
reference_offset: 0.0, 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] #[test]
fn free_end_no_cut() { fn free_end_no_cut() {
let input = JoinInput { let input = JoinInput {
@@ -319,8 +750,12 @@ mod tests {
} }
#[test] #[test]
fn t_junction_no_cut() { fn t_junction_branch_gets_face_cut() {
// Drei Enden treffen sich in (5,0): rechtwinklig lassen. // 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 { let input = JoinInput {
walls: vec![ walls: vec![
w("A", 0.0, 0.0, 5.0, 0.0, 0.2), w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
@@ -330,11 +765,21 @@ mod tests {
}; };
let out = compute_joins(input); let out = compute_joins(input);
assert_eq!(out.len(), 3); assert_eq!(out.len(), 3);
for id in ["A", "B", "C"] {
let c = find(&out, id); let ca = find(&out, "A");
assert!(c.start_cut.is_none(), "{id} startCut none"); let cc = find(&out, "C");
assert!(c.end_cut.is_none(), "{id} endCut none"); 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] #[test]
@@ -354,4 +799,115 @@ mod tests {
assert!(c.end_cut.is_none(), "{id} endCut none"); assert!(c.end_cut.is_none(), "{id} endCut none");
} }
} }
#[test]
fn mid_span_tee_free_end_hits_wall_side() {
// Freie Wand B endet bei (5,0) exakt auf der Achse der durchgehenden
// Wand A (0,0)-(10,0), mittig auf deren Spanne (nicht an deren Enden).
// B's anderes Ende (5,2) bleibt frei (zu weit von A entfernt). Der
// Treffer liegt exakt auf A's Achse (perp=0) -> die zugewandte Flaeche
// ist A's linke Flaeche bei y=+tw/2=+0.1 (sign=+1 per Konvention bei
// perp>=0). Die Schnittlinie ist als Punkt+Richtung auf A's Anker
// (A.start) verankert, verlaeuft aber unabhaengig davon entlang y=0.1.
let input = JoinInput {
walls: vec![
w("A", 0.0, 0.0, 10.0, 0.0, 0.2),
w("B", 5.0, 2.0, 5.0, 0.0, 0.2),
],
};
let out = compute_joins(input);
assert_eq!(out.len(), 2);
let ca = find(&out, "A");
assert!(ca.start_cut.is_none(), "A durchgehend -> kein Schnitt");
assert!(ca.end_cut.is_none(), "A durchgehend -> kein Schnitt");
let cb = find(&out, "B");
assert!(cb.start_cut.is_none(), "B startCut ist freies Ende bei (5,2)");
let cut = cb.end_cut.expect("B endCut (Mittelspann-Treffer) gesetzt");
// Verankert an A.start = (0,0), verschoben um A's halbe Dicke entlang
// ihrer linken Normale (0,1) -> Punkt (0, 0.1), Richtung parallel zu A.
assert!((cut.point.x - 0.0).abs() < 1e-9);
assert!((cut.point.y - 0.1).abs() < 1e-9);
assert!(cut.dir.y.abs() / len(cut.dir) < 1e-9, "Schnittlinie parallel zu A");
// Robuster: die Gerade verlaeuft exakt bei y=0.1, unabhaengig vom x.
let dist = |q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir);
assert!(dist(Vec2 { x: 5.0, y: 0.1 }) < 1e-9, "Treffpunkt (5,0.1) auf Gehrung");
}
/// W9-artiger Wandtyp: Innenputz (0.015) / Backstein-Kern (0.12) / Innenputz
/// (0.015), joinPriority 10 bzw. 50 -- Spiegel von `layeredWallProject` aus
/// `model/joins.test.ts`.
fn w_iw(id: &str, sx: f64, sy: f64, ex: f64, ey: f64) -> WallInput {
WallInput {
id: id.to_string(),
start: Vec2 { x: sx, y: sy },
end: Vec2 { x: ex, y: ey },
thickness: 0.15,
reference_offset: 0.0,
layers: vec![
LayerInput { component_id: "render-int".into(), join_priority: 10.0, thickness: 0.015 },
LayerInput { component_id: "brick".into(), join_priority: 50.0, thickness: 0.12 },
LayerInput { component_id: "render-int".into(), join_priority: 10.0, thickness: 0.015 },
],
}
}
#[test]
fn t_junction_layer_cuts_merge_and_trim() {
// Wie t_junction_branch_gets_face_cut, aber alle drei Waende vom
// W9-artigen Wandtyp: der Backstein-Kern (Index 1) sticht durch den
// Nah-Putz und stoppt an der Kern-Nahflaeche (y=0.06), die beiden
// Innenputz-Schichten (Index 0/2) werden getrimmt und bekommen
// zusaetzlich die seitliche L-Linie an derselben Flaeche.
let input = JoinInput {
walls: vec![
w_iw("A", 0.0, 0.0, 5.0, 0.0),
w_iw("B", 5.0, 0.0, 5.0, 3.0),
w_iw("C", 5.0, 0.0, 10.0, 0.0),
],
};
let out = compute_joins(input);
let cb = find(&out, "B");
// Aggregat-Cut bleibt unveraendert (bit-identisch zum bisherigen Verhalten).
let agg = cb.start_cut.expect("B startCut gesetzt");
assert!((agg.point.x - 5.0).abs() < 1e-9);
assert!((agg.point.y - 0.075).abs() < 1e-9);
let lc = cb.layer_cuts.as_ref().expect("layerCuts gesetzt (Backstein verschmilzt)");
assert_eq!(lc.start.len(), 3);
let dist = |cut: Line, q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir);
// Schicht 1 (Backstein-Kern): Cut an der Rueckgrat-Nahflaeche y=0.06
// (Halbdicke 0.075 minus Nah-Putz 0.015), NICHT null/Achse; keine
// Seitenlinie (verschmilzt).
let l1 = lc.start[1].expect("Schicht 1 (Kern) hat Merge-Cut");
assert!(dist(l1, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
assert!(dist(l1, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
assert!(dist(l1, Vec2 { x: 5.0, y: 0.0 }) > 1e-6); // nicht bis zur Achse
assert!(lc.start_side[1].is_none());
// Schichten 0/2 (Innenputz): derselbe Nahflaechen-Cut wie das Aggregat...
let l0 = lc.start[0].expect("Schicht 0 getrimmt");
let l2 = lc.start[2].expect("Schicht 2 getrimmt");
assert!((l0.point.y - 0.075).abs() < 1e-9);
assert!((l2.point.y - 0.075).abs() < 1e-9);
// ... UND die seitliche L-Linie an der Rueckgrat-Nahflaeche (y=0.06,
// parallel zur x-Achse).
let s0 = lc.start_side[0].expect("Schicht 0 hat L-Seitenlinie");
let s2 = lc.start_side[2].expect("Schicht 2 hat L-Seitenlinie");
assert!(dist(s0, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
assert!(dist(s0, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
assert!(dist(s2, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
// Die kollineare Durchgangswand bleibt unangetastet (kein layerCuts).
let ca = find(&out, "A");
let cc = find(&out, "C");
assert!(ca.layer_cuts.is_none());
assert!(cc.layer_cuts.is_none());
}
} }
+1 -1
View File
@@ -68,7 +68,7 @@ struct Globals {
struct VsIn { struct VsIn {
@location(0) position : vec2<f32>, // Bildschirm-Raum-Endpunkt @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(2) side : f32, // +1.0 oder -1.0
@location(3) miter : f32, // 1/cos(theta/2)-Laengenfaktor (Gehrung) @location(3) miter : f32, // 1/cos(theta/2)-Laengenfaktor (Gehrung)
}; };
+154 -70
View File
@@ -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]) (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). /// Einheits-Richtung von `from` nach `to` (Bildschirm-Raum); None bei Nulllaenge.
const MITER_LIMIT: f32 = 8.0;
/// Einheits-Links-Normale von `from` nach `to` (Bildschirm-Raum); None bei Nulllaenge.
#[inline] #[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 dx = to[0] - from[0];
let dy = to[1] - from[1]; let dy = to[1] - from[1];
let len = (dx * dx + dy * dy).sqrt(); let len = (dx * dx + dy * dy).sqrt();
if len < 1e-6 { if len < 1e-9 {
None None
} else { } 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). /// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert).
fn point_in_tri(a: Point, b: Point, c: Point, p: Point) -> bool { fn point_in_tri(a: Point, b: Point, c: Point, p: Point) -> bool {
let d1 = cross(a, b, p); let d1 = cross(a, b, p);
@@ -390,7 +393,10 @@ pub struct GpuGeometry {
pub fill_idx: Vec<u32>, pub fill_idx: Vec<u32>,
pub fill_batches: Vec<FillBatch>, pub fill_batches: Vec<FillBatch>,
/// Linien-Vertices, interleaved [x,y, bx,by, side, miter] in Bildschirm-Raum /// 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_verts: Vec<f32>,
pub line_idx: Vec<u32>, pub line_idx: Vec<u32>,
pub line_batches: Vec<LineBatch>, pub line_batches: Vec<LineBatch>,
@@ -461,11 +467,23 @@ impl GpuGeometry {
.push((BatchKind::Line, (self.line_batches.len() - 1) as u32)); .push((BatchKind::Line, (self.line_batches.len() - 1) as u32));
} }
/// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) als EINEN /// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) mit ECHTEN
/// gehrten Streifen: an jedem Stuetzpunkt wird der Versatz entlang des Miter- /// RUNDEN Kappen/Ecken (wie SVG `stroke-linecap/linejoin: round` und der
/// Bisektors verlaengert (1/cos(theta/2)), sodass benachbarte Segmente buendig /// Vektor-PDF-Pfad) statt eckigem Butt-Cap/Gehrung:
/// verschmelzen -> gehrte Ecke statt Butt-Cap-Stufe. `closed` schliesst den Ring /// - jedes Segment ist ein eigenstaendiges Quad mit BUTT-Enden (eigene
/// (letzter<->erster Punkt). Vertex-Layout: [x,y, bx,by, side, miter] (6 floats). /// 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`. /// 1:1-Port von `strokePolyline` in `src/plan/glPlan/glPlanCompile.ts`.
fn stroke_polyline( fn stroke_polyline(
@@ -501,69 +519,135 @@ impl GpuGeometry {
return; return;
} }
let base = (self.line_verts.len() / 6) as u32; let start_idx_len = self.line_idx.len();
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 (bx, by, miter): (f32, f32, f32); // Vertex fuer Quad-Rand ODER Faecher (Zentrum bei side=0, Rand bei side=1)
match (n_in, n_out) { // anhaengen; gibt den neuen Index zurueck.
(Some(a), Some(b)) => { #[inline]
let sx = a[0] + b[0]; fn push_vert(verts: &mut Vec<f32>, p: Point, n: Point, side: f32) -> u32 {
let sy = a[1] + b[1]; verts.extend_from_slice(&[p[0], p[1], n[0], n[1], side, 1.0]);
let slen = (sx * sx + sy * sy).sqrt(); (verts.len() / 6 - 1) as u32
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]);
} }
let segs = if closed { k } else { k - 1 }; let segs = if closed { k } else { k - 1 };
let mut seg_dir: Vec<Option<Point>> = Vec::with_capacity(segs);
for i in 0..segs { for i in 0..segs {
let a = base + 2 * i as u32; seg_dir.push(unit_dir(s[i], s[(i + 1) % k]));
let b = base + 2 * (((i + 1) % k) as u32);
self.line_idx
.extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]);
} }
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, sill: 0.8,
height: 1.0, height: 1.0,
}], }],
layers: None,
}, },
WallInput { WallInput {
start: [0.0, 3.0], start: [0.0, 3.0],
@@ -40,6 +41,7 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
base_elevation: 0.0, base_elevation: 0.0,
color, color,
openings: vec![], openings: vec![],
layers: None,
}, },
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X, // "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
// also groessere Tiefe von der Betrachter-Ebene aus), genau im // 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, base_elevation: 0.0,
color, color,
openings: vec![], openings: vec![],
layers: None,
}, },
]; ];
let slabs = vec![SlabInput { let slabs = vec![SlabInput {
+1
View File
@@ -37,6 +37,7 @@ fn demo_walls() -> Vec<WallInput> {
base_elevation: 0.0, base_elevation: 0.0,
color: grey, color: grey,
openings: vec![], openings: vec![],
layers: None,
}; };
// Raum 6 x 4 m. // Raum 6 x 4 m.
vec![ vec![
+250
View File
@@ -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
View File
@@ -12,14 +12,76 @@
use bytemuck::{Pod, Zeroable}; use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
use crate::math::{view_projection, Mat4}; use crate::math::{view_projection, Mat4};
use crate::mesh::{build_model_mesh, build_walls_mesh}; use crate::mesh::{build_scene_mesh, build_walls_mesh};
use crate::shaders::MESH_WGSL; use crate::section::SectionPlane;
use crate::types::{Camera, Mesh, SlabInput, WallInput, FLOATS_PER_VERTEX}; 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). /// Tiefenformat des Z-Puffers (32 Bit Float, ueberall verfuegbar).
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; 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 /// Uniform-Block, 1:1 zum WGSL-`Globals`-Struct. std140-kompatibel (mat4/vec4 auf
/// 16 Byte ausgerichtet). /// 16 Byte ausgerichtet).
#[repr(C)] #[repr(C)]
@@ -30,6 +92,13 @@ struct Globals {
sky_color: [f32; 4], sky_color: [f32; 4],
ground_color: [f32; 4], ground_color: [f32; 4],
sun_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 { impl Default for Globals {
@@ -44,6 +113,9 @@ impl Default for Globals {
ground_color: [0.30, 0.29, 0.27, 1.0], ground_color: [0.30, 0.29, 0.27, 1.0],
// Directional-Sonne: warmweiss, moderat. // Directional-Sonne: warmweiss, moderat.
sun_color: [0.55, 0.53, 0.49, 1.0], 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, 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 + /// Der native 3D-Renderer: haelt die Pipeline, das Uniform (View-Projektion +
/// Licht) und das aktuell hochgeladene Mesh. Ein Tiefenpuffer wird passend zur /// Licht) und das aktuell hochgeladene Mesh. Ein Tiefenpuffer wird passend zur
/// Ziel-Groesse (neu) angelegt. /// Ziel-Groesse (neu) angelegt.
pub struct Renderer { pub struct Renderer {
pipeline: wgpu::RenderPipeline, 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, bind_group: wgpu::BindGroup,
uniform: wgpu::Buffer, uniform: wgpu::Buffer,
mesh: Option<MeshBuffers>, 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>, depth: Option<DepthTarget>,
msaa: Option<MsaaTarget>,
/// Farbformat des Render-Ziels (Surface), fuer die MSAA-Zwischentextur.
color_format: wgpu::TextureFormat,
globals: Globals, globals: Globals,
/// Loeschfarbe (Hintergrund). Default heller Grauton wie die three.js-Sicht. /// Loeschfarbe (Hintergrund). Default heller Grauton wie die three.js-Sicht.
pub clear_color: wgpu::Color, pub clear_color: wgpu::Color,
@@ -81,6 +213,14 @@ struct DepthTarget {
height: u32, 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 { impl Renderer {
/// Erzeugt Pipeline + Layout fuer ein gegebenes Farbformat (Surface). /// Erzeugt Pipeline + Layout fuer ein gegebenes Farbformat (Surface).
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { 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], attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3],
}; };
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { // Mesh-Pipeline-Fabrik: identische Beschreibung fuer die Standard-Flaechen
label: Some("mesh.pipeline"), // (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), layout: Some(&pipeline_layout),
vertex: wgpu::VertexState { vertex: wgpu::VertexState {
module: &module, module: &module,
entry_point: Some("vs_main"), entry_point: Some("vs_main"),
buffers: &[vertex_layout], buffers: &[vertex_layout.clone()],
compilation_options: Default::default(), compilation_options: Default::default(),
}, },
fragment: Some(wgpu::FragmentState { fragment: Some(wgpu::FragmentState {
@@ -150,9 +294,170 @@ impl Renderer {
depth_write_enabled: Some(true), depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less), depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(), 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, multiview_mask: None,
cache: None, cache: None,
}); });
@@ -174,10 +479,22 @@ impl Renderer {
Self { Self {
pipeline, pipeline,
hidden_face_pipeline,
grid_pipeline,
highlight_pipeline,
cap_pipeline,
bind_group, bind_group,
uniform, uniform,
mesh: None, mesh: None,
edges: None,
grid: None,
highlight: None,
caps: None,
grid_visible: false,
style: RenderStyle::Shaded,
depth: None, depth: None,
msaa: None,
color_format,
globals: Globals::default(), globals: Globals::default(),
// #f5f5f5 heller Hintergrund (wie der 2D-Grundriss). // #f5f5f5 heller Hintergrund (wie der 2D-Grundriss).
clear_color: wgpu::Color { clear_color: wgpu::Color {
@@ -194,17 +511,44 @@ impl Renderer {
self.upload_mesh(device, build_walls_mesh(walls)); self.upload_mesh(device, build_walls_mesh(walls));
} }
/// Erzeugt das Mesh aus Waenden UND Deckenplatten und laedt die Puffer hoch. /// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes
pub fn upload_model(&mut self, device: &wgpu::Device, walls: &[WallInput], slabs: &[SlabInput]) { /// (Terrain/importierte Volumen, siehe `MeshInput`) und laedt die Puffer hoch.
self.upload_mesh(device, build_model_mesh(walls, slabs)); 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) { fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
if mesh.indices.is_empty() { if mesh.indices.is_empty() {
self.mesh = None; self.mesh = None;
self.edges = None;
return; 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 { let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.vbo"), label: Some("mesh.vbo"),
contents: bytemuck::cast_slice(&mesh.verts), contents: bytemuck::cast_slice(&mesh.verts),
@@ -236,6 +580,129 @@ impl Renderer {
self.globals.sun_color = [sun[0], sun[1], sun[2], 1.0]; 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. /// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) { 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); 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, depth_or_array_layers: 1,
}, },
mip_level_count: 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, dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT, format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
@@ -264,14 +732,45 @@ impl Renderer {
}); });
} }
/// Zeichnet einen Frame in `view` (Surface- oder Offscreen-Textur). Die Kamera /// Stellt sicher, dass eine multisampled Farb-Zwischentextur passend zur
/// liefert die View-Projektions-Matrix; `viewport` bestimmt das Seitenverhaeltnis /// Ziel-Groesse existiert (wird jeden Frame in die Surface-View aufgeloest).
/// und die Tiefenpuffer-Groesse. 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( pub fn render(
&mut self, &mut self,
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
view: &wgpu::TextureView, target: &wgpu::TextureView,
camera: &Camera, camera: &Camera,
viewport: (u32, u32), viewport: (u32, u32),
) { ) {
@@ -279,10 +778,15 @@ impl Renderer {
let aspect = if h > 0 { w as f32 / h as f32 } else { 1.0 }; let aspect = if h > 0 { w as f32 / h as f32 } else { 1.0 };
let vp: Mat4 = view_projection(camera, aspect); let vp: Mat4 = view_projection(camera, aspect);
self.globals.view_proj = vp; 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)); queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
self.ensure_depth(device, w, h); self.ensure_depth(device, w, h);
self.ensure_msaa(device, w, h);
let depth_view = &self.depth.as_ref().unwrap().view; let depth_view = &self.depth.as_ref().unwrap().view;
let msaa_view = &self.msaa.as_ref().unwrap().view;
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("3d.encoder"), label: Some("3d.encoder"),
@@ -291,13 +795,17 @@ impl Renderer {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("3d.pass"), label: Some("3d.pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment { 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. // Ziel ist eine 2D-Textur (kein 3D-Volumen) -> kein Depth-Slice.
depth_slice: None, depth_slice: None,
resolve_target: None, resolve_target: Some(target),
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(self.clear_color), 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 { depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
@@ -313,14 +821,71 @@ impl Renderer {
multiview_mask: None, 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 { 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_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, m.vbo.slice(..)); pass.set_vertex_buffer(0, m.vbo.slice(..));
pass.set_index_buffer(m.ibo.slice(..), wgpu::IndexFormat::Uint32); pass.set_index_buffer(m.ibo.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..m.index_count, 0, 0..1); 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())); queue.submit(std::iter::once(encoder.finish()));
} }
} }
+176
View File
@@ -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);
}
}
+443 -4
View File
@@ -14,9 +14,13 @@
// Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die // Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die
// GPU-freien Schichten und ist damit unabhaengig von einer Display-Session. // GPU-freien Schichten und ist damit unabhaengig von einer Display-Session.
pub mod edges;
pub mod grid;
pub mod math; pub mod math;
pub mod mesh; pub mod mesh;
pub(crate) mod openings;
pub mod section; pub mod section;
pub mod section_fill;
pub mod shaders; pub mod shaders;
pub mod types; pub mod types;
@@ -33,12 +37,16 @@ pub use math::{
look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix, look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix,
view_matrix, view_projection, Mat4, 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::{ pub use section::{
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane, cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
}; };
pub use types::{ 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) ------------------- // --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
@@ -46,7 +54,7 @@ pub use types::{
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX}; 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. /// Bequemer Bau einer achsparallelen Wand entlang +X.
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput { fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
@@ -58,6 +66,7 @@ mod tests {
base_elevation: 0.0, base_elevation: 0.0,
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None,
} }
} }
@@ -94,6 +103,7 @@ mod tests {
base_elevation: 0.0, base_elevation: 0.0,
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None,
}, },
]); ]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
@@ -171,6 +181,7 @@ mod tests {
base_elevation: 0.0, base_elevation: 0.0,
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None,
}; };
let mut mesh = Mesh::default(); let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w); extrude_wall(&mut mesh, &w);
@@ -191,12 +202,332 @@ mod tests {
base_elevation: 0.0, base_elevation: 0.0,
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None,
}; };
let mesh = build_walls_mesh(&[w]); let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0); assert_eq!(mesh.vertex_count(), 0);
assert!(mesh.indices.is_empty()); 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) --------------------------------- // --- Deckenplatten (extrudierte Polygone) ---------------------------------
use super::mesh::{build_model_mesh, extrude_slab, triangulate}; use super::mesh::{build_model_mesh, extrude_slab, triangulate};
@@ -300,6 +631,98 @@ mod tests {
assert_eq!(mesh.vertex_count(), 0); 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 ---------------------------------------------------- // --- Kamera / Matrizen ----------------------------------------------------
#[test] #[test]
@@ -363,7 +786,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
preset_camera(CameraPreset::Iso, t, 10.0).projection, preset_camera(CameraPreset::Iso, t, 10.0).projection,
Projection::Perspective Projection::Orthographic
); );
assert_eq!( assert_eq!(
preset_camera(CameraPreset::Persp, t, 10.0).projection, preset_camera(CameraPreset::Persp, t, 10.0).projection,
@@ -388,5 +811,21 @@ mod tests {
validator validator
.validate(&module) .validate(&module)
.unwrap_or_else(|e| panic!("mesh: WGSL-Validierung fehlgeschlagen: {e:?}")); .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:?}"));
} }
} }
+11 -4
View File
@@ -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` /// 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 /// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side/Iso
/// orthografisch achsparallel; Iso/Persp sind perspektivisch. /// sind orthografisch (Front/Top/Side achsparallel, Iso diagonal); nur Persp
/// ist perspektivisch.
pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera { pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera {
let mut cam = Camera { let mut cam = Camera {
target, 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.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5; 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 => { CameraPreset::Iso => {
cam.projection = Projection::Perspective; cam.projection = Projection::Orthographic;
let d = dist / 3.0_f32.sqrt(); let d = dist / 3.0_f32.sqrt();
cam.eye = [target[0] + d, target[1] + d, target[2] + d]; cam.eye = [target[0] + d, target[1] + d, target[2] + d];
cam.up = [0.0, 1.0, 0.0]; 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). // Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
CameraPreset::Persp => { CameraPreset::Persp => {
+569 -41
View File
@@ -8,14 +8,59 @@
// Diese 2D-Band-Ecken werden nach world abgebildet (XZ-Ebene) und entlang +Y von // Diese 2D-Band-Ecken werden nach world abgebildet (XZ-Ebene) und entlang +Y von
// `base_elevation` bis `base_elevation + height` extrudiert — analog zur // `base_elevation` bis `base_elevation + height` extrudiert — analog zur
// three.js-`ExtrudeGeometry`, die den 2D-`clippedBand` hochzieht (Viewport3D.tsx // three.js-`ExtrudeGeometry`, die den 2D-`clippedBand` hochzieht (Viewport3D.tsx
// `addLayerPrism`). M1 nimmt die stumpfe (rechtwinklige) Band-Form; Gehrungen // `addLayerPrism`).
// (`computeJoins`/`clippedBand`) und Oeffnungen kommen in spaeteren Milestones. //
// 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 // KOORDINATEN: model [x, y] -> world (x, elevation, y). Also Grundriss in der
// XZ-Ebene, Extrusion entlang +Y (Y-up), exakt wie Viewport3D.tsx: // XZ-Ebene, Extrusion entlang +Y (Y-up), exakt wie Viewport3D.tsx:
// "Modell (x,y,z) -> Three (x, z, y) (Z = Hoehe nach oben)". // "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 /// 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, /// 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] [-dy / len, dx / len]
} }
/// Extrudiert EINE Wand zu einem Quader-Mesh und haengt sie an `mesh` an. /// Extrudiert EINE Wand (ohne Kenntnis von Nachbarn, also stets rechtwinklige
/// /// Enden) zu einem oder mehreren Quader-Meshes. Fuer Szenen mit mehreren
/// Aufbau: die vier Grundriss-Ecken des Bandes (CCW) bilden das Deckel-Rechteck. /// Waenden bitte `build_walls_mesh`/`build_model_mesh` nutzen — die berechnen
/// Boden = gleiche XZ auf `y0`, Deckel = auf `y1`. Jede der sechs Seiten bekommt /// zuerst die Eck-Gehrung ueber alle Waende (siehe `compute_wall_miters`) und
/// eigene Vertices mit ihrer (nach aussen zeigenden) Flaechen-Normale — daher /// wenden sie hier mit an. Diese Funktion bleibt der einfache Einzel-Wand-
/// flaches Shading und korrektes Backface-Culling. /// Einstieg (u. a. fuer bestehende Tests) und entspricht exakt dem Aufruf mit
/// leeren Gehrungen.
pub fn extrude_wall(mesh: &mut Mesh, wall: &WallInput) { 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); let n = left_normal(wall.start, wall.end);
// Degenerierte Wand (Start == Ende) ueberspringen. // Degenerierte Wand (Start == Ende) ueberspringen.
if n == [0.0, 0.0] { if n == [0.0, 0.0] {
return; 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)`): let z0 = wall.base_elevation;
// p0 = start + n*h, p1 = end + n*h, p2 = end - n*h, p3 = start - n*h. let z1 = wall.base_elevation + wall.height;
let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s * h, p[1] + n[1] * s * h] }; let segments = wall_solid_segments(length, &wall.openings, z0, z1);
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 y0 = wall.base_elevation; // Schicht-Stapel: ohne `layers` (oder leer) EIN synthetisches Layer aus
let y1 = wall.base_elevation + wall.height; // der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten.
let color = wall.color; 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). // world-Position aus Grundriss-XZ + Hoehe: (x, y, z=grundriss.y).
let w = |g: Point2, y: f32| -> [f32; 3] { [g[0], y, g[1]] }; 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). // 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); push_quad(mesh, b0, b3, b2, b1, [0.0, -1.0, 0.0], color);
// Vier Mantelflaechen. Aussen-Normalen aus dem Grundriss: // Zwei Mantelflaechen (+n-/n-Seite): bleiben bei Gehrung EXAKT planar mit
// Kante g0->g1 liegt auf der +n-Seite -> Normale +n. // fester Normale, weil sich die Ecken nur entlang `u` verschieben (siehe
// Kante g2->g3 liegt auf der -n-Seite -> Normale -n. // Moduldoc-Beweis) — der `off_a`/`off_b`-Abstand zur Achse bleibt konstant.
// Kappen an Start (g3->g0) und Ende (g1->g2) liegen an den Achsen-Enden. let np = [n[0], 0.0, n[1]];
let np = [n[0], 0.0, n[1]]; // +n-Seite (world) let nn = [-n[0], 0.0, -n[1]];
let nn = [-n[0], 0.0, -n[1]]; // -n-Seite push_quad(mesh, b0, b1, t1, t0, np, color);
// Achsenrichtung (fuer die End-Kappen-Normalen). 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 ux = wall.end[0] - wall.start[0];
let uz = wall.end[1] - wall.start[1]; let uz = wall.end[1] - wall.start[1];
let ul = (ux * ux + uz * uz).sqrt().max(1e-9); let ul = (ux * ux + uz * uz).sqrt().max(1e-9);
let u = [ux / ul, 0.0, uz / ul]; let u: Point2 = [ux / ul, uz / ul];
let neg_u = [-u[0], 0.0, -u[2]]; 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. /// Grundriss-Fussabdruecke ALLER Waende, an ihren echten Ecken verschnitten
push_quad(mesh, b0, b1, t1, t0, np, color); /// (siehe `wall_mitered_footprint`). Berechnet die Gehrung einmalig ueber die
// -n-Wand: aussen betrachtet CCW = b2, b3, t3, t2. /// gesamte Wandkonnektivitaet (`compute_wall_miters`) und teilt so die
push_quad(mesh, b2, b3, t3, t2, nn, color); /// Ecken-Verschneidungs-Wahrheit mit `section::cut_section` — damit der Schnitt
// End-Kappe (Achsen-Ende, Normale +u): b1, b2, t2, t1. /// an Wandecken denselben verschnittenen Wand-Querschnitt liefert wie die
push_quad(mesh, b1, b2, t2, t1, u, color); /// 3D-Extrusion, ohne die Gehrungslogik zu duplizieren. Reihenfolge/Anzahl
// Start-Kappe (Achsen-Anfang, Normale -u): b3, b0, t0, t3. /// deckungsgleich mit `walls` (`None` bei entarteter Wand).
push_quad(mesh, b3, b0, t0, t3, neg_u, color); 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 /// 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 /// Baut ein einzelnes Mesh aus mehreren Waenden (alle in EINEN Puffer). Die
/// Reihenfolge der Vertices ist deterministisch (je Wand ein Quader), damit die /// Reihenfolge der Vertices ist deterministisch (je Wand ein Quader, bzw. bei
/// Tests stabile Zaehlungen erwarten koennen. /// `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 { pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
let mut mesh = Mesh::default(); let mut mesh = Mesh::default();
for w in walls { let miters = compute_wall_miters(walls);
extrude_wall(&mut mesh, w); for (w, m) in walls.iter().zip(miters.iter()) {
extrude_wall_with_miters(&mut mesh, w, *m);
} }
mesh mesh
} }
@@ -146,6 +586,94 @@ pub fn build_model_mesh(walls: &[WallInput], slabs: &[SlabInput]) -> Mesh {
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) ───────────────────────────────────── // ── Deckenplatten (extrudierte Polygone) ─────────────────────────────────────
/// Signierte Flaeche eines Grundriss-Polygons (Shoelace) in Modell-Koordinaten. /// Signierte Flaeche eines Grundriss-Polygons (Shoelace) in Modell-Koordinaten.
+69
View File
@@ -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
}
+94 -36
View File
@@ -101,17 +101,27 @@
//! (Rechteck-Baender) und Decken (i.d.R. konvexe Rechtecke) ist das exakt; //! (Rechteck-Baender) und Decken (i.d.R. konvexe Rechtecke) ist das exakt;
//! fuer stark diagonale oder konkave Grundrisse kann es zu Ueberverdeckung //! fuer stark diagonale oder konkave Grundrisse kann es zu Ueberverdeckung
//! fuehren. //! fuehren.
//! - Keine Wandknoten-Verschneidung (T-/X-Stoesse): Waende werden wie in //! - Wandknoten-Verschneidung (Gehrung) — zwei Wege, je nach Zweck:
//! `mesh.rs` als eigenstaendige, stumpf abgeschlossene Quader behandelt, die //! * CUT-Polygone (der eigentliche Schnitt-Querschnitt) nutzen den an den
//! sich an Ecken UEBERLAPPEN (kein Miter-Join). Das kann in Ecknaehe dazu //! echten Wandecken GEHRTEN Fussabdruck aus `mesh::wall_mitered_footprints`
//! fuehren, dass eine Wand als "in eine andere eingebettet" verdeckt erscheint //! — DIESELBE Ecken-Verschneidung wie die 3D-Vollkoerper-Extrusion
//! (siehe Test `eck_ueberlappung_verdeckt_kante_am_wandknoten` unten) — real, //! (`mesh::compute_wall_miters`). Dadurch liefert ein Schnitt an einer
//! aber ein Artefakt der fehlenden Verschneidungslogik, kein Bug dieses Moduls. //! Wandecke genau EINEN sauberen, verschnittenen Querschnitt statt zweier
//! - Oeffnungen werden hier (Cut + Projektion) ausgewertet, aber NICHT von der //! ueberlappender Rechtecke (siehe `Prism::cut_footprint`, `cut_section` und
//! Vollkoerper-Extrusion in `mesh.rs` — die 3D-Solid-Mesh-Erzeugung extrudiert //! Test `schnitt_an_wandecke_liefert_verschnittene_querschnitte`).
//! weiterhin die volle Wandflaeche (siehe `mesh.rs`-Moduldoc). Cut-Ansicht und //! * Verdeckung/Projektion (Bounding-Box + Ansichtskanten) bleiben bei der
//! 3D-Solid-Ansicht sind bis zu diesem Nachtrag also bewusst inkonsistent; //! ACHSPARALLELEN, ungehrten Grundflaeche (`Prism::footprint`): die
//! das Nachziehen von `mesh.rs` ist eine bekannte, separate Luecke. //! 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 //! - Oeffnungs-Durchblick nutzt dieselbe achsparallele Bounding-Box-Naeherung
//! wie die allgemeine Verdeckung (kein exaktes Polygon-Clipping der Lochflaeche //! wie die allgemeine Verdeckung (kein exaktes Polygon-Clipping der Lochflaeche
//! gegen dahinterliegende Kanten) und wird bei Wand-Orientierungen nahe //! gegen dahinterliegende Kanten) und wird bei Wand-Orientierungen nahe
@@ -176,7 +186,9 @@ impl SectionPlane {
/// u-Achse der Projektion: horizontal, senkrecht zur Blickrichtung (siehe /// u-Achse der Projektion: horizontal, senkrecht zur Blickrichtung (siehe
/// Moduldoc — dieselbe Konvention wie `math::look_at`s `right`-Vektor). /// 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 up = [0.0, 1.0, 0.0];
let u = math::cross(self.normal, up); let u = math::cross(self.normal, up);
if math::length(u) > 1e-6 { if math::length(u) > 1e-6 {
@@ -289,7 +301,16 @@ pub struct SectionOutput {
struct Prism { struct Prism {
kind: ComponentKind, kind: ComponentKind,
index: usize, 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>, 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, z0: f32,
z1: f32, z1: f32,
color: Rgb, color: Rgb,
@@ -371,6 +392,9 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option<Prism> {
Some(Prism { Some(Prism {
kind: ComponentKind::Wall, kind: ComponentKind::Wall,
index, index,
// Standardmaessig ungehrt; `cut_section` ersetzt ihn durch den gehrten
// Fussabdruck aus `mesh::wall_mitered_footprints`.
cut_footprint: footprint.clone(),
footprint, footprint,
z0: wall.base_elevation, z0: wall.base_elevation,
z1: wall.base_elevation + wall.height, z1: wall.base_elevation + wall.height,
@@ -396,6 +420,7 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option<Prism> {
Some(Prism { Some(Prism {
kind: ComponentKind::Slab, kind: ComponentKind::Slab,
index, index,
cut_footprint: slab.outline.clone(),
footprint: slab.outline.clone(), footprint: slab.outline.clone(),
z0, z0,
z1, z1,
@@ -508,28 +533,11 @@ fn wall_cut_rectangles(
if voids.is_empty() { if voids.is_empty() {
return vec![(u_lo, u_hi, prism.z0, prism.z1)]; return vec![(u_lo, u_hi, prism.z0, prism.z1)];
} }
voids.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); // Bruestungs-/Sturz-Zerlegung ist ab hier koordinatenraum-neutral (nur noch
// u-Intervall + Hoehe + geclippte Voids) -> gemeinsame Basis mit
let mut rects = Vec::new(); // `mesh::wall_solid_segments` (siehe `crate::openings`-Moduldoc), damit
let mut cursor = u_lo; // Cut-Ansicht und 3D-Solid-Mesh garantiert dasselbe Oeffnungsmodell nutzen.
for (v_lo, v_hi, op) in &voids { crate::openings::split_range_by_voids(u_lo, u_hi, prism.z0, prism.z1, 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
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -938,8 +946,14 @@ fn compute_view_edges(
/// Decken) aus. Siehe Moduldoc fuer Format/Konventionen. /// Decken) aus. Siehe Moduldoc fuer Format/Konventionen.
pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> SectionOutput { pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> SectionOutput {
let mut prisms: Vec<Prism> = Vec::new(); 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() { 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); prisms.push(p);
} }
} }
@@ -951,7 +965,7 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput
let mut cut_polygons = Vec::new(); let mut cut_polygons = Vec::new();
for prism in &prisms { 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)); let (lo, hi) = (u0.min(u1), u0.max(u1));
for (rlo, rhi, rz0, rz1) in wall_cut_rectangles(plane, prism, lo, hi) { for (rlo, rhi, rz0, rz1) in wall_cut_rectangles(plane, prism, lo, hi) {
cut_polygons.push(CutPolygon { cut_polygons.push(CutPolygon {
@@ -1001,6 +1015,7 @@ mod tests {
base_elevation: 0.0, base_elevation: 0.0,
color, color,
openings: vec![], openings: vec![],
layers: None,
}, },
WallInput { WallInput {
start: [0.0, 3.0], start: [0.0, 3.0],
@@ -1010,6 +1025,7 @@ mod tests {
base_elevation: 0.0, base_elevation: 0.0,
color, color,
openings: vec![], openings: vec![],
layers: None,
}, },
]; ];
let slabs = vec![SlabInput { 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) ----------------------------------------- // --- Oeffnungen (Tueren/Fenster) -----------------------------------------
/// Eine Wand entlang +X (Achse (0,0)->(4,0)) mit einem Fenster: Intervall /// Eine Wand entlang +X (Achse (0,0)->(4,0)) mit einem Fenster: Intervall
@@ -1225,6 +1281,7 @@ mod tests {
sill: 1.0, sill: 1.0,
height: 1.0, height: 1.0,
}], }],
layers: None,
} }
} }
@@ -1309,6 +1366,7 @@ mod tests {
base_elevation: 0.0, base_elevation: 0.0,
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
openings: vec![], openings: vec![],
layers: None,
}; };
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter. // Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
let plane = SectionPlane::looking_plus_y(-2.0); let plane = SectionPlane::looking_plus_y(-2.0);
+82
View File
@@ -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
}
+160 -1
View File
@@ -16,8 +16,16 @@
// sky_color : vec4<f32> Himmels-Ambient (rgb; von oben) // sky_color : vec4<f32> Himmels-Ambient (rgb; von oben)
// ground_color : vec4<f32> Boden-Ambient (rgb; von unten) // ground_color : vec4<f32> Boden-Ambient (rgb; von unten)
// sun_color : vec4<f32> Farbe/Staerke des Directional-Lights (rgb) // 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). // 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) /// Der einzige Shader (Vertex + Fragment). Hemisphaerisches Ambient (Himmel/Boden)
/// + Lambert-Directional — GPU-Aequivalent eines Architektur-Massenmodell-Lichts. /// + Lambert-Directional — GPU-Aequivalent eines Architektur-Massenmodell-Lichts.
@@ -28,6 +36,8 @@ struct Globals {
sky_color : vec4<f32>, sky_color : vec4<f32>,
ground_color : vec4<f32>, ground_color : vec4<f32>,
sun_color : vec4<f32>, sun_color : vec4<f32>,
mode : vec4<f32>,
section_plane : vec4<f32>,
}; };
@group(0) @binding(0) var<uniform> globals : Globals; @group(0) @binding(0) var<uniform> globals : Globals;
@@ -41,6 +51,7 @@ struct VsOut {
@builtin(position) clip_pos : vec4<f32>, @builtin(position) clip_pos : vec4<f32>,
@location(0) world_normal : vec3<f32>, @location(0) world_normal : vec3<f32>,
@location(1) color : vec3<f32>, @location(1) color : vec3<f32>,
@location(2) world_pos : vec3<f32>,
}; };
@vertex @vertex
@@ -49,13 +60,24 @@ fn vs_main(in : VsIn) -> VsOut {
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0); out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.world_normal = in.normal; out.world_normal = in.normal;
out.color = in.color; out.color = in.color;
out.world_pos = in.position;
return out; return out;
} }
@fragment @fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> { 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 n = normalize(in.world_normal);
let l = normalize(globals.light_dir.xyz); let l = normalize(globals.light_dir.xyz);
let style = globals.mode.x;
// Hemisphaerisches Ambient: Mischfaktor aus der Vertikal-Komponente der // Hemisphaerisches Ambient: Mischfaktor aus der Vertikal-Komponente der
// Normalen (n.y = +1 -> voll Himmel, n.y = -1 -> voll Boden). // 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. // Gerichteter Lambert-Anteil (Sonne); Rueckseiten (n.l < 0) tragen nichts bei.
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb; 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, // Sanfte Kantenbetonung: senkrechte Flaechen (n.y nahe 0) minimal abdunkeln,
// damit sich Waende vom hellen Deckel/Boden abheben (ohne Postprocessing). // damit sich Waende vom hellen Deckel/Boden abheben (ohne Postprocessing).
let edge = 0.90 + 0.10 * abs(n.y); let edge = 0.90 + 0.10 * abs(n.y);
shaded = shaded * edge; 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); 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);
}
"#;
+101 -7
View File
@@ -25,13 +25,17 @@ pub type Rgb = [f32; 3];
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`). /// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
/// ///
/// Vereinfachung des Web-`Wall` (siehe src/model/wall.ts): Ecken-Verschneidung /// Vereinfachung des Web-`Wall` (siehe src/model/wall.ts): Ecken-Verschneidung
/// (`computeJoins`/`wallCorners`) kommt in einem spaeteren Milestone; M1 extrudiert /// (Gehrung an echten Wandenden, analog `computeJoins`/`wallCorners` im Web)
/// jede Wand als eigenstaendigen, an den Enden stumpf abgeschlossenen Quader. /// 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 /// `openings` (Tueren/Fenster, siehe `Opening`) werden vom Schnitt-Modul
/// (`section.rs`) ausgewertet; die Vollkoerper-Extrusion (`mesh.rs`) kennt sie /// (`section.rs`) UND von der Vollkoerper-Extrusion (`mesh.rs`) ausgewertet
/// (noch) NICHT und extrudiert weiterhin die volle Wandflaeche — siehe /// (siehe `mesh::wall_solid_segments`) — beide nutzen dieselbe Bruestungs-/
/// `mesh.rs`-Moduldoc und `docs/design/engine-section-pipeline.md` fuer diese /// Sturz-Zerlegung (`crate::openings`).
/// bekannte Diskrepanz.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WallInput { pub struct WallInput {
/// Achsen-Startpunkt im Grundriss (Modell-Meter). /// Achsen-Startpunkt im Grundriss (Modell-Meter).
@@ -39,6 +43,9 @@ pub struct WallInput {
/// Achsen-Endpunkt im Grundriss (Modell-Meter). /// Achsen-Endpunkt im Grundriss (Modell-Meter).
pub end: Point2, pub end: Point2,
/// Wanddicke in Metern (das Band wird um `thickness/2` je Seite versetzt). /// 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, pub thickness: f32,
/// Wandhoehe in Metern (Extrusion entlang +Y). /// Wandhoehe in Metern (Extrusion entlang +Y).
pub height: f32, pub height: f32,
@@ -46,11 +53,28 @@ pub struct WallInput {
#[serde(default, rename = "baseElevation")] #[serde(default, rename = "baseElevation")]
pub base_elevation: f32, pub base_elevation: f32,
/// Albedo-Farbe der Wand (RGB 0..1). Default helles Grau wie die three.js-Sicht. /// 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")] #[serde(default = "default_wall_color")]
pub color: Rgb, pub color: Rgb,
/// Oeffnungen (Tueren/Fenster) entlang der Wandachse. Default leer (volle Wand). /// Oeffnungen (Tueren/Fenster) entlang der Wandachse. Default leer (volle Wand).
#[serde(default)] #[serde(default)]
pub openings: Vec<Opening>, 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 /// 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] [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 /// Projektionsart der Kamera. Die three.js-Sicht schaltet zwischen perspektivisch
/// (freies Orbit) und orthografisch (die achsparallelen Presets front/top/side um). /// (freies Orbit) und orthografisch (die achsparallelen Presets front/top/side um).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -145,7 +237,9 @@ pub struct Camera {
pub far: f32, 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 Grad, wie die three.js-PerspectiveCamera-Voreinstellung.
50.0_f32.to_radians() 50.0_f32.to_radians()
} }
+258 -7
View File
@@ -11,11 +11,16 @@
// up.x, up.y, up.z, perspective, fovY, orthoHalfHeight); // up.x, up.y, up.z, perspective, fovY, orthoHalfHeight);
// r.resize(px_w, px_h); r.render(); // pro Frame // r.resize(px_w, px_h); r.render(); // pro Frame
// //
// KAMERA: die Fassade nimmt eine FERTIGE Kamera (eye/target/up + Projektionsart) // KAMERA: zwei Wege, dieselbe `Camera`. `set_camera` nimmt eine FERTIGE Kamera
// entgegen — Orbit/Pan/Zoom-Interaktion (Yaw/Pitch/Distanz, Praesets) rechnet die // (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
// TS-Seite (dieselbe Konvention wie `math::orbit_eye`/`preset_camera`), damit die // (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention),
// Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgen kann, ohne // damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt.
// eine zweite Praeset-Tabelle in Rust pflegen zu muessen. // `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 // MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts). // 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 web_sys::HtmlCanvasElement;
use crate::gpu::Renderer; 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 /// JSON-Eingabe des Modells: Waende (bereits in Teilquader zerlegt, siehe
/// `projectToWalls3d`) + Deckenplatten. `slabs` optional (leeres Modell ohne /// `projectToWalls3d`) + Deckenplatten. `slabs` optional (leeres Modell ohne
@@ -35,6 +42,45 @@ struct ModelInput {
walls: Vec<WallInput>, walls: Vec<WallInput>,
#[serde(default)] #[serde(default)]
slabs: Vec<SlabInput>, 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` /// Der Browser-Renderer: haelt Surface + Device/Queue + den geteilten `Renderer`
@@ -47,6 +93,63 @@ pub struct WebModelRenderer {
config: wgpu::SurfaceConfiguration, config: wgpu::SurfaceConfiguration,
renderer: Renderer, renderer: Renderer,
camera: Camera, 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] #[wasm_bindgen]
@@ -129,17 +232,64 @@ impl WebModelRenderer {
config, config,
renderer, renderer,
camera: Camera::default(), camera: Camera::default(),
bounds: None,
walls: Vec::new(),
slabs: Vec::new(),
section: None,
}) })
} }
/// Setzt/aktualisiert das darzustellende Modell aus einem JSON-String /// Setzt/aktualisiert das darzustellende Modell aus einem JSON-String
/// (Format = `{ walls: WallInput[], slabs?: SlabInput[] }`, identisch zum /// (Format = `{ walls: WallInput[], slabs?: SlabInput[] }`, identisch zum
/// nativen Push). Laedt die GPU-Puffer neu hoch; ruft NICHT selbst `render`. /// 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> { pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> {
let model: ModelInput = serde_json::from_str(json) let model: ModelInput = serde_json::from_str(json)
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?; .map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
self.bounds = model_bounds(&model.walls, &model.slabs);
self.renderer 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(()) 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). /// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
pub fn resize(&mut self, width: u32, height: u32) { pub fn resize(&mut self, width: u32, height: u32) {
let (w, h) = (width.max(1), height.max(1)); let (w, h) = (width.max(1), height.max(1));
+11 -6
View File
@@ -92,7 +92,7 @@ pub fn push_walls(value: serde_json::Value) {
fn parse_model3d(value: serde_json::Value) -> Result<Model3d, serde_json::Error> { fn parse_model3d(value: serde_json::Value) -> Result<Model3d, serde_json::Error> {
if value.is_array() { if value.is_array() {
let walls = serde_json::from_value::<Vec<WallInput>>(value)?; 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 { } else {
serde_json::from_value::<Model3d>(value) serde_json::from_value::<Model3d>(value)
} }
@@ -232,7 +232,7 @@ use render3d::gpu::Renderer as Renderer3d;
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
use render3d::math::orbit_eye; use render3d::math::orbit_eye;
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
use render3d::types::{Camera, Projection, SlabInput, WallInput}; use render3d::types::{Camera, MeshInput, Projection, SlabInput, WallInput};
#[cfg(feature = "native3d")] #[cfg(feature = "native3d")]
use serde::Deserialize; use serde::Deserialize;
@@ -245,6 +245,10 @@ struct Model3d {
walls: Vec<WallInput>, walls: Vec<WallInput>,
#[serde(default)] #[serde(default)]
slabs: Vec<SlabInput>, 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")] #[cfg(feature = "native3d")]
@@ -262,7 +266,7 @@ impl GpuState3d {
fn new(window: Arc<Window>, model: &Model3d) -> Self { fn new(window: Arc<Window>, model: &Model3d) -> Self {
let (surface, device, queue, config) = configure_surface(&window, "3d.device"); let (surface, device, queue, config) = configure_surface(&window, "3d.device");
let mut renderer = Renderer3d::new(&device, config.format); 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]); renderer.set_light([6.0, 12.0, 4.0]);
Self { surface, device, queue, config, renderer, window } Self { surface, device, queue, config, renderer, window }
} }
@@ -315,12 +319,12 @@ fn load_model() -> Model3d {
Ok(model) => model, Ok(model) => model,
Err(e) => { Err(e) => {
eprintln!("native3d: Modell-Parse-Fehler ({WALLS_PATH}): {e} — nutze Demo-Waende"); 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) => { Err(e) => {
eprintln!("native3d: Modell nicht ladbar ({WALLS_PATH}): {e} — nutze Demo-Waende"); 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, base_elevation: 0.0,
color: grey, color: grey,
openings: vec![], openings: vec![],
layers: None,
}; };
vec![ vec![
mk([0.0, 0.0], [6.0, 0.0]), mk([0.0, 0.0], [6.0, 0.0]),
@@ -715,7 +720,7 @@ impl ApplicationHandler<UserEvent> for App {
self.pending3d = Some(model); self.pending3d = Some(model);
return; 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 { if !self.nav3d {
self.orbit = Some(Orbit::framed(&model)); self.orbit = Some(Orbit::framed(&model));
} }
+831 -167
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -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>
);
}
+65
View File
@@ -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");
});
});
+13
View File
@@ -85,12 +85,25 @@ function ringDrawing(src: Drawing2D, pts: Vec2[], filled: boolean): Drawing2D {
categoryCode: src.categoryCode, categoryCode: src.categoryCode,
geom: { shape: "polyline", pts, closed: true }, 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.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
if (src.color !== undefined) out.color = src.color; if (src.color !== undefined) out.color = src.color;
if (src.weightMm !== undefined) out.weightMm = src.weightMm; if (src.weightMm !== undefined) out.weightMm = src.weightMm;
if (src.strokeWeightSource !== undefined) out.strokeWeightSource = src.strokeWeightSource;
if (filled) { 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.hatchId !== undefined) out.hatchId = src.hatchId;
if (src.fillColor !== undefined) out.fillColor = src.fillColor; 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; return out;
} }
+23
View File
@@ -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
View File
@@ -12,6 +12,7 @@ import type { Plan, Primitive } from "../plan/generatePlan";
import type { LayerCategory, Project, Vec2 } from "../model/types"; import type { LayerCategory, Project, Vec2 } from "../model/types";
import { flattenCategories } from "../model/types"; import { flattenCategories } from "../model/types";
import { DxfWriter } from "./dxfWriter"; import { DxfWriter } from "./dxfWriter";
import { buildRandomHatchRuns, zigzagPoints, motifPoints } from "../plan/glPlan/glPlanHatch";
export interface ExportDxfOptions { export interface ExportDxfOptions {
/** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */ /** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */
@@ -106,7 +107,18 @@ function emitPrimitive(
): void { ): void {
switch (p.kind) { switch (p.kind) {
case "line": 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); dxf.addLine(layer, p.a, p.b);
}
break; break;
case "arc": case "arc":
@@ -121,15 +133,31 @@ function emitPrimitive(
dxf.addPolyline(layer, pts, true); dxf.addPolyline(layer, pts, true);
// Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine // Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine
// sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus. // 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 ( if (
includeHatches && includeHatches &&
p.hatch.kind !== "image" &&
p.hatch.pattern !== "none" && p.hatch.pattern !== "none" &&
p.hatch.pattern !== "solid" 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); dxf.addLine(LAYER_HATCH, seg.a, seg.b);
} }
} }
}
break; break;
} }
} }
@@ -174,6 +202,44 @@ interface Seg {
b: Vec2; 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( function hatchSegments(
poly: Vec2[], poly: Vec2[],
hatch: Extract<Primitive, { kind: "polygon" }>["hatch"], hatch: Extract<Primitive, { kind: "polygon" }>["hatch"],
+7 -6
View File
@@ -64,13 +64,14 @@ export async function buildPlanPdf(
): Promise<jsPDF> { ): Promise<jsPDF> {
const { w: pageW, h: pageH } = pageSizeMm(opts.paper, opts.orientation); const { w: pageW, h: pageH } = pageSizeMm(opts.paper, opts.orientation);
// Render-Szene EXAKT wie der Viewport erzeugen (keine Zusatz-Optionen — // Render-Szene wie der Viewport erzeugen, dann als Papier-SVG serialisieren.
// `planToRenderScene(plan)` ist der vollstaendige Aufruf, siehe // Einziger Unterschied zum Viewport-Aufruf: der echte Export-Massstab wird an
// `useWasmPlanRenderer.ts`), dann als Papier-SVG serialisieren. Etwas mehr // `planToRenderScene` durchgereicht (derselbe `scaleDenominator` wie unten an
// unterer Rand, falls ein Schriftfeld gezeichnet wird (damit es nicht in // `sceneToPrintSvg`), damit der Zeilenabstand mehrzeiliger Stempeltexte auch
// den Plan ragt). // 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 margin = 10;
const scene = planToRenderScene(plan); const scene = planToRenderScene(plan, opts.scaleDenominator);
const print = sceneToPrintSvg(scene, { const print = sceneToPrintSvg(scene, {
scaleDenominator: opts.scaleDenominator, scaleDenominator: opts.scaleDenominator,
pageWidthMm: pageW, pageWidthMm: pageW,
+121
View File
@@ -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