Compare commits

..

143 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
karim 3402e6ac96 HANDOVER: Übergabe an neue Instanz — Nordstern 2/3/4/5 gelandet, Schnitt-UI-Anbindung offen 2026-07-03 18:18:52 +02:00
karim b4c4a2cc6b wgpu 22 → 29, glyphon 0.6 → 0.11: requestDevice-Shim entfällt
glyphon 0.11 ist die neueste zu wgpu 29 passende Version (wgpu 30 existiert
bereits, glyphon pinnt aber ^29). Das 22er-Limit maxInterStageShaderComponents
wird von 29 nicht mehr in requiredLimits gesendet — src/engine/requestDeviceShim.ts
komplett entfernt, beide Hook-Importstellen (useWasmPlanRenderer, useWasm3dRenderer)
angepasst. pollster 0.3→0.4, naga 22→29 mitgezogen. Alle Draw-/Text-Pfade
(draw_sequence, glyphon ColorMode::Web, widthScreen-Polylinien, Headless/Golden)
unverändert funktionsfähig.

Verifiziert: cargo check nativ (render2d/render3d/src-tauri) grün, cargo test
render2d 17/17 + Golden bit-exakt, render3d 29/29, wasm32 --features web für
beide grün, npx tsc -b + npm run build grün.
2026-07-03 18:17:41 +02:00
karim 382771b2e7 Vektor-PDF aus der RenderScene: eine Szene, zwei Targets
exportPdf baut jetzt planToRenderScene(plan) — identischer Aufruf wie der
Engine-Viewport — und serialisiert über den neuen sceneToPrintSvg nach
Papier-mm: z-stabile Reihenfolge wie compile_scene, PEN_STEPS-Quantisierung
wie bisher, widthScreen-Schraffurbreiten via (widthPx/PX_PER_M)*(1000/N)
in mm umgerechnet, Texte neu als echte Vektor-Texte (alter Pfad liess sie
weg). planToPrintSvg bleibt als Referenz, im Kopf als abgeloest markiert.
probe-pdf.mjs: Icon-Button-Selektor nachgezogen, neue Assertions (Schraffur-
Polylinien vorhanden, alle stroke-widths auf PEN_STEPS). Messung: 3666
Vektor-Pfadoperatoren, 7 Text-Operatoren, 0 Rasterbilder; Inhalt per
pdftoppm 53.51x43.69 mm vs. erwartet 53.45x43.45 bei 1:100.
2026-07-03 08:46:35 +02:00
karim a8e491e534 HANDOVER: Stand Nordstern-Session 2026-07-03 (Engine-Meilensteine, offene Agent-Arbeit) 2026-07-03 08:42:27 +02:00
karim 76029dbf68 render3d-Schnitt: Öffnungen — Brüstung/Sturz-Teilpolygone und Durchblick
WallInput bekommt openings (from/to/sill/height, serde-default). Der Cut
durch eine Öffnung liefert mehrere einfache Rechtecke (Brüstung, Sturz,
Leibungen) statt Lochpolygon — kompatibel zu render2d::FillPolygon. Der
Verdeckungstest behandelt Öffnungen als Durchblick (u-Zuordnung exakt im
Elevationsfall, konservativ undurchsichtig bei kantennaher Ausrichtung);
Fensterrahmen-Kanten werden als projizierte Kanten ausgegeben. Tests
26 auf 29, Beweis-SVG mit Fenster + dahinterliegender Wand neu generiert
(sichtbares Band exakt im Fensterausschnitt). mesh.rs (3D-Vollkoerper)
beruecksichtigt Öffnungen weiterhin nicht — dokumentierte Luecke.
2026-07-03 08:36:35 +02:00
karim 4b02e40238 render2d: Headless-Rendering — PNG ohne Fenster + Golden-Image-Test
HeadlessRenderer (Feature headless) baut Instance/Adapter/Device ohne
Surface (Vulkan, kein Display-Server); render_to_image rendert in eine
Rgba8Unorm-Texture (non-sRGB wie ColorMode::Web) und liest mit 256-Byte-
Row-Padding zurueck. Draw-Code unveraendert geteilt mit dem Fenster-Pfad.
CLI-Bin render_png (Demo-Szene, 990x630); Demo additiv um 45-Grad-
Schraffur, Tuerblatt und Schwenkbogen erweitert, damit das Golden alle
Pfade abdeckt. Golden-Test mit eingechecktem Referenzbild: 0/623700 Pixel
Abweichung, bit-exakt reproduzierbar; bei Abweichung Diff-Dump nach
target/. Doku in docs/design/engine-headless.md.
2026-07-03 08:31:38 +02:00
karim ce6bd26ff7 2D-Engine-Parität: WASM-Pfad rendert deckungsgleich zum SVG-Referenzpfad
Sechs Lücken geschlossen: Text-Massstab vom Geometrie-Massstab entkoppelt
(set_text_scale, spiegelt SVG-Referenzskala); glyphon auf ColorMode::Web
(Text war linear-konvertiert zu dunkel); z-basierte Maler-Reihenfolge
(interleavte draw_sequence statt fills-vor-lines, Alt-Szenen unverändert);
CSS-Klassenfarben/-Opacities in toRenderScene gespiegelt (Türschwenk etc.);
greyed-Dimmung 0.3 auf allen Primitiven; Dämmschraffur am Modell-Ursprung
verankert (userSpaceOnUse), exakte Bézier-Wellenform statt Sinus und
kachelgekoppelte Strichbreite (widthScreen-Modus). Probe
scripts/probe-engine-parity.mjs vergleicht ?gl=0 gegen ?engine=wasm;
Rest-Diff nur AA/Glyphen-Rasterung. cargo 17/17, vitest 94/94, Builds grün.
2026-07-03 08:17:01 +02:00
karim 54211b1443 render3d: Schnitt-Modul — Cut-Polygone + sichtbare/verdeckte Kanten aus Prismen
Analytische Eigen-Engine-Alternative zum OCCT-HLR-Spike: Schnittebene
(Punkt+Normale) gegen extrudierte Fussabdruck-Prismen; Cut-Polygone in
(u,v)-Schnittkoordinaten mit Komponenten-Referenz (spaetere Schraffur),
Projektion der dahinterliegenden Kanten mit Verdeckungstest, getrennt
visible/hidden. SectionOutput serde-serialisierbar (Meter). 26 Tests,
Example section_svg schreibt docs/welle-c-hlr-spike/section-engine-proof.svg
(L-Wand+Bodenplatte: Poché, durchgezogene sichtbare, gestrichelte verdeckte
Kanten). Offene Punkte in docs/design/engine-section-pipeline.md.
2026-07-03 08:13:57 +02:00
karim c8a4188618 render3d im Browser: WASM/WebGPU-3D-Viewport hinter ?engine=wasm
Feature web (wasm-bindgen) + cdylib analog render2d; WebModelRenderer
mit Canvas-Surface, set_model (walls/slabs wie der native Push) und
set_camera. Projektion liefert bereits [0,1]-Clip-Z, math.rs unveraendert.
wgpu-22-requestDevice-Shim in src/engine/requestDeviceShim.ts geteilt.
Neuer Hook useWasm3dRenderer + Wasm3DViewport (Orbit/Pan/Zoom wie three.js-
Sicht); Viewport3D dispatcht per ?engine=wasm bzw. localStorage, three.js
bleibt Default. Build-Script build:engine3d (wasm-pack, src/engine/pkg3d).
Verifiziert headful per scripts/probe-engine3d.mjs (37 % Geometrie-Pixel);
headless praesentiert Chromium keine WebGPU-Frames (auch bei render2d).
2026-07-03 07:41:37 +02:00
karim 7ca5197b6b Resource Manager: Materialien-Tab mit PBR-Kugel-Vorschau, Suche und Kategorie-Chips
Geteilter Offscreen-three.js-Renderer (ein Kontext, serielle Queue, Cache
per Map-Signatur) rendert 128px-Kugeln lazy via IntersectionObserver.
Kategorie-Chips aus der Bibliothek abgeleitet, uneinheitliche Manifest-
Schreibweisen normalisiert; Suche und Kategorie kombinierbar. Kachel-Klick
markiert aktiv (gleiche Sprache wie MaterialPicker). Puppeteer-Probe
scripts/probe-material-tiles.mjs prüft Grid, Lazy-Render und Filter.
2026-07-03 02:04:33 +02:00
karim a010584d9b README: kritisch überarbeitet — Parametric Walls, PDF/DXF-Export, Materials-Lib, HLR-Spike-Stand ergänzt; Dev-Port korrigiert 2026-07-03 00:19:54 +02:00
karim e6d061cc91 README: keine Browser-App mehr, sondern Electron-Desktop-Shell mit eigener Engine 2026-07-03 00:10:24 +02:00
karim 0161b0231d HANDOVER: Electron-WebGPU-Befund korrigieren (funktioniert, kein Fallback)
Der vorherige Eintrag ging von einem WebGL2-Fallback aus; genauere Messung
(GPU-Status erst nach Initialisierung abfragen statt sofort bei whenReady)
zeigt: WebGPU/?engine=wasm laeuft in Electron einwandfrei, per Screenshot
verifiziert (RENDERER-Anzeige zeigt Engine aktiv).
2026-07-03 00:00:00 +02:00
karim 34317e53f4 Electron-Prototyp: eigenes App-Fenster statt Tauri/WebKitGTK
Ersetzt den Tauri-Webview-Weg testweise durch eine Electron-Shell (randlos,
ohne native Menüleiste, wie chromium-shell.sh), damit die WASM/WebGPU-Engine
zuverlässig laeuft statt in WebKitGTK. Rust-Backend nicht noetig, da
compute_joins einen TS-Fallback hat. npm run electron zum Starten.
2026-07-02 23:51:23 +02:00
karim 27e41077b1 Renderer-Umschalter: Wahl in localStorage persistieren (uebersteht Neustart ohne URL-Param) 2026-07-02 23:38:05 +02:00
karim a37b1c0cc9 UI: Renderer-Umschalter (WebGL2 / eigene Engine) in der Statusleiste 2026-07-02 23:35:01 +02:00
karim 4eb635cc89 WASM-Viewport: SVG-Raumtext bei aktiver Engine unterdruecken (kein Doppeltext) 2026-07-02 23:28:25 +02:00
karim 4734c04ec0 HANDOVER: naechster Schritt = Electron-Prototyp (Rezept fuer neue Session) 2026-07-02 23:18:27 +02:00
karim 2f971a54c0 HANDOVER: Richtungsentscheidung Electron/Chromium+WASM statt Tauri; all-native als Fernziel 2026-07-02 23:02:35 +02:00
karim 8cfe6c2521 HANDOVER: Engine-Nordstern — exakte Breiten, Druck=Bildschirm, Headless, HLR, WebKit-Unabhaengigkeit 2026-07-02 22:31:27 +02:00
karim 665cbce1f9 render2d im Browser: WASM/WebGPU-Viewport hinter ?engine=wasm
Integrations-Spike: die native 2D-Engine (src-tauri/render2d) laeuft jetzt als
WASM/WebGPU-Canvas in der App-UI, mit derselben Szene (planToRenderScene) und
ViewBox wie der WebGL2-Pfad; das SVG-Overlay (Text/Griffe/Auswahl) bleibt drueber.

- render2d: neues Feature "web" (wasm-bindgen/web-sys), Fassade WebPlanRenderer
  (new/set_scene/set_view_box/set_paper_scale/resize/render) auf Canvas-Surface.
  Geteilte GPU-Schicht (gpu.rs), native Fensterschicht (winit) unberuehrt.
- Font: cosmic-text findet auf wasm keine Systemfonts -> Inter-Bytes eingebettet
  (assets/Inter.ttf), Renderer.load_font + text_family aus der geladenen Familie.
- Frontend: useWasmPlanRenderer (gleiche Schnittstelle wie useGlPlanRenderer);
  PlanView waehlt die Engine per ?engine=wasm + navigator.gpu, sonst WebGL2/SVG.
- Build: npm-Script build:engine (wasm-pack, target web -> src/engine/pkg,
  gitignored). chromium-shell.sh mit WebGPU-Flags (--enable-unsafe-webgpu/Vulkan).
- Kompat-Shim: wgpu 22 sendet das spec-entfernte Limit maxInterStageShaderComponents,
  das neuere Chromium ablehnen -> im Hook vor requestDevice entfernt.

Verifiziert: cargo test -p render2d gruen (18), tsc gruen, wasm-Build gruen;
Headless-Chromium (?engine=wasm) zeichnet den Grundriss (Waende/Schraffur/
Tuerschwenk/Treppe/Raumtext) im WASM-Viewport, Fallback ohne Flag unveraendert.
2026-07-02 22:30:47 +02:00
karim 6eead6d493 2D-Bögen analytisch: exakter Kreis-Shader statt Segment-Tessellierung
Bögen im nativen 2D-wgpu-Renderer werden nicht mehr zoomabhängig in Segmente
zerlegt, sondern per SDF-Fragment-Shader (ARC_WGSL) mathematisch exakt rund
gerendert — bei jeder Zoomstufe ein echter Kreis, kein Vieleck, ohne
Neu-Tessellierung.

- compile_scene sammelt je Bogen EINE analytische Instanz (ArcInstanceData,
  Bildschirm-Raum-Parameter + Dash in Modell-Metern), zoom-invariant.
- Eigene Arc-Pipeline (ein Frame-Uniform, Quad je Instanz aus vertex_index):
  radiale Kante, Butt-Cap-Winkelclamp (beide Sweep-Vorzeichen) und Dash
  (Bogenlänge modulo Muster) analytisch antialiased; Strichbreite mit derselben
  mm->px-Formel wie die Linien.
- tessellate_arc + Zoom-Retessellierungs-Cache (last_scene/arc_px_per_m/
  maybe_retessellate) entfernt; upload_scene ohne px_per_m.
- Tests auf die neue Semantik umgeschrieben (Winkel-Parität, Bounding-Box,
  Dash-Mapping), ARC_WGSL per naga validiert.
2026-07-02 22:07:18 +02:00
karim 7b9f72e79f 2D-Plan: Strichmuster (Umriss/Linie/Bogen) im nativen wgpu-Renderer zeichnen
Papier-mm-Strichmuster (dash) wurden bisher komplett ignoriert und immer
durchgezogen gerendert — im nativen 2D-Fenster gab es dafür bislang gar keinen
Mechanismus. `split_dash` portiert den bereits für Schraffuren bewährten
`applyDashRuns`-Algorithmus (glPlanHatch.ts) nach Rust und zerlegt einen
Linienzug in seine "an"-Teilstücke; die Phase läuft dabei über bereits
verkettete Zeichnungszüge UND über die neu adaptiv tessellierten Bögen
hinweg durch, sodass z. B. der gestrichelte Türschwenk-Bogen gleichmäßig
gestrichelt bleibt statt an jedem Segment neu anzusetzen.
2026-07-02 21:45:57 +02:00
karim cd0f976b4f 2D-Plan-Qualität: Türschwenk-Bögen adaptiv rund tessellieren (nativer wgpu-Renderer)
Bögen (Türschwenke) wurden bisher einmalig in JS in eine feste Facettenzahl
zerlegt; beim Hineinzoomen wurden die Facetten sichtbar. Die Zerlegung
(`tessellate_arc`) wandert nach Rust und läuft jetzt zoomabhängig anhand einer
Sehnenabweichungs-Toleranz (Sagitta ≤0.3 Geräte-px), Segmentzahl auf 8..512
geklemmt. Die native GPU-Szene bekommt dafür einen eigenen `Arc`-Primitiv-Typ
(unvortessellliert); der Renderer merkt sich die zuletzt hochgeladene Szene und
tessellliert Bögen automatisch neu, sobald sich der Zoom seit dem letzten
Upload um mehr als Faktor 1.3 verändert hat.
2026-07-02 21:43:55 +02:00
karim 919bc67bfa 3D: Geschossdecken als extrudierte Polygone + hemisphärisches Licht
Deckenplatten (SlabInput) werden im render3d aus dem Grundriss-Umriss per
Ear-Clipping trianguliert und über die Deckendicke extrudiert (Deckel/Boden/
Mantel mit robust nach außen orientierten Normalen). Payload erweitert auf
{ walls, slabs } — blanke Wand-Arrays bleiben kompatibel. Beleuchtung auf
hemisphärisches Ambient (Himmel/Boden) + Directional-Sonne umgestellt, dezente
Kantenbetonung, hellerer Hintergrund (#f5f5f5). Beispiel-Geschossdecke im EG.
2026-07-02 21:08:34 +02:00
karim ec5cbf6fa0 3D-Politur: ACES-Tonemapping, PMREM-Environment, Standard-Materialien
Viewport3D nutzt jetzt ACESFilmicToneMapping + sRGB-Ausgabe (weiche
Lichter statt hartem Clipping) und eine neutrale Raum-Umgebung
(PMREMGenerator + RoomEnvironment) als IBL-Quelle für plausible
Reflexionen. Wände/Decken/Öffnungsrahmen/Glas/Türblatt/Treppen und das
Auswahl-Highlight laufen von MeshLambertMaterial auf MeshStandardMaterial
um (Rauigkeit/Metallizität je Bauteilart, moderate envMapIntensity);
direktes Licht entsprechend zurückgenommen, da die Umgebung nun mit-
trägt. Hintergrundfarbe, Hidden-Line- und Clay-Modus unverändert.
2026-07-02 21:04:22 +02:00
karim f1b802299f 3D-Wände mit Öffnungen: Türen/Fenster als Teilquader (Pfeiler+Brüstung+Sturz) 2026-07-02 21:00:19 +02:00
karim 0337422ae6 Echte Fonts im nativen 2D: glyphon-Glyphenatlas statt Browser-Overlay
Raumstempel-Text rendert jetzt im nativen wgpu-Fenster mit echten
TrueType-Glyphen (Inter, Systemfonts via cosmic-text) ueber einen
GPU-Glyphen-Atlas — im selben 4x-MSAA-Pass nach der Geometrie.
Schriftgroesse in Papier-mm (gleiche Formel wie Strichbreiten, skaliert
mit dem Zoom); die Bridge flacht Rich-Text-Stempel + Zusatzzeilen
zeilenweise auf serde-kompatible texts ab (Layout wie der SVG-Pfad).
2026-07-02 20:28:40 +02:00
karim d39244989b 2D-Umrisse polygonuebergreifend naehen: Gehrung an Wand-zu-Wand-Ecken 2026-07-02 20:12:29 +02:00
karim 01424c1e22 Natives 2D/3D live: Webview pusht Szene/Waende per Tauri-Command
Die nativen wgpu-Fenster spiegeln jetzt das LIVE-Modell statt des
eingefrorenen JSON-Snapshots: die Webview schiebt bei jeder Aenderung
(debounced 200 ms) den sichtbaren Plan (planToRenderScene) bzw. die
Projekt-Waende (projectToWalls3d) per push_native_scene/push_native_walls
an einen EventLoopProxy<UserEvent> der winit-Loop. Ansicht wird nur neu
eingepasst, solange im Fenster noch nicht gepannt/gezoomt/orbitiert
wurde; die JSON-Assets bleiben Start-Fallback. Ohne Tauri: No-op.
2026-07-02 20:10:38 +02:00
karim 1811e82005 Chromium-App-Shell: fluessiger Launcher fuer die CAD-Oberflaeche
npm run shell startet den Vite-Dev-Server bei Bedarf und oeffnet ihn in
einem randlosen Chromium-Fenster, um den WebKitGTK-Cairo-Flaschenhals
von tauri:dev zu umgehen.
2026-07-02 19:54:11 +02:00
karim 0a1aaedef8 render2d: 4x MSAA fuer glatte Linien (wie im Browser)
Beide Pipelines (Fill/Line) rendern mit sample_count=4 in eine lazily verwaltete
Multisample-Textur (ensure_msaa, Muster analog render3d::ensure_depth) und
resolven in die Surface-View. Kantige Haarlinien im nativen 2D-Viewport werden
dadurch knackscharf; Renderer-API unveraendert.
2026-07-02 19:44:14 +02:00
karim 483ad3f278 Nativer 2D-Viewport: Raumstempel-Text wieder entfernen
Eine Einstrich-Vektorschrift sieht neben dem echten Browser-Font schlecht aus;
Text bleibt dem Browser-/Overlay-Pfad ueberlassen. strokeFont.ts entfernt,
toRenderScene ueberspringt Text-Primitive wieder. Schraffuren/Poche/Ecken bleiben.
2026-07-02 19:40:19 +02:00
karim d27622d581 Nativer 2D-Viewport: Raumstempel-Text (Einstrich-Vektorschrift)
Neues strokeFont.ts (kompakte Einstrich-Schrift: A-Z, 0-9, Symbole inkl. ²/·).
toRenderScene wickelt Text-Primitive (Doc-Zeilen + Live-Zusatzzeilen) zu Glyph-
Polylinien in Modell-Metern ab (vertikal um den Anker zentriert, Ausrichtung je
Absatz) und speist sie wie die Schraffuren in die render2d-Szene. Erste Fassung
in Versalien; render2d bleibt textrenderer-frei. Damit zeigt der native Grundriss
den Raumstempel (Name/Fläche/SIA) analog zum Browser.
2026-07-02 19:05:01 +02:00
karim f315c80748 Nativer 2D-Viewport: Schraffuren wie im Browser
toRenderScene emittiert jetzt die aufs Polygon geclippten Musterlinien (glPlanHatch:
buildHatchRuns + applyDashRuns) als Polylinien — Daemmung/Diagonal/Kreuz erscheinen
im nativen wgpu-Grundriss identisch zum WebGL-Pfad. 'none'/'solid' brauchen keine
Linien (solid deckt die Fuellung farbig ab).
2026-07-02 18:58:13 +02:00
karim bda256d3a4 Native wgpu-Viewports (2D+3D) im Tauri-Prozess: echtes Modell + gehrte Ecken
- native.rs: EINE winit-EventLoop hostet 2D- und 3D-Fenster (winit erlaubt nur
  eine Loop pro Prozess) — loest den RecreationAttempt-Panic zweier Loops; ersetzt
  native2d.rs/native3d.rs. Feature-gegated (native2d/native3d, einzeln oder zusammen).
- render2d/render3d laden das ECHTE Modell aus assets/native2d_scene.json bzw.
  native3d_walls.json (Demo-Szene als Fallback); initialer Ausschnitt/Kamera aus
  den Modell-Grenzen gerahmt, initialer Redraw + gesetzte Fenstergroesse.
- TS-Konverter toRenderScene/toWalls3d + scripts/dump-native-scene erzeugen die
  JSON aus sampleProject/generatePlan (npm run dump:native).
- render2d: Scene.polylines fuer zusammenhaengende Umriss-/Zeichnungslaeufe →
  Gehrung statt Stumpfkappen an Wandecken/2D-Geometrien; MITER_LIMIT 4→8
  (deckungsgleich mit SVG stroke-miterlimit:8 und WebGL2).
- native3d-Feature + render3d-Pfad-Dep in der Tauri-Crate.
2026-07-02 08:54:26 +02:00
karim c93b168f5c Add native2d feature: wgpu 2D viewport window inside Tauri process
Spawns a GTK-free winit window with its own wgpu surface from Tauri's
setup hook on a background thread, sidestepping the WebKitGTK surface
contention on Wayland. Reuses render2d's renderer via a shared demo
module. Opt-in behind the native2d cargo feature; default build
unaffected.
2026-07-02 01:26:07 +02:00
karim a971cd610b 2D-Plan-Qualitaet: saubere Wandecken + glatte Dämmschraffur
Wandecken (generatePlan/glPlanCompile/PlanView): die diagonale Miter-Stirnkante
am L-Stoss wurde als Umriss gestrokt -> Barb-Ueberstand am Aussenapex + 45deg-
Naht zwischen gleichen Schichten. Fix: interne Join-Stirnkanten per neuem
noStrokeEdges vom Umriss ausnehmen (Fuellung bleibt volle Flaeche, laengs
laufende Materialfugen bleiben). Ecke = sauberer Miter ohne Ueberstand, gleiche
Schichten verschmelzen nahtlos ueber die Ecke. GPU- und SVG-Pfad teilen sich
noStrokeEdges.

Dämmschraffur (glPlanHatch): die Sinuswelle wurde pro Segment einzeln aufs
Polygon geclippt und mit Butt-Caps gestrokt -> fransige Fragmente an jeder
Wellenbiegung. Fix: kontinuierliche Punkt-Laeufe (clipPolylineToPolygon) als EIN
gehrter Streifen; Dash laeuft ueber die Laeufe. STEPS 12->22. Gerade Scharen
(diagonal/crosshatch) unveraendert.
2026-07-02 00:54:41 +02:00
karim 48bbcaed1e Nativer 3D-wgpu-Renderer (render3d, M0+M1): Wand-Extrusion, Kamera, Licht
Eigenstaendige Crate wie render2d (render/window-Stufung, serde-only Mesh-
schicht headless testbar). Wand-Extrusion (Band via Links-Normale, Quader mit
nach aussen zeigenden Normalen), handgerechnete Mat4 (perspektiv+ortho, wgpu-
Clip-Z [0,1], 5 Kamera-Presets), Directional-Light + Tiefenpuffer + Backface-
Culling. Orbit-Spike (cargo run --features window --bin spike3d). Plus Port-
Briefing mit M2..M9-Milestones (three.js-Viewport-Bestandsaufnahme).
2026-07-02 00:29:02 +02:00
karim 6d4b4e66c1 GPU-Schraffuren im WebGL2-Grundriss: diagonal/crosshatch/insulation + Dash
Muster in Modell-Metern erzeugt (massstabstreu wie SVG-<pattern>), konkav-
faehig auf das Fuellpolygon geclippt (even-odd-Scanline, halb-offene Kanten-
Konvention). Strichbreite in Papier-mm; Dash geometrisch aufgeloest. Emittiert
nach der Fuellung, vor dem Umriss (Stapelreihenfolge wie SVG). 12 Unit-Tests.
2026-07-02 00:28:11 +02:00
karim 27bb5a42ec Nativer 2D-wgpu-Renderer (render2d): Ear-Clipping-Fuellungen, gehrte Papier-mm-Linien, GPU-Pan/Zoom
Eigenstaendige Crate (render/window-Feature-Stufung), serde-only Tessellier-
schicht headless testbar. Linien als EIN gehrter Streifen (Miter-Bisektor +
1/cos-Laengenfaktor) statt Butt-Cap-Quads pro Segment -> saubere Ecken.
Standalone-Spike-Fenster via winit (cargo run --features window --bin spike).
2026-07-02 00:27:46 +02:00
karim 8fd8987b70 2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
2026-07-02 00:12:39 +02:00
karim 7b3b597abc Add parametric walls unit tests
50 Vitest-Tests für die parametrische Wand-Engine (src/model/parametricWalls.ts):
GridRule, ModuleRule, ConditionalThicknessRule, ReferenceLineRule, SequenceRule,
deduplicateWalls, matchesCondition/matchesTarget, Integration via resolveParametricWall.
Vitest als devDependency + Testskript in package.json + vitest.config.ts ergänzt.
2026-07-01 20:33:38 +02:00
karim 4a08161e4b Add parametric walls design documentation 2026-07-01 20:32:07 +02:00
karim 39236c65c7 Add parametric wall types and engine skeleton 2026-07-01 20:06:19 +02:00
karim b9dc1838c7 HANDOVER: Koordinations- und Memory-Regeln fuer mehrere Instanzen/Agents ergaenzt 2026-06-30 21:45:10 +02:00
83 changed files with 815 additions and 13291 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ Rendern angewandt, nie in die Geometrie eingebacken.
## Arbeitsweise (für Beiträge)
- Substanzielle, mehrstufige Arbeit schrittweise in isolierten Schritten angehen.
- Substanzielle, mehrstufige Arbeit an **Subagenten** delegieren, wo möglich.
- Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via
`node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
+31 -33
View File
@@ -2,35 +2,6 @@
Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen.
> **➡️ Aufgaben-Queue steht in `PENDENZEN.md`** (Single Source of Truth, priorisierte Checkliste + Arbeitsprotokoll). HANDOVER = nur noch Kontext/Konventionen/Environment/Zielmodelle, KEIN Backlog mehr. Neue Pendenzen/Rückfragen dort eintragen, nicht hier.
## ⚠️ NACHTRAG 2026-07-04 abends — Unterbruch, 3 Slices verloren
> **STAND-UPDATE (Session 4, neues Gerät):** **Environment behoben** — Toolchain vorhanden (`node`/`npm`/`cargo` unter `/usr/bin`, `wasm-pack` via `npx` 0.15.0; es war die Flatpak-Sandbox); fehlendes Rust-Target per `sudo dnf install rust-std-static-wasm32-unknown-unknown` (+ lld) nachinstalliert; beide WASM-Engines gebaut → App läuft. **Slice 1 (Locked-Iso `cb8fae5`) + Slice 2 (Joins Phase 1c `c5a344d`) committet.** **Slice 3 (Öffnungen als Boolean-Löcher + Deckentrim-nur-3D) ✅ committet `1407c68`:** Fenster/Türen = 1 Wandkörper pro Schicht-Band mit rechteckigen `holes` (render3d stanzt sie per achsparalleler Rechteck-Gitter-Zerlegung + 4 Laibungsquads/Loch), Segment-Boxen weg; `holes`/`openings` schliessen sich im Emitter aus; Schnitt-Pfad (layered=false) segmentiert unverändert weiter. Deckentrim `trimWallTopForCeilings` gilt nur noch im 3D-Pfad, Schnitt-Pfad bekommt volle Wandhöhe. Alle Datei-Edits waren gelandet — unabhängig verifiziert: cargo test 56 (inkl. beider Pflicht-Tests versetzt-überlappende Fenster + Tür-Loch-bis-Boden), vitest 230, build:engine3d + tsc sauber, Trace clean. **Damit sind alle drei verlorenen 3b-Slices rekonstruiert.** **Memory-Direktiven `priority-based-joins.md` + `openings-as-booleans.md` wiederhergestellt.**
>
> **⚠️ PUSH BLOCKIERT:** keine Git-Credentials auf diesem Gerät (kein Helper, keine `~/.git-credentials`, kein SSH-Key; Remote HTTPS `git.openbureau.ch/karim/DOSSIER-STANDALONE`). `cb8fae5` + `c5a344d` (+ folgender Öffnungen-Commit) sind **lokal committet, NICHT gepusht** (`ahead of origin/master`). Sobald Gitea-PAT (repo-write) oder SSH-Key da ist: `git push origin master`. Bis dahin holt das andere Gerät die Arbeit nicht.
>
> Der folgende Block bleibt als historischer Kontext/Vorlage für den offenen Slice 3 stehen.
Die Arbeit wurde unerwartet unterbrochen — mehrere Tasks liefen parallel. Diese Übergabe wurde nachträglich rekonstruiert. **Der tatsächliche HEAD-Commit ist `03f0c40`** (Nordstern-3D Live-Schnittebene) — alles danach existierte nur im Arbeitsbaum der alten Maschine und ist auf **diesem** Checkout (neues Gerät, `git status` sauber bis auf `package-lock.json`) **nicht vorhanden**. Diese drei Slices waren in echter Arbeit (nicht nur geplant) und gelten als verloren — neu beauftragen, nicht nur „prüfen ob gelandet":
1. **Locked-Iso-Fix** (`src/viewport/Wasm3DViewport.tsx`, `OrbitState` um `ortho: boolean` + `orthoHalfHeight: number` erweitern, `orbitCamera()`/Preset-Effect/Pan/Zoom entsprechend anpassen) — Bug: Iso-Ansicht kippt bei der ersten Kamerabewegung sofort in Perspektive statt orthografisch (parallel) zu bleiben. War fertig gebaut und quantitativ verifiziert (Parallelogramm-Kantenvergleich vor/nach Orbit, 225/225 Tests grün), aber **nicht mehr committet**, bevor der Unterbruch kam. Kleinster, unabhängigster der drei Fixes — zuerst neu machen.
2. **Joins Phase 1c — Durchgangswand echt aufbrechen** (`spanCutouts` in `src/model/joins.ts` + `src/plan/generatePlan.ts` + Rust-Parität `src-tauri/geometry/src/lib.rs`): Nutzer-Befund nach Phase 1b war, dass der Innenputz der DURCHGANGSWAND am T-Stoss nur überdeckt wird (Zeichenreihenfolge), nicht geometrisch ausgeschnitten — Fugen-/Nahflächenlinien laufen noch durch den Backstein-Durchstoss, es „liest" noch nicht wie ein einziger Join. Das war die **explizite Priorität des Nutzers** für diese Session. Die Umsetzung war weit fortgeschritten (schrieb bereits in `joins.ts`), dann abgebrochen. **Nicht committet, nicht im Baum.** Vollständige Aufgabenbeschreibung mit allen Datei:Zeile-Ankern steht im Transkript bei „Agent:Phase 1c: Durchgangswand aufbrechen" — 1:1 als Vorlage für den Neu-Auftrag wiederverwenden.
3. **Öffnungen als echte Boolean-Löcher** (`RWall.holes`, `src/plan/toWalls3d.ts`, `src-tauri/render3d/src/mesh.rs`, additiv `types.rs`): EIN Wandkörper mit rechteckigen Löchern statt der heutigen Pfeiler/Brüstung/Sturz-Ersatzkörper (sichtbare Segment-Nähte, keine echten Löcher). Pflicht-Testfall (Nutzer-Direktive, Kernmotivation des Slices): zwei Fenster in derselben Wand mit überlappendem Achsen-Intervall, aber unterschiedlicher Höhe (z. B. u=[1.0,2.0]/z=[0.9,2.1] und u=[1.5,2.5]/z=[0.3,0.7]) — die alte Segment-Zerlegung kann das nicht abbilden, echte Löcher schon. Zusatz-Auftrag im selben Slice: `trimWallTopForCeilings` (Phase-3-Deckentrim, Commit `e4b8df6`) darf **nur** im 3D-Viewer-Pfad (`layeredWalls:true`) gelten, **nicht** im Schnitt-Pfad (`layeredWalls:false`) — der Schnitt braucht die volle, ungetrimmte Wandhöhe für seine eigene schichtweise Prioritäts-Subtraktion (`subtractDominantBands`), sonst zeigt der Schnitt die Wände fälschlich abgeschnitten. Fortschritt bei Abbruch **unklar** (letztes Status-Update deutete auf „noch in der Kartierungsphase" hin) — vermutlich am wenigsten weit von den dreien; komplett neu beauftragen. Vollständiger Auftragstext im Transkript bei „Agent:Oeffnungen als Boolean-Loecher" + die zwei Zusatz-Direktiven (Decken-Trim-Fix, Pflicht-Testfall versetzte Fenster) kurz danach.
**Zwei Engine-Direktiven vom alten Stand fehlen** (`priority-based-joins.md`, `openings-as-booleans.md`): die in Punkt 2/3 beschriebenen Zielverhalten waren gesichert (u. a. „Trennlinien an Merge-Kontakten verschwinden immer", „Öffnungen dürfen nie wieder zu Segmenten vereinfacht werden"). Beim Neu-Beauftragen diese Direktiven wieder explizit festhalten.
**Environment-Lücke auf diesem Gerät:** `node`/`npm`/`cargo`/`wasm-pack` waren zum Zeitpunkt dieser Übergabe **nicht auf dem PATH** (geprüft: kein Treffer, auch nicht unter der VSCodium-Flatpak-Datenlage). **Vermutliche Ursache: VSCodium lief als Flatpak** — Flatpak-Sandboxing blendet system-installierte Toolchains (Node/Rust unter `/usr/...` oder `~/.cargo`) typischerweise aus, auch wenn sie auf dem Host tatsächlich installiert sind. Der Nutzer wechselt gerade auf **natives VSCodium** (kein Flatpak mehr) — das könnte die Lücke von selbst beheben, MUSS aber nach dem Wechsel neu geprüft werden (`which node npm cargo wasm-pack`), bevor man von "Toolchain fehlt" auf "Toolchain neu installieren" schließt. Erst wenn nach dem Wechsel auf natives VSCodium immer noch nichts gefunden wird, wirklich neu installieren (Node LTS + Rust via rustup + `wasm-pack`, `npm install`, einmal `cargo build --manifest-path src-tauri/render3d/Cargo.toml` durchlaufen lassen). Ohne Toolchain ist die in `CONVENTIONS.md` vorgeschriebene Verifikation (`tsc`/`vitest`/`cargo test`/`wasm-pack`) nicht möglich — das zuerst klären, bevor einer der drei Slices neu beauftragt wird, sonst baut ein Agent blind ohne Verifikation.
**Aufräum-Hinweis:** `Edit MEMORY md Added 1.txt` (lag unversioniert im Repo-Root) und die lokale `package-lock.json`-Änderung waren dirty/untracked. Die txt-Datei gehört nicht ins Repo — außerhalb verschieben oder löschen. `package-lock.json` prüfen (`git diff package-lock.json`) und nur committen, falls die Dependency-Änderung beabsichtigt ist.
### Empfohlene Reihenfolge zum Wiederaufsetzen
1. Nach dem Wechsel auf natives VSCodium zuerst `which node npm cargo wasm-pack` prüfen — evtl. war es nur die Flatpak-Sandbox, die die Toolchain versteckt hat. Nur falls immer noch nichts gefunden wird: Node + Rust + wasm-pack installieren + `npm install`. Blocker für alles Weitere.
2. Locked-Iso-Fix neu bauen (klein, unabhängig, Spezifikation im Transkript vollständig vorhanden).
3. Joins Phase 1c (Nutzer-Priorität dieser Session) — Direktive zuerst wieder ins Memory schreiben, dann Agent mit dem Auftragstext aus dem Transkript beauftragen.
4. Öffnungen als Boolean-Löcher + Deckentrim-nur-3D-Zusatz (dieselbe Rust-Crate wie Phase 1c — nacheinander, nicht parallel, wegen Datei-Overlap in `mesh.rs`/`toWalls3d.ts`).
5. Repo aufräumen (txt-Datei raus, `package-lock.json` klären), danach diesen Abschnitt auf „erledigt" aktualisieren.
## NACHTRAG Session 3 (2026-07-04 mittags) — Prioritaets-Joins + Schnitt-Sanierung
**Prioritäts-Join-Epic (Kern-Direktive, siehe unten „Verschneidungs-Zielmodell"):** Joins werden nach `Component.joinPriority` materialbewusst aufgelöst — konsistent in Grundriss, Schnitt UND 3D. Gelandet:
@@ -75,17 +46,44 @@ Die Arbeit wurde unerwartet unterbrochen — mehrere Tasks liefen parallel. Dies
WASM-Workflow: `src/engine/pkg3d/` ist gitignore't → nach Rust-Änderung `npm run build:engine3d`, nur die `.rs` committen. DOSSIER-Audit-Details jetzt in `docs/design/dossier-feature-audit.md` (A1A6/B1B4/C/D/E) + `ROADMAP.md` §11.
## Konventionen (ZWINGEND — zuerst lesen)
- **Keine Fremd-Tool-Spuren** im Repo: vor jedem Commit Trace-Scan (`git diff | grep -iE "co-authored|generated with"`) → muss leer sein. Commits deutsch, sachlich, KEINE Co-Authored-By-Trailer.
- **Keine KI-/Assistant-Spuren** im Repo: vor jedem Commit `git diff | grep -iE "claude|anthropic|opus|sonnet|generated with|co-authored|gpt|openai|assistant"` → muss leer sein. Commits deutsch, sachlich, KEINE Co-Authored-By-Trailer.
- **NICHT ANFASSEN (fremde WIP):** `.gitignore`, `public/assets/materials/manifest.json`, `src/materials/library.ts`, `scripts/fetch-materials.mjs` — bleiben dauerhaft dirty, nie stagen.
- **Verifikation:** `tsc --noEmit` clean + `vitest run` + trace-scan vor jedem Commit. Nur Task-Dateien stagen (Kollisionen: parallele Tasks disjunkte Datei-Lanes geben; i18n exklusiv einem Task).
- **Agenten-Regeln:** Subagenten default **Sonnet** (Nutzer-Wunsch 2026-07-04); Opus nur bei intricater Geometrie. Agenten dürfen NICHT weiterdelegieren (Agent/Task-Tool verbieten — sonst Spawn-Schleifen), NICHT committen, keine Memory. Hauptinstanz verifiziert (`tsc --noEmit` clean + `vitest run`) + trace-scan, committet dann selbst, staged nur die Task-Dateien (Kollisionen: parallelen Agenten disjunkte Datei-Lanes geben; i18n exklusiv einem Agenten).
- **Budget:** Session-Ende bei 98% — Sparflamme.
- **Engine = „Nordstern"** (render2d/render3d/WASM). Referenz DOSSIER-Rhino: `https://git.openbureau.ch/karim/dossier` (Alias git.kgva.ch), geklont `/tmp/dossier-ref`.
## In dieser Session gelandet (Auswahl Commits)
Schraffur/Linien-Epic komplett: Typmodell (vector/image Hatch, dash/zigzag/custom Line, Component Vordergrund/Hintergrund), ResourceManager **Master-Detail** (Schraffuren/Linien/Wandstile/Deckenstile, `9695748`), Rendering neuer Typen (Bild-`<pattern>`, random modellraum-verankert, Zickzack, Custom-Motiv-Editor `src/ui/MotifEditor.tsx`), modulares Linien-Segment-System (Strich/Punkt/Lücke), Attribut Vordergrund/Hintergrund + **By-Layer/By-Object-Quellen** (Nach Ebene/Bauteil/eigener Wert für Vordergrund/Hintergrund/Strichstärke/Schraffur, `d02781e`).
Weiter: Gehrung spitze Winkel (2D+Schnitt+Rust), TopBar/Footer-Reorg, Zoom-% am Massstab, Tool-Shortcuts 1..0, Verschiebe-Dreieck flacher + Snap, Statusleiste „Nordstern", ResourceManager als **floating nicht-modales Fenster**, **swisstopo** Gebäude(extrudiert)+Terrain-Mesh in 3D (`f22970f`), Schnitt-Wandschicht-Orientierung (`7569524`), **Schnitt-Boolean-Dominanz** nach joinPriority (`23d81be`).
## Backlog & Rückfragen → `PENDENZEN.md`
Der frühere „Offener Backlog"/„Offene Rückfragen"/„IN FLIGHT"-Abschnitt ist vollständig nach `PENDENZEN.md` migriert (Single Source of Truth). Dort steht auch das Arbeitsprotokoll.
## IN FLIGHT bei Session-Ende (PRÜFEN!)
- **Snap an Wand-Schichttrennlinien** (Agent Sonnet): sollte `src/tools/snapping.ts` (+ ggf. `src/model/geometry.ts` + Test) geändert haben. `git status` prüfen — falls dirty: `tsc`+`vitest`+trace, committen. Falls nicht gelandet: neu beauftragen (Schicht-Offsetlinien aus `addWallPoche`-Logik in generatePlan spiegeln [nur LESEN], als Snap-Targets in snapping.ts einspeisen).
## Offener Backlog (Priorität grob absteigend)
1. **Zu verifizieren/klären**:
- Isometrie — „echte" orthographische Isometrie? (nicht perspektivisch; alle Längen gleich dargestellt?) — Status unklar.
- TopBar Detailgrad — zoom% + zoom/scale komplett aus Footer? Details der Reorg prüfen.
- DOSSIER Audit — welche Features konkret übernommen (über AUDIT Punkt 9 hinaus)?
2. **Ebene-Schraffur editierbar**: „Nach Ebene"-Schraffur ist in der Resolve fertig, aber `LayerCategory.hatch` ist im Kategorie-Dialog (App.tsx ~3350, editiert nur name/color/lw) noch nicht editierbar. Kleine Folge-Arbeit.
2. **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
- Reale Höhen + **Projekt-MüM**: Gebäude/Terrain sind auf z=0 (Terrain zentriert, Gebäude-Basis z=0). Nutzer will Projekt-Setting für MüM (EG-Referenzhöhe), Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert.
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + eine **Gelände-Ebene**; Kontext liegt in `project.context` (siteSlice) ohne floorId/categoryCode.
- **Nordstern-Geo-Rendering**: importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh` in Viewport3D) auch in `projectToModel3d` einspeisen — Nordstern zeigt sie sonst nicht.
- **3D-Mesh-DXF/DWG-Import** für präzise Gebäude (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
3. **ResourceManager Bauteile-Tab** auf Master-Detail (wie Wandstile) — BEVOR Bauteil-Logik angefasst wird.
4. **Einstellungs-Fenster**: Auswahlrahmen Default **Yuyake #CEB188**; Snap/Endpunkt Default = RÜCKFRAGE **„aki"** (nicht in Accents: Ajisai/Sakura/Suna/Ichigo/Yuyake/Sora/Kusa/Kori/Amagumo/Yuki); freier Picker + Accents-Presets; verdrahten in PlanView (Selektionsrahmen + Snap-Marker). Dazu Projekt-MüM-Feld.
5. **Bildschraffur**: ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
6. **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
7. **Linienstile aufräumen** (Weight-only-Stile → „Volllinie", lineStyleId-Referenzen remappen).
8. 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
9. **AUDIT (DOSSIER-Studie)**: A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch; A6 Bauteil-Schedule-CSV. Belege in `/tmp/dossier-ref/rhino/*.py`.
10. Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
## Offene Rückfragen
- **„aki"** = welcher Accent für Snap/Endpunkt-Farbe.
- Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT).
- Geo-Block: Projekt-MüM zuerst oder Nordstern-Geo-Rendering.
## Verifikation
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
-142
View File
@@ -1,142 +0,0 @@
# PENDENZEN — Aufgaben-Queue (Single Source of Truth)
> **Diese Datei ist die einzige verbindliche Aufgabenliste.** HANDOVER.md enthält nur
> noch Kontext/Konventionen/Environment/Zielmodelle — keinen Backlog mehr.
## Arbeitsprotokoll (ZWINGEND)
**Bearbeiter:**
1. **Zuerst diese Datei lesen**, dann `CONVENTIONS.md` + verlinkte Detailquellen des Items.
2. Oberstes nicht-blockiertes Item aus **🔧 In Arbeit** bzw. sonst **⏭️ Als Nächstes** nehmen, **fertig** machen, verifizieren (`npx tsc --noEmit` + `npx vitest run` + trace-scan), abhaken, eine Ergebniszeile nach **✅ Erledigt** schreiben (mit Commit-Hash, sobald committet).
3. **Rückfrage-Recht:** Bei Unklarheit/Design-Entscheid NICHT raten — Item mit `❓` markieren, kurze Frage darunter notieren, nächstes freies Item nehmen. Fragen sammeln sich unter **❓ Offene Rückfragen**.
4. **Kein Arbeitsschritt endet ohne aktualisierte Liste.** Status hier ist immer aktuell, sonst driftet es wieder.
5. Bearbeiter **committet nicht selbst** und delegiert Unteraufgaben nicht weiter.
**Planer** (mit dem Nutzer, kuratiert):
- Nimmt Nutzer-Feedback, pflegt daraus die Liste (rein/umpriorisieren/aufspalten). Führt selbst keine Queue-Items aus.
- Prüft **❓**-Zeilen mit dem Nutzer, wandelt sie in konkrete Items.
---
## 📅 Geplant
- [ ] **truck-Integration (Profil-Extrusion / B-Rep)****Start: 2026-07-07 (Dienstag, nach Reset).** Nutzer zeichnet 2D-Querschnitt (L-Profil, T-Träger, Freiform) → truck-Extrusion → 3D-Körper + Boolean gegen Wand/Decke. Voraussetzung: truck-Booleans stabil + WASM-Integration. Umfang: ~46 Wochen. Mit Nutzer Scope/MVP klären vor Start.
## ⛔ Blocker (zuerst klären)
- [x] ~~**Toolchain prüfen**~~**ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
## 🔧 In Arbeit
- [ ] **kernel2d-Port nach Rust/WASM** (Plan: [PORT_PLAN.md](PORT_PLAN.md), Crate `src-tauri/kernel2d`, TS-Referenz bleibt `kernel2d.ts`, Differential-Harness `src/geometry/kernel2d.parity.test.ts`). Fortschritt:
- [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d``a78e7c7`
- [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521`
- [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf`
- [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1`
- [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. **Offen in Phase 5:** `opening` (mit Aufrufstellen-Änderung) — noch nicht portiert.
- [ ] Phase 6: TS-Fassade umstellen (alt → `kernel2d.legacy.ts`), Suite grün, `npm run build` + WASM sauber. **Achtung Laufzeit-Entscheid:** macht die Live-App synchron WASM-abhängig (Init + Per-Call-Marshalling) — heute nutzt die App KEINE WASM-Geometrie zur Laufzeit; vor Umstellung mit Nutzer klären.
- **Join-Durchstich verworfen (2026-07-05, gemessen):** `computeJoins` live auf WASM zu legen lohnt NICHT. Benchmark TS vs. WASM inkl. JSON-Marshalling (Median ms/Aufruf, 200 Iter): 6 W → TS 0.018/WASM 0.022 · 20 → 0.027/0.043 · 50 → 0.082/0.102 · 120 → 0.331/0.246 · 300 → 1.68/0.67. Crossover erst ~100+ Wände; realistische Plan-/Geschossmengen liegen darunter → TS schneller, und selbst 300 Wände sind mit TS 1,7 ms (nicht wahrnehmbar). Marshalling frisst den Rust-Vorteil, plus dauerhafte Rust↔TS-Paritätspflicht. **Fazit:** reines TS behalten; WASM für Joins nicht weiterverfolgen. WASM lohnt erst bei echten Rechen-Hotspots (Booleans/Tessellierung).
## ⚠️ Zu prüfen (evtl. schon erledigt / Status unklar)
- [x] ~~**Snap an Wand-Schichttrennlinien**~~**gelandet `339202b`** (`wallLayerBoundarySegments` in `src/tools/snapping.ts`), verifiziert vorhanden.
## ⏭️ Als Nächstes
- [ ] **Ribbon-UI + modulare Bars** (Nutzer-Vision 2026-07-05, bestätigt: Tab-Schema **2D·3D·BIM·Ansichten**). Voller Plan + datengetriebene Architektur: **[docs/design/ribbon-ui-plan.md](docs/design/ribbon-ui-plan.md)**. Ribbon-Oberleiste mit Tabs ersetzt die Werkzeug-Sidebar; datengetriebene Registry (RibbonItem = tool|command|action) ermöglicht auch eine **modulare Custom-Bar** (gleiche Items, vom Nutzer gewählt). Attribute bekommen volle Höhe, Objektinfo darunter gemergt; XYZ-Box oben rechts bleibt. **Phasen:** ~~(1) Gerüst + 2D/BIM-Tab~~ ✅ (`9d6e86d`), ~~(2) TopBar → Ansichten-Tab mergen~~ ✅ (`85011cb`), ~~(2b) Tabs in die TopBar-Zeile~~ ✅ (`456ebc8`), ~~(2c) OCS-Chrome (kleine Wortmarke + Quick-Access-Icons statt Burger, Zeile 26px)~~ ✅ (`4e3b074`), ~~(2d) Text + Ansichten auf eine Leiste, „Ansichten" als Standard-Tab zuerst~~ ✅ (`fe22cbf`), (3) **teilweise** ✅: Werkzeug-Sidebar aus Default-Layout raus + Attribute volle linke Höhe + Wandtyp-/Deckentyp-Picker ins Attribute-Panel verschoben (Nutzer-Entscheid), `LAYOUT_VERSION`→8; ~~**offen:** Objektinfo unter Attribute mergen~~ ✅ (`3a2cef3`): element-spezifische Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) + Wand-Referenzlinie ins Attribute-Panel verschoben; ObjectInfo trägt nur noch Bezugspunkt + Masse (Nutzer-Wunsch). ~~(4) modulare Custom-Bar~~ ✅ (Tab „Eigene" + -Picker, localStorage-persistiert). **Offen:** 3D-Tab füllen (aktuell leer; ggf. 3D-spezifisch statt Doppelung mit Ansichten), Band-Höhe/Abstände + 26px-Zeile visuell im Tauri abnehmen, Dauer-Zoom-Anzeige (Statusleiste?) klären. ✅ Vorarbeit: Eigenschaften-Grid im OCS-Stil (`00733d8`), Kreis+Bogen-Werkzeuge (`e454eab`/`bd2b12b`). **Nächster Schritt: im Tauri visuell prüfen (Band-Höhe, Icons, Aktiv-Highlight), dann Phase 2/3.**
- [x] ~~**SPIKE — Bild-Texturen in `render3d`**~~**erledigt `0ca3b1d`** (verifiziert 2026-07-05): `RenderStyle::Textured` real, prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), UVs planar in Metern, Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`), `spike3d` per `T` umschaltbar. Alt-Vertexpfad `[pos,normal,color]` bitgleich (Regressionstest). `cargo test` 58 grün (59 mit `--features render`, inkl. naga-Test); `spike3d`-Build sauber; keine neuen Deps, Default-Build unverändert. Auftrag: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md). **Lücken bis „richtig gutes" Texturing → siehe 3D-REST unten.**
## 📋 Backlog (Priorität grob absteigend)
- [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~**komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
- [x] ~~**Kreis-Toolbar** (trivial)~~**erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.)
- [x] ~~**Bogen-Werkzeug** (mittel)~~**erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334).
- [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. ✅ **Gruppe A (2D, Items 16) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, Treppen-Pfeil-Style `filled`, Fenster/Tür-Presets, `swing_invert`) — Feinpolish. ✅ **Fenster-Anschlag-Striche erledigt** (`8d688b9`, Laibungsstriche quer zur Wand bei „fein", analog Tür; +2 Tests). Gruppe C (3D: Rahmen/Blatt/Glas/Sims als Mesh — wartet auf Mesh-/B-Rep-Pipeline). **Der große Rest ist „Schnitt- vs. Ansichts-Darstellung" (eigenes Item unten).**
- [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (`<input type=file>`→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber R13R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen**`parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt**`parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT**`parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings``{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen**`Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG `<circle>`) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke).
- [ ] **STRATEGIE — „von BIM-Tool zu echtem CAD".** Direkt am Quellcode studierte Referenzen (OpenCADStudio/truck/acadrust) + Web-Import-Landkarte → konkrete, priorisierte Ansätze in **[RESEARCH_CAD_APPROACHES.md](RESEARCH_CAD_APPROACHES.md)**. Kern: (1) generisches Entity-Modell + Trait-Dispatch, (2) modeless Command-System (`StepInput`-Funnel + Kommandozeile), (3) DWG/DXF-Round-Trip via `acadrust` (MPL-2.0, pure Rust, WASM-tauglich), (4) volle Object-Snap-Schicht, (5) 3D-B-Rep später selektiv via `truck` (Apache-2.0, Geometrie-Crates WASM-fähig, Booleans/Fillets noch instabil). Der `kernel2d`-Rust/WASM-Kurs ist damit bestätigt. **Mit Nutzer priorisieren, welcher Ansatz zuerst.**
- [ ] **Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe (BIM-Standard).****Phase 1 (Wand unter Schnittebene) erledigt (2026-07-05):** `generatePlan.addWallPoche` hat einen `viewOnly`-Zweig — erreicht eine Wand die Grundriss-Schnitthöhe nicht (`wall.height < floor.cutHeight`, z. B. 0.3-m-Brüstung bei 1 m), wird sie nur als Ansichts-Umriss (Haarlinie, `fill:"none"`, keine Schraffur) gezeichnet statt als Schnitt-Poché; normale Wände (≥ Schnitthöhe) unberührt. Segment-/Gehrungs-/Öffnungs-Logik geteilt. +2 Tests (`generatePlan.viewwall.test.ts`, Suite 336). ✅ **Phase 2 (Decke über Ebene = gestrichelte Überkopf-Linie) erledigt (2026-07-05, `39ddd9b`):** der freie Decken-Umriss (Überstände/Balkone, wo keine Wand verdeckt) ist jetzt gestrichelte Haarlinie (`OVERHEAD_DASH`) statt kräftiger Volllinie — BIM-Konvention „Aufsicht auf Bauteil über einem". Decken-FLÄCHE war schon Ansicht (viewHatchId, weiß). +1 Test. **Offen (Phase 3):** per-Component View-/Cut-Weight + eigene View-Schraffur (Slot-Trennung); cut/Aufsicht/Untersicht (Deckenspiegel/Reflected Ceiling Plan); Unterzüge; Feinheiten. Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.250.35 mm, + Schnitt-Poché) und eine **Ansichtslinie** (Haarlinie); WELCHE gilt, entscheidet die z-Ausdehnung des Bauteils vs. die **Schnitthöhe** des Grundrisses (Default ~1 m):
- Bauteil wird von der Schnittebene GESCHNITTEN → Schnittlinie + Schnitt-Poché (heutiges Wandverhalten).
- Bauteil liegt ganz UNTER der Ebene (z. B. 30-cm-Wand bei 1 m Schnitthöhe, Brüstung, Podest) → nur „von oben gesehen" = **Ansichtslinie/Haarlinie, KEINE Schnitt-Poché**. ← genau der vom Nutzer genannte Fall.
- Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie.
- **Passt konsistent zum bereits existierenden `viewHatchId` (Ansichts-Schraffur) vs. Schnitt-Schraffur** — die Linienstärke-Dualität (View-/Cut-Weight je Component) ist die natürliche Erweiterung derselben Logik.
- **Nicht nur cut/view, sondern cut / AUFSICHT / UNTERSICHT (Nutzer):** ein Bauteil sieht von oben anders aus als von unten. Der Grundriss (Blick nach unten) zeigt Bauteile UNTER der Ebene in **Aufsicht** (Oberseite); ein **Deckenspiegel/Reflected Ceiling Plan** (Blick nach oben) zeigt Bauteile ÜBER der Ebene in **Untersicht** (Unterseite — z. B. Kassettendecke, Leuchten). Also je Component potenziell **drei** Darstellungs-Slots (Schnitt / Aufsicht / Untersicht) × {Schraffur + Linienstärke}. Das heutige `viewHatchId` ist faktisch EIN View-Slot und vermischt Auf-/Untersicht; sauber wäre die Trennung. WELCHER Slot gilt, entscheidet die **Blickrichtung der Sicht** (Grundriss ↓ / Deckenspiegel ↑) UND die z-Lage relativ zur Schnittebene.
- **Verallgemeinert den Decken-Footprint-Clip** (`a2f6923`): „Decke unter Wand verdeckt" ist ein Spezialfall von „Ansichtsbauteil vs. schneidende/überdeckende Bauteile".
- Aufwand: mittelgroß, phasenweise machbar (1: z-Extent-vs-Schnitthöhe-Klassifikation cut/above/below; 2: Ansichtslinie-Weight je Component + Haarlinie/gestrichelt; 3: 30-cm-Wände & Slabs verdrahten). Nicht zu komplex im Konzept — es ist der reguläre CAD/BIM-Weg (ArchiCAD/Vectorworks/Revit). Mit Nutzer Detailgrade/Defaults festlegen.
- [x] ~~**Ebene-Schraffur editierbar**~~**gelandet `dcb6ed5`**: Kategorie-Dialog (`App.tsx`, `editor.hatch`-Feld) hat `<select>` auf `cat.hatch` + `HatchSwatch`-Vorschau. Verifiziert vorhanden.
- [ ] **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
- Reale Höhen + **Projekt-MüM** (EG-Referenzhöhe): Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert (heute alles z=0).
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + Gelände-Ebene.
- **Nordstern-Geo-Rendering:** importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh`) auch in `projectToModel3d` einspeisen.
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
- [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~**bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden.
- [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers.
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
- [x] ~~**Linienstile aufräumen**~~**erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line``thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive``thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün.
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
- [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt.) Belege in `/tmp/dossier-ref/rhino/*.py`.
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
- [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten:
- **Auth + Projekt-Liste:** Supabase Auth (Email/Magic-Link), Projektübersicht, Öffnen/Schliessen.
- **Lock-Service:** Tabelle `object_locks (project_id, object_id, user_id, locked_at)` mit Row-Level Security; `reserveObjects(ids[])` / `releaseObjects(ids[])` als Supabase-RPC; Optimistisches Check-in via DB-Constraint (doppelte Reserve → Fehler → UI-Feedback).
- **Realtime-Sync:** Supabase Realtime-Channel pro Projekt; bei Freigabe werden die veränderten Objekte (JSON-Patch oder ganzes Objekt-Payload, TBD) gepusht; lokaler Store merged incoming changes sofort.
- **Presence:** Wer ist online, wer hat welche Objekte reserviert (farbige User-Badges an reservierten Elementen im Plan).
- **Offline-Guard:** Beim Verbindungsabbruch Locks automatisch nach Timeout freigeben (DB-seitig: `locked_at + interval` prüfen).
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
- **Aufwand:** gross (24 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen)
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). ✅ **Erster Durchstich erledigt** (`0ca3b1d`, Schachbrett-Textur auf Wänden, group-1-Bind-Group, `MESH_TEXTURED_WGSL`, planare Meter-UVs). **Verbleibende Lücken bis „richtig gut" (Grobschätzung ~1829 PT):** Bild-Datei-Laden (`image`-Crate, PNG/JPG→GPU) ~12 · Material→Textur-Zuordnung (Wandtyp/Layer→Material→Textur-Set, Cache/Atlas oder Bind-Group je Material) ~35 · Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~58 · Mipmaps (Blit-Pass) + anisotropes Filtern ~12 · Web-Pfad (`web.rs`/WebGPU, Bild-Upload via ImageBitmap) ~34 · UI zum Zuweisen + Persistenz im Dokumentmodell ~58.
- [x] ~~**Wand-Schicht-Bänder in 3D** Option B~~**bereits umgesetzt** in `resolveWallBands(layered=true)` (`src/plan/toWalls3d.ts:236-241`): 3D-Viewer-Pfad liefert je Materiallage ein eigenes `WallBand` (Dicke + Component-Albedo + Normalen-Versatz), `pushSegment` emittiert jede Lage als eigene Voll-Box. `dominantLayerColor` ist nur noch Fallback für den Schnitt-Einkörper-Pfad (`layered=false`). Verifiziert per Code-Lesung.
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
## ❓ Offene Rückfragen (an den Nutzer)
- [x] ~~**„aki"** Snap/Endpunkt-Farbe~~**entschieden 2026-07-04: Sora #5FA1C9** als endgültiger Default festgeschrieben (`src/theme/accents.ts` `DEFAULT_SNAP_COLOR`, Doc aktualisiert). Frage geschlossen.
- [ ] Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT)
- [ ] Geo-Block: Projekt-MüM zuerst oder Nordstern-Geo-Rendering?
- [ ] Verifizieren/klären: Isometrie „echte" orthographische Iso (teilw. durch Locked-Iso `cb8fae5` adressiert)? · TopBar-Detailgrad (zoom% ganz aus Footer)? · DOSSIER-Audit welche Features konkret übernommen?
---
## ✅ Erledigt (Verlauf, neueste oben)
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
- [x] 2026-07-05 **Schnitt/Ansicht Phase 2: Decke über Schnittebene = gestrichelte Überkopf-Linie** (`39ddd9b`) — freier Decken-Umriss (Balkon-/Vordach-Überstände) jetzt gestrichelte Haarlinie (`OVERHEAD_DASH` in `generatePlan.ts`, `addCeilingPoche`) statt Volllinie; Decken-Fläche war schon Ansicht (viewHatchId). `lwMm`-Param entfernt (feste Haarlinie). +1 Test, Suite 337.
- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie**`addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe".
- [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün.
- [x] 2026-07-05 **Ribbon Phase 3 (Teil): Werkzeug-Sidebar raus**`DEFAULT_LEFT_GROUPS` nur noch `attributes` (volle linke Höhe), `LAYOUT_VERSION` 7→8 (gespeicherte Layouts fallen auf neuen Default zurück → sichtbar). Wandtyp-/Deckentyp-Picker von `ToolsPanel` ins `AttributesPanel` verschoben (Nutzer-Entscheid „ins Attribute-Panel") — erscheint bei aktivem Wand-/Decken-Werkzeug (Sektion „Neues Bauteil", auch ohne Auswahl), Host-State unverändert. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar), `Dropdown`-Import dort entfernt. tsc + Suite 334 grün. **Offen:** Objektinfo unter Attribute mergen.
- [x] 2026-07-05 **Fix: gezeichnete/importierte Kreise+Bögen im WebGL unsichtbar** — der WebGL-Compiler (`glPlan/glPlanCompile.ts`, Default-Renderer) dispatchte `polygon`/`line`/`arc`, aber NICHT `drawingCircle`/`drawingArc` → seit `4ac99d3` (Kreise als echtes `drawingCircle`-Primitiv statt 64-Eck-Polygon) fielen sie im GL still raus (SVG-/WASM-Pfad hatten sie, GL nicht). Zweig ergänzt: bildschirm-adaptive Tessellierung wie beim `arc`-Primitiv (Kreis geschlossen + optionale Vollton-Füllung, Bogen a0..a1 offen). tsc + Suite 334 grün. **Nutzer-Report** („zeichne Kreis, bleibt nicht sichtbar").
- [x] 2026-07-05 **Textur-Spike verifiziert erledigt** (`0ca3b1d`) — bei der Backlog-Abarbeitung festgestellt, dass die render3d-Textur-Spike (`RenderStyle::Textured`, Schachbrett, planare Meter-UVs, `MESH_TEXTURED_WGSL` group 1, `spike3d`-`T`-Toggle) bereits vollständig umgesetzt + committet war; `cargo test` 58/59 grün, keine neuen Deps, Alt-Pfad bitgleich. „Als Nächstes"-Eintrag war veraltet → abgehakt; PBR-Restlückenliste (~1829 PT) in 3D-REST übernommen.
- [x] 2026-07-05 **Ribbon-Feinschliff (OCS-Zeile)** — Tabs in die TopBar-Zeile verlegt (`456ebc8`, eine Leiste Chrome+Tabs, Inhalt darunter; Tab-State in App); OCS-Chrome: kleine Wortmarke „dossier." + Quick-Access-Icons für ALLE Datei-/Export-Aktionen statt Burger-Menü, Zeile auf 26px (`4e3b074`); Text-/Font-Formatierung mit der Ansichts-/Zoom-Steuerung auf EINE Leiste gelegt, „Ansichten" als Standard-Tab zuerst + initial aktiv (`fe22cbf`). Alle tsc + 331 Tests grün. Visuelle Abnahme (26px, Icon-Sitz) noch offen.
- [x] 2026-07-05 **Ribbon Phase 2: TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll mergen") — die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid+Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart) als `ViewRibbonTab` (in `TopBar.tsx`) in den Ansichten-Tab verschoben; App reicht sie als `viewsContent`-Node an `RibbonBar` (analog Layout-Menü). TopBar ist jetzt schmale globale Leiste (Marke/Ressourcen · Text · Datei/Export/Einstellungen · Fensterknöpfe). `TopBarProps` entsprechend verschlankt. Visuelle Feinabstimmung offen.
- [x] 2026-07-05 **Ribbon-UI Phase 1** (`9d6e86d`) — datengetriebene Tab-Leiste (`src/ui/ribbon/`: `ribbonItems.ts` Registry, `RibbonBar.tsx`, `CommandIcon.tsx`) unter der TopBar, additiv (Sidebar bleibt). Tabs 2D·3D·BIM·Ansichten; 2D=Zeichnen(select/line/polyline/rect/circle/arc)+Ändern(move/copy/mirror/offset/trim/join), BIM=Bauteile(wall/window/door/stair/ceiling/room); 3D/Ansichten noch leer. Ein Aktivierungs-Pfad: tool→`onSelectTool`, command→`onRunCommand`(engine.start); Aktiv-Highlight über `activeTool`/`engineView.commandName`. `ToolIcon` aus ToolsPanel exportiert (wiederverwendet).
- [x] 2026-07-05 **Attribut-Panel: ein Grid, volle Breite** (`ace0dc6`) — drei getrennte Grids zu EINEM durchgehenden Zwei-Spalten-Grid gemerged (Wertspalte über alle Sektionen bündig), Dropdown-Pills füllen die Wertspalte (fixe Breiten raus), doppeltes Eigen-Padding + zweiter „Attribute"-Titel entfernt. **Nutzer-Report.**
- [x] 2026-07-05 **Eigenschaften-Grid OCS-Stil** (`00733d8`) — `.attr-*` als klares Grid: Sektion-Balken, Zeilentrenner, füllende linksbündige Wertfelder (Texte/Zahlen/Dropdowns einheitlich). Erste Stufe der Ribbon-UI-Vision.
- [x] 2026-07-05 **Zoom-Scroll-Fix** (`077e774`) — Dokument-Overscroll/Rubberband gesperrt (`html,body,#root { overflow:hidden; overscroll-behavior:none }`); Viewport-Zoom zieht die UI nicht mehr mit (macOS). **Nutzer-Report.**
- [x] 2026-07-05 **Kreis- + Bogen-Werkzeug** (`e454eab`, `bd2b12b`) — Kreis-Toolbar (circleCommand gekoppelt) + neues `arcCommand` (3-Klick CCW), beide mit Toolbar-Icon/i18n; rendern glatt via `drawingCircle`/`drawingArc`.
- [x] 2026-07-05 **DXF-Import: Platzierungsoption** (`dd76ec8`) — ImportDialog fragt „relativ zum Nullpunkt" ODER „in die Mitte der aktuellen Ansicht"; `PlanViewHandle.viewCenterModel()` neu; `runImport` verschiebt den Gesamt-Umriss (bbox-Mitte → Ansichtsmitte). tsc + Suite 331 grün.
- [x] 2026-07-05 **Zeichnungen Copy/Paste über Geschosse** (`376aa67`) — Ctrl/Cmd+C kopiert gewählte Drawing2D tief; Ctrl/Cmd+V fügt Klone (neue IDs) auf dem AKTIVEN Geschoss ein und wählt sie. Für z. B. importiertes Mobiliar. Textfeld-Fokus bleibt natives Copy/Paste.
- [x] 2026-07-05 **Tauri Drag&Drop + Import-Dialog-Fix** (`c5b5ca6`, `45e19b7`) — `import`-Befehl als `autoRun` (Datei-Dialog öffnet synchron in der Geste, HMR-Falle via Config-Relaunch behoben); `dragDropEnabled:false` (Tauris natives Drag-Drop fing HTML-Drops ab). **Nutzer-bestätigt: Dialog + Text gehen.**
- [x] 2026-07-05 **Layout: Befehlszeile in Mitte-Spalte** (`c794fee`) — nur Viewport-breit, Docks gewinnen Höhe. **Nutzer-bestätigt „sieht clean aus".**
- [x] 2026-07-05 **Textur-Spike render3d** (`RenderStyle::Textured` real) — zweiter Vertex-Pfad `[pos,normal,uv]` aus dem Mesh abgeleitet (Alt-Pfad bitgleich, per Test belegt), prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus Sampler), Pipeline bitidentisch zur Haupt-Pipeline, `spike3d` per `T` umschaltbar. Verifiziert: cargo test 58/59 (inkl. naga-Test) grün, alle 4 Builds sauber. **Visuelle Fenster-Abnahme durch Nutzer bestanden 2026-07-05** (Schachbrett korrekt, `T`-Umschaltung ok). Erster Durchstich der Textur-/PBR-Pipeline; ehrliche Lückenliste im Bericht — `8556037`
- [x] 2026-07-04 **Grundriss: Decke drückt nicht mehr durch die Wände** — Decken-Umriss an Wand-Footprints (OBB) geclippt; Füllfläche strokelos, Umriss nur über unverdeckte Teilstücke (Überstände). Deckungsgleiche Decke ⇒ Umriss entfällt. vitest 230, tsc sauber — `a2f6923`
- [x] 2026-07-04 **T-Stoss-Putznaht entfernt** — L-Seitenlinie nur noch bei materialFREMDEM Nah-Putz; materialgleicher Putz verschmilzt nahtlos (Nutzer-Direktive „Naht entfernen"). TS+Rust synchron, 3 Tests gezogen, cargo 8/8 — `a5ebfa7`
- [x] 2026-07-04 **Snap-Farbe entschieden: Sora #5FA1C9** als endgültiger Default festgeschrieben (`DEFAULT_SNAP_COLOR`, Doc), „aki"-❓ geschlossen.
- [x] 2026-07-04 **Mac-Build via Tauri + Queue-Abgleich** — Toolchain auf macOS-Gerät verifiziert, `cad.app`/`cad_0.1.0_aarch64.dmg` gebaut & gestartet. Verifikations-Baseline grün (tsc/vitest 230/cargo 56). Queue-Reconciliation: Toolchain-Blocker, Snap-Slice (`339202b`), Ebene-Schraffur (`dcb6ed5`), Bauteile-Tab-Master-Detail, Einstellungs-Verdrahtung UND Wand-Schicht-Bänder 3D (Option B, `toWalls3d.ts`) waren allesamt **bereits gelandet**, aber in PENDENZEN noch als offen gelistet → abgehakt. (kein Code-Commit)
- [x] 2026-07-04 **Öffnungen als echte Boolean-Löcher** (+ Deckentrim-nur-3D) — Fenster/Türen = 1 Wandkörper mit rechteckigen `holes` (Rechteck-Gitter-Zerlegung + Laibungsquads), Segment-Boxen weg; Deckentrim nur noch im 3D-Pfad, Schnitt volle Höhe. Pflicht-Testfall versetzt-überlappende Fenster grün. cargo test 56, vitest 230, build:engine3d + tsc sauber — `1407c68`
- [x] 2026-07-04 **Joins Phase 1c** Durchgangswand am T-Stoss echt aufbrechen (spanCutouts) — `c5a344d`
- [x] 2026-07-04 **Locked-Iso-Fix** freie Kamera bleibt nach Ortho-Preset orthografisch — `cb8fae5`
- [x] 2026-07-04 **3D-Live-Schnittebene** mit schraffierten Schnittflächen — `03f0c40`
- [x] 2026-07-04 **Joins Phase 2** Merge-Regel im Schnitt (gleiche Komponente verschmilzt) — `82d354f`
-124
View File
@@ -1,124 +0,0 @@
# PORT_PLAN — kernel2d nach Rust/WASM (hinter identischem TS-Interface)
Stand: 2026-07-04. **Dieser Plan ist der geforderte erste Schritt — noch kein Code.**
Ziel: `src/geometry/kernel2d.ts` (+ reine Geometrie aus room/ceiling/opening/stair) in
ein Rust-Crate portieren, zu WASM bauen, hinter einer TS-Fassade mit *exakt gleichen*
Signaturen einhängen. TS-Kernel bleibt als Referenz (`kernel2d.legacy.ts`).
Differential-Test: Rust-WASM == TS-Legacy auf identischen Eingaben (epsilon je Funktion).
---
## 1. Scope-Inventar (was wirklich portiert wird)
**Externe JS-Geometrie-Libs: KEINE.** Der Kernel ist handgeschriebene f64-Mathematik,
einzige Abhängigkeit sind die Vektor-Helfer aus `src/model/geometry.ts`
(`add/sub/scale/len/normalize/leftNormal/cross/dot/lineIntersect`) — trivial mit zu
portieren. `polygon-clipping`/`delaunator` werden im Kernel **nicht** genutzt.
### Voll im Scope (reine Geometrie)
- **`kernel2d.ts`** — komplett: Primitive, Schnitt, Offset, Trim/Split/Join, Kreis, Fläche/Orientierung, Fillet.
- **`roomBoundary.ts`** — komplett: `detectRooms`, `roomFromPointInside(Faces)`, `pointInPolygon`, `WallSegment`/`WallFace`/`DetectRoomsOptions` (generische Geometrie-Typen, `thickness: number`).
- **`ceiling.ts`** — komplett: `normalizeOutline`, `isValidOutline`, `ceilingArea`, `outlineBBox`, `outlineCentroid`, `pointInOutline` (generische Polygon-Utilities; Name irreführend, keine Decken-Semantik).
### Teilweise im Scope
- **`roomArea.ts`** — NUR `signedArea`, `polygonArea`, `perimeter`, `centroid`. Alles ab `SiaCategory` (`siaLabel`, `evaluateRoom`, `balance`, `roomsToCsv`) ist SIA-416-Domänenlogik/CSV → **bleibt TS**.
- **`stair.ts`** — portierbar, aber `stairGeometry` nimmt `Stair`. „Scheinkopplung": es werden nur geometrische Felder gelesen (`shape/start/dir/runLength/width/stepCount/…`), `totalRise` kommt bereits aufgelöst vom Aufrufer. Port via Rust-Struct `StairParams` (strukturgleich, null Model-Semantik).
### ⚠️ Scope-Spannung: `opening.ts` (im Auftrag genannt, aber stark model-gekoppelt)
7 von 9 Exporten binden `Wall`/`Opening` direkt ein; `getWallType`/`wallTypeThickness`/`wallReferenceOffset`/`wallVerticalExtent` lösen gegen `Project` auf.
- **Portierbar mit abgeflachter Signatur** (Vec2/number statt Wall/Opening): `wallAxisLength`, `openingInterval`, `wallAxisFrame`, `openingJambs`, `openingCenter`, `doorSymbol`, `openingGapQuad`/`windowSymbol` (letztere brauchen `thickness`/`refOff` als `number`-Parameter).
- **Bleibt TS** (braucht `Project`/Geschoss-Auflösung): `openingVerticalExtent` (via `wallVerticalExtent``Project.drawingLevels`).
- **→ Entscheidung nötig** (siehe §7): Der Auftrag verbietet Änderungen an Aufrufstellen außer dem Import-Pfad. Ein Port von `opening` würde die *Signaturen* ändern (Wall→Vec2/number) und damit die Aufrufstellen brechen — das widerspricht „keine Änderung an Aufrufstellen". Empfehlung: **opening in Phase 1 ausklammern**, nur den echt reinen Kern (kernel2d/roomBoundary/ceiling/roomArea-Flächen/stair) portieren.
---
## 2. Crate-vs-Portieren — pro Operation
Akzeptanzkriterium ist **Differential-Parität gegen die naive TS-Routine**, nicht „geometrisch besser". Jedes Fremd-Crate mit anderem Algorithmus bricht die Parität per Konstruktion → Default = **PORTIEREN** (TS-Routinen sind 540 Zeilen f64, 1:1 übersetzbar).
| Operation | geprüftes Crate | Entscheidung | Grund |
|---|---|---|---|
| `offsetPolyline`/`offsetSegment` | cavalier_contours 0.6 | **PORTIEREN** | Crate liefert **Arcs (bulge)** statt `Vec2[]`, **heilt Selbstschnitte**, gibt mehrere Polylinien — TS heilt bewusst NICHT. Semantik nicht angleichbar. |
| Segment/Line-Schnitt | geo / robust | **PORTIEREN** | Cramer-Formel; jede andere denom-/Epsilon-Politik driftet in Parallel-Grenzfällen. |
| Trim/Split/`segmentPolylineHits` | geo `Relate` | **PORTIEREN** | Projektspezifisch (t-Dedup `1e-6`, Pick-nächster-Bogen, Wrap-Logik). |
| Kreis-Schnitte | — | **PORTIEREN** | Quadratik mit projekt-EPS-Disc-Klemmung. |
| `signedArea`/`isCCW` | geo `Area` | **PORTIEREN** | Shoelace-Summierung in **identischer Vertex-Reihenfolge** (f64 nicht assoziativ). |
| `filletCorner` | — | **PORTIEREN** | `acos/atan2/tan(θ/2)`-Kette + projekt-Cutoffs; liefert projekt-spezifische `Fillet`-Struktur. |
| `detectRooms` / `joinChains` | geo / i_overlay | **PORTIEREN** | Topologie-/reihenfolgeabhängig; andere Kantendurchlauf-Reihenfolge → andere (gleich gültige) Ringe → Parität bricht. |
| point-in-polygon | robust | **PORTIEREN** (robust nur intern, optional) | Siehe §3. |
Fremd-Crates (cavalier_contours/geo/i_overlay) wären für einen späteren *Tier-2-Rewrite* (echter Arc-Offset, Boolean-Ops) wertvoll — das ist ein **anderes Produkt**, nicht dieser paritätserhaltende Port.
---
## 3. `robust`-Prädikate vs. Differential-Parität (Spannung auflösen)
TS testet Orientierung überall via naives `cross()` gegen `EPS`. `robust::orient2d` liefert das **exakte** Vorzeichen — weicht von naiv **nur in der nahe-degenerierten Zone** ab (fast-parallele Segmente, Null-Fläche-Polygone, Punkt-auf-Kante). Genau dort schlägt der Diff-Test am ehesten an. Man kann nicht gleichzeitig „bit-Parität gegen naiv" und „robuste Prädikate" im *selben* Vergleich haben.
**Entscheidung:**
1. **v1 portiert die naiven `cross`-Vergleiche 1:1** (KEIN `robust`) → Zufalls-Diff-Test wird bit-nah grün. Robustheit kommt aus denselben f64-Formeln + demselben `EPS` wie TS.
2. **Additiv, getrennt:** `robust` nur *intern* in `detectRooms`/point-in-polygon hinter optionalem Feature `robust-predicates`, flankiert von **Golden-Cases, die die KORREKTE (robuste) Antwort asserten** (nicht TS-Parität). Diese Fälle sind aus dem Zufalls-Diff-Test ausgenommen.
3. Zwei Testklassen, nie gemischt: **Zufalls-Parität = naiv**, **Golden-Korrektheit = robust**.
---
## 4. Crate-Setup & Vite (exakter Klon von `src-tauri/geometry`)
Ort: **`src-tauri/kernel2d`** (analog render2d/render3d/geometry; das gesamte Tooling ist auf `src-tauri/<crate>` + `../../src/engine/pkg<X>` verdrahtet). *Namens-Hinweis:* Auftrag sagt `crates/kernel2d` — siehe §7.
- `Cargo.toml`: `crate-type = ["cdylib","rlib"]`; Features `default=[]`, `web=[wasm-bindgen, serde_json, console_error_panic_hook]`, `robust-predicates=[robust]` (additiv, nicht in web-Default).
- `src-tauri/Cargo.toml`: `exclude = [..., "kernel2d"]` erweitern (sonst „multiple workspace roots").
- `src/lib.rs`: reiner f64-Rechenkern feature-frei (`cargo test`-bar, headless) + `#[cfg(feature="web")]` JSON-**Batch**-Fassade pro Operation (`offset_polylines_json`, `intersect_batch_json`, `fillet_batch_json`, `circle_intersect_batch_json`, `detect_rooms_json`), Muster `compute_joins_json`.
- `examples/parity.rs`: Klon von `geometry/examples/parity.rs` (Batch-JSON stdin→stdout) — nativer Diff-Kanal.
- `package.json`: `"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web"`.
- **Vite: kein Config-Eintrag nötig** — `--target web`-Pakete werden als normales ES-Modul importiert, Vite bündelt `kernel2d_bg.wasm` automatisch (`new URL(..., import.meta.url)`). `pkgKernel2d/` ist git-ignoriert (self-`.gitignore = *`) → vor `vitest`/`build` muss `build:kernel2d` laufen (CI-Schritt).
- **TS-Fassade** `src/geometry/kernel2d.ts` wird dünner Wrapper (init-WASM, JSON-Marshalling, gleiche Exports); Alt-Impl → `src/geometry/kernel2d.legacy.ts` (nicht löschen, ist die Diff-Referenz).
---
## 5. Differential-Test-Harness
- **Grobkörnige WASM-Grenze:** eine Batch-Funktion je Operation (N Polylinien rein, N Ergebnisse raus). Keine Per-Punkt-Calls (jeder Call marshallt einen String = O(n)-Kopie).
- **Diff-Kanal:** Der Auftrag verlangt **Rust-WASM** vs. TS-Legacy → primär WASM (via `initSync`). Zusätzlich `cargo run --example parity` (nativ) als schneller Sekundär-Kanal (bit-identisch zu WASM für `+ - * / sqrt`; **Ausnahme** `atan2/acos/tan` in Fillet: libm nativ ≠ wasm um letzte ULP → Winkel-Epsilon).
- **vitest-Init synchron:** `initSync({ module: readFileSync(pkgKernel2d/kernel2d_bg.wasm) })` (kein `fetch`), einmal in `beforeAll`.
- **Zufallsgeneratoren:** seed-basiert (Seed im Testnamen), Polylinien 320 Vertices, Koordinaten `[-100,100] m`, `closed`/`d` zufällig; zusätzlich Cluster nahe `0` und `1e-6..1e-3`, um Toleranzschwellen zu treffen.
- **Vergleichsreihenfolge:** zuerst **Struktur exakt** (Array-Längen, closed-Flags, Punktzahl, null/nicht-null), dann Werte mit op-Epsilon. Struktur ist der schärfste Paritäts-Wächter.
### Epsilon pro Funktion
| Größe | Toleranz | Grund |
|---|---|---|
| Punktkoordinaten (Offset/Trim/Split/Kreis) | `abs 1e-9` | Bestehender Paritätstest nutzt `1e-9` und besteht bit-nah. |
| Fläche (`signedArea`) | `rel 1e-9·max(1,\|A\|)` | Shoelace ∝ coord² → absolute ULP wächst mit Flächengröße; relativ skaliert korrekt. |
| Winkel (`filletCorner`) | `abs 1e-7 rad` | `atan2/acos` libm-abhängig (nativ↔wasm ULP-Drift); 1e-7 rad ≈ 5.7e-6°, weit unter Zeichenrelevanz. |
| Parameter t/s | `abs 1e-9` | TS dedupliziert erst ab `1e-6` → kleinere Diffs ändern nie die Struktur. |
| Struktur | **exakt** | Kein Epsilon. Hier bricht ein Fremd-Crate. |
### Golden-Cases (explizit, aus Zufallstest teils ausgenommen)
kollineare Tripel · Null-Länge-Segmente (Dublett-Vertex) · Offset-Selbstschnitt (enges U, großes d — hier bräche cavalier_contours) · spitze Fillet-Winkel (θ→0) · fast-paralleler Schnitt (denom knapp <>EPS → **Korrektheits-Golden/robust**) · konzentrische/tangentiale Kreise · Punkt exakt auf Polygonkante (**Korrektheits-Golden**).
---
## 6. Kritische Paritäts-Details (MÜSSEN exakt repliziert werden)
- `len` = `Math.hypot`**`f64::hypot`** (nicht `(x²+y²).sqrt()`).
- `normalize` Null-Guard: `len || 1``if l==0.0 {1.0} else {l}` (Ergebnis `{0,0}`, kein NaN).
- **Zwei Epsilons:** `EPS=1e-7` (kernel2d) UND hartkodiert **`1e-9`** in `lineIntersect` (Offset-Miter-Fallback hängt daran — geometrischer Sprung, nicht epsilon).
- Dedup-Schwelle `1e-6`, Fillet-Kollinearität `1e-4` (nicht EPS).
- **Stabile Sortierung** (JS `Array.sort` ist stabil): Rust `sort_by`, nicht `sort_unstable_by`.
- Term-Reihenfolge in `cross`, `signedArea`-Summierung, Kreis-Diskriminante `B*B4*A*C` exakt beibehalten (f64 nicht assoziativ; kein Kahan/Reorder).
- Modulo-Indizierung `(i+len-1)%len` (usize-Unterlauf vermeiden).
- `joinChains`: greedy `i<j`-erster-Treffer-dann-Neustart exakt nachbilden (reihenfolgeabhängiges Ergebnis).
---
## 7. Offene Entscheidungen (vor Coding klären)
1. **`opening` im Scope?** Portieren würde Signaturen (Wall→Vec2/number) und damit Aufrufstellen ändern — widerspricht „keine Änderung an Aufrufstellen außer Import-Pfad". **Empfehlung: opening in Phase 1 ausklammern.**
2. **Crate-Ort:** Auftrag `crates/kernel2d` vs. Repo-Konvention `src-tauri/kernel2d` (analog render2d/render3d). **Empfehlung: `src-tauri/kernel2d`** (Tooling passt out-of-the-box).
3. **Diff-Kanal:** Auftrag verlangt Rust-**WASM**; nativer `parity`-Kanal ist schneller (kein wasm-pack in CI) und bit-identisch außer Fillet-Transzendente. **Empfehlung: WASM primär (Auftragskonform) + nativ sekundär.**
## 8. Phasen (Reihenfolge)
1. Crate-Skelett `src-tauri/kernel2d` + Build-Script + Workspace-exclude + leere WASM-Fassade → `build:kernel2d` grün.
2. Primitive + Schnitt + Fläche + Kreis portieren (trivialmittel) + Batch-Fassade + Diff-Test-Harness (Zufall+Golden) → grün.
3. Offset (Miter+1e-9-Fallback) + Fillet portieren → Golden für Selbstschnitt/spitze Winkel grün.
4. Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — der Löwenanteil) → Struktur-Golden grün.
5. `roomBoundary` (`detectRooms`) + `ceiling` + `roomArea`-Flächen + `stair` (StairParams).
6. TS-Fassade umstellen (Alt → `.legacy.ts`), Import-Pfade der Aufrufstellen unverändert lassen, bestehende Suite grün, `npm run build` + WASM-Build sauber.
-43
View File
@@ -1,43 +0,0 @@
# RESEARCH — Bauteile Treppe/Fenster/Tür: Rhino-Plugin als Vorbild
Stand: 2026-07-05. Vergleich der authoritativen Rhino-Plugin-Implementierung
(`/Users/karim/PROJECTS/DOSSIER/rhino/`, C#/Python + RhinoCommon-B-Rep) mit
DOSSIER-STANDALONEs vereinfachter TS-Umsetzung. Ziel: Bauteile aufs Rhino-Niveau
heben. Belege = Rhino-Funktionsnamen.
**Grundsatz:** Rhino hat echten B-Rep-Kernel (3D). DOSSIER hat extrudierte Meshes +
`kernel2d` (2D). Darum getrennt: **2D-Symbol-Logik** (leicht in TS/kernel2d
übernehmbar) vs. **3D** (braucht Mesh-Pipeline bzw. später truck).
## Treppe (Rhino `treppe.py`, 1784 Z. — massiv reicher als `stair.ts`)
- **Typen:** gerade / L (3- und 4-Punkt mit Podest) / Wendel (inkl. Spindel-Cone bei r_in<0.05). TS hat alle drei, aber simpler.
- **3D-Querschnitt-Modi** `massiv`/`flach`/`plattenrand` (`_treppe_profile_2d`) — TS: nur Stufenboxen. **fehlt.**
- **2D-Plansymbol** (`_make_treppe_2d_symbol`): Tritte, Lauflinie (4 Pfeil-Styles: klassisch/filled/breit/voll, visuell zentriert via `mid_off`), **Aussenlinie/Outline** (alle 3 Typen), Bruchlinie, Podest-Hexagon. TS: Tritte+Bruch+Lauflinie(1 Style) da; **Aussenlinie fehlt komplett**, Referenz links/mitte/rechts fehlt.
- **Soll-Schrittmass** (2S+A, Lock), Show-Flags, Grips — fehlen in TS.
## Fenster (Rhino `elements.py` `_make_oeffnung_pieces`)
- **3D:** Rahmen (BooleanDiff), Mittelpfosten je Flügel (14), Glas (einfach 12mm / Doppel 2×6+16), Sims aussen (4 Styles), Rahmen-Offset innen/mitte/aussen. TS: nur 2D-Rahmen+Glaslinien, Wand-Boolean-Loch (`1407c68`); kein Rahmen-3D.
- **2D:** TS hat Rahmen + 12 Glaslinien (grob/mittel/fein). **Fehlt:** Brüstungslinie im Plan, Flügel-Mittelpfosten, Anschlag-Striche (nur Tür hat sie), Flügelanzahl.
## Tür (Rhino `elements.py`)
- **Typen:** `normal` / `wandoeffnung` (reiner Durchbruch, kein Blatt). **Rahmen:** `zarge` (3-seitig, im Wandquerschnitt) / `block` (+5cm Überhang).
- **2D-Schwung** (`_make_tuer_swing_curves`): Blatt+Arc, hinge_side/open_angle/aussenseite/swing_invert; Bogen-Anlage am Rahmen (standard/detail). **2D-Sturzlinien** (`_make_tuer_sturz_curves`, gestrichelt, Modi keine/innen/aussen/beide — SIA: Sturz über Schnittebene → gestrichelt). TS: Blatt+Arc+Anschlag (fein) da; **Sturzlinien fehlen komplett**, `wandoeffnung`-Typ + swing_invert fehlen.
---
## Gesamtreihenfolge „was zuerst" (Agenten-Empfehlung)
**Gruppe A — 2D-leicht, hoher Plan-Wert, kernel2d-kompatibel:**
1. **Treppe Aussenlinie/Outline** (alle 3 Typen; gerade=4 Linien, L via Linienschnitt `_line_intersect_xy`, Wendel=2 Bögen+radiale). Ohne sie wirkt der Plan unfertig.
2. **Fenster Brüstungslinie** im Plan (1 gepunktete Linie bei sillHeight>0). XS.
3. **Tür Sturzlinien** (gestrichelt, SIA) — Schulfall der geplanten Schnitt/Ansichts-Logik.
4. **Treppe Referenz links/mitte/rechts** + Lauflinie visuell zentriert.
5. **Fenster Flügel-Mittelpfosten** (2D, `wingCount` ins Modell).
6. **Tür `wandoeffnung`-Typ** (reine Öffnung ohne Blatt/Symbol).
7. Treppe Show-Flags + `obere_dashed`; 8. Fenster Anschlag-Striche.
**Gruppe B — 2D mittel:** Tür-Schwung am Rahmen; Treppe Pfeil-Style `filled`; Fenster/Tür Styles/Presets; swing_invert.
**Gruppe C — 3D (nach Mesh-/B-Rep-Pipeline):** Treppe `flach`-Querschnitt; Rahmen als Mesh (Fenster/Tür); Türblatt+Glas; Sims; L-Podest-Hexagon; Wendel helikoidale Unterseite (truck).
**Bezug PENDENZEN:** Gruppe-A-Items 13 (Aussenlinie/Brüstung/Sturz) fallen in das Backlog-Item „Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe" — Sturzlinie = Bauteil unter Schnittebene → gestrichelte Überkopf-Projektion. Gruppe C wartet auf den Textur-/Mesh-Pipeline-Nachfolger bzw. truck.
-162
View File
@@ -1,162 +0,0 @@
# RESEARCH — Von BIM-Tool zu „echtem CAD": Ansätze aus fortgeschrittenen Programmen
Stand: 2026-07-05. Direkt am Quellcode studiert (Repos nach `/tmp/cad-study` geklont,
liegen NICHT im Projekt). Ziel: konkrete, verfolgbare Ansätze, um DOSSIER von einem
spezialisierten BIM/Architektur-Werkzeug zu einem allgemeineren CAD zu erweitern.
## Studierte Programme
| Repo | Was | Lizenz | Stack | Für uns |
|---|---|---|---|---|
| [OpenCADStudio](https://github.com/HakanSeven12/OpenCADStudio) | Eigenständige 2D/3D-CAD-App, ~200k LOC Rust | GPL-3.0 | Iced (GUI) + wgpu + WASM-Web-Build | **App-Architektur-Blaupause** (Entity-Modell, Command-System, Snap, Grips). Fast unser Stack. |
| [truck](https://github.com/ricosjp/truck) | Reiner Rust-CAD-**Kernel** (NURBS/B-Rep/Mesh/Boolean/STEP) | Apache-2.0 | 16 Crates, wgpu-Rendering + `truck-js` (WASM) | **3D-Kernel-Blaupause**. OpenCADStudio nutzt ihn als 3D-Backend. |
| [acadrust](https://github.com/HakanSeven12/acadrust) | Pure-Rust **DWG+DXF** R13R2018 read+write, 41 Entity-Typen | **MPL-2.0** | nom/byteorder/flate2/nalgebra/encoding_rs (alles pure Rust) | **DWG/DXF-Rückgrat**, MPL-2.0 nutzbar, sehr wahrscheinlich WASM-baubar. |
**Lizenz-Klarstellung:** `acadrust` ist **MPL-2.0** (file-level copyleft — für uns nutzbar,
keine GPL-Ansteckung), NICHT GPL. `truck` ist Apache-2.0 (frei nutzbar). Nur OpenCADStudio
selbst ist GPL-3.0 — wir lesen es als **Architektur-Referenz**, kopieren keinen Code.
---
## Die zentrale Lektion: was ein „echtes CAD" architektonisch ausmacht
DOSSIER ist heute **domänenzentriert** (Wand/Öffnung/Component/Plan-Pipeline, TS-Modell).
Ein echtes CAD (Rhino/AutoCAD-artig) hat stattdessen einen **generischen Kern**. Aus
OpenCADStudios `src/` destilliert:
### 1. Generisches Entity-Modell mit Traits (statt Wand-Spezialfall)
`src/entities/` = 41 Entity-Typen (line, arc, circle, lwpolyline, spline, ellipse, hatch,
text, mtext, dimension, leader, insert/block, table, viewport, mesh, solid3d, …). Jeder
Typ implementiert ein **Trait-Set** statt einer Sonderbehandlung:
- `TruckConvertible` → 3D-Kernel-Geometrie
- `FallbackTess` → Display-Geometrie (Punkte/Snap-Vertices)
- `Grippable` → editierbare Griffe + Hover-Menü (Add Vertex / Convert to Arc / Reverse / Lengthen…)
- `PropertyEditable` → Eigenschaften-Panel
- `Transformable` → Move/Rotate/Scale/Mirror
- `MassProps` → Fläche/Umfang für Abfragen
**DOSSIER-Lehre:** ein generisches `Entity`-Modell mit Trait-Dispatch neben das BIM-Modell
stellen. Wir haben Ansätze (`Drawing2D`, splitJoin-Editoren, kernel2d), aber keine einheitliche
Entity-Taxonomie mit Traits.
### 2. Modeless Command-System — der eine `StepInput`-Step-Machine
`src/command.rs`: EIN Enum `StepInput { Point / Text / EntityPick / StructurePick /
SelectionComplete }`. **Jede** Eingabequelle (Kommandozeile, Viewport-Klick, Pick, Selektion,
Dynamic Input, Plugin-API, Headless-Script) übersetzt in `StepInput` und läuft durch **eine**
`feed_command`-Funktion → treibt die aktive `CadCommand`-Step-Machine, egal woher der Schritt kam.
**DOSSIER-Lehre:** Das ist der größte UX-Unterschied. Echtes CAD = „Befehl tippen → Punkte
picken → Optionen". DOSSIER ist heute Tool-Button + Direktmanipulation. Ein `StepInput`-Funnel +
Kommandozeile wäre der Hebel, um beliebige Werkzeuge einheitlich, scriptbar und headless-testbar
zu machen.
### 3. Object-Snap-Engine (eigenständig, groß)
`src/snap.rs` = 70 KB nur für OSNAP (endpoint/midpoint/center/intersection/tangent/
perpendicular/…). Wir haben Teil-Snapping; ein echtes CAD hat eine dedizierte, vollständige
Snap-Schicht.
### 4. Universelle Grip-Editierung
Jede Entity liefert Griffe + kontextuelle Griff-Menüs mit teils numerischem Follow-up
(„Lengthen" fragt Wert an der Kommandozeile ab). Einheitlich über das `Grippable`-Trait.
### 5. `CadDocument` als Dokumentmodell = das DWG/DXF-Objektmodell
OpenCADStudio nutzt `acadrust::CadDocument` DIREKT als sein Datenmodell (Entities + Objects +
Tables: Layer/Linetype/Style/Blocks + XData). D. h. das native CAD-Austauschformat IST das
interne Modell → verlustfreier Round-Trip „for free".
---
## truck — 3D-Kernel-Blaupause (+ harte WASM-Realität)
Saubere Schichtung (Ship-of-Theseus, kleine ersetzbare Crates):
```
truck-base Basistraits/Toleranz (cgmath)
truck-geotrait ParametricCurve / ParametricSurface Traits
truck-geometry Knot-Vektor, B-Spline, NURBS ← Kurven/Flächen
truck-topology vertex/edge/wire/face/shell/solid ← B-Rep-Topologie
truck-modeling Geometrie + Topologie integriert ← Solids bauen
truck-polymesh Polygon-Datenstruktur + Meshing
truck-meshalgo Tessellation der Shapes
truck-shapeops Boolean-Ops auf Solids (v0.4 — früh)
truck-stepio STEP read/write (v0.3)
truck-platform wgpu-Grafik-Utility ┐ Rendering — brauchen wir NICHT
truck-rendimpl Shape/Mesh-Visualisierung ┘ (wir haben eigenen wgpu-Renderer „Nordstern")
truck-js WASM-Wrapper (v0.2)
```
**WASM-Realität (zwei Befunde abgeglichen):**
- OpenCADStudio pinnt `truck-meshalgo` 0.4 und schaltet den `solid3d`-Feature **im WASM-Build ab**,
weil dessen ACIS-/vtkio-Pfad über `xz2 → lzma-sys` (C-Bibliothek) nicht nach wasm32 kommt →
**im Web ist OpenCADStudio nur 2D.**
- Neuere `truck-meshalgo` (0.6) gated `rayon`/`vtkio` selbst per `cfg(not(target_arch="wasm32"))`
aus → **baut zu wasm32, aber single-threaded**. Der reine Geometrie-Stack
(base → geotrait → geometry → topology → polymesh → meshalgo → modeling → shapeops) hat keine
native-only-Deps und ist **sauber vom wgpu-Rendering trennbar** (truck-platform/-rendimpl weglassen).
**Reifegrad (Apache-2.0, aktiv, ~1 Hauptentwickler; ausdrücklich KEIN Produktionskern):**
- NURBS/B-Spline + B-Rep-Topologie: **stabil** (v0.5/0.6).
- Booleans (`truck-shapeops`): `and`/`or` vorhanden, aber **instabil bei coincident geometry**
(Issue #114); **`difference` fehlt** (#85); **Fillets fehlen komplett**. Der Fork
[`monstertruck`](https://github.com/virtualritz/monstertruck) hat difference + rolling-ball-Fillet.
→ Für unsere Wandöffnungen (boolean holes) haben wir das ohnehin schon selbst gelöst (`spanCutouts`).
- `truck-stepio`: STEP **schreiben** (aber NICHT für boolean-operierte Shapes) + **lesen** (v0.6 beta,
neues `truck_stepio::in`).
- `truck-js`: fertiger wasm-bindgen-Wrapper (kein npm-Paket → selbst per `wasm-pack` bauen), bündelt
modeling/shapeops/meshalgo/stepio; `IntoWasm`-Muster.
---
## acadrust — DWG/DXF-Rückgrat
Pure Rust, **MPL-2.0**, `CadDocument`-Objektmodell (entities/objects/tables/xdata/classes),
41 Entity-Typen, DWG „208/208 roundtrip-perfect", serde optional, failsafe-Parsing, ~40 Codepages.
**Alle Deps pure Rust** (nom/byteorder/flate2-miniz/nalgebra/indexmap/ahash/encoding_rs) → sehr
wahrscheinlich zu wasm32 baubar (Datei-I/O-API müsste auf Bytes/Reader statt `std::fs` umgestellt
werden — vermutlich schon vorhanden). Heute nutzen wir JS `dxf-parser` (nur 2D) + `libredwg-web`.
---
## Web-Import-Landkarte (für den GEO-BLOCK / Import-Backlog)
Pro Format die aktive, web-/WASM-taugliche Lib (Stand Mitte 2026):
| Format | Empfohlene Lib | Lizenz | Anmerkung |
|---|---|---|---|
| DWG/DXF (browser) | `@mlightcad/cad-viewer` / `libredwg-web` (nutzen wir) **oder** `acadrust` (Rust) | MIT / GPL-3.0 / **MPL-2.0** | acadrust = Rust-Weg, MPL-2.0 |
| STEP/IGES/BREP | `occt-wasm` (OCCT V8, ~4.5 MB) oder unser `opencascade.js` | LGPL-2.1 | occt-wasm = moderne TS-API |
| **IFC** (BIM!) | `web-ifc` (ThatOpen) | MPL-2.0 | De-facto-Standard, read+write, sehr aktiv |
| SHP/GIS | `shpjs` + `proj4` | MIT | direkt für GEO-BLOCK (SWISSIMAGE/Terrain) |
| Punktwolken LAS/LAZ | `@loaders.gl/las` (+ `laz-perf`) | MIT/Apache | Rendering: `potree-core` oder deck.gl |
| E57 | `e57-js` (Emscripten/libE57Format) | MIT | neu, funktional, sehr obskur |
| glTF/OBJ/STL | three.js-Loader (haben wir via `three`) | MIT | |
| 3MF | `lib3mf` (WASM) oder `THREE.3MFLoader` | BSD-2 / MIT | |
| PDF-Vektor | `pdfjs-dist` `getOperatorList()` | Apache-2.0 | kein fertiges PDF→DXF; roher Pfad-Stream |
---
## Priorisierte Ansätze für DOSSIER (die eigentliche Antwort)
Reihenfolge = Nutzen × Machbarkeit, ohne die BIM-Stärke zu opfern.
1. **Generisches Entity-Modell + Trait-Dispatch** (Fundament). Neben das BIM-Modell eine
generische Entity-Schicht (line/arc/circle/polyline/spline/text/dimension/hatch/block) mit
Traits à la OpenCADStudio (Display/Grips/Props/Transform). Unser `kernel2d` liefert schon die
Geometrie-Operationen dafür. **Größter struktureller Hebel.**
2. **Modeless Command-System** (`StepInput`-Funnel + Kommandozeile). Ein Eingabe-Enum, eine
`feed_command`-Schleife, `CadCommand`-Step-Machines. Macht Werkzeuge einheitlich, scriptbar,
headless-testbar. **Größter UX-Hebel Richtung „echtes CAD".**
3. **DWG/DXF-Round-Trip über `acadrust`** (Rust/WASM). MPL-2.0, pure Rust → passt in unseren
neuen `src-tauri`-Kern. Ersetzt langfristig die JS-Parser, bringt echten Import/Export.
Erst: WASM-Baubarkeit + Bytes-API verifizieren (Spike wie beim Textur-Spike).
4. **Vollständige Object-Snap-Schicht** als eigenes Modul (endpoint/mid/center/intersection/
tangent/perp/…), aufbauend auf `kernel2d`.
5. **3D-B-Rep später, selektiv** (truck-geometry/-topology/-modeling als reine Crates, OHNE
truck-Rendering — wir haben Nordstern). NUR wenn wir echte NURBS/Solids/Booleans brauchen; der
Meshing-/Boolean-Pfad ist WASM-problematisch (native C-Deps) und Booleans sind noch früh.
Bis dahin: truck als **Referenz** lesen, nicht einbinden.
**Kernaussage:** DOSSIERs Rust-nach-WASM-Kurs (den wir mit `kernel2d` gerade gehen) ist genau
richtig — OpenCADStudio bestätigt ihn. Der Weg zu „echtem CAD" führt über (1) generisches
Entity-Modell und (2) modeless Command-System; (3) `acadrust` bringt echten DWG/DXF-Austausch.
3D-B-Rep (truck) ist ein späterer, selektiver Schritt mit klaren WASM-Vorbehalten.
-137
View File
@@ -1,137 +0,0 @@
# SPIKE — Bild-Texturen in `render3d` (`RenderStyle::Textured` real machen)
Stand: 2026-07-05. **Auftrag/Übergabe für einen Agenten. Kleinster ehrlicher
Durchstich — kein Produktfeature, keine Integration.**
Ziel: Beweisen, dass der bestehende wgpu-3D-Renderer echte **Bild-Texturen** auf
Wandflächen darstellen kann, sichtbar im `spike3d`-Fenster. Am Ende steht eine
belastbare Aussage, wie viel Arbeit „richtig gutes texturiertes 3D" wirklich ist —
statt Spekulation.
---
## 0. Ausgangslage (verifiziert am 2026-07-05)
- `render3d` ist **kein** Three.js-Wrapper, sondern ein eigenständiger wgpu-Renderer
(~6400 LOC): echte GPU-Pipeline (wgpu 29), Tiefenpuffer, MSAA, WGSL-Shader.
Läuft nativ (winit-Spike), headless (naga-validiert) und im Browser (WebGPU).
- **`RenderStyle::Textured` existiert bereits als Stub** (`gpu.rs:34` Enum-Variante,
`gpu.rs:47` Parse aus `"textured"`) — es gibt aber **kein echtes Texturing**:
kein Sampler, keine Textur-Bind-Group, keine Bilddaten.
- Die `cap_pipeline` mit Layout `[pos vec3, uv vec2]` + `CAP_WGSL` ist **nicht** für
Bildtexturen, sondern für die **Schnittflächen-Kappen** (prozedurale Schraffur);
die UVs steuern dort den Schraffur-Abstand. **Nicht damit verwechseln.**
- **Günstig für uns:** Die Muster, die der Spike braucht, existieren schon —
ein UV-tragendes Vertex-Layout und eine zweite/dritte Pipeline, die sich die
`Globals`-Bind-Group teilt (`grid`, `cap`). Der texturierte Mesh-Pfad reiht sich
1:1 in dieses Muster ein.
---
## 1. Echte Symbole — vor dem Coding lesen
| Was | Ort |
|---|---|
| Vertex heute interleaved `[pos.xyz, normal.xyz, color.rgb]`, `FLOATS_PER_VERTEX`, `Mesh`-Struct | `src-tauri/render3d/src/types.rs:322` ff. |
| Quad-Emitter (Normale + Farbe je Vertex, Reihenfolge `(0,1,2)+(0,2,3)`) | `src-tauri/render3d/src/mesh.rs:886` |
| Wand-Extrusion / Mesh-Bau | `src-tauri/render3d/src/mesh.rs``extrude_wall` (`:91`), `build_walls_mesh` (`:911`) |
| Haupt-Pipeline + `Globals`-Bind-Group (group 0) | `src-tauri/render3d/src/gpu.rs:232``:320` |
| Vorlage „zweite Pipeline teilt sich Globals" — Grid | `src-tauri/render3d/src/gpu.rs:329` |
| Vorlage „Pipeline mit UV-Layout `[pos vec3, uv vec2]`" — Cap | `src-tauri/render3d/src/gpu.rs:408` |
| `RenderStyle`-Enum + Stub `Textured` | `src-tauri/render3d/src/gpu.rs:34`, `:47` |
| Pipeline-Bindung im Render-Pass (Muster für Stil-Umschaltung) | `src-tauri/render3d/src/gpu.rs:847` |
| Shader als WGSL-Konstanten (`MESH_WGSL`, `CAP_WGSL`) | `src-tauri/render3d/src/shaders.rs` |
| Beleuchtungsmodell (hemisphärisch + Directional + Fill) — Doku | `src-tauri/render3d/src/shaders.rs:1``40` |
| naga-WGSL-Validierung headless (Test-Vorlage) | `src-tauri/render3d/src/lib.rs:924` (`cap_module`) |
| Fenster-Spike mit Orbit-Kamera | `src-tauri/render3d/src/bin/spike3d.rs` |
---
## 2. Umfang — exakt das, nicht mehr
### 2.1 UVs auf Wandflächen
- In der Quad-Emitter-Funktion (`mesh.rs:886`) je Vertex eine **UV** berechnen:
planare Projektion in **Metern**`u` = Distanz entlang der Wandachse,
`v` = Höhe (z). Textur-Raster damit weltmassstäblich (z. B. 1 Kachel = 1 m).
- Den bestehenden `[pos, normal, color]`-Pfad **bitgleich unangetastet** lassen.
Zwei zulässige Wege (Agent wählt begründet):
1. **Separates additives UV-Array** in `Mesh` (Default leer/None), oder
2. **Paralleler `build_walls_mesh_textured`** → interleaved
`[pos.xyz, normal.xyz, uv.xy]`.
- Regressionstests für den Alt-Pfad müssen grün bleiben (siehe §4).
### 2.2 Test-Textur prozedural (kein Asset, keine `image`-Crate)
- Ein **256×256 RGBA-Schachbrett/Grid im Code** generieren (`Vec<u8>`).
- `device.create_texture` + `queue.write_texture` + `Sampler`
(`FilterMode::Linear`, `AddressMode::Repeat`). Mipmaps optional (nice-to-have für
flache Blickwinkel; kein Muss für den Spike).
- Selbstständig, damit der Spike ohne Dateipfade/Asset-Pipeline läuft.
### 2.3 Textur-Bind-Group (group 1)
- Neue Bind-Group-Layout mit `texture_view` (`TextureSampleType::Float`) +
`sampler`. **`Globals` bleibt group 0** und unverändert.
### 2.4 Textured-Pipeline + WGSL (`MESH_TEXTURED_WGSL`)
- Vertex-Layout `[pos vec3, normal vec3, uv vec2]`, `TriangleList`.
- **Dieselbe Beleuchtung wie `MESH_WGSL`** (hemisphärisches Ambient + Directional +
Fill) — nur **Albedo = `textureSample(tex, samp, uv)`** statt Vertex-Farbe.
- Depth-Format, MSAA (`SAMPLE_COUNT`) und Color-Target **identisch** zur
Haupt-Pipeline (sonst inkompatibler Render-Pass).
- Pipeline-Layout bindet group 0 (Globals) **und** group 1 (Textur).
### 2.5 Verdrahten
- Bei `RenderStyle::Textured` im Render-Pass die neue Pipeline + beide Bind-Groups
setzen (Muster: `cap_pipeline`-Bindung bei `gpu.rs:847`).
### 2.6 Spike sichtbar machen
- `spike3d.rs` so erweitern, dass der Stil auf `Textured` schaltbar ist
(Tastendruck, z. B. `T`, **oder** Startkonstante). Die Demo-Wände sollen
texturiert im Orbit erscheinen.
---
## 3. Randbedingungen (hart)
- **Nur** die `render3d`-Crate. `src/web.rs` und die Tauri-/`native3d`-Oberfläche
**nicht** anfassen.
- Feature-gegatet unter dem bestehenden `render`/`window`-Feature.
**Default-Build und Default-Darstellung bleiben unverändert.**
- **Keine neuen Dependencies** (insbesondere **kein `image`-Crate**) für den Spike.
- Term-/Reihenfolge-sensible Geometrie (Parität) wird **nicht** berührt — es kommt
nur additiv ein UV-Kanal + ein zweiter Render-Pfad dazu.
- Kommentar-Stil und Sprache (Deutsch, ausführliche Begründungs-Kommentare) wie im
umgebenden Code beibehalten.
---
## 4. Akzeptanz / Verifikation
1. `cargo test` (im Crate-Verzeichnis `src-tauri/render3d`) **grün**, inklusive:
- bestehende Mesh-Regression (Alt-Pfad `[pos,normal,color]` unverändert),
- **neuer naga-Validierungstest** für `MESH_TEXTURED_WGSL` (Vorlage:
`lib.rs:924`).
2. `cargo run --features window --bin spike3d` zeigt die Demo-Wände mit
**erkennbarer, korrekt gemappter** Schachbrett-Textur:
- Raster weltmassstäblich (in Metern), keine Verzerrung an Gehrungen/Ecken,
- beleuchtet wie im Shaded-Modus (Volumen bleibt ablesbar).
3. Umschalten Shaded ↔ Textured zur Laufzeit (oder per Startkonstante) funktioniert
ohne Re-Meshing-Crash.
---
## 5. Abschlussbericht (vom Agenten am Ende zu liefern)
- Welche Dateien geändert/hinzugefügt wurden und warum.
- Wie die UVs projiziert werden (Achswahl, Massstab, Verhalten an Gehrungen).
- Welcher der beiden UV-Wege (§2.1) gewählt wurde und weshalb.
- **Ehrliche Lückenliste für „richtig gutes" Texturing:** Asset-/Bild-Datei-Laden,
Material→Textur-Zuordnung (Wandtyp/Layer → Material), Normal-/Roughness-Maps
(PBR), anisotropes Filtern + Mipmaps, Web-Pfad (`web.rs`/WebGPU), UI zum
Zuweisen. Grobschätzung Aufwand je Punkt.
---
## 6. Nicht im Scope
Asset-/Bild-Datei-Laden · Material-System · mehrere Texturen gleichzeitig ·
PBR/Normal-Maps · Web-Pfad (`web.rs`) · jegliche UI · Anbindung unter die Webview.
+2 -2
View File
@@ -1,4 +1,4 @@
# ARCHITEKTUR-BRIEFING: Tauri + wgpu (Korrigiert)
# HAUPTINSTANZ-BRIEFING: Tauri + wgpu (Korrigiert)
**Stand:** 2026-07-01 — **KORREKTUR** (vorherige Dokumente waren unvollständig)
@@ -158,7 +158,7 @@ npm run tauri:build # → Windows .exe / macOS .app / Linux .deb
---
## Nächste Schritte
## Nächste Schritte (für Hauptinstanz)
1. **Lesen:** `docs/design/tauri-migration-plan.md` (technisch, konkret)
2. **Spawn:** vier Agents (parallel, unabhängig)
+4 -2
View File
@@ -1,8 +1,10 @@
# 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. Abgleich DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen
den aktuellen Browser-Port. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
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.
-74
View File
@@ -1,74 +0,0 @@
# Ribbon-UI + modulare Bars — Plan
Nutzer-Vision (2026-07-05, mit OCS/AutoCAD-Ribbon als Referenz). Ziel: klare,
einheitliche Werkzeug-/Eigenschaften-Darstellung; Werkzeug-Sidebar entfällt.
## Zielbild
- **Ribbon-Oberleiste mit Tabs: 2D · 3D · BIM · Ansichten.** Jeder Tab zeigt
gruppierte Werkzeug-/Aktions-Icons (wie OCS Draw/Model/Insert/Annotate/View).
- **2D**: Zeichnen (Select/Linie/Polylinie/Rechteck/Kreis/Bogen/Text) · Ändern
(Move/Copy/Mirror/Offset/Trim/Join/Boolean).
- **3D**: Volumen/Boolean (vorhandene 3D-Aktionen), Kamera-Presets.
- **BIM**: Wand/Fenster/Tür/Treppe/Decke/Raum (die Bauteile).
- **Ansichten**: Geschoss/Schnitt/Ansicht-Wechsel, Zoom/Einpassen, Render-Modi.
- **Werkzeug-Sidebar entfällt** → rechtes Panel (Attribute) bekommt volle Höhe.
- **Objektinfo unter die Attribute** mergen (Wandstil-Definition etc. zusammen).
- **XYZ-Referenzpunkt-Box oben rechts bleibt** (vom Nutzer ausdrücklich gewünscht).
- **Eigenschaften-Grid im OCS-Stil** — ✅ bereits erledigt (`00733d8`): Sektion-
Balken, Zeilentrenner, füllende linksbündige Wertfelder (`.attr-*`).
## Architektur — DATENGETRIEBEN (ermöglicht modulare Custom-Bar)
Kern: EINE Registry beschreibt alle Ribbon-Elemente; Ribbon-Tabs UND eine
benutzerdefinierte Custom-Bar sind bloß zwei Ansichten über dieselbe Registry.
```
RibbonItem =
| { kind: "tool"; id: ToolId } // aktiviert ein Werkzeug (onSelectTool)
| { kind: "command"; name: string } // startet einen Engine-Befehl (engine.start)
| { kind: "action"; id: string; run: () => void } // freie App-Aktion (Zoom, Render-Modus …)
RibbonGroup = { titleKey: string; items: RibbonItem[] }
RibbonTab = { id: "2d"|"3d"|"bim"|"views"; labelKey: string; groups: RibbonGroup[] }
RIBBON: RibbonTab[]
```
- Icons: `ToolIcon` (vorhanden) für `tool`-Items; für `command`/`action` eine
kleine Icon-Map (SVG) analog `ToolsPanel`.
- Aktivierung: `tool``onSelectTool(id)` · `command``engine.start(name)` ·
`action``run()`. Aktiv-Highlight über `activeTool` bzw. laufenden Befehl.
- **Custom-Bar (modular)**: der Nutzer wählt beliebige `RibbonItem`s in eine
persistierte Liste (`viewSlice`/Projekt); eine `CustomBar`-Komponente rendert
genau diese. Gleiche Item-Typen, gleiche Aktivierung — kein Sonderweg.
## Phasen
1.**Gerüst + 2D/BIM-Tab** (`9d6e86d`). `RibbonBar` unter der TopBar (additiv).
Datengetriebene Registry (`src/ui/ribbon/ribbonItems.ts`), 2D-Tab (Zeichnen +
Ändern) und BIM-Tab (Bauteile) gefüllt, Tab-State lokal, Modify-Icons ergänzt.
2.**TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll
mergen"). Die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid +
Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart)
sind als `ViewRibbonTab` (in `TopBar.tsx`) in den „Ansichten"-Tab gewandert;
App reicht sie als `viewsContent`-Node an `RibbonBar` (wie das Layout-Menü).
Die **Tab-Reiter sitzen in der TopBar-Zeile** (`RibbonTabs`, in TopBar
gerendert; Tab-State in App), der Ribbon-Inhalt (`RibbonBar`) folgt darunter →
nur ZWEI Bänder. Die TopBar ist eine **schmale Leiste** (Höhe 40px): Marke/
Ressourcen · Tabs · Datei/Export/Einstellungen · Fensterknöpfe. Die Text-/
Font-Formatierung (`TextGroup`) liegt im eigenen **„Text"-Tab** (Reihenfolge
2D·3D·BIM·Text·Ansichten; `RibbonBar.tabContent` trägt „text" + „views").
- **Offen (visuell iterieren):** ob Zoom/Massstab zusätzlich als Dauer-Anzeige
(Statusleiste) sichtbar sein soll; Default-Tab (aktuell 2D); 3D-Tab noch
leer (Kamera-Presets/3D-Aktionen füllen).
3. **Sidebar raus** (ToolsPanel aus Default-Layout) + rechtes Panel volle Höhe;
Objektinfo unter Attribute mergen.
4. **Modulare Custom-Bar**: Item-Picker + persistierte Custom-Bar-Ansicht.
## Hinweise / Randbedingungen
- Additiv bauen: bestehende Kommandozeile + Nummern-Shortcuts + `ToolsPanel`
bleiben funktionsfähig, bis Phase 3 die Sidebar bewusst entfernt.
- Werkzeug-Kopplung Tool↔Befehl existiert bereits (`TOOL_COMMAND`) — Ribbon nutzt
denselben `onSelectTool`/`engine.start`-Pfad (EIN Pfad, keine Duplikate).
- Empfehlung: Phase 1+ in fokussierter Session mit visueller Iteration im Tauri.
+1 -1
View File
@@ -146,6 +146,6 @@ Neue Keys in de.ts UND en.ts: `text.style`, `text.font`, `text.size`,
## 7. Gate (Pflicht)
`rm -f tsconfig.tsbuildinfo && npx tsc -b` grün, `npm run build` grün, keine
Trace-Scan (grep auf Co-Authored/Generated), Boot-Probe
AI-Spuren (grep auf Claude/Anthropic/AI/Generated/Co-Authored), Boot-Probe
(`node scripts/probe.mjs`) ohne Konsolenfehler, Screenshot der Oberleiste.
KEIN Commit.
-403
View File
@@ -1,403 +0,0 @@
# truck-Integration — Profil-Extrusion (B-Rep → 3D-Mesh)
> Übergabe-Dokument für die Implementierung. Lies zuerst CONVENTIONS.md.
## Ziel
Nutzer zeichnen im Grundriss ein geschlossenes Polygon (z. B. L-Profil einer Stütze)
und können es als 3D-Körper auf eine Höhe extrudieren. Das Ergebnis erscheint sofort
im 3D-Viewport neben Wänden und Decken.
**MVP-Scope (dieser Auftrag):**
- Neue Rust/WASM-Crate `trucksolid` mit zwei Funktionen: `extrude_polygon` + `extrude_circle`
- Neue TS-Wrapper-Datei `src/engine/truckSolid.ts` (WASM laden + typisierte API)
- KEIN neues Werkzeug, KEIN UI — nur die Geometrie-Schicht. UI kommt in einem
separaten Folgeauftrag.
---
## Architektur-Kontext
```
src-tauri/
render3d/ ← bestehend: wgpu-Renderer (wasm-pack → pkg3d/)
kernel2d/ ← bestehend: 2D-Geometrie-Kern
trucksolid/ ← NEU (dieser Auftrag)
Cargo.toml
src/
lib.rs
src/engine/
pkg3d/ ← render3d WASM-Output
pkgTruck/ ← NEU: trucksolid WASM-Output (wasm-pack Ziel)
truckSolid.ts ← NEU: TS-Wrapper
src/plan/
toWalls3d.ts ← bestehend: baut RenderScene; emitMeshes() muss erweitert werden
```
Das Koordinatensystem in DOSSIER ist **Modell-Meter: X = rechts, Y = oben (Grundriss),
Z = Höhe**. render3d erwartet `positions` im gleichen System — `mesh.rs` macht intern
`(x, y, z) → (x, z, y)` für wgpu (Y-up world). Truck arbeitet in 3D; wir bauen
das Polygon in der XY-Ebene (z=0) und extrudieren nach +Z.
---
## 1 — Rust-Crate `src-tauri/trucksolid/`
### 1.1 Cargo.toml
Exakt dasselbe Muster wie `src-tauri/kernel2d/Cargo.toml`:
```toml
[workspace] # entkoppelt vom cad-tauri-Workspace (Pflicht)
[package]
name = "trucksolid"
version = "0.1.0"
edition = "2021"
description = "Profil-Extrusion via truck (B-Rep → tesselliertes Mesh für render3d)"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
web = [
"dep:wasm-bindgen",
"dep:serde_json",
"dep:console_error_panic_hook",
]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
# truck: nur die stabilen Crates (KEIN truck-modeling — Booleans instabil)
truck-geometry = "0.3"
truck-topology = "0.3"
truck-rendimesh = "0.3"
[dev-dependencies]
serde_json = "1"
```
**Wichtig:** `truck-modeling` (die Crate mit Boolean-Operatoren) wird NICHT eingebunden.
Nur `truck-geometry`, `truck-topology` und `truck-rendimesh` — die sind stabil.
### 1.2 src/lib.rs — Kern-Logik
#### Eingabe/Ausgabe-Typen
```rust
use serde::{Deserialize, Serialize};
/// Input: flaches Array [x0,y0, x1,y1, ...] in Modell-Metern (Grundriss-XY).
/// Muss ≥ 3 Punkte enthalten; Wiederholung des ersten Punkts am Ende optional.
/// Reihenfolge CCW oder CW — truck normalisiert selbst.
#[derive(Deserialize)]
pub struct ExtrudePolyInput {
pub points: Vec<f64>, // flat: [x0,y0, x1,y1, ...]
pub height: f64, // Extrusionshöhe in Metern (> 0)
}
/// Output: trianguliertes Mesh, kompatibel mit render3d::types::MeshInput.
/// positions: flat [x0,y0,z0, x1,y1,z1, ...] in Modell-Metern
/// indices: Dreiecks-Indizes (je 3 = 1 Dreieck), 0-basiert
#[derive(Serialize)]
pub struct MeshOutput {
pub positions: Vec<f32>,
pub indices: Vec<u32>,
}
```
#### Polygon-Extrusion (truck-API)
```rust
use truck_geometry::prelude::*;
use truck_topology::*;
pub fn extrude_polygon_core(pts: &[(f64, f64)], height: f64) -> Result<MeshOutput, String> {
if pts.len() < 3 { return Err("min 3 Punkte".into()); }
if height <= 0.0 { return Err("height muss > 0 sein".into()); }
// 1. Punkte in der XY-Ebene (z=0) als truck-Vertices
let verts: Vec<Vertex<Point3>> = pts.iter()
.map(|(x, y)| Vertex::new(Point3::new(*x, *y, 0.0)))
.collect();
// 2. Kanten: je zwei aufeinanderfolgende Vertices verbinden, Ring schliessen
let edges: Vec<Edge<_, Line<Point3>>> = verts.windows(2)
.chain(std::iter::once([verts.last().unwrap(), &verts[0]].as_slice()))
.map(|w| {
let p0 = *w[0].point();
let p1 = *w[1].point();
Edge::new(&w[0], &w[1], Line(p0, p1))
})
.collect();
// 3. Wire (geschlossener Kantenzug)
let wire = Wire::from_iter(edges);
// 4. Planare Face aus dem Wire
// truck-topology::Face::new braucht die äussere Boundary + optional Holes
let face = Face::new(vec![wire]);
// 5. Lineare Extrusion: tsweep entlang +Z um `height`
let solid = face.tsweep(&Vector3::new(0.0, 0.0, height));
// 6. Tessellieren (chord-tolerance in Metern — 0.005 = 5 mm)
use truck_rendimesh::MeshedShape;
let mesh = solid.triangulation(0.005).to_polygon();
// 7. positions + indices extrahieren
let positions: Vec<f32> = mesh.positions().iter()
.flat_map(|p| [p.x as f32, p.y as f32, p.z as f32])
.collect();
let indices: Vec<u32> = mesh.tri_faces().iter()
.flat_map(|tri| tri.iter().map(|idx| idx.pos as u32))
.collect();
Ok(MeshOutput { positions, indices })
}
```
> **Achtung:** Die exakte truck-API (Methoden-Namen, Trait-Imports, tsweep-Signatur)
> kann je nach veröffentlichter Version leicht abweichen. Prüfe die Docs von
> `truck-topology 0.3` und `truck-rendimesh 0.3` auf docs.rs. Die Struktur oben
> ist das Ziel-Pattern — passe Methoden-Signaturen an, wenn der Compiler es verlangt.
> Ändere NICHT die Eingabe/Ausgabe-JSON-Struktur.
#### Kreis-Extrusion (Zylinder)
```rust
pub fn extrude_circle_core(cx: f64, cy: f64, r: f64, height: f64) -> Result<MeshOutput, String> {
// Kreis in XY-Ebene tessellieren (N Segmente je nach r),
// dann wie extrude_polygon_core aufrufen.
// Alternativ: truck-geometry::Ellipse/Circle direkt nutzen, falls vorhanden.
let n = (2.0 * std::f64::consts::PI * r / 0.02).ceil().max(16.0) as usize;
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
extrude_polygon_core(&pts, height)
}
```
#### WASM-Bindings (Feature "web")
```rust
#[cfg(feature = "web")]
mod web {
use super::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn init() {
console_error_panic_hook::set_once();
}
/// Extrudiert ein Polygon-Profil.
/// `input_json`: `{ "points": [x0,y0,…], "height": 2.5 }`
/// Rückgabe: `{ "positions": […], "indices": […] }` oder throws JsError.
#[wasm_bindgen]
pub fn extrude_polygon(input_json: &str) -> Result<String, JsError> {
let input: ExtrudePolyInput = serde_json::from_str(input_json)
.map_err(|e| JsError::new(&e.to_string()))?;
let pts: Vec<(f64, f64)> = input.points.chunks(2)
.map(|c| (c[0], c[1]))
.collect();
let mesh = extrude_polygon_core(&pts, input.height)
.map_err(|e| JsError::new(&e))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
/// Extrudiert einen Kreis-Querschnitt (Zylinder).
/// `input_json`: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0 }`
#[wasm_bindgen]
pub fn extrude_circle(input_json: &str) -> Result<String, JsError> {
#[derive(serde::Deserialize)]
struct In { cx: f64, cy: f64, r: f64, height: f64 }
let i: In = serde_json::from_str(input_json)
.map_err(|e| JsError::new(&e.to_string()))?;
let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height)
.map_err(|e| JsError::new(&e))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
}
```
### 1.3 Tests (headless, ohne Feature "web")
Mindestens diese drei Tests müssen mit `cargo test` grün sein:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quad_extrusion_vertex_count() {
// 1m × 1m Quadrat, 2m hoch → 8 Eckpunkte minimum (6 Flächen × 2 Dreiecke)
let pts = vec![(0.0,0.0),(1.0,0.0),(1.0,1.0),(0.0,1.0)];
let m = extrude_polygon_core(&pts, 2.0).unwrap();
assert!(m.positions.len() >= 8 * 3); // ≥ 8 Vertices × 3 floats
assert_eq!(m.indices.len() % 3, 0); // vollständige Dreiecke
assert!(m.indices.len() >= 12 * 3); // ≥ 12 Dreiecke (Quader)
}
#[test]
fn l_profile_extrusion() {
// L-Profil: 6 Punkte
let pts = vec![
(0.0,0.0),(0.3,0.0),(0.3,0.1),(0.1,0.1),(0.1,0.3),(0.0,0.3),
];
let m = extrude_polygon_core(&pts, 3.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(m.positions.len() > 0);
}
#[test]
fn cylinder_extrusion() {
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(m.positions.len() > 0);
}
#[test]
fn rejects_too_few_points() {
assert!(extrude_polygon_core(&[(0.0,0.0),(1.0,0.0)], 1.0).is_err());
}
#[test]
fn rejects_zero_height() {
let pts = vec![(0.0,0.0),(1.0,0.0),(0.5,1.0)];
assert!(extrude_polygon_core(&pts, 0.0).is_err());
}
}
```
---
## 2 — Build-Script (package.json)
Füge in `package.json` unter `"scripts"` hinzu:
```json
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
```
Und in `src-tauri/Cargo.toml` unter `exclude`:
```toml
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
```
---
## 3 — TS-Wrapper `src/engine/truckSolid.ts`
Gleiche Lade-Pattern wie `src/plan/useWasmPlanRenderer.ts` (render2d) und
`src/viewport/useWasm3dRenderer.ts` (render3d):
```typescript
// Lazy-Singleton: WASM einmalig laden, dann gecacht.
let modulePromise: Promise<typeof import("../../engine/pkgTruck/trucksolid")> | null = null;
async function getModule() {
if (!modulePromise) {
modulePromise = import("../../engine/pkgTruck/trucksolid").then(async (m) => {
await m.default(); // WASM-Binary initialisieren
return m;
});
}
return modulePromise;
}
export interface ExtrudedMesh {
positions: number[];
indices: number[];
}
/** Extrudiert ein geschlossenes Polygon-Profil (Modell-Meter XY) um `height` m. */
export async function extrudePolygon(
points: number[], // flat [x0,y0, x1,y1, ...]
height: number,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_polygon(JSON.stringify({ points, height }));
return JSON.parse(json) as ExtrudedMesh;
}
/** Extrudiert einen Kreis-Querschnitt (Zylinder). */
export async function extrudeCircle(
cx: number, cy: number, r: number, height: number,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height }));
return JSON.parse(json) as ExtrudedMesh;
}
```
---
## 4 — Integration in toWalls3d.ts (Vorbereitung)
In `src/plan/toWalls3d.ts` ist `RMeshKind` bereits definiert:
```typescript
export type RMeshKind = "terrain" | "imported";
```
Erweitere auf `"extrusion"` (damit der Renderer später eine eigene Farbe/Darstellung
wählen kann, auch wenn heute noch kein Unterschied besteht):
```typescript
export type RMeshKind = "terrain" | "imported" | "extrusion";
```
Die `emitMeshes()`-Funktion liest bereits `project.drawings2d` und ähnliche Arrays.
Für extrudierte Körper wird es später ein `project.extrudedSolids`-Array geben
(oder ähnlich — das ist Teil des UI-Folgeauftrags). Die `emitMeshes()`-Erweiterung
kommt dann.
---
## 5 — Verifikations-Checkliste
Bevor du als fertig meldest, müssen alle Punkte grün sein:
- [ ] `cd src-tauri/trucksolid && cargo test` → alle 5 Tests grün, keine Warnings
- [ ] `npm run build:truck``src/engine/pkgTruck/trucksolid.js` + `.wasm` erzeugt
- [ ] `npx tsc --noEmit` (aus Root) → 0 Fehler
- [ ] `npx vitest run` → alle bestehenden Tests weiterhin grün (keine Regression)
- [ ] `src-tauri/Cargo.toml` hat `"trucksolid"` im `exclude`-Array
- [ ] `package.json` hat `"build:truck"` im `"scripts"`-Block
---
## 6 — Was NICHT in diesem Auftrag
- Kein neues UI/Werkzeug (kommt später)
- Kein `truck-modeling` (Boolean-Operationen — instabil upstream)
- Kein STEP-Export (kommt in Folgeauftrag wenn Basisschicht steht)
- Kein Eintrag in `project.extrudedSolids` oder Store (UI-Auftrag)
- Keine Änderungen an render3d, generatePlan, App.tsx
---
## 7 — Koordinatensystem-Reminder
| DOSSIER-Modell | truck (im Code) | render3d (wgpu) |
|---|---|---|
| X = rechts | X = rechts | X = rechts |
| Y = oben (Grundriss) | Y = oben (Grundriss) | Z = oben (Y-up swapped) |
| Z = Höhe | Z = Höhe | Y = Höhe |
`mesh.rs` in render3d macht bereits `(model.x, model.y, model.z) → (x, z, y)` beim
Hochladen. Du musst also in truck DOSSIER-Koordinaten verwenden (XY-Ebene = Grundriss,
Z = Höhe) — das ist konsistent mit der bestehenden `MeshInput.positions`-Konvention.
+7 -6
View File
@@ -1,12 +1,13 @@
# Briefing — Nativer wgpu-2D-Renderer (parallele Instanz)
> Für den Entwickler, der den nativen GPU-Renderer baut. Isoliert vom
> Für die Instanz/den Agenten, der den nativen GPU-Renderer baut. Isoliert vom
> Web-Renderer; koordiniert über dieses Dokument.
## COMMIT-REGEL (verbindlich)
Dieses Repo darf **keinerlei Fremd-Tool-Hinweise** enthalten — nicht im Code,
in Kommentaren oder in der Git-Historie. Kommentare deutsch, Identifier englisch.
**Kein Commit ohne Absprache.**
Dieses Repo darf **keinerlei Hinweise auf KI-Werkzeuge** enthalten — nicht im Code,
in Kommentaren oder in der Git-Historie. Keine `Co-Authored-By`-Trailer, keine
„Generated with"-Zeilen, keine Modell-/Agenten-Namen. Kommentare deutsch,
Identifier englisch. **Kein Commit ohne Ansage der Hauptinstanz.**
## Warum
Der 2D-Plan wird aktuell in einem WebGL-Canvas gerendert (Web-Stack). In Chromium
@@ -71,9 +72,9 @@ per Matrix-Uniform macht. Fenster-Integration in Tauri ist der zweite, separate
- **Eigener Branch** (z. B. `feature/wgpu-renderer`), NICHT auf `master`/
`feature/parametric-walls` committen. Isolierter Worktree.
- **Nur** `src-tauri/` + neue Rust-Module + `docs/`. **NICHT** `src/App.tsx`,
`src/plan/*`, `types.ts` anfassen (dort laufen parallel Features).
`src/plan/*`, `types.ts` anfassen (die Hauptinstanz arbeitet dort an Features).
- Der Web-WebGL-Renderer bleibt bestehen (Browser-Lauffähigkeit + Referenz).
- Ergebnisse/Diffs zurückliefern; Projektleitung committet.
- Ergebnisse/Diffs zurückliefern; die Hauptinstanz committet und pflegt das Memory.
## Gates
`cargo check`/`cargo test`/`cargo build` grün. Trace-Scan sauber (COMMIT-REGEL).
+3 -1
View File
@@ -6,7 +6,9 @@
> Port-Plan) und M1 (entkoppelter Standalone-Spike). Noch NICHT die Migration.
## Commit-/Spuren-Regel
Wie im ganzen Repo: keine Fremd-Tool-Hinweise im Code, in Kommentaren oder der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne Absprache.
Wie im ganzen Repo: keine Hinweise auf KI-Werkzeuge — nicht im Code, in Kommentaren
oder in der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne
Ansage der Hauptinstanz.
## Warum
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
+1 -4
View File
@@ -16,10 +16,7 @@
"shell": "scripts/chromium-shell.sh",
"electron": "scripts/electron-shell.sh",
"build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web",
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
"build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web",
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web"
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web"
},
"dependencies": {
"@mlightcad/libredwg-web": "^0.7.7",
-2
View File
@@ -1488,10 +1488,8 @@ dependencies = [
name = "geometry"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
+6 -10
View File
@@ -1,14 +1,10 @@
[workspace]
members = ["."]
# render2d/render3d/geometry sind eigenstaendige Crates, damit sie headless ohne
# Tauri-Toolchain UND per wasm-pack (Feature "web") zu WASM baubar bleiben. Sie
# liegen zwar im src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace —
# hier ausschliessen, damit `cad-tauri` sie dennoch per Pfad als Abhaengigkeit
# nutzen kann. geometry: neu ausgeschlossen (war Member), seit es zu WASM gebaut
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport"]
members = [".", "geometry"]
# render2d/render3d sind eigenstaendige Crates (jeweils eigener [workspace]), damit
# sie headless ohne Tauri-Toolchain baubar/testbar bleiben. Sie liegen zwar im
# src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace — hier ausschliessen,
# damit `cad-tauri` sie dennoch per Pfad als (optionale) Abhaengigkeit nutzen kann.
exclude = ["render2d", "render3d"]
[package]
name = "cad-tauri"
-16
View File
@@ -1,16 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region).",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:window:allow-start-dragging"
]
}
-578
View File
@@ -1,578 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "acadrust"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153498e7c9b058326ff0fae11bc9fd870cf2ea0aa9111875d03625fa9018c218"
dependencies = [
"ahash",
"anyhow",
"bitflags",
"byteorder",
"encoding_rs",
"flate2",
"indexmap",
"itoa",
"nalgebra",
"nom",
"once_cell",
"ryu",
"thiserror",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "anyhow"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "dwgimport"
version = "0.1.0"
dependencies = [
"acadrust",
"console_error_panic_hook",
"getrandom",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "nalgebra"
version = "0.32.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4"
dependencies = [
"approx",
"matrixmultiply",
"nalgebra-macros",
"num-complex",
"num-rational",
"num-traits",
"simba",
"typenum",
]
[[package]]
name = "nalgebra-macros"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "safe_arch"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
dependencies = [
"bytemuck",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "simba"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae"
dependencies = [
"approx",
"num-complex",
"num-traits",
"paste",
"wide",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wide"
version = "0.7.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
-36
View File
@@ -1,36 +0,0 @@
# Eigener leerer Workspace-Block: entkoppelt dwgimport vollstaendig vom
# cad-tauri-Workspace (src-tauri/Cargo.toml). Muster: kernel2d/render2d.
[workspace]
[package]
name = "dwgimport"
version = "0.1.0"
edition = "2021"
description = "DXF-Import-Probe: beweist, dass acadrust (MPL-2.0) aus einem Byte-Buffer parst und nach wasm32-unknown-unknown kompiliert."
# cdylib: fuer wasm-pack (Feature \"web\"). rlib: fuer cargo test + Pfad-Dep.
[lib]
crate-type = ["cdylib", "rlib"]
[features]
# Standard: headless, kein WASM-Overhead — per `cargo test` pruefbar.
default = []
# Browser-Bindings: wasm-bindgen-Fassade; nur fuer wasm32-unknown-unknown.
web = ["dep:wasm-bindgen", "dep:serde_json", "dep:console_error_panic_hook"]
[dependencies]
# DXF/DWG-Parser (MPL-2.0), von crates.io.
acadrust = "0.4"
# getrandom wird transitiv via ahash→acadrust gezogen. Fuer wasm32 muss das
# Feature "wasm_js" explizit aktiviert werden (sonst Compile-Fehler).
# Direkte Dep mit Feature-Aktivierung unifiziert das Flag fuer alle Abhaengigen.
getrandom = { version = "0.3", features = ["wasm_js"] }
# Serialisierung: immer vorhanden (fuer den headless parse_dxf_summary-Kern).
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
# WASM-Bindings — nur im Feature "web" aktiv.
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
-284
View File
@@ -1,284 +0,0 @@
// dwgimport — SPIKE: beweist, dass acadrust (MPL-2.0) aus einem Byte-Buffer
// ein DXF parst und nach wasm32-unknown-unknown kompiliert.
//
// Aufbau:
// - `parse_dxf_summary`: headless, feature-frei testbar.
// - `#[cfg(feature="web")]` wasm-bindgen-Fassade darueber.
//
// API-Stuetzpunkt acadrust:
// `DxfReader::from_reader<R: Read + Seek + 'static>(reader: R)`
// → std::io::Cursor<&[u8]> implementiert Read + Seek → WASM-tauglich.
use std::collections::HashMap;
use std::io::Cursor;
use acadrust::DxfReader;
// serde_json nur bei Feature "web" als optionale Dep, aber serde ist immer da.
// Fuer den headless-Kern bauen wir das JSON mit einem einfachen Format-String,
// damit keine Compile-Time-Dep auf serde_json im Default-Feature noetig ist.
/// Parst ein DXF aus einem Byte-Slice und gibt ein JSON-Summary zurueck.
///
/// Rueckgabe-JSON-Schema:
/// ```json
/// {
/// "version": "AC1015",
/// "entityCount": 5,
/// "entityTypeCounts": { "LINE": 3, "CIRCLE": 2 },
/// "layerNames": ["0", "Wand"]
/// }
/// ```
///
/// Fehler: String mit Fehlerbeschreibung.
pub fn parse_dxf_summary(bytes: &[u8]) -> Result<String, String> {
// Cursor<Vec<u8>> implementiert Read + Seek + 'static — kein std::fs noetig.
// (from_reader verlangt R: 'static; Cursor<&[u8]> wuerde die Lifetime verletzen.)
let cursor = Cursor::new(bytes.to_vec());
let reader = DxfReader::from_reader(cursor)
.map_err(|e| format!("DxfReader::from_reader fehlgeschlagen: {e}"))?;
let doc = reader
.read()
.map_err(|e| format!("DxfReader::read fehlgeschlagen: {e}"))?;
// Versions-String aus dem Enum (Debug-Format, z. B. "AC1015").
let version = format!("{:?}", doc.version);
// Entitaeten zaehlen und nach Typ aufschluesseln.
let mut type_counts: HashMap<&str, usize> = HashMap::new();
for entity in doc.entities() {
let typ = entity.as_entity().entity_type();
*type_counts.entry(typ).or_insert(0) += 1;
}
let entity_count = type_counts.values().sum::<usize>();
// Layer-Namen aus der Layer-Tabelle.
let mut layer_names: Vec<String> = doc
.layers
.iter()
.map(|l| l.name.clone())
.collect();
layer_names.sort();
// JSON-Serialisierung ohne optionale serde_json-Dep im Default-Feature.
// Fuer den headless-Test reicht ein sauberer Format-String.
let type_counts_json = {
let mut pairs: Vec<String> = type_counts
.iter()
.map(|(k, v)| format!(" \"{k}\": {v}"))
.collect();
pairs.sort(); // deterministisch fuer Tests
format!("{{\n{}\n }}", pairs.join(",\n"))
};
let layer_names_json = {
let quoted: Vec<String> = layer_names
.iter()
.map(|n| format!("\"{}\"", n.replace('"', "\\\"")))
.collect();
format!("[{}]", quoted.join(", "))
};
Ok(format!(
"{{\n \"version\": \"{version}\",\n \"entityCount\": {entity_count},\n \"entityTypeCounts\": {type_counts_json},\n \"layerNames\": {layer_names_json}\n}}"
))
}
// ---------------------------------------------------------------------------
// WASM-Fassade (nur bei Feature "web" compiliert)
// ---------------------------------------------------------------------------
#[cfg(feature = "web")]
mod wasm {
use super::parse_dxf_summary;
use wasm_bindgen::prelude::*;
/// Initialisiert den Panic-Hook fuer bessere Fehlermeldungen im Browser.
#[wasm_bindgen(start)]
pub fn init_panic_hook() {
console_error_panic_hook::set_once();
}
/// WASM-Fassade: nimmt einen Byte-Buffer (Uint8Array aus JS) und gibt ein
/// JSON-String zurueck, das das DXF-Summary beschreibt.
///
/// JS-Aufruf:
/// ```js
/// import init, { parse_dxf_summary_json } from './pkgDwgImport/dwgimport.js';
/// await init();
/// const json = parse_dxf_summary_json(new Uint8Array(buffer));
/// ```
#[wasm_bindgen]
pub fn parse_dxf_summary_json(bytes: &[u8]) -> Result<String, JsValue> {
parse_dxf_summary(bytes)
.map_err(|e| JsValue::from_str(&e))
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Minimales ASCII-DXF mit 3 LINE- und 2 CIRCLE-Entitaeten auf Layer "WAND"
/// und Layer "0". Wird direkt als Byte-Slice eingebettet — kein Datei-IO.
const MINI_DXF: &str = r#" 0
SECTION
2
HEADER
9
$ACADVER
1
AC1015
0
ENDSEC
0
SECTION
2
TABLES
0
TABLE
2
LAYER
70
2
0
LAYER
2
0
70
0
62
7
6
Continuous
0
LAYER
2
WAND
70
0
62
3
6
Continuous
0
ENDTAB
0
ENDSEC
0
SECTION
2
ENTITIES
0
LINE
8
WAND
10
0.0
20
0.0
30
0.0
11
100.0
21
0.0
31
0.0
0
LINE
8
WAND
10
100.0
20
0.0
30
0.0
11
100.0
21
50.0
31
0.0
0
LINE
8
0
10
100.0
20
50.0
30
0.0
11
0.0
21
0.0
31
0.0
0
CIRCLE
8
WAND
10
50.0
20
25.0
30
0.0
40
10.0
0
CIRCLE
8
0
10
20.0
20
10.0
30
0.0
40
5.0
0
ENDSEC
0
EOF
"#;
#[test]
fn test_parse_mini_dxf() {
let result = parse_dxf_summary(MINI_DXF.as_bytes());
assert!(result.is_ok(), "parse_dxf_summary schlug fehl: {:?}", result);
let json = result.unwrap();
println!("DXF-Summary:\n{json}");
// Version muss AC1015 sein.
assert!(json.contains("AC1015"), "Version AC1015 nicht im JSON: {json}");
// Genau 5 Entitaeten: 3 LINE + 2 CIRCLE.
assert!(
json.contains("\"entityCount\": 5"),
"entityCount soll 5 sein: {json}"
);
assert!(
json.contains("\"LINE\": 3"),
"LINE-Zaehler soll 3 sein: {json}"
);
assert!(
json.contains("\"CIRCLE\": 2"),
"CIRCLE-Zaehler soll 2 sein: {json}"
);
// Layer "WAND" und "0" muessen erscheinen.
assert!(json.contains("\"WAND\""), "Layer WAND fehlt: {json}");
assert!(json.contains("\"0\""), "Layer 0 fehlt: {json}");
}
}
-19
View File
@@ -2,28 +2,9 @@
name = "geometry"
version = "0.1.0"
edition = "2021"
description = "Serde-only Geometrie-Kern (Wand-Verschneidungen/Joins) — headless und per wasm-pack (Feature \"web\") zu WASM baubar; Single Source of Truth fuer TS + nativen Host."
# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul benoetigt. rlib:
# damit die Crate weiterhin als Pfad-Abhaengigkeit (cad-tauri) und im Test-Build
# nutzbar bleibt. Der cdylib-Artefakt-Build auf nativen Zielen ist harmlos (leere
# Export-Oberflaeche ohne Feature "web"). Muster: render2d/render3d.
[lib]
crate-type = ["cdylib", "rlib"]
[features]
# Standard: reine serde-Geometrie, headless per `cargo test` pruefbar.
default = []
# Browser-Bindings: dieselbe Geometrie hinter einer wasm-bindgen-Fassade
# (`compute_joins_json`), aus TS via wasm-pack aufgerufen. Baut nur fuer
# target wasm32-unknown-unknown sinnvoll. Muster: render3d/Cargo.toml.
web = ["dep:wasm-bindgen", "dep:serde_json", "dep:console_error_panic_hook"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
[dev-dependencies]
+21 -165
View File
@@ -9,20 +9,6 @@
use serde::{Deserialize, Serialize};
/// Browser-Fassade (Feature "web"): JSON rein/raus, damit das TS-Frontend die
/// EINE Rust-Join-Implementierung per wasm-pack aufrufen kann (Single Source of
/// Truth statt TS-Duplikat). `input_json` = serialisiertes `JoinInput`, Rueckgabe
/// = serialisiertes `Vec<WallCuts>`.
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn compute_joins_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let input: JoinInput = serde_json::from_str(input_json)
.map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))?;
let out = compute_joins(input);
serde_json::to_string(&out).map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))
}
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct Vec2 {
pub x: f64,
@@ -81,20 +67,6 @@ pub struct LayerCuts {
pub end_side: Vec<Option<Line>>,
}
/// Aussparung der DURCHGANGSWAND am materialbewussten T-Stoss -- Rust-Spiegel
/// von `SpanCutout` aus `model/joins.ts`. `from`/`to` = Achsen-Intervall (Meter
/// ab `wall.start`), `offA`/`offB` = Quer-Offsetbereich (Nah-Putz-Zone) in der
/// Offset-Konvention der Schicht-Baender; `offA` ist stets die Nahflaeche.
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct SpanCutout {
pub from: f64,
pub to: f64,
#[serde(rename = "offA")]
pub off_a: f64,
#[serde(rename = "offB")]
pub off_b: f64,
}
/// Schnittlinien einer Wand an ihren beiden Achsenden.
#[derive(Serialize, Deserialize)]
pub struct WallCuts {
@@ -110,11 +82,6 @@ pub struct WallCuts {
/// weiterhin `start_cut`/`end_cut` fuer alle Schichten.
#[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")]
pub layer_cuts: Option<LayerCuts>,
/// ADDITIV (Durchgangswand-Seite): Nah-Putz-Aussparungen, durch die ein
/// Abzweig-Kern sticht. Leer -> von serde bei Serialisierung weggelassen
/// (`skip_serializing_if`), Wand wird unveraendert gezeichnet.
#[serde(rename = "spanCutouts", skip_serializing_if = "Vec::is_empty")]
pub span_cutouts: Vec<SpanCutout>,
}
/// Verschneidungs-Entscheidung zweier Bauteile am Stoss -- Rust-Spiegel von
@@ -168,13 +135,6 @@ fn apply_layer_cuts(
through_dir: Vec2,
sign: f64,
off_t: f64,
// Durchgangswand-Aussparung (Phase 1c): Index/Start der Durchgangswand,
// Achsrichtung + Referenzversatz des Abzweigs -- fuer die Projektion der
// Kernbreite auf die Durchgangsachse.
through_wall_idx: usize,
through_start: Vec2,
branch_dir: Vec2,
branch_ref_off: f64,
) {
if branch_layers.is_empty() || through_layers.is_empty() {
return;
@@ -239,76 +199,17 @@ fn apply_layer_cuts(
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). In einem Block, damit die
// mutable Ausleihe von `result[branch_wall_idx]` vor dem Zugriff auf die
// Durchgangswand unten endet.
// Nah-Putz-Komponenten der Durchgangswand (dieselbe sign-Seite wie
// near_plaster oben): ist die getrimmte Abzweig-Schicht materialgleich mit
// einer davon, fuellt der Nah-Putz den Abzweig-Putz an dieser Stelle
// durchgehend (spanCutout schneidet nur die KERN-Breite aus, nicht die
// schmalen Putzflanken) -> Putz-L OHNE Trennnaht, also KEINE L-Seitenlinie.
let near_plaster_comps: Vec<&str> = through_layers
.iter()
.enumerate()
.filter(|(i, _)| if sign > 0.0 { *i > backbone_idx } else { *i < backbone_idx })
.map(|(_, l)| l.component_id.as_str())
.collect();
{
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]));
let merges_with_near_plaster =
near_plaster_comps.contains(&branch_layers[i].component_id.as_str());
side_arr[i] = if adj_merged && !merges_with_near_plaster {
Some(side_line)
} else {
None
};
}
}
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand ueber
// die Breite des durchstechenden Abzweig-Kerns ECHT ausschneiden. Nur noetig,
// wenn davor Nah-Putz liegt (near_plaster > 0).
if near_plaster > 1e-9 {
let total_branch: f64 = branch_layers.iter().map(|l| l.thickness).sum();
let mut b_off = branch_ref_off - total_branch / 2.0;
let mut core_lo = f64::INFINITY;
let mut core_hi = f64::NEG_INFINITY;
for (i, l) in branch_layers.iter().enumerate() {
if merged[i] {
if b_off < core_lo {
core_lo = b_off;
}
if b_off + l.thickness > core_hi {
core_hi = b_off + l.thickness;
}
}
b_off += l.thickness;
}
if core_hi > core_lo {
// Projektion der Kern-Offsethuelle auf die Durchgangsachse.
let n_branch = left_normal(branch_dir);
let proj = dot(n_branch, through_dir);
let axis_at = dot(sub(axis_point, through_start), through_dir);
let e1 = axis_at + core_lo * proj;
let e2 = axis_at + core_hi * proj;
let off_a = off_t + sign * (t_t / 2.0);
let off_b = off_t + sign * (t_t / 2.0 - near_plaster);
result[through_wall_idx].span_cutouts.push(SpanCutout {
from: e1.min(e2),
to: e1.max(e2),
off_a,
off_b,
});
}
// 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 };
}
}
@@ -404,7 +305,6 @@ pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
start_cut: None,
end_cut: None,
layer_cuts: None,
span_cutouts: Vec::new(),
})
.collect();
@@ -585,10 +485,6 @@ fn apply_t_junction(
u_t,
sign,
off_t,
through_wall_idx,
through_wall.start,
dir_of(branch_wall),
branch_wall.reference_offset,
);
}
@@ -679,10 +575,6 @@ fn apply_mid_span_tee(
u_w,
sign,
off_w,
w_idx,
w.start,
dir_of(free_wall),
free_wall.reference_offset,
);
}
@@ -968,8 +860,8 @@ mod tests {
// 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. Da der Nah-Putz der
// Durchgangswand materialgleich ist, entsteht KEINE L-Trennnaht.
// 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),
@@ -1004,54 +896,18 @@ mod tests {
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);
// ... aber KEINE seitliche L-Linie: der Innenputz der Durchgangswand ist
// materialgleich und fuellt den getrimmten Abzweig-Putz durchgehend
// (Putz-L ohne Trennnaht). Nur bei materialFREMDEM Nah-Putz stuende hier
// eine L-Linie.
assert!(lc.start_side[0].is_none());
assert!(lc.start_side[2].is_none());
// ... 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 bei den layerCuts unangetastet.
// 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());
// ... bekommt aber die Durchgangswand-Aussparung (Phase 1c): die als
// Durchgang gewaehlte Haelfte A wird ueber die Backstein-Kernbreite (0.12)
// im Nah-Putz-Bereich (y in [0.06,0.075]) ausgeschnitten. Das Intervall
// ist die Projektion der Kernbreite auf die Achse, zentriert am Knoten x=5.
assert_eq!(ca.span_cutouts.len(), 1, "A (Durchgang) hat genau eine Aussparung");
let sc = ca.span_cutouts[0];
assert!((sc.from - 4.94).abs() < 1e-9, "from = 5 - 0.06");
assert!((sc.to - 5.06).abs() < 1e-9, "to = 5 + 0.06");
assert!(((sc.to - sc.from) - 0.12).abs() < 1e-9, "Intervallbreite = Kernbreite");
assert!((sc.off_a - 0.075).abs() < 1e-9, "offA = Nahflaeche");
assert!((sc.off_b - 0.06).abs() < 1e-9, "offB = Rueckgrat-Nahflaeche");
// Der Abzweig B traegt keine Aussparung (die gehoert der Durchgangswand).
assert!(cb.span_cutouts.is_empty());
}
#[test]
fn mid_span_tee_through_wall_gets_span_cutout() {
// Mittelspannen-T-Stoss (wie der reale W9-Fall): Durchgangswand W als EINE
// Wand (0,0)->(10,0), Abzweig B trifft mit seinem freien Ende (4,0) mittig
// auf ihre Seite. W bekommt EINE Aussparung um x=4, Breite = Kernbreite.
let input = JoinInput {
walls: vec![
w_iw("W", 0.0, 0.0, 10.0, 0.0),
w_iw("B", 4.0, 3.0, 4.0, 0.0),
],
};
let out = compute_joins(input);
let cw = find(&out, "W");
assert_eq!(cw.span_cutouts.len(), 1, "W hat genau eine Aussparung");
let sc = cw.span_cutouts[0];
assert!((sc.from - 3.94).abs() < 1e-9);
assert!((sc.to - 4.06).abs() < 1e-9);
assert!((sc.off_a - 0.075).abs() < 1e-9);
assert!((sc.off_b - 0.06).abs() < 1e-9);
// Der Abzweig selbst hat keine Aussparung.
assert!(find(&out, "B").span_cutouts.is_empty());
}
}
-195
View File
@@ -1,195 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kernel2d"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"robust",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "robust"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
-40
View File
@@ -1,40 +0,0 @@
# Eigener leerer Workspace-Block: entkoppelt kernel2d vollstaendig vom
# cad-tauri-Workspace (src-tauri/Cargo.toml), damit `cargo test`/`wasm-pack`
# aus diesem Verzeichnis heraus nicht faelschlich dessen Workspace erben.
# Muster: render2d/render3d. (Parent-`exclude` allein greift beim Bauen aus
# dem Unterverzeichnis nicht zuverlaessig.)
[workspace]
[package]
name = "kernel2d"
version = "0.1.0"
edition = "2021"
description = "2D-Geometrie-Kern (Port von src/geometry/kernel2d.ts) — handgeschriebene f64-Mathematik, headless per `cargo test` UND per wasm-pack (Feature \"web\") zu WASM baubar. Hinter identischer TS-Fassade; TS-Legacy bleibt Differential-Referenz."
# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul. rlib: als
# Pfad-Abhaengigkeit und fuer den Test-/Example-Build (parity). Muster:
# render2d/render3d/geometry.
[lib]
crate-type = ["cdylib", "rlib"]
[features]
# Standard: reiner f64-Rechenkern, headless per `cargo test` pruefbar.
default = []
# Browser-Bindings: dieselbe Geometrie hinter wasm-bindgen-Batch-Fassaden,
# aus TS via wasm-pack aufgerufen. Nur fuer wasm32-unknown-unknown sinnvoll.
web = ["dep:wasm-bindgen", "dep:serde_json", "dep:console_error_panic_hook"]
# ADDITIV, NICHT im web-Default: exakte Orientierungs-Praedikate (robust::orient2d)
# nur intern fuer Korrektheits-Golden-Cases (detectRooms/point-in-polygon). Der
# Zufalls-Diff-Test gegen TS-naiv laeuft OHNE dieses Feature (siehe PORT_PLAN §3).
robust-predicates = ["dep:robust"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
robust = { version = "1", optional = true }
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
[dev-dependencies]
serde_json = "1"
File diff suppressed because it is too large Load Diff
@@ -32,7 +32,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
height: 1.0,
}],
layers: None,
holes: vec![],
},
WallInput {
start: [0.0, 3.0],
@@ -43,7 +42,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
color,
openings: vec![],
layers: None,
holes: vec![],
},
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
@@ -59,7 +57,6 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
color,
openings: vec![],
layers: None,
holes: vec![],
},
];
let slabs = vec![SlabInput {
+2 -28
View File
@@ -13,14 +13,13 @@
use std::sync::Arc;
use render3d::gpu::{RenderStyle, Renderer};
use render3d::gpu::Renderer;
use render3d::math::orbit_eye;
use render3d::types::{Camera, Projection, WallInput};
use winit::application::ApplicationHandler;
use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{Window, WindowId};
/// Demo-Szene: ein rechteckiger Raum (4 Aussenwaende) plus eine Innenwand. Achsen
@@ -39,7 +38,6 @@ fn demo_walls() -> Vec<WallInput> {
color: grey,
openings: vec![],
layers: None,
holes: vec![],
};
// Raum 6 x 4 m.
vec![
@@ -197,9 +195,6 @@ struct App {
orbit: Orbit,
dragging: bool,
last_cursor: (f64, f64),
/// Darstellung: false = Shaded (Default, unveraendert), true = Textured
/// (prozedurales Schachbrett auf den Wandflaechen). Per Taste `T` umschaltbar.
textured: bool,
}
impl ApplicationHandler for App {
@@ -227,27 +222,6 @@ impl ApplicationHandler for App {
state.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::KeyboardInput {
event:
KeyEvent {
physical_key: PhysicalKey::Code(KeyCode::KeyT),
state: ElementState::Pressed,
repeat: false,
..
},
..
} => {
// `T` schaltet Shaded <-> Textured um (Laufzeit, kein Re-Meshing:
// der Renderer haelt beide Vertex-Puffer bereit).
self.textured = !self.textured;
let style = if self.textured {
RenderStyle::Textured
} else {
RenderStyle::Shaded
};
state.renderer.set_render_style(style);
state.window.request_redraw();
}
WindowEvent::MouseInput { state: s, button, .. } => {
if button == MouseButton::Left {
self.dragging = s == ElementState::Pressed;
+5 -283
View File
@@ -15,13 +15,11 @@ use wgpu::util::DeviceExt;
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
use crate::math::{view_projection, Mat4};
use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh};
use crate::mesh::{build_scene_mesh, build_walls_mesh};
use crate::section::SectionPlane;
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX};
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
use crate::types::{
Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_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
@@ -32,9 +30,7 @@ pub enum RenderStyle {
Shaded,
/// Clay-Look: einheitlich helles Material, beleuchtet.
White,
/// Echte Bild-Textur (prozedurales Schachbrett) auf den Wandflaechen,
/// beleuchtet wie `Shaded` (eigene Pipeline + Textur-Bind-Group group 1,
/// siehe `textured_pipeline`).
/// Mangels Textur-Pipeline VORERST identisch zu `Shaded` behandelt (kein Fake).
Textured,
/// Nur Modell-Kanten (Feature-Edges), keine gefuellten Flaechen.
Wireframe,
@@ -136,55 +132,6 @@ struct MeshBuffers {
index_count: u32,
}
/// GPU-seitige Puffer des TEXTURIERTEN Wand-Meshes (`RenderStyle::Textured`).
/// Eigenes Vertex-Layout `[pos vec3, normal vec3, uv vec2]`
/// (`TEXTURED_FLOATS_PER_VERTEX`), sonst wie `MeshBuffers`. Zweiter, additiver
/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet.
struct TexturedMeshBuffers {
vbo: wgpu::Buffer,
ibo: wgpu::Buffer,
index_count: u32,
}
/// Kantenlaenge der prozeduralen Test-Textur (Schachbrett), in Texeln.
const TEXTURE_SIZE: u32 = 256;
/// Erzeugt ein 256x256-RGBA-Schachbrett IM CODE (kein Asset, keine `image`-Crate):
/// 8x8 Felder abwechselnd hell/dunkel, mit einem duennen Raster als Kachel-Grenze.
/// Rueckgabe = flaches RGBA8-Byte-Array (`4 * TEXTURE_SIZE * TEXTURE_SIZE`), direkt
/// fuer `queue.write_texture` (Zeilen sind `4 * TEXTURE_SIZE` Byte breit, ohne
/// Padding, weil `256 * 4 = 1024` bereits ein Vielfaches von 256 ist). Bewusst
/// selbststaendig, damit der Spike ohne Dateipfade/Asset-Pipeline laeuft.
fn build_checker_texture() -> Vec<u8> {
let size = TEXTURE_SIZE as usize;
// 8 Felder je Kante -> jedes Feld 32 Texel breit. Das macht 1 Textur-Kachel
// (= 1 m Welt, siehe UV-Massstab) in ein 8x8-Schachbrett sichtbar.
let cell = size / 8;
let mut data = vec![0u8; size * size * 4];
// Zwei Grautoene als Schachbrett + eine dunkle Rasterlinie an den Feldgrenzen.
let light = [220u8, 220, 224, 255];
let dark = [150u8, 152, 160, 255];
let line = [70u8, 72, 80, 255];
for y in 0..size {
for x in 0..size {
let checker = ((x / cell) + (y / cell)) % 2 == 0;
// Duenne Rasterlinie (2 Texel) exakt auf den Feldkanten — betont die
// weltmassstaebliche Kachelung an Gehrungen/Ecken sichtbar.
let on_line = (x % cell) < 2 || (y % cell) < 2;
let px = if on_line {
line
} else if checker {
light
} else {
dark
};
let o = (y * size + x) * 4;
data[o..o + 4].copy_from_slice(&px);
}
}
data
}
/// 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).
@@ -225,29 +172,9 @@ pub struct Renderer {
/// 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,
/// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList,
/// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet
/// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
/// Shader/Layout unterscheiden sich.
textured_pipeline: wgpu::RenderPipeline,
/// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler.
/// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende).
texture_bind_group: wgpu::BindGroup,
/// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim
/// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine
/// Queue bekommt.
texture: wgpu::Texture,
/// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim
/// ersten `render` einmalig gesetzt (lazy Upload).
texture_uploaded: bool,
bind_group: wgpu::BindGroup,
uniform: wgpu::Buffer,
mesh: Option<MeshBuffers>,
/// Texturiertes Wand-Mesh (`[pos, normal, uv]`) — zweiter, additiver Puffer
/// neben `mesh`. Nur im Stil `Textured` gezeichnet; bei `upload_*` zusammen mit
/// `mesh` erzeugt. None = kein Mesh geladen.
textured_mesh: Option<TexturedMeshBuffers>,
/// 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.
@@ -535,144 +462,6 @@ impl Renderer {
cache: None,
});
// ── Texturierter Wand-Pfad (RenderStyle::Textured) ──────────────────────
// Bild-Textur-Bind-Group-Layout (group 1): sampelbare 2D-Float-Textur +
// Filter-Sampler. `Globals` bleibt group 0 (oben) UND unveraendert.
let texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("texture.layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
// Pipeline-Layout des texturierten Pfades: group 0 = Globals (wie oben),
// group 1 = Textur. Reihenfolge = Bindungs-Index, deshalb beide hier.
let textured_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("3d.textured.layout"),
bind_group_layouts: &[Some(&bind_group_layout), Some(&texture_bind_group_layout)],
immediate_size: 0,
});
let textured_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mesh_textured.wgsl"),
source: wgpu::ShaderSource::Wgsl(MESH_TEXTURED_WGSL.into()),
});
// Vertex-Layout: [pos vec3, normal vec3, uv vec2], stride 8*4.
let textured_vertex_layout = wgpu::VertexBufferLayout {
array_stride: (TEXTURED_FLOATS_PER_VERTEX * 4) as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2],
};
// WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format,
// Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline)
// — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der
// Pipeline-State kompatibel sein (sonst wgpu-Validierungspanik). Einziger
// Unterschied: Shader/Vertex-Layout und das zusaetzliche group-1-Layout.
let textured_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.textured.pipeline"),
layout: Some(&textured_pipeline_layout),
vertex: wgpu::VertexState {
module: &textured_module,
entry_point: Some("vs_main"),
buffers: &[textured_vertex_layout],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &textured_module,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: SAMPLE_COUNT,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
// Prozedurale 256x256-Schachbrett-Textur ANLEGEN (kein Asset). RGBA8-srgb,
// damit die im Shader gesampelten Werte im linearen Raum landen (die
// Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das
// Hochladen der Texel (queue.write_texture) passiert einmalig beim ersten
// `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue
// bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs,
// spike3d) unveraendert.
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("checker.texture"),
size: wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
// Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung).
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("checker.sampler"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
address_mode_w: wgpu::AddressMode::Repeat,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("texture.bind"),
layout: &texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&texture_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("globals.buffer"),
size: std::mem::size_of::<Globals>() as u64,
@@ -694,14 +483,9 @@ impl Renderer {
grid_pipeline,
highlight_pipeline,
cap_pipeline,
textured_pipeline,
texture_bind_group,
texture,
texture_uploaded: false,
bind_group,
uniform,
mesh: None,
textured_mesh: None,
edges: None,
grid: None,
highlight: None,
@@ -745,7 +529,6 @@ impl Renderer {
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
if mesh.indices.is_empty() {
self.mesh = None;
self.textured_mesh = None;
self.edges = None;
return;
}
@@ -781,27 +564,6 @@ impl Renderer {
ibo,
index_count: mesh.indices.len() as u32,
});
// Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je
// Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten
// Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad,
// und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit.
let tex_mesh = textured_from_mesh(&mesh);
let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.textured.vbo"),
contents: bytemuck::cast_slice(&tex_mesh.verts),
usage: wgpu::BufferUsages::VERTEX,
});
let tibo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.textured.ibo"),
contents: bytemuck::cast_slice(&tex_mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
self.textured_mesh = Some(TexturedMeshBuffers {
vbo: tvbo,
ibo: tibo,
index_count: tex_mesh.indices.len() as u32,
});
}
/// Setzt die Richtung ZUM Directional-Light (Sonne, world). Das hemisphaerische
@@ -1021,33 +783,6 @@ impl Renderer {
self.globals.mode[0] = self.style.mesh_mode();
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
// Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine
// Queue bekommt — Signatur unveraendert). Danach steht sie fuer den Stil
// `Textured` bereit; kostet ab dem zweiten Frame nichts mehr.
if !self.texture_uploaded {
let texel_data = build_checker_texture();
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&texel_data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * TEXTURE_SIZE),
rows_per_image: Some(TEXTURE_SIZE),
},
wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
depth_or_array_layers: 1,
},
);
self.texture_uploaded = true;
}
self.ensure_depth(device, w, h);
self.ensure_msaa(device, w, h);
let depth_view = &self.depth.as_ref().unwrap().view;
@@ -1090,20 +825,7 @@ impl Renderer {
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
// die folgenden Kanten sauber obenauf liegen.
if self.style.draws_faces() {
if self.style == RenderStyle::Textured {
// Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer
// ([pos, normal, uv]) + zusaetzliche Textur-Bind-Group (group 1).
// Globals bleiben group 0. Fehlt der texturierte Puffer (kein
// Mesh geladen), wird nichts gezeichnet.
if let Some(t) = &self.textured_mesh {
pass.set_pipeline(&self.textured_pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_bind_group(1, &self.texture_bind_group, &[]);
pass.set_vertex_buffer(0, t.vbo.slice(..));
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..t.index_count, 0, 0..1);
}
} else if let Some(m) = &self.mesh {
if let Some(m) = &self.mesh {
let face_pipeline = if self.style == RenderStyle::Hidden {
&self.hidden_face_pipeline
} else {
+6 -180
View File
@@ -38,27 +38,23 @@ pub use math::{
view_matrix, view_projection, Mat4,
};
pub use mesh::{
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh,
build_walls_mesh_textured, extrude_slab, extrude_wall, textured_from_mesh, triangulate,
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh, extrude_slab,
extrude_wall, triangulate,
};
pub use section::{
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
};
pub use types::{
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput,
TexturedMesh, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, WallInput,
FLOATS_PER_VERTEX,
};
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
#[cfg(test)]
mod tests {
use super::mesh::{
build_walls_mesh, build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
};
use super::types::{
Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
};
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX};
use super::types::{Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX};
/// Bequemer Bau einer achsparallelen Wand entlang +X.
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
@@ -71,7 +67,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
}
}
@@ -84,67 +79,6 @@ mod tests {
)
}
#[test]
fn texturierter_pfad_ist_geometrisch_deckungsgleich_mit_shaded() {
// Der texturierte Wand-Pfad (`build_walls_mesh_textured`, [pos,normal,uv])
// muss GEOMETRISCH bitgleich zum Alt-Pfad (`build_walls_mesh`,
// [pos,normal,color]) sein: gleiche Vertex-/Index-Zahl und -Reihenfolge,
// identische Positionen und Normalen. Nur der letzte Kanal (Farbe -> UV)
// unterscheidet sich. So ist belegt, dass der additive UV-Kanal den
// Alt-Pfad nicht beruehrt.
let walls = [wall_x(3.0, 0.2, 2.5)];
let shaded = build_walls_mesh(&walls);
let tex = build_walls_mesh_textured(&walls);
assert_eq!(tex.indices, shaded.indices, "Index-Puffer identisch");
assert_eq!(
tex.vertex_count(),
shaded.vertex_count(),
"gleiche Vertex-Zahl"
);
for i in 0..shaded.vertex_count() {
let sb = i * FLOATS_PER_VERTEX;
let tb = i * TEXTURED_FLOATS_PER_VERTEX;
// Position (0..3) und Normale (3..6) bitgleich uebernommen.
for k in 0..6 {
assert_eq!(
shaded.verts[sb + k],
tex.verts[tb + k],
"Vertex {i} Kanal {k}: pos/normal muss identisch sein"
);
}
}
}
#[test]
fn texturierte_uv_ist_weltmassstaeblich() {
// UV-Massstab: `u`/`v` in Metern (1 Kachel = 1 m). Fuer eine Wand entlang +X
// liegt die +Z-Mantelflaeche bei Normale (0,0,1); dort ist die Tangente
// t=(-n.z,0,n.x)=(-1,0,0), also u = -x und v = Hoehe. Die Deckflaeche
// (Normale +Y) projiziert auf u=x, v=z. Wir pruefen, dass es UEBERHAUPT
// Vertices mit v == Wandhoehe (2.5) bzw. v == 0 gibt (Hoehe wandert als v
// mit) — belegt den weltmassstaeblichen Hoehen-Kanal.
let tex = build_walls_mesh_textured(&[wall_x(3.0, 0.2, 2.5)]);
let n = tex.vertex_count();
let mut saw_top = false;
let mut saw_bottom = false;
for i in 0..n {
let b = i * TEXTURED_FLOATS_PER_VERTEX;
let ny = tex.verts[b + 4];
let v = tex.verts[b + 7];
// Nur Mantelflaechen (ny ~ 0) tragen die Hoehe als v.
if ny.abs() < 0.5 {
if (v - 2.5).abs() < 1e-5 {
saw_top = true;
}
if v.abs() < 1e-5 {
saw_bottom = true;
}
}
}
assert!(saw_top, "Mantel-Vertex mit v == Wandhoehe (2.5 m) erwartet");
assert!(saw_bottom, "Mantel-Vertex mit v == 0 (Sockel) erwartet");
}
#[test]
fn eine_wand_hat_quader_zaehlung() {
// Eine Wand -> ein Quader: 24 Vertices, 36 Indizes (12 Dreiecke).
@@ -170,7 +104,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
},
]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
@@ -249,7 +182,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
};
let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w);
@@ -271,7 +203,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
};
let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0);
@@ -375,97 +306,6 @@ mod tests {
assert_eq!(mesh.indices.len(), INDICES_PER_BOX);
}
// --- Echte Loecher in EINEM Wandkoerper (WallInput::holes) -----------------
#[test]
fn zwei_versetzte_ueberlappende_loecher_in_einem_koerper() {
// KERNFALL: EINE Wand (Laenge 4, Hoehe 2.5) mit ZWEI Loechern, deren
// Achsen-Intervalle sich UEBERLAPPEN, aber auf UNTERSCHIEDLICHER Hoehe
// liegen — Fenster A u=[1.0,2.0] z=[0.9,2.1], Fenster B u=[1.5,2.5]
// z=[0.3,0.7]. Das kann die Segment-Zerlegung nicht abbilden; als Loecher
// in EINEM Koerper muss es sauber zerfallen (keine Geometrie in den
// Loechern, Aussenmasse erhalten, beide Laibungen vollstaendig).
let mut w = wall_x(4.0, 0.2, 2.5);
w.holes = vec![
Hole { from: 1.0, to: 2.0, z_bottom: 0.9, z_top: 2.1 },
Hole { from: 1.5, to: 2.5, z_bottom: 0.3, z_top: 0.7 },
];
let mesh = build_walls_mesh(&[w]);
// Mehr als ein Vollkoerper (Loecher fuegen Teilrechtecke + Laibungen hinzu).
assert!(mesh.vertex_count() > VERTS_PER_BOX);
// Index-Integritaet.
let max_idx = *mesh.indices.iter().max().unwrap();
assert!((max_idx as usize) < mesh.vertex_count());
// Aussenmasse unveraendert (die soliden Teilrechtecke decken den Rand ab).
let (min, max) = mesh.bounds();
assert!((min[0]).abs() < 1e-5 && (max[0] - 4.0).abs() < 1e-5, "u 0..4");
assert!((min[1]).abs() < 1e-5 && (max[1] - 2.5).abs() < 1e-5, "z 0..2.5");
assert!((min[2] + 0.1).abs() < 1e-5 && (max[2] - 0.1).abs() < 1e-5, "Dicke +/-0.1");
// KEIN Vertex liegt (mit Sicherheitsabstand) im Inneren eines der Loecher.
let holes = [(1.0f32, 2.0f32, 0.9f32, 2.1f32), (1.5, 2.5, 0.3, 0.7)];
let m = 0.05;
let n = mesh.vertex_count();
for i in 0..n {
let (p, _) = vert(&mesh, i);
for (a0, a1, zb, zt) in holes {
let inside =
p[0] > a0 + m && p[0] < a1 - m && p[1] > zb + m && p[1] < zt - m;
assert!(!inside, "Vertex {i} bei {p:?} liegt im Loch [{a0},{a1}]x[{zb},{zt}]");
}
}
// Beide Loecher besitzen ihre vier inneren Laibungen: fuer jede Loch-Kante
// muss mindestens ein Vertex GENAU auf der Kante (in u bzw. z) UND im
// jeweils anderen Loch-Intervall liegen (die Laibungsflaeche).
let has_vert = |pred: &dyn Fn([f32; 3]) -> bool| (0..n).any(|i| pred(vert(&mesh, i).0));
for (a0, a1, zb, zt) in holes {
// linke/rechte Laibung: Vertex bei u==a0 bzw. u==a1, z im Loch.
assert!(
has_vert(&|p| (p[0] - a0).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4),
"linke Laibung fehlt fuer Loch [{a0},{a1}]"
);
assert!(
has_vert(&|p| (p[0] - a1).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4),
"rechte Laibung fehlt fuer Loch [{a0},{a1}]"
);
// untere/obere Laibung: Vertex bei z==zb bzw. z==zt, u im Loch.
assert!(
has_vert(&|p| (p[1] - zb).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4),
"untere Laibung fehlt fuer Loch [{a0},{a1}]"
);
assert!(
has_vert(&|p| (p[1] - zt).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4),
"obere Laibung fehlt fuer Loch [{a0},{a1}]"
);
}
}
#[test]
fn tuer_loch_bis_zum_boden_ohne_untere_laibung() {
// Tuer-Loch reicht bis zum Boden (z_bottom == base_elevation 0): die
// untere Kante liegt am Wandrand -> KEINE untere Laibung, stattdessen eine
// Luecke im Boden-Streifen. Unter der Kopfhoehe (z_top 2.1) darf im
// Tuerbereich keine Geometrie liegen.
let mut w = wall_x(4.0, 0.2, 2.5);
w.holes = vec![Hole { from: 1.0, to: 1.8, z_bottom: 0.0, z_top: 2.1 }];
let mesh = build_walls_mesh(&[w]);
let n = mesh.vertex_count();
let m = 0.05;
for i in 0..n {
let (p, _) = vert(&mesh, i);
let in_span = p[0] > 1.0 + m && p[0] < 1.8 - m;
if in_span {
assert!(
p[1] < m || p[1] > 2.1 - 1e-4,
"Vertex {i} bei {p:?} liegt unter der Tuer-Kopfhoehe im Loch"
);
}
}
}
// --- Ecken-Verschneidung (Gehrung) -----------------------------------------
#[test]
@@ -485,7 +325,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
};
let b = WallInput {
start: [0.0, 0.0],
@@ -496,7 +335,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
};
let mesh = build_walls_mesh(&[a, b]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
@@ -556,7 +394,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0]),
@@ -585,7 +422,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0], 0.0),
@@ -674,7 +510,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: layers(),
holes: vec![],
};
let mut b = a.clone();
b.end = [0.0, 3.0];
@@ -992,14 +827,5 @@ mod tests {
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&cap_module)
.unwrap_or_else(|e| panic!("cap: WGSL-Validierung fehlgeschlagen: {e:?}"));
// Texturierter Wand-Shader (RenderStyle::Textured, Bild-Textur in group 1)
// ebenso headless validieren — faengt Textur-/Sampler-Bindungsfehler ab.
let tex_src = super::shaders::MESH_TEXTURED_WGSL;
let tex_module = naga::front::wgsl::parse_str(tex_src)
.unwrap_or_else(|e| panic!("textured: WGSL-Parse-Fehler: {e:?}"));
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&tex_module)
.unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}"));
}
}
+6 -414
View File
@@ -59,8 +59,7 @@ use std::collections::HashMap;
use crate::openings::{split_range_by_voids, MIN_SPAN};
use crate::types::{
Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, TexturedMesh, WallInput, WallLayer,
FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, FLOATS_PER_VERTEX,
};
/// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2
@@ -110,16 +109,7 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter
let z0 = wall.base_elevation;
let z1 = wall.base_elevation + wall.height;
// Mit echten Loechern (`holes`) wird die Wand NICHT an Oeffnungen segmentiert,
// sondern als EIN voller Achsen-Abschnitt extrudiert; die Loecher stanzt
// `extrude_layer_segment_with_holes` in die Langseiten (holes/openings
// schliessen sich aus, siehe `types::WallInput::holes`).
let has_holes = !wall.holes.is_empty();
let segments = if has_holes {
vec![(0.0, length, z0, z1)]
} else {
wall_solid_segments(length, &wall.openings, z0, z1)
};
let segments = wall_solid_segments(length, &wall.openings, z0, z1);
// Schicht-Stapel: ohne `layers` (oder leer) EIN synthetisches Layer aus
// der Gesamtdicke/-Farbe — bitgenau das bisherige Vollkoerper-Verhalten.
@@ -149,20 +139,10 @@ fn extrude_wall_with_miters(mesh: &mut Mesh, wall: &WallInput, miters: WallMiter
// Wandende grenzen (innere Laibungs-Teilstuecke bleiben eckig).
let start_cut = if a0 <= 1e-6 { miters.start } else { None };
let end_cut = if a1 >= length - 1e-6 { miters.end } else { None };
if has_holes {
// Voller Achsen-Abschnitt (a0=0, a1=length) mit echten Loechern
// in den Langseiten — pro Schicht-Band dieselben Loecher (quer
// gestapelt bilden ihre Laibungen die volle Loch-Tiefe).
extrude_layer_segment_with_holes(
mesh, wall, n, length, y0, y1, off_a, off_b, start_cut, end_cut, layer.color,
&wall.holes,
);
} else {
extrude_layer_segment(
mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut,
layer.color,
);
}
extrude_layer_segment(
mesh, wall, n, length, a0, a1, y0, y1, off_a, off_b, start_cut, end_cut,
layer.color,
);
}
off_a = off_b;
}
@@ -304,328 +284,6 @@ fn extrude_layer_segment(
push_quad(mesh, b3, b0, t0, t3, start_normal, color);
}
/// Haengt ein Quad (vier Ecken als Ring, beliebige Reihenfolge) als zwei
/// orientierte Dreiecke an — Winding via `push_tri_oriented` an `want_normal`
/// ausgerichtet (robust gegen die Eck-Reihenfolge, anders als `push_quad`).
/// Genutzt fuer die Loch-Zerlegung (Langseiten-Teilrechtecke, Deckel-/Boden-
/// Streifen, Laibungen), wo die Ecken aus Gitter-Koordinaten kommen.
fn push_quad_oriented(
mesh: &mut Mesh,
a: [f32; 3],
b: [f32; 3],
c: [f32; 3],
d: [f32; 3],
want_normal: [f32; 3],
color: Rgb,
) {
push_tri_oriented(mesh, a, b, c, want_normal, color);
push_tri_oriented(mesh, a, c, d, want_normal, color);
}
/// Zerlegt das Rechteck `[u_lo,u_hi] x [z_lo,z_hi]` MINUS der `holes`
/// (`(u0,u1,z0,z1)`) in achsparallele, paarweise disjunkte Teilrechtecke (die
/// SOLIDEN Bereiche). Verfahren: Koordinaten-Kompression — alle Loch-Kanten in u
/// und z bilden ein Gitter; jede Gitterzelle, deren Mittelpunkt in KEINEM Loch
/// liegt, ist ein solides Teilrechteck. Dadurch koennen sich Loecher in u
/// ueberlappen und auf unterschiedlicher Hoehe liegen (Kernfall zweier versetzt
/// uebereinanderliegender Fenster), ohne dass degenerierte oder ueberlappende
/// Teilrechtecke entstehen.
fn solid_subrects(
u_lo: f32,
u_hi: f32,
z_lo: f32,
z_hi: f32,
holes: &[(f32, f32, f32, f32)],
) -> Vec<(f32, f32, f32, f32)> {
let sort_dedup = |mut v: Vec<f32>| -> Vec<f32> {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
v.dedup_by(|a, b| (*a - *b).abs() <= MIN_SPAN);
v
};
let mut us = vec![u_lo, u_hi];
let mut zs = vec![z_lo, z_hi];
for (a0, a1, hb, ht) in holes {
us.push(*a0);
us.push(*a1);
zs.push(*hb);
zs.push(*ht);
}
let us = sort_dedup(us);
let zs = sort_dedup(zs);
let mut out = Vec::new();
for i in 0..us.len().saturating_sub(1) {
let (ua, ub) = (us[i], us[i + 1]);
if ub - ua <= MIN_SPAN {
continue;
}
for j in 0..zs.len().saturating_sub(1) {
let (za, zb) = (zs[j], zs[j + 1]);
if zb - za <= MIN_SPAN {
continue;
}
let cu = (ua + ub) * 0.5;
let cz = (za + zb) * 0.5;
let in_hole = holes
.iter()
.any(|(a0, a1, hb, ht)| cu > *a0 && cu < *a1 && cz > *hb && cz < *ht);
if !in_hole {
out.push((ua, ub, za, zb));
}
}
}
out
}
/// Zieht die (ggf. ueberlappenden) `gaps` vom Intervall `[lo,hi]` ab und liefert
/// die verbleibenden Teilintervalle (sortiert, disjunkt). Fuer die Deckel-/
/// Boden-/Kappen-Flaechen, in denen ein randstaendiges Loch eine Luecke reisst.
fn subtract_intervals(lo: f32, hi: f32, gaps: &[(f32, f32)]) -> Vec<(f32, f32)> {
let mut gs: Vec<(f32, f32)> = gaps
.iter()
.map(|(a, b)| (a.max(lo), b.min(hi)))
.filter(|(a, b)| b - a > MIN_SPAN)
.collect();
gs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let mut out = Vec::new();
let mut cursor = lo;
for (a, b) in gs {
if a > cursor + MIN_SPAN {
out.push((cursor, a));
}
cursor = cursor.max(b);
}
if hi > cursor + MIN_SPAN {
out.push((cursor, hi));
}
out
}
/// Extrudiert EIN Schicht-Band (volle Achse `0..length`, Hoehe `[y0,y1]`,
/// Dicken-Versatz `[off_a,off_b]`) mit ECHTEN rechteckigen LOECHERN (`holes`,
/// siehe `types::Hole`) statt der Oeffnungs-Segmentierung. Aufbau:
/// • Langseiten (+n bei `off_a`, n bei `off_b`): das Flaechen-Rechteck MINUS
/// der Loecher, per `solid_subrects` in Teilrechtecke zerlegt (je 2 Dreiecke).
/// • Deckel (`y1`) / Boden (`y0`): voller Streifen minus der u-Intervalle der
/// Loecher, die die jeweilige Kante beruehren (z. B. eine Tuer am Boden).
/// • End-Kappen (`a=0`/`a=length`, gemitert): volle Hoehe minus der z-Intervalle
/// randstaendiger Loecher (Loecher liegen normalerweise im Inneren → volle
/// Kappe, unveraendert gegenueber der Gehrung).
/// • Laibungen je Loch: vier Quads (links/rechts/unten/oben) — aber nur fuer die
/// Seiten, die im Wand-INNEREN liegen (die randstaendige Seite hat stattdessen
/// die Luecke in Deckel/Boden/Kappe, keine Laibung).
/// Winding/Normalen konsistent ueber `push_quad_oriented`/`push_tri_oriented`
/// (Backface-Culling + Beleuchtung korrekt).
#[allow(clippy::too_many_arguments)]
fn extrude_layer_segment_with_holes(
mesh: &mut Mesh,
wall: &WallInput,
n: Point2,
length: f32,
y0: f32,
y1: f32,
off_a: f32,
off_b: f32,
start_cut: Option<MiterLine>,
end_cut: Option<MiterLine>,
color: Rgb,
holes: &[Hole],
) {
let ux = wall.end[0] - wall.start[0];
let uz = wall.end[1] - wall.start[1];
let ul = (ux * ux + uz * uz).sqrt().max(1e-9);
let u: Point2 = [ux / ul, uz / ul];
let off = |p: Point2, s: f32| -> Point2 { [p[0] + n[0] * s, p[1] + n[1] * s] };
let clip = |base: Point2, cut: Option<MiterLine>| -> Point2 {
match cut {
None => base,
Some(c) => line_intersect_2(base, u, c.point, c.dir).unwrap_or(base),
}
};
// Plan-Punkt bei Achsen-Position `a`, Dicken-Versatz `offv`; NUR an den echten
// Wandenden (a≈0 / a≈length) gegen die Gehrungslinie verschnitten — innere
// Loch-Kanten bleiben rechtwinklig (unabhaengig von der Gehrung).
let g_at = |a: f32, offv: f32| -> Point2 {
let base = off(axis_point(wall, length, a), offv);
if a <= 1e-6 {
clip(base, start_cut)
} else if a >= length - 1e-6 {
clip(base, end_cut)
} else {
base
}
};
let w = |g: Point2, y: f32| -> [f32; 3] { [g[0], y, g[1]] };
// Loecher auf [0,length] x [y0,y1] klemmen, entartete verwerfen.
let mut hs: Vec<(f32, f32, f32, f32)> = Vec::new();
for h in holes {
let a0 = h.from.max(0.0);
let a1 = h.to.min(length);
let zb = h.z_bottom.max(y0);
let zt = h.z_top.min(y1);
if a1 - a0 > MIN_SPAN && zt - zb > MIN_SPAN {
hs.push((a0, a1, zb, zt));
}
}
// ── Langseiten (+n / n): Rechteck-Gitter minus Loecher. ──────────────────
let np = [n[0], 0.0, n[1]];
let nn = [-n[0], 0.0, -n[1]];
for (ua, ub, za, zb) in solid_subrects(0.0, length, y0, y1, &hs) {
push_quad_oriented(
mesh,
w(g_at(ua, off_a), za),
w(g_at(ub, off_a), za),
w(g_at(ub, off_a), zb),
w(g_at(ua, off_a), zb),
np,
color,
);
push_quad_oriented(
mesh,
w(g_at(ua, off_b), za),
w(g_at(ub, off_b), za),
w(g_at(ub, off_b), zb),
w(g_at(ua, off_b), zb),
nn,
color,
);
}
// ── Deckel (y1) und Boden (y0): voller Streifen minus randstaendiger Loecher.
let top_gaps: Vec<(f32, f32)> = hs
.iter()
.filter(|(_, _, _, zt)| *zt >= y1 - MIN_SPAN)
.map(|(a0, a1, _, _)| (*a0, *a1))
.collect();
for (ua, ub) in subtract_intervals(0.0, length, &top_gaps) {
push_quad_oriented(
mesh,
w(g_at(ua, off_a), y1),
w(g_at(ub, off_a), y1),
w(g_at(ub, off_b), y1),
w(g_at(ua, off_b), y1),
[0.0, 1.0, 0.0],
color,
);
}
let bot_gaps: Vec<(f32, f32)> = hs
.iter()
.filter(|(_, _, zb, _)| *zb <= y0 + MIN_SPAN)
.map(|(a0, a1, _, _)| (*a0, *a1))
.collect();
for (ua, ub) in subtract_intervals(0.0, length, &bot_gaps) {
push_quad_oriented(
mesh,
w(g_at(ua, off_a), y0),
w(g_at(ub, off_a), y0),
w(g_at(ub, off_b), y0),
w(g_at(ua, off_b), y0),
[0.0, -1.0, 0.0],
color,
);
}
// ── End-Kappen (a=0 / a=length): volle Hoehe minus randstaendiger Loecher. ─
// Aussen-Normale je Kappe geometrisch aus den vollen Kappen-Ecken (wie im
// rechtwinkligen/gehrten Fall in `extrude_layer_segment`).
let start_normal = quad_normal(
w(g_at(0.0, off_b), y0),
w(g_at(0.0, off_a), y0),
w(g_at(0.0, off_b), y1),
);
let start_gaps: Vec<(f32, f32)> = hs
.iter()
.filter(|(a0, _, _, _)| *a0 <= MIN_SPAN)
.map(|(_, _, zb, zt)| (*zb, *zt))
.collect();
for (za, zb) in subtract_intervals(y0, y1, &start_gaps) {
push_quad_oriented(
mesh,
w(g_at(0.0, off_b), za),
w(g_at(0.0, off_a), za),
w(g_at(0.0, off_a), zb),
w(g_at(0.0, off_b), zb),
start_normal,
color,
);
}
let end_normal = quad_normal(
w(g_at(length, off_a), y0),
w(g_at(length, off_b), y0),
w(g_at(length, off_a), y1),
);
let end_gaps: Vec<(f32, f32)> = hs
.iter()
.filter(|(_, a1, _, _)| *a1 >= length - MIN_SPAN)
.map(|(_, _, zb, zt)| (*zb, *zt))
.collect();
for (za, zb) in subtract_intervals(y0, y1, &end_gaps) {
push_quad_oriented(
mesh,
w(g_at(length, off_a), za),
w(g_at(length, off_b), za),
w(g_at(length, off_b), zb),
w(g_at(length, off_a), zb),
end_normal,
color,
);
}
// ── Laibungen je Loch: nur die im Wand-Inneren liegenden Seiten. ──────────
for &(a0, a1, zb, zt) in &hs {
// Linke Laibung (bei a0), Normale +u (in die Oeffnung).
if a0 > MIN_SPAN {
push_quad_oriented(
mesh,
w(g_at(a0, off_a), zb),
w(g_at(a0, off_b), zb),
w(g_at(a0, off_b), zt),
w(g_at(a0, off_a), zt),
[u[0], 0.0, u[1]],
color,
);
}
// Rechte Laibung (bei a1), Normale u.
if a1 < length - MIN_SPAN {
push_quad_oriented(
mesh,
w(g_at(a1, off_a), zb),
w(g_at(a1, off_b), zb),
w(g_at(a1, off_b), zt),
w(g_at(a1, off_a), zt),
[-u[0], 0.0, -u[1]],
color,
);
}
// Untere Laibung (bei zb = Bruestungsoberkante), Normale +Y.
if zb > y0 + MIN_SPAN {
push_quad_oriented(
mesh,
w(g_at(a0, off_a), zb),
w(g_at(a1, off_a), zb),
w(g_at(a1, off_b), zb),
w(g_at(a0, off_b), zb),
[0.0, 1.0, 0.0],
color,
);
}
// Obere Laibung (bei zt = Sturzunterkante), Normale Y.
if zt < y1 - MIN_SPAN {
push_quad_oriented(
mesh,
w(g_at(a0, off_a), zt),
w(g_at(a1, off_a), zt),
w(g_at(a1, off_b), zt),
w(g_at(a0, off_b), zt),
[0.0, -1.0, 0.0],
color,
);
}
}
}
/// Aussen-Normale eines planaren, CCW-von-aussen georderten Quads `(a,b,_,d)`
/// (nur die drei ersten Ecken werden gebraucht: `d` ist die vierte, ueber `a`
/// benachbarte Ecke). `cross(b-a, d-a)` — Herleitung/Verifikation siehe
@@ -917,72 +575,6 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
mesh
}
/// TEXTURIERTER Wand-Pfad (`RenderStyle::Textured`): liefert DIESELBE Geometrie wie
/// `build_walls_mesh` — Positionen und Normalen bitgleich, gleiche Vertex-/Dreiecks-
/// Reihenfolge — aber mit einer planaren, WELTMASSSTAEBLICHEN UV je Vertex statt der
/// Bauteilfarbe. Bewusst ALS ABLEITUNG aus dem fertigen `Mesh` implementiert (nicht
/// als parallele Re-Extrusion): so bleibt die paritaets-/reihenfolgesensible
/// Geometrie (Gehrungen, Oeffnungen, Loecher, Schichten) die EINE Quelle der
/// Wahrheit und der Alt-Pfad `[pos, normal, color]` bleibt bitgleich unangetastet —
/// wir tauschen nur den color-Kanal gegen den aus Position+Normale abgeleiteten
/// UV-Kanal aus.
///
/// UV-PROJEKTION (planar, in Metern, damit das Textur-Raster weltmassstaeblich ist,
/// z. B. 1 Kachel = 1 m; kein Verzug an Gehrungen/Ecken, weil jede Flaeche planar
/// projiziert wird und benachbarte Waende dieselbe Welt-Projektion fortsetzen —
/// die Kacheln stossen im Weltraum sauber aneinander):
/// - Senkrechte Flaechen (Wand-Mantel, Normale ~ horizontal): `u` = Weglaenge
/// entlang der horizontalen Flaechen-Tangente `t = (-n.z, 0, n.x)`
/// (`u = dot(pos.xz, t.xz)`), `v` = Hoehe `pos.y`.
/// - Waagerechte Flaechen (Deckel/Boden, Normale ~ +/-Y): direkt aus dem
/// Grundriss `u = pos.x`, `v = pos.z`.
/// Die Wahl der Achse allein aus der Flaechen-Normale (statt aus der Wand-Achse)
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
textured_from_mesh(&build_walls_mesh(walls))
}
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
/// (`[pos, normal, uv]`) um: Positionen/Normalen/Indizes werden 1:1 uebernommen,
/// die Farbe je Vertex wird durch die planare Welt-UV ersetzt (siehe
/// `build_walls_mesh_textured` fuer die Achswahl). Indizes bleiben unveraendert,
/// weil sich die Vertex-Anzahl/-Reihenfolge nicht aendert. `pub`, damit die
/// GPU-Schicht (`gpu.rs`) das texturierte Pendant AUS DEMSELBEN `Mesh` ableiten
/// kann, das sie ohnehin schon gebaut/hochgeladen hat (eine Geometrie-Quelle).
pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
let n = mesh.vertex_count();
let mut verts: Vec<f32> = Vec::with_capacity(n * TEXTURED_FLOATS_PER_VERTEX);
for i in 0..n {
let b = i * FLOATS_PER_VERTEX;
let px = mesh.verts[b];
let py = mesh.verts[b + 1];
let pz = mesh.verts[b + 2];
let nx = mesh.verts[b + 3];
let ny = mesh.verts[b + 4];
let nz = mesh.verts[b + 5];
// Waagerecht (Deckel/Boden) vs. senkrecht (Mantel) an der Vertikal-
// Komponente der Normale unterscheiden. Waende sind vertikal extrudiert,
// ihre Mantelnormale ist streng horizontal (ny == 0) — die Schwelle 0.5
// trennt sauber Deckel/Boden (|ny| == 1) von Mantelflaechen (ny == 0).
let (u, v) = if ny.abs() > 0.5 {
// Grundriss-Projektion: u = x, v = z (Meter).
(px, pz)
} else {
// Horizontale Flaechen-Tangente t = (-n.z, 0, n.x): u = Weglaenge
// entlang t, v = Hoehe. `t` ist ein Einheitsvektor, weil (n.x, n.z)
// fuer Mantelflaechen normiert ist.
let u = px * (-nz) + pz * nx;
(u, py)
};
verts.extend_from_slice(&[px, py, pz, nx, ny, nz, u, v]);
}
TexturedMesh {
verts,
indices: mesh.indices.clone(),
}
}
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
-4
View File
@@ -1016,7 +1016,6 @@ mod tests {
color,
openings: vec![],
layers: None,
holes: vec![],
},
WallInput {
start: [0.0, 3.0],
@@ -1027,7 +1026,6 @@ mod tests {
color,
openings: vec![],
layers: None,
holes: vec![],
},
];
let slabs = vec![SlabInput {
@@ -1284,7 +1282,6 @@ mod tests {
height: 1.0,
}],
layers: None,
holes: vec![],
}
}
@@ -1370,7 +1367,6 @@ mod tests {
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
};
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
let plane = SectionPlane::looking_plus_y(-2.0);
-83
View File
@@ -125,89 +125,6 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
}
"#;
/// WGSL des TEXTURIERTEN Wand-Pfades (`RenderStyle::Textured`, gpu::textured_pipeline).
/// GLEICHE Beleuchtung wie `MESH_WGSL` (hemisphaerisches Ambient Himmel/Boden +
/// Lambert-Directional + Gegen-Fuelllicht + Kantenbetonung) — nur die ALBEDO kommt
/// aus einer Bild-Textur (`textureSample`) statt aus der Vertexfarbe. Vertex-Layout
/// [pos vec3, normal vec3, uv vec2]; `Globals` bleibt group(0) binding(0) unveraendert,
/// die Textur haengt in group(1). Die Weiss-/Hidden-Modi (`mode.x`) sind hier nicht
/// relevant: `Textured` reicht `mode.x == 0` (Material) durch (siehe gpu.rs).
pub const MESH_TEXTURED_WGSL: &str = r#"
struct Globals {
view_proj : mat4x4<f32>,
light_dir : vec4<f32>,
sky_color : vec4<f32>,
ground_color : vec4<f32>,
sun_color : vec4<f32>,
mode : vec4<f32>,
section_plane : vec4<f32>,
};
@group(0) @binding(0) var<uniform> globals : Globals;
// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) ->
// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
@group(1) @binding(0) var tex : texture_2d<f32>;
@group(1) @binding(1) var samp : sampler;
struct VsIn {
@location(0) position : vec3<f32>,
@location(1) normal : vec3<f32>,
@location(2) uv : vec2<f32>,
};
struct VsOut {
@builtin(position) clip_pos : vec4<f32>,
@location(0) world_normal : vec3<f32>,
@location(1) uv : vec2<f32>,
@location(2) world_pos : vec3<f32>,
};
@vertex
fn vs_main(in : VsIn) -> VsOut {
var out : VsOut;
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.world_normal = in.normal;
out.uv = in.uv;
out.world_pos = in.position;
return out;
}
@fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
// Live-Schnitt-Kappung identisch zur Mesh-Pipeline (mode.y > 0.5 = aktiv).
if (globals.mode.y > 0.5) {
if (dot(in.world_pos, globals.section_plane.xyz) + globals.section_plane.w > 0.0) {
discard;
}
}
let n = normalize(in.world_normal);
let l = normalize(globals.light_dir.xyz);
// Hemisphaerisches Ambient (identisch zu MESH_WGSL).
let hemi_t = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
let ambient = mix(globals.ground_color.rgb, globals.sky_color.rgb, hemi_t);
// Gerichteter Lambert-Anteil (Sonne).
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb;
// Gegen-Fuelllicht (identisch zu MESH_WGSL).
let fill_l = normalize(vec3<f32>(-l.x, abs(l.y) * 0.35 + 0.15, -l.z));
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
* vec3<f32>(0.24, 0.27, 0.30);
// EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe.
let albedo = textureSample(tex, samp, in.uv).rgb;
var shaded = albedo * (ambient + diffuse + fill);
// Sanfte Kantenbetonung (identisch zu MESH_WGSL).
let edge = 0.90 + 0.10 * abs(n.y);
shaded = shaded * edge;
return vec4<f32>(shaded, 1.0);
}
"#;
/// WGSL des 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.
-64
View File
@@ -66,46 +66,6 @@ pub struct WallInput {
/// Verhalten unveraendert).
#[serde(default)]
pub layers: Option<Vec<WallLayer>>,
/// Rechteckige LOECHER (Fenster/Tueren) als ECHTE Aussparungen in EINEM
/// Wandkoerper (siehe `Hole` + `mesh::extrude_wall*`). Default leer.
///
/// Anders als `openings` zerlegt das die Wand NICHT in Pfeiler/Bruestung/
/// Sturz-Teilquader, sondern stanzt die Loecher in die beiden Langseiten
/// (Rechteck-Gitter-Zerlegung + Laibungsflaechen). So bildet EIN Koerper
/// beliebig (auch versetzt uebereinander) angeordnete Oeffnungen ab — das
/// kann die Segment-Zerlegung nicht. Genutzt vom 3D-Viewer-Pfad der
/// TS-Ableitung (`toWalls3d`, `layeredWalls:true`); der Schnitt-Pfad
/// (`layeredWalls:false`) bleibt bei `openings`/Segmentierung.
///
/// `holes` und `openings` schliessen sich im TS-Emitter gegenseitig aus; ist
/// `holes` gesetzt, ueberspringt `mesh.rs` die Oeffnungs-Segmentierung und
/// extrudiert den vollen Achsen-Abschnitt mit Loechern.
#[serde(default)]
pub holes: Vec<Hole>,
}
/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe
/// `WallInput::holes`.
///
/// KONVENTION: `from`/`to` sind Achsen-Meter ab `WallInput::start` (0..Wandlaenge,
/// dieselbe Achse wie `Opening::from`/`to`); `z_bottom`/`z_top` sind ABSOLUTE
/// Weltmeter (NICHT relativ zu `base_elevation` — anders als `Opening::sill`).
/// Eine Tuer ist der Sonderfall `z_bottom == base_elevation` (Loch bis zur
/// Wand-Unterkante). Loecher liegen definitionsgemaess im Wand-INNEREN und
/// beruehren die gemiterten Enden nicht — die Gehrung (`compute_wall_miters`)
/// bleibt davon unberuehrt (siehe `mesh`-Moduldoc).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct Hole {
/// Start des Loch-Intervalls entlang der Wandachse (Meter ab `start`).
pub from: f32,
/// Ende des Loch-Intervalls entlang der Wandachse (Meter ab `start`).
pub to: f32,
/// Absolute Unterkante des Lochs (Weltmeter).
#[serde(default, rename = "zBottom")]
pub z_bottom: f32,
/// Absolute Oberkante des Lochs (Weltmeter).
#[serde(default, rename = "zTop")]
pub z_top: f32,
}
/// Eine Wandschicht: Dicke + eigene Albedo-Farbe. Siehe `WallInput::layers`.
@@ -333,30 +293,6 @@ pub struct Mesh {
/// Anzahl f32 je Vertex im interleaved Puffer: 3 Position + 3 Normale + 3 Farbe.
pub const FLOATS_PER_VERTEX: usize = 9;
/// Ausgabe der TEXTURIERTEN Mesh-Erzeugung (`RenderStyle::Textured`): interleaved
/// [pos.xyz, normal.xyz, uv.xy] je Vertex plus Indexpuffer. Ein ZWEITER, additiver
/// Vertex-Pfad neben `Mesh` — der bestehende `[pos, normal, color]`-Pfad (`Mesh`)
/// bleibt dadurch bitgleich unangetastet. Genutzt nur mit den GPU-Features
/// (`render`/`window`); serde-frei (reine GPU-Zwischenform), damit der Default-Bau
/// ohne GPU nichts davon zieht.
#[derive(Debug, Clone, Default)]
pub struct TexturedMesh {
/// Interleaved Vertices: je 8 f32 = [px,py,pz, nx,ny,nz, u,v].
pub verts: Vec<f32>,
/// Dreiecks-Indizes (0-basiert auf die Vertices).
pub indices: Vec<u32>,
}
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
pub const TEXTURED_FLOATS_PER_VERTEX: usize = 8;
impl TexturedMesh {
/// Anzahl Vertices im Puffer.
pub fn vertex_count(&self) -> usize {
self.verts.len() / TEXTURED_FLOATS_PER_VERTEX
}
}
impl Mesh {
/// Anzahl Vertices im Puffer.
pub fn vertex_count(&self) -> usize {
+1 -5
View File
@@ -10,13 +10,9 @@
"app": {
"windows": [
{
"label": "main",
"title": "cad",
"width": 1400,
"height": 900,
"dragDropEnabled": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true
"height": 900
}
],
"security": {
+94 -300
View File
@@ -69,9 +69,7 @@ import type { Pick3dHit } from "./viewport/Wasm3DViewport";
import { ResourceManager } from "./ui/ResourceManager";
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
import { HatchSwatch } from "./ui/hatchPreview";
import { TopBar, ViewRibbonTab, Ribbon3dTab } from "./ui/TopBar";
import { RibbonBar } from "./ui/ribbon/RibbonBar";
import type { RibbonTabId } from "./ui/ribbon/ribbonItems";
import { TopBar } from "./ui/TopBar";
import { Dropdown } from "./ui/Dropdown";
import type { DropdownItem } from "./ui/Dropdown";
import { TextEditorDialog } from "./ui/TextEditorDialog";
@@ -214,8 +212,6 @@ const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
line: "line",
polyline: "polyline",
rect: "rect",
circle: "circle",
arc: "arc",
};
/** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */
function coupledCommandFor(id: ToolId): string | null {
@@ -230,68 +226,6 @@ function promptCount(): number | null {
return Number.isFinite(n) && n >= 1 ? n : null;
}
/** localStorage-Schlüssel der modularen Custom-Bar (Ribbon-Tab „Eigene"). */
const RIBBON_CUSTOM_KEY = "dossier.ribbon.customItems";
/** Persistierte Custom-Bar-Item-Schlüssel lesen (leer bei Fehler/Erststart). */
function loadCustomItems(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(RIBBON_CUSTOM_KEY);
const arr = raw ? JSON.parse(raw) : null;
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
/** Custom-Bar-Item-Schlüssel persistieren (Fehler still schlucken). */
function saveCustomItems(items: string[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(RIBBON_CUSTOM_KEY, JSON.stringify(items));
} catch {
// localStorage nicht verfügbar (privater Modus o. ä.) → ignorieren.
}
}
/** Umriss (bbox) aller Drawing2D-Geometrien in Modell-Metern (null = leer). */
function drawingsBBox(
drawings: Drawing2D[],
): { minX: number; minY: number; maxX: number; maxY: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const acc = (x: number, y: number) => {
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
};
for (const d of drawings) {
const g = d.geom;
if (g.shape === "line") { acc(g.a.x, g.a.y); acc(g.b.x, g.b.y); }
else if (g.shape === "polyline") for (const p of g.pts) acc(p.x, p.y);
else if (g.shape === "rect") { acc(g.min.x, g.min.y); acc(g.max.x, g.max.y); }
else if (g.shape === "circle" || g.shape === "arc") {
acc(g.center.x - g.r, g.center.y - g.r);
acc(g.center.x + g.r, g.center.y + g.r);
} else if (g.shape === "text") acc(g.at.x, g.at.y);
}
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
}
/** Ein Drawing2D um (dx,dy) in Modell-Metern verschieben (alle Formen). */
function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
const mv = (p: Vec2): Vec2 => ({ x: p.x + dx, y: p.y + dy });
const g = d.geom;
switch (g.shape) {
case "line": return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
case "polyline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "rect": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
default: return d;
}
}
export default function App() {
// Sprachwechsel abonnieren: re-rendert den gesamten Baum, wenn die Sprache
// umgeschaltet wird (alle Kinder lesen denselben modulweiten Zustand über t).
@@ -381,22 +315,6 @@ export default function App() {
// Einstellungen sowie der laufende Werkzeug-Zustand (Ref, kein Re-Render je
// Mausbewegung) und die Live-Vorschau (Draft → Overlay in PlanView).
const [activeTool, setActiveTool] = useState<ToolId>("select");
// Aktiver Ribbon-Tab (2D/3D/BIM/Ansichten). Liegt hier (nicht in der RibbonBar),
// weil die Tab-Reiter in der TopBar-Zeile sitzen und der Ribbon-Inhalt darunter
// — beide teilen sich denselben aktiven Tab.
const [ribbonTab, setRibbonTab] = useState<RibbonTabId>("views");
// Modulare Custom-Bar (Ribbon-Tab „Eigene"): persistierte Item-Schlüssel. Der
// Picker im Tab wählt Werkzeuge/Befehle an/ab (localStorage-persistiert).
const [customItems, setCustomItems] = useState<string[]>(loadCustomItems);
const onToggleCustomItem = (key: string) => {
setCustomItems((prev) => {
const next = prev.includes(key)
? prev.filter((k) => k !== key)
: [...prev, key];
saveCustomItems(next);
return next;
});
};
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(
project.wallTypes[0].id,
);
@@ -431,11 +349,6 @@ export default function App() {
selectedDrawingIds.length === 1 ? selectedDrawingIds[0] : null;
const setSelectedDrawingId = (id: string | null) =>
setSelectedDrawingIds(id ? [id] : []);
// Zwischenablage für 2D-Zeichenelemente (Ctrl/Cmd+C/V). Kopiert die GEWÄHLTEN
// Drawing2D tief; Einfügen legt Klone mit neuen IDs auf dem AKTIVEN Geschoss
// (Zeichnungsebene) an — so lässt sich z. B. importiertes Mobiliar über
// Geschosse hinweg wiederverwenden. Kategorie/Stil bleiben erhalten.
const [drawingClipboard, setDrawingClipboard] = useState<Drawing2D[]>([]);
// Bequemer Einzel-Selektor (erste gewählte Wand).
const selectedWallId = selectedWallIds[0] ?? null;
// Decken-Auswahl (MENGE); für die Einzel-Element-Maschinerie der „eine"
@@ -853,16 +766,6 @@ export default function App() {
setActiveTool(id);
};
// Ribbon-Befehl (Ändern-Gruppe u. a.): startet einen Engine-Befehl OHNE
// eigenes Werkzeug (kein ToolId). Läuft über DIESELBE Engine wie das Tippen des
// Befehls; das Werkzeug-Highlight fällt auf „Auswahl" zurück, das Aktiv-
// Highlight des Ribbon-Befehls spiegelt der laufende Befehl (engineView).
const onRunCommand = (name: string) => {
engine.start(name);
setActiveTool("select");
requestAnimationFrame(() => commandLineRef.current?.focus());
};
// 3D-Viewport als Erstellungs-Fläche: der Viewport projiziert den Cursor auf
// die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern
// ihn in DIESELBE ansichts-agnostische CommandEngine wie PlanView (move/pick).
@@ -1678,46 +1581,6 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDrawingIds, engine]);
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen. Kopieren legt
// die GEWÄHLTEN Drawing2D tief in die Zwischenablage; Einfügen erzeugt Klone mit
// neuen IDs auf dem AKTIVEN Geschoss (Zeichnungsebene) und wählt sie aus — so
// wandert z. B. importiertes Mobiliar von einem Geschoss auf ein anderes. Im
// Textfeld bleibt das native Copy/Paste; ohne 2D-Auswahl kein preventDefault.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
const k = e.key.toLowerCase();
if (k !== "c" && k !== "v") return;
const el = e.target as HTMLElement | null;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
return;
}
if (k === "c") {
const ids = new Set(selectedDrawingIds);
if (ids.size === 0) return; // nur bei 2D-Auswahl (sonst Browser-Copy)
const picked = project.drawings2d.filter((d) => ids.has(d.id));
if (picked.length === 0) return;
e.preventDefault();
setDrawingClipboard(picked.map((d) => structuredClone(d)));
return;
}
// Einfügen: Klone auf dem aktiven Geschoss, neue IDs, danach ausgewählt.
if (drawingClipboard.length === 0) return;
e.preventDefault();
const stamp = Date.now();
const pasted: Drawing2D[] = drawingClipboard.map((d, i) => ({
...structuredClone(d),
id: `drawing-${stamp}-${i}`,
levelId: activeLevelId,
}));
importDrawings(pasted);
setSelectedDrawingIds(pasted.map((p) => p.id));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDrawingIds, drawingClipboard, activeLevelId, project.drawings2d]);
// Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten
// geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2
// geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.)
@@ -2228,24 +2091,12 @@ export default function App() {
decision.target.kind === "new"
? addDrawingLevel(decision.target.name)
: decision.target.levelId;
let drawings = buildDrawings(
const drawings = buildDrawings(
decision,
levelId,
project,
activeCategoryCode,
);
// Platzierung „viewCenter": den Gesamt-Umriss so verschieben, dass seine
// Mitte auf die Mitte des aktuellen Ausschnitts fällt (sonst bleibt alles an
// den DXF-Koordinaten relativ zum Nullpunkt).
if (decision.placement === "viewCenter" && drawings.length) {
const center = planViewRef.current?.viewCenterModel();
const bb = drawingsBBox(drawings);
if (center && bb) {
const dx = center.x - (bb.minX + bb.maxX) / 2;
const dy = center.y - (bb.minY + bb.maxY) / 2;
drawings = drawings.map((d) => translateDrawing(d, dx, dy));
}
}
if (drawings.length) importDrawings(drawings);
if (decision.importMeshes && decision.parsed.meshes.length) {
addContextObjects(decision.parsed.meshes);
@@ -2486,16 +2337,6 @@ export default function App() {
onSetOpeningDir: (openingDir) => {
if (selection?.kind === "opening") updateOpening(selection.id, { openingDir });
},
onSetOpeningWingCount: (wingCount) => {
if (selection?.kind === "opening")
updateOpening(selection.id, { wingCount: Math.max(1, Math.min(4, Math.round(wingCount))) });
},
onSetOpeningDoorType: (doorType) => {
if (selection?.kind === "opening") updateOpening(selection.id, { doorType });
},
onSetOpeningLintelLines: (lintelLines) => {
if (selection?.kind === "opening") updateOpening(selection.id, { lintelLines });
},
// ── Treppen-Attribute (nur bei selektierter Treppe) ────────────────────
onSetStairShape: (shape) => {
if (selection?.kind === "stair") updateStair(selection.id, { shape });
@@ -2515,9 +2356,6 @@ export default function App() {
onSetStairUp: (up) => {
if (selection?.kind === "stair") updateStair(selection.id, { up });
},
onSetStairReferenz: (referenz) => {
if (selection?.kind === "stair") updateStair(selection.id, { referenz });
},
// ── Raum-Attribute (nur bei selektiertem Raum) ─────────────────────────
onSetRoomName: (name) => {
if (selection?.kind === "room") updateRoom(selection.id, { name });
@@ -3233,17 +3071,55 @@ export default function App() {
e.target.value = "";
}}
/>
{/* Schmale globale Leiste: Marke/Ressourcen · Text-Formatierung ·
Datei/Export/Einstellungen. Ansichts-/Zoom-/Darstellungs-Steuerung ist
in den Ansichten"-Ribbon-Tab gewandert (ViewRibbonTab, unten). */}
<TopBar
project={project}
ribbonTab={ribbonTab}
onRibbonTab={setRibbonTab}
viewToggleEnabled={activeLevel.kind === "floor"}
viewType={viewType}
onViewType={setViewType}
view3d={view3d}
onView3d={setView3d}
fov={fov}
onFov={setFov}
detail={detail}
onDetail={setDetail}
detailEnabled={planActive}
scaleDenominator={scaleDenominator}
onApplyScale={applyScale}
liveScale={planActive ? liveScale : null}
zoomPercent={zoomPercent}
zoomEnabled={planActive}
onFit={fitPlan}
onFitSelection={fitSelection}
onZoom100={zoomToApplied}
onExportPdf={() => setExportOpen(true)}
onExportDxf={() => setExportDxfOpen(true)}
onExportSchedule={onExportSchedule}
renderMode={renderMode}
onRenderMode={setRenderMode}
renderModeEnabled={viewType === "perspektive"}
planColorMode={planColorMode}
onPlanColorMode={setPlanColorMode}
combos={{
// Ebenen-Kombinationen: Speichern = Snapshot des Layer-Baums →
// localStorage; Laden = Map lesen → Sichtbarkeits-Flags anwenden.
listLayerCombos,
saveLayerCombo: (name) =>
saveLayerCombo(name, snapshotLayerVisibility()),
loadLayerCombo: (name) => {
const codes = loadLayerCombo(name);
if (codes) applyLayerVisibility(codes);
},
deleteLayerCombo,
// Zeichnungs-Kombinationen: analog über drawingLevels[].visible.
listDrawingCombos,
saveDrawingCombo: (name) =>
saveDrawingCombo(name, snapshotDrawingVisibility()),
loadDrawingCombo: (name) => {
const ids = loadDrawingCombo(name);
if (ids) applyDrawingVisibility(ids);
},
deleteDrawingCombo,
}}
resourcesOpen={resourcesOpen}
onToggleResources={() => setResourcesOpen((v) => !v)}
onOpenSettings={() => setSettingsOpen(true)}
@@ -3256,85 +3132,7 @@ export default function App() {
onApply={(l) => setLayout(reconcileLayout(l))}
/>
}
/>
{/* Ribbon-Oberleiste (2D · 3D · BIM · Ansichten). Der Ansichten"-Tab trägt
die aus der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung
(ViewRibbonTab); die Werkzeug-Sidebar bleibt vorerst erhalten. */}
<RibbonBar
tab={ribbonTab}
activeTool={activeTool}
activeCommand={engineView.commandName}
toolsEnabled={activeLevel.kind === "floor"}
onSelectTool={onSelectTool}
onRunCommand={onRunCommand}
customItems={customItems}
onToggleCustomItem={onToggleCustomItem}
tabContent={{
views: (
<ViewRibbonTab
viewToggleEnabled={activeLevel.kind === "floor"}
viewType={viewType}
onViewType={setViewType}
view3d={view3d}
onView3d={setView3d}
fov={fov}
onFov={setFov}
detail={detail}
onDetail={setDetail}
detailEnabled={planActive}
scaleDenominator={scaleDenominator}
onApplyScale={applyScale}
liveScale={planActive ? liveScale : null}
zoomPercent={zoomPercent}
zoomEnabled={planActive}
onFit={fitPlan}
onFitSelection={fitSelection}
onZoom100={zoomToApplied}
renderMode={renderMode}
onRenderMode={setRenderMode}
renderModeEnabled={viewType === "perspektive"}
planColorMode={planColorMode}
onPlanColorMode={setPlanColorMode}
combos={{
// Ebenen-Kombinationen: Speichern = Snapshot des Layer-Baums →
// localStorage; Laden = Map lesen → Sichtbarkeits-Flags anwenden.
listLayerCombos,
saveLayerCombo: (name) =>
saveLayerCombo(name, snapshotLayerVisibility()),
loadLayerCombo: (name) => {
const codes = loadLayerCombo(name);
if (codes) applyLayerVisibility(codes);
},
deleteLayerCombo,
// Zeichnungs-Kombinationen: analog über drawingLevels[].visible.
listDrawingCombos,
saveDrawingCombo: (name) =>
saveDrawingCombo(name, snapshotDrawingVisibility()),
loadDrawingCombo: (name) => {
const ids = loadDrawingCombo(name);
if (ids) applyDrawingVisibility(ids);
},
deleteDrawingCombo,
}}
textTarget={textTarget}
/>
),
"3d": (
<Ribbon3dTab
viewToggleEnabled={activeLevel.kind === "floor"}
viewType={viewType}
onViewType={setViewType}
view3d={view3d}
onView3d={setView3d}
fov={fov}
onFov={setFov}
renderMode={renderMode}
onRenderMode={setRenderMode}
renderModeEnabled={viewType === "perspektive"}
/>
),
}}
textTarget={textTarget}
/>
<div className="body">
@@ -3357,10 +3155,6 @@ export default function App() {
/>
)}
{/* Mitte-Spalte: Hauptansicht + Befehlszeile DARUNTER. So ist die
Befehlszeile nur so breit wie der Viewport (nicht die ganze App),
und die Docks nehmen die volle Body-Höhe ein (inkl. der Zeile). */}
<div className="center-col">
{/* Hauptansicht (Mitte) füllt den verbleibenden Platz. Die Plan-
Ansicht meldet Cursor + Zoom an die Status-/Oberleiste und nimmt
das Zoom-Handle entgegen. */}
@@ -3425,54 +3219,6 @@ export default function App() {
onEdit3dWallTop={onEdit3dWallTop}
onEdit3dEnd={onEdit3dEnd}
/>
{/* Command-Line (Rhino-artig) UNTER dem Viewport nur Viewport-breit.
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
<CommandLine
ref={commandLineRef}
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
active={gripEdit ? true : activeTransform ? true : engineView.active}
promptKey={
gripEdit
? "cmd.edit.point"
: activeTransform
? `transform.hint.${activeTransform.op}.${Math.min(
activeTransform.points.length,
activeTransform.op === "rotate" ? 2 : 1,
)}`
: engineView.promptKey
}
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
fields={
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
}
onSubmit={(text) => {
if (gripEdit) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitGripEditValue(v);
return;
}
if (activeTransform) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitTransformValue(v);
return;
}
engine.submitText(text);
}}
onOption={(id) =>
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
}
onCycleField={() =>
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
}
onCancel={() =>
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
}
suggest={(prefix) => engine.suggest(prefix)}
/>
</div>
{/* Rechtes Dock: standardmäßig leer (Ressourcen ist ein schwebendes
Fenster). Nur gerendert, wenn es Gruppen hat sonst nutzt die Mitte
@@ -3541,6 +3287,54 @@ export default function App() {
{/* Drag-Overlay (Geist + Tab-Einfüge-Caret) während eines Tab-Ziehens. */}
<DragOverlay drag={drag} />
{/* Command-Line (Rhino-artig) ÜBER der Statusleiste: Prompt + klickbare
Inline-Optionen + Texteingabe + Autocomplete. Tab fokussiert sie. */}
<CommandLine
ref={commandLineRef}
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
active={gripEdit ? true : activeTransform ? true : engineView.active}
promptKey={
gripEdit
? "cmd.edit.point"
: activeTransform
? `transform.hint.${activeTransform.op}.${Math.min(
activeTransform.points.length,
activeTransform.op === "rotate" ? 2 : 1,
)}`
: engineView.promptKey
}
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
fields={
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
}
onSubmit={(text) => {
if (gripEdit) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitGripEditValue(v);
return;
}
if (activeTransform) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitTransformValue(v);
return;
}
engine.submitText(text);
}}
onOption={(id) =>
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
}
onCycleField={() =>
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
}
onCancel={() =>
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
}
suggest={(prefix) => engine.suggest(prefix)}
/>
{/* Footer / Status-Leiste (unten). Werte rein aus Modell + View-State.
Cursor/Zoom nur, wenn eine Plan-Ansicht aktiv ist (sonst "). */}
<StatusBar
-166
View File
@@ -1,166 +0,0 @@
// Arc — Mittelpunkt + Startpunkt + Endpunkt. Schritte:
// 1) „Mittelpunkt:" → Punkt
// 2) „Startpunkt:" → Punkt (setzt Radius r = |P1Center| UND Startwinkel a0)
// 3) „Endpunkt:" → Punkt (setzt Endwinkel a1; Radius bleibt) → commit
//
// Der Bogen läuft CCW von a0 nach a1 (wie DXF-ARC und das `{shape:"arc"}`-
// Rendering in PlanView). Der Endpunkt wird nur für seinen WINKEL genutzt (auf
// den Kreis mit Radius r projiziert). In generatePlan/PlanView als glatter
// SVG-Bogen (`drawingArc`) gerendert.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
interface ArcCenter extends CommandState {
phase: "center";
}
interface ArcStart extends CommandState {
phase: "start";
center: Vec2;
cursor: Vec2 | null;
}
interface ArcEnd extends CommandState {
phase: "end";
center: Vec2;
r: number;
a0: number;
cursor: Vec2 | null;
}
type ArcState = ArcCenter | ArcStart | ArcEnd;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const angle = (c: Vec2, p: Vec2): number => Math.atan2(p.y - c.y, p.x - c.x);
/** CCW-Spanne von a0 nach a1, normiert auf (0, 2π]. */
function sweepOf(a0: number, a1: number): number {
const TAU = Math.PI * 2;
const s = ((a1 - a0) % TAU + TAU) % TAU;
return s < EPS ? TAU : s;
}
/** Punkte eines Bogens (CCW a0→a1) für die Vorschau. */
function arcPts(center: Vec2, r: number, a0: number, a1: number): Vec2[] {
const sweep = sweepOf(a0, a1);
const N = Math.max(2, Math.ceil(sweep / (Math.PI / 32)));
const pts: Vec2[] = [];
for (let i = 0; i <= N; i++) {
const t = a0 + (sweep * i) / N;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Punkte einer vollen Kreis-Approximation (Radius-Vorschau im „start"-Schritt). */
function circlePts(center: Vec2, r: number): Vec2[] {
const N = 48;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Vorschau (offener Bogen) + HUD (Spanne in Grad). */
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
if (r < EPS) return { preview: [], vertices: [center] };
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
const draft: ToolDraft = { preview, vertices: [center] };
if (at) {
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
draft.hud = { at, text: `${deg.toFixed(0)}°` };
}
return draft;
}
function appendArc(p: Project, center: Vec2, r: number, a0: number, a1: number, ctx: CommandContext): Project {
if (r < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "arc", center, r, a0, a1 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "center", lastPoint: null },
{ draft: null, done: true },
];
export const arcCommand: Command = {
name: "arc",
labelKey: "cmd.arc.label",
prompt: (s) => {
const phase = (s as ArcState).phase;
return phase === "start" ? "cmd.arc.start" : phase === "end" ? "cmd.arc.end" : "cmd.arc.center";
},
accepts: () => ["point"],
options: () => [],
init: (): ArcCenter => ({ phase: "center", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ArcState;
if (input.kind !== "point") return [s, { draft: null }];
const pt = input.point;
if (s.phase === "center") {
const ns: ArcStart = { phase: "start", center: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: { preview: [], vertices: [pt] } }];
}
if (s.phase === "start") {
const r = dist(s.center, pt);
if (r < EPS) return [s, { draft: null }];
const a0 = angle(s.center, pt);
const ns: ArcEnd = { phase: "end", center: s.center, r, a0, cursor: pt, lastPoint: pt };
return [ns, { draft: arcDraft(s.center, r, a0, a0, pt) }];
}
// Endpunkt: Endwinkel setzen, Bogen committen.
const a1 = angle(s.center, pt);
const { center, r, a0 } = s;
return [
{ phase: "center", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendArc(p, center, r, a0, a1, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ArcState;
if (s.phase === "start") {
const r = dist(s.center, point);
const ns: ArcStart = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
vertices: [s.center],
hud: { at: point, text: `r ${r.toFixed(2)} m` },
},
},
];
}
if (s.phase === "end") {
const a1 = angle(s.center, point);
const ns: ArcEnd = { ...s, cursor: point };
return [ns, { draft: arcDraft(s.center, s.r, s.a0, a1, point) }];
}
return [s, { draft: null }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
-10
View File
@@ -26,16 +26,6 @@ export const importCommand: Command = {
options: () => [],
init: () => ({ ...IDLE }),
// Sofort-Befehl (wie Join/Split): `startCommand` ruft `autoRun` SYNCHRON im
// selben Aufruf-Stack wie die Enter-/Klick-Geste auf. Genau das braucht der
// Datei-Dialog — ein `<input type=file>.click()` öffnet den OS-Dialog nur
// innerhalb einer echten Nutzer-Geste; ein State-Update/async-Hop dazwischen
// (wie beim reinen onConfirm/onInput-Weg) lässt ihn verzögern oder ausbleiben.
autoRun: (): CommandResult => {
openDxfImport();
return { draft: null, done: true };
},
// Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet.
onInput: () => open(),
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
-85
View File
@@ -1,85 +0,0 @@
/**
* Unit-Tests für `stepsFromTread` Stufenzahl-Ableitung aus Soll-Auftritt + Lauflänge.
*
* Entspricht dem `target_a`-Modus im Rhino-Plugin (_make_treppe_preview_handler,
* Variable `target_a` = Soll-Auftritt).
*/
import { describe, it, expect } from "vitest";
import { stepsFromTread } from "./stair";
import { MIN_STEPS, IDEAL_TREAD } from "../../geometry/stair";
describe("stepsFromTread — Grundfälle", () => {
it("ergibt das ideale Soll-Trittmass bei exakter Teilbarkeit", () => {
// 10 × 0.29 m = 2.9 m → 10 Stufen
const steps = stepsFromTread(IDEAL_TREAD, 10 * IDEAL_TREAD);
expect(steps).toBe(10);
});
it("rundet auf die nächste ganze Stufenzahl", () => {
// 3.0 / 0.29 ≈ 10.34 → 10
expect(stepsFromTread(0.29, 3.0)).toBe(10);
// 3.1 / 0.29 ≈ 10.69 → 11
expect(stepsFromTread(0.29, 3.1)).toBe(11);
});
it("bleibt mindestens bei MIN_STEPS", () => {
// Sehr kurze Lauflänge → immer MIN_STEPS (2)
expect(stepsFromTread(0.29, 0.01)).toBe(MIN_STEPS);
});
it("klemmt nach oben: Stufenzahl durch 0.24-m-Auftritts-Grenze begrenzt", () => {
// 10 m Lauflänge, Auftritt 0.01 m → raw = 1000, aber durch Clamp auf
// floor(10/0.24)+1 = 42 begrenzt. Der resultierende Auftritt (≈ 0.238 m)
// ist der Grenzwert der floor-Formel aus defaultStepCount — kein echter Bug,
// sondern die bewusste Clamp-Schwelle (≥ min ~ 0.24 m, off-by-one durch +1).
const steps = stepsFromTread(0.01, 10.0);
// Die Stufenzahl muss deutlich kleiner als 1000 sein (Clamp wirkt).
expect(steps).toBeLessThan(100);
expect(steps).toBeGreaterThanOrEqual(MIN_STEPS);
// Auftrittstiefe muss im vernünftigen Bereich liegen (nahe 0.24 m).
const treadDepth = 10.0 / steps;
expect(treadDepth).toBeGreaterThanOrEqual(0.23 - 1e-6);
expect(treadDepth).toBeLessThanOrEqual(0.30);
});
it("typische Nutzung: Lauflänge 3 m, Auftritt 0.29 m → ca. 10 Stufen", () => {
const steps = stepsFromTread(0.29, 3.0);
expect(steps).toBeGreaterThanOrEqual(MIN_STEPS);
// Auftrittstiefe nahe am Soll
const tread = 3.0 / steps;
expect(tread).toBeGreaterThan(0.20);
expect(tread).toBeLessThan(0.40);
});
it("nullartige Auftritt-Eingabe wird intern auf 0.05 m geklippt", () => {
// Sollte nicht abstürzen, Ergebnis durch 0.24-m-Clamp begrenzt.
const steps = stepsFromTread(0, 3.0);
expect(steps).toBeGreaterThanOrEqual(MIN_STEPS);
});
it("negative Lauflänge → MIN_STEPS", () => {
expect(stepsFromTread(0.29, -1)).toBe(MIN_STEPS);
});
it("Auftritt 0.25 m, Lauflänge 2.5 m → 10 Stufen (exakt)", () => {
expect(stepsFromTread(0.25, 2.5)).toBe(10);
});
});
describe("stepsFromTread — Grenzwerte / Konsistenz", () => {
it("liefert immer ganze Zahlen", () => {
for (const run of [1.0, 2.5, 3.14, 5.0, 10.0]) {
const steps = stepsFromTread(0.29, run);
expect(Number.isInteger(steps)).toBe(true);
}
});
it("resultierender Auftritt liegt stets im erlaubten Bereich (>= 0.24 m)", () => {
for (const run of [1.0, 2.0, 3.0, 5.0]) {
const steps = stepsFromTread(IDEAL_TREAD, run);
const tread = run / steps;
expect(tread).toBeGreaterThanOrEqual(0.24 - 1e-6);
}
});
});
+22 -175
View File
@@ -4,12 +4,9 @@
// Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel
// ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe.
//
// Optionen (togglebar, IMMER sichtbar): Grundform gerade/L/Wendel, Auf-/Ab-
// Umschalter, Referenz (links/mitte/rechts), Trittmass-Modus (mit/ohne).
// Tab-Felder in BEIDEN Phasen: Breite (idle + run). Im Laufschritt zusätzlich:
// • Trittmass-Modus „ohne": Stufen + Steighöhe (heutiges Verhalten).
// • Trittmass-Modus „mit" : Auftritt (Soll-Trittmass); Stufenzahl wird daraus
// abgeleitet: steps = clamp(round(runLength / auftritt), MIN_STEPS, …).
// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
// zeigt die Tritt-Linien + die Lauflinie.
//
// Herkunft der Treppen-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
@@ -20,7 +17,7 @@
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
import { stairGeometry, defaultStepCount, IDEAL_TREAD, MIN_STEPS } from "../../geometry/stair";
import { stairGeometry, defaultStepCount } from "../../geometry/stair";
import { uniqueId } from "../../tools/types";
import type {
Command,
@@ -41,35 +38,6 @@ const STAIR_CATEGORY = "40";
const DEF_WIDTH = 1.0;
const EPS = 1e-6;
// ── Trittmass-Modus ──────────────────────────────────────────────────────────
/** Trittmass-Modus: with" = Soll-Auftritt vorgegeben, Stufen abgeleitet;
* without" = heutiges Verhalten (freies Stufen-Feld). */
type TreadMode = "with" | "without";
// ── Persistente Defaults (modulweit, leben über Befehls-Instanzen hinweg) ────
/** Letzte Nutzer-Einstellungen für Treppen; wird von jeder neuen Instanz gelesen
* und nach jedem Commit aktualisiert. */
const stairDefaults: {
shape: StairShape;
up: boolean;
width: number;
referenz: "links" | "mitte" | "rechts";
treadMode: TreadMode;
auftritt: number; // Soll-Trittmass (Meter)
steps: number | null;
rise: number | null;
} = {
shape: "straight",
up: true,
width: DEF_WIDTH,
referenz: "mitte",
treadMode: "without",
auftritt: IDEAL_TREAD,
steps: null,
rise: null,
};
// ── Vektor-Helfer ─────────────────────────────────────────────────────────────
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
const normv = (a: Vec2): Vec2 => {
@@ -97,45 +65,21 @@ function floorRise(ctx: CommandContext): number {
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
}
// ── Trittmass-Ableitung ───────────────────────────────────────────────────────
/**
* Leitet die Stufenzahl aus Auftritt (Soll-Trittmass) + Lauflänge ab.
* Klemmt auf [MIN_STEPS, maxByRun] (mind. 0.24 m Auftrittstiefe).
* Entspricht dem `target_a`-Modus im Rhino-Plugin (_make_treppe_preview_handler).
*/
export function stepsFromTread(auftritt: number, runLength: number): number {
const a = Math.max(0.05, auftritt);
const raw = Math.round(runLength / a);
// Auftrittstiefe aus Stufenzahl darf nicht unter 0.24 m sinken.
const maxByRun = runLength > 0 ? Math.max(MIN_STEPS, Math.floor(runLength / 0.24) + 1) : raw;
return Math.max(MIN_STEPS, Math.min(raw, maxByRun));
}
/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */
const RUN_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "steps", labelKey: "cmd.stair.stepsField" },
{ id: "rise", labelKey: "cmd.stair.riseField" },
];
// ── Felder (Tab-Zyklus) ───────────────────────────────────────────────────────
/** Tab-Feld: Breite (immer, ab idle). */
const FIELD_WIDTH: CommandField = { id: "width", labelKey: "cmd.field.width" };
/** Tab-Feld: Stufenanzahl (run-Phase, nur Trittmass-Modus „ohne"). */
const FIELD_STEPS: CommandField = { id: "steps", labelKey: "cmd.stair.stepsField" };
/** Tab-Feld: Steighöhe (run-Phase, nur Trittmass-Modus „ohne"). */
const FIELD_RISE: CommandField = { id: "rise", labelKey: "cmd.stair.riseField" };
/** Tab-Feld: Auftritt (run-Phase, nur Trittmass-Modus „mit"). */
const FIELD_AUFTRITT: CommandField = { id: "auftritt", labelKey: "cmd.stair.auftrittField" };
// ── Optionen (immer sichtbar) ─────────────────────────────────────────────────
const SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" };
const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" };
const REFERENZ: CmdOption = { id: "referenz", labelKey: "cmd.stair.referenz", value: "mitte" };
const TREAD_MODE: CmdOption = { id: "treadMode", labelKey: "cmd.stair.treadMode", value: "without" };
// ── Zustands-Typen ────────────────────────────────────────────────────────────
interface StairIdle extends CommandState {
phase: "start";
shape: StairShape;
up: boolean;
width: number;
referenz: "links" | "mitte" | "rechts";
treadMode: TreadMode;
auftritt: number;
steps: number | null; // null = automatisch
rise: number | null; // null = Geschosshöhe
}
@@ -144,9 +88,6 @@ interface StairRun extends CommandState {
shape: StairShape;
up: boolean;
width: number;
referenz: "links" | "mitte" | "rechts";
treadMode: TreadMode;
auftritt: number;
steps: number | null;
rise: number | null;
start: Vec2;
@@ -154,30 +95,14 @@ interface StairRun extends CommandState {
}
type StairCmdState = StairIdle | StairRun;
/** Neuer Ruhezustand aus den modulweiten Defaults (oder überschrieben). */
function initState(override?: Partial<typeof stairDefaults>): StairIdle {
const d = { ...stairDefaults, ...override };
return {
phase: "start",
lastPoint: null,
shape: d.shape,
up: d.up,
width: d.width,
referenz: d.referenz,
treadMode: d.treadMode,
auftritt: d.auftritt,
steps: d.steps,
rise: d.rise,
};
function initState(shape: StairShape = "straight"): StairIdle {
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
}
/**
* Baut aus dem Laufzustand ein konkretes `Stair`-Objekt (aufgelöste Felder). Die
* Richtung/Länge folgen aus startcursor; bei L bricht der zweite Lauf um 90°
* (links) ab, bei Wendel wird der Cursorabstand zum Radius.
*
* Im Trittmass-Modus mit" wird die Stufenzahl aus dem Soll-Auftritt + der
* aktuellen Lauflänge abgeleitet (stepsFromTread), kein freies steps-Feld.
*/
function buildStair(s: StairRun, ctx: CommandContext): Stair {
const rise = s.rise ?? floorRise(ctx);
@@ -185,15 +110,7 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair {
const dist = Math.max(0.1, lenOf(d));
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist;
// Stufenzahl je Trittmass-Modus:
// „mit Trittmass" → aus Auftritt + Lauflänge ableiten (Soll-Schrittmass).
// „ohne Trittmass" → manuell oder defaultStepCount (heutiges Verhalten).
const steps =
s.treadMode === "with"
? stepsFromTread(s.auftritt, runLength)
: (s.steps ?? defaultStepCount(rise, runLength));
const steps = s.steps ?? defaultStepCount(rise, runLength);
const base: Stair = {
id: uniqueId("ST"),
type: "stair",
@@ -204,7 +121,6 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair {
dir,
runLength,
width: s.width,
referenz: s.referenz,
totalRise: rise,
stepCount: steps,
up: s.up,
@@ -240,42 +156,18 @@ function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
return draft;
}
/** Hängt eine Treppe ans Projekt (immutabel) und aktualisiert die Defaults. */
/** Hängt eine Treppe ans Projekt (immutabel). */
function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project {
const stair = buildStair(s, ctx);
// Zuletzt genutzte Einstellungen für die nächste Treppe merken.
stairDefaults.shape = s.shape;
stairDefaults.up = s.up;
stairDefaults.width = s.width;
stairDefaults.referenz = s.referenz;
stairDefaults.treadMode = s.treadMode;
stairDefaults.auftritt = s.auftritt;
stairDefaults.steps = s.steps;
stairDefaults.rise = s.rise;
const stairs = p.stairs ? [...p.stairs, stair] : [stair];
return { ...p, stairs };
}
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
initState(from ? {
shape: from.shape,
up: from.up,
width: from.width,
referenz: from.referenz,
treadMode: from.treadMode,
auftritt: from.auftritt,
steps: from.steps,
rise: from.rise,
} : undefined),
initState(from?.shape ?? "straight"),
{ draft: null, done: true },
];
// ── Referenz-Toggle-Zyklus ────────────────────────────────────────────────────
function nextReferenz(r: "links" | "mitte" | "rechts"): "links" | "mitte" | "rechts" {
return r === "links" ? "mitte" : r === "mitte" ? "rechts" : "links";
}
// ── Command-Export ────────────────────────────────────────────────────────────
export const stairCommand: Command = {
name: "stair",
labelKey: "cmd.stair.label",
@@ -285,20 +177,14 @@ export const stairCommand: Command = {
(s as StairCmdState).phase === "run"
? ["point", "number", "option"]
: ["point", "option"],
/** Optionen werden IMMER angezeigt (idle + run): Grundform, Auf/Ab, Referenz,
* Trittmass-Modus. */
options: (s): CmdOption[] => {
const st = s as StairCmdState;
return [
{ ...SHAPE, value: st.shape },
{ ...UPDOWN, value: st.up ? "up" : "down" },
{ ...REFERENZ, value: st.referenz },
{ ...TREAD_MODE, value: st.treadMode },
];
},
init: (): StairIdle => initState(),
init: (): StairIdle => initState("straight"),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
@@ -309,12 +195,6 @@ export const stairCommand: Command = {
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
} else if (input.id === "updown") {
next.up = !s.up;
} else if (input.id === "referenz") {
next.referenz = nextReferenz(s.referenz);
} else if (input.id === "treadMode") {
next.treadMode = s.treadMode === "with" ? "without" : "with";
// Im „mit"-Modus freie Stufenzahl zurücksetzen (wird abgeleitet).
if (next.treadMode === "with") next.steps = null;
}
return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }];
}
@@ -331,9 +211,6 @@ export const stairCommand: Command = {
shape: s.shape,
up: s.up,
width: s.width,
referenz: s.referenz,
treadMode: s.treadMode,
auftritt: s.auftritt,
steps: s.steps,
rise: s.rise,
start: pt,
@@ -372,50 +249,20 @@ export const stairCommand: Command = {
},
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
/**
* Tab-Felder:
* idle-Phase: [Breite] (Konfiguration VOR dem ersten Punkt).
* run-Phase, Trittmass-Modus ohne": [Breite, Stufen, Steighöhe].
* run-Phase, Trittmass-Modus mit" : [Breite, Auftritt].
*/
fields: (state): CommandField[] => {
const s = state as StairCmdState;
if (s.phase === "start") {
return [FIELD_WIDTH];
}
// run-Phase
if (s.treadMode === "with") {
return [FIELD_WIDTH, FIELD_AUFTRITT];
}
return [FIELD_WIDTH, FIELD_STEPS, FIELD_RISE];
},
/** Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt. */
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
pointFromFields: (state, locks) => {
const s = state as StairCmdState;
if ("width" in locks && locks.width > 0) s.width = locks.width;
if (s.phase === "run") {
if ("auftritt" in locks && locks.auftritt > 0) s.auftritt = locks.auftritt;
// Stufen + Steighöhe nur im „ohne"-Modus als freie Felder.
if (s.treadMode === "without") {
if ("steps" in locks && locks.steps >= MIN_STEPS) s.steps = Math.round(locks.steps);
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
}
}
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
return null;
},
fieldValues: (state, _locks, _cursor): Record<string, number> => {
const s = state as StairCmdState;
const out: Record<string, number> = { width: s.width };
if (s.phase === "run") {
if (s.treadMode === "with") {
out.auftritt = s.auftritt;
} else {
if (s.steps != null) out.steps = s.steps;
if (s.rise != null) out.rise = s.rise;
}
}
if (s.steps != null) out.steps = s.steps;
if (s.rise != null) out.rise = s.rise;
return out;
},
};
-4
View File
@@ -13,7 +13,6 @@ import { stairCommand } from "./cmds/stair";
import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle";
import { arcCommand } from "./cmds/arc";
import { moveCommand } from "./cmds/move";
import { mirrorCommand } from "./cmds/mirror";
import { joinCommand } from "./cmds/join";
@@ -36,7 +35,6 @@ export const COMMANDS: Record<string, Command> = {
polyline: polylineCommand,
rect: rectCommand,
circle: circleCommand,
arc: arcCommand,
move: moveCommand,
mirror: mirrorCommand,
join: joinCommand,
@@ -69,8 +67,6 @@ export const ALIASES: Record<string, string> = {
pl: "polyline",
rec: "rect",
c: "circle",
a: "arc",
bogen: "arc",
m: "move",
s: "mirror",
spiegeln: "mirror",
-980
View File
@@ -1,980 +0,0 @@
// Differential-Paritaet: Rust-WASM-Kernel (`src-tauri/kernel2d`, Feature "web")
// gegen die TS-Referenz `kernel2d.ts` — auf identischen Eingaben muessen beide
// bis auf ein funktionsspezifisches Epsilon dasselbe liefern (siehe PORT_PLAN §5).
// Kern der Migration: beweist, dass der Port die Semantik bitnah erhaelt.
//
// VORAUSSETZUNG: `npm run build:kernel2d` muss vorher gelaufen sein (das Paket
// `src/engine/pkgKernel2d` ist git-ignoriert). Init synchron via `initSync` mit
// den WASM-Bytes aus `readFileSync` (kein fetch im Node-Lauf).
//
// Zwei Testklassen, NIE gemischt: Zufalls-Paritaet (naiv==naiv) und Golden
// (explizite Grenzfaelle). Robuste Praedikate sind hier NICHT im Spiel (v1).
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import type { Vec2 } from "../model/types";
import {
circleCircleIntersect,
closestPointOnSegment,
extendSegment,
filletCorner,
isCCW,
joinChains,
lineCircleIntersect,
lineSegmentIntersect,
offsetPolyline,
offsetSegment,
pointSegmentDistance,
projectParam,
removeSegment,
segmentCircleIntersect,
segmentIntersect,
segmentPolylineHits,
signedArea,
splitAtIntersections,
splitClosedByChord,
splitPolylineAtParam,
splitSegmentByCutters,
trimPolyline,
trimSegment,
type Fillet,
type Hit,
} from "./kernel2d";
import {
ceilingArea,
isValidOutline,
normalizeOutline,
outlineBBox,
outlineCentroid,
pointInOutline,
} from "./ceiling";
import { centroid, perimeter, polygonArea } from "./roomArea";
import {
defaultStepCount,
pointHitsStair,
pointInPolygon as stairPointInPolygon,
stairBBox,
stairCut,
stairGeometry,
type StairGeometry as TSStairGeometry,
} from "./stair";
import type { Stair } from "../model/types";
import {
detectRooms,
pointInPolygon as rbPointInPolygon,
roomFromPointInside,
roomFromPointInsideFaces,
type WallSegment,
type WallFace,
} from "./roomBoundary";
type Poly = { pts: Vec2[]; closed: boolean };
// Das WASM-Paket ist git-ignoriert und wird nur von `npm run build:kernel2d`
// erzeugt. Fehlt es, wird die Suite SAUBER uebersprungen (statt Collection-Fehler),
// damit `vitest run` ohne vorherigen WASM-Build gruen bleibt. Darum dynamischer
// Import erst in `beforeAll` (kein statischer Top-Level-Import des Pakets).
type Batch = (json: string) => string;
const wasmPath = fileURLToPath(
new URL("../engine/pkgKernel2d/kernel2d_bg.wasm", import.meta.url),
);
const built = existsSync(wasmPath);
let K: Record<string, Batch>;
beforeAll(async () => {
if (!built) return;
const m = await import("../engine/pkgKernel2d/kernel2d.js");
(m as { initSync: (o: { module: Buffer }) => unknown }).initSync({
module: readFileSync(wasmPath),
});
K = m as unknown as Record<string, Batch>;
});
if (!built) {
// eslint-disable-next-line no-console
console.warn(
"[kernel2d.parity] pkgKernel2d fehlt — `npm run build:kernel2d` fuer den Diff-Test noetig. Uebersprungen.",
);
}
// ── Seed-basierter RNG (mulberry32), deterministisch ─────────────────────────
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Koordinate in [-100,100] m, mit Clustern nahe 0 und im 1e-6..1e-3-Bereich. */
function coord(rng: () => number): number {
const r = rng();
if (r < 0.15) return (rng() * 2 - 1) * 1e-3; // nahe 0
if (r < 0.25) return (rng() * 2 - 1) * 1e-6 + Math.round(rng() * 4 - 2); // Schwellen
return (rng() * 2 - 1) * 100;
}
const v = (rng: () => number): Vec2 => ({ x: coord(rng), y: coord(rng) });
/** Zwei sich garantiert schneidende Strecken um ein Zentrum c. */
function crossingSegs(rng: () => number) {
const c = v(rng);
const ang1 = rng() * Math.PI;
const ang2 = ang1 + 0.2 + rng() * (Math.PI - 0.4); // nicht (fast) parallel
const d1 = { x: Math.cos(ang1), y: Math.sin(ang1) };
const d2 = { x: Math.cos(ang2), y: Math.sin(ang2) };
const ext = () => 0.1 + rng() * 3;
return {
a1: { x: c.x - d1.x * ext(), y: c.y - d1.y * ext() },
a2: { x: c.x + d1.x * ext(), y: c.y + d1.y * ext() },
b1: { x: c.x - d2.x * ext(), y: c.y - d2.y * ext() },
b2: { x: c.x + d2.x * ext(), y: c.y + d2.y * ext() },
};
}
// ── Vergleichs-Helfer (Struktur zuerst, dann Werte mit op-Epsilon) ───────────
/** Abs-ODER-relative Toleranz: eng bei ~1, skaliert bei grossen Werten. */
function closeNum(a: number, b: number, rel = 1e-9): boolean {
return Math.abs(a - b) <= rel * Math.max(1, Math.abs(a), Math.abs(b));
}
function closeVec(a: Vec2, b: Vec2, rel = 1e-9): boolean {
return closeNum(a.x, b.x, rel) && closeNum(a.y, b.y, rel);
}
function closeHit(a: Hit, b: Hit): boolean {
return closeVec(a.point, b.point) && closeNum(a.t, b.t) && closeNum(a.s, b.s);
}
function eqPts(w: Vec2[], t: Vec2[]): boolean {
return w.length === t.length && t.every((p, i) => closeVec(w[i], p));
}
function eqPtsLists(w: Vec2[][], t: Vec2[][]): boolean {
return w.length === t.length && t.every((s, i) => eqPts(w[i], s));
}
function eqPolyList(w: Poly[], t: Poly[]): boolean {
return w.length === t.length && t.every((pl, i) => w[i].closed === pl.closed && eqPts(w[i].pts, pl.pts));
}
function eqPairs(w: [Vec2, Vec2][], t: [Vec2, Vec2][]): boolean {
return w.length === t.length && t.every((pr, i) => closeVec(w[i][0], pr[0]) && closeVec(w[i][1], pr[1]));
}
/** Ein paar lange Schneider-Segmente quer durch die BBox (als offene Cutter). */
function genCutters(rng: () => number): Poly[] {
const k = 1 + Math.floor(rng() * 3);
return Array.from({ length: k }, () => ({
pts: [
{ x: -80, y: (rng() * 2 - 1) * 60 },
{ x: 80, y: (rng() * 2 - 1) * 60 },
],
closed: false,
}));
}
function genPolyline(rng: () => number, minN: number, maxN: number): Vec2[] {
const n = minN + Math.floor(rng() * (maxN - minN + 1));
return Array.from({ length: n }, () => ({ x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 }));
}
const N = 300;
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Zufall", () => {
it("projectParam / closestPointOnSegment / pointSegmentDistance", () => {
const rng = mulberry32(1);
const qs = Array.from({ length: N }, () => ({ p: v(rng), a: v(rng), b: v(rng) }));
const wProj = JSON.parse(K.project_param_batch_json(JSON.stringify(qs))) as number[];
const wClose = JSON.parse(K.closest_point_batch_json(JSON.stringify(qs))) as Vec2[];
const wDist = JSON.parse(K.point_segment_distance_batch_json(JSON.stringify(qs))) as number[];
qs.forEach((q, i) => {
expect(closeNum(wProj[i], projectParam(q.p, q.a, q.b)), `proj#${i}`).toBe(true);
expect(closeVec(wClose[i], closestPointOnSegment(q.p, q.a, q.b)), `close#${i}`).toBe(true);
expect(closeNum(wDist[i], pointSegmentDistance(q.p, q.a, q.b)), `dist#${i}`).toBe(true);
});
});
it("segmentIntersect / lineSegmentIntersect (Zufall + garantierte Kreuzungen)", () => {
const rng = mulberry32(2);
const qs = Array.from({ length: N }, (_, i) =>
i % 2 === 0
? { a1: v(rng), a2: v(rng), b1: v(rng), b2: v(rng) }
: crossingSegs(rng),
);
const wSeg = JSON.parse(K.segment_intersect_batch_json(JSON.stringify(qs))) as (Hit | null)[];
const wLine = JSON.parse(K.line_segment_intersect_batch_json(JSON.stringify(qs))) as (Hit | null)[];
let hits = 0;
qs.forEach((q, i) => {
const t = segmentIntersect(q.a1, q.a2, q.b1, q.b2);
// Struktur exakt: null ⇔ null.
expect(wSeg[i] === null, `seg-null#${i}`).toBe(t === null);
if (t && wSeg[i]) {
expect(closeHit(wSeg[i]!, t), `seg#${i}`).toBe(true);
hits++;
}
const tl = lineSegmentIntersect(q.a1, q.a2, q.b1, q.b2);
expect(wLine[i] === null, `line-null#${i}`).toBe(tl === null);
if (tl && wLine[i]) expect(closeHit(wLine[i]!, tl), `line#${i}`).toBe(true);
});
expect(hits, "keine einzige Kreuzung getroffen — Test waere aussagelos").toBeGreaterThan(50);
});
it("segmentPolylineHits (Struktur exakt + Werte)", () => {
const rng = mulberry32(3);
const qs = Array.from({ length: N }, () => {
const n = 3 + Math.floor(rng() * 18);
const pts = Array.from({ length: n }, () => v(rng));
// Langer Schneider quer durch die BBox.
return { a1: { x: -120, y: coord(rng) }, a2: { x: 120, y: coord(rng) }, pts, closed: rng() < 0.5 };
});
const wHits = JSON.parse(K.segment_polyline_hits_batch_json(JSON.stringify(qs))) as Hit[][];
qs.forEach((q, i) => {
const t = segmentPolylineHits(q.a1, q.a2, q.pts, q.closed);
expect(wHits[i].length, `hits-len#${i}`).toBe(t.length);
t.forEach((h, j) => expect(closeHit(wHits[i][j], h), `hit#${i}.${j}`).toBe(true));
});
});
it("lineCircleIntersect / segmentCircleIntersect (Struktur + Werte)", () => {
const rng = mulberry32(4);
const qs = Array.from({ length: N }, () => ({
a: v(rng),
b: v(rng),
center: v(rng),
r: 0.01 + rng() * 50,
}));
const wLine = JSON.parse(K.line_circle_intersect_batch_json(JSON.stringify(qs))) as Vec2[][];
const wSeg = JSON.parse(K.segment_circle_intersect_batch_json(JSON.stringify(qs))) as Vec2[][];
qs.forEach((q, i) => {
const tl = lineCircleIntersect(q.a, q.b, q.center, q.r);
expect(wLine[i].length, `lc-len#${i}`).toBe(tl.length);
tl.forEach((p, j) => expect(closeVec(wLine[i][j], p), `lc#${i}.${j}`).toBe(true));
const ts = segmentCircleIntersect(q.a, q.b, q.center, q.r);
expect(wSeg[i].length, `sc-len#${i}`).toBe(ts.length);
ts.forEach((p, j) => expect(closeVec(wSeg[i][j], p), `sc#${i}.${j}`).toBe(true));
});
});
it("circleCircleIntersect (Struktur + Werte)", () => {
const rng = mulberry32(5);
const qs = Array.from({ length: N }, () => ({
c1: v(rng),
r1: 0.01 + rng() * 50,
c2: v(rng),
r2: 0.01 + rng() * 50,
}));
const w = JSON.parse(K.circle_circle_intersect_batch_json(JSON.stringify(qs))) as Vec2[][];
qs.forEach((q, i) => {
const t = circleCircleIntersect(q.c1, q.r1, q.c2, q.r2);
expect(w[i].length, `cc-len#${i}`).toBe(t.length);
t.forEach((p, j) => expect(closeVec(w[i][j], p), `cc#${i}.${j}`).toBe(true));
});
});
it("signedArea (rel 1e-9) / isCCW (Struktur exakt)", () => {
const rng = mulberry32(6);
const polys = Array.from({ length: N }, () => {
const n = 3 + Math.floor(rng() * 8);
return Array.from({ length: n }, () => v(rng));
});
const wArea = JSON.parse(K.signed_area_batch_json(JSON.stringify(polys))) as number[];
const wCcw = JSON.parse(K.is_ccw_batch_json(JSON.stringify(polys))) as boolean[];
polys.forEach((p, i) => {
const a = signedArea(p);
expect(Math.abs(wArea[i] - a) <= 1e-9 * Math.max(1, Math.abs(a)), `area#${i}`).toBe(true);
expect(wCcw[i], `ccw#${i}`).toBe(isCCW(p));
});
});
it("offsetSegment / offsetPolyline (Miter, Struktur + Werte rel 1e-9)", () => {
const rng = mulberry32(7);
const segs = Array.from({ length: N }, () => ({
a: v(rng),
b: v(rng),
d: (rng() * 2 - 1) * 5,
}));
const wSeg = JSON.parse(K.offset_segment_batch_json(JSON.stringify(segs))) as [Vec2, Vec2][];
segs.forEach((q, i) => {
const [a, b] = offsetSegment(q.a, q.b, q.d);
expect(closeVec(wSeg[i][0], a) && closeVec(wSeg[i][1], b), `offSeg#${i}`).toBe(true);
});
const polys = Array.from({ length: N }, () => {
const n = 3 + Math.floor(rng() * 10);
const pts = Array.from({ length: n }, () => v(rng));
return { pts, d: (rng() * 2 - 1) * 5, closed: rng() < 0.5 };
});
const wPoly = JSON.parse(K.offset_polyline_batch_json(JSON.stringify(polys))) as Vec2[][];
polys.forEach((q, i) => {
const t = offsetPolyline(q.pts, q.d, q.closed);
expect(wPoly[i].length, `offPoly-len#${i}`).toBe(t.length);
t.forEach((p, j) => expect(closeVec(wPoly[i][j], p), `offPoly#${i}.${j}`).toBe(true));
});
});
it("filletCorner (Struktur exakt + Werte, Winkel abs 1e-7)", () => {
const rng = mulberry32(8);
// Halb kontrolliert (garantiert gueltige Verrundung), halb Zufall (None-Paritaet).
const qs = Array.from({ length: N }, (_, i) => {
if (i % 2 === 0) {
const corner = v(rng);
const a0 = rng() * Math.PI * 2;
const half = 0.3 + rng() * 0.9; // theta = 2*half ∈ [0.6, 2.4] rad (weg von 0/π)
const legLen = 3 + rng() * 7;
const rMax = legLen * Math.tan(half);
const r = 0.1 + rng() * 0.8 * rMax;
const dir = (ang: number) => ({ x: Math.cos(ang), y: Math.sin(ang) });
const d1 = dir(a0 + half);
const d2 = dir(a0 - half);
return {
corner,
p1: { x: corner.x + d1.x * legLen, y: corner.y + d1.y * legLen },
p2: { x: corner.x + d2.x * legLen, y: corner.y + d2.y * legLen },
r,
};
}
return { corner: v(rng), p1: v(rng), p2: v(rng), r: 0.1 + rng() * 5 };
});
const w = JSON.parse(K.fillet_corner_batch_json(JSON.stringify(qs))) as (Fillet | null)[];
let valid = 0;
qs.forEach((q, i) => {
const t = filletCorner(q.corner, q.p1, q.p2, q.r);
expect(w[i] === null, `fil-null#${i}`).toBe(t === null);
if (t && w[i]) {
const f = w[i]!;
expect(closeVec(f.center, t.center), `fil-center#${i}`).toBe(true);
expect(closeVec(f.tangentA, t.tangentA), `fil-tA#${i}`).toBe(true);
expect(closeVec(f.tangentB, t.tangentB), `fil-tB#${i}`).toBe(true);
expect(closeNum(f.radius, t.radius), `fil-r#${i}`).toBe(true);
expect(Math.abs(f.startAngle - t.startAngle) <= 1e-7, `fil-sa#${i}`).toBe(true);
expect(Math.abs(f.endAngle - t.endAngle) <= 1e-7, `fil-ea#${i}`).toBe(true);
valid++;
}
});
expect(valid, "keine gueltige Verrundung — Test waere aussagelos").toBeGreaterThan(50);
});
it("splitSegmentByCutters / trimSegment (Struktur + Werte)", () => {
const rng = mulberry32(9);
const qs = Array.from({ length: N }, () => ({
a1: { x: -60, y: (rng() * 2 - 1) * 40 },
a2: { x: 60, y: (rng() * 2 - 1) * 40 },
cutters: genCutters(rng),
pick: { x: (rng() * 2 - 1) * 60, y: (rng() * 2 - 1) * 40 },
}));
const wSplit = JSON.parse(K.split_segment_by_cutters_batch_json(JSON.stringify(qs))) as [Vec2, Vec2][][];
const wTrim = JSON.parse(K.trim_segment_batch_json(JSON.stringify(qs))) as [Vec2, Vec2][][];
qs.forEach((q, i) => {
expect(eqPairs(wSplit[i], splitSegmentByCutters(q.a1, q.a2, q.cutters)), `split#${i}`).toBe(true);
expect(eqPairs(wTrim[i], trimSegment(q.a1, q.a2, q.cutters, q.pick)), `trim#${i}`).toBe(true);
});
});
it("trimPolyline / splitAtIntersections (offen+geschlossen, Struktur exakt)", () => {
const rng = mulberry32(10);
const qs = Array.from({ length: N }, () => ({
pts: genPolyline(rng, 4, 9),
closed: rng() < 0.5,
cutters: genCutters(rng),
pick: { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 },
others: genCutters(rng).map((c) => c.pts),
}));
const wTrim = JSON.parse(K.trim_polyline_batch_json(JSON.stringify(qs))) as Poly[][];
const wSplit = JSON.parse(
K.split_at_intersections_batch_json(
JSON.stringify(qs.map((q) => ({ targetPts: q.pts, closed: q.closed, others: q.others }))),
),
) as Vec2[][][];
qs.forEach((q, i) => {
expect(eqPolyList(wTrim[i], trimPolyline(q.pts, q.closed, q.cutters, q.pick)), `trimPoly#${i}`).toBe(true);
expect(eqPtsLists(wSplit[i], splitAtIntersections(q.pts, q.closed, q.others)), `splitAt#${i}`).toBe(true);
});
});
it("splitPolylineAtParam / splitClosedByChord / removeSegment", () => {
const rng = mulberry32(11);
const qs = Array.from({ length: N }, () => {
const pts = genPolyline(rng, 4, 9);
const n = pts.length;
const closed = rng() < 0.5;
return { pts, closed, edgeIndex: Math.floor(rng() * (closed ? n : n - 1)), t: rng() };
});
const wSap = JSON.parse(K.split_polyline_at_param_batch_json(JSON.stringify(qs))) as Vec2[][][];
const wRem = JSON.parse(
K.remove_segment_batch_json(
JSON.stringify(qs.map((q) => ({ pts: q.pts, closed: q.closed, edgeIndex: q.edgeIndex }))),
),
) as Poly[];
const chords = Array.from({ length: N }, () => {
const pts = genPolyline(rng, 4, 8);
const n = pts.length;
const i = Math.floor(rng() * n);
let j = Math.floor(rng() * n);
if (j === i) j = (j + 1) % n;
return { pts, i, ti: rng(), j, tj: rng() };
});
const wChord = JSON.parse(K.split_closed_by_chord_batch_json(JSON.stringify(chords))) as ([Vec2[], Vec2[]] | null)[];
qs.forEach((q, i) => {
expect(eqPtsLists(wSap[i], splitPolylineAtParam(q.pts, q.closed, q.edgeIndex, q.t)), `sap#${i}`).toBe(true);
const tr = removeSegment(q.pts, q.closed, q.edgeIndex);
expect(wRem[i].closed === tr.closed && eqPts(wRem[i].pts, tr.pts), `rem#${i}`).toBe(true);
});
chords.forEach((q, i) => {
const t = splitClosedByChord(q.pts, q.i, q.ti, q.j, q.tj);
expect((wChord[i] === null) === (t === null), `chord-null#${i}`).toBe(true);
if (t && wChord[i]) {
expect(eqPts(wChord[i]![0], t[0]) && eqPts(wChord[i]![1], t[1]), `chord#${i}`).toBe(true);
}
});
});
it("extendSegment (start/end, null-Paritaet + Werte)", () => {
const rng = mulberry32(12);
const qs = Array.from({ length: N }, (_, k) => {
const c = { x: (rng() * 2 - 1) * 30, y: (rng() * 2 - 1) * 30 };
return {
a1: { x: c.x - 1, y: c.y },
a2: { x: c.x + 1, y: c.y },
end: (k % 2 === 0 ? "end" : "start") as "start" | "end",
cutters: genCutters(rng).concat([
{ pts: [{ x: c.x + 5, y: -50 }, { x: c.x + 5, y: 50 }], closed: false },
]),
};
});
const w = JSON.parse(K.extend_segment_batch_json(JSON.stringify(qs))) as ([Vec2, Vec2] | null)[];
qs.forEach((q, i) => {
const t = extendSegment(q.a1, q.a2, q.end, q.cutters);
expect((w[i] === null) === (t === null), `ext-null#${i}`).toBe(true);
if (t && w[i]) expect(closeVec(w[i]![0], t[0]) && closeVec(w[i]![1], t[1]), `ext#${i}`).toBe(true);
});
});
it("joinChains (mergeable + Zufall, Struktur exakt)", () => {
const rng = mulberry32(13);
const groups = Array.from({ length: N }, () => {
const base = genPolyline(rng, 4, 7);
const chains: Poly[] = [];
for (let k = 0; k < base.length - 1; k++) {
const seg = [base[k], base[k + 1]];
chains.push({ pts: rng() < 0.5 ? seg : [seg[1], seg[0]], closed: false });
}
if (rng() < 0.5) chains.push({ pts: genPolyline(rng, 2, 3), closed: false });
for (let k = chains.length - 1; k > 0; k--) {
const j = Math.floor(rng() * (k + 1));
[chains[k], chains[j]] = [chains[j], chains[k]];
}
return chains;
});
const w = JSON.parse(K.join_chains_batch_json(JSON.stringify(groups))) as Poly[][];
groups.forEach((g, i) => {
expect(eqPolyList(w[i], joinChains(g)), `join#${i}`).toBe(true);
});
});
});
// ── Slice 1: roomArea / ceiling ───────────────────────────────────────────────
/** Zufaelliges konvexes Polygon mit n=3..10 Ecken in [-30,30]×[-30,30]. */
function genConvexPoly(rng: () => number, n: number): Vec2[] {
// Einfache Methode: Einheitswinkel sortieren + skalieren.
const angles = Array.from({ length: n }, () => rng() * 2 * Math.PI);
angles.sort((a, b) => a - b);
const rx = 1 + rng() * 20;
const ry = 1 + rng() * 20;
const cx = (rng() * 2 - 1) * 10;
const cy = (rng() * 2 - 1) * 10;
return angles.map((a) => ({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) }));
}
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 1: roomArea + ceiling", () => {
it("polygonArea / perimeter / centroid (Zufallspolygone 310 Ecken, rel 1e-9)", () => {
const rng = mulberry32(20);
const polys = Array.from({ length: N }, () => {
const n = 3 + Math.floor(rng() * 8);
return genConvexPoly(rng, n);
});
const wArea = JSON.parse(K.polygon_area_batch_json(JSON.stringify(polys))) as number[];
const wPerim = JSON.parse(K.perimeter_batch_json(JSON.stringify(polys))) as number[];
const wCent = JSON.parse(K.centroid_batch_json(JSON.stringify(polys))) as Vec2[];
polys.forEach((p, i) => {
const a = polygonArea(p);
const pe = perimeter(p);
const c = centroid(p);
expect(closeNum(wArea[i], a), `area#${i}`).toBe(true);
expect(closeNum(wPerim[i], pe), `perim#${i}`).toBe(true);
expect(closeVec(wCent[i], c), `centroid#${i}`).toBe(true);
});
});
it("normalizeOutline: Struktur exakt (null↔null) + Werte + None-Paritaet", () => {
const rng = mulberry32(21);
// Haelfte: gueltige Polygone; Haelfte: degenerierte (< 3 Punkte oder kollinear).
const inputs = Array.from({ length: N }, (_, i) => {
if (i % 4 < 3) {
const n = 3 + Math.floor(rng() * 8);
return genConvexPoly(rng, n);
}
// Degeneriert: 0, 1 oder 2 Punkte.
const k = Math.floor(rng() * 3);
return Array.from({ length: k }, () => ({ x: rng() * 10, y: rng() * 10 }));
});
const w = JSON.parse(K.normalize_outline_batch_json(JSON.stringify(inputs))) as (Vec2[] | null)[];
inputs.forEach((p, i) => {
const t = normalizeOutline(p);
expect(w[i] === null, `norm-null#${i}`).toBe(t === null);
if (t !== null && w[i] !== null) {
const wp = w[i]!;
expect(wp.length, `norm-len#${i}`).toBe(t.length);
t.forEach((pt, j) => expect(closeVec(wp[j], pt), `norm-pt#${i}.${j}`).toBe(true));
}
});
});
it("isValidOutline / ceilingArea (Struktur exakt + Werte)", () => {
const rng = mulberry32(22);
const polys = Array.from({ length: N }, (_, i) => {
if (i % 5 < 4) return genConvexPoly(rng, 3 + Math.floor(rng() * 8));
return Array.from({ length: Math.floor(rng() * 3) }, () => ({ x: rng() * 5, y: rng() * 5 }));
});
const wValid = JSON.parse(K.is_valid_outline_batch_json(JSON.stringify(polys))) as boolean[];
const wArea = JSON.parse(K.ceiling_area_batch_json(JSON.stringify(polys))) as number[];
polys.forEach((p, i) => {
expect(wValid[i], `valid#${i}`).toBe(isValidOutline(p));
expect(closeNum(wArea[i], ceilingArea(p)), `ceilArea#${i}`).toBe(true);
});
});
it("outlineBBox / outlineCentroid (Werte rel 1e-9)", () => {
const rng = mulberry32(23);
const polys = Array.from({ length: N }, () => genConvexPoly(rng, 3 + Math.floor(rng() * 8)));
const wBBox = JSON.parse(K.outline_bbox_batch_json(JSON.stringify(polys))) as {
minX: number;
minY: number;
maxX: number;
maxY: number;
}[];
const wCent = JSON.parse(K.outline_centroid_batch_json(JSON.stringify(polys))) as Vec2[];
polys.forEach((p, i) => {
const bb = outlineBBox(p);
const c = outlineCentroid(p);
expect(closeNum(wBBox[i].minX, bb.minX), `bbox-minX#${i}`).toBe(true);
expect(closeNum(wBBox[i].minY, bb.minY), `bbox-minY#${i}`).toBe(true);
expect(closeNum(wBBox[i].maxX, bb.maxX), `bbox-maxX#${i}`).toBe(true);
expect(closeNum(wBBox[i].maxY, bb.maxY), `bbox-maxY#${i}`).toBe(true);
expect(closeVec(wCent[i], c), `outCentroid#${i}`).toBe(true);
});
});
it("pointInOutline (Struktur exakt: true↔true, Zufallspunkte innen+aussen)", () => {
const rng = mulberry32(24);
const qs = Array.from({ length: N }, () => {
const n = 3 + Math.floor(rng() * 8);
const outline = genConvexPoly(rng, n);
// Schwerpunkt des Polygons als garantiert innerer Punkt.
const cx = outline.reduce((s, p) => s + p.x, 0) / outline.length;
const cy = outline.reduce((s, p) => s + p.y, 0) / outline.length;
const p = rng() < 0.5 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 };
return { p, outline };
});
const w = JSON.parse(K.point_in_outline_batch_json(JSON.stringify(qs))) as boolean[];
qs.forEach((q, i) => {
expect(w[i], `pio#${i}`).toBe(pointInOutline(q.p, q.outline));
});
});
});
// ── Slice 2: stair ────────────────────────────────────────────────────────────
/** Erzeugt einen einfachen Stair-Stub mit geometrisch relevanten Feldern. */
function genStair(
rng: () => number,
shape: "straight" | "L" | "spiral",
): Stair & { totalRise: number } {
const start = { x: (rng() * 2 - 1) * 10, y: (rng() * 2 - 1) * 10 };
const angle = rng() * Math.PI * 2;
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
const runLength = 1 + rng() * 5;
const width = 0.8 + rng() * 1.5;
const stepCount = 3 + Math.floor(rng() * 12);
const totalRise = 0.5 + rng() * 3;
const up = rng() < 0.5 ? true : false;
const base: Stair = {
id: "test",
type: "stair",
floorId: "f1",
categoryCode: "40",
shape,
start,
dir,
runLength,
width,
stepCount,
up,
};
if (shape === "L") {
return { ...base, run2Length: 1 + rng() * 4, turn: rng() < 0.5 ? 1 : -1, totalRise };
}
if (shape === "spiral") {
return {
...base,
center: { x: start.x + rng() * 2, y: start.y + rng() * 2 },
radius: 0.5 + rng() * 2,
sweep: 90 + rng() * 270,
totalRise,
};
}
return { ...base, totalRise };
}
/** Prueft Tritt-Listen-Gleichheit: Laenge, dann je Tritt index/baseRise/topRise/pts. */
function eqTreads(
w: { pts: Vec2[]; index: number; topRise: number; baseRise: number }[],
t: TSStairGeometry["treads"],
): boolean {
if (w.length !== t.length) return false;
return t.every((tr, i) => {
const wtr = w[i];
if (wtr.index !== tr.index) return false;
if (!closeNum(wtr.topRise, tr.topRise)) return false;
if (!closeNum(wtr.baseRise, tr.baseRise)) return false;
if (!eqPts(wtr.pts, tr.pts)) return false;
return true;
});
}
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 2: stair", () => {
it("defaultStepCount (Struktur exakt + Werte)", () => {
const rng = mulberry32(30);
const qs = Array.from({ length: N }, () => ({
totalRise: 0.1 + rng() * 5,
runLength: rng() * 8,
}));
const w = JSON.parse(K.default_step_count_batch_json(JSON.stringify(qs))) as number[];
qs.forEach((q, i) => {
expect(w[i], `dsc#${i}`).toBe(defaultStepCount(q.totalRise, q.runLength));
});
});
it("stairGeometry gerade: Tritte + Werte (Struktur exakt, rel 1e-9)", () => {
const rng = mulberry32(31);
const qs = Array.from({ length: 100 }, () => {
const s = genStair(rng, "straight");
return { stair: s, totalRise: s.totalRise };
});
const wGeos = JSON.parse(K.stair_geometry_batch_json(JSON.stringify(qs))) as {
treads: { pts: Vec2[]; index: number; topRise: number; baseRise: number }[];
landing: Vec2[] | null;
riserHeight: number;
treadDepth: number;
runLine: Vec2[];
totalRise: number;
}[];
qs.forEach((q, i) => {
const t = stairGeometry(q.stair, q.totalRise);
const w = wGeos[i];
expect(eqTreads(w.treads, t.treads), `treads#${i}`).toBe(true);
expect(w.landing === null, `landing-null#${i}`).toBe(t.landing === null);
expect(closeNum(w.riserHeight, t.riserHeight), `riser#${i}`).toBe(true);
expect(closeNum(w.treadDepth, t.treadDepth), `depth#${i}`).toBe(true);
expect(eqPts(w.runLine, t.runLine), `runLine#${i}`).toBe(true);
});
});
it("stairGeometry L + spiral: Struktur exakt + Werte", () => {
const rng = mulberry32(32);
const lqs = Array.from({ length: 50 }, () => {
const s = genStair(rng, "L");
return { stair: s, totalRise: s.totalRise };
});
const spiralqs = Array.from({ length: 50 }, () => {
const s = genStair(rng, "spiral");
return { stair: s, totalRise: s.totalRise };
});
const wL = JSON.parse(K.stair_geometry_batch_json(JSON.stringify(lqs))) as {
treads: { pts: Vec2[]; index: number; topRise: number; baseRise: number }[];
landing: Vec2[] | null;
riserHeight: number;
treadDepth: number;
runLine: Vec2[];
}[];
lqs.forEach((q, i) => {
const t = stairGeometry(q.stair, q.totalRise);
const w = wL[i];
expect(eqTreads(w.treads, t.treads), `L-treads#${i}`).toBe(true);
expect(w.landing !== null, `L-landing#${i}`).toBe(t.landing !== null);
if (t.landing && w.landing) expect(eqPts(w.landing, t.landing), `L-landingPts#${i}`).toBe(true);
expect(eqPts(w.runLine, t.runLine), `L-runLine#${i}`).toBe(true);
});
const wSp = JSON.parse(K.stair_geometry_batch_json(JSON.stringify(spiralqs))) as typeof wL;
spiralqs.forEach((q, i) => {
const t = stairGeometry(q.stair, q.totalRise);
const w = wSp[i];
expect(eqTreads(w.treads, t.treads), `sp-treads#${i}`).toBe(true);
expect(eqPts(w.runLine, t.runLine), `sp-runLine#${i}`).toBe(true);
});
});
it("stairCut (Struktur exakt: Indizes + breakLine-null)", () => {
const rng = mulberry32(33);
const qs = Array.from({ length: 100 }, () => {
const s = genStair(rng, "straight");
const geo = stairGeometry(s, s.totalRise);
const cutRise = rng() * s.totalRise * 1.1;
return { geo, cutRise };
});
const w = JSON.parse(K.stair_cut_batch_json(JSON.stringify(qs))) as {
belowIndices: number[];
aboveIndices: number[];
breakLine: [Vec2, Vec2][] | null;
}[];
qs.forEach((q, i) => {
const t = stairCut(q.geo, q.cutRise);
expect(w[i].belowIndices.length, `cut-below-len#${i}`).toBe(t.belowIndices.length);
expect(w[i].aboveIndices.length, `cut-above-len#${i}`).toBe(t.aboveIndices.length);
expect(w[i].belowIndices, `cut-below#${i}`).toEqual(t.belowIndices);
expect(w[i].aboveIndices, `cut-above#${i}`).toEqual(t.aboveIndices);
expect(w[i].breakLine === null, `cut-break-null#${i}`).toBe(t.breakLine === null);
});
});
it("stairBBox (Werte rel 1e-9)", () => {
const rng = mulberry32(34);
const geos = Array.from({ length: 100 }, () => {
const s = genStair(rng, "straight");
return stairGeometry(s, s.totalRise);
});
const w = JSON.parse(K.stair_bbox_batch_json(JSON.stringify(geos))) as {
minX: number; minY: number; maxX: number; maxY: number;
}[];
geos.forEach((g, i) => {
const t = stairBBox(g);
expect(closeNum(w[i].minX, t.minX), `bbox-minX#${i}`).toBe(true);
expect(closeNum(w[i].minY, t.minY), `bbox-minY#${i}`).toBe(true);
expect(closeNum(w[i].maxX, t.maxX), `bbox-maxX#${i}`).toBe(true);
expect(closeNum(w[i].maxY, t.maxY), `bbox-maxY#${i}`).toBe(true);
});
});
it("pointInPolygon / pointHitsStair (Struktur exakt)", () => {
const rng = mulberry32(35);
const pipQs = Array.from({ length: N }, () => {
const n = 3 + Math.floor(rng() * 8);
const poly = genConvexPoly(rng, n);
const cx = poly.reduce((s, p) => s + p.x, 0) / poly.length;
const cy = poly.reduce((s, p) => s + p.y, 0) / poly.length;
const p = rng() < 0.5 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 };
return { p, poly };
});
const wPip = JSON.parse(K.point_in_polygon_batch_json(JSON.stringify(pipQs))) as boolean[];
pipQs.forEach((q, i) => {
expect(wPip[i], `pip#${i}`).toBe(stairPointInPolygon(q.p, q.poly));
});
const phsQs = Array.from({ length: 50 }, () => {
const s = genStair(rng, "straight");
const geo = stairGeometry(s, s.totalRise);
const p = { x: s.start.x + rng() * 6 - 1, y: s.start.y + (rng() * 2 - 1) * 2 };
return { p, geo };
});
const wPhs = JSON.parse(K.point_hits_stair_batch_json(JSON.stringify(phsQs))) as boolean[];
phsQs.forEach((q, i) => {
expect(wPhs[i], `phs#${i}`).toBe(pointHitsStair(q.p, q.geo));
});
});
});
// ── Slice 3: roomBoundary ─────────────────────────────────────────────────────
/** Erzeugt eine Menge rechtwinkliger Wandsegmente, die ein einfaches Rechteck
* (oder L-Form) bilden. */
function genRectWalls(rng: () => number): WallSegment[] {
const x0 = (rng() * 2 - 1) * 5;
const y0 = (rng() * 2 - 1) * 5;
const w = 2 + rng() * 5;
const h = 2 + rng() * 5;
const t = 0.1 + rng() * 0.3;
return [
{ a: { x: x0, y: y0 }, b: { x: x0 + w, y: y0 }, thickness: t },
{ a: { x: x0 + w, y: y0 }, b: { x: x0 + w, y: y0 + h }, thickness: t },
{ a: { x: x0 + w, y: y0 + h }, b: { x: x0, y: y0 + h }, thickness: t },
{ a: { x: x0, y: y0 + h }, b: { x: x0, y: y0 }, thickness: t },
];
}
/** Erzeugt WallFace-Liste aus einem geschlossenen Polygon. */
function polyToFaces(pts: Vec2[]): WallFace[] {
const n = pts.length;
return Array.from({ length: n }, (_, i) => ({ a: pts[i], b: pts[(i + 1) % n] }));
}
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 3: roomBoundary", () => {
it("rbPointInPolygon (roomBoundary-Variante, kein Nenner-Guard, Struktur exakt)", () => {
const rng = mulberry32(40);
const qs = Array.from({ length: N }, () => {
const n = 3 + Math.floor(rng() * 8);
const poly = genConvexPoly(rng, n);
const cx = poly.reduce((s, p) => s + p.x, 0) / poly.length;
const cy = poly.reduce((s, p) => s + p.y, 0) / poly.length;
const p = rng() < 0.5 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 };
return { p, poly };
});
const w = JSON.parse(K.rb_point_in_polygon_batch_json(JSON.stringify(qs))) as boolean[];
qs.forEach((q, i) => {
expect(w[i], `rbpip#${i}`).toBe(rbPointInPolygon(q.p, q.poly));
});
});
it("detectRooms — Rechteck-Grundriss: gleiche Anzahl + Reihenfolge Raeume", () => {
const rng = mulberry32(41);
const qs = Array.from({ length: 50 }, () => ({
walls: genRectWalls(rng),
gapTol: 0.05,
minArea: 0.05,
offsetToInner: true,
}));
const w = JSON.parse(K.detect_rooms_batch_json(JSON.stringify(qs))) as Vec2[][][];
qs.forEach((q, i) => {
const t = detectRooms(q.walls, { gapTol: q.gapTol, minArea: q.minArea, offsetToInner: q.offsetToInner });
// Gleiche Anzahl Raeume.
expect(w[i].length, `dr-count#${i}`).toBe(t.length);
// Je Raum: gleiche Punktanzahl (Reihenfolge-abhaengig, aber deterministisch).
t.forEach((room, j) => {
expect(w[i][j]?.length, `dr-roomLen#${i}.${j}`).toBe(room.length);
room.forEach((pt, k) => expect(closeVec(w[i][j][k], pt), `dr-pt#${i}.${j}.${k}`).toBe(true));
});
});
});
it("detectRooms ohne Offset + degeneriert (< 3 Waende → leer)", () => {
const rng = mulberry32(42);
const qs = Array.from({ length: 30 }, () => ({
walls: genRectWalls(rng),
gapTol: 0.05,
minArea: 0.05,
offsetToInner: false,
}));
const w = JSON.parse(K.detect_rooms_batch_json(JSON.stringify(qs))) as Vec2[][][];
qs.forEach((q, i) => {
const t = detectRooms(q.walls, { gapTol: q.gapTol, minArea: q.minArea, offsetToInner: q.offsetToInner });
expect(w[i].length, `drno-count#${i}`).toBe(t.length);
});
// Degeneriert: < 3 Waende → leer.
const deg = [{ walls: [{ a: { x: 0, y: 0 }, b: { x: 1, y: 0 }, thickness: 0.2 }], gapTol: 0.05, minArea: 0.05, offsetToInner: true }];
const wd = JSON.parse(K.detect_rooms_batch_json(JSON.stringify(deg))) as Vec2[][][];
expect(wd[0].length).toBe(detectRooms(deg[0].walls, {}).length);
expect(wd[0].length).toBe(0);
});
it("roomFromPointInside — Schwerpunkt als Saatpunkt findet den Raum (nicht null)", () => {
const rng = mulberry32(43);
const qs = Array.from({ length: 50 }, () => {
const walls = genRectWalls(rng);
// Mittelpunkt des Rechtecks als Saatpunkt.
const xs = walls.flatMap((w) => [w.a.x, w.b.x]);
const ys = walls.flatMap((w) => [w.a.y, w.b.y]);
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
return { point: { x: cx, y: cy }, walls, gapTol: 0.05, offsetToInner: true };
});
const w = JSON.parse(K.room_from_point_inside_batch_json(JSON.stringify(qs))) as (Vec2[] | null)[];
qs.forEach((q, i) => {
const t = roomFromPointInside(q.point, q.walls, { gapTol: q.gapTol, offsetToInner: q.offsetToInner });
expect(w[i] === null, `rfpi-null#${i}`).toBe(t === null);
if (t !== null && w[i] !== null) {
expect(w[i]!.length, `rfpi-len#${i}`).toBe(t.length);
t.forEach((pt, j) => expect(closeVec(w[i]![j], pt), `rfpi-pt#${i}.${j}`).toBe(true));
}
});
});
it("roomFromPointInsideFaces — Struktur exakt (null↔null, Laenge, Punkte)", () => {
const rng = mulberry32(44);
const qs = Array.from({ length: 30 }, () => {
const n = 4 + Math.floor(rng() * 4);
const poly = genConvexPoly(rng, n);
const faces = polyToFaces(poly);
const cx = poly.reduce((s, p) => s + p.x, 0) / poly.length;
const cy = poly.reduce((s, p) => s + p.y, 0) / poly.length;
const inOrOut = rng() < 0.7 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 40, y: (rng() * 2 - 1) * 40 };
return { point: inOrOut, wallFaces: faces, gapTol: 0.05 };
});
const w = JSON.parse(K.room_from_point_inside_faces_batch_json(JSON.stringify(qs))) as (Vec2[] | null)[];
qs.forEach((q, i) => {
const t = roomFromPointInsideFaces(q.point, q.wallFaces, q.gapTol);
expect(w[i] === null, `rfpif-null#${i}`).toBe(t === null);
if (t !== null && w[i] !== null) {
expect(w[i]!.length, `rfpif-len#${i}`).toBe(t.length);
t.forEach((pt, j) => expect(closeVec(w[i]![j], pt), `rfpif-pt#${i}.${j}`).toBe(true));
}
});
});
});
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {
it("parallele/kollineare Strecken → null (beide)", () => {
const qs = [
{ a1: { x: 0, y: 0 }, a2: { x: 2, y: 0 }, b1: { x: 0, y: 1 }, b2: { x: 2, y: 1 } }, // parallel
{ a1: { x: 0, y: 0 }, a2: { x: 4, y: 0 }, b1: { x: 1, y: 0 }, b2: { x: 3, y: 0 } }, // kollinear
{ a1: { x: 0, y: 0 }, a2: { x: 0, y: 0 }, b1: { x: 1, y: 1 }, b2: { x: 2, y: 2 } }, // Null-Laenge
];
const w = JSON.parse(K.segment_intersect_batch_json(JSON.stringify(qs))) as (Hit | null)[];
qs.forEach((q, i) => {
const t = segmentIntersect(q.a1, q.a2, q.b1, q.b2);
expect(w[i] === null, `#${i}`).toBe(t === null);
if (t && w[i]) expect(closeHit(w[i]!, t)).toBe(true);
});
});
it("tangentiale Gerade → genau 1 Punkt; konzentrische/getrennte Kreise → leer", () => {
const lc = [{ a: { x: -1, y: 1 }, b: { x: 1, y: 1 }, center: { x: 0, y: 0 }, r: 1 }]; // Tangente
const wlc = JSON.parse(K.line_circle_intersect_batch_json(JSON.stringify(lc))) as Vec2[][];
expect(wlc[0].length).toBe(lineCircleIntersect(lc[0].a, lc[0].b, lc[0].center, lc[0].r).length);
expect(wlc[0].length).toBe(1);
const cc = [
{ c1: { x: 0, y: 0 }, r1: 1, c2: { x: 0, y: 0 }, r2: 2 }, // konzentrisch → leer
{ c1: { x: 0, y: 0 }, r1: 1, c2: { x: 5, y: 0 }, r2: 1 }, // getrennt → leer
{ c1: { x: 0, y: 0 }, r1: 2, c2: { x: 1, y: 0 }, r2: 1 }, // innen tangential → 1
];
const wcc = JSON.parse(K.circle_circle_intersect_batch_json(JSON.stringify(cc))) as Vec2[][];
cc.forEach((q, i) => {
const t = circleCircleIntersect(q.c1, q.r1, q.c2, q.r2);
expect(wcc[i].length, `cc#${i}`).toBe(t.length);
t.forEach((p, j) => expect(closeVec(wcc[i][j], p)).toBe(true));
});
});
it("degeneriertes Null-Flaeche-Polygon (kollinear) → Flaeche 0, nicht CCW", () => {
const polys = [[{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }]];
const wArea = JSON.parse(K.signed_area_batch_json(JSON.stringify(polys))) as number[];
const wCcw = JSON.parse(K.is_ccw_batch_json(JSON.stringify(polys))) as boolean[];
expect(closeNum(wArea[0], signedArea(polys[0]))).toBe(true);
expect(Math.abs(wArea[0])).toBeLessThan(1e-12);
expect(wCcw[0]).toBe(isCCW(polys[0]));
});
it("Offset L-Ecke (Gehrung) und Fillet rechter Winkel / kollinear→null / zu gross→null", () => {
const off = [{ pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }], d: 0.5, closed: false }];
const wOff = JSON.parse(K.offset_polyline_batch_json(JSON.stringify(off))) as Vec2[][];
const tOff = offsetPolyline(off[0].pts, off[0].d, off[0].closed);
expect(wOff[0].length).toBe(tOff.length);
tOff.forEach((p, j) => expect(closeVec(wOff[0][j], p)).toBe(true));
const fil = [
{ corner: { x: 0, y: 0 }, p1: { x: 5, y: 0 }, p2: { x: 0, y: 5 }, r: 1 }, // rechter Winkel
{ corner: { x: 0, y: 0 }, p1: { x: 1, y: 0 }, p2: { x: -1, y: 0 }, r: 0.5 }, // kollinear → null
{ corner: { x: 0, y: 0 }, p1: { x: 0.1, y: 0 }, p2: { x: 0, y: 0.1 }, r: 10 }, // zu gross → null
];
const wFil = JSON.parse(K.fillet_corner_batch_json(JSON.stringify(fil))) as (Fillet | null)[];
fil.forEach((q, i) => {
const t = filletCorner(q.corner, q.p1, q.p2, q.r);
expect(wFil[i] === null, `golden-fil#${i}`).toBe(t === null);
if (t && wFil[i]) {
expect(closeVec(wFil[i]!.center, t.center)).toBe(true);
expect(closeVec(wFil[i]!.tangentA, t.tangentA)).toBe(true);
}
});
});
});
+2 -27
View File
@@ -170,21 +170,12 @@ export interface WindowSymbol {
frame: Vec2[];
/** Glaslinien-Segmente quer über die Lücke (12 parallele Linien). */
glassLines: [Vec2, Vec2][];
/**
* Flügel-Mittelpfosten (`wingCount 1` Querlinien), jeweils von einer
* Laibungsseite zur anderen (parallel zu den Laibungslinien, quer zur
* Öffnungsrichtung), gleichmäßig zwischen den beiden Pfosten verteilt. Bei
* `wingCount ≤ 1` leer. Entspricht den Mittelpfosten in `_make_oeffnung_pieces`
* des Rhino-Plugins (`oeff_fluegel`).
*/
mullionLines: [Vec2, Vec2][];
}
/**
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
* Wanddicke) plus 12 Glaslinien (dünne parallele Linien in der Wandmitte,
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
* (`wingCount 1` Querlinien).
* `count` steuert die Anzahl je Detailgrad).
*/
export function windowSymbol(
project: Project,
@@ -214,21 +205,5 @@ export function windowSymbol(
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
}
// Flügel-Mittelpfosten: (wingCount 1) Querlinien gleichmäßig zwischen den
// Pfosten. Jede Linie läuft quer über die Öffnung (n-Richtung), auf ~60 % der
// Mauerdicke symmetrisch um die Wandmitte (Referenz-Offset) — analog Rhino, wo
// die Pfosten x_mid = inner_l + span·(i/fluegel) über die Rahmentiefe sitzen.
const mullionLines: [Vec2, Vec2][] = [];
const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
if (wings > 1) {
const postDepth = total * 0.6;
const pOuter = refOff + postDepth / 2;
const pInner = refOff - postDepth / 2;
for (let i = 1; i < wings; i++) {
const t = i / wings; // gleichmäßige Teilung entlang der Achse
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
}
}
return { frame, glassLines, mullionLines };
return { frame, glassLines };
}
+5 -177
View File
@@ -27,32 +27,6 @@ const norm = (a: Vec2): Vec2 => {
/** Linke Normale (n = (u.y, u.x)) — quer zur Laufrichtung. */
const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x });
/**
* Perp-Versatz von der Referenzkante zur visuellen Treppen-MITTE, entlang der
* linken Normale `n = leftN(u)`. Entspricht `mid_off` aus `_treppe_2d_side_offsets`
* im Rhino-Plugin (Konvention: `perp` = CCW-Normale = linke Normale):
* referenz "links" Achse ist die LINKE Kante; die Mitte liegt eine
* halbe Breite in n-Richtung (rechts) mid_off = width/2.
* referenz "rechts" Achse ist die RECHTE Kante; die Mitte liegt eine
* halbe Breite in +n-Richtung (links) mid_off = +width/2.
* referenz "mitte"/undefined Achse ist bereits die Mitte 0.
*
* ANNAHME (selbstkritisch): links" heisst „linke Kante in Laufrichtung", und die
* linke Kante liegt in +n (n = leftN(u), 90° links der Laufrichtung). Von der
* linken Kante zur Mitte geht es also nach n, daher mid_off = width/2. Das
* deckt sich mit `_treppe_2d_side_offsets` (referenz="links" off_a=0, off_b=b,
* Mitte = b/2·perp). Wird die Richtung im UI als seitenverkehrt empfunden, ist
* hier das Vorzeichen zu drehen die Geometrie bleibt in sich konsistent.
*/
function referenzMidShift(stair: Stair, u: Vec2): Vec2 {
const ref = stair.referenz ?? "mitte";
if (ref === "mitte") return { x: 0, y: 0 };
const n = leftN(u);
const halfW = Math.max(0.05, stair.width / 2);
const midOff = ref === "links" ? -halfW : halfW;
return scale(n, midOff);
}
/** SIA-nahe Schrittregel: gute Steigungshöhe ≈ 0.17 m, Auftritt ≈ 0.29 m. */
export const IDEAL_RISER = 0.17;
export const IDEAL_TREAD = 0.29;
@@ -131,22 +105,13 @@ export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
const u = norm(stair.dir);
const n = leftN(u);
// Referenz (links/mitte/rechts): die gespeicherte Achse wird auf die visuelle
// Treppen-MITTE versetzt, danach greift die bestehende, um die Achse zentrierte
// Tritt-/Lauflinien-Logik unverändert. So bleibt die Lauflinie samt Pfeil stets
// auf der Mitte (nicht auf der Referenzkante). "mitte"/undefined ⇒ Nullversatz.
const c = referenzMidShift(stair, u);
const s = stair.referenz && stair.referenz !== "mitte"
? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center }
: stair;
if (s.shape === "spiral") {
return spiralGeometry(s, totalRise, steps, riserHeight, halfW);
if (stair.shape === "spiral") {
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
}
if (s.shape === "L") {
return lGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
if (stair.shape === "L") {
return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
}
return straightGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
}
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
@@ -444,140 +409,3 @@ export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean {
if (geo.landing && pointInPolygon(p, geo.landing)) return true;
return false;
}
// ── Treppen-Aussenlinie (Plan-Outline) ───────────────────────────────────────
/**
* Schliessendes Umriss-Polygon einer geraden Treppe im Grundriss (4 Ecken CCW).
* Entspricht `_aussen_gerade` im Rhino-Plugin (Rechteck über Laufbreite + Lauflänge).
* Liefert null bei entarteter Geometrie.
*/
function straightOutlinePoints(stair: Stair, halfW: number): Vec2[] | null {
const u = norm({ x: stair.dir.x, y: stair.dir.y });
const n = leftN(u);
const L = Math.max(1e-4, stair.runLength);
const s = stair.start;
// Vier Ecken: Start-links → Start-rechts → End-rechts → End-links (CCW).
return [
add(s, scale(n, halfW)),
add(s, scale(n, -halfW)),
add(add(s, scale(u, L)), scale(n, -halfW)),
add(add(s, scale(u, L)), scale(n, halfW)),
];
}
/**
* 2D-Linien-Schnittpunkt. Liefert null bei Parallelität (det < ε).
* Entspricht `_line_intersect_xy` im Rhino-Plugin.
*/
function lineIntersect2d(p1: Vec2, d1: Vec2, p2: Vec2, d2: Vec2): Vec2 | null {
const det = d1.x * (-d2.y) - d1.y * (-d2.x);
if (Math.abs(det) < 1e-9) return null;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const t = (dx * (-d2.y) - dy * (-d2.x)) / det;
return { x: p1.x + d1.x * t, y: p1.y + d1.y * t };
}
/**
* Umriss-Polygon einer L-Treppe im Grundriss. Entspricht `_aussen_l_polygon`
* im Rhino-Plugin: Linien-Schnittpunkte liefern die Ecken des L-Polygons.
* Liefert null bei entarteter Geometrie.
*/
function lOutlinePoints(stair: Stair, halfW: number): Vec2[] | null {
const u1 = norm(stair.dir);
const n1 = leftN(u1);
const L1 = Math.max(1e-4, stair.runLength);
const turn = stair.turn ?? 1;
const u2: Vec2 = turn > 0 ? n1 : scale(n1, -1);
const n2 = leftN(u2);
const L2 = Math.max(1e-4, stair.run2Length ?? stair.runLength);
// Podest-Zentrum am Ende des ersten Laufs (Mitte = halfW hinter dem Lauf-Ende).
const corner = add(stair.start, scale(u1, L1 + halfW));
const run2Start = add(corner, scale(u2, halfW));
// Lauf-1-Anfang je Seite
const p0l = add(stair.start, scale(n1, halfW));
const p0r = add(stair.start, scale(n1, -halfW));
// Lauf-2-Ende je Seite
const endRun2 = add(run2Start, scale(u2, L2));
const p3l = add(endRun2, scale(n2, halfW));
const p3r = add(endRun2, scale(n2, -halfW));
// Innere Eck-Punkte: Schnittpunkt der parallelen Seitenlinien je Seite.
const cornerL = lineIntersect2d(p0l, u1, p3l, scale(u2, -1)) ?? add(corner, scale(n1, halfW));
const cornerR = lineIntersect2d(p0r, u1, p3r, scale(u2, -1)) ?? add(corner, scale(n1, -halfW));
// CCW-Polygon: p0l → p0r → cornerR → p3r → p3l → cornerL
return [p0l, p0r, cornerR, p3r, p3l, cornerL];
}
/**
* Spiral-Outline: Innenbogen + Aussenbogen + zwei radiale Schliesslinien.
* Entspricht `_aussen_wendel` im Rhino-Plugin (immer vollständig, ohne Cut).
* Die Bögen werden als Kreisbogen-Parameter geliefert (cx/cy/r/a0/a1), die
* radialen Schliesslinien als Segment-Paare.
*/
export interface SpiralOutlineSegments {
/** Radiale Abschlusslinien (jeweils Innenpunkt → Aussenpunkt). */
lines: [Vec2, Vec2][];
/** Kreisbogen-Parameter: Innen- und Aussenkreis. */
arcs: { cx: number; cy: number; r: number; a0: number; a1: number }[];
}
function spiralOutline(stair: Stair, halfW: number): SpiralOutlineSegments | null {
const center = stair.center ?? stair.start;
const radius = Math.max(halfW + 0.1, stair.radius ?? stair.width);
const sweep = (stair.sweep ?? 270) * (Math.PI / 180);
const startVec = sub(stair.start, center);
const a0 = Math.atan2(startVec.y, startVec.x);
const a1 = a0 + sweep;
const rIn = Math.max(0.02, radius - halfW);
const rOut = radius + halfW;
const ptAt = (r: number, a: number): Vec2 => ({
x: center.x + r * Math.cos(a),
y: center.y + r * Math.sin(a),
});
return {
lines: [
[ptAt(rIn, a0), ptAt(rOut, a0)],
[ptAt(rIn, a1), ptAt(rOut, a1)],
],
arcs: [
{ cx: center.x, cy: center.y, r: rIn, a0, a1 },
{ cx: center.x, cy: center.y, r: rOut, a0, a1 },
],
};
}
/**
* Aussenlinie einer Treppe im Grundriss je nach Form:
* gerade geschlossenes Rechteck als Polygon-Punkte.
* L L-Polygon via Linien-Schnittpunkten.
* Wendel `SpiralOutlineSegments` (Bögen + radiale Linien).
*
* Genau einer von `polygon` / `spiral` ist nicht null.
*/
export function stairOutline(
stair: Stair,
totalRise: number,
): { polygon: Vec2[] | null; spiral: SpiralOutlineSegments | null } {
void totalRise; // Signatur-Konsistenz mit stairGeometry; derzeit ungenutzt
const halfW = Math.max(0.05, stair.width / 2);
// Referenz (links/mitte/rechts): die gespeicherte Achse zuerst auf die
// visuelle Treppen-Mitte versetzen, danach die um die Mitte zentrierte
// Aussenlinien-Logik unverändert anwenden (identisch zu `stairGeometry`).
const u = norm(stair.dir);
const c = referenzMidShift(stair, u);
const s = stair.referenz && stair.referenz !== "mitte"
? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center }
: stair;
if (s.shape === "spiral") {
return { polygon: null, spiral: spiralOutline(s, halfW) };
}
if (s.shape === "L") {
return { polygon: lOutlinePoints(s, halfW), spiral: null };
}
return { polygon: straightOutlinePoints(s, halfW), spiral: null };
}
-211
View File
@@ -1,211 +0,0 @@
/**
* Unit-Tests für `stairOutline` (Plan-Aussenlinie).
*
* Prüft:
* Gerade Treppe 4-Punkte-Rechteck (polygon, keine spiral).
* L-Treppe 6-Punkte-L-Polygon via Linien-Schnittpunkten.
* Wendeltreppe SpiralOutlineSegments (2 Bögen, 2 radiale Linien, kein polygon).
*
* Die genauen Koordinaten-Werte werden nur grob geprüft (Vorzeichen, Länge,
* Schliessen); die Geometrie-Logik wird via Abstand-Invarianten verifiziert.
*/
import { describe, it, expect } from "vitest";
import { stairOutline, stairGeometry } from "./stair";
import type { Stair, Vec2 } from "../model/types";
/** Einfache Distanz-Funktion. */
const dist = (a: Vec2, b: Vec2) => Math.hypot(b.x - a.x, b.y - a.y);
/** Minimale gerade Treppe nach rechts (+X). */
const geradeStair: Stair = {
id: "g1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3.0,
width: 1.2,
stepCount: 16,
};
/** L-Treppe: erster Lauf +X, zweiter Lauf +Y (turn = +1). */
const lStair: Stair = {
id: "l1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "L",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 2.0,
run2Length: 2.0,
turn: 1,
width: 1.2,
stepCount: 14,
};
/** Wendeltreppe: 270° im Gegenuhrzeigersinn, Radius 1.5 m. */
const wendelStair: Stair = {
id: "w1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "spiral",
start: { x: 1.5, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3.0, // bei Wendel: Pflichtfeld, inhaltlich nicht genutzt
center: { x: 0, y: 0 },
radius: 1.5,
sweep: 270,
width: 1.2,
stepCount: 12,
};
describe("stairOutline — gerade Treppe", () => {
const totalRise = 3.0;
const result = stairOutline(geradeStair, totalRise);
it("liefert polygon, keine spiral", () => {
expect(result.polygon).not.toBeNull();
expect(result.spiral).toBeNull();
});
it("polygon hat genau 4 Ecken", () => {
expect(result.polygon!.length).toBe(4);
});
it("Lauflaenge entspricht runLength", () => {
// Start-Kante (x=0) vs End-Kante (x=3): Abstand der gleichseitigen Kanten = 3.0 m.
const pts = result.polygon!;
// pts[0] und pts[1] liegen bei x≈0, pts[2] und pts[3] bei x≈runLength.
const startX = (pts[0].x + pts[1].x) / 2;
const endX = (pts[2].x + pts[3].x) / 2;
expect(Math.abs(endX - startX)).toBeCloseTo(geradeStair.runLength, 5);
});
it("Breite entspricht stair.width", () => {
const pts = result.polygon!;
// Seitenkante 0→3 und 1→2 haben die Breite = stair.width.
const w = dist(pts[0], pts[1]);
expect(w).toBeCloseTo(geradeStair.width, 5);
});
});
describe("stairOutline — L-Treppe", () => {
const totalRise = 3.0;
const result = stairOutline(lStair, totalRise);
it("liefert polygon, keine spiral", () => {
expect(result.polygon).not.toBeNull();
expect(result.spiral).toBeNull();
});
it("polygon hat 6 Ecken (L-Form)", () => {
// 4 Aussen-Eck + 2 Innen-Eck via Linien-Schnittpunkten = 6 Punkte.
expect(result.polygon!.length).toBe(6);
});
it("keine NaN-Koordinaten", () => {
for (const p of result.polygon!) {
expect(isFinite(p.x)).toBe(true);
expect(isFinite(p.y)).toBe(true);
}
});
});
describe("stairOutline — Wendeltreppe", () => {
const totalRise = 3.0;
const result = stairOutline(wendelStair, totalRise);
it("liefert spiral, kein polygon", () => {
expect(result.spiral).not.toBeNull();
expect(result.polygon).toBeNull();
});
it("zwei radiale Schliesslinien (Start + Ende)", () => {
expect(result.spiral!.lines.length).toBe(2);
});
it("zwei Kreisboegen (Innen + Aussen)", () => {
expect(result.spiral!.arcs.length).toBe(2);
});
it("Innenradius < Aussenradius", () => {
const [arcIn, arcOut] = result.spiral!.arcs;
expect(arcIn.r).toBeLessThan(arcOut.r);
});
it("Startlinie Innen-Punkt liegt auf Innenkreis", () => {
const arcIn = result.spiral!.arcs[0];
const [lineStart] = result.spiral!.lines;
const [innerPt] = lineStart;
const d = dist({ x: arcIn.cx, y: arcIn.cy }, innerPt);
expect(d).toBeCloseTo(arcIn.r, 4);
});
});
describe("stairOutline — Referenz links/mitte/rechts (Item 4)", () => {
const totalRise = 3.0;
// Gerade Treppe entlang +X, Breite 1.2 m. Perp (linke Normale) = (0, +1).
const base: Stair = { ...geradeStair, referenz: "mitte" };
const mitte = stairOutline(base, totalRise).polygon!;
const links = stairOutline({ ...base, referenz: "links" }, totalRise).polygon!;
const rechts = stairOutline({ ...base, referenz: "rechts" }, totalRise).polygon!;
/** Mittlere Y-Höhe eines Rechteck-Polygons. */
const midY = (pts: Vec2[]) => pts.reduce((s, p) => s + p.y, 0) / pts.length;
it("Breite bleibt bei allen Referenzen gleich", () => {
for (const poly of [mitte, links, rechts]) {
expect(dist(poly[0], poly[1])).toBeCloseTo(geradeStair.width, 5);
}
});
it("referenz=links: Treppe liegt RECHTS der Achse (n) → Mitte um width/2 nach Y", () => {
// Achse (start/dir) = linke Kante; die Fläche versetzt sich in n = (0,1).
expect(midY(links) - midY(mitte)).toBeCloseTo(-geradeStair.width / 2, 5);
});
it("referenz=rechts: Treppe liegt LINKS der Achse (+n) → Mitte um width/2 nach +Y", () => {
expect(midY(rechts) - midY(mitte)).toBeCloseTo(+geradeStair.width / 2, 5);
});
it("bei referenz=links liegt die Achse (y=0) auf der linken Kante der Treppe", () => {
// Linke Kante = max-Y-Kante der Fläche (n = +Y). Sie soll bei y=0 liegen.
const maxY = Math.max(...links.map((p) => p.y));
expect(maxY).toBeCloseTo(0, 5);
});
it("mitte entspricht dem Default (kein referenz-Feld gesetzt)", () => {
const noRef = stairOutline({ ...geradeStair }, totalRise).polygon!;
for (let i = 0; i < noRef.length; i++) {
expect(noRef[i].x).toBeCloseTo(mitte[i].x, 6);
expect(noRef[i].y).toBeCloseTo(mitte[i].y, 6);
}
});
});
describe("stairGeometry — Lauflinie bleibt visuell zentriert (Item 4)", () => {
const totalRise = 3.0;
const links = stairGeometry({ ...geradeStair, referenz: "links" }, totalRise);
const mitte = stairGeometry({ ...geradeStair, referenz: "mitte" }, totalRise);
it("Lauflinie liegt auf der Treppen-Mitte, nicht auf der Referenzkante (y=0)", () => {
// Bei referenz=links liegt die Mitte bei y = width/2 = 0.6.
for (const p of links.runLine) {
expect(p.y).toBeCloseTo(-geradeStair.width / 2, 5);
}
// Bei mitte liegt die Lauflinie auf y=0 (Achse = Mitte).
for (const p of mitte.runLine) {
expect(p.y).toBeCloseTo(0, 5);
}
});
it("Tritte sind bei referenz=links um width/2 versetzt", () => {
const dy = links.treads[0].pts[0].y - mitte.treads[0].pts[0].y;
expect(dy).toBeCloseTo(-geradeStair.width / 2, 5);
});
});
+5 -49
View File
@@ -116,23 +116,6 @@ export const de = {
"tool.line": "Linie",
"tool.polyline": "Polylinie",
"tool.rect": "Rechteck",
"tool.circle": "Kreis",
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
"tool.arc": "Bogen",
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
// Ribbon-Oberleiste (Tabs + Gruppen)
"ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D",
"ribbon.tab.bim": "BIM",
"ribbon.tab.views": "Ansichten",
"ribbon.tab.custom": "Eigene",
"ribbon.group.draw": "Zeichnen",
"ribbon.group.modify": "Ändern",
"ribbon.group.components": "Bauteile",
"ribbon.group.custom": "Eigene Leiste",
"ribbon.custom.add": " Hinzufügen",
"ribbon.custom.empty": "Elemente über Hinzufügen wählen",
"ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
"tool.window.hint": "Fenster: Wand wählen, dann Position setzen",
"tool.door.hint": "Tür: Wand wählen, dann Position setzen",
@@ -225,7 +208,6 @@ export const de = {
// Attribute-Palette (AttributesPanel)
"attr.title": "Attribute",
"attr.empty": "Keine Auswahl",
"attr.newComponent": "Neues Bauteil",
"attr.stroke": "Stift",
"attr.color": "Farbe",
"attr.weight": "Strichstärke (mm)",
@@ -273,7 +255,7 @@ export const de = {
"objinfo.wall.refLine.center": "Mitte",
"objinfo.wall.refLine.right": "Innen (rechts)",
"objinfo.wall.buildup": "Aufbau",
"objinfo.wall.single": "Solid",
"objinfo.wall.single": "Einschichtig",
"objinfo.wall.multi": "Mehrschichtig",
"objinfo.wall.thickness": "Dicke",
"objinfo.wall.preset": "Wandaufbau",
@@ -282,8 +264,8 @@ export const de = {
"objinfo.wall.linkAbove": "An oberes Geschoss binden",
"objinfo.wall.bottom": "Unterkante (UK)",
"objinfo.wall.top": "Oberkante (OK)",
"objinfo.wall.anchorFloor": "Gebunden",
"objinfo.wall.anchorCustom": "Eigene",
"objinfo.wall.anchorFloor": "An Geschoss gebunden",
"objinfo.wall.anchorCustom": "Eigene Höhe",
"objinfo.wall.height": "Höhe",
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
"objinfo.ceiling.section": "Decke",
@@ -295,8 +277,8 @@ export const de = {
"objinfo.ceiling.bottom": "Unterkante (UK)",
"objinfo.ceiling.height": "Dicke (OKUK)",
"objinfo.ceiling.area": "Fläche",
"objinfo.ceiling.anchorFloor": "Gebunden",
"objinfo.ceiling.anchorCustom": "Eigene",
"objinfo.ceiling.anchorFloor": "An Geschoss gebunden",
"objinfo.ceiling.anchorCustom": "Eigene Höhe",
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
"objinfo.opening.section": "Öffnung",
"objinfo.opening.kind": "Art",
@@ -319,16 +301,6 @@ export const de = {
"objinfo.opening.dir.out": "Außen",
"objinfo.opening.bottom": "Unterkante (UK)",
"objinfo.opening.top": "Oberkante (OK)",
// Neue Öffnungs-Felder
"objinfo.opening.wingCount": "Flügel",
"objinfo.opening.doorType": "Typ",
"objinfo.opening.doorType.normal": "Normal",
"objinfo.opening.doorType.wandoeffnung": "Wandöffnung",
"objinfo.opening.lintelLines": "Sturzlinien",
"objinfo.opening.lintelLines.keine": "Keine",
"objinfo.opening.lintelLines.innen": "Innen",
"objinfo.opening.lintelLines.aussen": "Außen",
"objinfo.opening.lintelLines.beide": "Beide",
// ── Treppen-Attribute (Object-Info-Panel) ───────────────────────────────
"objinfo.stair.section": "Treppe",
"objinfo.stair.shape": "Grundform",
@@ -346,11 +318,6 @@ export const de = {
"objinfo.stair.refFloor": "Referenzgeschoss",
"objinfo.stair.bottom": "Unterkante (UK)",
"objinfo.stair.top": "Oberkante (OK)",
// Neues Treppen-Feld
"objinfo.stair.referenz": "Referenz",
"objinfo.stair.referenz.links": "Links",
"objinfo.stair.referenz.mitte": "Mitte",
"objinfo.stair.referenz.rechts": "Rechts",
// ── Raum-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.room.section": "Raum",
"objinfo.room.name": "Name",
@@ -702,9 +669,6 @@ export const de = {
"cmd.stair.updown": "Auf/Ab",
"cmd.stair.stepsField": "Stufen",
"cmd.stair.riseField": "Steighöhe",
"cmd.stair.referenz": "Referenz",
"cmd.stair.treadMode": "Trittmass",
"cmd.stair.auftrittField": "Auftritt",
"cmd.line.label": "Linie",
"cmd.line.start": "Startpunkt der Linie:",
"cmd.line.end": "Endpunkt der Linie:",
@@ -725,10 +689,6 @@ export const de = {
"cmd.circle.label": "Kreis",
"cmd.circle.center": "Mittelpunkt:",
"cmd.circle.radius": "Radius:",
"cmd.arc.label": "Bogen",
"cmd.arc.center": "Mittelpunkt:",
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
"cmd.arc.end": "Endpunkt (Endwinkel):",
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
"cmd.move.label": "Verschieben",
"cmd.move.base": "Basispunkt:",
@@ -801,7 +761,6 @@ export const de = {
"import.file": "Datei",
"import.summary.heading": "Inhalt",
"import.summary.contours": "{n} Konturen",
"import.summary.texts": "{n} Texte",
"import.summary.meshes": "{n} Meshes",
"import.summary.layers": "Layer: {layers}",
"import.summary.noLayers": "keine Layer-Namen",
@@ -813,9 +772,6 @@ export const de = {
"import.layers.heading": "Ebenen-Handling (DXF-Layer)",
"import.layers.asCategories": "DXF-Layer als Kategorien übernehmen",
"import.layers.toActive": "Alles auf die aktive Kategorie ({code})",
"import.placement.heading": "Platzierung",
"import.placement.origin": "An DXF-Koordinaten (relativ zum Nullpunkt)",
"import.placement.viewCenter": "In die Mitte der aktuellen Ansicht",
"import.mesh.heading": "Meshes",
"import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren",
"import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.",
+5 -47
View File
@@ -115,23 +115,6 @@ export const en: Record<TranslationKey, string> = {
"tool.line": "Line",
"tool.polyline": "Polyline",
"tool.rect": "Rectangle",
"tool.circle": "Circle",
"tool.circle.hint": "Circle: set center, then radius",
"tool.arc": "Arc",
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
// Ribbon top bar (tabs + groups)
"ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D",
"ribbon.tab.bim": "BIM",
"ribbon.tab.views": "Views",
"ribbon.tab.custom": "Custom",
"ribbon.group.draw": "Draw",
"ribbon.group.modify": "Modify",
"ribbon.group.components": "Components",
"ribbon.group.custom": "Custom bar",
"ribbon.custom.add": " Add",
"ribbon.custom.empty": "Pick elements via “+ Add”",
"ribbon.empty": "No tools in this tab yet",
"tool.select.hint": "Select — click/drag elements",
"tool.window.hint": "Window: pick a wall, then set the position",
"tool.door.hint": "Door: pick a wall, then set the position",
@@ -224,7 +207,6 @@ export const en: Record<TranslationKey, string> = {
// Attribute-Palette (AttributesPanel)
"attr.title": "Attributes",
"attr.empty": "No selection",
"attr.newComponent": "New component",
"attr.stroke": "Pen",
"attr.color": "Color",
"attr.weight": "Line weight (mm)",
@@ -272,7 +254,7 @@ export const en: Record<TranslationKey, string> = {
"objinfo.wall.refLine.center": "Center",
"objinfo.wall.refLine.right": "Interior (right)",
"objinfo.wall.buildup": "Build-up",
"objinfo.wall.single": "Solid",
"objinfo.wall.single": "Single layer",
"objinfo.wall.multi": "Multi layer",
"objinfo.wall.thickness": "Thickness",
"objinfo.wall.preset": "Wall style",
@@ -281,8 +263,8 @@ export const en: Record<TranslationKey, string> = {
"objinfo.wall.linkAbove": "Bind to floor above",
"objinfo.wall.bottom": "Bottom (UK)",
"objinfo.wall.top": "Top (OK)",
"objinfo.wall.anchorFloor": "Bound",
"objinfo.wall.anchorCustom": "Custom",
"objinfo.wall.anchorFloor": "Bound to floor",
"objinfo.wall.anchorCustom": "Custom height",
"objinfo.wall.height": "Height",
"objinfo.wall.floorAboveNone": "No floor above",
// ── Ceiling attributes (object info panel) ───────────────────────────────
@@ -295,8 +277,8 @@ export const en: Record<TranslationKey, string> = {
"objinfo.ceiling.bottom": "Bottom (UK)",
"objinfo.ceiling.height": "Thickness (OKUK)",
"objinfo.ceiling.area": "Area",
"objinfo.ceiling.anchorFloor": "Bound",
"objinfo.ceiling.anchorCustom": "Custom",
"objinfo.ceiling.anchorFloor": "Bound to floor",
"objinfo.ceiling.anchorCustom": "Custom height",
// ── Opening attributes (object info panel) ──────────────────────────────
"objinfo.opening.section": "Opening",
"objinfo.opening.kind": "Type",
@@ -319,15 +301,6 @@ export const en: Record<TranslationKey, string> = {
"objinfo.opening.dir.out": "Out",
"objinfo.opening.bottom": "Bottom (UK)",
"objinfo.opening.top": "Top (OK)",
"objinfo.opening.wingCount": "Wings",
"objinfo.opening.doorType": "Type",
"objinfo.opening.doorType.normal": "Normal",
"objinfo.opening.doorType.wandoeffnung": "Wall opening",
"objinfo.opening.lintelLines": "Lintel lines",
"objinfo.opening.lintelLines.keine": "None",
"objinfo.opening.lintelLines.innen": "Inside",
"objinfo.opening.lintelLines.aussen": "Outside",
"objinfo.opening.lintelLines.beide": "Both",
"objinfo.stair.section": "Stair",
"objinfo.stair.shape": "Shape",
"objinfo.stair.shape.straight": "Straight",
@@ -344,10 +317,6 @@ export const en: Record<TranslationKey, string> = {
"objinfo.stair.refFloor": "Reference floor",
"objinfo.stair.bottom": "Bottom (UK)",
"objinfo.stair.top": "Top (OK)",
"objinfo.stair.referenz": "Reference",
"objinfo.stair.referenz.links": "Left",
"objinfo.stair.referenz.mitte": "Centre",
"objinfo.stair.referenz.rechts": "Right",
// ── Room attributes (object info panel) ──────────────────────────────────
"objinfo.room.section": "Room",
"objinfo.room.name": "Name",
@@ -692,9 +661,6 @@ export const en: Record<TranslationKey, string> = {
"cmd.stair.updown": "Up/Down",
"cmd.stair.stepsField": "Steps",
"cmd.stair.riseField": "Rise",
"cmd.stair.referenz": "Reference",
"cmd.stair.treadMode": "Tread",
"cmd.stair.auftrittField": "Tread depth",
"cmd.line.label": "Line",
"cmd.line.start": "Start of line:",
"cmd.line.end": "End of line:",
@@ -715,10 +681,6 @@ export const en: Record<TranslationKey, string> = {
"cmd.circle.label": "Circle",
"cmd.circle.center": "Center:",
"cmd.circle.radius": "Radius:",
"cmd.arc.label": "Arc",
"cmd.arc.center": "Center:",
"cmd.arc.start": "Start point (radius + start angle):",
"cmd.arc.end": "End point (end angle):",
"cmd.move.label": "Move",
"cmd.move.base": "Base point:",
"cmd.move.target": "Target point:",
@@ -790,7 +752,6 @@ export const en: Record<TranslationKey, string> = {
"import.file": "File",
"import.summary.heading": "Contents",
"import.summary.contours": "{n} contours",
"import.summary.texts": "{n} texts",
"import.summary.meshes": "{n} meshes",
"import.summary.layers": "Layers: {layers}",
"import.summary.noLayers": "no layer names",
@@ -802,9 +763,6 @@ export const en: Record<TranslationKey, string> = {
"import.layers.heading": "Layer handling (DXF layers)",
"import.layers.asCategories": "Keep DXF layers as categories",
"import.layers.toActive": "Everything onto the active category ({code})",
"import.placement.heading": "Placement",
"import.placement.origin": "At DXF coordinates (relative to origin)",
"import.placement.viewCenter": "Centered in the current view",
"import.mesh.heading": "Meshes",
"import.mesh.asContext": "Import meshes as context geometry (3D)",
"import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.",
-539
View File
@@ -1,539 +0,0 @@
// Tests für den DXF-Import der Kurven-Entities ARC / CIRCLE / ELLIPSE:
// belegt Tessellierung, Winkeleinheit (Radiant), Schließung und Z-Höhe.
import { describe, it, expect } from "vitest";
import { parseDxf } from "./dxfParser";
import { contoursToDrawings, textsToDrawings } from "./dxfToDrawings";
import type { Contour, ContourSet, Vec2 } from "../model/types";
/** Minimales DXF aus Gruppencode/Wert-Paaren; nur eine ENTITIES-Sektion. */
function dxf(...entities: string[][]): string {
const lines = ["0", "SECTION", "2", "ENTITIES"];
for (const pairs of entities) lines.push(...pairs);
lines.push("0", "ENDSEC", "0", "EOF");
return lines.join("\n");
}
/** Ein CIRCLE-Entity: Zentrum (cx,cy,cz), Radius r. */
function circle(cx: number, cy: number, cz: number, r: number): string[] {
return ["0", "CIRCLE", "8", "0", "10", `${cx}`, "20", `${cy}`, "30", `${cz}`, "40", `${r}`];
}
/** Ein ARC-Entity: Zentrum, Radius, Start/End in GRAD (DXF-Konvention). */
function arc(cx: number, cy: number, r: number, startDeg: number, endDeg: number): string[] {
return [
"0", "ARC", "8", "0",
"10", `${cx}`, "20", `${cy}`, "30", "0",
"40", `${r}`, "50", `${startDeg}`, "51", `${endDeg}`,
];
}
/** Ein ELLIPSE-Entity: Zentrum, Hauptachsen-Endpunkt (rel.), Verhältnis, Start/End (Radiant). */
function ellipse(
cx: number, cy: number, majX: number, majY: number, ratio: number, start: number, end: number,
): string[] {
return [
"0", "ELLIPSE", "8", "0",
"10", `${cx}`, "20", `${cy}`, "30", "0",
"11", `${majX}`, "21", `${majY}`, "31", "0",
"40", `${ratio}`, "41", `${start}`, "42", `${end}`,
];
}
const dist = (p: Vec2, cx: number, cy: number) => Math.hypot(p.x - cx, p.y - cy);
/** Einzige Kontur des Imports (Test-Bequemlichkeit). */
function onlyContour(text: string): Contour {
const res = parseDxf(text);
expect(res.contours).toHaveLength(1);
expect(res.contours[0].contours).toHaveLength(1);
return res.contours[0].contours[0];
}
describe("parseDxf — CIRCLE", () => {
it("erzeugt einen geschlossenen, tessellierten Ring auf konstantem Radius", () => {
const c = onlyContour(dxf(circle(10, 20, 5, 4)));
expect(c.closed).toBe(true);
expect(c.z).toBe(5);
// Voller Kreis ohne Schluss-Duplikat: 2π / (π/32) = 64 Segmente → 64 Punkte.
expect(c.pts).toHaveLength(64);
for (const p of c.pts) expect(dist(p, 10, 20)).toBeCloseTo(4, 9);
// Erster Punkt bei Winkel 0: (cx+r, cy).
expect(c.pts[0].x).toBeCloseTo(14, 9);
expect(c.pts[0].y).toBeCloseTo(20, 9);
// Kein Duplikat des Startpunkts am Ende.
expect(dist(c.pts[c.pts.length - 1], 14, 20)).toBeGreaterThan(0.01);
});
});
describe("parseDxf — ARC", () => {
it("tesselliert einen Viertelbogen 0°→90° CCW (offen)", () => {
const c = onlyContour(dxf(arc(0, 0, 10, 0, 90)));
expect(c.closed).toBe(false);
// Spanne π/2 → 16 Segmente → 17 Punkte.
expect(c.pts).toHaveLength(17);
expect(c.pts[0].x).toBeCloseTo(10, 9);
expect(c.pts[0].y).toBeCloseTo(0, 9);
const end = c.pts[c.pts.length - 1];
expect(end.x).toBeCloseTo(0, 9);
expect(end.y).toBeCloseTo(10, 9);
for (const p of c.pts) expect(dist(p, 0, 0)).toBeCloseTo(10, 9);
});
it("ergänzt eine umlaufende Spanne (270°→90°) um 2π statt negativ", () => {
const c = onlyContour(dxf(arc(0, 0, 5, 270, 90)));
// Spanne 180° = π → 32 Segmente → 33 Punkte, CCW von unten (y) nach oben (+y).
expect(c.pts).toHaveLength(33);
expect(c.pts[0].y).toBeCloseTo(-5, 9);
expect(c.pts[c.pts.length - 1].y).toBeCloseTo(5, 9);
// Mittelpunkt der Spanne (bei 0°) liegt bei (+r, 0), nicht (r, 0).
expect(c.pts[16].x).toBeCloseTo(5, 6);
});
});
describe("parseDxf — ELLIPSE", () => {
it("tesselliert einen vollen Umlauf (geschlossen) mit korrekten Halbachsen", () => {
const c = onlyContour(ellipse2Dxf());
expect(c.closed).toBe(true);
// Voller Umlauf → Schluss-Duplikat weggelassen.
expect(dist(c.pts[c.pts.length - 1], c.pts[0].x, c.pts[0].y)).toBeGreaterThan(0.01);
// Hauptachse 10 entlang x, Nebenachse 5 entlang y (ratio 0.5).
const maxX = Math.max(...c.pts.map((p) => p.x));
const maxY = Math.max(...c.pts.map((p) => p.y));
expect(maxX).toBeCloseTo(10, 6);
expect(maxY).toBeCloseTo(5, 6);
// Parameter t=0 → Center + Hauptachse = (10, 0).
expect(c.pts[0].x).toBeCloseTo(10, 9);
expect(c.pts[0].y).toBeCloseTo(0, 9);
});
});
/** Volle Ellipse: Center (0,0), Hauptachse (10,0), ratio 0.5, 0..2π. */
function ellipse2Dxf(): string {
return dxf(ellipse(0, 0, 10, 0, 0.5, 0, Math.PI * 2));
}
describe("parseDxf — gemischt", () => {
it("liest mehrere Kurven-Entities in EINEN Konturensatz", () => {
const res = parseDxf(dxf(circle(0, 0, 0, 1), arc(0, 0, 2, 0, 90)));
expect(res.contours).toHaveLength(1);
expect(res.contours[0].contours).toHaveLength(2);
});
});
// ── SPLINE ────────────────────────────────────────────────────────────────────
/** LINE-Entity (10/20/30 Start, 11/21/31 Ende). */
function line(x1: number, y1: number, x2: number, y2: number): string[] {
return ["0", "LINE", "8", "0", "10", `${x1}`, "20", `${y1}`, "30", "0", "11", `${x2}`, "21", `${y2}`, "31", "0"];
}
/** SPLINE mit Kontrollpunkten + Knoten (71 Grad, 40 Knoten, 10/20/30 CPs). */
function spline(degree: number, knots: number[], cps: Array<[number, number]>): string[] {
const g = ["0", "SPLINE", "8", "0", "71", `${degree}`];
for (const k of knots) g.push("40", `${k}`);
for (const [x, y] of cps) g.push("10", `${x}`, "20", `${y}`, "30", "0");
return g;
}
/** SPLINE nur mit Stützpunkten (11/21/31 fitPoints), ohne gültige Knoten. */
function splineFit(fps: Array<[number, number]>): string[] {
const g = ["0", "SPLINE", "8", "0", "71", "3"];
for (const [x, y] of fps) g.push("11", `${x}`, "21", `${y}`, "31", "0");
return g;
}
describe("parseDxf — SPLINE", () => {
it("Grad-1-Spline zeichnet exakt das Kontrollpolygon nach", () => {
// Clamped-Knoten für 2 CPs, Grad 1: |U| = 2+1+1 = 4, Domain [0,1].
const c = onlyContour(dxf(spline(1, [0, 0, 1, 1], [[0, 0], [10, 0]])));
expect(c.closed).toBe(false);
expect(c.pts[0].x).toBeCloseTo(0, 9);
expect(c.pts[0].y).toBeCloseTo(0, 9);
const last = c.pts[c.pts.length - 1];
expect(last.x).toBeCloseTo(10, 9);
expect(last.y).toBeCloseTo(0, 9);
// Linear: alle Punkte auf y=0, x monoton steigend.
for (let i = 1; i < c.pts.length; i++) {
expect(c.pts[i].y).toBeCloseTo(0, 9);
expect(c.pts[i].x).toBeGreaterThanOrEqual(c.pts[i - 1].x - 1e-9);
}
});
it("Grad-2-Spline bleibt in der konvexen Hülle und endet auf den Rand-CPs", () => {
// 3 CPs, Grad 2, clamped: |U| = 3+2+1 = 6 → [0,0,0,1,1,1], Domain [0,1].
const c = onlyContour(
dxf(spline(2, [0, 0, 0, 1, 1, 1], [[0, 0], [5, 10], [10, 0]])),
);
// Endpunkte = erster/letzter Kontrollpunkt (clamped).
expect(c.pts[0].x).toBeCloseTo(0, 9);
expect(c.pts[0].y).toBeCloseTo(0, 9);
const last = c.pts[c.pts.length - 1];
expect(last.x).toBeCloseTo(10, 9);
expect(last.y).toBeCloseTo(0, 9);
// Konvexe Hülle: y nie über 10, x in [0,10]; Scheitel bei x≈5 unter 10.
for (const p of c.pts) {
expect(p.y).toBeLessThanOrEqual(10 + 1e-9);
expect(p.y).toBeGreaterThanOrEqual(-1e-9);
expect(p.x).toBeGreaterThanOrEqual(-1e-9);
expect(p.x).toBeLessThanOrEqual(10 + 1e-9);
}
// Mittelpunkt (t=0.5) einer quadratischen Bézier: 0.25·P0+0.5·P1+0.25·P2 = (5,5).
const mid = c.pts[Math.floor(c.pts.length / 2)];
expect(mid.x).toBeCloseTo(5, 6);
expect(mid.y).toBeCloseTo(5, 6);
});
it("fällt ohne gültige Knoten auf die Stützpunkte zurück", () => {
const c = onlyContour(dxf(splineFit([[0, 0], [1, 2], [3, 4]])));
expect(c.pts).toHaveLength(3);
expect(c.pts[1].x).toBeCloseTo(1, 9);
expect(c.pts[1].y).toBeCloseTo(2, 9);
});
});
// ── INSERT (Block-Referenzen) ─────────────────────────────────────────────────
/** BLOCK-Definition: Name, Basispunkt, enthaltene Entities. */
function block(name: string, bx: number, by: number, ...ents: string[][]): string[] {
const g = ["0", "BLOCK", "8", "0", "2", name, "10", `${bx}`, "20", `${by}`, "30", "0", "70", "0"];
for (const e of ents) g.push(...e);
g.push("0", "ENDBLK");
return g;
}
interface InsOpts {
rot?: number; sx?: number; sy?: number; nc?: number; nr?: number; dc?: number; dr?: number;
}
/** INSERT-Referenz auf einen Block. */
function insert(name: string, x: number, y: number, o: InsOpts = {}): string[] {
const g = ["0", "INSERT", "2", name, "10", `${x}`, "20", `${y}`, "30", "0"];
if (o.sx !== undefined) g.push("41", `${o.sx}`);
if (o.sy !== undefined) g.push("42", `${o.sy}`);
if (o.rot !== undefined) g.push("50", `${o.rot}`);
if (o.nc !== undefined) g.push("70", `${o.nc}`);
if (o.nr !== undefined) g.push("71", `${o.nr}`);
if (o.dc !== undefined) g.push("44", `${o.dc}`);
if (o.dr !== undefined) g.push("45", `${o.dr}`);
return g;
}
/** DXF mit BLOCKS- und ENTITIES-Sektion. */
function dxfFull(blocks: string[][], entities: string[][]): string {
const lines: string[] = [];
if (blocks.length) {
lines.push("0", "SECTION", "2", "BLOCKS");
for (const b of blocks) lines.push(...b);
lines.push("0", "ENDSEC");
}
lines.push("0", "SECTION", "2", "ENTITIES");
for (const e of entities) lines.push(...e);
lines.push("0", "ENDSEC", "0", "EOF");
return lines.join("\n");
}
describe("parseDxf — INSERT", () => {
it("verschiebt eine Block-Linie an den Einfügepunkt", () => {
const c = onlyContour(
dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 5, 5)]),
);
expect(c.pts[0].x).toBeCloseTo(5, 9);
expect(c.pts[0].y).toBeCloseTo(5, 9);
expect(c.pts[1].x).toBeCloseTo(6, 9);
expect(c.pts[1].y).toBeCloseTo(5, 9);
});
it("rotiert um 90° und skaliert", () => {
const rot = onlyContour(
dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 0, 0, { rot: 90 })]),
);
// (1,0) um 90° CCW → (0,1).
expect(rot.pts[1].x).toBeCloseTo(0, 6);
expect(rot.pts[1].y).toBeCloseTo(1, 6);
const scl = onlyContour(
dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 0, 0, { sx: 2, sy: 3 })]),
);
expect(scl.pts[1].x).toBeCloseTo(2, 9);
expect(scl.pts[1].y).toBeCloseTo(0, 9);
});
it("expandiert ein 2×1-Array zu zwei Konturen", () => {
const res = parseDxf(
dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 0, 0, { nc: 2, dc: 10 })]),
);
const cs = res.contours[0].contours;
expect(cs).toHaveLength(2);
// Zweite Spalte um columnSpacing 10 versetzt.
const xs = cs.map((c) => c.pts[0].x).sort((a, b) => a - b);
expect(xs[0]).toBeCloseTo(0, 9);
expect(xs[1]).toBeCloseTo(10, 9);
});
it("löst verschachtelte Blockreferenzen auf (Transform-Komposition)", () => {
const c = onlyContour(
dxfFull(
[
block("inner", 0, 0, line(0, 0, 1, 0)),
block("outer", 0, 0, insert("inner", 2, 0)),
],
[insert("outer", 0, 3)],
),
);
// inner (0,0)-(1,0) → +2,0) durch outer → +0,3) durch top = (2,3)-(3,3).
expect(c.pts[0].x).toBeCloseTo(2, 9);
expect(c.pts[0].y).toBeCloseTo(3, 9);
expect(c.pts[1].x).toBeCloseTo(3, 9);
expect(c.pts[1].y).toBeCloseTo(3, 9);
});
});
// ── HATCH (gefüllte Flächen) ──────────────────────────────────────────────────
/** HATCH mit Polyline-Randpfad (Flag 3 = external+polyline). */
function hatchPoly(verts: Array<[number, number]>): string[] {
const g = ["0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "1", "92", "3", "72", "0", "73", "1", "93", `${verts.length}`];
for (const [x, y] of verts) g.push("10", `${x}`, "20", `${y}`);
g.push("97", "0");
return g;
}
/** HATCH mit Kanten-Randpfad aus Linienkanten (Flag 1 = external, kein Polyline-Bit). */
function hatchLineEdges(verts: Array<[number, number]>): string[] {
const g = ["0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "1", "92", "1", "93", `${verts.length}`];
for (let k = 0; k < verts.length; k++) {
const a = verts[k];
const b = verts[(k + 1) % verts.length];
g.push("72", "1", "10", `${a[0]}`, "20", `${a[1]}`, "11", `${b[0]}`, "21", `${b[1]}`);
}
g.push("97", "0");
return g;
}
/** HATCH mit einer Bogenkante (Kantentyp 2), Winkel in Grad. */
function hatchArc(cx: number, cy: number, r: number, s: number, e: number): string[] {
return [
"0", "HATCH", "8", "0", "2", "SOLID", "70", "1",
"91", "1", "92", "1", "93", "1",
"72", "2", "10", `${cx}`, "20", `${cy}`, "40", `${r}`, "50", `${s}`, "51", `${e}`, "73", "1",
"97", "0",
];
}
describe("parseDxf — HATCH", () => {
it("Polyline-Rand → geschlossene gefüllte Kontur", () => {
const c = onlyContour(dxf(hatchPoly([[0, 0], [10, 0], [10, 10], [0, 10]])));
expect(c.closed).toBe(true);
expect(c.filled).toBe(true);
expect(c.pts).toHaveLength(4);
expect(c.pts[1].x).toBeCloseTo(10, 9);
expect(c.pts[1].y).toBeCloseTo(0, 9);
expect(c.pts[2].x).toBeCloseTo(10, 9);
expect(c.pts[2].y).toBeCloseTo(10, 9);
});
it("Linienkanten-Rand → geschlossene gefüllte Kontur (Eckpunkte)", () => {
const c = onlyContour(dxf(hatchLineEdges([[0, 0], [4, 0], [4, 4], [0, 4]])));
expect(c.closed).toBe(true);
expect(c.filled).toBe(true);
expect(c.pts).toHaveLength(4);
const xs = c.pts.map((p) => p.x).sort((a, b) => a - b);
expect(xs[0]).toBeCloseTo(0, 9);
expect(xs[3]).toBeCloseTo(4, 9);
});
it("Bogenkante → tessellierter Halbkreis auf Radius", () => {
const c = onlyContour(dxf(hatchArc(0, 0, 5, 0, 180)));
expect(c.filled).toBe(true);
for (const p of c.pts) expect(Math.hypot(p.x, p.y)).toBeCloseTo(5, 6);
expect(c.pts[0].x).toBeCloseTo(5, 6);
expect(c.pts[0].y).toBeCloseTo(0, 6);
const last = c.pts[c.pts.length - 1];
expect(last.x).toBeCloseTo(-5, 6);
expect(last.y).toBeCloseTo(0, 6);
});
it("Ellipsenkante → tessellierter Halbumlauf mit korrekten Halbachsen", () => {
// Center (0,0), Hauptachse (10,0), Verhältnis 0.5, 0°→180° ccw.
const g = [
"0", "HATCH", "8", "0", "2", "SOLID", "70", "1",
"91", "1", "92", "1", "93", "1",
"72", "3", "10", "0", "20", "0", "11", "10", "21", "0", "40", "0.5", "50", "0", "51", "180", "73", "1",
"97", "0",
];
const c = onlyContour(dxf(g));
expect(c.filled).toBe(true);
// t=0 → Center + Hauptachse = (10,0).
expect(c.pts[0].x).toBeCloseTo(10, 6);
expect(c.pts[0].y).toBeCloseTo(0, 6);
// Scheitel bei t=90° → Nebenachse (0,5).
const maxY = Math.max(...c.pts.map((p) => p.y));
expect(maxY).toBeCloseTo(5, 6);
const last = c.pts[c.pts.length - 1];
expect(last.x).toBeCloseTo(-10, 6);
expect(last.y).toBeCloseTo(0, 6);
});
it("Spline-Kante (Grad 1) → Kontrollpolygon nachgezeichnet", () => {
// 2 CPs (0,0)-(10,0), Grad 1, clamped-Knoten [0,0,1,1].
const g = [
"0", "HATCH", "8", "0", "2", "SOLID", "70", "1",
"91", "1", "92", "1", "93", "1",
"72", "4", "94", "1", "95", "4", "96", "2",
"40", "0", "40", "0", "40", "1", "40", "1",
"10", "0", "20", "0", "10", "10", "20", "0",
"97", "0",
];
const c = onlyContour(dxf(g));
expect(c.filled).toBe(true);
expect(c.pts[0].x).toBeCloseTo(0, 9);
expect(c.pts[0].y).toBeCloseTo(0, 9);
const last = c.pts[c.pts.length - 1];
expect(last.x).toBeCloseTo(10, 9);
expect(last.y).toBeCloseTo(0, 9);
for (const p of c.pts) expect(p.y).toBeCloseTo(0, 9);
});
it("mehrere Randpfade → mehrere Loops (Insel als eigener Ring)", () => {
// Zwei Polyline-Pfade in EINER HATCH: numPaths = 2.
const g = [
"0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "2",
"92", "3", "72", "0", "73", "1", "93", "4",
"10", "0", "20", "0", "10", "10", "20", "0", "10", "10", "20", "10", "10", "0", "20", "10",
"97", "0",
"92", "3", "72", "0", "73", "1", "93", "4",
"10", "3", "20", "3", "10", "7", "20", "3", "10", "7", "20", "7", "10", "3", "20", "7",
"97", "0",
];
const res = parseDxf(dxf(g));
expect(res.contours[0].contours).toHaveLength(2);
expect(res.contours[0].contours.every((c) => c.filled && c.closed)).toBe(true);
});
});
// ── TEXT / MTEXT ──────────────────────────────────────────────────────────────
/** TEXT-Entity: Position, Höhe, Rotation (Grad), String. */
function textEnt(x: number, y: number, h: number, rotDeg: number, s: string): string[] {
return ["0", "TEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", `${rotDeg}`, "1", s];
}
/** MTEXT-Entity: Position, Höhe, String (Rotation 0). */
function mtextEnt(x: number, y: number, h: number, s: string): string[] {
return ["0", "MTEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", "0", "1", s];
}
describe("parseDxf — TEXT/MTEXT", () => {
it("TEXT → ImportedText mit Position/Höhe/Winkel (Grad→Radiant)", () => {
const res = parseDxf(dxf(textEnt(5, 3, 0.5, 90, "Hallo")));
expect(res.texts).toHaveLength(1);
const t0 = res.texts![0];
expect(t0.at.x).toBeCloseTo(5, 9);
expect(t0.at.y).toBeCloseTo(3, 9);
expect(t0.height).toBeCloseTo(0.5, 9);
expect(t0.angle).toBeCloseTo(Math.PI / 2, 9);
expect(t0.text).toBe("Hallo");
});
it("MTEXT → Formatcodes gesäubert (\\P → Leerzeichen)", () => {
const res = parseDxf(dxf(mtextEnt(1, 2, 0.3, "Welt\\Pzeile2")));
expect(res.texts![0].text).toBe("Welt zeile2");
expect(res.texts![0].at.x).toBeCloseTo(1, 9);
expect(res.texts![0].height).toBeCloseTo(0.3, 9);
});
it("leerer Text wird übersprungen", () => {
const res = parseDxf(dxf(textEnt(0, 0, 0.2, 0, "")));
expect(res.texts ?? []).toHaveLength(0);
});
it("fehlende Höhe → positiver Default", () => {
const g = ["0", "TEXT", "8", "0", "10", "0", "20", "0", "30", "0", "1", "X"];
const res = parseDxf(dxf(g));
expect(res.texts![0].height).toBeGreaterThan(0);
});
});
describe("textsToDrawings", () => {
it("ImportedText → Drawing2D {shape:\"text\"}", () => {
const drawings = textsToDrawings(
[{ at: { x: 1, y: 2 }, text: "Hi", height: 0.4, angle: 0, layer: "L" }],
"lvl", "active", "CODE", (s) => s,
);
expect(drawings).toHaveLength(1);
const g = drawings[0].geom;
expect(g.shape).toBe("text");
if (g.shape === "text") {
expect(g.text).toBe("Hi");
expect(g.height).toBeCloseTo(0.4, 9);
expect(g.at.x).toBeCloseTo(1, 9);
}
});
});
describe("parseDxf — Kurven behalten echte Geometrie (curve)", () => {
it("CIRCLE trägt curve {kind:circle}", () => {
const c = onlyContour(dxf(circle(10, 20, 5, 4)));
expect(c.curve?.kind).toBe("circle");
expect(c.curve?.cx).toBeCloseTo(10, 9);
expect(c.curve?.cy).toBeCloseTo(20, 9);
expect(c.curve?.r).toBeCloseTo(4, 9);
});
it("ARC trägt curve {kind:arc} mit Winkeln (Radiant)", () => {
const c = onlyContour(dxf(arc(0, 0, 10, 0, 90)));
expect(c.curve?.kind).toBe("arc");
expect(c.curve?.r).toBeCloseTo(10, 9);
expect(c.curve?.a0).toBeCloseTo(0, 9);
expect(c.curve?.a1).toBeCloseTo(Math.PI / 2, 9);
});
});
describe("contoursToDrawings — Kurven → glatte Formen", () => {
it("curve circle → {shape:\"circle\"}", () => {
const set: ContourSet = {
id: "s", type: "contourSet", name: "c",
contours: [{ z: 0, closed: true, pts: [{ x: 0, y: 0 }], curve: { kind: "circle", cx: 1, cy: 2, r: 3, a0: 0, a1: Math.PI * 2 } }],
};
const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s);
expect(d.geom.shape).toBe("circle");
if (d.geom.shape === "circle") {
expect(d.geom.r).toBeCloseTo(3, 9);
expect(d.geom.center.x).toBeCloseTo(1, 9);
}
});
it("curve arc → {shape:\"arc\"}", () => {
const set: ContourSet = {
id: "s", type: "contourSet", name: "c",
contours: [{ z: 0, closed: false, pts: [{ x: 0, y: 0 }], curve: { kind: "arc", cx: 0, cy: 0, r: 5, a0: 0, a1: 1 } }],
};
const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s);
expect(d.geom.shape).toBe("arc");
if (d.geom.shape === "arc") {
expect(d.geom.r).toBeCloseTo(5, 9);
expect(d.geom.a1).toBeCloseTo(1, 9);
}
});
});
describe("contoursToDrawings — HATCH-Füllung", () => {
it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => {
const set: ContourSet = {
id: "s", type: "contourSet", name: "h",
contours: [{ z: 0, closed: true, filled: true, pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }] }],
};
const [d] = contoursToDrawings([set], "lvl", "active", "CODE", (s) => s);
expect(d.geom.shape).toBe("polyline");
expect(d.fillColor).toBeTruthy();
});
it("ungefüllte Kontur → Drawing2D ohne fillColor", () => {
const set: ContourSet = {
id: "s", type: "contourSet", name: "h",
contours: [{ z: 0, closed: true, pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }] }],
};
const [d] = contoursToDrawings([set], "lvl", "active", "CODE", (s) => s);
expect(d.fillColor).toBeUndefined();
});
});
+32 -714
View File
@@ -17,17 +17,9 @@
// • MESH → Dreiecks-Mesh (Vertices + Face-Liste).
// • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z).
// • LINE → Kontur (zwei-Punkt-Linienzug).
// • ARC → Kontur (offener Bogen, tesselliert).
// • CIRCLE → Kontur (geschlossener Kreis, tesselliert).
// • ELLIPSE → Kontur (Ellipsenbogen/-umlauf, tesselliert).
// • SPLINE → Kontur (B-Spline via De Boor; Fallback fitPoints).
// • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt).
// • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler,
// Polyline- + Linien-/Bogen-/Ellipsen-/Spline-Kanten; Fill via `Contour.filled`).
// • TEXT / MTEXT → ImportedText (Position/Höhe/Winkel) → Drawing2D `{shape:"text"}`.
import DxfParser from "dxf-parser";
import type { Contour, ContourSet, ImportedMesh, ImportedText, Vec2 } from "../model/types";
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
/**
* Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ Anzahl)
@@ -42,12 +34,10 @@ export interface ImportDiagnostics {
total: number;
}
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze + Texte. */
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
export interface DxfImportResult {
meshes: ImportedMesh[];
contours: ContourSet[];
/** Importierte TEXT/MTEXT-Elemente (optional; der DWG-Parser füllt sie nicht). */
texts?: ImportedText[];
/** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */
diagnostics?: ImportDiagnostics;
}
@@ -71,57 +61,10 @@ interface DxfEntity {
faces?: number[][];
isPolyfaceMesh?: boolean;
is3dPolygonMesh?: boolean;
// Kurven-Entities (ARC/CIRCLE/ELLIPSE). Winkel liefert dxf-parser in RADIANT
// (ARC/CIRCLE Grad→rad umgerechnet; ELLIPSE-Parameterwinkel roh in Radiant).
center?: DxfVertex;
radius?: number;
startAngle?: number;
endAngle?: number;
/** ELLIPSE: Hauptachsen-Endpunkt RELATIV zum Center. */
majorAxisEndPoint?: DxfVertex;
/** ELLIPSE: Verhältnis Neben-/Hauptachse (b/a). */
axisRatio?: number;
// SPLINE-Felder. Winkel/Parameter roh; Knoten/Grad definieren die B-Spline.
controlPoints?: DxfVertex[];
fitPoints?: DxfVertex[];
knotValues?: number[];
degreeOfSplineCurve?: number;
closed?: boolean;
// INSERT-Felder (Block-Referenz). `position` (Einfügepunkt) wird tolerant
// gelesen (das Feld ist oben als MESH-Vertexliste getippt) — daher hier NICHT
// erneut deklariert; `rotation` ist in GRAD (anders als ARC/CIRCLE).
name?: string;
xScale?: number;
yScale?: number;
rotation?: number;
columnCount?: number;
rowCount?: number;
columnSpacing?: number;
rowSpacing?: number;
// HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler).
rawCodes?: RawCode[];
// TEXT/MTEXT. `position` (MTEXT-Einfügepunkt) wird tolerant gelesen (oben als
// MESH-Vertexliste getippt). `rotation` (Grad) ist bereits oben deklariert.
startPoint?: DxfVertex; // TEXT-Einfügepunkt
textHeight?: number; // TEXT-Höhe (Code 40)
height?: number; // MTEXT-Höhe (Code 40)
text?: string; // Textinhalt (Code 1, MTEXT auch 3)
[k: string]: unknown;
}
/** Ein rohes DXF-Gruppencode/Wert-Paar (HATCH-Randpfad-Auswertung). */
interface RawCode {
code: number;
value: number | string;
}
/** Ein Block (BLOCKS-Tabelle): Basispunkt `position` + eigene Entity-Liste. */
interface DxfBlock {
name?: string;
position?: DxfVertex;
entities?: DxfEntity[];
}
interface DxfDocument {
entities?: DxfEntity[];
blocks?: Record<string, DxfBlock>;
}
let importSeq = 0;
@@ -135,45 +78,46 @@ const nextId = (prefix: string) =>
*/
export function parseDxf(text: string): DxfImportResult {
const parser = new DxfParser();
// dxf-parser bringt keinen HATCH-Handler mit (verwirft HATCH sonst stumm).
// Eigenen registrieren, der die rohen Gruppencodes einsammelt; die Auswertung
// der Randpfade läuft separat und testbar in `hatchContours`.
(parser as unknown as {
registerEntityHandler: (h: new () => unknown) => void;
}).registerEntityHandler(HatchHandler);
// parseSync wirft bei strukturell kaputtem DXF; das soll nach oben.
const doc = parser.parseSync(text) as unknown as DxfDocument;
const entities = doc?.entities ?? [];
const blocks = doc?.blocks ?? {};
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
const meshIndices: number[] = [];
const contours: Contour[] = [];
const texts: ImportedText[] = [];
for (const e of entities) {
const type = (e.type ?? "").toUpperCase();
// Mesh-Entities in die Dreiecks-Puffer; POLYLINE nur als Mesh-Variante.
if (type === "3DFACE") {
addFace(meshTriangles, meshIndices, e);
continue;
switch (type) {
case "3DFACE":
addFace(meshTriangles, meshIndices, e);
break;
case "MESH":
addMesh(meshTriangles, meshIndices, e);
break;
case "POLYLINE":
// POLYLINE ist mehrdeutig: Polyface/PolygonMesh → Mesh; sonst → Kontur.
if (isMeshPolyline(e)) {
addPolyfaceMesh(meshTriangles, meshIndices, e);
} else {
const ct = polylineContour(e);
if (ct) contours.push(ct);
}
break;
case "LWPOLYLINE": {
const ct = polylineContour(e);
if (ct) contours.push(ct);
break;
}
case "LINE": {
const ct = lineContour(e);
if (ct) contours.push(ct);
break;
}
default:
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
break;
}
if (type === "MESH") {
addMesh(meshTriangles, meshIndices, e);
continue;
}
if (type === "POLYLINE" && isMeshPolyline(e)) {
addPolyfaceMesh(meshTriangles, meshIndices, e);
continue;
}
if (type === "TEXT" || type === "MTEXT") {
const it = textEntity(e, type);
if (it) texts.push(it);
continue;
}
// Alle übrigen Kontur-Entities (inkl. SPLINE + INSERT-Block-Expansion) über
// den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion.
collectContours(e, blocks, contours, 0);
}
const meshes: ImportedMesh[] = [];
@@ -197,43 +141,7 @@ export function parseDxf(text: string): DxfImportResult {
});
}
return { meshes, contours: contourSets, texts };
}
// ── TEXT / MTEXT ─────────────────────────────────────────────────────────────
/** Fallback-Schrifthöhe (Meter), wenn die DXF-Höhe fehlt/0 ist. */
const DEFAULT_TEXT_HEIGHT = 0.25;
/**
* MTEXT-Inhalt von Formatierungscodes säubern (grober erster Wurf): \P Leer-
* zeichen, \~ Leerzeichen, `\f…;`/`\H…;`/`\C…;`/`\A…;`-Sequenzen und die {}-
* Gruppenklammern entfernen, `\{`/`\}`/`\\` entschärfen. Komplexe Verschachte-
* lungen werden flachgeklopft (dokumentierte Grenze).
*/
function sanitizeMText(s: string): string {
return s
.replace(/\\P/gi, " ")
.replace(/\\~/g, " ")
.replace(/\\[A-Za-z][^;\\]*;/g, "")
.replace(/[{}]/g, "")
.replace(/\\([{}\\])/g, "$1")
.trim();
}
/** TEXT/MTEXT-Entity → ImportedText (Position/Höhe/Winkel), oder null bei leer. */
function textEntity(e: DxfEntity, type: string): ImportedText | null {
const rawText = typeof e.text === "string" ? e.text : "";
const content = type === "MTEXT" ? sanitizeMText(rawText) : rawText;
if (!content) return null;
const at: Vec2 =
type === "MTEXT"
? { x: coord(e.position, "x"), y: coord(e.position, "y") }
: { x: e.startPoint?.x ?? 0, y: e.startPoint?.y ?? 0 };
const rawHeight = type === "MTEXT" ? numOr(e.height, 0) : numOr(e.textHeight, 0);
const height = rawHeight > 0 ? rawHeight : DEFAULT_TEXT_HEIGHT;
const angle = (numOr(e.rotation, 0) * Math.PI) / 180; // DXF: Grad CCW → Radiant
return { at, text: content, height, angle, layer: e.layer };
return { meshes, contours: contourSets };
}
// ── Mesh-Entities ────────────────────────────────────────────────────────────
@@ -400,593 +308,3 @@ function lineContour(e: DxfEntity): Contour | null {
layer: e.layer,
};
}
// ── Kurven-Entities (ARC/CIRCLE/ELLIPSE) → tessellierte Konturen ──────────────
/** Winkelauflösung der Tessellierung (~5.6° pro Segment). */
const CURVE_STEP = Math.PI / 32;
/** Obergrenze der Segmentzahl (Schutz gegen entartete Eingaben). */
const CURVE_MAX_SEG = 256;
/** Segmentzahl für eine Winkelspanne (Radiant): mind. 2, gedeckelt. */
function segmentsFor(sweep: number): number {
return Math.max(2, Math.min(CURVE_MAX_SEG, Math.ceil(Math.abs(sweep) / CURVE_STEP)));
}
/**
* ARC offener Bogen-Linienzug. `startAngle`/`endAngle` in Radiant (dxf-parser
* rechnet Gradrad). Bögen laufen CCW; eine nicht-positive Spanne wird um 2π
* ergänzt (voller-Kreis-Fall bleibt 2π).
*/
function arcContour(e: DxfEntity): Contour | null {
const c = e.center;
const r = e.radius;
if (
!c ||
!Number.isFinite(c.x) ||
!Number.isFinite(c.y) ||
typeof r !== "number" ||
!Number.isFinite(r) ||
r <= 0
) {
return null;
}
const start = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
const end = Number.isFinite(e.endAngle) ? (e.endAngle as number) : Math.PI * 2;
let sweep = end - start;
if (sweep <= 0) sweep += Math.PI * 2;
const cx = c.x ?? 0;
const cy = c.y ?? 0;
const z = Number.isFinite(c.z) ? (c.z as number) : 0;
const segs = segmentsFor(sweep);
const pts: Vec2[] = [];
for (let i = 0; i <= segs; i++) {
const t = start + (sweep * i) / segs;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
}
return {
z,
pts,
closed: false,
layer: e.layer,
curve: { kind: "arc", cx, cy, r, a0: start, a1: start + sweep },
};
}
/** CIRCLE → geschlossener Kreis-Linienzug (voller Umlauf, letzter Punkt weggelassen). */
function circleContour(e: DxfEntity): Contour | null {
const c = e.center;
const r = e.radius;
if (
!c ||
!Number.isFinite(c.x) ||
!Number.isFinite(c.y) ||
typeof r !== "number" ||
!Number.isFinite(r) ||
r <= 0
) {
return null;
}
const cx = c.x ?? 0;
const cy = c.y ?? 0;
const z = Number.isFinite(c.z) ? (c.z as number) : 0;
const segs = segmentsFor(Math.PI * 2);
const pts: Vec2[] = [];
// 0..2π ohne Schluss-Duplikat (closed schließt den Ring).
for (let i = 0; i < segs; i++) {
const t = (Math.PI * 2 * i) / segs;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
}
return {
z,
pts,
closed: true,
layer: e.layer,
curve: { kind: "circle", cx, cy, r, a0: 0, a1: Math.PI * 2 },
};
}
/**
* ELLIPSE Linienzug. Hauptachse = `majorAxisEndPoint` (Vektor relativ zum
* Center), Nebenachse = dazu · `axisRatio`. `startAngle`/`endAngle` sind
* PARAMETERwinkel in Radiant; ein voller Umlauf (Spanne 2π) wird geschlossen.
* Punkt(t) = Center + cos t · Haupt + sin t · Neben.
*/
function ellipseContour(e: DxfEntity): Contour | null {
const c = e.center;
const maj = e.majorAxisEndPoint;
const ratio = e.axisRatio;
if (
!c ||
!maj ||
!Number.isFinite(c.x) ||
!Number.isFinite(c.y) ||
!Number.isFinite(maj.x) ||
!Number.isFinite(maj.y) ||
typeof ratio !== "number" ||
!Number.isFinite(ratio)
) {
return null;
}
const cx = c.x ?? 0;
const cy = c.y ?? 0;
const ax = maj.x ?? 0; // Hauptachsen-Vektor (relativ Center)
const ay = maj.y ?? 0;
const bx = -ay * ratio; // Nebenachse = Linksnormale der Hauptachse · Verhältnis
const by = ax * ratio;
const start = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
const end = Number.isFinite(e.endAngle) ? (e.endAngle as number) : Math.PI * 2;
let sweep = end - start;
if (sweep <= 0) sweep += Math.PI * 2;
const full = Math.abs(sweep - Math.PI * 2) < 1e-9;
const z = Number.isFinite(c.z) ? (c.z as number) : 0;
const segs = segmentsFor(sweep);
const pts: Vec2[] = [];
// Bei vollem Umlauf Schluss-Duplikat weglassen (closed schließt den Ring).
const last = full ? segs - 1 : segs;
for (let i = 0; i <= last; i++) {
const t = start + (sweep * i) / segs;
const ct = Math.cos(t);
const st = Math.sin(t);
pts.push({ x: cx + ax * ct + bx * st, y: cy + ay * ct + by * st });
}
return { z, pts, closed: full, layer: e.layer };
}
// ── SPLINE (B-Spline via De Boor) ────────────────────────────────────────────
/** DxfVertex-Liste → finite Vec2-Stützpunkte. */
function toVec2s(vs: DxfVertex[] | undefined): Vec2[] {
const out: Vec2[] = [];
for (const v of vs ?? []) {
if (Number.isFinite(v.x) && Number.isFinite(v.y)) {
out.push({ x: v.x ?? 0, y: v.y ?? 0 });
}
}
return out;
}
/** Erster finiter Z-Wert einer DxfVertex-Liste (sonst 0). */
function firstZ(vs: DxfVertex[] | undefined): number {
for (const v of vs ?? []) if (Number.isFinite(v.z)) return v.z as number;
return 0;
}
/**
* Knoten-Span-Index (The NURBS Book, A2.1): grösstes k mit U[k] t < U[k+1],
* geklemmt auf [p, n]. `n` = letzter Kontrollpunkt-Index.
*/
function findSpan(n: number, p: number, t: number, U: number[]): number {
if (t >= U[n + 1]) return n;
if (t <= U[p]) return p;
let low = p;
let high = n + 1;
let mid = Math.floor((low + high) / 2);
while (t < U[mid] || t >= U[mid + 1]) {
if (t < U[mid]) high = mid;
else low = mid;
mid = Math.floor((low + high) / 2);
}
return mid;
}
/** Punkt einer (nicht-rationalen) B-Spline bei Parameter t (De Boor, A2.4). */
function deBoor(cps: Vec2[], U: number[], p: number, t: number): Vec2 {
const n = cps.length - 1;
const span = findSpan(n, p, t, U);
const d: Vec2[] = [];
for (let j = 0; j <= p; j++) d[j] = { ...cps[span - p + j] };
for (let r = 1; r <= p; r++) {
for (let j = p; j >= r; j--) {
const i = span - p + j;
const denom = U[i + p - r + 1] - U[i];
const a = denom === 0 ? 0 : (t - U[i]) / denom;
d[j] = {
x: (1 - a) * d[j - 1].x + a * d[j].x,
y: (1 - a) * d[j - 1].y + a * d[j].y,
};
}
}
return d[p];
}
/**
* Nicht-rationale B-Spline über die Domäne [U[p], U[n+1]] abtasten (De Boor),
* wenn Kontrollpunkte/Knoten/Grad konsistent sind (|U| = |P| + Grad + 1). Sonst
* null. ~8 Abtastpunkte je Kontrollpunkt, gedeckelt.
*/
function sampleBSpline(cps: Vec2[], knots: number[], degree: number): Vec2[] | null {
if (cps.length < 2 || degree < 1 || knots.length !== cps.length + degree + 1) {
return null;
}
const samples = Math.max(16, Math.min(CURVE_MAX_SEG, cps.length * 8));
const n = cps.length - 1;
const t0 = knots[degree];
const t1 = knots[n + 1];
const pts: Vec2[] = [];
for (let i = 0; i <= samples; i++) {
const t = i === samples ? t1 : t0 + ((t1 - t0) * i) / samples;
pts.push(deBoor(cps, knots, degree, t));
}
return pts;
}
/**
* SPLINE Linienzug. Primär echte B-Spline-Auswertung (Kontrollpunkte + Knoten
* + Grad, wenn der Knotenvektor konsistent ist: |U| = |P| + Grad + 1). Sonst
* Rückfall auf Stützpunkte (fitPoints) bzw. grob das Kontrollpolygon.
* Rationale Gewichte werden ignoriert (selten; dxf-parser liefert sie nicht).
*/
function splineContour(e: DxfEntity): Contour | null {
const cps = toVec2s(e.controlPoints);
const fps = toVec2s(e.fitPoints);
const knots = (e.knotValues ?? []).filter((k) => Number.isFinite(k));
const degree = Number.isFinite(e.degreeOfSplineCurve)
? (e.degreeOfSplineCurve as number)
: 3;
const closed = e.closed === true;
const z = cps.length > 0 ? firstZ(e.controlPoints) : firstZ(e.fitPoints);
const sampled = sampleBSpline(cps, knots, degree);
if (sampled) return { z, pts: sampled, closed, layer: e.layer };
if (fps.length >= 2) return { z, pts: fps, closed, layer: e.layer };
if (cps.length >= 2) return { z, pts: cps, closed, layer: e.layer };
return null;
}
// ── INSERT (Block-Referenz → transformierte Konturen) ─────────────────────────
/** Tolerantes Zahlenfeld: endliche Zahl oder Default. */
function numOr(v: unknown, d: number): number {
return typeof v === "number" && Number.isFinite(v) ? v : d;
}
/** Tolerante Koordinate aus einem (unbekannt getippten) Punktobjekt. */
function coord(v: unknown, key: "x" | "y"): number {
if (v && typeof v === "object") {
const n = (v as Record<string, unknown>)[key];
if (typeof n === "number" && Number.isFinite(n)) return n;
}
return 0;
}
/** Schutz gegen zyklische/tief verschachtelte Blockreferenzen. */
const MAX_INSERT_DEPTH = 8;
/**
* INSERT expandieren: Block-Entities rekursiv zu LOKALEN Konturen sammeln, dann
* je Array-Zelle mit der 2D-Transform der Referenz nach Welt abbilden.
* Welt(p, off) = Einfügepunkt + Rot(θ) · ( Scale(p Basispunkt) + off ).
* `off` = (Spalte·Spaltenabstand, Zeile·Zeilenabstand) im rotierten Blockraster.
*/
function expandInsert(
e: DxfEntity,
blocks: Record<string, DxfBlock>,
out: Contour[],
depth: number,
): void {
if (depth >= MAX_INSERT_DEPTH) return;
const name = typeof e.name === "string" ? e.name : null;
if (!name) return;
const block = blocks[name];
if (!block || !Array.isArray(block.entities)) return;
const sx = numOr(e.xScale, 1);
const sy = numOr(e.yScale, 1);
const rot = (numOr(e.rotation, 0) * Math.PI) / 180; // INSERT-Rotation in GRAD
const cos = Math.cos(rot);
const sin = Math.sin(rot);
const ix = coord(e.position, "x"); // Einfügepunkt (tolerant gelesen)
const iy = coord(e.position, "y");
const bx = coord(block.position, "x"); // Block-Basispunkt
const by = coord(block.position, "y");
const nc = Math.max(1, Math.floor(numOr(e.columnCount, 1)));
const nr = Math.max(1, Math.floor(numOr(e.rowCount, 1)));
const dc = numOr(e.columnSpacing, 0);
const dr = numOr(e.rowSpacing, 0);
// Block-Inhalt EINMAL lokal sammeln (rekursiv), dann je Zelle transformieren.
const local: Contour[] = [];
for (const be of block.entities) collectContours(be, blocks, local, depth + 1);
if (local.length === 0) return;
for (let row = 0; row < nr; row++) {
for (let col = 0; col < nc; col++) {
const offx = col * dc;
const offy = row * dr;
for (const lc of local) {
const pts = lc.pts.map((p) => {
const vx = (p.x - bx) * sx + offx;
const vy = (p.y - by) * sy + offy;
return { x: ix + (vx * cos - vy * sin), y: iy + (vx * sin + vy * cos) };
});
out.push({ z: lc.z, pts, closed: lc.closed, layer: e.layer ?? lc.layer });
}
}
}
}
/**
* Gemeinsamer Kontur-Dispatch für eine Entity (Top-Level UND Block-Inhalt).
* Mesh-Entities werden hier NICHT behandelt (nur der Top-Level-Pfad erzeugt
* Meshes; Block-interne Meshes sind im 2D-Import selten und bleiben aussen vor).
*/
function collectContours(
e: DxfEntity,
blocks: Record<string, DxfBlock>,
out: Contour[],
depth: number,
): void {
const type = (e.type ?? "").toUpperCase();
switch (type) {
case "LWPOLYLINE": {
const c = polylineContour(e);
if (c) out.push(c);
break;
}
case "POLYLINE": {
if (!isMeshPolyline(e)) {
const c = polylineContour(e);
if (c) out.push(c);
}
break;
}
case "LINE": {
const c = lineContour(e);
if (c) out.push(c);
break;
}
case "ARC": {
const c = arcContour(e);
if (c) out.push(c);
break;
}
case "CIRCLE": {
const c = circleContour(e);
if (c) out.push(c);
break;
}
case "ELLIPSE": {
const c = ellipseContour(e);
if (c) out.push(c);
break;
}
case "SPLINE": {
const c = splineContour(e);
if (c) out.push(c);
break;
}
case "HATCH":
for (const c of hatchContours(e)) out.push(c);
break;
case "INSERT":
expandInsert(e, blocks, out, depth);
break;
default:
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
break;
}
}
// ── HATCH (Custom-Handler + Randpfad-Auswertung → gefüllte Konturen) ──────────
/**
* Custom dxf-parser-Handler für HATCH: sammelt die rohen Gruppencodes der Entity
* (bis zum nächsten Code 0), OHNE zu interpretieren. Muster wie die eingebauten
* Handler (Schleife bricht bei Code 0; `lastReadGroup` bleibt darauf stehen).
* Die eigentliche Randpfad-Logik liegt testbar in `hatchContours`.
*/
class HatchHandler {
ForEntityName = "HATCH";
parseEntity(
scanner: {
next: () => { code: number; value: number | string };
isEOF: () => boolean;
},
curr: { code: number; value: number | string },
): DxfEntity {
const raw: RawCode[] = [];
const entity: DxfEntity = { type: String(curr.value), rawCodes: raw };
curr = scanner.next();
while (!scanner.isEOF()) {
if (curr.code === 0) break;
if (curr.code === 8) entity.layer = String(curr.value);
raw.push({ code: curr.code, value: curr.value });
curr = scanner.next();
}
return entity;
}
}
/** Zahlenwert eines Gruppencodes (tolerant). */
function rc(v: number | string | undefined): number {
if (typeof v === "number") return Number.isFinite(v) ? v : 0;
const n = typeof v === "string" ? Number.parseFloat(v) : NaN;
return Number.isFinite(n) ? n : 0;
}
/** Erster Wert eines Codes aus der Kanten-Code-Sammlung. */
function firstOf(m: Map<number, number[]>, code: number): number | undefined {
const a = m.get(code);
return a && a.length > 0 ? a[0] : undefined;
}
/** Bogenkante (Kantentyp 2): Zentrum/Radius, Winkel in GRAD, ggf. im Uhrzeigersinn. */
function pushArcEdge(pts: Vec2[], m: Map<number, number[]>): void {
const cx = firstOf(m, 10);
const cy = firstOf(m, 20);
const r = firstOf(m, 40);
if (cx === undefined || cy === undefined || r === undefined || r <= 0) return;
const s = (firstOf(m, 50) ?? 0) * (Math.PI / 180);
const e = (firstOf(m, 51) ?? 360) * (Math.PI / 180);
const ccw = (firstOf(m, 73) ?? 1) !== 0;
// Spanne in Laufrichtung auf (0, 2π] normieren.
let sweep = ccw ? e - s : -(e - s);
sweep = ((sweep % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
if (sweep === 0) sweep = Math.PI * 2;
const dir = ccw ? 1 : -1;
const segs = segmentsFor(sweep);
// Startpunkt weglassen, wenn er den vorherigen Kanten-Endpunkt dupliziert.
for (let i = 0; i <= segs; i++) {
const t = s + dir * (sweep * i) / segs;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
}
}
/**
* Ellipsenkante (Kantentyp 3): Zentrum (10/20), Hauptachsen-Endpunkt relativ
* (11/21), Achsverhältnis (40), Start/End-PARAMETERwinkel in GRAD (50/51), ggf.
* im Uhrzeigersinn (73). Punkt(t) = Center + cos t·Haupt + sin t·Neben.
*/
function pushEllipseEdge(pts: Vec2[], m: Map<number, number[]>): void {
const cx = firstOf(m, 10);
const cy = firstOf(m, 20);
const mx = firstOf(m, 11);
const my = firstOf(m, 21);
const ratio = firstOf(m, 40);
if (cx === undefined || cy === undefined || mx === undefined || my === undefined || ratio === undefined) {
return;
}
const bx = -my * ratio; // Nebenachse = Linksnormale der Hauptachse · Verhältnis
const by = mx * ratio;
const s = (firstOf(m, 50) ?? 0) * (Math.PI / 180);
const e = (firstOf(m, 51) ?? 360) * (Math.PI / 180);
const ccw = (firstOf(m, 73) ?? 1) !== 0;
let sweep = ccw ? e - s : -(e - s);
sweep = ((sweep % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
if (sweep === 0) sweep = Math.PI * 2;
const dir = ccw ? 1 : -1;
const segs = segmentsFor(sweep);
for (let i = 0; i <= segs; i++) {
const t = s + (dir * (sweep * i)) / segs;
const ct = Math.cos(t);
const st = Math.sin(t);
pts.push({ x: cx + mx * ct + bx * st, y: cy + my * ct + by * st });
}
}
/**
* Spline-Kante (Kantentyp 4): Grad (94), Knoten (40×), Kontrollpunkte (10/20×).
* Über De Boor abgetastet (rationale Gewichte 42 ignoriert). Fallback: rohe
* Kontrollpunkte als Polygonzug.
*/
function pushSplineEdge(pts: Vec2[], m: Map<number, number[]>): void {
const xs = m.get(10) ?? [];
const ys = m.get(20) ?? [];
const cps: Vec2[] = [];
for (let i = 0; i < Math.min(xs.length, ys.length); i++) cps.push({ x: xs[i], y: ys[i] });
const knots = m.get(40) ?? [];
const degree = firstOf(m, 94) ?? 3;
const sampled = sampleBSpline(cps, knots, degree);
if (sampled) {
for (const p of sampled) pts.push(p);
} else {
for (const p of cps) pts.push(p);
}
}
/** Punkte einer HATCH-Randkante an die Loop-Punkte anhängen (Typ 1/2/3/4). */
function appendEdge(pts: Vec2[], edgeType: number, m: Map<number, number[]>): void {
if (edgeType === 1) {
// Linie: Start (10/20) — der Endpunkt ist der Start der nächsten Kante.
const x = firstOf(m, 10);
const y = firstOf(m, 20);
if (x !== undefined && y !== undefined) pts.push({ x, y });
// Bei der LETZTEN Kante fehlt sonst der Endpunkt; der closed-Ring schließt ihn.
return;
}
if (edgeType === 2) pushArcEdge(pts, m);
else if (edgeType === 3) pushEllipseEdge(pts, m);
else if (edgeType === 4) pushSplineEdge(pts, m);
}
/**
* HATCH-Entity (rohe Gruppencodes) gefüllte, geschlossene Konturen (ein Loop
* je Randpfad). Unterstützt Polyline-Randpfade (Flag-Bit 2) sowie Kanten-Pfade
* mit Linien-, Bogen-, Ellipsen- und Spline-Kanten (alle tesselliert). Bulges an
* Polyline-Rändern werden ignoriert (als Sehne); Insel-Loops entstehen als eigene
* geschlossene Ringe (keine echten Löcher).
*/
function hatchContours(e: DxfEntity): Contour[] {
const raw = e.rawCodes;
if (!raw || raw.length === 0) return [];
const n = raw.length;
const layer = e.layer;
// Zum ersten Code 91 (Anzahl Randpfade).
let i = 0;
while (i < n && raw[i].code !== 91) i++;
if (i >= n) return [];
const numPaths = rc(raw[i].value);
i++;
const loops: Contour[] = [];
for (let p = 0; p < numPaths && i < n; p++) {
// Zum Pfadtyp-Flag (92) des nächsten Pfads.
while (i < n && raw[i].code !== 92) i++;
if (i >= n) break;
const flag = rc(raw[i].value);
i++;
const isPolyline = (flag & 2) !== 0;
const pts: Vec2[] = [];
if (isPolyline) {
// Optionale 72/73 überspringen, bis 93 (Vertex-Anzahl).
while (i < n && raw[i].code !== 93) i++;
if (i >= n) break;
const numVerts = rc(raw[i].value);
i++;
let cx: number | null = null;
let got = 0;
while (i < n && got < numVerts) {
const c = raw[i];
if (c.code === 91 || c.code === 92) break; // Sicherheitsnetz
if (c.code === 10) {
cx = rc(c.value);
} else if (c.code === 20 && cx !== null) {
pts.push({ x: cx, y: rc(c.value) });
cx = null;
got++;
}
i++;
}
} else {
// Kanten-Pfad: 93 = Anzahl Kanten.
while (i < n && raw[i].code !== 93) i++;
if (i >= n) break;
const numEdges = rc(raw[i].value);
i++;
for (let ed = 0; ed < numEdges && i < n; ed++) {
// Zum Kantentyp (72), aber nicht über den Pfad/Kanten-Block hinaus.
while (
i < n &&
raw[i].code !== 72 &&
raw[i].code !== 97 &&
raw[i].code !== 92 &&
raw[i].code !== 91
) {
i++;
}
if (i >= n || raw[i].code !== 72) break;
const edgeType = rc(raw[i].value);
i++;
// Kanten-Codes bis zur nächsten Kante/Blockgrenze sammeln.
const m = new Map<number, number[]>();
while (i < n) {
const c = raw[i];
if (c.code === 72 || c.code === 97 || c.code === 92 || c.code === 91) break;
const arr = m.get(c.code) ?? [];
arr.push(rc(c.value));
m.set(c.code, arr);
i++;
}
appendEdge(pts, edgeType, m);
}
}
if (pts.length >= 2) {
loops.push({ z: 0, pts, closed: true, layer, filled: true });
}
}
return loops;
}
+3 -54
View File
@@ -15,7 +15,7 @@
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom, ImportedText } from "../model/types";
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types";
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
export type CategoryMode = "active" | "byLayer";
@@ -23,9 +23,6 @@ export type CategoryMode = "active" | "byLayer";
let drawingSeq = 0;
const nextId = (): string => `dxf2d-${Date.now()}-${drawingSeq++}`;
/** Default-Füllfarbe für aus HATCH importierte Flächen (neutral, restylebar). */
const DXF_HATCH_FILL = "#c8c8c8";
/**
* Wandelt einen Satz Konturen-Sätze in Drawing2D-Elemente für die Ziel-Ebene
* `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den
@@ -48,68 +45,20 @@ export function contoursToDrawings(
categoryMode === "byLayer" && layerName
? layerToCode(layerName)
: fallbackCode;
const drawing: Drawing2D = {
out.push({
id: nextId(),
type: "drawing2d",
levelId,
categoryCode,
geom,
};
// Aus einer HATCH stammende Kontur → geschlossene, gefüllte Form. Vollton-
// Füllung als restylebarer Default (neutrales Grau); Umriss bleibt Kategorie.
if (contour.filled && geom.shape === "polyline") {
drawing.fillColor = DXF_HATCH_FILL;
}
out.push(drawing);
});
}
}
return out;
}
/**
* Wandelt importierte Texte in Drawing2D-`text`-Elemente um analog
* `contoursToDrawings` (gleiche LayerKategorie-Zuordnung). Winkel/Höhe kommen
* 1:1 aus `ImportedText` (Höhe in Modell-Metern, Winkel in Radiant).
*/
export function textsToDrawings(
texts: ImportedText[],
levelId: string,
categoryMode: CategoryMode,
fallbackCode: string,
layerToCode: (layerName: string) => string,
): Drawing2D[] {
const out: Drawing2D[] = [];
for (const it of texts) {
const layerName = it.layer ?? "";
const categoryCode =
categoryMode === "byLayer" && layerName ? layerToCode(layerName) : fallbackCode;
out.push({
id: nextId(),
type: "drawing2d",
levelId,
categoryCode,
geom: {
shape: "text",
at: { ...it.at },
text: it.text,
height: it.height,
angle: it.angle,
},
});
}
return out;
}
/** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */
function contourGeom(contour: Contour): Drawing2DGeom | null {
// Echte Kurve (CIRCLE/ARC) → GLATTE Kreis-/Bogen-Form statt tesselliertem Vieleck.
const c = contour.curve;
if (c) {
if (c.kind === "circle") {
return { shape: "circle", center: { x: c.cx, y: c.cy }, r: c.r };
}
return { shape: "arc", center: { x: c.cx, y: c.cy }, r: c.r, a0: c.a0, a1: c.a1 };
}
const pts = contour.pts;
if (pts.length < 2) return null;
if (pts.length === 2 && !contour.closed) {
+10 -68
View File
@@ -267,7 +267,7 @@ function iwWall(id: string, start: Vec2, end: Vec2): Wall {
}
describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
it("W9-artiger T-Knoten: Backstein-Kern des Abzweigs sticht bis zur Rückgrat-Nahfläche durch, Putz wird getrimmt — materialgleicher Nah-Putz ⇒ KEINE L-Trennnaht", () => {
it("W9-artiger T-Knoten: Backstein-Kern des Abzweigs sticht bis zur Rückgrat-Nahfläche durch, Putz wird getrimmt + L-Seitenlinie an derselben Fläche", () => {
// Durchgangswand WA1 (0,0)→(5,0) / WA2 (5,0)→(10,0), Abzweig WB (5,0)→(5,3),
// alle vom selben Wandtyp "iw" (Innenputz/Backstein/Innenputz, T=0.15).
const proj = layeredWallProject(
@@ -303,76 +303,18 @@ describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
expect(lc.start[2]).not.toBeNull();
expect(distToLine(lc.start[0]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
expect(distToLine(lc.start[2]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
// ... aber KEINE L-Seitenlinie: der Nah-Putz der Durchgangswand ist
// materialgleicher Innenputz und füllt den getrimmten Abzweig-Putz an den
// Flanken durchgehend (der spanCutout schneidet nur die Kernbreite aus).
// Es entsteht ein durchgehendes Putz-L ohne Trennnaht. Nur bei material-
// FREMDEM Nah-Putz stünde hier eine L-Linie.
expect(lc.startSide[0]).toBeNull();
expect(lc.startSide[2]).toBeNull();
// ... UND bekommen die zusätzliche L-Seitenlinie an der Rückgrat-Nahfläche
// (y = 0.06, parallel zur Durchgangsachse) — dort endet der durchstechende
// Kern.
expect(lc.startSide[0]).not.toBeNull();
expect(lc.startSide[2]).not.toBeNull();
expect(distToLine(lc.startSide[0]!, { x: 5, y: 0.06 })).toBeCloseTo(0, 6);
expect(distToLine(lc.startSide[0]!, { x: 0, y: 0.06 })).toBeCloseTo(0, 6);
// Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts
// unangetastet (keine Pro-Schicht-Cuts für sie).
// Die kollinearen Hälften der Durchgangswand bleiben unangetastet
// (unverändertes Verhalten, keine layerCuts für sie).
expect(joins.get("WA1")!.layerCuts).toBeUndefined();
expect(joins.get("WA2")!.layerCuts).toBeUndefined();
// Die als Durchgang gewählte Wandhälfte bekommt eine Durchgangswand-Aussparung
// (Phase 1c): über die Backstein-Kernbreite (0.12) im Nah-Putz-Bereich
// (Offset 0.06…0.075). Welche Hälfte den Durchgang bildet, hängt von der
// Knoten-Reihenfolge ab — wir prüfen die, die den Cutout trägt.
const throughCuts = joins.get("WA1")!.spanCutouts ?? joins.get("WA2")!.spanCutouts;
expect(throughCuts).toBeDefined();
expect(throughCuts!.length).toBe(1);
const sc = throughCuts![0];
// Achsen-Intervall: Kernbreite 0.12, zentriert am Knoten (x=5 → [4.94,5.06]).
expect(sc.to - sc.from).toBeCloseTo(0.12, 6);
// offA = Nahfläche (0.075), offB = Rückgrat-Nahfläche (0.06).
expect(sc.offA).toBeCloseTo(0.075, 6);
expect(sc.offB).toBeCloseTo(0.06, 6);
// Der Abzweig selbst trägt keine Aussparung (die gehört der Durchgangswand).
expect(joins.get("WB")!.spanCutouts).toBeUndefined();
// L-Ecke bzw. Wände ohne T bleiben cutout-frei (die kollineare Nicht-Durchgangs-
// Hälfte hat keinen Cutout).
expect(
joins.get("WA1")!.spanCutouts === undefined ||
joins.get("WA2")!.spanCutouts === undefined,
).toBe(true);
});
it("Mittelspannen-T-Stoss: die Durchgangswand bekommt einen spanCutout mit korrekter Kernbreite und Nah-Putz-Offsetzone", () => {
// Realer W9-Fall: Durchgangswand als EINE Wand W (0,0)→(10,0), Abzweig WB
// trifft mit seinem freien Ende (4,0) mittig auf ihre Seite. Beide iw.
const proj = layeredWallProject(
iwWall("W", { x: 0, y: 0 }, { x: 10, y: 0 }),
iwWall("WB", { x: 4, y: 3 }, { x: 4, y: 0 }),
iwWall("Filler", { x: 100, y: 100 }, { x: 100, y: 103 }), // unbeteiligt
);
const joins = computeJoins(proj, proj.walls);
const wCuts = joins.get("W")!;
expect(wCuts.spanCutouts).toBeDefined();
expect(wCuts.spanCutouts!.length).toBe(1);
const sc = wCuts.spanCutouts![0];
// Intervall um x=4, Breite = Kernbreite 0.12 → [3.94, 4.06].
expect(sc.from).toBeCloseTo(3.94, 6);
expect(sc.to).toBeCloseTo(4.06, 6);
expect(sc.offA).toBeCloseTo(0.075, 6);
expect(sc.offB).toBeCloseTo(0.06, 6);
// Der Abzweig und die unbeteiligte Wand tragen keine Aussparung.
expect(joins.get("WB")!.spanCutouts).toBeUndefined();
expect(joins.get("Filler")!.spanCutouts).toBeUndefined();
});
it("L-Ecke und einfacher (nicht materialbewusster) Stoss bekommen KEINEN spanCutout", () => {
// Reine L-Ecke zweier Wände: keine Durchgangswand, kein Durchstoss.
const proj = twoWallProject(
wall("L1", { x: 0, y: 0 }, { x: 5, y: 0 }),
wall("L2", { x: 5, y: 0 }, { x: 5, y: 4 }),
0.4,
);
const joins = computeJoins(proj, proj.walls);
expect(joins.get("L1")!.spanCutouts).toBeUndefined();
expect(joins.get("L2")!.spanCutouts).toBeUndefined();
});
it("setzt layerCuts NICHT, wenn kein Bauteil des Abzweigs mit dem Rückgrat der Durchgangswand übereinstimmt", () => {
+1 -90
View File
@@ -35,29 +35,6 @@ export interface LayerCuts {
endSide: (Line | null)[];
}
/**
* Aussparung der DURCHGANGSWAND an einem materialbewussten T-Stoss: über das
* Achsen-Intervall `[from,to]` (Meter ab `wall.start` entlang der Achse) und den
* Quer-Offsetbereich `[offA,offB]` wird die Nah-Putz-Zone der Durchgangswand
* echt ausgeschnitten, damit der durchstechende Abzweig-Kern (Phase 1b) die
* Durchgangswand nicht nur übermalt, sondern geometrisch aufbricht.
*
* OFFSET-KONVENTION (identisch zu den Schicht-Bändern in `addWallPoche`):
* `offA`/`offB` sind Querabstände von der Wandachse ENTLANG der linken Normalen
* `leftNormal(dir)`, inkl. Referenzversatz also im selben Bereich wie
* `refOff total/2` (außen) `refOff + total/2` (innen). `offA` ist stets die
* NAHFLÄCHE (Wandoberfläche, `refOff + sign·total/2`), `offB` die dem Abzweig
* zugewandte Fläche des Rückgrat-KERNS (`refOff + sign·(total/2 nearPlaster)`)
* die Zone dazwischen ist exakt der Nah-Putz, den der Abzweig-Kern durchstösst.
* `from ≤ to` ist die Projektion der Abzweig-Kernbreite auf die Durchgangsachse.
*/
export interface SpanCutout {
from: number;
to: number;
offA: number;
offB: number;
}
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
export interface WallCuts {
startCut: Line | null;
@@ -69,13 +46,6 @@ export interface WallCuts {
* verschmilzt (siehe {@link resolveJoinPriority}).
*/
layerCuts?: LayerCuts;
/**
* Additive Erweiterung (Durchgangswand-Seite): Aussparungen der Nah-Putz-Zone,
* durch die ein Abzweig-Kern sticht. Fehlt/leer die Wand wird unverändert
* gezeichnet. Wird nur an der DURCHGANGSWAND gesetzt (nie am Abzweig), analog
* additiv zu {@link layerCuts}. Siehe {@link SpanCutout}.
*/
spanCutouts?: SpanCutout[];
}
/**
@@ -421,70 +391,11 @@ function applyLayerCuts(
// Nachbarschicht kappt ihre Breite.
const sideLine: Line = mergeCut;
// Materialien des durchgehenden Nah-Putzes der Durchgangswand (dieselbe
// sign-Seite wie die nearPlaster-Summe oben). Ist die getrimmte Abzweig-Schicht
// materialgleich mit einer davon, füllt der Nah-Putz den Abzweig-Putz an dieser
// Stelle durchgehend (der spanCutout schneidet nur die KERN-Breite aus, nicht
// die schmalen Putzflanken) → Putz-L OHNE Trennnaht, also KEINE L-Seitenlinie.
const nearPlasterComps = new Set<string>();
for (let i = 0; i < throughWt.layers.length; i++) {
if (sign > 0 ? i > backboneIdx : i < backboneIdx)
nearPlasterComps.add(throughWt.layers[i].componentId);
}
for (let i = 0; i < n; i++) {
faceArr[i] = merged[i] ? mergeCut : faceCut;
const adjMerged =
!merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
const mergesWithNearPlaster = nearPlasterComps.has(branchWt.layers[i].componentId);
sideArr[i] = adjMerged && !mergesWithNearPlaster ? sideLine : null;
}
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand über
// die Breite des durchstechenden Abzweig-Kerns ECHT ausschneiden. Nur nötig,
// wenn davor überhaupt Nah-Putz liegt (nearPlaster > 0) — sonst schliesst der
// Kern flächenbündig an und es gibt nichts aufzubrechen.
if (nearPlaster > 1e-9) {
// Kernbreite des Abzweigs im Quer-Offset (Offset-Konvention der Bänder:
// gestapelt entlang leftNormal ab refOff total/2). Die verschmelzenden
// Schichten (`merged`) bilden den Kern; Lo/Hi = ihre Offset-Hülle.
const totalBranch = wallTypeThickness(branchWt);
const refOffBranch = wallReferenceOffset(branchWall, totalBranch);
let bOff = refOffBranch - totalBranch / 2;
let coreLo = Infinity;
let coreHi = -Infinity;
for (let i = 0; i < n; i++) {
const th = branchWt.layers[i].thickness;
if (merged[i]) {
if (bOff < coreLo) coreLo = bOff;
if (bOff + th > coreHi) coreHi = bOff + th;
}
bOff += th;
}
if (coreHi > coreLo) {
// Projektion der Kern-Offsethülle auf die Durchgangsachse: der Achsenpunkt
// liegt bei `axisAt` (Meter ab Durchgangs-Start), ein Abzweig-Offset `o`
// verschiebt sich entlang der Achse um `o · (nBranch · uT)`.
const nBranch = leftNormal(dirOf(branchWall));
const proj = dot(nBranch, throughDir);
const axisAt = dot(sub(axisPoint, throughWall.start), throughDir);
const e1 = axisAt + coreLo * proj;
const e2 = axisAt + coreHi * proj;
// Quer-Offsetbereich = Nah-Putz-Zone der Durchgangswand: Nahfläche (offA)
// bis Rückgrat-Nahfläche (offB, = mergeCut-Offset). Beide in derselben
// Offset-Konvention wie die Schicht-Bänder der Durchgangswand.
const offA = offT + sign * (tT / 2);
const offB = offT + sign * (tT / 2 - nearPlaster);
const throughCuts = result.get(throughWall.id);
if (throughCuts) {
(throughCuts.spanCutouts ??= []).push({
from: Math.min(e1, e2),
to: Math.max(e1, e2),
offA,
offB,
});
}
}
sideArr[i] = adjMerged ? sideLine : null;
}
}
+18 -14
View File
@@ -16,18 +16,22 @@ export const sampleProject: Project = {
id: "demo",
name: "Demo-Haus",
// ── Ressourcen-Bibliotheken (zentral verwiesen per id) ──────────────────
// Linienstile: aufgeräumt auf kanonische VOLLLINIEN je Strichstärke (dash=null)
// + eine gestrichelte Linie. Früher gab es drei funktional identische 0.13-
// Volllinien ("thin"/"hatch-line"/"joint-massive") — zusammengeführt auf "thin"
// ("Volllinie 0.13"); Schraffur-/Schichtfugen-Referenzen zeigen jetzt darauf.
// (Die ids bleiben stabil, damit geladene Projekte + interne Referenzen halten.)
// Linienstile: Strichstärken aus DEFAULT_LAYER_SCHEMA (lw in mm). "hatch-line"
// ist der dünne Strich für Schraffur-Muster.
lineStyles: [
{ id: "hatch-hair", name: "Volllinie 0.02", weight: 0.02, color: "#1a1a1a", dash: null },
{ id: "thin", name: "Volllinie 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
{ id: "medium", name: "Volllinie 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
{ id: "thick", name: "Volllinie 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
{ id: "thin", name: "Dünn 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
{ id: "medium", name: "Mittel 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
{ id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
// Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren).
{ id: "hatch-line", name: "Schraffurlinie", weight: 0.13, color: "#1a1a1a", dash: null },
// Feinste Haarlinie für die SIA-Schnittschraffuren (Beton/Backstein/Dämmung):
// Gewicht unter der Bildschirm-Untergrenze, damit die Musterlinien als dünnste
// Haarlinie (Stroke-Minimum) rendern.
{ id: "hatch-hair", name: "Schraffur-Haarlinie", weight: 0.02, color: "#1a1a1a", dash: null },
// Gestrichelte Haarlinie für die Dämmung (Striche quer durch die Wanddicke).
{ id: "hatch-dash", name: "Dämmungsstrich", weight: 0.02, color: "#1a1a1a", dash: [0.35, 0.3] },
// Kräftige Schichtfuge, wo zwei massive Materialien aneinanderstoßen (Wandstile).
{ id: "joint-massive", name: "Schichtfuge 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
],
// Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad.
// "none" = keine Schraffur (nur Component-Füllung).
@@ -42,7 +46,7 @@ export const sampleProject: Project = {
scale: 1,
angle: 0,
color: "#1a1a1a",
lineStyleId: "thin",
lineStyleId: "hatch-line",
},
{
id: "diagonal",
@@ -51,7 +55,7 @@ export const sampleProject: Project = {
scale: 1,
angle: 45,
color: "#1a1a1a",
lineStyleId: "thin",
lineStyleId: "hatch-line",
},
{
id: "crosshatch",
@@ -60,7 +64,7 @@ export const sampleProject: Project = {
scale: 1,
angle: 0,
color: "#1a1a1a",
lineStyleId: "thin",
lineStyleId: "hatch-line",
},
{
id: "solid-concrete",
@@ -129,7 +133,7 @@ export const sampleProject: Project = {
layers: [
{ componentId: "render-ext", thickness: 0.02 },
// Innere Fuge der Dämmung trifft auf den Backstein (massiv↔massiv) → 0.13 mm.
{ componentId: "insulation", thickness: 0.16, jointLineStyleId: "thin" },
{ componentId: "insulation", thickness: 0.16, jointLineStyleId: "joint-massive" },
{ componentId: "brick", thickness: 0.15 },
{ componentId: "render-int", thickness: 0.015 },
],
@@ -156,7 +160,7 @@ export const sampleProject: Project = {
// Estrich (schwimmend) → Trittschalldämmung.
{ componentId: "screed", thickness: 0.06 },
// Innere Fuge der Dämmung trifft auf die Betondecke (massiv) → 0.13 mm.
{ componentId: "insulation", thickness: 0.04, jointLineStyleId: "thin" },
{ componentId: "insulation", thickness: 0.04, jointLineStyleId: "joint-massive" },
// Betondecke (Rohdecke, tragend) — innerste/unterste Schicht.
{ componentId: "concrete", thickness: 0.2 },
],
-70
View File
@@ -731,22 +731,6 @@ export interface Opening {
height: number;
/** Brüstungshöhe (Unterkante der Öffnung) über der Wand-UK; 0 bei Türen. */
sillHeight: number;
/**
* Nur Fenster: Anzahl der Flügel (14). Bei > 1 werden im Plan `wingCount 1`
* Mittelpfosten (Querlinien quer zur Öffnungsrichtung) gleichmäßig innerhalb des
* Rahmens gezeichnet analog den Mittelpfosten in `_make_oeffnung_pieces` des
* Rhino-Plugins (`oeff_fluegel`). Fehlt es, gilt 1 (= heutiges Verhalten: keine
* Pfosten).
*/
wingCount?: number;
/**
* Nur Tür: Tür-Typ (analog `oeff_tuer_typ` im Rhino-Plugin).
* "normal" Türblatt + Schwenkbogen (Default = heutiges Verhalten).
* "wandoeffnung" reiner Wanddurchbruch: KEIN Türblatt, KEIN Schwenkbogen;
* nur die Öffnung/Laibung (Wandlücke + ggf. Sturz-/Anschlaglinien) bleibt.
* Fehlt es, gilt "normal".
*/
doorType?: "normal" | "wandoeffnung";
/** Nur Tür: an welchem Pfosten das Scharnier sitzt. */
hinge?: "start" | "end";
/** Nur Tür: auf welche Seite der Wandachse das Blatt aufschlägt. */
@@ -757,15 +741,6 @@ export interface Opening {
openingDir?: "in" | "out";
/** Optionale Rahmenstärke (quer zur Wand) in Metern für die 3D-Darstellung. */
frameThickness?: number;
/**
* Nur Tür: Sturzlinien (SIA, gestrichelt) quer über die Öffnung an der Wand-
* Innen- und/oder Aussenkante. Zeigt die Überkopf-Projektion des Sturzes.
* "keine" keine Sturzlinien
* "innen" eine Linie an der Wand-Innenkante
* "aussen" eine Linie an der Wand-Aussenkante
* "beide" beide Linien (Default wenn nicht gesetzt)
*/
lintelLines?: "keine" | "innen" | "aussen" | "beide";
/**
* Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die
* Kategorie-Farbe.
@@ -830,19 +805,6 @@ export interface Stair {
sweep?: number;
/** Laufbreite in Metern (quer zur Laufrichtung). */
width: number;
/**
* Lage der gespeicherten Achse (`start`/`dir` bzw. Wendel-Bogen) über die
* Laufbreite analog `treppe_referenz` im Rhino-Plugin. Bestimmt, ob die Achse
* die LINKE, MITTLERE oder RECHTE Kante der Treppe repräsentiert (in
* Laufrichtung gesehen). Fehlt sie, gilt "mitte" (= heutiges Verhalten: Achse =
* Treppen-Mitte).
* "links" Achse = linke Kante; die Treppe liegt rechts der Achse.
* "mitte" Achse = Mitte (Default).
* "rechts" Achse = rechte Kante; die Treppe liegt links der Achse.
* Die Lauflinie samt Pfeil bleibt IMMER auf der visuellen Treppen-Mitte
* (nicht auf der Referenzkante) siehe `stairGeometry`.
*/
referenz?: "links" | "mitte" | "rechts";
/**
* Gesamt-Steighöhe in Metern (OKFF OKFF nächstes Geschoss). Fehlt sie, gilt
* beim Auflösen die Geschosshöhe des zugehörigen Geschosses.
@@ -1093,38 +1055,6 @@ export interface Contour {
closed: boolean;
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
layer?: string;
/**
* Gefüllte Fläche (aus einer DXF-HATCH). Beim Drawing-Import wird daraus eine
* geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss.
*/
filled?: boolean;
/**
* Wahre Kurvengeometrie (aus CIRCLE/ARC). `pts` bleibt tesselliert (Kontext/
* 3D), aber der Drawing-Import baut daraus eine GLATTE `{shape:"circle"|"arc"}`-
* Form statt eines Vielecks. Winkel in RADIANT (CCW); Kreis = a0..a1 über 2π.
*/
curve?: {
kind: "circle" | "arc";
cx: number;
cy: number;
r: number;
a0: number;
a1: number;
};
}
/**
* Ein importiertes Text-Element (DXF TEXT/MTEXT). `at` = Einfügepunkt in Metern,
* `height` = Schrifthöhe in Modell-Metern, `angle` = Drehung in RADIANT (CCW).
* Wird beim Drawing-Import zu einem `{shape:"text"}`-Drawing2D.
*/
export interface ImportedText {
at: Vec2;
text: string;
height: number;
angle: number;
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
layer?: string;
}
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
+21 -65
View File
@@ -30,16 +30,6 @@ import type { AttributeSource } from "../model/types";
import { usePanelHost } from "./host";
import { Dropdown } from "../ui/Dropdown";
import { ColorHexField } from "../ui/ColorHexField";
// Element-spezifische Attribut-Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) —
// aus ObjectInfoPanel hierher verschoben (Ribbon Phase 3: ObjectInfo trägt nur
// noch Bezugspunkt + Masse).
import {
WallSection,
CeilingSection,
OpeningSection,
StairSection,
RoomSection,
} from "./ObjectInfoPanel";
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
* custom" ist abgeleitet aus „ist ein expliziter Wert gesetzt?", nicht
@@ -57,38 +47,11 @@ export function AttributesPanel() {
const sel = host.selection;
const { project } = host;
// Wandtyp-/Deckentyp-Wahl für das NEUE Bauteil (was gezeichnet wird) — früher
// in der Werkzeug-Sidebar (ToolsPanel), seit deren Entfernung hier. Erscheint
// nur bei aktivem Wand-/Decken-Werkzeug (unabhängig von einer Auswahl); Wand und
// Decke teilen sich denselben Aufbau-Typ (WallType) + Host-State.
const drawingComponent =
host.activeTool === "wall" || host.activeTool === "ceiling";
const typeSection = drawingComponent ? (
<>
<div className="attr-section-label">{t("attr.newComponent")}</div>
<div className="attr-grid">
<span className="attr-key">
{t(host.activeTool === "wall" ? "tool.wallType" : "tool.ceilingType")}
</span>
<span className="attr-val">
<Dropdown
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(v) => host.onActiveWallTypeId(v)}
options={project.wallTypes.map((wt) => ({ value: wt.id, label: wt.name }))}
/>
</span>
</div>
</>
) : null;
// Leerzustand: der Typ-Picker (falls ein Bauteil-Werkzeug aktiv ist), sonst ein
// kompakter Hinweis.
// Leerzustand: kompakter Hinweis, wenn nichts (Sinnvolles) selektiert ist.
if (sel === null) {
return (
<div className="attr-panel">
{typeSection}
{!typeSection && <div className="attr-empty">{t("attr.empty")}</div>}
<div className="attr-empty">{t("attr.empty")}</div>
</div>
);
}
@@ -117,6 +80,7 @@ export function AttributesPanel() {
onChange: (next: UiSource) => void,
) => (
<Dropdown
width={104}
value={ui}
disabled={!editable}
onChange={(v) => onChange(v as UiSource)}
@@ -146,7 +110,7 @@ export function AttributesPanel() {
) => (
<>
<span className="attr-key">{t(labelKey)}</span>
<span className="attr-val">
<span className="attr-val" style={{ gap: 6 }}>
{ui === "custom" && valueEditor}
{sourceSelect(editable, ui, onSource)}
</span>
@@ -195,15 +159,11 @@ export function AttributesPanel() {
return (
<div className="attr-panel">
{/* Typ-Picker für das neue Bauteil (nur bei aktivem Wand-/Decken-Werkzeug). */}
{typeSection}
{/* EIN durchgehendes Grid über alle Sektionen dieselben zwei Spalten
(Label | Wert) für Stift/Füllung/Maße, damit die Wertfelder überall
bündig in EINER Spalte stehen. Sektion-Balken spannen beide Spalten. */}
<div className="attr-grid">
{/* ── Stift/Strich ──────────────────────────────────────────── */}
<div className="attr-section-label">{t("attr.stroke")}</div>
<div className="attr-title">{t("attr.title")}</div>
{/* ── Stift/Strich ──────────────────────────────────────────────── */}
<div className="attr-section-label">{t("attr.stroke")}</div>
<div className="attr-grid">
{/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */}
<span className="attr-key">{t("attr.color")}</span>
<span className="attr-val">
@@ -248,6 +208,7 @@ export function AttributesPanel() {
onChange={(v) =>
host.onSetSelectionLineStyle(v === "__none__" ? undefined : v)
}
width={120}
options={[
{ value: "__none__", label: t("attr.lineStyle.none") },
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
@@ -257,13 +218,14 @@ export function AttributesPanel() {
<span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span>
)}
</span>
</div>
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────── */}
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/
Poché. Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) /
Nach Bauteil (erbt vom Component-Manager, Default) / eigener Wert. */}
<div className="attr-section-label">{t("attr.fill")}</div>
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────────── */}
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/Poché.
Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) / Nach Bauteil
(erbt vom Component-Manager, Default) / eigener Wert (Override). */}
<div className="attr-section-label">{t("attr.fill")}</div>
<div className="attr-grid">
{sourceRow(
"attr.foreground",
pocheEditable,
@@ -286,6 +248,7 @@ export function AttributesPanel() {
hatchUi,
onHatchSourceChange,
<Dropdown
width={104}
value={sel.fillHatchId ?? ""}
onChange={(v) => host.onSetSelectionFill(v || null)}
options={[
@@ -294,23 +257,16 @@ export function AttributesPanel() {
]}
/>,
)}
</div>
{/* ── Maße (informativ, aus der bbox) ───────────────────────── */}
<div className="attr-section-label">{t("attr.size")}</div>
{/* ── Maße (informativ, aus der bbox) ───────────────────────────── */}
<div className="attr-section-label">{t("attr.size")}</div>
<div className="attr-grid">
<span className="attr-key">{t("attr.width")}</span>
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
<span className="attr-key">{t("attr.height")}</span>
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
</div>
{/* Element-spezifische Abschnitte (aus ObjectInfo hierher verschoben)
nur der jeweils passende erscheint. Nutzen weiter die `objinfo-*`-
Darstellung + die Host-Handler. */}
{sel.wall && <WallSection wall={sel.wall} host={host} />}
{sel.ceiling && <CeilingSection ceiling={sel.ceiling} host={host} />}
{sel.opening && <OpeningSection opening={sel.opening} host={host} />}
{sel.stair && <StairSection stair={sel.stair} host={host} />}
{sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />}
</div>
);
}
+56 -90
View File
@@ -82,14 +82,40 @@ export function ObjectInfoPanel() {
host.onResizeSelection(nextWidth, nextHeight, anchor);
}
const wall = sel.wall;
const ceiling = sel.ceiling;
const opening = sel.opening;
const stair = sel.stair;
const room = sel.room;
return (
<div className="objinfo-panel">
{/* Kopfzeile: Typ + Kategorie. Die element-spezifischen Attribut-Abschnitte
(Wand/Decke/Öffnung/Treppe/Raum) liegen jetzt im Attribute-Panel;
ObjectInfo trägt nur noch Bezugspunkt + Masse. */}
{/* Kopfzeile: Typ + Kategorie. Bei einer Wand rechts oben das
Referenzlinie-Dropdown (wo die Achse über die Dicke liegt). */}
<div className="objinfo-head">
<span className="objinfo-kind">{t(`objinfo.kind.${sel.kind}`)}</span>
<span className="objinfo-cat">{sel.categoryCode}</span>
{wall ? (
<div className="objinfo-head-ref" title={t("objinfo.wall.refLine")}>
<span className="objinfo-head-ref-label">
{t("objinfo.wall.refLine")}
</span>
<Dropdown
value={wall.referenceLine}
onChange={(v) =>
host.onSetWallReferenceLine(v as WallReferenceLine)
}
options={[
{ value: "left", label: t("objinfo.wall.refLine.left") },
{ value: "center", label: t("objinfo.wall.refLine.center") },
{ value: "right", label: t("objinfo.wall.refLine.right") },
]}
title={t("objinfo.wall.refLine")}
width={120}
/>
</div>
) : (
<span className="objinfo-cat">{sel.categoryCode}</span>
)}
</div>
<div className="objinfo-sep" />
@@ -148,9 +174,21 @@ export function ObjectInfoPanel() {
value={height}
onCommit={(h) => commitSize(width, h)}
/>
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
die exportierten *Section-Komponenten). */}
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
{wall && <WallSection wall={wall} host={host} />}
{/* ── Decken-Abschnitt (nur bei genau einer Decke) ─────────────────── */}
{ceiling && <CeilingSection ceiling={ceiling} host={host} />}
{/* ── Öffnungs-Abschnitt (nur bei genau einer Öffnung) ─────────────── */}
{opening && <OpeningSection opening={opening} host={host} />}
{/* ── Treppen-Abschnitt (nur bei genau einer Treppe) ───────────────── */}
{stair && <StairSection stair={stair} host={host} />}
{/* ── Raum-Abschnitt (nur bei genau einem Raum) ────────────────────── */}
{room && <RoomSection room={room} roomId={sel.id} host={host} />}
</div>
);
}
@@ -159,7 +197,7 @@ export function ObjectInfoPanel() {
// Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche
// + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text-
// Stempel-Editor öffnet. Alle Werte/Setter über den Host.
export function RoomSection({
function RoomSection({
room,
roomId,
host,
@@ -231,7 +269,7 @@ export function RoomSection({
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
export function StairSection({
function StairSection({
stair,
host,
}: {
@@ -259,22 +297,6 @@ export function StairSection({
/>
</div>
{/* Referenzpunkt der Laufbreite (links/mitte/rechts). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.referenz")}</span>
<Dropdown
value={stair.referenz ?? "mitte"}
onChange={(v) => host.onSetStairReferenz(v as "links" | "mitte" | "rechts")}
options={[
{ value: "links", label: t("objinfo.stair.referenz.links") },
{ value: "mitte", label: t("objinfo.stair.referenz.mitte") },
{ value: "rechts", label: t("objinfo.stair.referenz.rechts") },
]}
title={t("objinfo.stair.referenz")}
width={130}
/>
</div>
{/* Laufrichtung (aufwärts/abwärts). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.direction")}</span>
@@ -347,7 +369,7 @@ export function StairSection({
// ── Öffnungs-Attribut-Abschnitt ──────────────────────────────────────────────
// Art (Fenster/Tür), Wirts-Wand, Position/Breite/Höhe/Brüstung, bei Türen
// zusätzlich Anschlag/Aufschlag/Richtung/Winkel; UK/OK read-only.
export function OpeningSection({
function OpeningSection({
opening,
host,
}: {
@@ -404,55 +426,16 @@ export function OpeningSection({
onCommit={(v) => host.onSetOpeningHeight(v)}
/>
{!isDoor && (
<>
<DimensionField
label={t("objinfo.opening.sill")}
value={opening.sillHeight}
onCommit={(v) => host.onSetOpeningSill(v)}
/>
{/* Fenster-spezifisch: Flügelanzahl (14). */}
<DimensionField
label={t("objinfo.opening.wingCount")}
value={opening.wingCount ?? 1}
min={1}
onCommit={(v) => host.onSetOpeningWingCount(Math.max(1, Math.min(4, Math.round(v))))}
/>
</>
<DimensionField
label={t("objinfo.opening.sill")}
value={opening.sillHeight}
onCommit={(v) => host.onSetOpeningSill(v)}
/>
)}
{/* Tür-spezifisch: Typ / Sturzlinien / Anschlag / Aufschlagseite / Richtung / Winkel. */}
{/* Tür-spezifisch: Anschlag / Aufschlagseite / Richtung / Winkel. */}
{isDoor && (
<>
{/* Tür-Typ (normal / Wandöffnung). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.doorType")}</span>
<Dropdown
value={opening.doorType ?? "normal"}
onChange={(v) => host.onSetOpeningDoorType(v as "normal" | "wandoeffnung")}
options={[
{ value: "normal", label: t("objinfo.opening.doorType.normal") },
{ value: "wandoeffnung", label: t("objinfo.opening.doorType.wandoeffnung") },
]}
title={t("objinfo.opening.doorType")}
width={130}
/>
</div>
{/* Sturzlinien-Darstellung (keine/innen/aussen/beide). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.lintelLines")}</span>
<Dropdown
value={opening.lintelLines ?? "beide"}
onChange={(v) => host.onSetOpeningLintelLines(v as "keine" | "innen" | "aussen" | "beide")}
options={[
{ value: "keine", label: t("objinfo.opening.lintelLines.keine") },
{ value: "innen", label: t("objinfo.opening.lintelLines.innen") },
{ value: "aussen", label: t("objinfo.opening.lintelLines.aussen") },
{ value: "beide", label: t("objinfo.opening.lintelLines.beide") },
]}
title={t("objinfo.opening.lintelLines")}
width={130}
/>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.hinge")}</span>
<Dropdown
@@ -519,7 +502,7 @@ export function OpeningSection({
// ── Decken-Attribut-Abschnitt ────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// OK-Bindung, Fläche. Alle Werte/Setter über den Host.
export function CeilingSection({
function CeilingSection({
ceiling,
host,
}: {
@@ -632,7 +615,7 @@ export function CeilingSection({
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
export function WallSection({
function WallSection({
wall,
host,
}: {
@@ -663,23 +646,6 @@ export function WallSection({
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.wall.section")}</div>
{/* Referenzlinie (wo die Achse über die Dicke liegt) früher im ObjectInfo-
Kopf, jetzt hier bei den Wand-Attributen. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.refLine")}</span>
<Dropdown
value={wall.referenceLine}
onChange={(v) => host.onSetWallReferenceLine(v as WallReferenceLine)}
options={[
{ value: "left", label: t("objinfo.wall.refLine.left") },
{ value: "center", label: t("objinfo.wall.refLine.center") },
{ value: "right", label: t("objinfo.wall.refLine.right") },
]}
title={t("objinfo.wall.refLine")}
width={120}
/>
</div>
{/* Aufbau-Typ-Segment. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
+40 -18
View File
@@ -13,12 +13,13 @@ import { t } from "../i18n";
import { usePanelHost } from "./host";
import type { ToolId } from "./host";
import { TOOL_ORDER, getTool } from "../tools/tools";
import { Dropdown } from "../ui/Dropdown";
// ── Icons ──────────────────────────────────────────────────────────────────
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der
// Zeile übernimmt.
export function ToolIcon({ id }: { id: ToolId }) {
function ToolIcon({ id }: { id: ToolId }) {
const common = {
width: 16,
height: 16,
@@ -110,20 +111,6 @@ export function ToolIcon({ id }: { id: ToolId }) {
<rect x="2.5" y="3.5" width="11" height="9" />
</svg>
);
case "circle":
// Kreis-Umriss.
return (
<svg {...common}>
<circle cx="8" cy="8" r="5.5" />
</svg>
);
case "arc":
// Offener Halbbogen.
return (
<svg {...common}>
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
</svg>
);
default:
return null;
}
@@ -161,9 +148,44 @@ export function ToolsPanel() {
);
})}
{/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI
Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker
erscheint dort bei aktivem Wand-/Decken-Werkzeug. */}
<div className="tools-sep" />
{/* Wandtyp-Auswahl — nur sichtbar, wenn das Wand-Werkzeug aktiv ist. */}
{host.activeTool === "wall" && (
<label className="tools-field">
<span>{t("tool.wallType")}</span>
<Dropdown
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(v) => host.onActiveWallTypeId(v)}
options={host.project.wallTypes.map((wt) => ({
value: wt.id,
label: wt.name,
}))}
/>
</label>
)}
{host.activeTool === "wall" && <div className="tools-sep" />}
{/* Deckentyp-Auswahl nur sichtbar, wenn das Decken-Werkzeug aktiv ist.
Nutzt denselben Aufbau-Typ (WallType) wie die Wand. */}
{host.activeTool === "ceiling" && (
<label className="tools-field">
<span>{t("tool.ceilingType")}</span>
<Dropdown
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(v) => host.onActiveWallTypeId(v)}
options={host.project.wallTypes.map((wt) => ({
value: wt.id,
label: wt.name,
}))}
/>
</label>
)}
{host.activeTool === "ceiling" && <div className="tools-sep" />}
</div>
);
}
-8
View File
@@ -236,12 +236,6 @@ export interface PanelHostValue {
onSetOpeningSwing: (swing: "left" | "right") => void;
/** Setzt die Aufschlagrichtung der Tür (innen/außen). */
onSetOpeningDir: (dir: "in" | "out") => void;
/** Setzt die Flügelanzahl des Fensters (14). */
onSetOpeningWingCount: (wingCount: number) => void;
/** Setzt den Tür-Typ (normal / Wandöffnung). */
onSetOpeningDoorType: (doorType: "normal" | "wandoeffnung") => void;
/** Setzt die Sturzlinien-Darstellung der Tür. */
onSetOpeningLintelLines: (lintelLines: "keine" | "innen" | "aussen" | "beide") => void;
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
/** Setzt die Grundform (gerade/L/Wendel). */
@@ -254,8 +248,6 @@ export interface PanelHostValue {
onSetStairRise: (totalRise: number) => void;
/** Setzt die Laufrichtung (aufwärts/abwärts). */
onSetStairUp: (up: boolean) => void;
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
/** Setzt den Raum-Namen. */
+6 -13
View File
@@ -31,10 +31,7 @@ import type {
* der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts
* sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze).
*/
// v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
// (Attribute volle linke Höhe statt Werkzeug-Palette oben).
export const LAYOUT_VERSION = 8;
export const LAYOUT_VERSION = 7;
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
const CURRENT_KEY = "cad.layout";
@@ -58,12 +55,9 @@ const NAMED_PREFIX = "cad.layouts.";
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
*/
// Ribbon-UI Phase 3: die Werkzeug-Sidebar ("tools") ist NICHT mehr im Default-
// Layout — die Werkzeuge liegen im Ribbon, der Wandtyp-/Deckentyp-Picker im
// Attribute-Panel. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar),
// nur nicht mehr per Default sichtbar → Attribute bekommt die volle linke Höhe.
const DEFAULT_LEFT_GROUPS: DockGroup[] = [
{ tabs: ["attributes"], activeTab: "attributes", weight: 1 },
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
];
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
@@ -92,10 +86,9 @@ function cloneGroups(groups: DockGroup[]): DockGroup[] {
// ── Standard ────────────────────────────────────────────────────────────────
/**
* Liefert den eingebauten Startzustand: links das Attribute-Panel (volle Höhe),
* rechts Objektinfo/Raumbilanz oben + Zeichnungsebenen/Ebenen/Umgebung unten.
* Die Werkzeuge liegen im Ribbon (keine Sidebar mehr). Jeder Aufruf erzeugt
* frische Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
* Liefert den eingebauten Startzustand: links zwei Gruppen (Werkzeuge oben,
* Zeichnungsebenen/Ebenen unten), rechts LEER. Jeder Aufruf erzeugt frische
* Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
*/
export function defaultLayout(): LayoutState {
return {
+1 -85
View File
@@ -131,8 +131,6 @@ export interface PlanViewHandle {
applyScale: (n: number) => void;
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz der Command-Engine). */
pxPerMeter: () => number;
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
viewCenterModel: () => Vec2;
}
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
@@ -633,11 +631,6 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
if (n > 0) setPaperScale(n);
},
pxPerMeter: () => currentPxPerMeter(),
viewCenterModel: () => {
// Mitte der frischen viewBox (aus Ref) → Modell (inverse toScreen).
const v = viewRef.current;
return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M };
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
@@ -1647,15 +1640,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich
// zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay.
// Sonst (reines SVG): alles außer den gebündelten 2D-Zeichenlinien.
// `drawingText` (importierter Einzeltext) rendert IMMER im SVG — der GL-
// wie der WASM-Renderer zeichnet ihn nicht. Der Raum-Stempel (`text`)
// bleibt: unter WASM rastert ihn render2d selbst (sonst doppelt).
(useGpuRenderer
? wantWasm
? p.kind !== "drawingText"
: p.kind !== "text" && p.kind !== "drawingText"
: p.kind === "line" && p.drawingId)
? null : (
(useGpuRenderer ? wantWasm || p.kind !== "text" : p.kind === "line" && p.drawingId) ? null : (
<PrimitiveShape
key={i}
p={p}
@@ -3080,74 +3065,5 @@ function renderPrimitive(
</text>
);
}
case "drawingText": {
// Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox-
// Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
const c = toScreen(p.at);
const fontSize = p.heightM * PX_PER_M;
// Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg.
const deg = -(p.angle * 180) / Math.PI;
return (
<text
x={c.x}
y={c.y}
fontSize={fontSize}
fontFamily="var(--font-ui, sans-serif)"
fill={p.color}
opacity={p.greyed ? 0.45 : undefined}
textAnchor="start"
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
style={{ pointerEvents: "none", userSelect: "none" }}
>
{p.text}
</text>
);
}
case "drawingCircle": {
// Echtes `<circle>` — rund bei jedem Zoom (kein Vieleck). Füllung optional.
const c = toScreen(p.center);
const r = p.r * PX_PER_M;
return (
<circle
cx={c.x}
cy={c.y}
r={r}
fill={p.fill}
stroke={p.stroke}
strokeWidth={weight(p.weightMm)}
strokeDasharray={dashOf(p.dash)}
vectorEffect={vfx}
/>
);
}
case "drawingArc": {
// Glatter SVG-Bogen. Winkel in Radiant (CCW im Modell); Screen-Y ist
// gespiegelt → ein modell-CCW-Bogen läuft am Bildschirm im Uhrzeigersinn
// (SVG sweep-flag 1). large-arc aus der Spannweite (>π).
const TAU = Math.PI * 2;
const rPx = p.r * PX_PER_M;
const from = toScreen({
x: p.center.x + p.r * Math.cos(p.a0),
y: p.center.y + p.r * Math.sin(p.a0),
});
const to = toScreen({
x: p.center.x + p.r * Math.cos(p.a1),
y: p.center.y + p.r * Math.sin(p.a1),
});
const sweep = (((p.a1 - p.a0) % TAU) + TAU) % TAU;
const largeArc = sweep > Math.PI ? 1 : 0;
const d = `M ${from.x} ${from.y} A ${rPx} ${rPx} 0 ${largeArc} 1 ${to.x} ${to.y}`;
return (
<path
d={d}
fill="none"
stroke={p.stroke}
strokeWidth={weight(p.weightMm)}
strokeDasharray={dashOf(p.dash)}
strokeLinecap={dashHasDot(p.dash) ? "round" : undefined}
vectorEffect={vfx}
/>
);
}
}
}
-166
View File
@@ -1,166 +0,0 @@
/**
* Unit-Tests für die Öffnungs-Symbole im Grundriss (generatePlan):
* Item 5 Fenster Flügel-Mittelpfosten: bei wingCount=3 werden genau 2
* Pfosten-Linien (Querlinien) erzeugt; wingCount=1/undefined keine.
* Item 6 Tür-Typ wandoeffnung": kein Türblatt, kein Schwenkbogen (arc);
* normal" (Default) zeichnet Blatt + Bogen wie bisher.
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Opening, Project, Wall } from "../model/types";
/** Minimalprojekt mit einer Wand + einer Öffnung, konfigurierbar. */
function projectWithOpening(opening: Opening): Project {
const wall: Wall = {
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 4, y: 0 },
wallTypeId: "aw",
height: 2.6,
};
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.3 }] }],
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 },
{ code: "21", name: "Öffnungen", color: "#0a0a0a", lw: 0.3, visible: true, locked: false },
],
walls: [wall],
doors: [],
openings: [opening],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
const visible = new Set(["20", "21"]);
/** Basis-Fenster (Brüstung 0, damit keine Sill-Linie mitzählt). */
const baseWindow: Opening = {
id: "F1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
position: 1.0,
width: 1.6,
height: 1.2,
sillHeight: 0,
};
/** Basis-Tür. */
const baseDoor: Opening = {
id: "T1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "door",
position: 1.0,
width: 0.9,
height: 2.0,
sillHeight: 0,
swing: "left",
hinge: "start",
};
/** Zählt Mittelpfosten-Linien (cls "window-mullion") im Plan. */
const mullionCount = (p: Project) =>
generatePlan(p, "eg", visible, undefined, "mittel").primitives.filter(
(pr) => pr.kind === "line" && pr.cls === "window-mullion",
).length;
describe("generatePlan — Fenster Flügel-Mittelpfosten (Item 5)", () => {
it("wingCount=3 erzeugt genau 2 Pfosten-Linien", () => {
expect(mullionCount(projectWithOpening({ ...baseWindow, wingCount: 3 }))).toBe(2);
});
it("wingCount=1 erzeugt keine Pfosten", () => {
expect(mullionCount(projectWithOpening({ ...baseWindow, wingCount: 1 }))).toBe(0);
});
it("ohne wingCount (Default) keine Pfosten — rückwärtskompatibel", () => {
expect(mullionCount(projectWithOpening({ ...baseWindow }))).toBe(0);
});
it("wingCount=4 erzeugt 3 Pfosten", () => {
expect(mullionCount(projectWithOpening({ ...baseWindow, wingCount: 4 }))).toBe(3);
});
it("Pfosten sind reine Linien ohne Strichmuster (kein dash)", () => {
const plan = generatePlan(projectWithOpening({ ...baseWindow, wingCount: 3 }), "eg", visible, undefined, "mittel");
const mullions = plan.primitives.filter(
(pr) => pr.kind === "line" && pr.cls === "window-mullion",
);
for (const m of mullions) {
expect((m as { dash?: number[] | null }).dash ?? null).toBeNull();
}
});
it("bei grob keine Pfosten (nur mittel/fein)", () => {
const plan = generatePlan(projectWithOpening({ ...baseWindow, wingCount: 3 }), "eg", visible, undefined, "grob");
const n = plan.primitives.filter((pr) => pr.kind === "line" && pr.cls === "window-mullion").length;
expect(n).toBe(0);
});
});
describe("generatePlan — Tür-Typ wandoeffnung (Item 6)", () => {
/** Zählt Bögen (arc-Primitive) im Plan. */
const arcCount = (p: Project, detail: "grob" | "mittel" | "fein" = "mittel") =>
generatePlan(p, "eg", visible, undefined, detail).primitives.filter((pr) => pr.kind === "arc").length;
/** Zählt Türblatt-Linien (cls "door-leaf"). */
const leafCount = (p: Project) =>
generatePlan(p, "eg", visible, undefined, "mittel").primitives.filter(
(pr) => pr.kind === "line" && pr.cls === "door-leaf",
).length;
it("wandoeffnung erzeugt KEINE Bögen (arc)", () => {
expect(arcCount(projectWithOpening({ ...baseDoor, doorType: "wandoeffnung" }))).toBe(0);
});
it("wandoeffnung erzeugt KEIN Türblatt", () => {
expect(leafCount(projectWithOpening({ ...baseDoor, doorType: "wandoeffnung" }))).toBe(0);
});
it("normal (Default) erzeugt Türblatt + Schwenkbogen", () => {
const normal = projectWithOpening({ ...baseDoor, doorType: "normal" });
expect(leafCount(normal)).toBe(1);
expect(arcCount(normal)).toBe(1);
});
it("ohne doorType (Default) verhält sich wie normal — rückwärtskompatibel", () => {
const def = projectWithOpening({ ...baseDoor });
expect(leafCount(def)).toBe(1);
expect(arcCount(def)).toBe(1);
});
});
/** Zählt Fenster-Anschlag-/Laibungsstriche (cls "window-frame") im Plan. */
function windowRevealCount(p: Project, detail: "grob" | "mittel" | "fein") {
return generatePlan(p, "eg", visible, undefined, detail).primitives.filter(
(pr) => pr.kind === "line" && pr.cls === "window-frame",
).length;
}
describe("generatePlan — Fenster Anschlag-Striche (Item 8)", () => {
it("erzeugt bei 'fein' zwei Laibungsstriche (je Pfosten einen)", () => {
expect(windowRevealCount(projectWithOpening({ ...baseWindow }), "fein")).toBe(2);
});
it("erzeugt bei 'mittel' KEINE (nur im Detailgrad fein)", () => {
expect(windowRevealCount(projectWithOpening({ ...baseWindow }), "mittel")).toBe(0);
});
});
+22 -135
View File
@@ -6,9 +6,8 @@
* Der Backstein-Kern des Abzweigs sticht durch den Nah-Putz der Durchgangs-
* wand und stoppt an deren Kern-Nahfläche (kein Durchlaufen bis zur Achse,
* kein Überlappen) "durchstechender Kern".
* Die Innenputz-Schichten des Abzweigs enden an der Nahfläche. Ist der
* Nah-Putz der Durchgangswand materialgleich, füllt er die Flanken durchgehend
* durchgehendes Putz-L OHNE Trennnaht (keine "layer-joint-l"-Linie).
* Die Innenputz-Schichten des Abzweigs enden weiterhin an der Nahfläche
* UND bekommen eine zusätzliche seitliche L-Linie (cls "layer-joint-l").
*/
import { describe, it, expect } from "vitest";
@@ -97,147 +96,35 @@ describe("generatePlan — materialbewusster T-Stoss (Poché-Bänder)", () => {
expect(brick.pts[3].y).toBeCloseTo(0.06, 6);
});
it("zeichnet KEINE L-Seitenlinie, wenn der getrimmte Putz materialgleich mit dem durchgehenden Nah-Putz der Durchgangswand ist (durchgehendes Putz-L ohne Naht)", () => {
it("zeichnet die L-Seitenlinie nur für die getrimmten Putzschichten, nicht für den durchlaufenden Kern", () => {
const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel");
const lLines = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "line" }> => p.kind === "line" && p.cls === "layer-joint-l",
);
// Alle drei Wände sind vom selben iw-Typ: der Innenputz des Abzweigs trifft
// an der Flanke auf den materialgleichen Innenputz der Durchgangswand (der
// spanCutout schneidet nur die Kernbreite aus, nicht die Putzflanken). Der
// Putz läuft dort durchgehend ums Eck (L) — eine Trennnaht würde eine falsche
// Materialgrenze suggerieren. Daher KEINE L-Linie.
expect(lLines.length).toBe(0);
// Genau zwei L-Linien (eine je Innenputz-Schicht); der Backstein-Kern
// bekommt keine (er verschmilzt, kein Sprung im Material).
expect(lLines.length).toBe(2);
// Jede L-Linie verläuft an der Rückgrat-Nahfläche (y=0.06, parallel zur
// x-Achse) und spannt genau die Breite EINER Putzschicht (0.015 m) auf.
for (const l of lLines) {
expect(l.a.y).toBeCloseTo(0.06, 6);
expect(l.b.y).toBeCloseTo(0.06, 6);
expect(Math.abs(l.a.x - l.b.x)).toBeCloseTo(0.015, 6);
}
});
it("bricht die Nah-Putz-Schicht der Durchgangswand am Durchstoss auf (Phase 1c)", () => {
// Am T-Knoten wird eine der kollinearen Hälften als Durchgangswand gewählt
// und bekommt eine Aussparung; ihr Innenputz-Band auf der Durchstoss-Seite
// (y∈[0.06,0.075]) wird über die Backstein-Breite [4.94,5.06] gekürzt (der
// Cutout reicht bis ans Knoten-Ende x=5). Backstein-Kern + Aussenputz bleiben
// voll (kein Überlappungsbereich).
it("lässt die kollineare Durchgangswand unverändert (keine L-Linien, normale Bänder)", () => {
const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel");
// Die Durchgangs-Hälfte ist die mit spanCutouts — finde sie über ihre
// gekürzten Bänder: sammle die Bänder beider Hälften.
const bandsOf = (id: string) =>
plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === id && p.stroke === "none",
);
const wa1 = bandsOf("WA1");
const wa2 = bandsOf("WA2");
// Beide Hälften haben weiterhin drei Bänder (der Cutout reicht bis ans
// Wandende, splittet also nicht in zwei, sondern kürzt nur).
expect(wa1.length).toBe(3);
expect(wa2.length).toBe(3);
// Genau EINE Hälfte trägt ein gekürztes Nah-Putz-Band: das innere Putzband
// (y-Nahfläche 0.075) endet nicht mehr am Knoten x=5, sondern bei 4.94 bzw.
// (für die andere Orientierung) bei 5.06.
// Nah-Putz-Band = Offsetzone y∈[0.06,0.075] (offA=0.06 → pts[0/1], offB=0.075
// → pts[2/3]); dessen X-Kanten verraten die Kürzung.
const isNearPlaster = (b: { pts: { y: number }[] }) => {
const ys = b.pts.map((p) => p.y);
return Math.abs(Math.min(...ys) - 0.06) < 1e-6 && Math.abs(Math.max(...ys) - 0.075) < 1e-6;
};
const nearPlasterEnds = [...wa1, ...wa2]
.filter(isNearPlaster)
.flatMap((b) => b.pts.map((p) => p.x));
// Mindestens eine Kante liegt bei 4.94 oder 5.06 (Backstein-Breite ab Knoten).
const shortened = nearPlasterEnds.some(
(x) => Math.abs(x - 4.94) < 1e-6 || Math.abs(x - 5.06) < 1e-6,
);
expect(shortened).toBe(true);
});
});
/** Mittelspannen-Variante: EINE Durchgangswand W, Abzweig WB trifft mittig. */
function midSpanProject(): Project {
const base = tJunctionProject();
const wall = (id: string, start: Wall["start"], end: Wall["end"]): Wall => ({
id,
type: "wall",
floorId: "eg",
categoryCode: "20",
start,
end,
wallTypeId: "iw",
height: 2.6,
});
base.walls = [
wall("W", { x: 0, y: 0 }, { x: 10, y: 0 }),
wall("WB", { x: 4, y: 3 }, { x: 4, y: 0 }),
];
return base;
}
describe("generatePlan — Durchgangswand-Aussparung am Mittelspann-Stoss (Phase 1c)", () => {
it("splittet das Innenputz-Band der Durchgangswand über dem Durchstoss in zwei Teil-Bänder", () => {
const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel");
const bands = plan.primitives.filter(
const wa1Bands = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === "W" && p.stroke === "none",
p.kind === "polygon" && p.wallId === "WA1" && p.stroke === "none",
);
// Innenputz-Band (Nahfläche y=0.075) wird über [3.94,4.06] entfernt → ZWEI
// Teil-Bänder statt einem. Backstein (2 Bänder? nein 1) + Aussenputz bleiben
// je ein Band → insgesamt 4 Bänder (1 Aussenputz + 1 Backstein + 2 Innenputz).
const innerPlaster = bands.filter((b) => {
const ys = b.pts.map((p) => p.y);
return Math.abs(Math.min(...ys) - 0.06) < 1e-6 && Math.abs(Math.max(...ys) - 0.075) < 1e-6;
});
expect(innerPlaster.length).toBe(2);
// Das linke Teil-Band endet bei x=3.94, das rechte beginnt bei x=4.06.
const xs = innerPlaster.flatMap((b) => b.pts.map((p) => p.x)).sort((a, b) => a - b);
// Erwartete Kanten: 0, 3.94 (linkes Band) und 4.06, 10 (rechtes Band).
expect(xs.some((x) => Math.abs(x - 3.94) < 1e-6)).toBe(true);
expect(xs.some((x) => Math.abs(x - 4.06) < 1e-6)).toBe(true);
// Insgesamt bleiben Backstein-Kern (y-Nahfläche 0.06) und Aussenputz (0.06)
// ganze Bänder → 4 Bänder gesamt.
expect(bands.length).toBe(4);
});
it("unterbricht die Schichtfuge im Merge-Bereich (keine Trennlinie an der Verschmelzungsfläche)", () => {
const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel");
// Fugen der Durchgangswand W: layer-joint-Linien parallel zur x-Achse.
const joints = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "line" }> =>
p.kind === "line" && p.cls === "layer-joint",
);
// Die Fuge an der Rückgrat-Nahfläche y=0.06 (Merge-Kontakt) darf NICHT über
// den Durchstoss [3.94,4.06] laufen: kein Fugen-Segment enthält einen Punkt
// mit y≈0.06 und x im offenen Intervall (3.94, 4.06).
for (const l of joints) {
if (Math.abs(l.a.y - 0.06) < 1e-6 && Math.abs(l.b.y - 0.06) < 1e-6) {
const lo = Math.min(l.a.x, l.b.x);
const hi = Math.max(l.a.x, l.b.x);
// Das Segment darf den Durchstoss nicht überspannen/betreten.
expect(lo >= 4.06 - 1e-9 || hi <= 3.94 + 1e-9).toBe(true);
}
expect(wa1Bands.length).toBe(3);
// Kein Cut an ihrem gemeinsamen Knoten-Ende (endCut bleibt null, s. joins.ts) →
// alle drei Bänder enden exakt am Achsenpunkt (5,0), keine Gehrung nötig.
for (const b of wa1Bands) {
expect(b.pts[1].x).toBeCloseTo(5, 6);
}
// Und es MUSS Fugen-Segmente auf beiden Seiten geben (die Fuge existiert weiter,
// nur unterbrochen).
const at006 = joints.filter(
(l) => Math.abs(l.a.y - 0.06) < 1e-6 && Math.abs(l.b.y - 0.06) < 1e-6,
);
expect(at006.some((l) => Math.max(l.a.x, l.b.x) <= 3.94 + 1e-6)).toBe(true);
expect(at006.some((l) => Math.min(l.a.x, l.b.x) >= 4.06 - 1e-6)).toBe(true);
});
it("unterbricht die Nahflächen-Umrisslinie über dem Durchstoss", () => {
const plan = generatePlan(midSpanProject(), "eg", visible, undefined, "mittel");
// Die separat gezeichneten Nahkanten-Segmente (cls "wall-outline") liegen bei
// y=0.075 und lassen [3.94,4.06] frei.
const outline = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "line" }> =>
p.kind === "line" && p.cls === "wall-outline",
);
expect(outline.length).toBe(2); // links + rechts vom Durchstoss
for (const l of outline) {
expect(Math.abs(l.a.y - 0.075) < 1e-6).toBe(true);
expect(Math.abs(l.b.y - 0.075) < 1e-6).toBe(true);
}
// Ein Segment endet bei 3.94, das andere beginnt bei 4.06.
const allX = outline.flatMap((l) => [l.a.x, l.b.x]);
expect(allX.some((x) => Math.abs(x - 3.94) < 1e-6)).toBe(true);
expect(allX.some((x) => Math.abs(x - 4.06) < 1e-6)).toBe(true);
});
});
+90 -601
View File
@@ -38,20 +38,12 @@ import { docFromText } from "../text/richText";
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
import type { RoomStampLine } from "../model/roomStamp";
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
import type { SpiralOutlineSegments } from "../geometry/stair";
import {
doorSymbol,
openingInterval,
openingJambs,
wallAxisFrame,
windowSymbol,
} from "../geometry/opening";
import { stairGeometry, stairCut } from "../geometry/stair";
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
import {
add,
along,
clippedBand,
dot,
leftNormal,
lineIntersect,
normalize,
@@ -98,10 +90,6 @@ const MONO_INK = "#111111";
const LAYER_LINE_MM = 0.02;
/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */
const SYMBOL_HAIRLINE_MM = 0.02;
/** Strichmuster (mm Papier) für ÜBERKOPF-Ansichtslinien: Bauteile über der
* Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
* BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
const OVERHEAD_DASH: number[] = [0.4, 0.25];
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
grob: 1.6, // dickere Sammellinie
@@ -272,8 +260,6 @@ export type Primitive =
dash?: number[] | null;
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
openingId?: string;
/** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */
stairId?: string;
greyed?: boolean;
}
| {
@@ -298,52 +284,6 @@ export type Primitive =
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
roomId?: string;
greyed?: boolean;
}
| {
// Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`,
// z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt
// er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert.
kind: "drawingText";
/** Ankerpunkt im Modell (Meter). */
at: Vec2;
text: string;
/** Schrifthöhe in Modell-Metern. */
heightM: number;
/** Drehung in RADIANT (CCW im Modell). */
angle: number;
color: string;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */
drawingId?: string;
greyed?: boolean;
}
| {
// Glatter Kreis (aus Drawing2D `{shape:"circle"}`, z. B. DXF-CIRCLE) — als
// echtes `<circle>` gerendert (rund bei jedem Zoom), nicht als Vieleck.
kind: "drawingCircle";
center: Vec2;
/** Radius in Modell-Metern. */
r: number;
/** Vollton-Füllfarbe oder "none". */
fill: string;
stroke: string;
weightMm: number;
dash?: number[] | null;
drawingId?: string;
greyed?: boolean;
}
| {
// Glatter Bogen (aus Drawing2D `{shape:"arc"}`, z. B. DXF-ARC) — als
// SVG-Bogenpfad gerendert; Winkel in RADIANT (CCW im Modell).
kind: "drawingArc";
center: Vec2;
r: number;
a0: number;
a1: number;
stroke: string;
weightMm: number;
dash?: number[] | null;
drawingId?: string;
greyed?: boolean;
};
/**
@@ -634,17 +574,16 @@ export function generatePlan(
visibleCodes.has(c.categoryCode) &&
categoryDisplay(c.categoryCode).render,
);
// Wand-Footprints für das Verdecken der (darunterliegenden) Decken-Umrisse:
// die Decke liegt als Slab über der Schnittebene, ihre Umrisslinie soll dort,
// wo eine Wand darüber steht, NICHT durch die Wand-Poché schlagen. Nur nötig,
// wenn es überhaupt Decken gibt.
const wallFootprints = ceilings.length ? buildWallFootprints(project, walls) : [];
for (const ceiling of ceilings) {
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
const category = catByCode.get(ceiling.categoryCode);
// Kein lwMm mehr: der Decken-Umriss ist eine feste Überkopf-Haarlinie
// (gestrichelt), unabhängig von der Kategorie-Strichstärke.
addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints);
const lwMm = resolveStrokeWeight(
ceiling.strokeWeight,
ceiling.strokeWeightSource,
category,
lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM,
);
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category);
}
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
@@ -677,14 +616,7 @@ export function generatePlan(
const openings = openingsOfWall(project, wall.id);
const gaps = wallGaps(wall, doors, openings);
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
// Schnitt vs. Ansicht nach Schnitthöhe (BIM-Standard): erreicht die Wand die
// Grundriss-Schnittebene nicht (Höhe < Schnitthöhe des Geschosses, z. B. eine
// Brüstung/ein Podestrand), wird sie nur als Ansichts-Umriss gezeichnet —
// keine Schnitt-Poché. Normale Wände (Höhe ≥ Schnitthöhe) sind unberührt.
const wallViewOnly = wall.height < (floor?.cutHeight ?? 1.0);
addWallPoche(
primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory, wallViewOnly,
);
addWallPoche(primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory);
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
@@ -1053,48 +985,20 @@ function addDrawing2D(
seg(c4, c1);
break;
}
case "circle":
// Echtes Kreis-Primitiv → glattes `<circle>` (rund bei jedem Zoom) mit
// optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation.
out.push({
kind: "drawingCircle",
center: g.center,
r: g.r,
fill: background ?? d.fillColor ?? "none",
stroke: color,
weightMm,
dash,
greyed,
drawingId: d.id,
});
break;
case "arc":
// Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant).
out.push({
kind: "drawingArc",
center: g.center,
r: g.r,
a0: g.a0,
a1: g.a1,
stroke: color,
weightMm,
dash,
greyed,
drawingId: d.id,
});
break;
case "text":
out.push({
kind: "drawingText",
at: g.at,
text: g.text,
heightM: g.height,
angle: g.angle,
color,
greyed,
drawingId: d.id,
});
case "circle": {
// Kreis als geschlossene Polygon-Approximation (N Segmente) — nutzt die
// vorhandene Füll-/Strich-Pipeline, ohne ein eigenes Kreis-Primitiv.
const N = 64;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: g.center.x + g.r * Math.cos(t), y: g.center.y + g.r * Math.sin(t) });
}
fillPoly(pts);
for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]);
break;
}
// arc/text: Primitive-Erweiterungen folgen später.
default:
break;
}
@@ -1141,120 +1045,6 @@ export function wallGaps(
return gaps.sort((a, b) => a.from - b.from);
}
/** Toleranz für die Achsen-Intervall-Arithmetik der Durchgangswand-Aussparungen. */
const CUTOUT_EPS = 1e-9;
/**
* Zieht die Loch-Intervalle `holes` vom Segment `[s,e]` ab und liefert die
* verbleibenden (gezeichneten) Teil-Intervalle in Achsen-Metern. Löcher werden
* auf `[s,e]` geklemmt, sortiert und vereinigt; leeres Ergebnis heisst ganz
* ausgespart". Ist `holes` leer, kommt exakt `[[s,e]]` zurück (unverändertes
* Verhalten für Wände ohne Aussparung).
*/
function subtractIntervals(
s: number,
e: number,
holes: Array<[number, number]>,
): Array<[number, number]> {
const clipped = holes
.map(([a, b]): [number, number] => [
Math.max(s, Math.min(a, b)),
Math.min(e, Math.max(a, b)),
])
.filter(([a, b]) => b > a + CUTOUT_EPS)
.sort((p, q) => p[0] - q[0]);
const parts: Array<[number, number]> = [];
let cursor = s;
for (const [a, b] of clipped) {
if (a > cursor + CUTOUT_EPS) parts.push([cursor, a]);
if (b > cursor) cursor = b;
}
if (cursor < e - CUTOUT_EPS) parts.push([cursor, e]);
return parts;
}
/**
* Wand-Footprint als orientiertes Rechteck (OBB) für das Verdecken der
* darunterliegenden Decke im Grundriss: Achs-Startpunkt + Einheits-Richtung `u`
* und -Normale `m`, Halbdicke, Referenz-Querversatz (WandachseBandmitte) und
* Achslänge. Die u-Ausdehnung wird beim Test an beiden Enden um die Halbdicke
* verlängert, damit auch die Eck-Überlappe (Gehrungen) sicher abgedeckt sind.
*/
interface WallFootprint {
a: Vec2;
u: Vec2;
m: Vec2;
len: number;
half: number;
refOff: number;
}
/** Baut die Wand-Footprints (OBBs) aus den gerenderten Wänden. Nicht auflösbarer
* Wandtyp Default-Dicke 0.2 m (wie der Vollkörper-Fallback im 3D-Emitter). */
function buildWallFootprints(project: Project, walls: Wall[]): WallFootprint[] {
const out: WallFootprint[] = [];
for (const w of walls) {
let t = 0.2;
try {
t = wallTypeThickness(getWallType(project, w));
} catch {
// Default-Dicke behalten.
}
const u = normalize(sub(w.end, w.start));
out.push({
a: w.start,
u,
m: leftNormal(u),
len: Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y),
half: t / 2,
refOff: wallReferenceOffset(w, t),
});
}
return out;
}
/**
* Parametrisches Intervall [t0,t1] [0,1], über das die Strecke `pa→pb`
* INNERHALB des Wand-Footprints liegt (also von der Wand verdeckt). `null`, wenn
* die Strecke den Footprint nicht schneidet. OBB-Test über zwei Slabs: Projektion
* auf die Achse `u` (Bereich [half, len+half]) und auf die Normale `m` (Bereich
* [refOffhalf, refOff+half]); das Schnitt-Intervall beider Slabs [0,1] ist die
* verdeckte Strecke.
*/
function segmentInsideWallBand(
pa: Vec2,
pb: Vec2,
fp: WallFootprint,
): [number, number] | null {
const d = sub(pb, pa);
const rel = sub(pa, fp.a);
const clipSlab = (
f0: number,
fd: number,
lo: number,
hi: number,
): [number, number] | null => {
if (Math.abs(fd) < 1e-12)
return f0 >= lo - CUTOUT_EPS && f0 <= hi + CUTOUT_EPS ? [0, 1] : null;
let ta = (lo - f0) / fd;
let tb = (hi - f0) / fd;
if (ta > tb) [ta, tb] = [tb, ta];
return [ta, tb];
};
const su = clipSlab(dot(rel, fp.u), dot(d, fp.u), -fp.half, fp.len + fp.half);
if (!su) return null;
const sm = clipSlab(
dot(rel, fp.m),
dot(d, fp.m),
fp.refOff - fp.half,
fp.refOff + fp.half,
);
if (!sm) return null;
const t0 = Math.max(0, su[0], sm[0]);
const t1 = Math.min(1, su[1], sm[1]);
return t1 > t0 + CUTOUT_EPS ? [t0, t1] : null;
}
function addWallPoche(
out: Primitive[],
project: Project,
@@ -1265,10 +1055,6 @@ function addWallPoche(
detail: DetailLevel,
wallLwMm: number,
category: LayerCategory | undefined,
// Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei
// 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE
// Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`.
viewOnly: boolean,
): void {
const wt = getWallType(project, wall);
const total = wallTypeThickness(wt);
@@ -1309,13 +1095,6 @@ function addWallPoche(
// (höchste Priorität) — weiß, bzw. schwarz falls dessen Muster "solid" ist.
const simpleFill = backbonePocheFill(project, wt);
// Durchgangswand-Aussparungen (Phase 1c): über diese Achsen-Intervalle × Quer-
// Offsetbereiche wird die Nah-Putz-Zone der Durchgangswand echt ausgeschnitten
// (der Abzweig-Kern sticht durch). `offA` ist stets die Nahfläche, `offB` die
// Rückgrat-Nahfläche (siehe SpanCutout in model/joins.ts). Leer ⇒ heutiges
// Verhalten (Wand ohne T-Durchstoss).
const spanCutouts = cuts.spanCutouts ?? [];
// Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten).
// clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end)
// ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche.
@@ -1337,24 +1116,6 @@ function addWallPoche(
const startCut = s <= 1e-6 ? cuts.startCut : null;
const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null;
if (viewOnly) {
// Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke
// als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik
// bleibt dieselbe wie im Schnittfall (nur eben ohne Poché).
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
fill: "none",
stroke,
strokeWidthMm: layerLineMm,
hatch: NO_HATCH,
greyed,
noStrokeEdges: joinEdges(startCut, endCut),
wallId,
});
continue;
}
if (detail === "grob") {
// Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
out.push({
@@ -1376,15 +1137,6 @@ function addWallPoche(
// auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede
// innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet.
const bandJoinEdges = joinEdges(startCut, endCut);
// Nur die Aussparungen, die dieses Segment [s,e] entlang der Achse berühren.
const segCutouts = spanCutouts.filter(
(c) =>
Math.min(c.from, c.to) < e - CUTOUT_EPS &&
Math.max(c.from, c.to) > s + CUTOUT_EPS,
);
// Punkt entlang der Wandachse bei Achsen-Meter `d` (p1/p2 an den Rändern).
const axisPt = (d: number): Vec2 =>
d === s ? p1 : d === e ? p2 : along(wall.start, wall.end, d);
// Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts):
// liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den
// einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe
@@ -1409,38 +1161,20 @@ function addWallPoche(
const layerSolid = layerHatch.pattern === "solid";
const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null;
const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null;
// Durchgangswand-Aussparung: liegt der Quer-Offsetbereich dieses Bandes
// [off, off+thickness] im Nah-Putz-Bereich einer Aussparung, wird das Band
// entlang der Achse in Teil-Bänder VOR/NACH dem Intervall zerlegt (das
// Durchstoss-Stück entfällt). Ohne Überlappung bleibt genau ein Band
// [s,e] (unverändertes Verhalten).
const bandLo = off;
const bandHi = off + layer.thickness;
const bandHoles: Array<[number, number]> = [];
for (const c of segCutouts) {
const cLo = Math.min(c.offA, c.offB);
const cHi = Math.max(c.offA, c.offB);
if (bandLo < cHi - CUTOUT_EPS && bandHi > cLo + CUTOUT_EPS)
bandHoles.push([c.from, c.to]);
}
for (const [a, b] of subtractIntervals(s, e, bandHoles)) {
const sc = a <= s + CUTOUT_EPS ? layerStartCut : null;
const ec = b >= e - CUTOUT_EPS ? layerEndCut : null;
out.push({
kind: "polygon",
pts: clippedBand(axisPt(a), axisPt(b), off, off + layer.thickness, sc, ec),
fill: layerSolid ? HATCH_INK : HATCH_PAPER,
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
stroke: "none",
strokeWidthMm: 0,
hatch: layerHatch,
greyed,
// Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos).
noStrokeEdges: bandJoinEdges,
wallId,
});
}
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, off, off + layer.thickness, layerStartCut, layerEndCut),
fill: layerSolid ? HATCH_INK : HATCH_PAPER,
// Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht
// am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten).
stroke: "none",
strokeWidthMm: 0,
hatch: layerHatch,
greyed,
// Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos).
noStrokeEdges: bandJoinEdges,
wallId,
});
// L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine
// verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt
// zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie
@@ -1488,95 +1222,43 @@ function addWallPoche(
// Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB
// dieser Wand und bleibt daher an der bisherigen Nahfläche.
if (li < wt.layers.length - 1) {
// clippedBand(p1, p2, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
// (Ende), beide am Cut abgeschnitten wie die Bandlängskante.
const jq = clippedBand(p1, p2, off, off, startCut, endCut);
const jls = layer.jointLineStyleId
? project.lineStyles.find((l) => l.id === layer.jointLineStyleId)
: undefined;
// Durchgangswand-Aussparung: liegt die Fuge im (geschlossenen) Nah-Putz-
// Bereich einer Aussparung — inkl. der Rückgrat-Nahfläche (Merge-Kontakt)
// —, wird sie über das Durchstoss-Intervall unterbrochen. So bleibt an der
// Verschmelzungsfläche keine Trennlinie stehen.
const fugeHoles: Array<[number, number]> = [];
for (const c of segCutouts) {
const cLo = Math.min(c.offA, c.offB);
const cHi = Math.max(c.offA, c.offB);
if (off >= cLo - CUTOUT_EPS && off <= cHi + CUTOUT_EPS)
fugeHoles.push([c.from, c.to]);
}
for (const [a, b] of subtractIntervals(s, e, fugeHoles)) {
const sc = a <= s + CUTOUT_EPS ? startCut : null;
const ec = b >= e - CUTOUT_EPS ? endCut : null;
// clippedBand(pa, pb, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd,
// edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1
// (Ende), beide am Cut abgeschnitten wie die Bandlängskante.
const jq = clippedBand(axisPt(a), axisPt(b), off, off, sc, ec);
out.push({
kind: "line",
a: jq[0],
b: jq[1],
cls: "layer-joint",
weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
color: jls ? jls.color : stroke,
dash: jls ? jls.dash : null,
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
// Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen.
motif: jls?.kind === "custom" ? jls.motif : undefined,
greyed,
});
}
out.push({
kind: "line",
a: jq[0],
b: jq[1],
cls: "layer-joint",
weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm,
color: jls ? jls.color : stroke,
dash: jls ? jls.dash : null,
// Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen.
zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined,
// Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen.
motif: jls?.kind === "custom" ? jls.motif : undefined,
greyed,
});
}
}
// Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung),
// damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs-
// Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe.
// Bei einer Durchgangswand-Aussparung wird zusätzlich die NAHFLÄCHEN-Kante
// (die Längskante auf der Durchstoss-Seite) über [from,to] strokelos gemacht
// und als separate Segmente vor/nach dem Durchstoss gezeichnet — sonst liefe
// die Umrisslinie über die Durchstoss-Breite.
const offLo = refOff - total / 2;
const offHi = refOff + total / 2;
const holesHi: Array<[number, number]> = [];
const holesLo: Array<[number, number]> = [];
for (const c of segCutouts) {
// `offA` ist stets die Nahfläche (Wandoberfläche); ordne die Aussparung der
// passenden Längskante (offHi bzw. offLo) zu.
if (Math.abs(c.offA - offHi) < 1e-6) holesHi.push([c.from, c.to]);
else if (Math.abs(c.offA - offLo) < 1e-6) holesLo.push([c.from, c.to]);
}
const outlineNoStroke = [...bandJoinEdges];
// clippedBand-Kantenreihenfolge [A.start(offLo), A.end(offLo), B.end(offHi),
// B.start(offHi)]: Kante 0 ist die offLo-Längskante, Kante 2 die offHi-Längskante.
if (holesHi.length) outlineNoStroke.push(2);
if (holesLo.length) outlineNoStroke.push(0);
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut),
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
fill: "none",
stroke,
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
noStrokeEdges: outlineNoStroke,
noStrokeEdges: bandJoinEdges,
wallId,
});
const drawNearEdge = (edgeOff: number, holes: Array<[number, number]>): void => {
for (const [a, b] of subtractIntervals(s, e, holes)) {
const sc = a <= s + CUTOUT_EPS ? startCut : null;
const ec = b >= e - CUTOUT_EPS ? endCut : null;
const jq = clippedBand(axisPt(a), axisPt(b), edgeOff, edgeOff, sc, ec);
out.push({
kind: "line",
a: jq[0],
b: jq[1],
cls: "wall-outline",
weightMm: outlineMm,
color: stroke,
greyed,
});
}
};
if (holesHi.length) drawNearEdge(offHi, holesHi);
if (holesLo.length) drawNearEdge(offLo, holesLo);
}
}
@@ -1691,36 +1373,29 @@ function addOpeningSymbol(
if (o.kind === "door") {
const sym = doorSymbol(wall, o);
if (!sym) return;
// Tür-Typ „wandoeffnung": reiner Wanddurchbruch — KEIN Türblatt, KEIN
// Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der
// ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet).
// Entspricht `oeff_tuer_typ == "wandoeffnung"` im Rhino-Plugin.
const isWandoeffnung = (o.doorType ?? "normal") === "wandoeffnung";
if (!isWandoeffnung) {
// Türblatt (alle Stufen).
// Türblatt (alle Stufen).
out.push({
kind: "line",
a: sym.hinge,
b: sym.openEnd,
cls: "door-leaf",
weightMm: SYMBOL_HAIRLINE_MM,
greyed,
openingId: o.id,
});
// mittel + fein: Schwenkbogen von geschlossen → offen.
if (detail !== "grob") {
out.push({
kind: "line",
a: sym.hinge,
b: sym.openEnd,
cls: "door-leaf",
kind: "arc",
center: sym.hinge,
from: sym.closedEnd,
to: sym.openEnd,
r: sym.radius,
cls: "door-swing",
weightMm: SYMBOL_HAIRLINE_MM,
greyed,
openingId: o.id,
});
// mittel + fein: Schwenkbogen von geschlossen → offen.
if (detail !== "grob") {
out.push({
kind: "arc",
center: sym.hinge,
from: sym.closedEnd,
to: sym.openEnd,
r: sym.radius,
cls: "door-swing",
weightMm: SYMBOL_HAIRLINE_MM,
greyed,
openingId: o.id,
});
}
}
// fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
if (detail === "fein") {
@@ -1737,57 +1412,6 @@ function addOpeningSymbol(
});
}
}
// Sturzlinien (SIA, gestrichelt) — Überkopf-Projektion des Sturzes quer über
// die Öffnung an der Wand-Innen-/Aussenkante. Entspricht `_make_tuer_sturz_curves`
// im Rhino-Plugin. Default „beide" wenn nicht explizit gesetzt.
const lintelMode = o.lintelLines ?? "beide";
if (lintelMode !== "keine") {
const jambs = openingJambs(wall, o);
if (jambs) {
const { n } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total);
const halfD = total / 2;
const outerOff = refOff + halfD;
const innerOff = refOff - halfD;
// Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention).
const dash: number[] = [0.18, 0.09];
const strokeColor = o.color ?? POCHE_STROKE;
/** Linie quer über die Öffnung am gegebenen Perp-Offset. */
const lintelLine = (perpOff: number) => ({
a: add(jambs.jambStart, scale(n, perpOff)),
b: add(jambs.jambEnd, scale(n, perpOff)),
});
if (lintelMode === "aussen" || lintelMode === "beide") {
const seg = lintelLine(outerOff);
out.push({
kind: "line" as const,
a: seg.a,
b: seg.b,
cls: "door-lintel",
weightMm: SYMBOL_HAIRLINE_MM,
dash,
color: strokeColor,
greyed,
openingId: o.id,
});
}
if (lintelMode === "innen" || lintelMode === "beide") {
const seg = lintelLine(innerOff);
out.push({
kind: "line" as const,
a: seg.a,
b: seg.b,
cls: "door-lintel",
weightMm: SYMBOL_HAIRLINE_MM,
dash,
color: strokeColor,
greyed,
openingId: o.id,
});
}
}
}
return;
}
@@ -1807,20 +1431,6 @@ function addOpeningSymbol(
greyed,
openingId: o.id,
});
// Flügel-Mittelpfosten (wingCount 1) — reine Querlinien innerhalb des
// Rahmens (kein Strichmuster). Nur mittel/fein; entfällt bei grob/schema.
for (const [a, b] of sym.mullionLines) {
out.push({
kind: "line",
a,
b,
cls: "window-mullion",
weightMm: SYMBOL_HAIRLINE_MM,
color: o.color ?? POCHE_STROKE,
greyed,
openingId: o.id,
});
}
}
for (const [a, b] of sym.glassLines) {
out.push({
@@ -1833,50 +1443,6 @@ function addOpeningSymbol(
openingId: o.id,
});
}
// fein: Anschlag-/Laibungsstriche an beiden Pfosten (kurz quer zur Wand) —
// analog zur Tür, damit Fenster im Detailplan gleich „fertig" wirken (bisher
// hatten nur Türen sie). Haarlinie, 6 cm Andeutung je Seite.
if (detail === "fein") {
const jambs = openingJambs(wall, o);
const dir = sub(wall.end, wall.start);
const len = Math.hypot(dir.x, dir.y);
if (jambs && len > 1e-9) {
const n = { x: -dir.y / len, y: dir.x / len };
const reveal = 0.06;
for (const jamb of [jambs.jambStart, jambs.jambEnd]) {
out.push({
kind: "line",
a: add(jamb, scale(n, reveal)),
b: add(jamb, scale(n, -reveal)),
cls: "window-frame",
weightMm: SYMBOL_HAIRLINE_MM,
color: o.color ?? POCHE_STROKE,
greyed,
openingId: o.id,
});
}
}
}
// Brüstungslinie (gepunktet) — zeigt die Brüstung im Grundriss auf der Wandachse.
// Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in
// `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin.
if (o.sillHeight > 0) {
const jambs = openingJambs(wall, o);
if (jambs) {
out.push({
kind: "line",
a: jambs.jambStart,
b: jambs.jambEnd,
cls: "window-sill",
weightMm: SYMBOL_HAIRLINE_MM,
// Gepunktet (dot-dash) wie in _make_oeffnung_preview DrawDottedLine.
dash: [0.04, 0.08],
color: o.color ?? POCHE_STROKE,
greyed,
openingId: o.id,
});
}
}
}
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
@@ -1928,14 +1494,15 @@ function addCeilingPoche(
ceiling: Ceiling,
greyed: boolean,
detail: DetailLevel,
lwMm: number,
category: LayerCategory | undefined,
wallFootprints: WallFootprint[],
): void {
const pts = ceiling.outline;
if (pts.length < 3) return;
const wt = getCeilingType(project, ceiling);
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
const stroke = ceiling.color ?? POCHE_STROKE;
const outlineMm = lwMm * OUTLINE_DETAIL_FACTOR[detail];
const ceilingId = ceiling.id;
// Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss
@@ -1961,50 +1528,27 @@ function addCeilingPoche(
resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource),
)
: NO_HATCH;
// Gefüllte Fläche OHNE eigenen Umriss (stroke:"none"): die Decke liegt unter
// der Wand-Poché; ihre Kante wird separat als verdeckungs-geclippte Linie
// gezeichnet (s. u.), damit sie nicht durch die darüberstehenden Wände schlägt.
// Trägt weiterhin die `ceilingId` für die Links-Klick-Auswahl der Fläche.
out.push({
kind: "polygon",
pts,
fill: comp ? pocheFill(hatch.pattern) : "none",
stroke: "none",
strokeWidthMm: 0,
stroke,
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
hatch,
greyed,
ceilingId,
});
// Kräftige Umrisslinie NUR dort, wo keine Wand die Decke verdeckt: jede
// Umriss-Kante gegen die Wand-Footprints clippen und die freien Teilstücke als
// Linien ausgeben. Liegt die Decke deckungsgleich unter den Wänden (Regelfall:
// Deckenumriss = Gebäude-/Wand-Mittellinien), entfällt der Umriss ganz; nur
// Überstände (Vordach/Balkon jenseits der Wand) bleiben sichtbar.
const n = pts.length;
for (let i = 0; i < n; i++) {
const a = pts[i];
const b = pts[(i + 1) % n];
const holes: Array<[number, number]> = [];
for (const fp of wallFootprints) {
const iv = segmentInsideWallBand(a, b, fp);
if (iv) holes.push(iv);
}
const d = sub(b, a);
for (const [t0, t1] of subtractIntervals(0, 1, holes)) {
out.push({
kind: "line",
a: add(a, scale(d, t0)),
b: add(a, scale(d, t1)),
cls: "ceiling-outline",
// Überkopf-Ansichtslinie (Decke liegt als Slab über der Schnittebene) →
// gestrichelte Haarlinie statt kräftiger Volllinie (BIM-Konvention).
weightMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
dash: OVERHEAD_DASH,
color: stroke,
greyed,
});
}
}
// Kräftige Umrisslinie über die volle Decke (nur Kontur, keine Füllung).
out.push({
kind: "polygon",
pts,
fill: "none",
stroke,
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
ceilingId,
});
}
/**
@@ -2094,40 +1638,6 @@ function addRoomArea(
}
}
/**
* Zeichnet die Wendel-Aussenlinie: Innen-/Aussenbogen als Arc-Primitive plus zwei
* radiale Schliesslinien. Wird von `addStairSymbol` aufgerufen wenn `shape === "spiral"`.
*/
function _addSpiralOutline(
out: Primitive[],
seg: SpiralOutlineSegments,
stroke: string,
lwMm: number,
greyed: boolean,
stairId: string,
): void {
// Radiale Schliesslinien (Startwinkel + Endwinkel).
for (const [a, b] of seg.lines) {
out.push({ kind: "line", a, b, cls: "stair-outline", weightMm: lwMm, color: stroke, greyed, stairId });
}
// Innen- und Aussenbogen.
for (const arc of seg.arcs) {
const from: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a0), y: arc.cy + arc.r * Math.sin(arc.a0) };
const to: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a1), y: arc.cy + arc.r * Math.sin(arc.a1) };
out.push({
kind: "arc",
center: { x: arc.cx, y: arc.cy },
from,
to,
r: arc.r,
cls: "stair-outline",
weightMm: lwMm,
greyed,
stairId,
});
}
}
/**
* Treppen-Symbol im Grundriss (DOSSIER/SIA):
* Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet unter der
@@ -2232,27 +1742,6 @@ function addStairSymbol(
const [h1, tip, h2] = geo.arrow.head;
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
// Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
// bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel).
// Entspricht `_aussen_gerade` / `_aussen_l_polygon` / `_aussen_wendel` im
// Rhino-Plugin. Stets durchgezogen, volle Strichstärke.
const outline = stairOutline(stair, totalRise);
if (outline.polygon && outline.polygon.length >= 3) {
out.push({
kind: "polygon",
pts: outline.polygon,
fill: "none",
stroke,
strokeWidthMm: lwMm,
hatch: NO_HATCH,
greyed,
stairId,
});
}
if (outline.spiral) {
_addSpiralOutline(out, outline.spiral, stroke, lwMm, greyed, stairId);
}
}
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
-135
View File
@@ -1,135 +0,0 @@
/**
* Unit-Tests für Schnitt vs. Ansicht nach Schnitthöhe" (generatePlan):
* Eine Wand, die die Grundriss-Schnittebene NICHT erreicht (Höhe < `cutHeight`,
* z. B. eine Brüstung), wird nur als Ansichts-Umriss gezeichnet Poché-Polygone
* OHNE Füllung (`fill:"none"`), keine Schraffur. Eine normale Wand (Höhe
* Schnitthöhe) behält die geschnittene Poché (gefüllte Bänder).
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Project, Wall } from "../model/types";
/** Minimalprojekt: eine normale Wand (2.6 m) + eine niedrige Wand (0.3 m). */
function project(): Project {
const wall = (id: string, start: Wall["start"], end: Wall["end"], height: number): Wall => ({
id,
type: "wall",
floorId: "eg",
categoryCode: "20",
start,
end,
wallTypeId: "aw",
height,
});
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", 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: [
wall("HI", { x: 0, y: 0 }, { x: 4, y: 0 }, 2.6), // erreicht Schnitt → Poché
wall("LOW", { x: 0, y: 2 }, { x: 4, y: 2 }, 0.3), // Brüstung → nur Ansicht
],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
const visible = new Set(["20"]);
describe("generatePlan — Schnitt vs. Ansicht nach Schnitthöhe", () => {
it("zeichnet die niedrige Wand nur als Ansichts-Umriss (fill none), die normale mit Poché", () => {
const plan = generatePlan(project(), "eg", visible, undefined, "mittel");
const wallPolys = (id: string) =>
plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === id,
);
const hi = wallPolys("HI");
const low = wallPolys("LOW");
expect(hi.length).toBeGreaterThan(0);
expect(low.length).toBeGreaterThan(0);
// Normale Wand: mindestens ein gefülltes Poché-Band (Bauteil-Farbe).
expect(hi.some((p) => p.fill !== "none")).toBe(true);
// Niedrige Wand: ALLE Polygone ohne Füllung (reiner Ansichts-Umriss).
expect(low.every((p) => p.fill === "none")).toBe(true);
});
it("behandelt die niedrige Wand als geschnitten, wenn die Schnitthöhe darunter liegt", () => {
const p = project();
p.drawingLevels[0].cutHeight = 0.2; // Schnittebene unter 0.3 m → auch LOW geschnitten
const plan = generatePlan(p, "eg", visible, undefined, "mittel");
const low = plan.primitives.filter(
(pr): pr is Extract<typeof pr, { kind: "polygon" }> =>
pr.kind === "polygon" && pr.wallId === "LOW",
);
expect(low.some((pr) => pr.fill !== "none")).toBe(true);
});
});
// ── Decke über der Schnittebene → gestrichelte Überkopf-Umrisslinie ──────────
/** Minimalprojekt mit EINER Decke (Slab) ohne Wände → voller Umriss sichtbar. */
function ceilingProject(): Project {
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "30", name: "Decken", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [
{
id: "C1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 2 },
{ x: 0, y: 2 },
],
wallTypeId: "aw",
},
],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
describe("generatePlan — Decke = gestrichelte Überkopf-Ansicht", () => {
it("zeichnet den freien Decken-Umriss gestrichelt (Überkopf-Konvention)", () => {
const plan = generatePlan(ceilingProject(), "eg", new Set(["30"]), undefined, "mittel");
const outlines = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "line" }> =>
p.kind === "line" && p.cls === "ceiling-outline",
);
expect(outlines.length).toBeGreaterThan(0);
// JEDE Decken-Umrisslinie ist gestrichelt (dash gesetzt, nicht null).
expect(outlines.every((p) => Array.isArray(p.dash) && p.dash.length >= 2)).toBe(true);
});
});
-48
View File
@@ -476,54 +476,6 @@ export function compilePrimitivesToGpu(
arcPts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
}
strokePolyline(arcPts, false, color, strokeMm);
} else if (prim.kind === 'drawingCircle' || prim.kind === 'drawingArc') {
// 2D-Zeichnungs-Kreis/-Bogen (Kreis-/Bogen-Werkzeug ODER DXF-Import). Der
// SVG-Pfad zeichnet sie als glattes <circle>/<path>; die WebGL-Ebene kennt
// kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc').
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
// offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar.
const stroke = parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1];
const strokeMm = prim.weightMm || 0.18;
const c = prim.center;
const TAU = Math.PI * 2;
const a0 = prim.kind === 'drawingArc' ? prim.a0 : 0;
const sweep =
prim.kind === 'drawingArc'
? ((prim.a1 - prim.a0) % TAU + TAU) % TAU || TAU
: TAU;
// Segmentzahl wie im 'arc'-Zweig: Sagitta < ~0.5 px (LOD über pxPerMeter).
let segs: number;
if (pxPerMeter > 0) {
const rPx = prim.r * pxPerMeter;
const theta = rPx > 0.5 ? 2 * Math.acos(1 - 0.5 / rPx) : Math.PI;
segs = Math.min(2048, Math.max(8, Math.ceil(sweep / theta)));
} else {
segs = Math.max(8, Math.ceil((sweep / (Math.PI / 2)) * 16));
}
const pts: Vec2[] = [];
for (let i = 0; i <= segs; i++) {
const t = a0 + (sweep * i) / segs;
pts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
}
// Vollton-Füllung nur beim geschlossenen Kreis (Bogen ist offen).
if (prim.kind === 'drawingCircle') {
const fill = parseColor(prim.fill);
if (fill) {
const ring = pts.slice(0, segs); // ohne den doppelten Schlusspunkt
const tris = triangulate(ring);
if (tris.length) {
const base = fillPos.length / 2;
for (const pt of ring) {
const s = toScreen(pt);
fillPos.push(s.x, s.y);
track(pt.x, pt.y);
}
for (const t of tris) fillIdx.push(base + t);
addFillBatch(tris.length, fill);
}
}
}
strokePolyline(pts, prim.kind === 'drawingCircle', stroke, strokeMm);
}
// Text: bleibt im SVG-Overlay (scharfe Schrift, DOM-Hit-Test).
}
+1 -16
View File
@@ -486,22 +486,7 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
widthMm: p.weightMm,
dash: p.dash ?? null,
});
} else if (p.kind === "drawingCircle" || p.kind === "drawingArc") {
// Der native Renderer hat kein Kreis-/Bogen-Primitiv → hier tessellieren
// (der SVG-Pfad zeichnet sie glatt). Kreis = voller Umlauf, Bogen = a0..a1.
flushRun();
const col = withOpacity(toRgba(p.stroke, 1) ?? [0.1, 0.1, 0.1, 1], undefined, p.greyed);
const TAU = Math.PI * 2;
const a0 = p.kind === "drawingArc" ? p.a0 : 0;
const sweep = p.kind === "drawingArc" ? (((p.a1 - p.a0) % TAU) + TAU) % TAU : TAU;
const N = Math.max(8, Math.ceil(sweep / (Math.PI / 32)));
const pts: RPoint[] = [];
for (let i = 0; i <= N; i++) {
const t = a0 + (sweep * i) / N;
pts.push([p.center.x + p.r * Math.cos(t), p.center.y + p.r * Math.sin(t)]);
}
polylines.push({ pts, color: col, widthMm: p.weightMm, dash: p.dash ?? null, z: zc++ });
} else if (p.kind === "text") {
} else {
// "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust-
// Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie
// wie im Browser). Layout identisch zum SVG-Pfad in PlanView (case
+6 -90
View File
@@ -63,96 +63,12 @@ describe("selectionHighlightLines", () => {
expect(two.length).toBe(one.length * 2);
});
it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => {
// NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird
// NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro
// Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher
// genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele
// Kanten wie eine öffnungslose Wand desselben Typs (W2), NICHT ein Vielfaches
// durch Segmente. (Die Löcher selbst fügen im Highlight keine Kanten hinzu.)
const w1 = selectionHighlightLines(sampleProject, ["W1"], [], RGB);
const w2 = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
expect(w1.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
expect(w1.length).toBe(w2.length);
});
});
describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:true)", () => {
// Wand W1 (EG, Wandtyp "aw" = 4 Schichten) mit ZWEI Fenstern, deren Achsen-
// Intervalle sich ÜBERLAPPEN, aber auf UNTERSCHIEDLICHER Höhe liegen:
// Fenster A: u=[1.0,2.0] (position 1.0, width 1.0), z=[0.9,2.1] (sill 0.9, h 1.2)
// Fenster B: u=[1.5,2.5] (position 1.5, width 1.0), z=[0.3,0.7] (sill 0.3, h 0.4)
// Die alte Segment-Zerlegung kann diesen versetzt-überlappenden Fall nicht
// abbilden; als Löcher in EINEM Körper müssen beide sauber als `holes`
// erscheinen. W1: start (0,0)->(5,0), floor "eg" (baseElevation 0).
const twoOffsetWindows: Project = {
...sampleProject,
doors: [],
openings: [
{
id: "WA",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
position: 1.0,
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
{
id: "WB",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
position: 1.5,
width: 1.0,
height: 0.4,
sillHeight: 0.3,
},
],
};
it("layered=true: 1 RWall je Schicht-Band mit BEIDEN Fenstern als holes", () => {
const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1");
// Genau ein Körper je Schicht-Band (aw = 4 Schichten), KEINE Segment-Boxen.
expect(w1.length).toBe(4);
for (const body of w1) {
// Jeder Band-Körper trägt BEIDE Löcher (in EINEM Körper).
expect(body.holes).toBeDefined();
expect(body.holes!.length).toBe(2);
const byFrom = [...body.holes!].sort((a, b) => a.from - b.from);
// Fenster A: u=[1,2], z=[0.9,2.1].
expect(byFrom[0].from).toBeCloseTo(1.0, 6);
expect(byFrom[0].to).toBeCloseTo(2.0, 6);
expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6);
expect(byFrom[0].zTop).toBeCloseTo(2.1, 6);
// Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer.
expect(byFrom[1].from).toBeCloseTo(1.5, 6);
expect(byFrom[1].to).toBeCloseTo(2.5, 6);
expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6);
expect(byFrom[1].zTop).toBeCloseTo(0.7, 6);
}
});
it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => {
const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1");
// Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr
// Boxen als die 4 Schicht-Bänder des 3D-Pfads) und setzt NIE `holes`.
expect(w1.length).toBeGreaterThan(4);
for (const seg of w1) expect(seg.holes).toBeUndefined();
});
it("Tür = Loch bis zur Wand-UK (zBottom == baseElevation)", () => {
// Eine Tür (D1 auf W1) muss im 3D-Pfad als Loch bis zum Boden erscheinen:
// zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung.
const p: Project = { ...sampleProject, openings: [] };
const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
expect(w1.length).toBe(4);
const door = w1[0].holes?.find((h) => h.zBottom < 1e-6);
expect(door).toBeDefined();
expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK.
it("eine Wand MIT Öffnungen -> mehrere Teilquader (mehr als 12 Kanten)", () => {
// W1 trägt Tür D1 + Fenster O1 -> Pfeiler/Brüstung/Sturz-Segmente, also
// ein Vielfaches von 12 Kanten (mehr als ein einzelner Quader).
const lines = selectionHighlightLines(sampleProject, ["W1"], [], RGB);
expect(lines.length).toBeGreaterThan(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
expect(lines.length % (12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX)).toBe(0);
});
});
+4 -83
View File
@@ -59,24 +59,6 @@ export interface RLayer {
color: RRgb;
}
/**
* Ein rechteckiges LOCH (Fenster/Tür) in EINEM Wandkörper matcht 1:1
* render3d::types::Hole. KONVENTION: `from`/`to` sind Achsen-Meter ab
* `RWall.start` (0..Achslänge, dieselbe Achse wie die Öffnungs-Intervalle);
* `zBottom`/`zTop` sind ABSOLUTE Weltmeter (NICHT relativ zur Wand-UK anders
* als `Opening.sillHeight`). Eine Tür ist der Sonderfall
* `zBottom == RWall.baseElevation` (Loch bis zur Wand-UK). render3d stanzt die
* Löcher als echte Aussparungen in die beiden Langseiten (Rechteck-Gitter-
* Zerlegung + Laibungsflächen), statt die Wand in Pfeiler/Brüstung/Sturz zu
* zerlegen.
*/
export interface RHole {
from: number;
to: number;
zBottom: number;
zTop: number;
}
export interface RWall {
start: RVec2;
end: RVec2;
@@ -97,19 +79,6 @@ export interface RWall {
* serde verwirft unbekannte Felder).
*/
wallId: string;
/**
* Echte rechteckige Löcher (Fenster/Türen) in DIESEM Wandkörper (siehe
* {@link RHole}). Nur im 3D-Viewer-Pfad ({@link projectToModel3d} mit
* `layeredWalls:true`) gesetzt: dort emittiert {@link emitWall} EINEN RWall pro
* Schicht-Band über die volle Achse mit ALLEN Öffnungen als `holes` statt der
* Pfeiler/Brüstung/Sturz-Segment-Boxen. So bildet EIN Körper auch versetzt
* übereinanderliegende Fenster ab (das kann die Segment-Zerlegung nicht). Der
* Schnitt-Pfad (`layeredWalls:false`) setzt `holes` NICHT und segmentiert
* unverändert weiter (Owner-Kompatibilität mit `toSection.ts`, s. u.). Fehlt/
* leer Vollkörper ohne Aussparung (render3d: `#[serde(default)]`). `holes`
* und die Segment-Zerlegung schliessen sich damit gegenseitig aus.
*/
holes?: RHole[];
}
/** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */
@@ -255,13 +224,6 @@ function resolveWallBands(project: Project, wall: Wall, layered = true): WallBan
* mit Schicht-`thickness`/`color`. Entartete Stücke (Länge 0 oder Höhe 0)
* werden übersprungen so überleben Öffnungen dicht an den Wandenden ohne
* Nullquader.
*
* Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe
* {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe
* Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter beides
* unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur
* senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur
* im 3D-Pfad (voller Achsen-Quader) gesetzt.
*/
function pushSegment(
out: RWall[],
@@ -274,7 +236,6 @@ function pushSegment(
normalOffset: number,
color: RRgb,
wallId: string,
holes?: RHole[],
): void {
if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return;
@@ -308,7 +269,6 @@ function pushSegment(
color,
layers: [],
wallId,
...(holes && holes.length > 0 ? { holes } : {}),
});
}
@@ -462,31 +422,11 @@ function trimWallTopForCeilings(
return Math.max(zBottom, top);
}
/**
* Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen).
*
* ZWEI PFADE je `layered`:
* layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die
* VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in
* die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in
* EINEM Körper (keine Segment-Nähte).
* layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/
* Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die
* `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners`
* passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher
* werden hier NICHT emittiert.
*/
/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */
function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): void {
const bands = resolveWallBands(project, wall, layered);
const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall);
// Decken-Trim (kappt die Wand an der Unterkante einer dominanteren Decke) NUR
// im 3D-Viewer-Pfad (layered=true): dort vermeidet das Kappen das Z-Fighting
// Wand/Decke am bündigen Anschluss. Der Schnitt-Pfad (layered=false) will die
// UNGEKAPPTE volle Wandhöhe, weil er seine EIGENE prioritätsbasierte Schicht-
// Dominanz (toSection.ts::subtractDominantBands) auf Rohgeometrie anwendet —
// ein vorab gekürzter Wandkopf würde dort Schichteinzug/Bodenaufbau am Wand-
// Decken-Anschluss verfälschen.
const zTop = layered ? trimWallTopForCeilings(project, wall, zBottom, rawZTop) : rawZTop;
const zTop = trimWallTopForCeilings(project, wall, zBottom, rawZTop);
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
@@ -521,27 +461,8 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): v
}
cutouts.sort((a, b) => a.from - b.from);
// ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle
// Achse mit allen Öffnungen als echte Löcher. Die Segment-Zerlegung entfällt
// komplett — render3d stanzt die Löcher (Rechteck-Gitter + Laibungen). Türen
// sind Löcher bis zur Wand-UK (`oBottom == zBottom`), Fenster ab Brüstung. So
// bildet EIN Körper auch versetzt übereinanderliegende Fenster ab.
if (layered) {
const holes: RHole[] = cutouts.map((c) => ({
from: c.from,
to: c.to,
zBottom: c.oBottom,
zTop: c.oTop,
}));
for (const b of bands) {
pushSegment(out, wall, 0, axisLen, zBottom, zTop, b.thickness, b.offset, b.color, wall.id, holes);
}
return;
}
// ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz-
// Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners).
// Ohne Aussparungen: ein durchgehender Quader je Schicht.
// Ohne Aussparungen: ein durchgehender Quader je Schicht (wie bisher, aber
// in Lagen aufgeteilt).
if (cutouts.length === 0) {
emitSeg(0, axisLen, zBottom, zTop);
return;
-12
View File
@@ -137,12 +137,6 @@ export interface OpeningInfo {
swing?: "left" | "right";
swingAngle?: number;
openingDir?: "in" | "out";
/** Nur Fenster: Anzahl der Flügel (14). Fehlt → 1 (Default). */
wingCount?: number;
/** Nur Tür: Tür-Typ (normal / Wandöffnung). Fehlt → "normal". */
doorType?: "normal" | "wandoeffnung";
/** Nur Tür: Sturzlinien (keine/innen/aussen/beide). Fehlt → "beide". */
lintelLines?: "keine" | "innen" | "aussen" | "beide";
/** Aufgelöste absolute UK/OK (Meter). */
zBottom: number;
zTop: number;
@@ -166,8 +160,6 @@ export interface StairInfo {
treadDepth: number;
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
up: boolean;
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
referenz?: "links" | "mitte" | "rechts";
/** Geschoss, dem die Treppe zugeordnet ist. */
floorId: string;
floorName: string;
@@ -486,9 +478,6 @@ function openingSelection(project: Project, o: Opening): Selection {
swing: o.swing,
swingAngle: o.swingAngle,
openingDir: o.openingDir,
wingCount: o.wingCount,
doorType: o.doorType,
lintelLines: o.lintelLines,
zBottom: ext.zBottom,
zTop: ext.zTop,
};
@@ -524,7 +513,6 @@ function stairSelection(project: Project, s: Stair): Selection {
riserHeight: geo.riserHeight,
treadDepth: geo.treadDepth,
up: s.up !== false,
referenz: s.referenz,
floorId: s.floorId,
floorName: floor?.name ?? s.floorId,
zBottom,
+25 -330
View File
@@ -109,11 +109,6 @@ body,
#root {
height: 100%;
margin: 0;
/* Dokument selbst NIE scrollen/bouncen: das Zoom-Wheel im Viewport darf die
App-Chrome nicht mitziehen (macOS-Overscroll/Rubberband). Interne Panels
scrollen weiterhin über ihre eigenen overflow-Container. */
overflow: hidden;
overscroll-behavior: none;
}
/* Native-App-Verhalten: KEINE Textauswahl / Alles markieren" in der App-Chrome.
@@ -157,9 +152,9 @@ body {
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
height: 26px;
gap: 10px;
padding: 6px 14px;
height: 60px;
border-bottom: 1px solid var(--line);
/* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten
nach unten trennt sich klar von der Zeichenfläche (Tiefe). */
@@ -174,12 +169,6 @@ body {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
/* Tauri/macOS: native Ampeln (titleBarStyle "Overlay") liegen oben links über
dem Inhalt Zeile links einrücken, damit `dossier.` frei bleibt (~3 Ampeln +
Rand). */
.topbar.topbar-native-inset {
padding-left: 78px;
}
/* Sehr ruhige eigene Scrollleiste (WebKit/Blink): Thumb nur beim Scrollen
(`is-scrolling`, von TopBar gesetzt) oder beim Überfahren sichtbar. */
.topbar::-webkit-scrollbar {
@@ -561,12 +550,10 @@ body {
line-height: 1;
}
.brand-word {
font-weight: 700;
font-size: 12px;
font-weight: 800;
font-size: 15px;
letter-spacing: -0.01em;
text-transform: lowercase;
color: var(--ink);
white-space: nowrap;
}
.brand-dot {
color: var(--accent);
@@ -1476,18 +1463,6 @@ body {
min-width: 0;
}
/* Mitte-Spalte: Viewport (.content) + Befehlszeile (.cmdline) darunter. Nimmt in
der horizontalen .body den Platz ZWISCHEN den Docks ein und stapelt vertikal
so ist die Befehlszeile nur so breit wie der Viewport, und die Docks reichen
über die volle Body-Höhe (inkl. der Befehlszeilen-Zeile). */
.center-col {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
/* Dokument-Tabs Zeichnungen/Blätter" Kopfzeile über der Zeichenfläche.
Leiste auf Ebene 1 (--panel), der aktive Tab hebt sich auf Ebene 2 an und
verschmilzt unten mit dem Grund der Zeichenfläche darunter (--bg), wie bei
@@ -3917,64 +3892,6 @@ body {
font-size: 11px;
}
/* Element-Abschnitte IM Attribute-Panel dem .attr-Grid-Look angleichen
Die aus ObjectInfo verschobenen Sektionen (Wand/Decke/Öffnung/Treppe/Raum)
nutzen weiter die `objinfo-*`-Klassen; hier bekommen sie NUR innerhalb
`.attr-panel` denselben sauberen Look wie das obere Attribut-Grid: Sektion-
Balken, Zeilentrenner, einheitliche Label-Spalte, linksbündig füllende Felder.
Das eigenständige ObjectInfo-Panel (Bezugspunkt/Masse) bleibt unberührt. */
.attr-panel .objinfo-section-label {
color: var(--label);
font-weight: 700;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.05em;
margin: 8px 0 2px;
padding: 3px 6px;
background: var(--panel-2);
border-radius: 3px;
}
/* Sektion-Balken trennen bereits → die dünnen Extra-Striche entfallen. */
.attr-panel .objinfo-sep {
display: none;
}
/* Zeile: Label-Spalte | Wert/Control, mit dezentem Zeilentrenner (wie .attr-*). */
.attr-panel .objinfo-field {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 2px 2px 2px;
border-bottom: 1px solid var(--line);
}
.attr-panel .objinfo-flabel {
flex: 0 0 104px;
width: 104px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 8px;
color: var(--label);
}
/* Werte/Controls füllen die Zelle linksbündig (statt rechtsbündig). */
.attr-panel .objinfo-fval {
flex: 1 1 auto;
text-align: left;
}
.attr-panel .objinfo-input {
text-align: left;
}
.attr-panel .objinfo-field .tb-dd-trigger {
flex: 1 1 auto;
min-width: 0;
justify-content: space-between;
}
/* Segment-Umschalter + verschachtelte Felder füllen ebenfalls die Wert-Spalte. */
.attr-panel .objinfo-segment,
.attr-panel .objinfo-subfield {
flex: 1 1 auto;
min-width: 0;
}
/* ── Wand-Abschnitt (Object-Info) ─────────────────────────────────────── */
/* Referenzlinie-Dropdown rechts oben im Kopf. */
.objinfo-head-ref {
@@ -4045,6 +3962,10 @@ body {
/* ── Attribute-Palette (AttributesPanel) ─────────────────────────────── */
.attr-panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
overflow-y: auto;
height: 100%;
min-height: 0;
@@ -4055,66 +3976,44 @@ body {
padding: 8px 4px;
text-align: center;
}
/* OCS-artiges Eigenschaften-Grid: EIN durchgehendes Zwei-Spalten-Grid über alle
Sektionen (Label | Wert), damit die Wertfelder über Stift/Füllung/Maße hinweg
in EINER Spalte bündig stehen. Sektion-Balken spannen beide Spalten. Zeilen mit
dezenten Trennlinien; Wertfelder füllen ihre Zelle linksbündig über die volle
Panel-Breite einheitlicher Look für Texte/Zahlen/Dropdowns. */
.attr-title {
font-weight: 600;
color: var(--label);
padding: 0 2px 4px;
}
.attr-section-label {
grid-column: 1 / -1;
color: var(--label);
font-weight: 700;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.05em;
margin: 8px 0 2px;
padding: 3px 6px;
background: var(--panel-2);
border-radius: 3px;
}
.attr-section-label:first-child {
margin-top: 0;
margin-top: 6px;
padding: 0 2px;
}
.attr-grid {
display: grid;
grid-template-columns: minmax(80px, max-content) 1fr;
align-items: stretch;
gap: 0;
padding: 0;
grid-template-columns: auto 1fr;
align-items: center;
gap: 4px 10px;
padding: 2px;
}
.attr-key {
color: var(--label);
white-space: nowrap;
display: flex;
align-items: center;
padding: 3px 10px 3px 2px;
border-bottom: 1px solid var(--line);
}
.attr-val {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
justify-content: flex-end;
min-width: 0;
padding: 2px 0;
border-bottom: 1px solid var(--line);
}
/* Wertfelder (Dropdown-Trigger, Zahlenfeld) füllen die Wert-Spalte der
Dropdown wächst in die verfügbare Breite (kein fixes width mehr im TSX). */
.attr-val .tb-dd-trigger {
flex: 1 1 auto;
min-width: 0;
justify-content: space-between;
}
.attr-readonly {
color: var(--label);
opacity: 0.85;
padding-left: 6px;
}
.attr-num,
.attr-select {
width: 100%;
text-align: left;
width: 110px;
background: var(--panel-2);
color: var(--label);
border: 1px solid var(--line);
@@ -5381,16 +5280,15 @@ body {
display: inline-flex;
align-items: center;
gap: 2px;
/* Ganz links in der randlosen Titelleiste (macOS-Konvention). */
margin-right: 4px;
margin-left: 6px;
flex: 0 0 auto;
}
.win-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 22px;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
@@ -5407,206 +5305,3 @@ body {
background: #e5484d;
color: #fff;
}
/* Ribbon (2D · 3D · BIM · Ansichten)
Die Tab-REITER (`.ribbon-tabs`) sitzen in der TopBar-Zeile; hier folgt nur der
INHALT (`.ribbon`/`.ribbon-body`) des aktiven Tabs darunter. So gibt es genau
zwei Bänder: TopBar-Zeile (Chrome + Tabs) oben, Ribbon-Inhalt darunter. */
.ribbon {
display: flex;
flex-direction: column;
background: var(--panel);
border-bottom: 1px solid var(--line);
position: relative;
z-index: 1;
flex: 0 0 auto;
}
/* Tab-Reiter leben in der TopBar-Zeile: volle Zeilenhöhe, Aktiv-Unterstrich
sitzt an der Unterkante (verbindet optisch mit dem Inhalt darunter). */
.ribbon-tabs {
display: flex;
align-self: stretch;
align-items: stretch;
gap: 2px;
}
.ribbon-tab {
appearance: none;
border: none;
background: transparent;
color: var(--muted);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0 10px;
display: flex;
align-items: center;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.12s, border-color 0.12s;
}
/* Quick-Access-Leiste (OCS-Stil) — kleine Icon-Knöpfe in der TopBar-Zeile. */
.tb-qa {
display: inline-flex;
align-items: center;
gap: 1px;
flex: 0 0 auto;
}
.tb-qa-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: var(--muted);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.tb-qa-btn:hover:not(:disabled) {
background: var(--panel-2);
color: var(--label);
}
.tb-qa-btn.active {
background: var(--accent-dim);
color: var(--accent);
}
.tb-qa-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tb-qa-btn .tb-ico {
font-size: 16px;
}
.tb-qa-sep {
width: 1px;
height: 15px;
background: var(--line);
margin: 0 3px;
flex: 0 0 auto;
}
.ribbon-tab:hover {
color: var(--label);
}
.ribbon-tab.active {
color: var(--ink);
border-bottom-color: var(--accent-border, var(--accent));
}
.ribbon-body {
display: flex;
align-items: stretch;
gap: 0;
padding: 4px 8px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
height: 60px;
flex-shrink: 0;
}
.ribbon-body::-webkit-scrollbar {
height: 6px;
}
.ribbon-body::-webkit-scrollbar-thumb {
background: transparent;
}
.ribbon-body:hover::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
/* Eine Gruppe: Buttons oben, kleiner Gruppentitel darunter; rechts ein dezenter
Trenner (wie OCS-Ribbon-Gruppen). */
.ribbon-group {
display: flex;
flex-direction: column;
padding: 0 8px;
border-right: 1px solid var(--line);
}
.ribbon-group:last-child {
border-right: none;
}
.ribbon-items {
display: flex;
align-items: flex-start;
gap: 2px;
flex: 1;
}
.ribbon-group-title {
text-align: center;
color: var(--muted);
font-size: 9px;
letter-spacing: 0.05em;
text-transform: uppercase;
padding-top: 2px;
}
/* Ein Ribbon-Button: Icon oben, Label darunter (kompakt, wie eine Palette). */
.ribbon-btn {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--label);
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 52px;
padding: 4px 2px 3px;
border-radius: 5px;
cursor: pointer;
font-size: 10px;
line-height: 1.15;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.ribbon-btn span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ribbon-btn:hover:not(:disabled) {
background: var(--panel-2);
border-color: var(--line);
}
.ribbon-btn.active {
background: var(--accent-dim, var(--panel-2));
border-color: var(--accent-border, var(--accent));
color: var(--ink);
}
.ribbon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ribbon-empty {
display: flex;
align-items: center;
color: var(--muted);
font-size: 11px;
font-style: italic;
padding: 0 8px;
}
/* Custom-Bar-Picker ( Hinzufügen") kompakter, unauffälliger Trigger, der
sich neben die Custom-Buttons stellt. */
.ribbon-add.tb-dd-trigger {
align-self: center;
height: 24px;
font-size: 11px;
color: var(--muted);
border-style: dashed;
}
.ribbon-add.tb-dd-trigger:hover {
color: var(--label);
}
/* Ansichten"-Tab: die aus der TopBar gemergten Cluster (View-Grid, Kombis,
Detail, Zoom, Darstellung) in EINER Zeile nutzt die bestehenden `.tb-*`-
Stile unverändert weiter. */
.ribbon-views {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
overflow: hidden;
}
+5 -6
View File
@@ -39,12 +39,11 @@ export const ACCENT_SWATCHES: AccentSwatch[] = [
export const DEFAULT_MARQUEE_COLOR = "#CEB188";
/**
* Snap-/Endpunkt-Default: Akzent Sora" (#5FA1C9). Vom Nutzer als endgültiger
* Default festgelegt (2026-07-04) die frühere aki"-Rückfrage ist damit
* geschlossen. Bewusst ein kühler, heller Ton: hebt sich klar vom warmen
* Yuyake-Auswahlrahmen UND von den meist neutral-dunklen Wand-/Linienfarben ab,
* bleibt aber (anders als das sehr blasse Kori) auch auf hellem
* Papier-Hintergrund gut sichtbar. Per Picker im Einstellungs-Fenster änderbar.
* Snap-/Endpunkt-Default: Akzent Sora" (vorläufig die offene Rückfrage
* aki" im HANDOVER-Backlog ist noch nicht geklärt). Bewusst ein kühler,
* heller Ton: hebt sich klar vom warmen Yuyake-Auswahlrahmen UND von den
* meist neutral-dunklen Wand-/Linienfarben ab, bleibt aber (anders als das
* sehr blasse Kori) auch auf hellem Papier-Hintergrund gut sichtbar.
*/
export const DEFAULT_SNAP_COLOR = "#5FA1C9";
+1 -93
View File
@@ -12,7 +12,7 @@
import { describe, it, expect } from "vitest";
import { computeSnap, wallLayerBoundarySegments } from "./snapping";
import { DEFAULT_SNAP } from "./types";
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
import type { Project, Vec2, Wall } from "../model/types";
/** Minimalprojekt mit einer dreischichtigen Wand (0.1 / 0.15 / 0.2 m). */
function wallProject(wall: Wall): Project {
@@ -134,95 +134,3 @@ describe("computeSnap — fängt auf Wand-Schichttrennlinien", () => {
expect(result!.point.y).toBeCloseTo(0.025, 6);
});
});
// ── Kreis-/Bogen-Snaps (Center/Quadrant/Bogen-Endpunkt) ──────────────────────
/** Minimalprojekt mit gegebenen 2D-Geometrien auf dem Geschoss „eg". */
function drawProject(geoms: Drawing2D["geom"][]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: geoms.map((geom, i) => ({
id: `d${i}`,
type: "drawing2d" as const,
levelId: "eg",
categoryCode: "20",
geom,
})),
context: [],
};
}
function snapAt(project: Project, raw: Vec2, settings = DEFAULT_SNAP) {
return computeSnap({
raw,
project,
levelId: "eg",
visibleCodes: new Set(["20"]),
settings,
draftPoints: [],
lastPoint: null,
pxPerMeter: 100,
shift: false,
ctrl: false,
});
}
describe("computeSnap — Kreis/Bogen", () => {
const withCenter = { ...DEFAULT_SNAP, center: true };
it("fängt Mittelpunkt + Quadranten eines Kreises (center aktiv)", () => {
const p = drawProject([{ shape: "circle", center: { x: 0, y: 0 }, r: 1 }]);
const c = snapAt(p, { x: 0, y: 0 }, withCenter);
expect(c?.kind).toBe("center");
expect(c!.point.x).toBeCloseTo(0, 6);
expect(c!.point.y).toBeCloseTo(0, 6);
const q = snapAt(p, { x: 1, y: 0 }, withCenter);
expect(q?.kind).toBe("quadrant");
expect(q!.point.x).toBeCloseTo(1, 6);
expect(q!.point.y).toBeCloseTo(0, 6);
});
it("fängt Bogen-Endpunkte (endpoint per Default aktiv)", () => {
// Viertelbogen um (5,0), r=1, 0…90° → Endpunkte (6,0) und (5,1).
const p = drawProject([
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
]);
const e = snapAt(p, { x: 6, y: 0 });
expect(e?.kind).toBe("endpoint");
expect(e!.point.x).toBeCloseTo(6, 6);
expect(e!.point.y).toBeCloseTo(0, 6);
});
it("bietet Quadranten AUSSERHALB der Bogen-Spanne NICHT an", () => {
// Viertelbogen 0…90°: der West-Quadrant (4,0) liegt NICHT im Bogen.
const p = drawProject([
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
]);
const r = snapAt(p, { x: 4, y: 0 }, withCenter);
expect(r?.kind).not.toBe("quadrant");
});
});
-50
View File
@@ -108,28 +108,6 @@ function collectSegments(input: SnapInput): Seg[] {
return segs;
}
/** Kreis-/Bogen-Geometrie für Center-/Quadrant-/Endpunkt-Snaps. `arc=null` =
* Vollkreis (keine Endpunkte, alle vier Quadranten). */
interface CircleGeom {
center: Vec2;
r: number;
arc: { a0: number; a1: number } | null;
}
/** Sammelt sichtbare Kreise/Bögen des aktiven Geschosses (aus `drawings2d`). */
function collectCircles(input: SnapInput): CircleGeom[] {
const out: CircleGeom[] = [];
for (const d of input.project.drawings2d) {
if (d.levelId !== input.levelId) continue;
if (!input.visibleCodes.has(d.categoryCode)) continue;
const g = d.geom;
if (g.shape === "circle") out.push({ center: g.center, r: g.r, arc: null });
else if (g.shape === "arc")
out.push({ center: g.center, r: g.r, arc: { a0: g.a0, a1: g.a1 } });
}
return out;
}
/** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */
function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
const dx = b.x - a.x, dy = b.y - a.y;
@@ -206,34 +184,6 @@ export function computeSnap(input: SnapInput): SnapResult | null {
for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge");
}
// Kreis-/Bogen-Snaps: Mittelpunkt + Quadranten (Ost/Nord/West/Süd); bei Bögen
// nur die Quadranten IM Bogen-Bereich, plus die beiden Bogen-Endpunkte. Center/
// Quadrant hängen an der `center`-Einstellung, die Endpunkte an `endpoint`.
if (settings.center || settings.endpoint) {
const TAU = Math.PI * 2;
const norm = (x: number) => ((x % TAU) + TAU) % TAU;
const onCircle = (c: CircleGeom, ang: number): Vec2 => ({
x: c.center.x + c.r * Math.cos(ang),
y: c.center.y + c.r * Math.sin(ang),
});
for (const c of collectCircles(input)) {
if (settings.center) {
consider(c.center, "center");
// Volle CCW-Spanne (0 ⇒ Vollkreis); Quadrant nur, wenn im Bogen liegt.
const sweep = c.arc ? norm(c.arc.a1 - c.arc.a0) || TAU : TAU;
for (let q = 0; q < 4; q++) {
const ang = q * (Math.PI / 2);
if (c.arc && norm(ang - c.arc.a0) > sweep + 1e-9) continue;
consider(onCircle(c, ang), "quadrant");
}
}
if (settings.endpoint && c.arc) {
consider(onCircle(c, c.arc.a0), "endpoint");
consider(onCircle(c, c.arc.a1), "endpoint");
}
}
}
if (best) return best;
// Ortho / Winkelraster (Projektion relativ zum letzten Punkt).
-32
View File
@@ -526,34 +526,6 @@ const rectTool: Tool = {
onCancel: () => [{ phase: "idle" }, { draft: null, done: true }],
};
// Kreis: Platzhalter-Werkzeug (analog window/door), gekoppelt an den
// `circle`-Befehl (TOOL_COMMAND in App.tsx). Der eigentliche Ablauf
// (Mittelpunkt → Radius) läuft über die Command-Engine — EIN Pfad für
// Toolbar-Klick und getipptes „circle". Nicht floorOnly (freie 2D-Zeichnung).
const circleTool: Tool = {
id: "circle",
labelKey: "tool.circle",
hintKey: () => "tool.circle.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Bogen: Platzhalter-Werkzeug, gekoppelt an den `arc`-Befehl (3 Klicks:
// Mitte → Start/Radius → Ende). Wie Kreis nicht floorOnly.
const arcTool: Tool = {
id: "arc",
labelKey: "tool.arc",
hintKey: () => "tool.arc.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// ── Registry ─────────────────────────────────────────────────────────────────
const TOOLS: Record<ToolId, Tool> = {
@@ -567,8 +539,6 @@ const TOOLS: Record<ToolId, Tool> = {
line: lineTool,
polyline: polylineTool,
rect: rectTool,
circle: circleTool,
arc: arcTool,
};
/** Liefert das Werkzeug zur ID. */
@@ -588,6 +558,4 @@ export const TOOL_ORDER: ToolId[] = [
"line",
"polyline",
"rect",
"circle",
"arc",
];
+1 -3
View File
@@ -18,9 +18,7 @@ export type ToolId =
| "room"
| "line"
| "polyline"
| "rect"
| "circle"
| "arc";
| "rect";
// ── Snapping ───────────────────────────────────────────────────────────────
+3 -52
View File
@@ -21,7 +21,6 @@ import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
import {
contoursToDrawings,
textsToDrawings,
countContours,
distinctLayers,
} from "../io/dxfToDrawings";
@@ -36,12 +35,6 @@ export interface ImportDecision {
categoryMode: CategoryMode;
/** Ob die Meshes als Kontext-Geometrie übernommen werden. */
importMeshes: boolean;
/**
* Platzierung der importierten Geometrie: `origin` = 1:1 an den DXF-Koordinaten
* (relativ zum Nullpunkt); `viewCenter` = der Gesamt-Umriss wird in die Mitte
* des aktuellen Ausschnitts geschoben.
*/
placement: "origin" | "viewCenter";
/** Die geparsten Konturen-Sätze (für die spätere 2D-Konvertierung). */
parsed: DxfImportResult;
}
@@ -114,11 +107,9 @@ export function ImportDialog({
const contourSets = parsed?.contours ?? [];
const meshes: ImportedMesh[] = parsed?.meshes ?? [];
const texts = parsed?.texts ?? [];
const contourCount = countContours(contourSets);
const textCount = texts.length;
const layers = distinctLayers(contourSets);
const hasDrawings = contourCount > 0 || textCount > 0;
const hasDrawings = contourCount > 0;
const hasMeshes = meshes.length > 0;
// ── Formular-Zustand ──────────────────────────────────────────────────────
@@ -133,7 +124,6 @@ export function ImportDialog({
layers.length > 0 ? "byLayer" : "active",
);
const [importMeshes, setImportMeshes] = useState<boolean>(true);
const [placement, setPlacement] = useState<"origin" | "viewCenter">("origin");
const canImport =
!isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes));
@@ -144,7 +134,7 @@ export function ImportDialog({
targetKind === "existing"
? ({ kind: "existing", levelId: existingLevelId } as const)
: ({ kind: "new", name: newName } as const);
onImport({ target, categoryMode, importMeshes, placement, parsed });
onImport({ target, categoryMode, importMeshes, parsed });
};
return (
@@ -197,9 +187,6 @@ export function ImportDialog({
</div>
<ul className="imp-summary">
<li>{t("import.summary.contours", { n: contourCount })}</li>
{textCount > 0 && (
<li>{t("import.summary.texts", { n: textCount })}</li>
)}
<li>{t("import.summary.meshes", { n: meshes.length })}</li>
<li>
{layers.length > 0
@@ -302,34 +289,6 @@ export function ImportDialog({
</section>
)}
{/* Platzierung: an den DXF-Koordinaten (relativ zu 0) ODER in die
Mitte des aktuellen Ausschnitts geschoben. */}
{hasDrawings && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.placement.heading")}
</div>
<label className="imp-radio">
<input
type="radio"
name="imp-place"
checked={placement === "origin"}
onChange={() => setPlacement("origin")}
/>
<span>{t("import.placement.origin")}</span>
</label>
<label className="imp-radio">
<input
type="radio"
name="imp-place"
checked={placement === "viewCenter"}
onChange={() => setPlacement("viewCenter")}
/>
<span>{t("import.placement.viewCenter")}</span>
</label>
</section>
)}
{/* Mesh-Behandlung. */}
{hasMeshes && (
<section className="imp-section">
@@ -389,19 +348,11 @@ export function buildDrawings(
for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code);
const layerToCode = (layerName: string): string =>
byName.get(layerName.toLowerCase()) ?? activeCategoryCode;
const fromContours = contoursToDrawings(
return contoursToDrawings(
decision.parsed.contours,
levelId,
decision.categoryMode,
activeCategoryCode,
layerToCode,
);
const fromTexts = textsToDrawings(
decision.parsed.texts ?? [],
levelId,
decision.categoryMode,
activeCategoryCode,
layerToCode,
);
return [...fromContours, ...fromTexts];
}
+1 -2
View File
@@ -1847,8 +1847,7 @@ function patToHatches(text: string): Omit<HatchStyle, "id">[] {
scale: 1,
angle: p.families[0]?.angle ?? 45,
color: "#1a1a1a",
// Kanonische Volllinie 0.13 (früher "hatch-line", zusammengeführt auf "thin").
lineStyleId: "thin",
lineStyleId: "hatch-line",
}));
}
+210 -328
View File
@@ -15,8 +15,6 @@ import { useEffect, useRef, useState } from "react";
import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown";
import { WindowControls } from "./WindowControls";
import { RibbonTabs } from "./ribbon/RibbonBar";
import type { RibbonTabId } from "./ribbon/ribbonItems";
import {
applyMark,
applyPreset,
@@ -86,17 +84,49 @@ const ZOOM_CLUSTER_WIDTH = 140;
export interface TopBarProps {
project: Project;
// Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon-
// Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State).
ribbonTab: RibbonTabId;
onRibbonTab: (id: RibbonTabId) => void;
// Ansicht.
/** Ob der Ansichts-Umschalter sinnvoll ist (nur für Geschosse). */
viewToggleEnabled: boolean;
viewType: ViewType;
onViewType: (v: ViewType) => void;
// Ansichts-/Zoom-/Darstellungs-Steuerung ist in den „Ansichten"-Ribbon-Tab
// gewandert (siehe ViewRibbonTab). Die TopBar ist nur noch die schmale globale
// Leiste: Marke/Ressourcen · Tabs · Text-Formatierung · Datei/Export/Einstell.
// 3D-Ansicht: kanonischer Blickwinkel + Kamera-Sichtwinkel (FOV). Wirkt nur in
// der Perspektive; ein Preset-Klick wechselt nötigenfalls auch dorthin.
view3d: View3d;
onView3d: (v: View3d) => void;
/** Sichtwinkel der Perspektivkamera in Grad (Kamera-Einstellungen). */
fov: number;
onFov: (n: number) => void;
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
// Zeichenwerkzeuge (Auswahl/Wand/Linie …), Wandtyp-Wahl und Fang-Menü leben
// jetzt in der Werkzeug-Palette (ToolsPanel), nicht mehr in der Oberleiste.
// Detailgrad.
detail: DetailLevel;
onDetail: (d: DetailLevel) => void;
/** Detailgrad wirkt nur, wenn ein Plan gezeichnet wird (sonst deaktiviert). */
detailEnabled: boolean;
// Massstab + Zoom.
/**
* Gewählter (angewandter) Papier-Massstab-Nenner (1:N) steuert das Dropdown
* und 100 %". Setzt beim Wählen den Plan-Zoom auf exakt 1:N.
*/
scaleDenominator: number;
/** Einen Papier-Massstab anwenden (Preset/frei): setzt N + Plan-Zoom. */
onApplyScale: (n: number) => void;
/**
* LIVE Papier-Massstab-Nenner aus der aktuellen View-Transform (ändert sich
* beim Wheel-Zoom) oder `null`, wenn keine Plan-Ansicht aktiv ist.
*/
liveScale: number | null;
/** Live-Zoom in Prozent (aus der Plan-View-Transform). */
zoomPercent: number;
/** Zoom-Aktionen — nur wirksam, wenn eine Plan-Ansicht aktiv ist. */
zoomEnabled: boolean;
onFit: () => void;
onFitSelection: () => void;
onZoom100: () => void;
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
onExportPdf: () => void;
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
@@ -104,6 +134,21 @@ export interface TopBarProps {
/** Lädt die Bauteilliste des Projekts als CSV herunter. */
onExportSchedule: () => void;
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
/** Render-Modus nur in der Perspektive sinnvoll (sonst deaktiviert). */
renderModeEnabled: boolean;
// Farb-Modus des Grundrisses (Farbig ↔ Schwarz-Weiss). Nur im Grundriss.
planColorMode: PlanColorMode;
onPlanColorMode: (m: PlanColorMode) => void;
// Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen).
/** Handler-Satz für die beiden Kombinations-Dropdowns. */
combos: ComboMenuHandlers;
// Ressourcen-Fenster.
resourcesOpen: boolean;
onToggleResources: () => void;
@@ -122,8 +167,14 @@ export interface TopBarProps {
/** Projekt aus einer Datei laden. */
onOpenProject: () => void;
// Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab
// (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste.
// ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
/**
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
* Controls setzen nur Defaults für neuen Text). Sonst das aktuell selektierte
* Rich-Text-Dokument (z. B. Raumstempel) samt optionalem Bereich und einem
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
*/
textTarget: TextTarget | null;
}
/**
@@ -501,7 +552,7 @@ const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
*/
export function TextGroup({
function TextGroup({
textTarget,
}: {
textTarget: TextTarget | null;
@@ -865,145 +916,50 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die
* Leiste ruhiger bleibt (GTK-Burger-Stil).
*/
/** Ein kleiner Icon-Knopf der Quick-Access-Leiste (OCS-Stil). */
function QaButton({
icon,
label,
onClick,
disabled,
active,
}: {
icon: string;
label: string;
onClick: () => void;
disabled?: boolean;
active?: boolean;
}) {
return (
<button
type="button"
className={`tb-qa-btn${active ? " active" : ""}`}
onClick={onClick}
disabled={disabled}
aria-pressed={active}
aria-label={label}
title={label}
>
<TbIcon name={icon} />
</button>
);
}
/** Kleiner Trenner in der Quick-Access-Leiste. */
function QaSep() {
return <span className="tb-qa-sep" aria-hidden="true" />;
}
/**
* Quick-Access-Leiste (OCS-Stil) alle früheren Datei-Burger-Aktionen plus
* Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile.
* Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t).
*/
function QuickAccess({
onOpenProject,
onSaveProject,
function AppMenu({
onImport,
onExportPdf,
onExportDxf,
onExportSchedule,
onToggleResources,
onSaveProject,
onOpenProject,
onOpenSettings,
resourcesOpen,
exportEnabled,
}: {
onOpenProject: () => void;
onSaveProject: () => void;
onImport: () => void;
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onToggleResources: () => void;
onSaveProject: () => void;
onOpenProject: () => void;
onOpenSettings: () => void;
resourcesOpen: boolean;
exportEnabled: boolean;
}) {
const items: DropdownItem[] = [
{ label: t("file.save"), onSelect: onSaveProject },
{ label: t("file.open"), onSelect: onOpenProject },
{ divider: true },
{ label: t("file.import"), onSelect: onImport },
{ divider: true },
{ label: t("file.exportPdf"), onSelect: onExportPdf, disabled: !exportEnabled },
{ label: t("file.exportDxf"), onSelect: onExportDxf, disabled: !exportEnabled },
{ label: t("file.exportSchedule"), onSelect: onExportSchedule },
{ divider: true },
{ label: t("topbar.settings"), onSelect: onOpenSettings },
];
return (
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
<QaButton icon="folder_open" label={t("file.open")} onClick={onOpenProject} />
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
<QaSep />
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
<QaButton
icon="picture_as_pdf"
label={t("file.exportPdf")}
onClick={onExportPdf}
disabled={!exportEnabled}
/>
<QaButton
icon="output"
label={t("file.exportDxf")}
onClick={onExportDxf}
disabled={!exportEnabled}
/>
<QaButton
icon="table_view"
label={t("file.exportSchedule")}
onClick={onExportSchedule}
/>
<QaSep />
<QaButton
icon="widgets"
label={t("topbar.resources")}
onClick={onToggleResources}
active={resourcesOpen}
/>
<QaButton icon="settings" label={t("topbar.settings")} onClick={onOpenSettings} />
</div>
<Dropdown
items={items}
placeholder=""
title={t("file.menu")}
triggerClassName="tb-burger"
triggerSuffix={<TbIcon name="menu" />}
/>
);
}
/**
* Props des Ansichten"-Tabs die Ansichts-/Zoom-/Darstellungs-Steuerung, die
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
*/
export interface ViewRibbonTabProps {
viewToggleEnabled: boolean;
viewType: ViewType;
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
fov: number;
onFov: (n: number) => void;
detail: DetailLevel;
onDetail: (d: DetailLevel) => void;
detailEnabled: boolean;
scaleDenominator: number;
onApplyScale: (n: number) => void;
liveScale: number | null;
zoomPercent: number;
zoomEnabled: boolean;
onFit: () => void;
onFitSelection: () => void;
onZoom100: () => void;
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
renderModeEnabled: boolean;
planColorMode: PlanColorMode;
onPlanColorMode: (m: PlanColorMode) => void;
combos: ComboMenuHandlers;
/** Text-/Font-Formatierung auf DERSELBEN Leiste (Raumstempel-Ziel; `null` =
* nur Defaults). Liegt bewusst im Ansichten"-Tab (Nutzer-Wunsch). */
textTarget: TextTarget | null;
}
/**
* Ansichten"-Tab des Ribbons trägt die aus der TopBar gemergten Mittelcluster:
* 2D/3D-Ansichts-Raster (+ Kamera-FOV), Sichtbarkeits-Kombinationen, Detailgrad,
* Massstab/Zoom-Cluster und Darstellungsart. EIN Ribbon für alles; Handler/Setter
* kommen unverändert von App (gleiche Props wie zuvor an die TopBar). Wird von
* `RibbonBar` als Inhalt des Ansichten"-Tabs gerendert.
*/
export function ViewRibbonTab({
export function TopBar({
project,
viewToggleEnabled,
viewType,
onViewType,
@@ -1022,14 +978,44 @@ export function ViewRibbonTab({
onFit,
onFitSelection,
onZoom100,
onExportPdf,
onExportDxf,
onExportSchedule,
renderMode,
onRenderMode,
renderModeEnabled,
planColorMode,
onPlanColorMode,
combos,
resourcesOpen,
onToggleResources,
onOpenSettings,
layoutMenu,
onImport,
onSaveProject,
onOpenProject,
textTarget,
}: ViewRibbonTabProps) {
}: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
// NUR während des Scrollens ein (Klasse `is-scrolling`, ~900 ms nachlaufend).
const headerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const onScroll = () => {
el.classList.add("is-scrolling");
if (timer) clearTimeout(timer);
timer = setTimeout(() => el.classList.remove("is-scrolling"), 900);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (timer) clearTimeout(timer);
};
}, []);
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
const onScaleChange = (value: string) => {
if (value === "__custom__") {
@@ -1044,7 +1030,14 @@ export function ViewRibbonTab({
const n = Number(value);
if (Number.isFinite(n) && n > 0) onApplyScale(n);
};
// Aktueller Nenner steht evtl. nicht in den Presets (frei gesetzt) → dann
// einen Eintrag „1:N" voranstellen, damit das Select den Wert anzeigt.
const inPresets = SCALE_PRESETS.includes(scaleDenominator);
// Optionsliste des Massstab-Dropdowns: ggf. der frei gesetzte aktuelle Wert
// (damit er sichtbar bleibt), dann die Presets, zuletzt „frei …" als
// Sonder-Wert, der über onScaleChange den Prompt öffnet (Funktion unverändert).
const scaleOptions: DropdownOption[] = [
...(inPresets
? []
@@ -1054,8 +1047,41 @@ export function ViewRibbonTab({
];
return (
<div className="ribbon-views">
{/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */}
<header className="topbar" ref={headerRef}>
{/* Marke (DOSSIER-Wortmarke) + Settings/Ressourcen-Icons daneben,
gestapelt wie im Rhino-Plugin (ToolbarApp.jsx: Wortmarke links,
rechts daneben ein schmaler Icon-Stapel). */}
<div className="tb-brandgroup">
<div className="brand">
<span className="brand-word">
DOSSIER<span className="brand-dot">.</span>
</span>
<span className="tag">{t("brand.tag")}</span>
</div>
<div className="tb-brandicons">
<button
type="button"
className={`tb-brandicon${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
aria-pressed={resourcesOpen}
aria-label={t("topbar.resources")}
title={`${t("topbar.resources")}${t("topbar.resources.hint")}`}
>
<TbIcon name="widgets" />
</button>
</div>
</div>
<Sep />
{/* Ansichten als EIN kompaktes Icon-Grid (Vectorworks/DOSSIER-Stil): der
2D-Grundriss UND die 3D-Blicke sind eine durchgängige Icon-Serie
Grundriss" schaltet auf den 2D-Plan, die übrigen auf die jeweilige
3D-Ansicht (Perspektive wird dabei automatisch aktiviert). 4 oben /
3 unten. Tooltip nennt jede Ansicht im Klartext. */}
{/* Ansichts-Icons bleiben immer sichtbar; auf Nicht-Geschoss-Ebenen
(Schnitt/Ansicht/2D-Zeichnung) sind sie deaktiviert, weil dort keine
2D/3D-Umschaltung greift die Serie verschwindet nicht mehr ganz. */}
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
<div className="view-grid view-grid-4">
<button
@@ -1113,7 +1139,10 @@ export function ViewRibbonTab({
</div>
<Sep />
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
{/* Sichtbarkeits-Kombinationen (Vectorworks: Ebenen-/Zeichnungs-
kombinationen). Sitzt direkt nach dem Ansichts-Umschalter, weil beides
Navigations-/Sicht"-Steuerung ist ruhige Stelle vor dem Detailgrad.
Nur die Dropdowns; eine grafische Überschreibung kommt später. */}
<div className="tb-group tb-stack" role="group" aria-label={t("combo.layers.title")}>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("combo.layers")}</span>
@@ -1138,9 +1167,27 @@ export function ViewRibbonTab({
/>
</label>
</div>
<Sep />
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss. */}
{/* Text-Gruppe (immer sichtbar) Stil/Schrift/Grösse + B/I/U · L/C/R +
Zeilenhöhe. Formatiert das aktuelle Text-Ziel (Raumstempel) live.
Sitzt bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab.
+ Text" ist raus Text wird ein eigenständiges Zeichenwerkzeug
(HANDOVER-Backlog AUDIT B1), kein TopBar-Knopf mehr. */}
<TextGroup textTarget={textTarget} />
<Sep />
{/* Zeichenwerkzeuge liegen jetzt in der Werkzeug-Palette (ToolsPanel,
dockbar) nicht mehr in der Oberleiste (Vectorworks-Bild: Werkzeuge
in der Palette). Auch Wandtyp-Wahl + Fang-Menü sind dort. */}
{/* Detailgrad (grob/mittel/fein) wirkt im Grundriss auf Symbole +
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung dort
deaktiviert mit Tooltip statt stiller No-Op. */}
{/* Detailgrad Massstab lebt nur noch EINMAL im Zoom-Cluster (die
Dropdown hier war ein redundantes Relikt der TopBar-Reorg). */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.detail")}</span>
@@ -1161,9 +1208,14 @@ export function ViewRibbonTab({
/>
</label>
</div>
<Sep />
{/* Massstab/Zoom-Cluster (2×2). */}
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die
kombinierte Stat-Pille (Live-Massstab 1:N oben / Zoom% unten); rechts
oben das Massstab-Dropdown, rechts unten die Zoom-Segmentpille. am
Massstab" (Live-Massstab == gewählter Massstab) hebt die Stat-Pille im
Akzent hervor. */}
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
<div
className={`tb-stat${
@@ -1219,9 +1271,13 @@ export function ViewRibbonTab({
/>
</div>
</div>
<Sep />
{/* Darstellungsart — Grundriss: Farbig/SW; Perspektive: Render-Modus. */}
{/* Darstellungsart (Vectorworks-Stil) EIN Dropdown, dessen Optionen vom
Ansichtstyp abhängen: Grundriss Farbig/Schwarz-Weiss (planColorMode);
Perspektive Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
Linien-Modus (Display/Print) bleibt separat. */}
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field">
<span className="tb-label">{t("display.style")}</span>
@@ -1252,199 +1308,25 @@ export function ViewRibbonTab({
)}
</label>
</div>
<Sep />
{/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und
Ansichten zusammen) Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe,
formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
<TextGroup textTarget={textTarget} />
</div>
);
}
export interface Ribbon3dTabProps {
viewToggleEnabled: boolean;
viewType: ViewType;
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
fov: number;
onFov: (n: number) => void;
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
renderModeEnabled: boolean;
}
/** 3D-Tab des Ribbons Kamera-Presets + Darstellungsart. Kein Duplikat des
* Ansichten-Tabs: hier nur 3D-relevante Steuerung (Blickwinkel, Rendermodus). */
export function Ribbon3dTab({
viewToggleEnabled,
viewType,
onViewType,
view3d,
onView3d,
fov,
onFov,
renderMode,
onRenderMode,
renderModeEnabled,
}: Ribbon3dTabProps) {
return (
<div className="ribbon-views">
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
<div className="view-grid view-grid-4">
<button
className={`view-icon${viewToggleEnabled && viewType === "grundriss" ? " active" : ""}`}
onClick={() => onViewType("grundriss")}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? t("topbar.grundriss") : t("topbar.viewGroup.disabled")}
aria-label={t("topbar.grundriss")}
>
<ViewIcon name="grundriss" />
</button>
{(
[
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
] as [View3d, string][]
).map(([key, label]) => (
<button
key={key}
className={`view-icon${viewToggleEnabled && viewType === "perspektive" && view3d === key ? " active" : ""}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? `${label}${t(`view3d.${key}.hint`)}` : t("topbar.viewGroup.disabled")}
aria-label={label}
>
<ViewIcon name={key} />
</button>
))}
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!viewToggleEnabled || viewType !== "perspektive"}
/>
</div>
</div>
<Sep />
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field">
<span className="tb-label">{t("display.style")}</span>
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "white", label: t("display.style.white") },
{ value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
]}
/>
</label>
</div>
</div>
);
}
export function TopBar({
project,
ribbonTab,
onRibbonTab,
zoomEnabled,
onExportPdf,
onExportDxf,
onExportSchedule,
resourcesOpen,
onToggleResources,
onOpenSettings,
layoutMenu,
onImport,
onSaveProject,
onOpenProject,
}: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
// NUR während des Scrollens ein (Klasse `is-scrolling`, ~900 ms nachlaufend).
const headerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const onScroll = () => {
el.classList.add("is-scrolling");
if (timer) clearTimeout(timer);
timer = setTimeout(() => el.classList.remove("is-scrolling"), 900);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (timer) clearTimeout(timer);
};
}, []);
// Unter Tauri (native macOS-Ampeln oben links via titleBarStyle "Overlay")
// braucht die Zeile links Platz, damit `dossier.` nicht darunter liegt.
const tauriNativeTitlebar =
typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
return (
// `data-tauri-drag-region`: die Titelleiste ist selbst der Fenster-Ziehbereich
// (macOS titleBarStyle "Overlay"). Kind-Buttons/Dropdowns bleiben klickbar;
// nur die leeren Zonen ziehen das Fenster. Unter Tauri bekommt die Zeile links
// einen Inset, damit `dossier.` nicht unter den nativen macOS-Ampeln liegt.
<header
className={`topbar${tauriNativeTitlebar ? " topbar-native-inset" : ""}`}
ref={headerRef}
data-tauri-drag-region
>
{/* Fensterknöpfe NUR unter Electron (randloses frame:false-Fenster) unter
Tauri liefern die NATIVEN macOS-Ampeln Schliessen/Minimieren/Maximieren,
daher rendert WindowControls dort null. */}
<WindowControls />
{/* Kleine Wortmarke dossier" (OCS-Stil, klein/kleingeschrieben) + Quick-
Access-Leiste: alle Datei-/Export-Aktionen + Ressourcen/Einstellungen
als kleine Icons direkt in der Zeile (ersetzt das Burger-Menü). */}
<div className="tb-brandgroup">
<span className="brand-word">
dossier<span className="brand-dot">.</span>
</span>
</div>
<QuickAccess
onOpenProject={onOpenProject}
onSaveProject={onSaveProject}
onImport={onImport}
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onToggleResources={onToggleResources}
onOpenSettings={onOpenSettings}
resourcesOpen={resourcesOpen}
exportEnabled={zoomEnabled}
/>
<Sep />
{/* Ribbon-Tab-Reiter (2D/3D/BIM/Text/Ansichten) sitzen in dieser Zeile,
der Ribbon-Inhalt folgt darunter (RibbonBar). So gibt es nur ZWEI
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */}
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} />
{/* Rechte Gruppe: Layout-Menü · Projektname. Die Fensterknöpfe sitzen jetzt
ganz links (macOS-Titelleisten-Konvention); Datei-/Export-Aktionen in
der Quick-Access-Leiste (kein Burger). */}
{/* Rechte Gruppe: Layout-Menü · Projektname · Datei-Burger (ganz rechts,
GTK-Stil) Ressourcen sitzt jetzt bei der Marke (s. o.). */}
<div className="topbar-right">
{layoutMenu}
<span className="project-name">{project.name}</span>
<AppMenu
onImport={onImport}
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onSaveProject={onSaveProject}
onOpenProject={onOpenProject}
onOpenSettings={onOpenSettings}
exportEnabled={zoomEnabled}
/>
<WindowControls />
</div>
</header>
);
+22 -23
View File
@@ -1,11 +1,11 @@
// Eigene Fenstersteuerung (Minimieren/Maximieren/Schliessen) für das randlose
// ELECTRON-Fenster (frame:false, s. scripts/electron-main.cjs) — dort gibt es
// ohne native Titelleiste sonst keine Fenstersteuerung.
// Electron-Fenster (frame:false, s. scripts/electron-main.cjs) — ohne native
// Titelleiste gibt es sonst keine Möglichkeit, das Fenster zu steuern.
//
// Unter TAURI werden die NATIVEN macOS-Ampeln verwendet (tauri.conf.json:
// titleBarStyle "Overlay" + hiddenTitle, native Dekoration mit gerundeten Ecken),
// daher rendert diese Komponente dort NICHT (kein `window.dossierWindow`) — sonst
// gäbe es doppelte Knöpfe. Im Browser-Dev-Modus ebenfalls unsichtbar (`null`).
// Rendert NUR, wenn `window.dossierWindow` existiert (vom Preload-Skript
// injiziert) — also nur unter Electron. In Tauri (native Fensterdekoration)
// und im Browser-Dev-Modus (?vite) bleibt die Komponente unsichtbar (`null`),
// dort steuert das Betriebssystem bzw. der Browser das Fenster.
import { useEffect, useState } from "react";
@@ -43,25 +43,8 @@ export function WindowControls() {
if (!api) return null;
// Reihenfolge (macOS-Titelleiste links): Schliessen · Minimieren · Maximieren.
return (
<div className="win-controls" role="group" aria-label="Fenster">
<button
type="button"
className="win-btn win-btn-close"
onClick={() => api.close()}
title="Schliessen"
aria-label="Schliessen"
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path
d="M1 1 L9 9 M9 1 L1 9"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
</button>
<button
type="button"
className="win-btn"
@@ -103,6 +86,22 @@ export function WindowControls() {
</svg>
)}
</button>
<button
type="button"
className="win-btn win-btn-close"
onClick={() => api.close()}
title="Schliessen"
aria-label="Schliessen"
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path
d="M1 1 L9 9 M9 1 L1 9"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
}
-77
View File
@@ -1,77 +0,0 @@
// Kleine Inline-SVG-Glyphen für Befehls-Ribbon-Elemente (Ändern-Gruppe u. a.),
// die keine ToolId haben und daher nicht über `ToolIcon` abgedeckt sind. Gleicher
// Stil wie `ToolIcon` (~16×16, stroke=currentColor, fill=none), damit das Icon
// die Textfarbe der Zeile übernimmt. Unbekannte Namen → kein Icon (null).
const common = {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.4,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
export function CommandIcon({ name }: { name: string }) {
switch (name) {
case "move":
// Vier-Wege-Verschiebepfeil.
return (
<svg {...common}>
<line x1="8" y1="2.5" x2="8" y2="13.5" />
<line x1="2.5" y1="8" x2="13.5" y2="8" />
<polyline points="6,4.5 8,2.5 10,4.5" />
<polyline points="6,11.5 8,13.5 10,11.5" />
<polyline points="4.5,6 2.5,8 4.5,10" />
<polyline points="11.5,6 13.5,8 11.5,10" />
</svg>
);
case "copy":
// Zwei überlappende Rechtecke (Duplikat).
return (
<svg {...common}>
<rect x="2.5" y="2.5" width="8" height="8" />
<rect x="5.5" y="5.5" width="8" height="8" />
</svg>
);
case "mirror":
// Spiegelachse (gestrichelt) mit zwei gespiegelten Dreiecken.
return (
<svg {...common}>
<line x1="8" y1="2" x2="8" y2="14" strokeDasharray="1.6 1.6" />
<path d="M6.5 4 L2.5 8 L6.5 12 Z" />
<path d="M9.5 4 L13.5 8 L9.5 12 Z" />
</svg>
);
case "offset":
// Zwei parallele Winkel (Ursprung + Versatz).
return (
<svg {...common}>
<polyline points="3,12 3,4 11,4" />
<polyline points="6,12 6,7 14,7" />
</svg>
);
case "trim":
// Schere (Stutzen).
return (
<svg {...common}>
<line x1="13.5" y1="3" x2="6.5" y2="9" />
<circle cx="4" cy="11" r="2" />
<line x1="13.5" y1="13" x2="6.5" y2="7" />
<circle cx="4" cy="5" r="2" />
</svg>
);
case "join":
// Zwei Segmente, die sich an einer Ecke treffen (Verbinden).
return (
<svg {...common}>
<polyline points="2.5,13 8,13 8,3" />
<circle cx="8" cy="13" r="1.4" fill="currentColor" stroke="none" />
</svg>
);
default:
return null;
}
}
-229
View File
@@ -1,229 +0,0 @@
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert den INHALT des
// aktiven Ribbon-Tabs. Die Tab-REITER selbst sitzen in der TopBar-Zeile (eine
// Leiste: globales Chrome + Tabs), damit es nur ZWEI Bänder gibt: TopBar-Zeile
// (mit Tabs) oben, Ribbon-Inhalt darunter. Der aktive Tab wird als `tab`-Prop
// hereingereicht (State liegt in App, geteilt mit der TopBar).
//
// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie
// in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Der
// „Ansichten"-Tab trägt reichen Custom-Inhalt (`viewsContent`, aus der TopBar
// gemergt) statt datengetriebener Item-Gruppen.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
import { t } from "../../i18n";
import { ToolIcon } from "../../panels/ToolsPanel";
import { getTool } from "../../tools/tools";
import type { ToolId } from "../../tools/types";
import { getCommand } from "../../commands/registry";
import { Dropdown } from "../Dropdown";
import type { DropdownItem } from "../Dropdown";
import { RIBBON_TABS, ALL_RIBBON_ITEMS, itemKey, itemFromKey } from "./ribbonItems";
import type { RibbonItem, RibbonTabId } from "./ribbonItems";
import { CommandIcon } from "./CommandIcon";
/** Aufgelöste Beschriftung eines Items (Werkzeug bzw. Befehl). */
function itemLabel(item: RibbonItem): string {
if (item.kind === "tool") return t(getTool(item.id).labelKey);
const cmd = getCommand(item.name);
return cmd ? t(cmd.labelKey) : item.name;
}
export interface RibbonBarProps {
/** Aktiver Tab (State liegt in App, geteilt mit den Reitern in der TopBar). */
tab: RibbonTabId;
/** Aktives Werkzeug (Highlight der `tool`-Elemente). */
activeTool: ToolId;
/** Name des laufenden Engine-Befehls (Highlight der `command`-Elemente). */
activeCommand: string | null;
/** Ob Boden-gebundene (`floorOnly`) Werkzeuge nutzbar sind (aktives Geschoss). */
toolsEnabled: boolean;
/** Werkzeug aktivieren (gleicher Pfad wie die Sidebar). */
onSelectTool: (id: ToolId) => void;
/** Engine-Befehl starten (Ändern-Gruppe u. a.). */
onRunCommand: (name: string) => void;
/**
* Reicher Custom-Inhalt je Tab (statt datengetriebener Item-Gruppen) z. B.
* Ansichten" (`ViewRibbonTab`) und „Text" (`TextGroup`). Von App geliefert,
* damit deren Setter/State dort bleiben. Ein Tab ohne Custom-Inhalt und ohne
* Gruppen zeigt den Leer-Hinweis.
*/
tabContent?: Partial<Record<RibbonTabId, ReactNode>>;
/** Persistierte Item-Schlüssel der modularen Custom-Bar (Tab „Eigene"). */
customItems: string[];
/** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */
onToggleCustomItem: (key: string) => void;
}
/** Aktivierungs-/Highlight-Kontext, den jeder Button-Typ braucht. */
interface ButtonCtx {
activeTool: ToolId;
activeCommand: string | null;
toolsEnabled: boolean;
onSelectTool: (id: ToolId) => void;
onRunCommand: (name: string) => void;
}
/** Ein einzelner Ribbon-Button (Werkzeug ODER Befehl). */
function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) {
if (item.kind === "tool") {
const tool = getTool(item.id);
// Nur Boden-gebundene Werkzeuge sind ohne aktives Geschoss gesperrt; freie
// 2D-Werkzeuge (Linie/Kreis/…) bleiben auch auf Zeichnungsebenen nutzbar.
const disabled = !!tool.floorOnly && !ctx.toolsEnabled;
const active = ctx.activeTool === item.id;
const label = t(tool.labelKey);
return (
<button
type="button"
className={`ribbon-btn${active ? " active" : ""}`}
disabled={disabled}
title={disabled ? t("tool.floorOnlyDisabled") : label}
onClick={() => ctx.onSelectTool(item.id)}
>
<ToolIcon id={item.id} />
<span>{label}</span>
</button>
);
}
// Befehls-Element: Label aus der Befehls-Registry ableiten.
const cmd = getCommand(item.name);
const label = cmd ? t(cmd.labelKey) : item.name;
const active = ctx.activeCommand === item.name;
return (
<button
type="button"
className={`ribbon-btn${active ? " active" : ""}`}
title={label}
onClick={() => ctx.onRunCommand(item.name)}
>
<CommandIcon name={item.name} />
<span>{label}</span>
</button>
);
}
/**
* Die Tab-REITER als eigene Komponente exportiert, weil sie in der TopBar-Zeile
* gerendert werden (nicht hier). Rein gesteuert über `tab`/`onTab`.
*/
export function RibbonTabs({
tab,
onTab,
}: {
tab: RibbonTabId;
onTab: (id: RibbonTabId) => void;
}) {
return (
<div className="ribbon-tabs" role="tablist">
{RIBBON_TABS.map((tt) => (
<button
key={tt.id}
type="button"
role="tab"
aria-selected={tt.id === tab}
className={`ribbon-tab${tt.id === tab ? " active" : ""}`}
onClick={() => onTab(tt.id)}
>
{t(tt.labelKey)}
</button>
))}
</div>
);
}
/**
* Modulare Custom-Bar (Tab Eigene") rendert die vom Nutzer gewählten Items
* plus einen "-Picker, der ALLE verfügbaren Werkzeuge/Befehle mit Häkchen für
* die bereits enthaltenen listet (Klick = an-/abwählen). Gleiche Item-Typen +
* gleiche Aktivierung wie die normalen Tabs kein Sonderweg.
*/
function CustomBar({
customItems,
onToggle,
ctx,
}: {
customItems: string[];
onToggle: (key: string) => void;
ctx: ButtonCtx;
}) {
const pickerItems: DropdownItem[] = ALL_RIBBON_ITEMS.map((it) => {
const key = itemKey(it);
return {
label: itemLabel(it),
active: customItems.includes(key),
onSelect: () => onToggle(key),
};
});
const items = customItems
.map(itemFromKey)
.filter((it): it is RibbonItem => it !== null);
return (
<div className="ribbon-group">
<div className="ribbon-items">
{items.length === 0 && (
<div className="ribbon-empty">{t("ribbon.custom.empty")}</div>
)}
{items.map((item, i) => (
<RibbonButton key={`${itemKey(item)}-${i}`} item={item} ctx={ctx} />
))}
<Dropdown
items={pickerItems}
placeholder={t("ribbon.custom.add")}
title={t("ribbon.custom.add")}
triggerClassName="ribbon-add"
/>
</div>
<div className="ribbon-group-title">{t("ribbon.group.custom")}</div>
</div>
);
}
export function RibbonBar(props: RibbonBarProps) {
const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0];
const custom = props.tabContent?.[current.id];
const ctx: ButtonCtx = {
activeTool: props.activeTool,
activeCommand: props.activeCommand,
toolsEnabled: props.toolsEnabled,
onSelectTool: props.onSelectTool,
onRunCommand: props.onRunCommand,
};
return (
<div className="ribbon">
{/* Inhalt des aktiven Tabs. Tabs mit reichem Custom-Inhalt (Ansichten",
Text") rendern diesen; der „Eigene"-Tab die modulare Custom-Bar; sonst
die datengetriebenen Item-Gruppen. */}
<div className="ribbon-body">
{current.id === "custom" ? (
<CustomBar
customItems={props.customItems}
onToggle={props.onToggleCustomItem}
ctx={ctx}
/>
) : custom ? (
custom
) : current.groups.length === 0 ? (
<div className="ribbon-empty">{t("ribbon.empty")}</div>
) : (
current.groups.map((g) => (
<div key={g.titleKey} className="ribbon-group">
<div className="ribbon-items">
{g.items.map((item) => (
<RibbonButton
key={item.kind === "tool" ? `t:${item.id}` : `c:${item.name}`}
item={item}
ctx={ctx}
/>
))}
</div>
<div className="ribbon-group-title">{t(g.titleKey)}</div>
</div>
))
)}
</div>
</div>
);
}
-139
View File
@@ -1,139 +0,0 @@
// Ribbon-Registry (docs/design/ribbon-ui-plan.md §Architektur) — EINE
// datengetriebene Beschreibung aller Ribbon-Elemente. Die Ribbon-Tabs UND eine
// spätere modulare Custom-Bar (Phase 4) sind bloß zwei Ansichten über dieselbe
// Registry, ohne Sonderweg pro Element.
//
// Ein `RibbonItem` ist entweder ein Werkzeug (`tool` → onSelectTool, wie die
// Werkzeug-Sidebar) oder ein Engine-Befehl (`command` → engine.start). Beide
// münden in exakt DENSELBEN Pfad wie Klick in der alten Sidebar bzw. das Tippen
// des Befehlsnamens — keine Duplikate.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Sichtbare
// Texte kommen als i18n-Schlüssel und werden erst in der Komponente über t(...)
// aufgelöst.
import type { ToolId } from "../../tools/types";
/** Ein aktivierbares Ribbon-Element. Bei `command` wird die Beschriftung aus der
* Befehls-Registry (`getCommand(name).labelKey`) abgeleitet kein doppeltes
* Pflegen von Labels. */
export type RibbonItem =
| { kind: "tool"; id: ToolId }
| { kind: "command"; name: string };
/** Eine benannte Gruppe von Elementen innerhalb eines Tabs. */
export interface RibbonGroup {
titleKey: string;
items: RibbonItem[];
}
/** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */
export type RibbonTabId = "views" | "2d" | "3d" | "bim" | "custom";
/** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */
export interface RibbonTab {
id: RibbonTabId;
labelKey: string;
groups: RibbonGroup[];
}
const t = (id: ToolId): RibbonItem => ({ kind: "tool", id });
const c = (name: string): RibbonItem => ({ kind: "command", name });
/**
* Die Ribbon-Tabs (Reihenfolge Ansichten·2D·3D·BIM). Ansichten" ist der
* Standard-Tab (zuerst, initial aktiv) und trägt reichen Custom-Inhalt: die aus
* der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung UND die Text-/Font-
* Formatierung auf DERSELBEN Leiste (`ViewRibbonTab` `groups` bleibt leer). 2D
* (Zeichnen + Ändern) und BIM (Bauteile) sind datengetrieben aus Werkzeugen/
* Befehlen; 3D ist vorerst leer (spätere Phase).
*/
export const RIBBON_TABS: RibbonTab[] = [
{
// Custom-Inhalt (ViewRibbonTab): Ansichts-/Zoom-/Darstellungssteuerung + Text.
id: "views",
labelKey: "ribbon.tab.views",
groups: [],
},
{
id: "2d",
labelKey: "ribbon.tab.2d",
groups: [
{
titleKey: "ribbon.group.draw",
items: [
t("select"),
t("line"),
t("polyline"),
t("rect"),
t("circle"),
t("arc"),
],
},
{
titleKey: "ribbon.group.modify",
items: [
c("move"),
c("copy"),
c("mirror"),
c("offset"),
c("trim"),
c("join"),
],
},
],
},
{
id: "3d",
labelKey: "ribbon.tab.3d",
groups: [],
},
{
id: "bim",
labelKey: "ribbon.tab.bim",
groups: [
{
titleKey: "ribbon.group.components",
items: [
t("wall"),
t("window"),
t("door"),
t("stair"),
t("ceiling"),
t("room"),
],
},
],
},
{
// Modulare Custom-Bar (Phase 4): der Nutzer stellt sich die Elemente selbst
// zusammen (persistiert). Gerendert aus `customItems`, nicht aus `groups`.
id: "custom",
labelKey: "ribbon.tab.custom",
groups: [],
},
];
/** Stabiler Schlüssel eines Items für die Persistenz der Custom-Bar. */
export function itemKey(item: RibbonItem): string {
return item.kind === "tool" ? `tool:${item.id}` : `command:${item.name}`;
}
/** Parst einen Item-Schlüssel zurück (oder null bei unbekanntem Präfix). */
export function itemFromKey(key: string): RibbonItem | null {
const i = key.indexOf(":");
if (i < 0) return null;
const kind = key.slice(0, i);
const rest = key.slice(i + 1);
if (kind === "tool") return { kind: "tool", id: rest as ToolId };
if (kind === "command") return { kind: "command", name: rest };
return null;
}
/**
* Alle datengetriebenen Items (aus den Werkzeug-/Befehls-Tabs) die wählbare
* Menge für den Custom-Bar-Picker. views"/„custom" tragen keine `groups`.
*/
export const ALL_RIBBON_ITEMS: RibbonItem[] = RIBBON_TABS.flatMap((tab) =>
tab.groups.flatMap((g) => g.items),
);
+6 -49
View File
@@ -75,16 +75,6 @@ interface OrbitState {
dist: number;
/** Blickziel (world). */
target: [number, number, number];
/** Soll die freie Kamera orthografisch bleiben? (true fuer alle Presets ausser
* `perspective`) sonst kippt die erste Kamerabewegung fälschlich in
* Perspektive (locked iso"). */
ortho: boolean;
/** Orthografische Bild-Halbhoehe (Meter), UNABHAENGIG von `dist` gefuehrt: eine
* echte Ortho-Kamera skaliert ihr Bild ueber diese Groesse, nicht ueber den
* Augabstand. Wird beim Preset aus `dist*tan(FOV_Y/2)` geseedet (perspektiv-
* äquivalente Bildhoehe an der eingepassten Distanz kein Sprung beim ersten
* Move) und beim Zoom in Ortho-Modus multiplikativ skaliert. */
orthoHalfHeight: number;
}
/** Fertige (freie, perspektivische) Kamera aus dem Orbit-Zustand (Konvention
@@ -101,12 +91,9 @@ function orbitCamera(o: OrbitState): Camera3d {
],
target: [o.target[0], o.target[1], o.target[2]],
up: [0, 1, 0],
// Projektion aus dem Orbit-Zustand (war hartkodiert `true`/`dist*0.5` — genau
// DAS war der Locked-Iso-Bug: die erste Bewegung nach einem Ortho-Preset kippte
// sofort in Perspektive). Ortho behaelt seine eigene, dist-unabhaengige Halbhoehe.
perspective: !o.ortho,
perspective: true,
fovY: FOV_Y,
orthoHalfHeight: o.orthoHalfHeight,
orthoHalfHeight: o.dist * 0.5,
};
}
@@ -273,20 +260,7 @@ export function Wasm3DViewport({
applyViewPreset(view3d);
const { yaw, pitch } = VIEW3D_ORBIT[view3d];
const { target, dist } = fitTargetDist(projectRef.current);
// Alle Presets ausser `perspective` sind orthografisch (front/top/side/iso, wie
// die Rust-Seite `preset_camera`) → die freie Kamera bleibt danach ebenfalls
// ortho („locked iso"). orthoHalfHeight auf die perspektiv-aequivalente Bildhoehe
// an der eingepassten Distanz seeden, damit der erste manuelle Move/Zoom groessen-
// gleich an das von Rust gezeichnete Preset-Bild anschliesst (kein Sprung).
const ortho = view3d !== "perspective";
orbitRef.current = {
yaw,
pitch,
dist,
target,
ortho,
orthoHalfHeight: dist * Math.tan(FOV_Y / 2),
};
orbitRef.current = { yaw, pitch, dist, target };
redrawRef.current = () => render(null);
redrawRef.current();
}, [ready, view3d, applyViewPreset, render]);
@@ -431,13 +405,9 @@ export function Wasm3DViewport({
Math.min(PITCH_LIMIT, o.pitch + (2 * Math.PI * dy) / h),
);
} else {
// Pan in der Bildebene: Welt-Meter je Pixel. In Ortho aus der (dist-
// unabhaengigen) Bild-Halbhoehe, sonst aus Distanz + Sichtwinkel — sonst
// driftet die Pan-Geschwindigkeit gegen einen Ortho-Zoom, der `dist` gar
// nicht mehr anfasst. Ziel entgegen der Mausrichtung (Szene folgt Cursor).
const worldPerPx = o.ortho
? (2 * o.orthoHalfHeight) / h
: (2 * o.dist * Math.tan(FOV_Y / 2)) / h;
// Pan in der Bildebene: Welt-Meter je Pixel aus Distanz + Sichtwinkel;
// Ziel entgegen der Mausrichtung verschieben (Szene folgt dem Cursor).
const worldPerPx = (2 * o.dist * Math.tan(FOV_Y / 2)) / h;
const pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, o.pitch));
// Kamera-Basis aus dem Orbit: f = Blickrichtung, s = rechts, u = Kamera-oben.
const f = [
@@ -487,19 +457,6 @@ export function Wasm3DViewport({
if (!o) return;
e.preventDefault();
useOrbitRedraw();
// Ortho-Modus („locked iso"): eine echte Ortho-Kamera skaliert ihr Bild ueber
// die Halbhoehe, NICHT ueber den Augabstand — `dist` zu dollyen haette hier
// keinen sichtbaren Effekt. Also orthoHalfHeight multiplikativ skalieren und
// frueh raus (Eye/dist unangetastet). Zoom-to-Cursor bleibt in Ortho zentrisch
// — die cursor-versetzte Dolly-Logik unten ist perspektiv-only.
if (o.ortho) {
o.orthoHalfHeight = Math.max(
0.05,
Math.min(500, o.orthoHalfHeight * Math.exp(e.deltaY * 0.0013)),
);
redraw();
return;
}
const prevDist = o.dist;
// Exponentielles Dolly (rad-deltaY ≈ ±100 je Rastung → ~14 % je Schritt).
o.dist = Math.max(0.2, Math.min(2000, o.dist * Math.exp(e.deltaY * 0.0013)));