From 8750c8f547b8d4e90b2c26bf3e428c388f8d529a Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 00:43:03 +0200 Subject: [PATCH] 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. --- src-tauri/kernel2d/src/lib.rs | 464 +++++++++++++++++++++++++++ src/geometry/kernel2d.parity.test.ts | 262 +++++++++++++++ 2 files changed, 726 insertions(+) create mode 100644 src/geometry/kernel2d.parity.test.ts diff --git a/src-tauri/kernel2d/src/lib.rs b/src-tauri/kernel2d/src/lib.rs index 19e2701..f56d55e 100644 --- a/src-tauri/kernel2d/src/lib.rs +++ b/src-tauri/kernel2d/src/lib.rs @@ -96,6 +96,210 @@ pub fn line_intersect(a: Vec2, da: Vec2, b: Vec2, db: Vec2) -> Option { Some(add(a, scale(da, t))) } +/// Abstand zweier Punkte (= `len(sub(a,b))`, hypot-basiert). +#[inline] +pub fn dist(a: Vec2, b: Vec2) -> f64 { + len(sub(a, b)) +} + +/// Punkt-Gleichheit innerhalb Toleranz (Port von `vecEqual`, Default-eps = EPS). +pub fn vec_equal(a: Vec2, b: Vec2, eps: f64) -> bool { + (a.x - b.x).abs() <= eps && (a.y - b.y).abs() <= eps +} + +// --- Punkt/Strecke ----------------------------------------------------------- + +/// Projektionsparameter t von p auf die Gerade a→b (nicht geklemmt). +/// Guard `l2 < EPS → 0` exakt wie TS. +pub fn project_param(p: Vec2, a: Vec2, b: Vec2) -> f64 { + let ab = sub(b, a); + let l2 = dot(ab, ab); + if l2 < EPS { + return 0.0; + } + dot(sub(p, a), ab) / l2 +} + +/// Naechster Punkt auf der STRECKE a→b zu p (t auf [0,1] geklemmt). Klemm- +/// Reihenfolge wie TS `Math.max(0, Math.min(1, t))` → `.min(1).max(0)`. +pub fn closest_point_on_segment(p: Vec2, a: Vec2, b: Vec2) -> Vec2 { + let t = project_param(p, a, b).min(1.0).max(0.0); + add(a, scale(sub(b, a), t)) +} + +/// Abstand von p zur Strecke a→b. +pub fn point_segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f64 { + dist(p, closest_point_on_segment(p, a, b)) +} + +// --- Schnitt ----------------------------------------------------------------- + +/// Ergebnis eines Strecken-/Linienschnitts (Port von `Hit`). +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +pub struct Hit { + pub point: Vec2, + /// Parameter auf der ersten Strecke (0 = a1, 1 = a2). + pub t: f64, + /// Parameter auf der zweiten Strecke (0 = b1, 1 = b2). + pub s: f64, +} + +/// Schnitt zweier STRECKEN a1→a2 und b1→b2 (None ausserhalb [-eps,1+eps] oder +/// parallel). ACHTUNG: denom-Test gegen `EPS` (Konstante), Bereichstest gegen +/// den Parameter `eps` — im Default-Pfad sind beide EPS (wie TS-Default). +pub fn segment_intersect(a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2, eps: f64) -> Option { + let da = sub(a2, a1); + let db = sub(b2, b1); + let denom = cross(da, db); + if denom.abs() < EPS { + return None; // parallel/kollinear + } + let t = cross(sub(b1, a1), db) / denom; + let s = cross(sub(b1, a1), da) / denom; + if t < -eps || t > 1.0 + eps || s < -eps || s > 1.0 + eps { + return None; + } + Some(Hit { point: add(a1, scale(da, t)), t, s }) +} + +/// Schnitt der unendlichen GERADE a1→a2 mit der STRECKE b1→b2 (s ∈ [0,1], t frei). +pub fn line_segment_intersect(a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2, eps: f64) -> Option { + let da = sub(a2, a1); + let db = sub(b2, b1); + let denom = cross(da, db); + if denom.abs() < EPS { + return None; + } + let t = cross(sub(b1, a1), db) / denom; + let s = cross(sub(b1, a1), da) / denom; + if s < -eps || s > 1.0 + eps { + return None; + } + Some(Hit { point: add(a1, scale(da, t)), t, s }) +} + +/// Kanten einer Polylinie als (from,to)-Paare (Schlusskante bei `closed`). +/// Leere/ein-Punkt-Eingabe → leer (usize-Unterlauf vermeiden, TS-Verhalten). +pub fn polyline_edges(pts: &[Vec2], closed: bool) -> Vec<(Vec2, Vec2)> { + let mut out = Vec::new(); + if pts.is_empty() { + return out; + } + for i in 0..pts.len() - 1 { + out.push((pts[i], pts[i + 1])); + } + if closed && pts.len() > 2 { + out.push((pts[pts.len() - 1], pts[0])); + } + out +} + +/// Alle Schnittpunkte einer STRECKE mit den Kanten einer Polylinie, nach t +/// sortiert, dedupliziert ab Schwelle 1e-6. STABILE Sortierung (`sort_by`) wie +/// JS `Array.sort`. +pub fn segment_polyline_hits(a1: Vec2, a2: Vec2, pts: &[Vec2], closed: bool) -> Vec { + let mut hits: Vec = Vec::new(); + for (b1, b2) in polyline_edges(pts, closed) { + if let Some(h) = segment_intersect(a1, a2, b1, b2, EPS) { + hits.push(h); + } + } + hits.sort_by(|p, q| p.t.partial_cmp(&q.t).unwrap_or(std::cmp::Ordering::Equal)); + let mut dedup: Vec = Vec::new(); + for h in hits { + if dedup.is_empty() || (dedup[dedup.len() - 1].t - h.t).abs() > 1e-6 { + dedup.push(h); + } + } + dedup +} + +// --- Kreis-Schnitte ---------------------------------------------------------- + +/// Schnittpunkte einer unendlichen GERADE a→b mit einem Kreis (0/1/2 Punkte). +/// Diskriminante `B*B - 4*A*C` in exakt dieser Term-Reihenfolge; Klemmung +/// `disc < -EPS → []`, sonst `disc < 0 → 0`. +pub fn line_circle_intersect(a: Vec2, b: Vec2, center: Vec2, r: f64) -> Vec { + let d = sub(b, a); + let f = sub(a, center); + let aa = dot(d, d); + if aa < EPS { + return Vec::new(); + } + let bb = 2.0 * dot(f, d); + let cc = dot(f, f) - r * r; + let mut disc = bb * bb - 4.0 * aa * cc; + if disc < -EPS { + return Vec::new(); + } + if disc < 0.0 { + disc = 0.0; + } + let sq = disc.sqrt(); + let t1 = (-bb - sq) / (2.0 * aa); + let t2 = (-bb + sq) / (2.0 * aa); + let mut out = vec![add(a, scale(d, t1))]; + if (t1 - t2).abs() > EPS { + out.push(add(a, scale(d, t2))); + } + out +} + +/// Schnittpunkte einer STRECKE a→b mit einem Kreis (nur t ∈ [-EPS, 1+EPS]). +pub fn segment_circle_intersect(a: Vec2, b: Vec2, center: Vec2, r: f64) -> Vec { + line_circle_intersect(a, b, center, r) + .into_iter() + .filter(|p| { + let t = project_param(*p, a, b); + t >= -EPS && t <= 1.0 + EPS + }) + .collect() +} + +/// Schnittpunkte zweier Kreise (0/1/2 Punkte). +pub fn circle_circle_intersect(c1: Vec2, r1: f64, c2: Vec2, r2: f64) -> Vec { + let d = dist(c1, c2); + if d < EPS { + return Vec::new(); // konzentrisch + } + if d > r1 + r2 + EPS || d < (r1 - r2).abs() - EPS { + return Vec::new(); // getrennt/innen + } + let a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d); + let h2 = r1 * r1 - a * a; + let h = if h2 > 0.0 { h2.sqrt() } else { 0.0 }; + let u = normalize(sub(c2, c1)); + let mid = add(c1, scale(u, a)); + let n = left_normal(u); + if h < EPS { + return vec![mid]; + } + vec![add(mid, scale(n, h)), add(mid, scale(n, -h))] +} + +// --- Polygon-Flaeche / Wicklung ---------------------------------------------- + +/// Vorzeichenbehaftete Polygonflaeche (Shoelace); >0 = CCW, <0 = CW. +/// Summierung in identischer Vertex-Reihenfolge (f64 nicht assoziativ). +pub fn signed_area(pts: &[Vec2]) -> f64 { + let n = pts.len(); + if n == 0 { + return 0.0; + } + let mut s = 0.0; + for i in 0..n { + let a = pts[i]; + let b = pts[(i + 1) % n]; + s += a.x * b.y - b.x * a.y; + } + s / 2.0 +} + +/// Ob ein Polygonzug gegen den Uhrzeigersinn (CCW) gewickelt ist. +pub fn is_ccw(pts: &[Vec2]) -> bool { + signed_area(pts) > 0.0 +} + // --- Batch-WASM-Fassade (Feature "web") -------------------------------------- // Phase 1: nur ein Versions-/Ping-Export, um die WASM-Grenze + das Tooling // (wasm-pack → pkgKernel2d → Vite/vitest) end-to-end gruen zu bekommen. Die @@ -115,6 +319,188 @@ pub fn kernel2d_normalize_json(input_json: &str) -> Result(v: &T) -> Result { + serde_json::to_string(v).map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string())) +} + +#[cfg(feature = "web")] +fn from_js(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string())) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct ProjQuery { + p: Vec2, + a: Vec2, + b: Vec2, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct SegQuery { + a1: Vec2, + a2: Vec2, + b1: Vec2, + b2: Vec2, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct PolyHitsQuery { + a1: Vec2, + a2: Vec2, + pts: Vec, + closed: bool, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct LineCircleQuery { + a: Vec2, + b: Vec2, + center: Vec2, + r: f64, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct CircleCircleQuery { + c1: Vec2, + r1: f64, + c2: Vec2, + r2: f64, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn project_param_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec = qs.iter().map(|q| project_param(q.p, q.a, q.b)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn closest_point_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec = qs + .iter() + .map(|q| closest_point_on_segment(q.p, q.a, q.b)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn point_segment_distance_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec = qs + .iter() + .map(|q| point_segment_distance(q.p, q.a, q.b)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn segment_intersect_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| segment_intersect(q.a1, q.a2, q.b1, q.b2, EPS)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn line_segment_intersect_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| line_segment_intersect(q.a1, q.a2, q.b1, q.b2, EPS)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn segment_polyline_hits_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| segment_polyline_hits(q.a1, q.a2, &q.pts, q.closed)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn line_circle_intersect_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| line_circle_intersect(q.a, q.b, q.center, q.r)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn segment_circle_intersect_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| segment_circle_intersect(q.a, q.b, q.center, q.r)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn circle_circle_intersect_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| circle_circle_intersect(q.c1, q.r1, q.c2, q.r2)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn signed_area_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let polys: Vec> = from_js(input_json)?; + let out: Vec = polys.iter().map(|p| signed_area(p)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn is_ccw_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let polys: Vec> = from_js(input_json)?; + let out: Vec = polys.iter().map(|p| is_ccw(p)).collect(); + to_js(&out) +} + #[cfg(test)] mod tests { use super::*; @@ -160,4 +546,82 @@ mod tests { .unwrap(); assert!((hit.x - 2.0).abs() < T && hit.y.abs() < T); } + + #[test] + fn signed_area_unit_square_ccw() { + let sq = [ + Vec2::new(0.0, 0.0), + Vec2::new(1.0, 0.0), + Vec2::new(1.0, 1.0), + Vec2::new(0.0, 1.0), + ]; + assert!((signed_area(&sq) - 1.0).abs() < T); + assert!(is_ccw(&sq)); + // Umgekehrte Reihenfolge → CW, Flaeche negativ. + let mut cw = sq; + cw.reverse(); + assert!((signed_area(&cw) + 1.0).abs() < T); + assert!(!is_ccw(&cw)); + } + + #[test] + fn segment_intersect_crossing_and_miss() { + let hit = segment_intersect( + Vec2::new(0.0, 0.0), + Vec2::new(2.0, 2.0), + Vec2::new(0.0, 2.0), + Vec2::new(2.0, 0.0), + EPS, + ) + .unwrap(); + assert!((hit.point.x - 1.0).abs() < T && (hit.point.y - 1.0).abs() < T); + assert!((hit.t - 0.5).abs() < T && (hit.s - 0.5).abs() < T); + // Kein Treffer: zweite Strecke zu kurz. + assert!(segment_intersect( + Vec2::new(0.0, 0.0), + Vec2::new(2.0, 2.0), + Vec2::new(0.0, 2.0), + Vec2::new(0.9, 1.1), + EPS, + ) + .is_none()); + } + + #[test] + fn circle_circle_two_points() { + // Zwei Einheitskreise, Zentren Abstand 1 → Schnitt bei x=0.5, y=±√3/2. + let pts = circle_circle_intersect(Vec2::new(0.0, 0.0), 1.0, Vec2::new(1.0, 0.0), 1.0); + assert_eq!(pts.len(), 2); + let expect_y = (3.0_f64).sqrt() / 2.0; + for p in &pts { + assert!((p.x - 0.5).abs() < 1e-9); + assert!((p.y.abs() - expect_y).abs() < 1e-9); + } + } + + #[test] + fn line_circle_tangent_one_point() { + // Gerade y=1 tangiert Einheitskreis in (0,1). + let pts = line_circle_intersect( + Vec2::new(-1.0, 1.0), + Vec2::new(1.0, 1.0), + Vec2::new(0.0, 0.0), + 1.0, + ); + assert_eq!(pts.len(), 1); + assert!(pts[0].x.abs() < 1e-9 && (pts[0].y - 1.0).abs() < 1e-9); + } + + #[test] + fn polyline_edges_open_and_closed() { + let pts = [ + Vec2::new(0.0, 0.0), + Vec2::new(1.0, 0.0), + Vec2::new(1.0, 1.0), + ]; + assert_eq!(polyline_edges(&pts, false).len(), 2); + assert_eq!(polyline_edges(&pts, true).len(), 3); + assert!(polyline_edges(&[], true).is_empty()); + assert!(polyline_edges(&[Vec2::new(0.0, 0.0)], true).is_empty()); + } } diff --git a/src/geometry/kernel2d.parity.test.ts b/src/geometry/kernel2d.parity.test.ts new file mode 100644 index 0000000..70413a5 --- /dev/null +++ b/src/geometry/kernel2d.parity.test.ts @@ -0,0 +1,262 @@ +// 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, + isCCW, + lineCircleIntersect, + lineSegmentIntersect, + pointSegmentDistance, + projectParam, + segmentCircleIntersect, + segmentIntersect, + segmentPolylineHits, + signedArea, + type Hit, +} from "./kernel2d"; + +// 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; + +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; +}); + +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); +} + +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)); + }); + }); +}); + +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])); + }); +});