diff --git a/src-tauri/kernel2d/src/lib.rs b/src-tauri/kernel2d/src/lib.rs index 76e4f27..78f77f9 100644 --- a/src-tauri/kernel2d/src/lib.rs +++ b/src-tauri/kernel2d/src/lib.rs @@ -18,6 +18,7 @@ // (f64 nicht assoziativ; kein Kahan/Reorder). use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; /// EPS aus kernel2d.ts (Primitive/Schnitt/Trim). ACHTUNG: `lineIntersect` /// benutzt bewusst ein ANDERES, hartkodiertes 1e-9 — nicht dieses EPS. @@ -414,6 +415,568 @@ pub fn fillet_corner(corner: Vec2, p1: Vec2, p2: Vec2, r: f64) -> Option }) } +// --- Trim / Split / Join ----------------------------------------------------- +// Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Struktur- und +// reihenfolgeabhaengig — Sortier-Reihenfolge, Dedup-Schwellen (1e-6) und die +// greedy-Verbindungslogik von joinChains muessen EXAKT wie TS sein. + +/// Polylinie / Cutter / Kette: `{pts, closed}` (Port des TS-`{pts, closed}`). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Polyline { + pub pts: Vec, + pub closed: bool, +} + +/// Ein Schnitt-Treffer auf einer Kante: Kantenindex + Parameter + Punkt. +#[derive(Clone, Copy)] +struct EdgeHit { + edge: usize, + t: f64, + point: Vec2, +} + +/// Lineare Interpolation zweier Punkte. +fn lerp(a: Vec2, b: Vec2, t: f64) -> Vec2 { + add(a, scale(sub(b, a), t)) +} + +/// Entfernt aufeinanderfolgende (nahezu) gleiche Punkte (kein Ringschluss). +fn dedupe_consecutive(pts: &[Vec2]) -> Vec { + let mut out: Vec = Vec::new(); + for &p in pts { + if out.is_empty() || !vec_equal(out[out.len() - 1], p, EPS) { + out.push(p); + } + } + out +} + +/// Dedup aufeinanderfolgender Punkte UND schliessender Duplikat-Endpunkt. +fn dedupe_ring(pts: &[Vec2]) -> Vec { + let mut out = dedupe_consecutive(pts); + if out.len() > 1 && vec_equal(out[0], out[out.len() - 1], EPS) { + out.pop(); + } + out +} + +/// Vergleichsfunktion `(edge, t)` wie TS `(p.edge-q.edge) || (p.t-q.t)`, stabil. +fn cmp_edge_t(p: &EdgeHit, q: &EdgeHit) -> Ordering { + p.edge + .cmp(&q.edge) + .then(p.t.partial_cmp(&q.t).unwrap_or(Ordering::Equal)) +} + +/// Zerschneidet eine STRECKE an allen inneren Cutter-Schnitten (t ∈ (EPS,1-EPS)). +pub fn split_segment_by_cutters(a1: Vec2, a2: Vec2, cutters: &[Polyline]) -> Vec<(Vec2, Vec2)> { + let mut ts: Vec = vec![0.0, 1.0]; + for c in cutters { + for h in segment_polyline_hits(a1, a2, &c.pts, c.closed) { + if h.t > EPS && h.t < 1.0 - EPS { + ts.push(h.t); + } + } + } + ts.sort_by(|p, q| p.partial_cmp(q).unwrap_or(Ordering::Equal)); + let da = sub(a2, a1); + let mut pieces: Vec<(Vec2, Vec2)> = Vec::new(); + for i in 0..ts.len() - 1 { + if ts[i + 1] - ts[i] < 1e-6 { + continue; + } + pieces.push((add(a1, scale(da, ts[i])), add(a1, scale(da, ts[i + 1])))); + } + pieces +} + +/// Trim: schneidet an den Cuttern und VERWIRFT das dem `pick` naechste Teilstueck. +pub fn trim_segment(a1: Vec2, a2: Vec2, cutters: &[Polyline], pick: Vec2) -> Vec<(Vec2, Vec2)> { + let pieces = split_segment_by_cutters(a1, a2, cutters); + if pieces.len() <= 1 { + return pieces; + } + let mut best = 0usize; + let mut best_d = f64::INFINITY; + for (i, pc) in pieces.iter().enumerate() { + let d = point_segment_distance(pick, pc.0, pc.1); + if d < best_d { + best_d = d; + best = i; + } + } + pieces + .into_iter() + .enumerate() + .filter(|(i, _)| *i != best) + .map(|(_, p)| p) + .collect() +} + +/// Globaler Lauf-Parameter (edgeIndex + t) des dem Punkt naechsten Kettenpunktes. +fn nearest_param_on_chain(edges: &[(Vec2, Vec2)], p: Vec2) -> f64 { + let mut best = 0.0; + let mut best_d = f64::INFINITY; + for (ei, &(a, b)) in edges.iter().enumerate() { + let t = project_param(p, a, b).min(1.0).max(0.0); + let q = add(a, scale(sub(b, a), t)); + let d = dist(p, q); + if d < best_d { + best_d = d; + best = ei as f64 + t; + } + } + best +} + +/// Quick-Trim einer ganzen Kurve an einem Klickpunkt (siehe TS-Doku). +pub fn trim_polyline(pts: &[Vec2], closed: bool, cutters: &[Polyline], pick: Vec2) -> Vec { + let edges = polyline_edges(pts, closed); + if edges.is_empty() { + return vec![Polyline { pts: pts.to_vec(), closed }]; + } + let mut cuts: Vec = Vec::new(); + for ei in 0..edges.len() { + let (a1, a2) = edges[ei]; + for c in cutters { + for h in segment_polyline_hits(a1, a2, &c.pts, c.closed) { + if h.t > EPS && h.t < 1.0 - EPS { + cuts.push(EdgeHit { edge: ei, t: h.t, point: h.point }); + } + } + } + } + cuts.sort_by(cmp_edge_t); + let mut cut: Vec = Vec::new(); + for h in cuts { + if let Some(prev) = cut.last() { + if prev.edge == h.edge && (prev.t - h.t).abs() < 1e-6 { + continue; + } + } + cut.push(h); + } + if cut.is_empty() { + return vec![Polyline { pts: pts.to_vec(), closed }]; + } + + if !closed { + let pick_pos = nearest_param_on_chain(&edges, pick); + let cut_pos: Vec = cut.iter().map(|c| c.edge as f64 + c.t).collect(); + let mut lo_idx: isize = -1; + let mut hi_idx: usize = cut.len(); + for i in 0..cut.len() { + if cut_pos[i] <= pick_pos { + lo_idx = i as isize; + } else { + hi_idx = i; + break; + } + } + let mut result: Vec = Vec::new(); + if lo_idx >= 0 { + let c = cut[lo_idx as usize]; + let mut head: Vec = pts[..c.edge + 1].to_vec(); + head.push(c.point); + let d = dedupe_consecutive(&head); + if d.len() >= 2 { + result.push(Polyline { pts: d, closed: false }); + } + } + if hi_idx < cut.len() { + let c = cut[hi_idx]; + let mut tail: Vec = vec![c.point]; + for k in c.edge + 1..pts.len() { + tail.push(pts[k]); + } + let d = dedupe_consecutive(&tail); + if d.len() >= 2 { + result.push(Polyline { pts: d, closed: false }); + } + } + return result; + } + + // Geschlossen. + if cut.len() == 1 { + return vec![Polyline { pts: pts.to_vec(), closed: true }]; + } + let total = edges.len(); + let span = |e_x: usize, p_x: Vec2, e_y: usize, p_y: Vec2| -> Vec { + let mut out: Vec = vec![p_x]; + let mut e = e_x; + let mut steps = 0usize; + while steps <= total { + if e == e_y { + break; + } + out.push(edges[e].1); + e = (e + 1) % total; + steps += 1; + } + out.push(p_y); + dedupe_consecutive(&out) + }; + let pick_pos = nearest_param_on_chain(&edges, pick); + let cut_pos: Vec = cut.iter().map(|c| c.edge as f64 + c.t).collect(); + let mut seg: isize = -1; + for i in 0..cut.len() { + let a = cut_pos[i]; + let b = cut_pos[(i + 1) % cut.len()]; + let inside = if i == cut.len() - 1 { + pick_pos >= a || pick_pos <= b + } else { + pick_pos >= a && pick_pos <= b + }; + if inside { + seg = i as isize; + break; + } + } + if seg < 0 { + seg = 0; + } + let seg = seg as usize; + let from = cut[(seg + 1) % cut.len()]; + let to = cut[seg]; + let chain = span(from.edge, from.point, to.edge, to.point); + if chain.len() < 2 { + return Vec::new(); + } + vec![Polyline { pts: chain, closed: false }] +} + +/// Verlaengert das gewaehlte Ende bis zur naechsten Cutter-Kante (oder None). +pub fn extend_segment( + a1: Vec2, + a2: Vec2, + end: &str, + cutters: &[Polyline], +) -> Option<(Vec2, Vec2)> { + let mut best_t: Option = None; + for c in cutters { + for (b1, b2) in polyline_edges(&c.pts, c.closed) { + let h = match line_segment_intersect(a1, a2, b1, b2, EPS) { + Some(h) => h, + None => continue, + }; + if end == "end" && h.t > 1.0 + EPS { + if best_t.map_or(true, |bt| h.t < bt) { + best_t = Some(h.t); + } + } else if end == "start" && h.t < -EPS { + if best_t.map_or(true, |bt| h.t > bt) { + best_t = Some(h.t); + } + } + } + } + let bt = best_t?; + let da = sub(a2, a1); + let hit_point = add(a1, scale(da, bt)); + Some(if end == "end" { + (a1, hit_point) + } else { + (hit_point, a2) + }) +} + +/// Teilt eine Polylinie an EINEM Punkt P = lerp(pts[edgeIndex], next, t). +pub fn split_polyline_at_param( + pts: &[Vec2], + closed: bool, + edge_index: usize, + t: f64, +) -> Vec> { + let n = pts.len(); + if n < 2 { + return vec![pts.to_vec()]; + } + let a = pts[edge_index]; + let b = pts[(edge_index + 1) % n]; + let p = lerp(a, b, t); + let at_start = t <= EPS; + let at_end = t >= 1.0 - EPS; + + if !closed { + let mut left: Vec = pts[..edge_index + 1].to_vec(); + if !at_start && !at_end { + left.push(p); + } else if at_end { + left.push(b); + } + let mut right: Vec = Vec::new(); + if !at_start && !at_end { + right.push(p); + } else if at_start { + right.push(a); + } + for i in edge_index + 1..n { + right.push(pts[i]); + } + let pieces: Vec> = [left, right] + .into_iter() + .filter(|s| s.len() >= 2) + .collect(); + return if !pieces.is_empty() { + pieces + } else { + vec![pts.to_vec()] + }; + } + + let mut out: Vec = Vec::new(); + if !at_start { + out.push(p); + } + for k in 1..=n { + out.push(pts[(edge_index + k) % n]); + } + if !at_start { + out.push(p); + } else { + out.push(a); + } + vec![dedupe_consecutive(&out)] +} + +/// Teilt ein GESCHLOSSENES Polygon an zwei Randpunkten via Sehne (2 Ringe). +pub fn split_closed_by_chord( + pts: &[Vec2], + i: usize, + ti: f64, + j: usize, + tj: f64, +) -> Option<(Vec, Vec)> { + let n = pts.len(); + if n < 3 || i == j { + return None; + } + let (ia, ta, ib, tb) = if i > j { + (j, tj, i, ti) + } else { + (i, ti, j, tj) + }; + let p_a = lerp(pts[ia], pts[(ia + 1) % n], ta); + let p_b = lerp(pts[ib], pts[(ib + 1) % n], tb); + let mut arc1: Vec = vec![p_a]; + for k in ia + 1..=ib { + arc1.push(pts[k % n]); + } + arc1.push(p_b); + let mut arc2: Vec = vec![p_b]; + for k in ib + 1..=ia + n { + arc2.push(pts[k % n]); + } + arc2.push(p_a); + Some((dedupe_ring(&arc1), dedupe_ring(&arc2))) +} + +/// Entfernt das Segment `edge_index` (offen: laengeres Stueck; geschlossen: auftrennen). +pub fn remove_segment(pts: &[Vec2], closed: bool, edge_index: usize) -> Polyline { + let n = pts.len(); + if closed { + let mut out: Vec = Vec::new(); + for k in 1..=n { + out.push(pts[(edge_index + k) % n]); + } + return Polyline { pts: dedupe_consecutive(&out), closed: false }; + } + if n == 0 { + return Polyline { pts: Vec::new(), closed: false }; + } + if edge_index == 0 { + return Polyline { pts: pts[1..].to_vec(), closed: false }; + } + if edge_index >= n - 1 { + return Polyline { pts: pts[..n - 1].to_vec(), closed: false }; + } + let left = pts[..edge_index + 1].to_vec(); + let right = pts[edge_index + 1..].to_vec(); + if left.len() >= right.len() { + Polyline { pts: left, closed: false } + } else { + Polyline { pts: right, closed: false } + } +} + +/// Kanten einer (offenen ODER implizit geschlossenen) Punktliste. +fn polyline_edges_auto(pts: &[Vec2]) -> Vec<(Vec2, Vec2)> { + let closed = pts.len() > 2 && vec_equal(pts[0], pts[pts.len() - 1], EPS); + if closed { + polyline_edges(&pts[..pts.len() - 1], true) + } else { + polyline_edges(pts, false) + } +} + +/// Offene Polylinie an gegebenen EdgeHits (auf ihren Kanten) zerschneiden. +fn split_open_by_edge_hits(pts: &[Vec2], hits: &[EdgeHit]) -> Vec> { + if pts.is_empty() { + return Vec::new(); + } + let mut pieces: Vec> = Vec::new(); + let mut cur: Vec = vec![pts[0]]; + let mut hi = 0usize; + for ei in 0..pts.len() - 1 { + while hi < hits.len() && hits[hi].edge == ei { + let p = hits[hi].point; + cur.push(p); + pieces.push(cur.clone()); + cur = vec![p]; + hi += 1; + } + cur.push(pts[ei + 1]); + } + pieces.push(cur); + pieces + .into_iter() + .map(|s| dedupe_consecutive(&s)) + .filter(|s| s.len() >= 2) + .collect() +} + +/// Offene Polylinie an einer Liste von Schnitt-PUNKTEN (auf dem Zug) zerschneiden. +fn split_open_at_hits(pts: &[Vec2], cut_points: &[Vec2]) -> Vec> { + if cut_points.is_empty() { + return vec![pts.to_vec()]; + } + let mut hits: Vec = Vec::new(); + for ei in 0..pts.len().saturating_sub(1) { + let a = pts[ei]; + let b = pts[ei + 1]; + for &cp in cut_points { + let t = project_param(cp, a, b); + if t > EPS && t < 1.0 - EPS && point_segment_distance(cp, a, b) < 1e-6 { + hits.push(EdgeHit { edge: ei, t, point: cp }); + } + } + } + hits.sort_by(cmp_edge_t); + split_open_by_edge_hits(pts, &hits) +} + +/// Teilt das Ziel an allen Schnittpunkten mit den anderen Polylinien. +pub fn split_at_intersections( + target_pts: &[Vec2], + closed: bool, + others: &[Vec], +) -> Vec> { + let edges = polyline_edges(target_pts, closed); + let mut hits: Vec = Vec::new(); + for ei in 0..edges.len() { + let (a1, a2) = edges[ei]; + for o in others { + for (b1, b2) in polyline_edges_auto(o) { + if let Some(h) = segment_intersect(a1, a2, b1, b2, EPS) { + hits.push(EdgeHit { edge: ei, t: h.t, point: h.point }); + } + } + } + } + hits.sort_by(cmp_edge_t); + let mut dedup: Vec = Vec::new(); + for h in hits { + if let Some(prev) = dedup.last() { + if prev.edge == h.edge && (prev.t - h.t).abs() < 1e-6 { + continue; + } + } + if h.t <= EPS || h.t >= 1.0 - EPS { + continue; + } + dedup.push(h); + } + if dedup.is_empty() { + return vec![target_pts.to_vec()]; + } + if closed { + if dedup.len() == 2 { + return match split_closed_by_chord( + target_pts, dedup[0].edge, dedup[0].t, dedup[1].edge, dedup[1].t, + ) { + Some((a, b)) => vec![a, b], + None => vec![target_pts.to_vec()], + }; + } + let opened = split_polyline_at_param(target_pts, true, dedup[0].edge, dedup[0].t) + .into_iter() + .next() + .unwrap_or_default(); + let cut_pts: Vec = dedup[1..].iter().map(|h| h.point).collect(); + return split_open_at_hits(&opened, &cut_pts); + } + split_open_by_edge_hits(target_pts, &dedup) +} + +/// Verschmilzt Polylinien an koinzidenten Endpunkten zu laengeren Ketten. +/// Greedy `i Vec { + let mut closed_out: Vec = Vec::new(); + let mut open: Vec> = Vec::new(); + for pl in polylines { + if pl.closed { + closed_out.push(Polyline { pts: pl.pts.clone(), closed: true }); + } else if pl.pts.len() >= 2 { + open.push(pl.pts.clone()); + } else if pl.pts.len() == 1 { + open.push(pl.pts.clone()); + } + } + + let mut merged = true; + while merged { + merged = false; + 'outer: for i in 0..open.len() { + for j in i + 1..open.len() { + let a = &open[i]; + let b = &open[j]; + let a_s = a[0]; + let a_e = a[a.len() - 1]; + let b_s = b[0]; + let b_e = b[b.len() - 1]; + let combined: Option> = if vec_equal(a_e, b_s, EPS) { + let mut v = a.clone(); + v.extend_from_slice(&b[1..]); + Some(v) + } else if vec_equal(a_e, b_e, EPS) { + let mut v = a.clone(); + let mut rev: Vec = b[..b.len() - 1].to_vec(); + rev.reverse(); + v.extend(rev); + Some(v) + } else if vec_equal(a_s, b_e, EPS) { + let mut v = b.clone(); + v.extend_from_slice(&a[1..]); + Some(v) + } else if vec_equal(a_s, b_s, EPS) { + let mut v: Vec = b.clone(); + v.reverse(); + v.extend_from_slice(&a[1..]); + Some(v) + } else { + None + }; + if let Some(c) = combined { + open.remove(j); + open[i] = c; + merged = true; + break 'outer; + } + } + } + } + + let mut out: Vec = closed_out; + for chain in open { + if chain.len() > 2 && vec_equal(chain[0], chain[chain.len() - 1], EPS) { + out.push(Polyline { pts: chain[..chain.len() - 1].to_vec(), closed: true }); + } else { + out.push(Polyline { pts: chain, closed: false }); + } + } + out +} + // --- 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 @@ -517,6 +1080,79 @@ struct FilletQuery { r: f64, } +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct SplitCuttersQuery { + a1: Vec2, + a2: Vec2, + cutters: Vec, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct TrimSegQuery { + a1: Vec2, + a2: Vec2, + cutters: Vec, + pick: Vec2, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct TrimPolyQuery { + pts: Vec, + closed: bool, + cutters: Vec, + pick: Vec2, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct ExtendQuery { + a1: Vec2, + a2: Vec2, + end: String, + cutters: Vec, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct SplitAtParamQuery { + pts: Vec, + closed: bool, + #[serde(rename = "edgeIndex")] + edge_index: usize, + t: f64, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct ChordQuery { + pts: Vec, + i: usize, + ti: f64, + j: usize, + tj: f64, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct RemoveSegQuery { + pts: Vec, + closed: bool, + #[serde(rename = "edgeIndex")] + edge_index: usize, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct SplitIntersectQuery { + #[serde(rename = "targetPts")] + target_pts: Vec, + closed: bool, + others: Vec>, +} + #[cfg(feature = "web")] #[wasm_bindgen::prelude::wasm_bindgen] pub fn project_param_batch_json(input_json: &str) -> Result { @@ -673,6 +1309,119 @@ pub fn fillet_corner_batch_json(input_json: &str) -> Result Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| split_segment_by_cutters(q.a1, q.a2, &q.cutters)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn trim_segment_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| trim_segment(q.a1, q.a2, &q.cutters, q.pick)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn trim_polyline_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| trim_polyline(&q.pts, q.closed, &q.cutters, q.pick)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn extend_segment_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| extend_segment(q.a1, q.a2, &q.end, &q.cutters)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn split_polyline_at_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| split_polyline_at_param(&q.pts, q.closed, q.edge_index, q.t)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn split_closed_by_chord_batch_json( + input_json: &str, +) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec, Vec)>> = qs + .iter() + .map(|q| split_closed_by_chord(&q.pts, q.i, q.ti, q.j, q.tj)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn remove_segment_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| remove_segment(&q.pts, q.closed, q.edge_index)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn split_at_intersections_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| split_at_intersections(&q.target_pts, q.closed, &q.others)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn join_chains_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let groups: Vec> = from_js(input_json)?; + let out: Vec> = groups.iter().map(|g| join_chains(g)).collect(); + to_js(&out) +} + #[cfg(test)] mod tests { use super::*; @@ -855,4 +1604,67 @@ mod tests { ) .is_none()); } + + fn poly(pts: &[(f64, f64)], closed: bool) -> Polyline { + Polyline { + pts: pts.iter().map(|&(x, y)| Vec2::new(x, y)).collect(), + closed, + } + } + + #[test] + fn trim_segment_removes_picked_piece() { + // Strecke (0,0)->(4,0), ein vertikaler Cutter bei x=2 → zwei Stuecke. + // Pick bei (0.5,0) → linkes Stueck faellt weg, rechtes bleibt. + let cutters = [poly(&[(2.0, -1.0), (2.0, 1.0)], false)]; + let rest = trim_segment( + Vec2::new(0.0, 0.0), + Vec2::new(4.0, 0.0), + &cutters, + Vec2::new(0.5, 0.0), + ); + assert_eq!(rest.len(), 1); + assert!((rest[0].0.x - 2.0).abs() < 1e-9 && (rest[0].1.x - 4.0).abs() < 1e-9); + } + + #[test] + fn split_at_intersections_closed_square_by_two_cuts() { + // Einheitsquadrat, ein waagerechter Schneider y=0.5 quer → zwei Ringe. + let square = [ + Vec2::new(0.0, 0.0), + Vec2::new(1.0, 0.0), + Vec2::new(1.0, 1.0), + Vec2::new(0.0, 1.0), + ]; + let others = vec![vec![Vec2::new(-1.0, 0.5), Vec2::new(2.0, 0.5)]]; + let parts = split_at_intersections(&square, true, &others); + assert_eq!(parts.len(), 2, "zwei geschlossene Teilpolygone"); + for p in &parts { + assert!(p.len() >= 3); + } + } + + #[test] + fn join_chains_merges_and_closes() { + // Drei offene Kanten eines Dreiecks → eine geschlossene Kette. + let input = vec![ + poly(&[(0.0, 0.0), (1.0, 0.0)], false), + poly(&[(1.0, 0.0), (0.5, 1.0)], false), + poly(&[(0.5, 1.0), (0.0, 0.0)], false), + ]; + let out = join_chains(&input); + assert_eq!(out.len(), 1); + assert!(out[0].closed, "Dreieck schliesst sich"); + assert_eq!(out[0].pts.len(), 3, "Schlusspunkt-Duplikat entfernt"); + } + + #[test] + fn remove_segment_open_keeps_longer_piece() { + // Offene Polylinie mit 5 Punkten; innere Kante 1 entfernen → laengeres Stueck. + let pts: Vec = (0..5).map(|i| Vec2::new(i as f64, 0.0)).collect(); + let out = remove_segment(&pts, false, 1); + assert!(!out.closed); + assert_eq!(out.pts.len(), 3, "rechtes (laengeres) Stueck pts[2..5]"); + assert!((out.pts[0].x - 2.0).abs() < 1e-9); + } } diff --git a/src/geometry/kernel2d.parity.test.ts b/src/geometry/kernel2d.parity.test.ts index 9cc943e..2767cfd 100644 --- a/src/geometry/kernel2d.parity.test.ts +++ b/src/geometry/kernel2d.parity.test.ts @@ -18,22 +18,33 @@ import type { Vec2 } from "../model/types"; import { circleCircleIntersect, closestPointOnSegment, + extendSegment, filletCorner, isCCW, + joinChains, lineCircleIntersect, lineSegmentIntersect, offsetPolyline, offsetSegment, pointSegmentDistance, projectParam, + removeSegment, segmentCircleIntersect, segmentIntersect, segmentPolylineHits, signedArea, + splitAtIntersections, + splitClosedByChord, + splitPolylineAtParam, + splitSegmentByCutters, + trimPolyline, + trimSegment, type Fillet, type Hit, } from "./kernel2d"; +type Poly = { pts: Vec2[]; closed: boolean }; + // Das WASM-Paket ist git-ignoriert und wird nur von `npm run build:kernel2d` // erzeugt. Fehlt es, wird die Suite SAUBER uebersprungen (statt Collection-Fehler), // damit `vitest run` ohne vorherigen WASM-Build gruen bleibt. Darum dynamischer @@ -108,6 +119,33 @@ function closeVec(a: Vec2, b: Vec2, rel = 1e-9): boolean { function closeHit(a: Hit, b: Hit): boolean { return closeVec(a.point, b.point) && closeNum(a.t, b.t) && closeNum(a.s, b.s); } +function eqPts(w: Vec2[], t: Vec2[]): boolean { + return w.length === t.length && t.every((p, i) => closeVec(w[i], p)); +} +function eqPtsLists(w: Vec2[][], t: Vec2[][]): boolean { + return w.length === t.length && t.every((s, i) => eqPts(w[i], s)); +} +function eqPolyList(w: Poly[], t: Poly[]): boolean { + return w.length === t.length && t.every((pl, i) => w[i].closed === pl.closed && eqPts(w[i].pts, pl.pts)); +} +function eqPairs(w: [Vec2, Vec2][], t: [Vec2, Vec2][]): boolean { + return w.length === t.length && t.every((pr, i) => closeVec(w[i][0], pr[0]) && closeVec(w[i][1], pr[1])); +} +/** Ein paar lange Schneider-Segmente quer durch die BBox (als offene Cutter). */ +function genCutters(rng: () => number): Poly[] { + const k = 1 + Math.floor(rng() * 3); + return Array.from({ length: k }, () => ({ + pts: [ + { x: -80, y: (rng() * 2 - 1) * 60 }, + { x: 80, y: (rng() * 2 - 1) * 60 }, + ], + closed: false, + })); +} +function genPolyline(rng: () => number, minN: number, maxN: number): Vec2[] { + const n = minN + Math.floor(rng() * (maxN - minN + 1)); + return Array.from({ length: n }, () => ({ x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 })); +} const N = 300; @@ -287,6 +325,123 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Zufall", () => { }); expect(valid, "keine gueltige Verrundung — Test waere aussagelos").toBeGreaterThan(50); }); + + it("splitSegmentByCutters / trimSegment (Struktur + Werte)", () => { + const rng = mulberry32(9); + const qs = Array.from({ length: N }, () => ({ + a1: { x: -60, y: (rng() * 2 - 1) * 40 }, + a2: { x: 60, y: (rng() * 2 - 1) * 40 }, + cutters: genCutters(rng), + pick: { x: (rng() * 2 - 1) * 60, y: (rng() * 2 - 1) * 40 }, + })); + const wSplit = JSON.parse(K.split_segment_by_cutters_batch_json(JSON.stringify(qs))) as [Vec2, Vec2][][]; + const wTrim = JSON.parse(K.trim_segment_batch_json(JSON.stringify(qs))) as [Vec2, Vec2][][]; + qs.forEach((q, i) => { + expect(eqPairs(wSplit[i], splitSegmentByCutters(q.a1, q.a2, q.cutters)), `split#${i}`).toBe(true); + expect(eqPairs(wTrim[i], trimSegment(q.a1, q.a2, q.cutters, q.pick)), `trim#${i}`).toBe(true); + }); + }); + + it("trimPolyline / splitAtIntersections (offen+geschlossen, Struktur exakt)", () => { + const rng = mulberry32(10); + const qs = Array.from({ length: N }, () => ({ + pts: genPolyline(rng, 4, 9), + closed: rng() < 0.5, + cutters: genCutters(rng), + pick: { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 }, + others: genCutters(rng).map((c) => c.pts), + })); + const wTrim = JSON.parse(K.trim_polyline_batch_json(JSON.stringify(qs))) as Poly[][]; + const wSplit = JSON.parse( + K.split_at_intersections_batch_json( + JSON.stringify(qs.map((q) => ({ targetPts: q.pts, closed: q.closed, others: q.others }))), + ), + ) as Vec2[][][]; + qs.forEach((q, i) => { + expect(eqPolyList(wTrim[i], trimPolyline(q.pts, q.closed, q.cutters, q.pick)), `trimPoly#${i}`).toBe(true); + expect(eqPtsLists(wSplit[i], splitAtIntersections(q.pts, q.closed, q.others)), `splitAt#${i}`).toBe(true); + }); + }); + + it("splitPolylineAtParam / splitClosedByChord / removeSegment", () => { + const rng = mulberry32(11); + const qs = Array.from({ length: N }, () => { + const pts = genPolyline(rng, 4, 9); + const n = pts.length; + const closed = rng() < 0.5; + return { pts, closed, edgeIndex: Math.floor(rng() * (closed ? n : n - 1)), t: rng() }; + }); + const wSap = JSON.parse(K.split_polyline_at_param_batch_json(JSON.stringify(qs))) as Vec2[][][]; + const wRem = JSON.parse( + K.remove_segment_batch_json( + JSON.stringify(qs.map((q) => ({ pts: q.pts, closed: q.closed, edgeIndex: q.edgeIndex }))), + ), + ) as Poly[]; + const chords = Array.from({ length: N }, () => { + const pts = genPolyline(rng, 4, 8); + const n = pts.length; + const i = Math.floor(rng() * n); + let j = Math.floor(rng() * n); + if (j === i) j = (j + 1) % n; + return { pts, i, ti: rng(), j, tj: rng() }; + }); + const wChord = JSON.parse(K.split_closed_by_chord_batch_json(JSON.stringify(chords))) as ([Vec2[], Vec2[]] | null)[]; + qs.forEach((q, i) => { + expect(eqPtsLists(wSap[i], splitPolylineAtParam(q.pts, q.closed, q.edgeIndex, q.t)), `sap#${i}`).toBe(true); + const tr = removeSegment(q.pts, q.closed, q.edgeIndex); + expect(wRem[i].closed === tr.closed && eqPts(wRem[i].pts, tr.pts), `rem#${i}`).toBe(true); + }); + chords.forEach((q, i) => { + const t = splitClosedByChord(q.pts, q.i, q.ti, q.j, q.tj); + expect((wChord[i] === null) === (t === null), `chord-null#${i}`).toBe(true); + if (t && wChord[i]) { + expect(eqPts(wChord[i]![0], t[0]) && eqPts(wChord[i]![1], t[1]), `chord#${i}`).toBe(true); + } + }); + }); + + it("extendSegment (start/end, null-Paritaet + Werte)", () => { + const rng = mulberry32(12); + const qs = Array.from({ length: N }, (_, k) => { + const c = { x: (rng() * 2 - 1) * 30, y: (rng() * 2 - 1) * 30 }; + return { + a1: { x: c.x - 1, y: c.y }, + a2: { x: c.x + 1, y: c.y }, + end: (k % 2 === 0 ? "end" : "start") as "start" | "end", + cutters: genCutters(rng).concat([ + { pts: [{ x: c.x + 5, y: -50 }, { x: c.x + 5, y: 50 }], closed: false }, + ]), + }; + }); + const w = JSON.parse(K.extend_segment_batch_json(JSON.stringify(qs))) as ([Vec2, Vec2] | null)[]; + qs.forEach((q, i) => { + const t = extendSegment(q.a1, q.a2, q.end, q.cutters); + expect((w[i] === null) === (t === null), `ext-null#${i}`).toBe(true); + if (t && w[i]) expect(closeVec(w[i]![0], t[0]) && closeVec(w[i]![1], t[1]), `ext#${i}`).toBe(true); + }); + }); + + it("joinChains (mergeable + Zufall, Struktur exakt)", () => { + const rng = mulberry32(13); + const groups = Array.from({ length: N }, () => { + const base = genPolyline(rng, 4, 7); + const chains: Poly[] = []; + for (let k = 0; k < base.length - 1; k++) { + const seg = [base[k], base[k + 1]]; + chains.push({ pts: rng() < 0.5 ? seg : [seg[1], seg[0]], closed: false }); + } + if (rng() < 0.5) chains.push({ pts: genPolyline(rng, 2, 3), closed: false }); + for (let k = chains.length - 1; k > 0; k--) { + const j = Math.floor(rng() * (k + 1)); + [chains[k], chains[j]] = [chains[j], chains[k]]; + } + return chains; + }); + const w = JSON.parse(K.join_chains_batch_json(JSON.stringify(groups))) as Poly[][]; + groups.forEach((g, i) => { + expect(eqPolyList(w[i], joinChains(g)), `join#${i}`).toBe(true); + }); + }); }); describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {