Files
DOSSIER-STANDALONE/PORT_PLAN.md
T
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

12 KiB
Raw Permalink Blame History

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 wallVerticalExtentProject.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.hypotf64::hypot (nicht (x²+y²).sqrt()).
  • normalize Null-Guard: len || 1if 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.