- 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).
12 KiB
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— NURsignedArea,polygonArea,perimeter,centroid. Alles abSiaCategory(siaLabel,evaluateRoom,balance,roomsToCsv) ist SIA-416-Domänenlogik/CSV → bleibt TS.stair.ts— portierbar, aberstairGeometrynimmtStair. „Scheinkopplung": es werden nur geometrische Felder gelesen (shape/start/dir/runLength/width/stepCount/…),totalRisekommt bereits aufgelöst vom Aufrufer. Port via Rust-StructStairParams(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 brauchenthickness/refOffalsnumber-Parameter). - Bleibt TS (braucht
Project/Geschoss-Auflösung):openingVerticalExtent(viawallVerticalExtent→Project.drawingLevels). - → Entscheidung nötig (siehe §7): Der Auftrag verbietet Änderungen an Aufrufstellen außer dem Import-Pfad. Ein Port von
openingwü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 5–40 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:
- v1 portiert die naiven
cross-Vergleiche 1:1 (KEINrobust) → Zufalls-Diff-Test wird bit-nah grün. Robustheit kommt aus denselben f64-Formeln + demselbenEPSwie TS. - Additiv, getrennt:
robustnur intern indetectRooms/point-in-polygon hinter optionalem Featurerobust-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. - 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"]; Featuresdefault=[],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), Mustercompute_joins_json.examples/parity.rs: Klon vongeometry/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ündeltkernel2d_bg.wasmautomatisch (new URL(..., import.meta.url)).pkgKernel2d/ist git-ignoriert (self-.gitignore = *) → vorvitest/buildmussbuild:kernel2dlaufen (CI-Schritt). - TS-Fassade
src/geometry/kernel2d.tswird 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ätzlichcargo run --example parity(nativ) als schneller Sekundär-Kanal (bit-identisch zu WASM für+ - * / sqrt; Ausnahmeatan2/acos/tanin Fillet: libm nativ ≠ wasm um letzte ULP → Winkel-Epsilon). - vitest-Init synchron:
initSync({ module: readFileSync(pkgKernel2d/kernel2d_bg.wasm) })(keinfetch), einmal inbeforeAll. - Zufallsgeneratoren: seed-basiert (Seed im Testnamen), Polylinien 3–20 Vertices, Koordinaten
[-100,100] m,closed/dzufällig; zusätzlich Cluster nahe0und1e-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()).normalizeNull-Guard:len || 1→if l==0.0 {1.0} else {l}(Ergebnis{0,0}, kein NaN).- Zwei Epsilons:
EPS=1e-7(kernel2d) UND hartkodiert1e-9inlineIntersect(Offset-Miter-Fallback hängt daran — geometrischer Sprung, nicht epsilon). - Dedup-Schwelle
1e-6, Fillet-Kollinearität1e-4(nicht EPS). - Stabile Sortierung (JS
Array.sortist stabil): Rustsort_by, nichtsort_unstable_by. - Term-Reihenfolge in
cross,signedArea-Summierung, Kreis-DiskriminanteB*B−4*A*Cexakt beibehalten (f64 nicht assoziativ; kein Kahan/Reorder). - Modulo-Indizierung
(i+len-1)%len(usize-Unterlauf vermeiden). joinChains: greedyi<j-erster-Treffer-dann-Neustart exakt nachbilden (reihenfolgeabhängiges Ergebnis).
7. Offene Entscheidungen (vor Coding klären)
openingim 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.- Crate-Ort: Auftrag
crates/kernel2dvs. Repo-Konventionsrc-tauri/kernel2d(analog render2d/render3d). Empfehlung:src-tauri/kernel2d(Tooling passt out-of-the-box). - 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)
- Crate-Skelett
src-tauri/kernel2d+ Build-Script + Workspace-exclude + leere WASM-Fassade →build:kernel2dgrün. - Primitive + Schnitt + Fläche + Kreis portieren (trivial–mittel) + Batch-Fassade + Diff-Test-Harness (Zufall+Golden) → grün.
- Offset (Miter+1e-9-Fallback) + Fillet portieren → Golden für Selbstschnitt/spitze Winkel grün.
- Trim/Split/Join (
trimPolyline,splitAtIntersections,joinChains— der Löwenanteil) → Struktur-Golden grün. roomBoundary(detectRooms) +ceiling+roomArea-Flächen +stair(StairParams).- TS-Fassade umstellen (Alt →
.legacy.ts), Import-Pfade der Aufrufstellen unverändert lassen, bestehende Suite grün,npm run build+ WASM-Build sauber.