Compare commits

..

183 Commits

Author SHA1 Message Date
karim 05bc5aa6e1 DXF-HATCH: Ellipsen- + Spline-Randkanten tesselliert (B-Spline-Sampling extrahiert) 2026-07-05 14:18:49 +02:00
karim d36c84689f Doku: Sprache in Planungs- und Konventionsdokumenten vereinheitlicht 2026-07-05 14:14:40 +02:00
karim 8f9123ce0d Pendenzen: HATCH-Import erledigt vermerkt 2026-07-05 14:13:24 +02:00
karim a44572bf7b DXF-Import: HATCH via Custom-Handler -> gefuellte geschlossene Drawing2D-Flaeche 2026-07-05 14:12:59 +02:00
karim 79ea6b0b3b Pendenzen: SPLINE/INSERT-Import erledigt vermerkt 2026-07-05 13:59:41 +02:00
karim 83da278ed2 DXF-Import: SPLINE (De-Boor-B-Spline) + INSERT (Block-Expansion mit Transform/Array/Verschachtelung) 2026-07-05 13:59:15 +02:00
karim 33c2c6c22e Pendenzen: DXF-Kurven-Import erledigt, Import-Ist-Stand klargestellt 2026-07-05 13:51:37 +02:00
karim b7551d4930 DXF-Import: ARC/CIRCLE/ELLIPSE als tessellierte Konturen (Abdeckungsluecke geschlossen) 2026-07-05 13:51:08 +02:00
karim 25daec6be9 Pendenzen: Join-WASM-Durchstich verworfen (gemessen: TS schneller <100 Waende) 2026-07-05 13:40:58 +02:00
karim 5229174551 Treppe: Vorkonfiguration vor Erstellung (Breite/Referenz/Trittmass)
- Referenz (links/mitte/rechts) + Trittmass-Modus (mit/ohne) als togglebare
  Optionen, Breite als Tab-Feld — bereits in der Idle-Phase (vor dem 1. Punkt).
- Trittmass-Modus 'mit': stepsFromTread(auftritt, runLength) leitet Stufenzahl
  aus Ziel-Auftritt ab (Default IDEAL_TREAD); Feld Auftritt statt Stufen.
- appendStair schreibt Stair.referenz; Geometrie/Outline versetzt entsprechend.
- stairDefaults (modulweit) merkt zuletzt gewaehlte Werte fuer die naechste
  Treppe (Session-RAM; localStorage/projectSlice waere Folgeschritt).
- 10 Tests (stair.tread.test.ts), i18n de/en. vitest 302/302, tsc sauber.
2026-07-05 13:14:06 +02:00
karim df0e5e432a Pendenzen: DWG/DXF-Import-Spike (acadrust wasm32) vermerkt + Folgeschritte 2026-07-05 03:13:04 +02:00
karim 715e950d7c DWG/DXF-Import-Spike: acadrust baut zu wasm32 (Crate dwgimport)
- src-tauri/dwgimport: eigenstaendiges Crate (cdylib+rlib, eigener [workspace]),
  dep acadrust 0.4 (MPL-2.0, pure Rust). parse_dxf_summary(bytes) via
  DxfReader::from_reader + Cursor<Vec<u8>> -> JSON (version/entityCount/
  entityTypeCounts/layerNames). wasm-Fassade parse_dxf_summary_json.
- getrandom wasm_js-Feature noetig (transitiv via ahash) -> als Dep gesetzt.
- Kernbeweis: acadrust kompiliert nach wasm32 (839 KB), DXF-Parse headless
  getestet (5 Entities korrekt). Beweist den DWG/DXF-Import-Weg im Rust/WASM-Kern.
- build:dwgimport-Script, src-tauri/Cargo.toml exclude erweitert.
2026-07-05 03:12:32 +02:00
karim dbf78a9d75 UI: Gruppe-A-Felder in Objekt-Info-Panel verdrahtet
- Treppe: Dropdown Referenz (Links/Mitte/Rechts) vor Laufrichtung.
- Fenster: Feld Fluegel (1-4) nach Bruestung.
- Tuer: Dropdowns Typ (Normal/Wandoeffnung) + Sturzlinien (Keine/Innen/Aussen/
  Beide) vor Anschlag.
- host.ts/selectionInfo.ts Durchreichung, App.tsx-Handler (kind-guard +
  updateOpening/updateStair), i18n de/en. Rueckwaertskompatibel.
  tsc sauber, vitest 292/292.
2026-07-05 03:01:35 +02:00
karim 26fbd11b73 Bauteile Gruppe A (4-6): Treppe-Referenz, Fenster-Fluegel, Tuer-wandoeffnung
- Treppe referenz links/mitte/rechts (Stair.referenz, Default mitte): Perp-Versatz
  referenzMidShift auf start/center, Lauflinie/Pfeil bleiben visuell zentriert.
- Fenster Fluegel-Mittelpfosten (Opening.wingCount 1-4, Default 1): wingCount-1
  Querlinien im Rahmen (window-mullion), nur mittel/fein.
- Tuer-Typ wandoeffnung (Opening.doorType, Default normal): ueberspringt
  Tuerblatt + Schwenkbogen, Laibung/Sturz bleiben.
- Alle Modellfelder optional/rueckwaertskompatibel. 17 neue Tests.
  vitest 292/292, tsc sauber.
2026-07-05 02:33:48 +02:00
karim 6e5998ce72 Bauteile Gruppe A: Treppe-Aussenlinie, Fenster-Bruestung, Tuer-Sturz (Rhino-Vorbild)
- Treppe Aussenlinie/Outline (stair.ts stairOutline): gerade=4-Punkt-Rechteck,
  L=6-Punkt-Polygon via lineIntersect2d, Wendel=2 Boegen+2 Radiallinien; in
  addStairSymbol als durchgezogene Umrisslinie. (Rhino _aussen_gerade/_l/_wendel)
- Fenster Bruestungslinie: bei sillHeight>0 gepunktete Linie auf der Wandachse
  zwischen den Pfosten (Klasse window-sill).
- Tuer Sturzlinien (SIA): gestrichelte Linien an Wand-Innen/-Aussenkante, neues
  optionales Opening-Feld lintelLines (keine/innen/aussen/beide, Default beide),
  rueckwaertskompatibel.
- 12 neue Tests (stairOutline.test.ts). vitest 275/275, tsc sauber.
2026-07-05 01:56:07 +02:00
karim b65c676643 Research: Bauteile Treppe/Fenster/Tuer — Rhino-Plugin als Vorbild
RESEARCH_BAUTEILE_RHINO.md: Gap-Vergleich Rhino (treppe.py 1784Z + fenster/tuer)
vs. TS-Ist, priorisierte Anhebungs-Ansaetze (Gruppe A 2D-sofort / B 2D-mittel /
C 3D). Kern-Gaps: Treppe-Aussenlinie, Fenster-Bruestungslinie, Tuer-Sturzlinien
(SIA, gestrichelt) — fallen mit Schnitt/Ansichts-Darstellung zusammen.
PENDENZEN: Bauteil-Item im Backlog + Phase 5 abgehakt (a608a59).
2026-07-05 01:33:35 +02:00
karim ae18766b01 kernel2d-Port Phase 5: roomArea/ceiling/roomBoundary/stair
- roomArea: polygonArea/perimeter/centroid.
- ceiling: normalizeOutline/isValidOutline/ceilingArea/outlineBBox/
  outlineCentroid/pointInOutline (+ BBox-Struct, serde camelCase).
- stair: defaultStepCount/stairGeometry (gerade/L/Wendel)/stairCut/stairBBox/
  pointHitsStair (StairParams-Struct, strukturgleich; Nullguard ||1e-9 wie TS).
- roomBoundary: detectRooms/roomFromPointInside(Faces)/pointInPolygon
  (planarer Graph, Half-Edge-Faces, Miter-Offset; WallSegment/WallFace).
- Batch-Fassaden + Harness-Slices je Modul (Struktur exakt + Werte).

Verifiziert: vitest 263/263 (33 Parity), tsc sauber, build:kernel2d sauber.
Bekannte Teil-Deckung: detectRooms nur mit Rechtecken (1 Face) getestet —
komplexe Topologie-Reihenfolge nicht mit Zufallsgraphen abgesichert.
2026-07-05 01:30:11 +02:00
karim c7128fe669 Pendenzen: Modellnamen-Referenz in Erledigt-Zeile neutralisiert (Trace-Konvention) 2026-07-05 01:09:39 +02:00
karim b37c9f467b Research: CAD-Ansaetze aus OpenCADStudio/truck/acadrust (am Code studiert)
RESEARCH_CAD_APPROACHES.md: konkrete, priorisierte Ansaetze fuer 'von BIM-Tool
zu echtem CAD' — direkt an geklonten Repos gelernt.
- OpenCADStudio (GPL-3.0, Referenz): generisches Entity-Trait-Modell,
  modeless StepInput-Command-System, 70KB Snap-Engine, universelle Grips,
  CadDocument=DWG/DXF-Objektmodell.
- truck (Apache-2.0): Rust-B-Rep/NURBS-Kernel, Geometrie-Crates WASM-faehig
  und vom wgpu-Rendering trennbar; Booleans/Fillets noch instabil.
- acadrust (MPL-2.0!): pure-Rust DWG/DXF R13-R2018 read+write, alle Deps
  pure Rust -> WASM-tauglich; Kandidat fuers DWG/DXF-Rueckgrat.
- Web-Import-Landkarte (web-ifc/occt-wasm/shpjs/loaders.gl/...) fuer GEO-BLOCK.
PENDENZEN: Strategie-Item im Backlog verlinkt.
2026-07-05 01:08:08 +02:00
karim 08b0b23a68 Pendenzen: kernel2d-Port Phase 4 erledigt (28471c1) 2026-07-05 01:02:00 +02:00
karim 4a26c34db1 kernel2d-Port Phase 4: Trim/Split/Join (Loewenanteil)
Portiert (1:1, reihenfolge-/strukturtreu):
- splitSegmentByCutters, trimSegment, trimPolyline (+ span/nearestParamOnChain),
  extendSegment.
- splitPolylineAtParam, splitClosedByChord (+ dedupeRing), removeSegment.
- splitAtIntersections (+ polylineEdgesAuto, splitOpenByEdgeHits/AtHits, EdgeHit).
- joinChains: greedy i<j-erster-Treffer-dann-Neustart, exakt wie TS
  (splice-Semantik via remove(j)+open[i]=combined).
- Polyline-Struct {pts,closed}; stabile (edge,t)-Sortierung, 1e-6-Dedup.
- 9 Batch-Fassaden + 4 native Unit-Tests.

Harness: 5 neue Paritaets-Bloecke (Struktur EXAKT + Werte), Cutter-/Polylinien-
Generatoren, joinChains mit re-mergebaren Ketten. cargo test 18/18, vitest 247
(5 neu, davon 17 Parity) gruen, tsc sauber, build:kernel2d sauber.
2026-07-05 01:01:03 +02:00
karim 903dc19cec kernel2d-Port Phase 3: Offset (Miter+1e-9-Fallback) + Fillet
- offsetSegment / offsetPolyline: Gehrung via line_intersect, Fallback auf
  verschobenen Endpunkt an EXAKT 1e-9 (nicht EPS) — Selbstschnitte ungeheilt
  wie TS. Dedup der Eingabe innerhalb EPS.
- filletCorner + Fillet-Struct (serde camelCase): acos/tan/sin/atan2-Kette,
  Klemmung cos∈[-1,1], None bei kollinear (<1e-4 / π-θ<1e-4) oder zu kurzem
  Schenkel. Winkel im Diff-Test abs 1e-7 rad (libm-ULP-Drift), Struktur exakt.
- 3 Batch-Fassaden (offset_segment/offset_polyline/fillet_corner) + 4 Unit-Tests.
- Harness: Offset (rel 1e-9) + Fillet (halb kontrolliert/halb Zufall, Winkel
  1e-7) + Golden (L-Ecke, rechter Winkel, kollinear/zu-gross → null).

cargo test 14/14, vitest 242 (3 neu, davon 12 Parity) gruen, tsc sauber.
2026-07-05 00:50:38 +02:00
karim 09c5178c85 Pendenzen: kernel2d-Port als In-Arbeit-Item mit Phasen-Fortschritt (1+2 erledigt) 2026-07-05 00:43:29 +02:00
karim 8750c8f547 kernel2d-Port Phase 2: Primitive/Schnitt/Flaeche/Kreis + Differential-Harness
Rust-Port (1:1 aus kernel2d.ts, exakte Term-Reihenfolge/EPS-Politik):
- Primitive: dist, vecEqual, projectParam, closestPointOnSegment,
  pointSegmentDistance.
- Schnitt: Hit, segmentIntersect, lineSegmentIntersect, polylineEdges,
  segmentPolylineHits (stabile Sortierung, 1e-6-Dedup).
- Kreis: lineCircleIntersect (Disc B*B-4*A*C + Klemmung), segmentCircleIntersect,
  circleCircleIntersect.
- Flaeche: signedArea (Shoelace, identische Vertex-Reihenfolge), isCCW.
- 11 Batch-WASM-Fassaden (JSON rein/raus) + 10 native Unit-Tests.

Differential-Harness (src/geometry/kernel2d.parity.test.ts):
- Rust-WASM (initSync, readFileSync) gegen TS-Referenz kernel2d.ts, seed-basierte
  Zufallseingaben (Cluster nahe 0 / an Schwellen) + Golden-Grenzfaelle
  (parallel/kollinear/Null-Laenge, Tangente, konzentrisch/getrennt/innen-tangential,
  Null-Flaeche). Struktur exakt, dann Werte mit op-Epsilon (coord/param abs-rel 1e-9,
  Flaeche rel 1e-9).
- Skippt sauber ohne gebautes pkgKernel2d (git-ignoriert), bricht die Suite nicht.

cargo test 10/10, vitest 239 (9 neu) gruen, tsc sauber, build:kernel2d sauber.
2026-07-05 00:43:03 +02:00
karim 028a3637b4 Pendenzen: Textur-Spike visuelle Abnahme bestanden 2026-07-05 00:33:39 +02:00
karim 74eecf7a73 Pendenzen: Textur-Spike render3d erledigt (8556037), aus Queue in Verlauf 2026-07-05 00:30:58 +02:00
karim 0ca3b1dd57 render3d Textur-Spike: RenderStyle::Textured real (Bild-Textur auf Waenden)
- Zweiter Vertex-Pfad [pos,normal,uv] via build_walls_mesh_textured, ABGELEITET
  aus dem fertigen Mesh (Positionen/Normalen/Indizes 1:1) -> Alt-Pfad
  [pos,normal,color] bitgleich; per Regressionstest belegt.
- Prozedurale 256x256-Schachbrett-Textur (kein Asset, kein image-Crate),
  Sampler Linear/Repeat, Textur-Bind-Group group 1 (Globals bleibt group 0).
- MESH_TEXTURED_WGSL: gleiche Beleuchtung wie MESH_WGSL, Albedo aus textureSample.
  Pipeline in Depth/MSAA/Color-Target bitidentisch zur Haupt-Pipeline.
- UV planar in Metern: Mantel u=entlang Achse/v=Hoehe, Deckel u=x/v=z;
  weltraumstabil, keine Verzerrung an Gehrungen. 1 Kachel = 1 m.
- spike3d: Taste T schaltet Shaded <-> Textured zur Laufzeit (kein Re-Meshing).
- Feature-gegatet, Default-Build/-Darstellung unveraendert. cargo test 58 (default)
  / 59 (--features render, inkl. naga-Test MESH_TEXTURED_WGSL) gruen.
2026-07-05 00:30:34 +02:00
karim ecefe61611 Pendenzen: Textur-Spike render3d in Queue (Als Naechstes)
- SPIKE_TEXTUR_render3d.md: Auftrag/Uebergabe fuer den kleinsten ehrlichen
  Durchstich (RenderStyle::Textured real: UVs, prozedurales Schachbrett,
  Textur-Bind-Group, MESH_TEXTURED_WGSL, spike3d umschaltbar).
- PENDENZEN: Spike als konkretes Item unter 'Als Naechstes', verlinkt am
  bestehenden Textur-/PBR-Backlog-Eintrag als dessen erster Durchstich.
2026-07-05 00:12:06 +02:00
karim 2d96a864da kernel2d-Port Phase 1: Crate-Skelett + WASM-Fassade + build:kernel2d
- src-tauri/kernel2d: eigenstaendiges Crate (cdylib+rlib, eigener leerer
  [workspace]), Feature web (wasm-bindgen) und additives robust-predicates.
- Vektor-Helfer 1:1 aus src/model/geometry.ts portiert (hypot-len,
  normalize-Nullguard, hartkodiertes 1e-9 in line_intersect) + Unit-Tests.
- Leere Batch-Fassade kernel2d_normalize_json als WASM-Grenzen-Ping.
- package.json: build:kernel2d; src-tauri/Cargo.toml: workspace-exclude.
- PORT_PLAN.md: Portierungsplan (Scope, Crate-vs-Port, Diff-Harness, Phasen).
2026-07-05 00:09:12 +02:00
karim dec431579e geometry-Crate zu WASM baubar (Feature web, compute_joins_json); aus cad-tauri-Workspace ausgeschlossen
Erster Schritt der Rust-Kern-Migration: die Join-Geometrie wird per wasm-pack zu
WASM gebaut (build:geometry) und exportiert compute_joins_json (JSON rein/raus).
Damit kann das TS-Frontend kuenftig die EINE Rust-Implementierung aufrufen statt
des TS-Duplikats. Crate wie render2d/render3d aus dem Workspace excludet, bleibt
per Pfad-Dep fuer den nativen Host nutzbar. Verhalten unveraendert (noch nicht
verdrahtet). geometry cargo test 8/8, cad-tauri cargo check ok.
2026-07-04 23:44:18 +02:00
karim 9520191bde Pendenzen: Darstellungs-Slots cut/Aufsicht/Untersicht ergaenzt (Deckenspiegel-Fall) 2026-07-04 23:29:02 +02:00
karim 9136cad0ee Pendenzen: Bug-Fixes verbucht; Backlog-Item Schnitt- vs. Ansichtslinie nach Schnitthoehe 2026-07-04 23:27:24 +02:00
karim c7e080f639 Grundriss: Decken-Umriss an Wand-Footprints clippen (Decke liegt darunter)
Die Decke ist ein Slab ueber der Schnittebene; ihre Umrisslinie soll dort, wo
eine Wand darueber steht, NICHT durch die Wand-Poche schlagen (glPlan zeichnet
alle Fuellungen, dann alle Linien -> Kontur landete sonst ueber den Waenden).
Die Fuellflaeche wird jetzt strokelos gezeichnet, der Umriss nur ueber die
Teilstuecke, die KEIN Wand-Footprint (OBB) verdeckt. Deckungsgleiche Decke
(Umriss = Wand-Mittellinien) -> Umriss entfaellt ganz; nur Ueberstaende bleiben.
2026-07-04 23:26:00 +02:00
karim aa4205bf0b T-Stoss: materialgleicher Nah-Putz verschmilzt ohne L-Trennnaht (TS+Rust)
Die L-Seitenlinie am T-Stoss wird nur noch gezeichnet, wenn der getrimmte
Abzweig-Putz materialFREMD zum durchgehenden Nah-Putz der Durchgangswand ist.
Bei materialgleichem Putz (z. B. iw-Innenputz auf iw-Innenputz) fuellt der
Nah-Putz die Putzflanken durchgehend (spanCutout schneidet nur die Kernbreite)
-> durchgehendes Putz-L ohne Naht. Tests entsprechend gezogen.
2026-07-04 23:17:18 +02:00
karim 9a80d9cf42 Snap-Farbe Sora #5FA1C9 als Default festgeschrieben; Pendenzen-Queue auf verifizierten Stand 2026-07-04 22:46:21 +02:00
karim ea86cf7456 Doku: Recovery-Stand 2026-07-04 (alle drei 3b-Slices gelandet)
PENDENZEN: Oeffnungen als Boolean-Loecher (1407c68) nach Erledigt. HANDOVER:
Slice 3 committet, Environment-Behebung + Push-Blocker dokumentiert.
2026-07-04 22:19:19 +02:00
karim 05cfade483 Nordstern-3D: Fenster/Tueren als echte Boolean-Loecher in EINEM Wandkoerper
Bisher zerlegte emitWall eine Wand um jede Oeffnung in Pfeiler/Bruestung/Sturz-
Teilquader -> sichtbare Segment-Naehte im 3D, Oeffnung war semantisch kein Loch,
und versetzt uebereinanderliegende Fenster liessen sich gar nicht abbilden. Neu:
im 3D-Pfad (layered=true) EIN RWall pro Schicht-Band ueber die volle Achse mit
allen Oeffnungen als rechteckige holes; render3d stanzt sie per achsparalleler
Rechteck-Gitter-Zerlegung der Langseiten aus und setzt 4 Laibungsquads je Loch.
holes und openings schliessen sich im Emitter gegenseitig aus. Tuer = Loch bis
zum Wand-zBottom. Schnitt-Pfad (layered=false) segmentiert unveraendert weiter
(wallSegmentOwners bleibt synchron, toSection.ts unangetastet).

Zusatz: trimWallTopForCeilings (Deckentrim, e4b8df6) gilt jetzt NUR im 3D-Pfad
(layered=true, Z-Fighting-Vermeidung) und wird im Schnitt-Pfad uebersprungen -
der Schnitt braucht die volle Wandhoehe fuer seine schichtweise Prioritaets-
Subtraktion (Kontrolle von Schichteinzug/Bodenaufbau am Wand-Decken-Anschluss).

RWall.holes / WallInput.holes additiv (#[serde(default)]). Pflicht-Testfaelle:
zwei ueberlappende, hoehenversetzte Fenster in einem Koerper; Tuer-Loch bis Boden
ohne untere Laibung. cargo test 56 gruen, vitest 230 (+3), build:engine3d + tsc
sauber.
2026-07-04 22:17:21 +02:00
karim 87a30b7061 Pendenzen: zentrale Aufgaben-Queue (PENDENZEN.md), HANDOVER auf Kontext geschlankt
PENDENZEN.md als Single Source of Truth fuer Aufgaben eingefuehrt
(priorisierte Checkliste + Arbeitsprotokoll: Worker liest zuerst,
arbeitet top-down, Rueckfrage-Recht). Backlog/Rueckfragen/IN-FLIGHT
vollstaendig aus HANDOVER migriert; HANDOVER enthaelt nur noch
Kontext/Konventionen/Environment/Zielmodelle. Stand-Korrektur:
Locked-Iso (cb8fae5) und Joins Phase 1c (c5a344d) sind gelandet,
nur Oeffnungen-als-Boolean-Loecher noch offen.
2026-07-04 20:00:48 +02:00
karim 23dddf893e Joins Phase 1c: Durchgangswand am T-Stoss echt aufbrechen (spanCutouts)
Am materialbewussten T-Stoss wurde die Durchgangswand bisher nur uebermalt
(Zeichenreihenfolge), nicht geometrisch ausgeschnitten: ihr Nah-Putz-Band, die
Schichtfugen- und die Nahflaechenlinie liefen weiter ueber die Durchstoss-Breite
des Abzweig-Backsteins. Neu liefert computeJoins erstmals auch Cuts fuer die
DURCHGANGSWAND: WallCuts.spanCutouts (Achsen-Intervall = Projektion der Abzweig-
Kernbreite, Quer-Offsetzone = Nahflaeche bis Rueckgrat-Nahflaeche = Nah-Putz-
Tiefe aus Phase 1b). addWallPoche splittet betroffene Schicht-Baender entlang der
Achse in Teil-Baender vor/nach dem Intervall, unterbricht die Schichtfugen im
Merge-Bereich und macht die Nahflaechen-Umrisskante ueber dem Durchstoss
strokelos (separate Segmente davor/danach) — keine Trennlinie an der
Verschmelzungsflaeche, der T-Stoss liest als EIN Join. Rust-Paritaet additiv
(span_cutouts), Aggregat-startCut/endCut unveraendert -> parity gruen.

vitest 227 (+5), cargo 8 (+1), tsc sauber.
2026-07-04 19:22:32 +02:00
karim 45ded83294 Nordstern-3D: Locked-Iso — freie Kamera bleibt nach Ortho-Preset orthografisch
Iso/Front/Top/Side kippten bei der ersten Kamerabewegung sofort in Perspektive,
weil orbitCamera() Projektion und Ortho-Halbhoehe hartkodiert hatte
(perspective:true / dist*0.5). OrbitState fuehrt jetzt ortho:boolean +
orthoHalfHeight:number: der Preset-Effekt setzt ortho = view3d!=='perspective'
und seedet orthoHalfHeight auf dist*tan(FOV_Y/2) (perspektiv-aequivalente
Bildhoehe an der eingepassten Distanz -> kein Sprung beim ersten Move). Pan
rechnet worldPerPx in Ortho aus der Halbhoehe statt aus dist; Zoom skaliert im
Ortho-Modus orthoHalfHeight multiplikativ (0.05-500) und laesst dist/eye
unangetastet. Perspektive-Preset unveraendert.
2026-07-04 18:51:35 +02:00
karim 77f32f14ce 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 07042ebc75 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 ecf262ef07 HANDOVER: Stand 2026-07-04 (Prioritaets-Joins, Schnitt-Sanierung) 2026-07-04 15:00:55 +02:00
karim 9acce9d7cd 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 cf8b638f33 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 20cf870c4c 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 e095b51d15 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 65d6e843fb 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 c79d15dda4 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 6035b8a8c2 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 6c04935711 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 8c96a5dfb9 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 8547d38e9a 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 41d8fafa4e 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 0d3a0a081f 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 d1df9d4d5c 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 e11743d6e7 HANDOVER: weitere Commits (z-Anordnen, Wand-Bänder, A6-CSV, Glasscheiben) ergänzt 2026-07-04 12:53:22 +02:00
karim c54a2f916c 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 fde27f6838 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 00c90857ad 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 018fef56bf 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 1db661f19a HANDOVER: Stand 2026-07-03 (TopBar-Redesign + 3D-Editierbarkeit R1-R5) + 3D-Rest notiert 2026-07-04 06:32:48 +02:00
karim 6ea562ceda 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 e137353b95 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 16d32223a4 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 8f4fac633f 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 d4065c5a41 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 d0b94a75ec 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 8b6b9291a1 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 7e8764b21b Hex-Feld breiter + helleren Text (voller Hex-Code sichtbar) 2026-07-04 05:17:29 +02:00
karim efc9dd87ce 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 f724c088e0 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 44942e6976 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 e0d71691e1 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 9f6d4e8858 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 19d002d403 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 2c9633438d 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 19fcafbcab 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 4aed168760 HANDOVER: 3 ungeklärte Punkte festhalten (Isometrie, TopBar Detail, DOSSIER konkret) 2026-07-04 02:30:56 +02:00
karim 2eeafb9aca HANDOVER: Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen, offener Backlog) 2026-07-04 02:23:33 +02:00
karim 7d3e0943e1 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 d0f26cd874 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 777e02c927 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 c34b7e5771 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 b55ab9eb8f 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 bd13495923 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 683242ba78 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 e54bb318bb Statusleiste: Renderer-Umschalter 'Engine' -> 'Nordstern' 2026-07-04 01:28:26 +02:00
karim a302d512d9 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 f09ba0e49f 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 07475cbe6c 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 87ef7988d1 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 f3966f99e9 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 f3639cfb15 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 6288f755a8 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 724db0f9bf 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 c2d6893a59 Verschiebe-Dreieck flacher (Apex ~124 statt ~60 Grad) 2026-07-04 00:07:41 +02:00
karim c6d2641664 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 3d4c4985a9 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 912f0bc16d 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 ac7af9b9ef TopBar: Text-Gruppe zwischen Ebenen-Kombis und Detailgrad/Massstab 2026-07-03 23:57:08 +02:00
karim 400ec890b0 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 558a24cf98 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 e29f5a2a7c 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 b03614c35b 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 1086225f7b 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 ca1ed1b0d2 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 04c7334cc4 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 93b70fa95a 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 fae4f6fcb6 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 d65d84183a 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 1f6da09b2b 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 5fd0161bc7 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 f4e70902d0 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 af0b044fca 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 692dd3f719 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 4d721a62f7 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 b028fc6b31 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 581ddccf1a 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 1fd688896c 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 f9a5e763fd 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 ec2568276d 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 2b6b99059d 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 658536029b 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 675c68de55 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 e9dc423a12 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 6aaef8f46a 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 eaf6d8ed7b 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 d30f79e162 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 bde1f3f8d1 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 2dd7fe339d 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 3c4d983a7d 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 094d8b4504 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 330c01bbbd 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 d2503c1191 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 e3f460c9f8 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 28b5492def 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 4a62dbf83c HANDOVER: Stand 2026-07-03 — Nordstern 2/3/4/5 gelandet, Schnitt-UI-Anbindung offen 2026-07-03 18:18:52 +02:00
karim 8a79f6cbaa 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 9371b65b3b 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 e8f5e7272d HANDOVER: Stand Stand 2026-07-03 (Engine-Meilensteine, offene Arbeit) 2026-07-03 08:42:27 +02:00
karim f95df75ea1 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 918f60c498 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 23410f9b9e 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 98994c96aa 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 0e484746de 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 87d12b976f 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 519735c782 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 47f1c24bb3 README: keine Browser-App mehr, sondern Electron-Desktop-Shell mit eigener Engine 2026-07-03 00:10:24 +02:00
karim 1bbcc7038f 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 e6466a5027 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 1ab8c7e496 Renderer-Umschalter: Wahl in localStorage persistieren (uebersteht Neustart ohne URL-Param) 2026-07-02 23:38:05 +02:00
karim 0e839b50d6 UI: Renderer-Umschalter (WebGL2 / eigene Engine) in der Statusleiste 2026-07-02 23:35:01 +02:00
karim 623b243306 WASM-Viewport: SVG-Raumtext bei aktiver Engine unterdruecken (kein Doppeltext) 2026-07-02 23:28:25 +02:00
karim 6a7bbeec2f HANDOVER: naechster Schritt = Electron-Prototyp 2026-07-02 23:18:27 +02:00
karim cbf54e6b65 HANDOVER: Richtungsentscheidung Electron/Chromium+WASM statt Tauri; all-native als Fernziel 2026-07-02 23:02:35 +02:00
karim 543d06adb5 HANDOVER: Engine-Nordstern — exakte Breiten, Druck=Bildschirm, Headless, HLR, WebKit-Unabhaengigkeit 2026-07-02 22:31:27 +02:00
karim a3b25da96d 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 2a2b3b294c 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 926dedca40 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 4311a7dfbd 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 31ac91b2a7 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 0189eed5ac 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 45f9d0c381 3D-Wände mit Öffnungen: Türen/Fenster als Teilquader (Pfeiler+Brüstung+Sturz) 2026-07-02 21:00:19 +02:00
karim faa84e98af 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 e82f0b6de1 2D-Umrisse polygonuebergreifend naehen: Gehrung an Wand-zu-Wand-Ecken 2026-07-02 20:12:29 +02:00
karim 128c04a5d0 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 19f99382f6 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 ec181998ca 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 c400a96575 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 b4c3c2de4a 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 788f4d58ca 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 229169cea1 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 f4cd16b7ac 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 e7ea1eeddd 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 c96239a794 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 260320af2e 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 f08ef13fe8 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 3d2d4d6321 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 cfe5249440 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 b9731a4979 Add parametric walls design documentation 2026-07-01 20:32:07 +02:00
karim ce3b594403 Add parametric wall types and engine skeleton 2026-07-01 20:06:19 +02:00
karim 94a5af6b6f HANDOVER: Koordinations- und Arbeitsregeln ergaenzt 2026-06-30 21:45:10 +02:00
51 changed files with 8694 additions and 202 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ Rendern angewandt, nie in die Geometrie eingebacken.
## Arbeitsweise (für Beiträge)
- Substanzielle, mehrstufige Arbeit an **Subagenten** delegieren, wo möglich.
- Substanzielle, mehrstufige Arbeit schrittweise in isolierten Schritten angehen.
- Ä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.
+10 -11
View File
@@ -4,25 +4,25 @@
> **➡️ 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 Session 3b (2026-07-04 abends)Session hart abgebrochen (Spend-Limit), 3 Slices mitten in der Arbeit VERLOREN
## ⚠️ 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. Der zuständige Agent stallte kurz vor Schluss (Watchdog), aber alle Datei-Edits waren gelandet — Hauptinstanz hat 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.**
> **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 letzte Session endete nicht durch normales Handover, sondern weil das monatliche Spend-Limit des KI-Anbieters **mitten in laufender Arbeit** griff (mehrere Agenten liefen parallel, „arbeite die nächsten 2h selbstständig"-Direktive des Nutzers). Diese Übergabe wurde nachträglich aus dem rohen Chat-Transkript (`Edit MEMORY md Added 1.txt`, lag im Repo-Root, siehe Aufräum-Hinweis unten) 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":
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 von einem Agenten (kleineres Modell) fertig gebaut UND bereits quantitativ verifiziert (Parallelogramm-Kantenvergleich vor/nach Orbit per Screenshot-Analyse, 225/225 Tests grün), aber **nicht mehr committet**, bevor die Session starb — der komplette Fix-Ansatz inkl. Diff-Beschreibung steht im Transkript ab „Bash Verify locked-iso file isolated". 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. Der Agent (grosses Modell) war weit in der Umsetzung (schrieb bereits in `joins.ts`), letzte sichtbare Nachricht vor dem Abbruch: „The join looks correct — W9's brick runs through and W1's near plaster is notched" (Agent wollte gerade einen finalen Bestätigungs-Screenshot holen) — dann `failed` durch API-Fehler (Spend-Limit). **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.
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 Memory-Direktiven aus der alten Session sind ebenfalls weg** (`priority-based-joins.md`, `openings-as-booleans.md` — auf der alten Maschine im Instanz-Memory-Verzeichnis abgelegt, existieren auf diesem Gerät nicht): die in Punkt 2/3 beschriebenen Zielverhalten waren dort als dauerhafte Engine-Direktiven 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 ins Memory dieser Instanz schreiben, nicht nur einmalig in den Auftragstext — sonst geht dieselbe Information beim nächsten Abbruch wieder verloren.
**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` (roher Chat-Export dieser Session, lag unversioniert im Repo-Root) und die lokale `package-lock.json`-Änderung (vermutlich von einem `npm install` auf diesem Gerät) waren dirty/untracked. Die txt-Datei gehört NICHT ins Repo (Konvention: keine KI-Spuren) — außerhalb des Repos verschieben oder löschen, sobald nichts mehr daraus gebraucht wird. `package-lock.json` prüfen (`git diff package-lock.json`) und nur committen, falls die Dependency-Änderung beabsichtigt ist.
**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.
@@ -75,10 +75,9 @@ Die letzte Session endete nicht durch normales Handover, sondern weil das monatl
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 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.
- **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.
- **NICHT ANFASSEN (fremde WIP):** `.gitignore`, `public/assets/materials/manifest.json`, `src/materials/library.ts`, `scripts/fetch-materials.mjs` — bleiben dauerhaft dirty, nie stagen.
- **Agenten-Regeln:** Subagenten default **Sonnet** (Nutzer-Wunsch 2026-07-04); Opus nur bei intricater Geometrie. Agenten dürfen NICHT weiterdelegieren (Agent/Task-Tool verbieten — sonst Spawn-Schleifen), NICHT committen, keine Memory. Hauptinstanz verifiziert (`tsc --noEmit` clean + `vitest run`) + trace-scan, committet dann selbst, staged nur die Task-Dateien (Kollisionen: parallelen Agenten disjunkte Datei-Lanes geben; i18n exklusiv einem Agenten).
- **Budget:** Session-Ende bei 98% — Sparflamme.
- **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).
- **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)
@@ -86,7 +85,7 @@ Schraffur/Linien-Epic komplett: Typmodell (vector/image Hatch, dash/zigzag/custo
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 (Worker liest zuerst PENDENZEN, arbeitet top-down, hakt ab, Rückfrage-Recht via ❓).
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.
## Verifikation
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
+39 -13
View File
@@ -5,14 +5,14 @@
## Arbeitsprotokoll (ZWINGEND)
**Worker-Instanz** (führt aus):
**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. Worker **committet nicht selbst** und delegiert nicht weiter (Agent/Task verboten) — das macht die Hauptinstanz. (Konvention HANDOVER.)
5. Bearbeiter **committet nicht selbst** und delegiert Unteraufgaben nicht weiter.
**Planungs-Instanz** (mit dem Nutzer, kuratiert):
**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.
@@ -20,31 +20,52 @@
## ⛔ Blocker (zuerst klären)
- [ ] **Toolchain prüfen:** `which node npm cargo wasm-pack`. Auf dem alten Gerät fehlten sie (vermutlich Flatpak-Sandbox von VSCodium). Nutzer wechselt auf natives VSCodium → evtl. behoben. Nur falls immer noch nichts: Node LTS + Rust (rustup) + `wasm-pack` installieren, `npm install`, einmal `cargo build --manifest-path src-tauri/render3d/Cargo.toml`. **Ohne Toolchain keine Verifikation → keinen Engine-Slice blind bauen.** | Detail: HANDOVER §Nachtrag-3b, Environment-Lücke
- [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
_(leer — Worker trägt hier ein, was er gerade anpackt)_
- [ ] **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)
- [ ] **Snap an Wand-Schichttrennlinien** (war Agent-Slice, Session 3): `git status`/`git log` prüfen — sollte `src/tools/snapping.ts` (+ ggf. `src/model/geometry.ts` + Test) geändert haben. Falls nicht gelandet: neu beauftragen (Schicht-Offsetlinien aus `addWallPoche`-Logik in generatePlan spiegeln [nur LESEN], als Snap-Targets einspeisen). | Detail: HANDOVER §IN FLIGHT Session-Ende
- [x] ~~**Snap an Wand-Schichttrennlinien**~~**gelandet `339202b`** (`wallLayerBoundarySegments` in `src/tools/snapping.ts`), verifiziert vorhanden.
## ⏭️ Als Nächstes
_(Öffnungen als Boolean-Löcher ist jetzt in 🔧 In Arbeit)_
- [ ] **SPIKE — Bild-Texturen in `render3d` (`RenderStyle::Textured` real machen).** Kleinster ehrlicher Durchstich: beweisen, dass der wgpu-3D-Renderer echte Bild-Texturen auf Wandflächen zeigt, sichtbar im `spike3d`-Fenster. **Voller Auftrag/Übergabe: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md)** (Ausgangslage verifiziert, Symbol-Karte, exakter Umfang, harte Randbedingungen, Akzeptanz, Abschlussbericht). Kurz: UVs auf Wandflächen (planar in Metern, Alt-Pfad `[pos,normal,color]` bitgleich unangetastet) · prozedurales 256×256-Schachbrett (kein Asset, kein `image`-Crate) · Textur-Bind-Group group 1 (Globals bleibt group 0) · `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`) · Verdrahtung bei `RenderStyle::Textured` · `spike3d` umschaltbar (`T`). **Nur `render3d`-Crate, keine neuen Deps, Default-Build unverändert.** Akzeptanz: `cargo test` grün (Mesh-Regression + naga-Test für `MESH_TEXTURED_WGSL`), `spike3d` zeigt weltmassstäbliche Textur. Ist der **erste Durchstich der Textur-/PBR-Pipeline** (§3D-REST unten) → am Ende ehrliche Lückenliste + Aufwandsschätzung für „richtig gutes" Texturing.
## 📋 Backlog (Priorität grob absteigend)
- [ ] **Ebene-Schraffur editierbar:** „Nach Ebene"-Schraffur ist im Resolve fertig, aber `LayerCategory.hatch` im Kategorie-Dialog (`App.tsx` ~3350, editiert nur name/color/lw) noch nicht editierbar. Kleine Folge-Arbeit.
- [ ] **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, sofort, kernel2d): (1) **Treppe Aussenlinie/Outline** (gerade/L/Wendel), (2) **Fenster Brüstungslinie** im Plan, (3) **Tür Sturzlinien** (gestrichelt, SIA), (4) Treppe Referenz links/mitte/rechts, (5) Fenster Flügel-Mittelpfosten, (6) Tür `wandoeffnung`-Typ. Gruppe B (2D mittel), Gruppe C (3D, nach Mesh-Pipeline/truck). **A-Items 13 fallen mit „Schnitt- vs. Ansichts-Darstellung" zusammen** (Sturz = Bauteil unter Schnittebene → gestrichelte Überkopf-Projektion).
- [ ] **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. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Ellipse-/Spline-Kanten übersprungen; Insel-Loops = eigene Ringe (keine echten Löcher). **Weiter offen (grob absteigend):** TEXT/MTEXT (→ editierbarer Plan-Text via Drawings-Import, Nutzer-bestätigt); dann 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).** 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.
- [ ] **ResourceManager Bauteile-Tab** auf Master-Detail (wie Wandstile) — BEVOR Bauteil-Logik angefasst wird.
- [ ] **Einstellungs-Fenster:** Auswahlrahmen Default **Yuyake #CEB188**; Snap/Endpunkt Default = ❓„aki" (s.u.); freier Picker + Accents-Presets; verdrahten in PlanView (Selektionsrahmen + Snap-Marker). Dazu Projekt-MüM-Feld.
- [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).
- [ ] **Linienstile aufräumen** (Weight-only-Stile → „Volllinie", lineStyleId-Referenzen remappen).
@@ -53,14 +74,14 @@ _(Öffnungen als Boolean-Löcher ist jetzt in 🔧 In Arbeit)_
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
### 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).
- [ ] **Wand-Schicht-Bänder in 3D** Option B (jede Materiallage als eigener extrudierter Teilquader statt repräsentativer Farbe — pro-Schicht-Farben in `RWall.layers` liegen vor).
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). **Erster Durchstich als Spike terminiert → [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md) (steht unter ⏭️ Als Nächstes).**
- [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)
- [ ] **„aki"** = welcher Accent für Snap/Endpunkt-Farbe? (nicht in Accents: Ajisai/Sakura/Suna/Ichigo/Yuyake/Sora/Kusa/Kori/Amagumo/Yuki; vorläufig Sora #5FA1C9)
- [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?
@@ -71,6 +92,11 @@ _(Öffnungen als Boolean-Löcher ist jetzt in 🔧 In Arbeit)_
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
- [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`
+124
View File
@@ -0,0 +1,124 @@
# 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
@@ -0,0 +1,43 @@
# 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
@@ -0,0 +1,162 @@
# 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
@@ -0,0 +1,137 @@
# 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 @@
# HAUPTINSTANZ-BRIEFING: Tauri + wgpu (Korrigiert)
# ARCHITEKTUR-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 (für Hauptinstanz)
## Nächste Schritte
1. **Lesen:** `docs/design/tauri-migration-plan.md` (technisch, konkret)
2. **Spawn:** vier Agents (parallel, unabhängig)
+2 -4
View File
@@ -1,10 +1,8 @@
# DOSSIER-Feature-Audit (A1A6, B1B4, C1C3, D1D3, E)
Ausführliche Fassung des Audits, auf das HANDOVER.md Backlog-Punkt 9 nur noch mit
Einzeilern verweist. Ursprünglich von einem Opus-Subagent erarbeitet, der das
DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen den aktuellen Browser-Port
abgeglichen hat — das Ergebnis lag bisher nur in einem Session-Transkript, nicht
im Repo. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
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`.
Status-Symbole: ❌ nicht übernommen · 🟡 teilweise übernommen.
+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
AI-Spuren (grep auf Claude/Anthropic/AI/Generated/Co-Authored), Boot-Probe
Trace-Scan (grep auf Co-Authored/Generated), Boot-Probe
(`node scripts/probe.mjs`) ohne Konsolenfehler, Screenshot der Oberleiste.
KEIN Commit.
+6 -7
View File
@@ -1,13 +1,12 @@
# Briefing — Nativer wgpu-2D-Renderer (parallele Instanz)
> Für die Instanz/den Agenten, der den nativen GPU-Renderer baut. Isoliert vom
> Für den Entwickler, der den nativen GPU-Renderer baut. Isoliert vom
> Web-Renderer; koordiniert über dieses Dokument.
## COMMIT-REGEL (verbindlich)
Dieses Repo darf **keinerlei Hinweise auf KI-Werkzeuge** enthalten — nicht im Code,
in Kommentaren oder in der Git-Historie. Keine `Co-Authored-By`-Trailer, keine
„Generated with"-Zeilen, keine Modell-/Agenten-Namen. Kommentare deutsch,
Identifier englisch. **Kein Commit ohne Ansage der Hauptinstanz.**
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.**
## Warum
Der 2D-Plan wird aktuell in einem WebGL-Canvas gerendert (Web-Stack). In Chromium
@@ -72,9 +71,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 (die Hauptinstanz arbeitet dort an Features).
`src/plan/*`, `types.ts` anfassen (dort laufen parallel Features).
- Der Web-WebGL-Renderer bleibt bestehen (Browser-Lauffähigkeit + Referenz).
- Ergebnisse/Diffs zurückliefern; die Hauptinstanz committet und pflegt das Memory.
- Ergebnisse/Diffs zurückliefern; Projektleitung committet.
## Gates
`cargo check`/`cargo test`/`cargo build` grün. Trace-Scan sauber (COMMIT-REGEL).
+1 -3
View File
@@ -6,9 +6,7 @@
> Port-Plan) und M1 (entkoppelter Standalone-Spike). Noch NICHT die Migration.
## Commit-/Spuren-Regel
Wie im ganzen Repo: keine Hinweise auf KI-Werkzeuge — nicht im Code, in Kommentaren
oder in der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne
Ansage der Hauptinstanz.
Wie im ganzen Repo: keine Fremd-Tool-Hinweise im Code, in Kommentaren oder der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne Absprache.
## Warum
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
+4 -1
View File
@@ -16,7 +16,10 @@
"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: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"
},
"dependencies": {
"@mlightcad/libredwg-web": "^0.7.7",
+2
View File
@@ -1488,8 +1488,10 @@ dependencies = [
name = "geometry"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
+10 -6
View File
@@ -1,10 +1,14 @@
[workspace]
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"]
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"]
[package]
name = "cad-tauri"
+578
View File
@@ -0,0 +1,578 @@
# 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
@@ -0,0 +1,36 @@
# 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
@@ -0,0 +1,284 @@
// 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,9 +2,28 @@
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]
+41 -10
View File
@@ -9,6 +9,20 @@
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,
@@ -228,6 +242,18 @@ fn apply_layer_cuts(
// 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 {
@@ -238,7 +264,13 @@ fn apply_layer_cuts(
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 };
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
};
}
}
@@ -936,8 +968,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 und bekommen
// zusaetzlich die seitliche L-Linie an derselben Flaeche.
// Innenputz-Schichten (Index 0/2) werden getrimmt. Da der Nah-Putz der
// Durchgangswand materialgleich ist, entsteht KEINE L-Trennnaht.
let input = JoinInput {
walls: vec![
w_iw("A", 0.0, 0.0, 5.0, 0.0),
@@ -972,13 +1004,12 @@ 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);
// ... 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);
// ... 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());
// Die kollineare Durchgangswand bleibt bei den layerCuts unangetastet.
let ca = find(&out, "A");
+195
View File
@@ -0,0 +1,195 @@
# 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
@@ -0,0 +1,40 @@
# 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
+27 -2
View File
@@ -13,13 +13,14 @@
use std::sync::Arc;
use render3d::gpu::Renderer;
use render3d::gpu::{RenderStyle, Renderer};
use render3d::math::orbit_eye;
use render3d::types::{Camera, Projection, WallInput};
use winit::application::ApplicationHandler;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event::{ElementState, KeyEvent, 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
@@ -196,6 +197,9 @@ 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 {
@@ -223,6 +227,27 @@ 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;
+283 -5
View File
@@ -15,11 +15,13 @@ 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};
use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh};
use crate::section::SectionPlane;
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX};
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_WGSL};
use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX};
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,
};
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
/// wireframe/hidden). Bestimmt, welche Pipelines je Frame gezeichnet werden und
@@ -30,7 +32,9 @@ pub enum RenderStyle {
Shaded,
/// Clay-Look: einheitlich helles Material, beleuchtet.
White,
/// Mangels Textur-Pipeline VORERST identisch zu `Shaded` behandelt (kein Fake).
/// Echte Bild-Textur (prozedurales Schachbrett) auf den Wandflaechen,
/// beleuchtet wie `Shaded` (eigene Pipeline + Textur-Bind-Group group 1,
/// siehe `textured_pipeline`).
Textured,
/// Nur Modell-Kanten (Feature-Edges), keine gefuellten Flaechen.
Wireframe,
@@ -132,6 +136,55 @@ 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).
@@ -172,9 +225,29 @@ 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.
@@ -462,6 +535,144 @@ 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,
@@ -483,9 +694,14 @@ 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,
@@ -529,6 +745,7 @@ 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;
}
@@ -564,6 +781,27 @@ 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
@@ -783,6 +1021,33 @@ 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;
@@ -825,7 +1090,20 @@ impl Renderer {
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
// die folgenden Kanten sauber obenauf liegen.
if self.style.draws_faces() {
if let Some(m) = &self.mesh {
if 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 {
let face_pipeline = if self.style == RenderStyle::Hidden {
&self.hidden_face_pipeline
} else {
+80 -6
View File
@@ -38,23 +38,27 @@ pub use math::{
view_matrix, view_projection, Mat4,
};
pub use mesh::{
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh, extrude_slab,
extrude_wall, triangulate,
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh,
build_walls_mesh_textured, extrude_slab, extrude_wall, textured_from_mesh, triangulate,
};
pub use section::{
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
};
pub use types::{
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, WallInput,
FLOATS_PER_VERTEX,
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput,
TexturedMesh, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
};
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
#[cfg(test)]
mod tests {
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX};
use super::types::{Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX};
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,
};
/// Bequemer Bau einer achsparallelen Wand entlang +X.
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
@@ -80,6 +84,67 @@ 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).
@@ -927,5 +992,14 @@ 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:?}"));
}
}
+68 -2
View File
@@ -59,8 +59,8 @@ use std::collections::HashMap;
use crate::openings::{split_range_by_voids, MIN_SPAN};
use crate::types::{
Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer,
FLOATS_PER_VERTEX,
Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, TexturedMesh, WallInput, WallLayer,
FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
};
/// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2
@@ -917,6 +917,72 @@ 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).
+83
View File
@@ -125,6 +125,89 @@ 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.
+24
View File
@@ -333,6 +333,30 @@ 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 {
+13
View File
@@ -2337,6 +2337,16 @@ 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 });
@@ -2356,6 +2366,9 @@ 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 });
+85
View File
@@ -0,0 +1,85 @@
/**
* 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);
}
});
});
+175 -22
View File
@@ -4,9 +4,12 @@
// 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): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
// zeigt die Tritt-Linien + die Lauflinie.
// 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, …).
//
// Herkunft der Treppen-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
@@ -17,7 +20,7 @@
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
import { stairGeometry, defaultStepCount } from "../../geometry/stair";
import { stairGeometry, defaultStepCount, IDEAL_TREAD, MIN_STEPS } from "../../geometry/stair";
import { uniqueId } from "../../tools/types";
import type {
Command,
@@ -38,6 +41,35 @@ 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 => {
@@ -65,21 +97,45 @@ function floorRise(ctx: CommandContext): number {
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
}
/** 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" },
];
// ── 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));
}
// ── 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
}
@@ -88,6 +144,9 @@ 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;
@@ -95,14 +154,30 @@ interface StairRun extends CommandState {
}
type StairCmdState = StairIdle | StairRun;
function initState(shape: StairShape = "straight"): StairIdle {
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
/** 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,
};
}
/**
* 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);
@@ -110,7 +185,15 @@ 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;
const steps = s.steps ?? defaultStepCount(rise, runLength);
// 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 base: Stair = {
id: uniqueId("ST"),
type: "stair",
@@ -121,6 +204,7 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair {
dir,
runLength,
width: s.width,
referenz: s.referenz,
totalRise: rise,
stepCount: steps,
up: s.up,
@@ -156,18 +240,42 @@ function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
return draft;
}
/** Hängt eine Treppe ans Projekt (immutabel). */
/** Hängt eine Treppe ans Projekt (immutabel) und aktualisiert die Defaults. */
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 ?? "straight"),
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),
{ 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",
@@ -177,14 +285,20 @@ 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("straight"),
init: (): StairIdle => initState(),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
@@ -195,6 +309,12 @@ 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 }];
}
@@ -211,6 +331,9 @@ 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,
@@ -249,20 +372,50 @@ export const stairCommand: Command = {
},
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
/**
* 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. */
pointFromFields: (state, locks) => {
const s = state as StairCmdState;
if ("width" in locks && locks.width > 0) s.width = locks.width;
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
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;
}
}
return null;
},
fieldValues: (state, _locks, _cursor): Record<string, number> => {
const s = state as StairCmdState;
const out: Record<string, number> = { width: s.width };
if (s.steps != null) out.steps = s.steps;
if (s.rise != null) out.rise = s.rise;
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;
}
}
return out;
},
};
+980
View File
@@ -0,0 +1,980 @@
// 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);
}
});
});
});
+27 -2
View File
@@ -170,12 +170,21 @@ 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).
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
* (`wingCount 1` Querlinien).
*/
export function windowSymbol(
project: Project,
@@ -205,5 +214,21 @@ 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))]);
}
return { frame, glassLines };
// 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 };
}
+177 -5
View File
@@ -27,6 +27,32 @@ 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;
@@ -105,13 +131,22 @@ export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
const u = norm(stair.dir);
const n = leftN(u);
if (stair.shape === "spiral") {
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
// 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 === "L") {
return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
if (s.shape === "L") {
return lGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
}
return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
return straightGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
}
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
@@ -409,3 +444,140 @@ 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
@@ -0,0 +1,211 @@
/**
* 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);
});
});
+18
View File
@@ -301,6 +301,16 @@ 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",
@@ -318,6 +328,11 @@ 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",
@@ -669,6 +684,9 @@ 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:",
+16
View File
@@ -301,6 +301,15 @@ 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",
@@ -317,6 +326,10 @@ 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",
@@ -661,6 +674,9 @@ 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:",
+433
View File
@@ -0,0 +1,433 @@
// 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 } 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);
});
});
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();
});
});
+648 -29
View File
@@ -17,6 +17,13 @@
// • 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`).
import DxfParser from "dxf-parser";
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
@@ -61,10 +68,51 @@ 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[];
[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;
@@ -78,9 +126,16 @@ 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[] = [];
@@ -88,36 +143,22 @@ export function parseDxf(text: string): DxfImportResult {
for (const e of entities) {
const type = (e.type ?? "").toUpperCase();
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;
// Mesh-Entities in die Dreiecks-Puffer; POLYLINE nur als Mesh-Variante.
if (type === "3DFACE") {
addFace(meshTriangles, meshIndices, e);
continue;
}
if (type === "MESH") {
addMesh(meshTriangles, meshIndices, e);
continue;
}
if (type === "POLYLINE" && isMeshPolyline(e)) {
addPolyfaceMesh(meshTriangles, meshIndices, e);
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[] = [];
@@ -308,3 +349,581 @@ 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 };
}
/** 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 };
}
/**
* 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;
}
+11 -2
View File
@@ -23,6 +23,9 @@ 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
@@ -45,13 +48,19 @@ export function contoursToDrawings(
categoryMode === "byLayer" && layerName
? layerToCode(layerName)
: fallbackCode;
out.push({
const drawing: Drawing2D = {
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;
+8 -8
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 + L-Seitenlinie an derselben Fläche", () => {
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", () => {
// 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,13 +303,13 @@ 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);
// ... 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);
// ... 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();
// Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts
// unangetastet (keine Pro-Schicht-Cuts für sie).
+13 -1
View File
@@ -421,11 +421,23 @@ 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]));
sideArr[i] = adjMerged ? sideLine : null;
const mergesWithNearPlaster = nearPlasterComps.has(branchWt.layers[i].componentId);
sideArr[i] = adjMerged && !mergesWithNearPlaster ? sideLine : null;
}
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand über
+43
View File
@@ -731,6 +731,22 @@ 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. */
@@ -741,6 +757,15 @@ 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.
@@ -805,6 +830,19 @@ 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.
@@ -1055,6 +1093,11 @@ 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;
}
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
+61 -6
View File
@@ -297,6 +297,22 @@ 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>
@@ -426,16 +442,55 @@ function OpeningSection({
onCommit={(v) => host.onSetOpeningHeight(v)}
/>
{!isDoor && (
<DimensionField
label={t("objinfo.opening.sill")}
value={opening.sillHeight}
onCommit={(v) => host.onSetOpeningSill(v)}
/>
<>
<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))))}
/>
</>
)}
{/* Tür-spezifisch: Anschlag / Aufschlagseite / Richtung / Winkel. */}
{/* Tür-spezifisch: Typ / Sturzlinien / 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
+8
View File
@@ -236,6 +236,12 @@ 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). */
@@ -248,6 +254,8 @@ 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. */
+149
View File
@@ -0,0 +1,149 @@
/**
* 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);
});
});
+10 -14
View File
@@ -6,8 +6,9 @@
* 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 weiterhin an der Nahfläche
* UND bekommen eine zusätzliche seitliche L-Linie (cls "layer-joint-l").
* 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).
*/
import { describe, it, expect } from "vitest";
@@ -96,22 +97,17 @@ describe("generatePlan — materialbewusster T-Stoss (Poché-Bänder)", () => {
expect(brick.pts[3].y).toBeCloseTo(0.06, 6);
});
it("zeichnet die L-Seitenlinie nur für die getrimmten Putzschichten, nicht für den durchlaufenden Kern", () => {
it("zeichnet KEINE L-Seitenlinie, wenn der getrimmte Putz materialgleich mit dem durchgehenden Nah-Putz der Durchgangswand ist (durchgehendes Putz-L ohne Naht)", () => {
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",
);
// 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);
}
// 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);
});
it("bricht die Nah-Putz-Schicht der Durchgangswand am Durchstoss auf (Phase 1c)", () => {
+299 -34
View File
@@ -38,12 +38,20 @@ 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 } from "../geometry/stair";
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
import type { SpiralOutlineSegments } from "../geometry/stair";
import {
doorSymbol,
openingInterval,
openingJambs,
wallAxisFrame,
windowSymbol,
} from "../geometry/opening";
import {
add,
along,
clippedBand,
dot,
leftNormal,
lineIntersect,
normalize,
@@ -260,6 +268,8 @@ 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;
}
| {
@@ -574,6 +584,11 @@ 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);
@@ -583,7 +598,7 @@ export function generatePlan(
category,
lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM,
);
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category);
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category, wallFootprints);
}
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
@@ -1077,6 +1092,88 @@ function subtractIntervals(
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,
@@ -1491,29 +1588,36 @@ function addOpeningSymbol(
if (o.kind === "door") {
const sym = doorSymbol(wall, o);
if (!sym) return;
// 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") {
// 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).
out.push({
kind: "arc",
center: sym.hinge,
from: sym.closedEnd,
to: sym.openEnd,
r: sym.radius,
cls: "door-swing",
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: "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") {
@@ -1530,6 +1634,57 @@ 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;
}
@@ -1549,6 +1704,20 @@ 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({
@@ -1561,6 +1730,26 @@ function addOpeningSymbol(
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). */
@@ -1614,6 +1803,7 @@ function addCeilingPoche(
detail: DetailLevel,
lwMm: number,
category: LayerCategory | undefined,
wallFootprints: WallFootprint[],
): void {
const pts = ceiling.outline;
if (pts.length < 3) return;
@@ -1646,27 +1836,47 @@ 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,
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
stroke: "none",
strokeWidthMm: 0,
hatch,
greyed,
ceilingId,
});
// 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,
});
// 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",
weightMm: outlineMm,
color: stroke,
greyed,
});
}
}
}
/**
@@ -1756,6 +1966,40 @@ 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
@@ -1860,6 +2104,27 @@ 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);
+12
View File
@@ -137,6 +137,12 @@ 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;
@@ -160,6 +166,8 @@ 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;
@@ -478,6 +486,9 @@ 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,
};
@@ -513,6 +524,7 @@ 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,
+6 -5
View File
@@ -39,11 +39,12 @@ export const ACCENT_SWATCHES: AccentSwatch[] = [
export const DEFAULT_MARQUEE_COLOR = "#CEB188";
/**
* 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.
* 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.
*/
export const DEFAULT_SNAP_COLOR = "#5FA1C9";