From ae18766b01c4d0fe1acfeb90a392eb1afe1b791e Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 01:30:11 +0200 Subject: [PATCH] kernel2d-Port Phase 5: roomArea/ceiling/roomBoundary/stair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src-tauri/kernel2d/src/lib.rs | 1319 ++++++++++++++++++++++++++ src/geometry/kernel2d.parity.test.ts | 468 +++++++++ 2 files changed, 1787 insertions(+) diff --git a/src-tauri/kernel2d/src/lib.rs b/src-tauri/kernel2d/src/lib.rs index 78f77f9..0b8bdc7 100644 --- a/src-tauri/kernel2d/src/lib.rs +++ b/src-tauri/kernel2d/src/lib.rs @@ -977,6 +977,180 @@ pub fn join_chains(polylines: &[Polyline]) -> Vec { out } +// --- Polygon-Kennzahlen (roomArea) ------------------------------------------- + +/// Absolute Polygonflaeche in m² (Port von `polygonArea`). +pub fn polygon_area(pts: &[Vec2]) -> f64 { + signed_area(pts).abs() +} + +/// Umfang eines geschlossenen Polygons (Summe aller Kantenlaengen, hypot). +pub fn perimeter(pts: &[Vec2]) -> f64 { + let n = pts.len(); + if n < 2 { + return 0.0; + } + let mut p = 0.0; + for i in 0..n { + let a = pts[i]; + let b = pts[(i + 1) % n]; + p += (b.x - a.x).hypot(b.y - a.y); + } + p +} + +/// Flaechenschwerpunkt (momenten-gewichtet); degeneriert (|A|<1e-12) → Mittelwert. +pub fn centroid(pts: &[Vec2]) -> Vec2 { + let n = pts.len(); + if n == 0 { + return Vec2::new(0.0, 0.0); + } + if n < 3 { + let (mut sx, mut sy) = (0.0, 0.0); + for p in pts { + sx += p.x; + sy += p.y; + } + return Vec2::new(sx / n as f64, sy / n as f64); + } + let (mut a, mut cx, mut cy) = (0.0, 0.0, 0.0); + for i in 0..n { + let p = pts[i]; + let q = pts[(i + 1) % n]; + let cr = p.x * q.y - q.x * p.y; + a += cr; + cx += (p.x + q.x) * cr; + cy += (p.y + q.y) * cr; + } + a /= 2.0; + if a.abs() < 1e-12 { + let (mut sx, mut sy) = (0.0, 0.0); + for p in pts { + sx += p.x; + sy += p.y; + } + return Vec2::new(sx / n as f64, sy / n as f64); + } + Vec2::new(cx / (6.0 * a), cy / (6.0 * a)) +} + +// --- Umriss-/Decken-Utilities (ceiling) -------------------------------------- + +/// Kleinste sinnvolle Deckenflaeche (m²) — darunter gilt der Umriss als entartet. +pub const MIN_CEILING_AREA: f64 = 1e-4; + +/// Achsparallele Bounding-Box (serde camelCase: minX/minY/maxX/maxY wie TS). +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BBox { + pub min_x: f64, + pub min_y: f64, + pub max_x: f64, + pub max_y: f64, +} + +/// Normalisiert einen Umriss: Dedup, schliessenden Duplikat-Endpunkt weg, +/// CCW erzwingen. None bei < 3 Restpunkten. +pub fn normalize_outline(pts: &[Vec2]) -> Option> { + let mut clean: Vec = Vec::new(); + for &p in pts { + if clean.is_empty() || !vec_equal(clean[clean.len() - 1], p, EPS) { + clean.push(p); + } + } + if clean.len() > 1 && vec_equal(clean[0], clean[clean.len() - 1], EPS) { + clean.pop(); + } + if clean.len() < 3 { + return None; + } + if is_ccw(&clean) { + Some(clean) + } else { + clean.reverse(); + Some(clean) + } +} + +/// Fläche eines Deckenumrisses in m² (immer positiv). +pub fn ceiling_area(pts: &[Vec2]) -> f64 { + signed_area(pts).abs() +} + +/// Ob ein Umriss ein gueltiges (nicht entartetes) Deckenpolygon bildet. +pub fn is_valid_outline(pts: &[Vec2]) -> bool { + match normalize_outline(pts) { + Some(norm) => ceiling_area(&norm) >= MIN_CEILING_AREA, + None => false, + } +} + +/// Achsparallele Bounding-Box; leer/entartet → Null-Box. +pub fn outline_bbox(pts: &[Vec2]) -> BBox { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for p in pts { + min_x = min_x.min(p.x); + min_y = min_y.min(p.y); + max_x = max_x.max(p.x); + max_y = max_y.max(p.y); + } + if !min_x.is_finite() { + return BBox { min_x: 0.0, min_y: 0.0, max_x: 0.0, max_y: 0.0 }; + } + BBox { min_x, min_y, max_x, max_y } +} + +/// Schwerpunkt des Umriss-Polygons (ceiling-Variante: f = 1/(6·signedArea)). +pub fn outline_centroid(pts: &[Vec2]) -> Vec2 { + let a = signed_area(pts); + if a.abs() < 1e-12 { + let (mut sx, mut sy) = (0.0, 0.0); + for p in pts { + sx += p.x; + sy += p.y; + } + let n = if pts.is_empty() { 1.0 } else { pts.len() as f64 }; + return Vec2::new(sx / n, sy / n); + } + let (mut cx, mut cy) = (0.0, 0.0); + let n = pts.len(); + for i in 0..n { + let p = pts[i]; + let q = pts[(i + 1) % n]; + let cr = p.x * q.y - q.x * p.y; + cx += (p.x + q.x) * cr; + cy += (p.y + q.y) * cr; + } + let f = 1.0 / (6.0 * a); + Vec2::new(cx * f, cy * f) +} + +/// Punkt-in-Polygon (Ray-Casting) fuer einen geschlossenen Umriss. Der TS-Guard +/// `(b.y-a.y || 1e-12)` wird exakt nachgebildet (Null-Nenner → 1e-12). +pub fn point_in_outline(p: Vec2, outline: &[Vec2]) -> bool { + let n = outline.len(); + if n == 0 { + return false; + } + let mut inside = false; + let mut j = n - 1; + for i in 0..n { + let a = outline[i]; + let b = outline[j]; + let denom = if b.y - a.y == 0.0 { 1e-12 } else { b.y - a.y }; + let intersect = + (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / denom + a.x; + if intersect { + inside = !inside; + } + j = i; + } + inside +} + // --- 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 @@ -1422,6 +1596,1151 @@ pub fn join_chains_batch_json(input_json: &str) -> Result Vec2 { + let l = len(a); + let l = if l == 0.0 { 1e-9 } else { l }; + Vec2 { x: a.x / l, y: a.y / l } +} + +/// Linke Normale (stair.ts `leftN`). +#[inline] +fn stair_left_n(u: Vec2) -> Vec2 { + Vec2 { x: -u.y, y: u.x } +} + +/// Sinnvolle Default-Stufenzahl (Port von `defaultStepCount`). +pub fn default_step_count(total_rise: f64, run_length: f64) -> i64 { + let by_rise = (total_rise / IDEAL_RISER).round() as i64; + let by_rise = if by_rise < MIN_STEPS { MIN_STEPS } else { by_rise }; + let max_by_run = if run_length > 0.0 { + let v = (run_length / 0.24).floor() as i64 + 1; + if v < MIN_STEPS { MIN_STEPS } else { v } + } else { + by_rise + }; + let res = if by_rise < max_by_run { by_rise } else { max_by_run }; + if res < MIN_STEPS { MIN_STEPS } else { res } +} + +/// Ein Tritt im Grundriss (Port von `TreadRect`). +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TreadRect { + pub pts: Vec, + pub index: i64, + pub top_rise: f64, + pub base_rise: f64, +} + +/// Vollstaendige abgeleitete Treppengeometrie (Port von `StairGeometry`). +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct StairGeometry { + pub treads: Vec, + pub landing: Option>, + pub riser_height: f64, + pub tread_depth: f64, + pub run_line: Vec, + pub arrow: StairArrow, + pub total_rise: f64, +} + +/// Auf-/Abpfeil (Port von `arrow`-Typ in stair.ts). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct StairArrow { + pub shaft: (Vec2, Vec2), + pub head: (Vec2, Vec2, Vec2), +} + +/// Schnitt-Aufteilung (Port von `StairCut`). +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct StairCut { + pub below_indices: Vec, + pub above_indices: Vec, + pub break_line: Option>, +} + +/// Achsparallele BBox (Treppen, eigenes Struct um Konflikte zu vermeiden). +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +#[serde(rename_all = "camelCase")] +pub struct StairBBox { + pub min_x: f64, + pub min_y: f64, + pub max_x: f64, + pub max_y: f64, +} + +/// Tritt-Rechteck zwischen Achsparametern `a0..a1` entlang `dir`, Breite `half_w` +/// (Port der lokalen Hilfsfunktion `treadRect`). +fn tread_rect_pts(origin: Vec2, dir: Vec2, n: Vec2, a0: f64, a1: f64, half_w: f64) -> Vec { + let p0 = add(origin, scale(dir, a0)); + let p1 = add(origin, scale(dir, a1)); + vec![ + add(p0, scale(n, half_w)), + add(p1, scale(n, half_w)), + add(p1, scale(n, -half_w)), + add(p0, scale(n, -half_w)), + ] +} + +/// Auf-/Abpfeil aus der Lauflinie (Port von `arrowFor`). +fn arrow_for(run_line: &[Vec2], up: bool) -> StairArrow { + let a = run_line[0]; + let b = run_line[run_line.len() - 1]; + let tip = if up { b } else { a }; + let from = if up { + run_line[run_line.len() - 2] + } else { + run_line[1] + }; + let dir = stair_norm(sub(tip, from)); + let n = stair_left_n(dir); + let s = 0.22_f64; + let back = add(tip, scale(dir, -s)); + let w1 = add(back, scale(n, s * 0.55)); + let w2 = add(back, scale(n, -s * 0.55)); + StairArrow { shaft: (a, b), head: (w1, tip, w2) } +} + +/// Achteck-Approximation des Wendel-Auges (Port von `eyePolygon`, 12 Segmente). +fn eye_polygon(center: Vec2, r: f64) -> Vec { + (0..12).map(|i| { + let t = (i as f64 / 12.0) * std::f64::consts::PI * 2.0; + Vec2 { x: center.x + r * t.cos(), y: center.y + r * t.sin() } + }).collect() +} + +/// Parameter-Struct fuer die Batch-Fassade (enthaelt alle geometrisch relevanten +/// Felder des TS-Typs `Stair`; Modell-Semantik-Felder wie id/floorId werden +/// ignoriert). +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct StairParams { + shape: String, + start: Vec2, + dir: Vec2, + run_length: f64, + #[serde(default)] + run2_length: Option, + #[serde(default)] + turn: Option, + #[serde(default)] + center: Option, + #[serde(default)] + radius: Option, + #[serde(default)] + sweep: Option, + width: f64, + step_count: i64, + #[serde(default)] + up: Option, +} + +/// Gerade Treppe (Port von `straightGeometry`). +fn straight_geometry( + stair: &StairParams, + total_rise: f64, + steps: i64, + riser_height: f64, + half_w: f64, + u: Vec2, + n: Vec2, +) -> StairGeometry { + let run_length = stair.run_length.max(0.1); + let tread_depth = run_length / steps as f64; + let mut treads: Vec = Vec::new(); + for i in 0..steps { + treads.push(TreadRect { + pts: tread_rect_pts(stair.start, u, n, i as f64 * tread_depth, (i + 1) as f64 * tread_depth, half_w), + index: i, + base_rise: i as f64 * riser_height, + top_rise: (i + 1) as f64 * riser_height, + }); + } + let run_line: Vec = vec![ + add(stair.start, scale(u, tread_depth * 0.5)), + add(stair.start, scale(u, run_length - tread_depth * 0.15)), + ]; + let up = stair.up.unwrap_or(true); + StairGeometry { + arrow: arrow_for(&run_line, up), + treads, + landing: None, + riser_height, + tread_depth, + run_line, + total_rise, + } +} + +/// L-Treppe (Port von `lGeometry`). +fn l_geometry( + stair: &StairParams, + total_rise: f64, + steps: i64, + riser_height: f64, + half_w: f64, + u: Vec2, + n: Vec2, +) -> StairGeometry { + let run1 = stair.run_length.max(0.1); + let run2 = stair.run2_length.unwrap_or(stair.run_length).max(0.1); + let turn = stair.turn.unwrap_or(1); + let body_steps = steps - 1; + let s1 = { + let v = ((body_steps as f64 * run1) / (run1 + run2)).round() as i64; + if v < 1 { 1 } else { v } + }; + let s2 = { + let v = body_steps - s1; + if v < 1 { 1 } else { v } + }; + let td1 = run1 / s1 as f64; + let td2 = run2 / s2 as f64; + + let mut treads: Vec = Vec::new(); + let mut rise = 0.0_f64; + for i in 0..s1 { + treads.push(TreadRect { + pts: tread_rect_pts(stair.start, u, n, i as f64 * td1, (i + 1) as f64 * td1, half_w), + index: i, + base_rise: rise, + top_rise: rise + riser_height, + }); + rise += riser_height; + } + let corner_center = add(stair.start, scale(u, run1 + half_w)); + let u2: Vec2 = if turn > 0 { n } else { scale(n, -1.0) }; + let n2 = stair_left_n(u2); + let landing: Vec = vec![ + add(add(corner_center, scale(u, -half_w)), scale(n, half_w)), + add(add(corner_center, scale(u, half_w)), scale(n, half_w)), + add(add(corner_center, scale(u, half_w)), scale(n, -half_w)), + add(add(corner_center, scale(u, -half_w)), scale(n, -half_w)), + ]; + rise += riser_height; // Podest-Tritt + let run2_start = add(corner_center, scale(u2, half_w)); + for i in 0..s2 { + treads.push(TreadRect { + pts: tread_rect_pts(run2_start, u2, n2, i as f64 * td2, (i + 1) as f64 * td2, half_w), + index: s1 + 1 + i, + base_rise: rise, + top_rise: rise + riser_height, + }); + rise += riser_height; + } + let run_line: Vec = vec![ + add(stair.start, scale(u, td1 * 0.5)), + corner_center, + add(run2_start, scale(u2, run2 - td2 * 0.15)), + ]; + let up = stair.up.unwrap_or(true); + StairGeometry { + arrow: arrow_for(&run_line, up), + treads, + landing: Some(landing), + riser_height, + tread_depth: td1, + run_line, + total_rise, + } +} + +/// Wendeltreppe (Port von `spiralGeometry`). +fn spiral_geometry( + stair: &StairParams, + total_rise: f64, + steps: i64, + riser_height: f64, + half_w: f64, +) -> StairGeometry { + let center = stair.center.unwrap_or(stair.start); + let radius = (half_w + 0.1).max(stair.radius.unwrap_or(stair.width)); + let sweep = stair.sweep.unwrap_or(270.0) * DEG; + let start_vec = sub(stair.start, center); + let a0 = start_vec.y.atan2(start_vec.x); + let d_a = sweep / steps as f64; + let r_in = (radius - half_w).max(0.02); + let r_out = radius + half_w; + + let mut treads: Vec = Vec::new(); + for i in 0..steps { + let t0 = a0 + i as f64 * d_a; + let t1 = a0 + (i + 1) as f64 * d_a; + let pts: Vec = vec![ + Vec2 { x: center.x + r_in * t0.cos(), y: center.y + r_in * t0.sin() }, + Vec2 { x: center.x + r_out * t0.cos(), y: center.y + r_out * t0.sin() }, + Vec2 { x: center.x + r_out * t1.cos(), y: center.y + r_out * t1.sin() }, + Vec2 { x: center.x + r_in * t1.cos(), y: center.y + r_in * t1.sin() }, + ]; + treads.push(TreadRect { + pts, + index: i, + base_rise: i as f64 * riser_height, + top_rise: (i + 1) as f64 * riser_height, + }); + } + let n_seg = steps.max(2) as usize; + let mut run_line: Vec = Vec::new(); + for i in 0..=n_seg { + let tt = a0 + (sweep * i as f64) / n_seg as f64; + run_line.push(Vec2 { x: center.x + radius * tt.cos(), y: center.y + radius * tt.sin() }); + } + let tread_depth = (2.0 * std::f64::consts::PI * radius * (sweep / (2.0 * std::f64::consts::PI))) / steps as f64; + let up = stair.up.unwrap_or(true); + StairGeometry { + arrow: arrow_for(&run_line, up), + treads, + landing: Some(eye_polygon(center, r_in)), + riser_height, + tread_depth, + run_line, + total_rise, + } +} + +/// Vollstaendige Treppengeometrie (Port von `stairGeometry`). +fn stair_geometry(stair: &StairParams, total_rise: f64) -> StairGeometry { + let steps = (stair.step_count as i64).max(MIN_STEPS); + let riser_height = total_rise / steps as f64; + let half_w = (0.05_f64).max(stair.width / 2.0); + let u = stair_norm(stair.dir); + let n = stair_left_n(u); + + if stair.shape == "spiral" { + return spiral_geometry(stair, total_rise, steps, riser_height, half_w); + } + if stair.shape == "L" { + return l_geometry(stair, total_rise, steps, riser_height, half_w, u, n); + } + straight_geometry(stair, total_rise, steps, riser_height, half_w, u, n) +} + +/// Punkt-in-Polygon (Ray-Casting) — gleiches Muster wie `pointInOutline` / +/// stair.ts `pointInPolygon` (Nenner-Guard `|| 1e-12`). +pub fn point_in_polygon(p: Vec2, poly: &[Vec2]) -> bool { + let nn = poly.len(); + if nn == 0 { + return false; + } + let mut inside = false; + let mut j = nn - 1; + for i in 0..nn { + let a = poly[i]; + let b = poly[j]; + let denom = if b.y - a.y == 0.0 { 1e-12 } else { b.y - a.y }; + let intersect = + (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / denom + a.x; + if intersect { + inside = !inside; + } + j = i; + } + inside +} + +/// Schnittaufteilung (Port von `stairCut`). +pub fn stair_cut(geo: &StairGeometry, cut_rise: f64) -> StairCut { + let mut below: Vec = Vec::new(); + let mut above: Vec = Vec::new(); + let mut break_idx: i64 = -1; + for tr in &geo.treads { + if tr.top_rise <= cut_rise + 1e-6 { + below.push(tr.index); + } else { + if break_idx < 0 { + break_idx = tr.index; + } + above.push(tr.index); + } + } + let break_line = if break_idx >= 0 { + geo.treads.iter().find(|t| t.index == break_idx).map(|tr| { + let (c0, c1, c2, c3) = (tr.pts[0], tr.pts[1], tr.pts[2], tr.pts[3]); + let mid01 = scale(add(c0, c1), 0.5); + let mid23 = scale(add(c2, c3), 0.5); + let off = scale(stair_norm(sub(c1, c0)), 0.06); + vec![ + (add(mid01, off), add(mid23, off)), + (sub(mid01, off), sub(mid23, off)), + ] + }) + } else { + None + }; + StairCut { below_indices: below, above_indices: above, break_line } +} + +/// Achsparallele BBox aller Tritte + Podest (Port von `stairBBox`). +pub fn stair_bbox(geo: &StairGeometry) -> StairBBox { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for tr in &geo.treads { + for p in &tr.pts { + min_x = min_x.min(p.x); + min_y = min_y.min(p.y); + max_x = max_x.max(p.x); + max_y = max_y.max(p.y); + } + } + if let Some(land) = &geo.landing { + for p in land { + min_x = min_x.min(p.x); + min_y = min_y.min(p.y); + max_x = max_x.max(p.x); + max_y = max_y.max(p.y); + } + } + if !min_x.is_finite() { + return StairBBox { min_x: 0.0, min_y: 0.0, max_x: 0.0, max_y: 0.0 }; + } + StairBBox { min_x, min_y, max_x, max_y } +} + +/// Ob ein Punkt irgendeinen Tritt (oder das Podest) trifft (Port `pointHitsStair`). +pub fn point_hits_stair(p: Vec2, geo: &StairGeometry) -> bool { + for tr in &geo.treads { + if point_in_polygon(p, &tr.pts) { + return true; + } + } + if let Some(land) = &geo.landing { + if point_in_polygon(p, land) { + return true; + } + } + false +} + +// --- Raum-Erkennung (roomBoundary.ts, Slice 3) ------------------------------- +// Lokale Konstanten exakt wie TS (EPS=1e-9, normalize-Guard 1e-12). + +const RB_EPS: f64 = 1e-9; + +/// normalize (roomBoundary-Variante): Guard `l < 1e-12 → {0,0}` (NICHT `l||1`). +#[inline] +fn rb_normalize(a: Vec2) -> Vec2 { + let l = len(a); + if l < 1e-12 { Vec2 { x: 0.0, y: 0.0 } } else { Vec2 { x: a.x / l, y: a.y / l } } +} + +/// cross (lokal, identisch zur globalen). +#[inline] +fn rb_cross(a: Vec2, b: Vec2) -> f64 { + a.x * b.y - a.y * b.x +} + +// ── Planarer Graph ──────────────────────────────────────────────────────────── + +struct RbNode { + p: Vec2, +} + +struct RbUEdge { + u: usize, + v: usize, +} + +struct RbSplit { + t: f64, + p: Vec2, +} + +/// Baut den planaren Graphen aus Mittellinien-Segmenten (Port von `buildPlanarGraph`). +fn build_planar_graph(segments: &[(Vec2, Vec2)], snap: f64) -> (Vec, Vec) { + let mut nodes: Vec = Vec::new(); + + let add_node = |nodes: &mut Vec, p: Vec2| -> usize { + for i in 0..nodes.len() { + if dist(nodes[i].p, p) <= snap { + return i; + } + } + nodes.push(RbNode { p: Vec2 { x: p.x, y: p.y } }); + nodes.len() - 1 + }; + + // Rohe Segmente: degenerate (zu kurze) herausfiltern. + let raw: Vec<(Vec2, Vec2)> = segments + .iter() + .filter(|(a, b)| dist(*a, *b) > snap) + .cloned() + .collect(); + + let mut per_segment: Vec> = raw + .iter() + .map(|(a, b)| vec![RbSplit { t: 0.0, p: *a }, RbSplit { t: 1.0, p: *b }]) + .collect(); + + for i in 0..raw.len() { + for j in i + 1..raw.len() { + let (aa, ab) = raw[i]; + let (ba, bb) = raw[j]; + let da = sub(ab, aa); + let db = sub(bb, ba); + let denom = rb_cross(da, db); + if denom.abs() < RB_EPS { + continue; + } + let t = rb_cross(sub(ba, aa), db) / denom; + let s = rb_cross(sub(ba, aa), da) / denom; + let tol_t = snap / len(da).max(1e-9); + let tol_s = snap / len(db).max(1e-9); + if t < -tol_t || t > 1.0 + tol_t || s < -tol_s || s > 1.0 + tol_s { + continue; + } + let p = add(aa, scale(da, t)); + per_segment[i].push(RbSplit { t, p }); + per_segment[j].push(RbSplit { t: s, p }); + } + } + + let mut edge_set: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new(); + let mut edges: Vec = Vec::new(); + + let push_edge = |edges: &mut Vec, edge_set: &mut std::collections::HashSet<(usize, usize)>, u: usize, v: usize| { + if u == v { return; } + let key = if u < v { (u, v) } else { (v, u) }; + if edge_set.contains(&key) { return; } + edge_set.insert(key); + edges.push(RbUEdge { u, v }); + }; + + for i in 0..raw.len() { + per_segment[i].sort_by(|p, q| p.t.partial_cmp(&q.t).unwrap_or(std::cmp::Ordering::Equal)); + let mut prev: Option = None; + let mut prev_t = f64::NEG_INFINITY; + for sp in &per_segment[i] { + if sp.t - prev_t < 1e-9 && prev.is_some() { + continue; + } + let idx = add_node(&mut nodes, sp.p); + if let Some(pr) = prev { + if idx != pr { + push_edge(&mut edges, &mut edge_set, pr, idx); + } + } + prev = Some(idx); + prev_t = sp.t; + } + } + + (nodes, edges) +} + +// ── Face-Extraktion ─────────────────────────────────────────────────────────── + +struct RbHalfEdge { + from: usize, + to: usize, + angle: f64, + used: bool, +} + +/// Extrahiert die minimalen geschlossenen Maschen (Port von `extractFaces`). +fn extract_faces(nodes: &[RbNode], edges: &[RbUEdge]) -> Vec> { + let mut half_edges: Vec = Vec::new(); + let mut outgoing: Vec> = (0..nodes.len()).map(|_| Vec::new()).collect(); + + let angle_of = |from: usize, to: usize| -> f64 { + let d = sub(nodes[to].p, nodes[from].p); + d.y.atan2(d.x) + }; + + for e in edges { + let h1 = half_edges.len(); + half_edges.push(RbHalfEdge { from: e.u, to: e.v, angle: angle_of(e.u, e.v), used: false }); + outgoing[e.u].push(h1); + let h2 = half_edges.len(); + half_edges.push(RbHalfEdge { from: e.v, to: e.u, angle: angle_of(e.v, e.u), used: false }); + outgoing[e.v].push(h2); + } + + // Abgehende Halbkanten je Knoten nach Winkel sortieren (stabil). + for list in outgoing.iter_mut() { + list.sort_by(|&h1, &h2| half_edges[h1].angle.partial_cmp(&half_edges[h2].angle).unwrap_or(std::cmp::Ordering::Equal)); + } + + // Positionsindex je Halbkante in der sortierten Liste seines from-Knotens. + let mut pos_in_list: Vec = vec![0; half_edges.len()]; + for list in &outgoing { + for (k, &h) in list.iter().enumerate() { + pos_in_list[h] = k; + } + } + + // Zwilling: Paare liegen benachbart (2i, 2i+1). + let twin = |h: usize| -> usize { if h % 2 == 0 { h + 1 } else { h - 1 } }; + + let mut faces: Vec> = Vec::new(); + let max_steps = half_edges.len() + 2; + + for start in 0..half_edges.len() { + if half_edges[start].used { + continue; + } + let mut face_nodes: Vec = Vec::new(); + let mut h = start; + let mut guard = 0; + loop { + half_edges[h].used = true; + face_nodes.push(half_edges[h].from); + let tw = twin(h); + let to = half_edges[h].to; + let list = &outgoing[to]; + let pos = pos_in_list[tw]; + let next_pos = if pos == 0 { list.len() - 1 } else { pos - 1 }; + h = list[next_pos]; + guard += 1; + if h == start || guard >= max_steps { + break; + } + } + if face_nodes.len() >= 3 { + faces.push(face_nodes); + } + } + + // Aussenmaschen verwerfen: nur CCW (positive Flaeche) behalten. + faces.into_iter().filter(|f| { + let poly: Vec = f.iter().map(|&n| nodes[n].p).collect(); + signed_area(&poly) > RB_EPS + }).collect() +} + +// ── Innen-Offset ────────────────────────────────────────────────────────────── + +/// Versetzt ein CCW-Polygon um `d` nach innen (Port von `offsetInward`). +fn offset_inward(poly: &[Vec2], d: f64) -> Vec { + let n = poly.len(); + if n < 3 || d <= 0.0 { + return poly.to_vec(); + } + // CCW sicherstellen. + let pts_owned: Vec; + let pts: &[Vec2] = if signed_area(poly) > 0.0 { + poly + } else { + pts_owned = { let mut v = poly.to_vec(); v.reverse(); v }; + &pts_owned + }; + // Verschobene Kanten: linke Normale zeigt ins Innere bei CCW. + let shifted: Vec<(Vec2, Vec2)> = (0..n).map(|i| { + let a = pts[i]; + let b = pts[(i + 1) % n]; + let dir = rb_normalize(sub(b, a)); + let nrm = left_normal(dir); + (add(a, scale(nrm, d)), dir) + }).collect(); + + let mut out: Vec = Vec::new(); + for i in 0..n { + let prev = &shifted[(i + n - 1) % n]; + let curr = &shifted[i]; + let denom = rb_cross(prev.1, curr.1); + if denom.abs() < 1e-9 { + out.push(curr.0); + continue; + } + let t = rb_cross(sub(curr.0, prev.0), curr.1) / denom; + out.push(add(prev.0, scale(prev.1, t))); + } + out +} + +/// Dedupliziert aufeinanderfolgende nahezu gleiche Punkte im Ring (Port von +/// `dedupeRing`, Toleranz 1e-7 wie TS). +fn dedupe_ring_rb(pts: &[Vec2]) -> Vec { + let tol = 1e-7_f64; + let mut out: Vec = Vec::new(); + for &p in pts { + if let Some(&last) = out.last() { + if dist(last, p) <= tol { + continue; + } + } + out.push(p); + } + if out.len() > 1 { + let first = out[0]; + if dist(first, *out.last().unwrap()) <= tol { + out.pop(); + } + } + out +} + +/// Durchschnittliche Wanddicke (Port von `averageThickness`). +fn average_thickness(walls: &[WallSegment]) -> f64 { + if walls.is_empty() { + return 0.0; + } + let s: f64 = walls.iter().map(|w| w.thickness).sum(); + s / walls.len() as f64 +} + +// ── Oeffentliche Structs fuer die Batch-Fassade ─────────────────────────────── + +/// Wandsegment (Port von `WallSegment`): Mittellinie a→b mit Dicke. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct WallSegment { + pub a: Vec2, + pub b: Vec2, + pub thickness: f64, +} + +/// Wand-Innenflaeche (Port von `WallFace`). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct WallFace { + pub a: Vec2, + pub b: Vec2, +} + +// ── Oeffentliche Funktionen ─────────────────────────────────────────────────── + +/// pointInPolygon (roomBoundary-Variante, KEIN Nenner-Guard — `b.y - a.y + 0`). +pub fn rb_point_in_polygon(p: Vec2, poly: &[Vec2]) -> bool { + let mut inside = false; + let n = poly.len(); + if n == 0 { + return false; + } + let mut j = n - 1; + for i in 0..n { + let a = poly[i]; + let b = poly[j]; + let intersects = + (a.y > p.y) != (b.y > p.y) + && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x; + if intersects { + inside = !inside; + } + j = i; + } + inside +} + +/// Erkennt geschlossene Raeume aus Wandsegmenten (Port von `detectRooms`). +pub fn detect_rooms( + walls: &[WallSegment], + gap_tol: f64, + min_area: f64, + offset_to_inner: bool, +) -> Vec> { + if walls.len() < 3 { + return Vec::new(); + } + let segs: Vec<(Vec2, Vec2)> = walls.iter().map(|w| (w.a, w.b)).collect(); + let (nodes, edges) = build_planar_graph(&segs, gap_tol); + if edges.len() < 3 { + return Vec::new(); + } + let faces = extract_faces(&nodes, &edges); + let half = average_thickness(walls) / 2.0; + + let mut rooms: Vec> = Vec::new(); + for f in faces { + let raw: Vec = f.iter().map(|&n| nodes[n].p).collect(); + let mut poly = dedupe_ring_rb(&raw); + if poly.len() < 3 { + continue; + } + if offset_to_inner && half > 0.0 { + poly = dedupe_ring_rb(&offset_inward(&poly, half)); + if poly.len() < 3 { + continue; + } + } + if polygon_area(&poly) < min_area { + continue; + } + // Kanonisch CCW zurueckgeben. + if signed_area(&poly) < 0.0 { + poly.reverse(); + } + rooms.push(poly); + } + rooms +} + +/// Klick-in-Raum-Fallback (Port von `roomFromPointInside`). +pub fn room_from_point_inside( + point: Vec2, + walls: &[WallSegment], + gap_tol: f64, + offset_to_inner: bool, +) -> Option> { + if walls.len() < 3 { + return None; + } + let segs: Vec<(Vec2, Vec2)> = walls.iter().map(|w| (w.a, w.b)).collect(); + let (nodes, edges) = build_planar_graph(&segs, gap_tol); + if edges.len() < 3 { + return None; + } + let faces = extract_faces(&nodes, &edges); + let half = average_thickness(walls) / 2.0; + + let mut best: Option> = None; + let mut best_area = f64::INFINITY; + + for f in faces { + let raw: Vec = f.iter().map(|&n| nodes[n].p).collect(); + let raw = dedupe_ring_rb(&raw); + if raw.len() < 3 { + continue; + } + if !rb_point_in_polygon(point, &raw) { + continue; + } + let area = polygon_area(&raw); + if area >= best_area { + continue; + } + let mut inner = raw.clone(); + if offset_to_inner && half > 0.0 { + let off = dedupe_ring_rb(&offset_inward(&raw, half)); + if off.len() >= 3 { + inner = off; + } + } + if signed_area(&inner) < 0.0 { + inner.reverse(); + } + best = Some(inner); + best_area = area; + } + + best +} + +/// Klick-Tracer auf bereits berechneten Wand-Innenflaechen (Port von +/// `roomFromPointInsideFaces`). +pub fn room_from_point_inside_faces( + point: Vec2, + wall_faces: &[WallFace], + gap_tol: f64, +) -> Option> { + if wall_faces.len() < 3 { + return None; + } + let segs: Vec<(Vec2, Vec2)> = wall_faces.iter().map(|f| (f.a, f.b)).collect(); + let (nodes, edges) = build_planar_graph(&segs, gap_tol); + if edges.len() < 3 { + return None; + } + let faces = extract_faces(&nodes, &edges); + + let mut best: Option> = None; + let mut best_area = f64::INFINITY; + + for f in faces { + let raw: Vec = f.iter().map(|&n| nodes[n].p).collect(); + let poly = dedupe_ring_rb(&raw); + if poly.len() < 3 { + continue; + } + if !rb_point_in_polygon(point, &poly) { + continue; + } + let area = polygon_area(&poly); + if area < best_area { + let mut out = poly; + if signed_area(&out) < 0.0 { + out.reverse(); + } + best = Some(out); + best_area = area; + } + } + + best +} + +// --- Batch-Fassaden: roomArea / ceiling (Slice 1) ---------------------------- + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn polygon_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| polygon_area(p)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn perimeter_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| perimeter(p)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn centroid_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| centroid(p)).collect(); + to_js(&out) +} + +/// Query-Struct fuer normalize_outline: optional (None bei ungueltigem Umriss). +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn normalize_outline_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| normalize_outline(p)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn is_valid_outline_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_valid_outline(p)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn ceiling_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| ceiling_area(p)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn outline_bbox_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| outline_bbox(p)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn outline_centroid_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| outline_centroid(p)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct PointInOutlineQuery { + p: Vec2, + outline: Vec, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn point_in_outline_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_in_outline(q.p, &q.outline)).collect(); + to_js(&out) +} + +// --- Batch-Fassaden: stair (Slice 2) ----------------------------------------- + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct DefaultStepCountQuery { + #[serde(rename = "totalRise")] + total_rise: f64, + #[serde(rename = "runLength")] + run_length: f64, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn default_step_count_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| default_step_count(q.total_rise, q.run_length)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct StairGeoQuery { + stair: StairParams, + #[serde(rename = "totalRise")] + total_rise: f64, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn stair_geometry_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| stair_geometry(&q.stair, q.total_rise)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct StairCutQuery { + geo: StairGeometry, + #[serde(rename = "cutRise")] + cut_rise: f64, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn stair_cut_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| stair_cut(&q.geo, q.cut_rise)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn stair_bbox_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let geos: Vec = from_js(input_json)?; + let out: Vec = geos.iter().map(|g| stair_bbox(g)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct PointHitsStairQuery { + p: Vec2, + geo: StairGeometry, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn point_hits_stair_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_hits_stair(q.p, &q.geo)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct PointInPolygonQuery { + p: Vec2, + poly: Vec, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn point_in_polygon_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_in_polygon(q.p, &q.poly)).collect(); + to_js(&out) +} + +// --- Batch-Fassaden: roomBoundary (Slice 3) ----------------------------------- + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct DetectRoomsQuery { + walls: Vec, + #[serde(rename = "gapTol", default = "default_gap_tol")] + gap_tol: f64, + #[serde(rename = "minArea", default = "default_min_area")] + min_area: f64, + #[serde(rename = "offsetToInner", default = "default_true")] + offset_to_inner: bool, +} + +#[cfg(feature = "web")] +fn default_gap_tol() -> f64 { 0.05 } +#[cfg(feature = "web")] +fn default_min_area() -> f64 { 0.05 } +#[cfg(feature = "web")] +fn default_true() -> bool { true } + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn detect_rooms_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| detect_rooms(&q.walls, q.gap_tol, q.min_area, q.offset_to_inner)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct RoomFromPointQuery { + point: Vec2, + walls: Vec, + #[serde(rename = "gapTol", default = "default_gap_tol")] + gap_tol: f64, + #[serde(rename = "offsetToInner", default = "default_true")] + offset_to_inner: bool, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn room_from_point_inside_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| room_from_point_inside(q.point, &q.walls, q.gap_tol, q.offset_to_inner)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct RoomFromFacesQuery { + point: Vec2, + #[serde(rename = "wallFaces")] + wall_faces: Vec, + #[serde(rename = "gapTol", default = "default_gap_tol")] + gap_tol: f64, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn room_from_point_inside_faces_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| room_from_point_inside_faces(q.point, &q.wall_faces, q.gap_tol)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct RbPointInPolygonQuery { + p: Vec2, + poly: Vec, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn rb_point_in_polygon_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| rb_point_in_polygon(q.p, &q.poly)).collect(); + to_js(&out) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/geometry/kernel2d.parity.test.ts b/src/geometry/kernel2d.parity.test.ts index 2767cfd..a6490d9 100644 --- a/src/geometry/kernel2d.parity.test.ts +++ b/src/geometry/kernel2d.parity.test.ts @@ -42,6 +42,33 @@ import { 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 }; @@ -444,6 +471,447 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Zufall", () => { }); }); +// ── 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 3–10 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 = [