Files
DOSSIER-STANDALONE/src-tauri/kernel2d/src/lib.rs
T
karim 35299307d6 Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
2026-07-09 00:57:29 +02:00

3384 lines
105 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// kernel2d — Rust/WASM-Port von `src/geometry/kernel2d.ts` (+ reine Geometrie aus
// room/ceiling/roomArea/stair). Handgeschriebene f64-Mathematik, KEINE externen
// Geometrie-Crates: Akzeptanzkriterium ist Differential-Paritaet gegen die naive
// TS-Routine (siehe PORT_PLAN.md). Fremd-Crates mit anderem Algorithmus braechen
// die Paritaet per Konstruktion.
//
// Aufbau (waechst ueber die Phasen des PORT_PLAN):
// - Phase 1 (hier): Vec2 + Vektor-Helfer (Port von src/model/geometry.ts) +
// leere Batch-WASM-Fassade. `cargo test` + `build:kernel2d` gruen.
// - Phase 2+: Schnitt/Offset/Trim/Fillet/Fläche/Kreis/detectRooms/… .
//
// KRITISCHE PARITAETS-REGELN (PORT_PLAN §6), gelten fuer den ganzen Port:
// - `len` = Math.hypot → `f64::hypot` (NICHT (x²+y²).sqrt()).
// - `normalize` Null-Guard: `len || 1` → `if l==0.0 {1.0} else {l}` (Ergebnis
// {0,0}, kein NaN).
// - Zwei Epsilons: EPS=1e-7 (kernel2d) UND hartkodiert 1e-9 in lineIntersect.
// - Term-Reihenfolge in cross/signedArea/Diskriminante exakt beibehalten
// (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.
pub const EPS: f64 = 1e-7;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
pub struct Vec2 {
pub x: f64,
pub y: f64,
}
impl Vec2 {
pub const fn new(x: f64, y: f64) -> Self {
Vec2 { x, y }
}
}
// --- Vektor-Helfer: 1:1-Port aus src/model/geometry.ts -----------------------
#[inline]
pub fn sub(a: Vec2, b: Vec2) -> Vec2 {
Vec2 { x: a.x - b.x, y: a.y - b.y }
}
#[inline]
pub fn add(a: Vec2, b: Vec2) -> Vec2 {
Vec2 { x: a.x + b.x, y: a.y + b.y }
}
#[inline]
pub fn scale(a: Vec2, s: f64) -> Vec2 {
Vec2 { x: a.x * s, y: a.y * s }
}
/// `len` = `Math.hypot` → `f64::hypot` (NICHT sqrt(x²+y²), siehe §6).
#[inline]
pub fn len(a: Vec2) -> f64 {
a.x.hypot(a.y)
}
/// Null-Guard wie TS `len(a) || 1`: bei Laenge 0 → Divisor 1 (Ergebnis {0,0}).
#[inline]
pub fn normalize(a: Vec2) -> Vec2 {
let l = len(a);
let l = if l == 0.0 { 1.0 } else { l };
Vec2 { x: a.x / l, y: a.y / l }
}
/// Linke Normale (90° gegen den Uhrzeigersinn gedreht).
#[inline]
pub fn left_normal(a: Vec2) -> Vec2 {
Vec2 { x: -a.y, y: a.x }
}
/// Kreuzprodukt (Z-Komponente). Term-Reihenfolge exakt wie TS: `p.x*q.y - p.y*q.x`.
#[inline]
pub fn cross(p: Vec2, q: Vec2) -> f64 {
p.x * q.y - p.y * q.x
}
/// Skalarprodukt. Term-Reihenfolge exakt wie TS: `p.x*q.x + p.y*q.y`.
#[inline]
pub fn dot(p: Vec2, q: Vec2) -> f64 {
p.x * q.x + p.y * q.y
}
/// Schnittpunkt der Geraden (a + t·da) mit (b + s·db). None bei (nahezu)
/// parallelen Richtungen. HARTKODIERTES 1e-9 (nicht EPS!) — der Offset-Miter-
/// Fallback haengt an genau dieser Schwelle (§6).
pub fn line_intersect(a: Vec2, da: Vec2, b: Vec2, db: Vec2) -> Option<Vec2> {
let denom = cross(da, db);
if denom.abs() < 1e-9 {
return None; // parallel → kein Schnitt
}
let t = cross(sub(b, a), db) / denom;
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<Hit> {
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<Hit> {
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<Hit> {
let mut hits: Vec<Hit> = 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<Hit> = 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<Vec2> {
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<Vec2> {
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<Vec2> {
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
}
// --- Offset ------------------------------------------------------------------
/// Offset einer einzelnen Strecke um `d` (links positiv).
pub fn offset_segment(a: Vec2, b: Vec2, d: f64) -> (Vec2, Vec2) {
let n = left_normal(normalize(sub(b, a)));
let off = scale(n, d);
(add(a, off), add(b, off))
}
/// Offset einer Polylinie um `d` (links positiv) mit GEHRUNG (miter). Bei
/// (nahezu) parallelen Nachbarkanten faellt `line_intersect` (Schwelle 1e-9)
/// auf den verschobenen Endpunkt zurueck — dieser geometrische Sprung MUSS an
/// exakt 1e-9 haengen (nicht EPS). Selbstschnitte werden NICHT geheilt (wie TS).
pub fn offset_polyline(pts: &[Vec2], d: f64, closed: bool) -> Vec<Vec2> {
// Auf signifikante Kanten reduzieren (Duplikate verwerfen).
let mut clean: Vec<Vec2> = Vec::new();
for &p in pts {
if clean.is_empty() || dist(clean[clean.len() - 1], p) > EPS {
clean.push(p);
}
}
if closed && clean.len() > 1 && dist(clean[0], clean[clean.len() - 1]) <= EPS {
clean.pop();
}
let n = clean.len();
if n < 2 {
return pts.to_vec();
}
let edges: Vec<(Vec2, Vec2)> = polyline_edges(&clean, closed)
.into_iter()
.map(|(a, b)| offset_segment(a, b, d))
.collect();
if edges.is_empty() {
return pts.to_vec();
}
// Schnitt zweier (verschobener) Kanten als unendliche Geraden; None → fallback.
let join = |e1: (Vec2, Vec2), e2: (Vec2, Vec2), fallback: Vec2| -> Vec2 {
let d1 = sub(e1.1, e1.0);
let d2 = sub(e2.1, e2.0);
line_intersect(e1.0, d1, e2.0, d2).unwrap_or(fallback)
};
let m = edges.len();
let mut result: Vec<Vec2> = Vec::new();
if !closed {
result.push(edges[0].0);
for i in 0..m - 1 {
result.push(join(edges[i], edges[i + 1], edges[i].1));
}
result.push(edges[m - 1].1);
return result;
}
for i in 0..m {
let prev = edges[(i + m - 1) % m];
let curr = edges[i];
result.push(join(prev, curr, curr.0));
}
result
}
// --- Fillet (Eck-Verrundung) -------------------------------------------------
/// Ergebnis einer Eck-Verrundung (Port von `Fillet`). serde-camelCase, damit die
/// JSON-Keys (`tangentA`/`startAngle` …) exakt der TS-Referenz entsprechen.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Fillet {
pub center: Vec2,
pub radius: f64,
/// Tangentenpunkt auf dem ersten Schenkel (corner→p1).
pub tangent_a: Vec2,
/// Tangentenpunkt auf dem zweiten Schenkel (corner→p2).
pub tangent_b: Vec2,
pub start_angle: f64,
pub end_angle: f64,
}
/// Verrundet die Ecke bei `corner` (Schenkel corner→p1, corner→p2) mit Radius r.
/// None bei (nahezu) kollinearen/zu kurzen Schenkeln. Transzendente Kette
/// (`acos/tan/sin/atan2`) — libm nativ↔wasm↔JS driftet um letzte ULP, daher im
/// Diff-Test Winkel-Epsilon 1e-7 rad (Struktur/None-Entscheidung bleibt exakt).
pub fn fillet_corner(corner: Vec2, p1: Vec2, p2: Vec2, r: f64) -> Option<Fillet> {
let u1 = normalize(sub(p1, corner));
let u2 = normalize(sub(p2, corner));
// Klemm-Reihenfolge wie TS `Math.max(-1, Math.min(1, dot))`.
let cos_theta = dot(u1, u2).min(1.0).max(-1.0);
let theta = cos_theta.acos();
if theta < 1e-4 || std::f64::consts::PI - theta < 1e-4 {
return None; // kollinear
}
let tan_half = (theta / 2.0).tan();
if tan_half < EPS {
return None;
}
let setback = r / tan_half;
if setback > len(sub(p1, corner)) + EPS || setback > len(sub(p2, corner)) + EPS {
return None;
}
let tangent_a = add(corner, scale(u1, setback));
let tangent_b = add(corner, scale(u2, setback));
let bis = normalize(add(u1, u2));
let center_dist = r / (theta / 2.0).sin();
let center = add(corner, scale(bis, center_dist));
let start_angle = (tangent_a.y - center.y).atan2(tangent_a.x - center.x);
let end_angle = (tangent_b.y - center.y).atan2(tangent_b.x - center.x);
Some(Fillet {
center,
radius: r,
tangent_a,
tangent_b,
start_angle,
end_angle,
})
}
// --- 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<Vec2>,
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<Vec2> {
let mut out: Vec<Vec2> = 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<Vec2> {
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<f64> = 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<Polyline> {
let edges = polyline_edges(pts, closed);
if edges.is_empty() {
return vec![Polyline { pts: pts.to_vec(), closed }];
}
let mut cuts: Vec<EdgeHit> = 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<EdgeHit> = 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<f64> = 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<Polyline> = Vec::new();
if lo_idx >= 0 {
let c = cut[lo_idx as usize];
let mut head: Vec<Vec2> = 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<Vec2> = 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<Vec2> {
let mut out: Vec<Vec2> = 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<f64> = 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<f64> = 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<Vec<Vec2>> {
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<Vec2> = 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<Vec2> = 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<Vec<Vec2>> = [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<Vec2> = 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<Vec2>, Vec<Vec2>)> {
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<Vec2> = vec![p_a];
for k in ia + 1..=ib {
arc1.push(pts[k % n]);
}
arc1.push(p_b);
let mut arc2: Vec<Vec2> = 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<Vec2> = 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<Vec<Vec2>> {
if pts.is_empty() {
return Vec::new();
}
let mut pieces: Vec<Vec<Vec2>> = Vec::new();
let mut cur: Vec<Vec2> = 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<Vec<Vec2>> {
if cut_points.is_empty() {
return vec![pts.to_vec()];
}
let mut hits: Vec<EdgeHit> = 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<Vec2>],
) -> Vec<Vec<Vec2>> {
let edges = polyline_edges(target_pts, closed);
let mut hits: Vec<EdgeHit> = 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<EdgeHit> = 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<Vec2> = 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<j`-erster-Treffer-dann-Neustart — reihenfolgeabhaengig, exakt wie TS.
pub fn join_chains(polylines: &[Polyline]) -> Vec<Polyline> {
let mut closed_out: Vec<Polyline> = Vec::new();
let mut open: Vec<Vec<Vec2>> = 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<Vec<Vec2>> = 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<Vec2> = 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<Vec2> = 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<Polyline> = 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
}
// --- 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<Vec<Vec2>> {
let mut clean: Vec<Vec2> = 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
// echten Batch-Fassaden (offset_polylines_json, intersect_batch_json, …) kommen
// ab Phase 2, Muster: geometry::compute_joins_json (JSON rein/raus, O(n)-Grenze).
/// Ping-Export: beweist die WASM-Grenze. Nimmt ein JSON-`Vec2`, spiegelt es
/// normalisiert zurueck — genug, um Init + JSON-Marshalling im vitest-Harness
/// zu verifizieren, bevor die echten Operationen landen.
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn kernel2d_normalize_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let v: Vec2 = serde_json::from_str(input_json)
.map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))?;
let out = normalize(v);
serde_json::to_string(&out).map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))
}
// Grobkoernige Grenze: je Operation EINE Batch-Funktion (N Queries rein, N
// Ergebnisse raus), Muster geometry::compute_joins_json. Serde-Helfer buendeln
// das immergleiche JsValue-Fehlermapping. Die Query-Structs definieren zugleich
// das JSON-Format, das der vitest-Differential-Harness sendet.
#[cfg(feature = "web")]
fn to_js<T: serde::Serialize>(v: &T) -> Result<String, wasm_bindgen::JsValue> {
serde_json::to_string(v).map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))
}
#[cfg(feature = "web")]
fn from_js<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, wasm_bindgen::JsValue> {
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<Vec2>,
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")]
#[derive(Deserialize)]
struct OffsetSegQuery {
a: Vec2,
b: Vec2,
d: f64,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct OffsetPolyQuery {
pts: Vec<Vec2>,
d: f64,
closed: bool,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct FilletQuery {
corner: Vec2,
p1: Vec2,
p2: Vec2,
r: f64,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct SplitCuttersQuery {
a1: Vec2,
a2: Vec2,
cutters: Vec<Polyline>,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct TrimSegQuery {
a1: Vec2,
a2: Vec2,
cutters: Vec<Polyline>,
pick: Vec2,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct TrimPolyQuery {
pts: Vec<Vec2>,
closed: bool,
cutters: Vec<Polyline>,
pick: Vec2,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct ExtendQuery {
a1: Vec2,
a2: Vec2,
end: String,
cutters: Vec<Polyline>,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct SplitAtParamQuery {
pts: Vec<Vec2>,
closed: bool,
#[serde(rename = "edgeIndex")]
edge_index: usize,
t: f64,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct ChordQuery {
pts: Vec<Vec2>,
i: usize,
ti: f64,
j: usize,
tj: f64,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct RemoveSegQuery {
pts: Vec<Vec2>,
closed: bool,
#[serde(rename = "edgeIndex")]
edge_index: usize,
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct SplitIntersectQuery {
#[serde(rename = "targetPts")]
target_pts: Vec<Vec2>,
closed: bool,
others: Vec<Vec<Vec2>>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn project_param_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<ProjQuery> = from_js(input_json)?;
let out: Vec<f64> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<ProjQuery> = from_js(input_json)?;
let out: Vec<Vec2> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<ProjQuery> = from_js(input_json)?;
let out: Vec<f64> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<SegQuery> = from_js(input_json)?;
let out: Vec<Option<Hit>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<SegQuery> = from_js(input_json)?;
let out: Vec<Option<Hit>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<PolyHitsQuery> = from_js(input_json)?;
let out: Vec<Vec<Hit>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<LineCircleQuery> = from_js(input_json)?;
let out: Vec<Vec<Vec2>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<LineCircleQuery> = from_js(input_json)?;
let out: Vec<Vec<Vec2>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<CircleCircleQuery> = from_js(input_json)?;
let out: Vec<Vec<Vec2>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<f64> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<bool> = polys.iter().map(|p| is_ccw(p)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn offset_segment_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OffsetSegQuery> = from_js(input_json)?;
let out: Vec<(Vec2, Vec2)> = qs.iter().map(|q| offset_segment(q.a, q.b, q.d)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn offset_polyline_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OffsetPolyQuery> = from_js(input_json)?;
let out: Vec<Vec<Vec2>> = qs
.iter()
.map(|q| offset_polyline(&q.pts, q.d, q.closed))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn fillet_corner_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<FilletQuery> = from_js(input_json)?;
let out: Vec<Option<Fillet>> = qs
.iter()
.map(|q| fillet_corner(q.corner, q.p1, q.p2, q.r))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn split_segment_by_cutters_batch_json(
input_json: &str,
) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<SplitCuttersQuery> = from_js(input_json)?;
let out: Vec<Vec<(Vec2, Vec2)>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<TrimSegQuery> = from_js(input_json)?;
let out: Vec<Vec<(Vec2, Vec2)>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<TrimPolyQuery> = from_js(input_json)?;
let out: Vec<Vec<Polyline>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<ExtendQuery> = from_js(input_json)?;
let out: Vec<Option<(Vec2, Vec2)>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<SplitAtParamQuery> = from_js(input_json)?;
let out: Vec<Vec<Vec<Vec2>>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<ChordQuery> = from_js(input_json)?;
let out: Vec<Option<(Vec<Vec2>, Vec<Vec2>)>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<RemoveSegQuery> = from_js(input_json)?;
let out: Vec<Polyline> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<SplitIntersectQuery> = from_js(input_json)?;
let out: Vec<Vec<Vec<Vec2>>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let groups: Vec<Vec<Polyline>> = from_js(input_json)?;
let out: Vec<Vec<Polyline>> = groups.iter().map(|g| join_chains(g)).collect();
to_js(&out)
}
// --- Treppen-Geometrie (stair.ts, Slice 2) -----------------------------------
/// Formkonstanten fuer Treppen (identisch zur TS-Referenz).
pub const IDEAL_RISER: f64 = 0.17;
pub const IDEAL_TREAD: f64 = 0.29;
pub const MIN_STEPS: i64 = 2;
const DEG: f64 = std::f64::consts::PI / 180.0;
/// norm in stair.ts: `lenOf(a) || 1e-9` (NICHT EPS!).
#[inline]
fn stair_norm(a: Vec2) -> 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<Vec2>,
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<TreadRect>,
pub landing: Option<Vec<Vec2>>,
pub riser_height: f64,
pub tread_depth: f64,
pub run_line: Vec<Vec2>,
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<i64>,
pub above_indices: Vec<i64>,
pub break_line: Option<Vec<(Vec2, Vec2)>>,
}
/// 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<Vec2> {
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<Vec2> {
(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<f64>,
#[serde(default)]
turn: Option<i32>,
#[serde(default)]
center: Option<Vec2>,
#[serde(default)]
radius: Option<f64>,
#[serde(default)]
sweep: Option<f64>,
width: f64,
step_count: i64,
#[serde(default)]
up: Option<bool>,
}
/// 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<TreadRect> = 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<Vec2> = 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<TreadRect> = 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<Vec2> = 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<Vec2> = 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<TreadRect> = 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<Vec2> = 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<Vec2> = 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<i64> = Vec::new();
let mut above: Vec<i64> = 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<RbNode>, Vec<RbUEdge>) {
let mut nodes: Vec<RbNode> = Vec::new();
let add_node = |nodes: &mut Vec<RbNode>, 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<Vec<RbSplit>> = 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<RbUEdge> = Vec::new();
let push_edge = |edges: &mut Vec<RbUEdge>, 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<usize> = 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<Vec<usize>> {
let mut half_edges: Vec<RbHalfEdge> = Vec::new();
let mut outgoing: Vec<Vec<usize>> = (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<usize> = 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<usize>> = 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<usize> = 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<Vec2> = 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<Vec2> {
let n = poly.len();
if n < 3 || d <= 0.0 {
return poly.to_vec();
}
// CCW sicherstellen.
let pts_owned: Vec<Vec2>;
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<Vec2> = 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<Vec2> {
let tol = 1e-7_f64;
let mut out: Vec<Vec2> = 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<Vec<Vec2>> {
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<Vec2>> = Vec::new();
for f in faces {
let raw: Vec<Vec2> = 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<Vec<Vec2>> {
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<Vec<Vec2>> = None;
let mut best_area = f64::INFINITY;
for f in faces {
let raw: Vec<Vec2> = 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<Vec<Vec2>> {
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<Vec<Vec2>> = None;
let mut best_area = f64::INFINITY;
for f in faces {
let raw: Vec<Vec2> = 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
}
// --- Oeffnungs-Geometrie (Port von src/geometry/opening.ts, Slice 4) --------
// `Wall`/`Opening`/`Project`-Kopplung durch Vec2/number-Parameter ersetzt
// (PORT_PLAN §1 "Teilweise im Scope"): `thickness`/`refOffset` werden hier als
// bereits aufgeloeste Zahlen uebergeben (TS: `wallTypeThickness(getWallType(...))`
// bzw. `wallReferenceOffset(wall, total)` gegen ein `Project`). Nur
// `openingVerticalExtent` bleibt TS (haengt an `Project.drawingLevels` via
// `wallVerticalExtent`) und wird hier NICHT portiert.
//
// `DEG` (Grad→Radiant, Port des lokalen `DEG` in opening.ts) ist bereits als
// modul-weite Konstante aus dem stair-Slice vorhanden (identischer Wert).
/// Punkt entlang der Strecke p1→p2 im Abstand d vom Start (Port von `along`
/// aus src/model/geometry.ts).
pub fn along(p1: Vec2, p2: Vec2, d: f64) -> Vec2 {
add(p1, scale(normalize(sub(p2, p1)), d))
}
/// Achslaenge einer Wand (Port von `wallAxisLength`; Wand → start/end).
pub fn wall_axis_length(start: Vec2, end: Vec2) -> f64 {
(end.x - start.x).hypot(end.y - start.y)
}
/// Oeffnungs-Intervall entlang der Achse (Port von `openingInterval`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Interval {
pub from: f64,
pub to: f64,
}
pub fn opening_interval(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Interval> {
let axis = wall_axis_length(start, end);
let from = position.min(axis).max(0.0);
let to = (position + width).min(axis).max(from);
if to - from < 1e-4 {
return None; // entartet: Breite <= 0 oder vollstaendig ausserhalb der Achse
}
Some(Interval { from, to })
}
/// Einheits-Laufrichtung (u) und linke Normale (n) der Wandachse (Port von
/// `wallAxisFrame`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
pub struct AxisFrame {
pub u: Vec2,
pub n: Vec2,
}
pub fn wall_axis_frame(start: Vec2, end: Vec2) -> AxisFrame {
let u = normalize(sub(end, start));
AxisFrame { u, n: left_normal(u) }
}
/// Die beiden Pfosten-Punkte einer Oeffnung im Grundriss (Port von `openingJambs`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Jambs {
pub jamb_start: Vec2,
pub jamb_end: Vec2,
}
pub fn opening_jambs(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Jambs> {
let iv = opening_interval(start, end, position, width)?;
Some(Jambs {
jamb_start: along(start, end, iv.from),
jamb_end: along(start, end, iv.to),
})
}
/// Luecken-Rechteck-Ecken ueber die volle Wanddicke (Port von `openingGapQuad`).
/// Reihenfolge: jambStart(+n/2) → jambEnd(+n/2) → jambEnd(n/2) → jambStart(n/2).
pub fn opening_gap_quad(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
) -> Option<Vec<Vec2>> {
let jambs = opening_jambs(start, end, position, width)?;
let n = wall_axis_frame(start, end).n;
let outer = ref_offset + thickness / 2.0;
let inner = ref_offset - thickness / 2.0;
Some(vec![
add(jambs.jamb_start, scale(n, outer)),
add(jambs.jamb_end, scale(n, outer)),
add(jambs.jamb_end, scale(n, inner)),
add(jambs.jamb_start, scale(n, inner)),
])
}
/// Mittelpunkt einer Oeffnung im Grundriss (Port von `openingCenter`).
pub fn opening_center(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Vec2> {
let iv = opening_interval(start, end, position, width)?;
Some(along(start, end, (iv.from + iv.to) / 2.0))
}
/// Aufgeloeste Tuer-Symbol-Geometrie im Grundriss (Port von `DoorSymbol`).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DoorSymbol {
pub hinge: Vec2,
pub open_end: Vec2,
pub closed_end: Vec2,
pub radius: f64,
pub jamb_start: Vec2,
pub jamb_end: Vec2,
pub normal: Vec2,
}
/// Port von `doorSymbol`. `swing`/`opening_dir`/`hinge_side` folgen der TS-
/// `??`-Fallback-Semantik (`None` → TS-Default); `swing_angle` `None` → 90°.
pub fn door_symbol(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
swing: Option<&str>,
opening_dir: Option<&str>,
hinge_side: Option<&str>,
swing_angle: Option<f64>,
) -> Option<DoorSymbol> {
let jambs = opening_jambs(start, end, position, width)?;
let frame = wall_axis_frame(start, end);
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
// TS berechnet die Breite NEU aus den geklemmten Pfosten (nicht das rohe
// `o.width`) — bewusst repliziert.
let w = (jamb_end.x - jamb_start.x).hypot(jamb_end.y - jamb_start.y);
let swing_sign = if swing.unwrap_or("left") == "left" { 1.0 } else { -1.0 };
let dir_sign = if opening_dir.unwrap_or("in") == "in" { 1.0 } else { -1.0 };
let swing_dir = scale(frame.n, swing_sign * dir_sign);
let hinge_at_start = hinge_side.unwrap_or("start") == "start";
let hinge = if hinge_at_start { jamb_start } else { jamb_end };
let closed_dir = if hinge_at_start { frame.u } else { scale(frame.u, -1.0) };
let angle = swing_angle.unwrap_or(90.0) * DEG;
let open_dir = Vec2 {
x: closed_dir.x * angle.cos() + swing_dir.x * angle.sin(),
y: closed_dir.y * angle.cos() + swing_dir.y * angle.sin(),
};
Some(DoorSymbol {
hinge,
open_end: add(hinge, scale(open_dir, w)),
closed_end: add(hinge, scale(closed_dir, w)),
radius: w,
jamb_start,
jamb_end,
normal: frame.n,
})
}
/// Aufgeloeste Fenster-Linien im Grundriss (Port von `WindowSymbol`).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct WindowSymbol {
pub frame: Vec<Vec2>,
pub glass_lines: Vec<(Vec2, Vec2)>,
pub mullion_lines: Vec<(Vec2, Vec2)>,
}
/// Port von `windowSymbol`. `thickness`/`ref_offset` sind bereits aufgeloeste
/// Werte (siehe Modul-Kommentar); `wing_count` `None` → TS-Default `1`.
pub fn window_symbol(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
glass_count: f64,
wing_count: Option<f64>,
) -> Option<WindowSymbol> {
let jambs = opening_jambs(start, end, position, width)?;
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
let n = wall_axis_frame(start, end).n;
let outer = ref_offset + thickness / 2.0;
let inner = ref_offset - thickness / 2.0;
let frame_quad = vec![
add(jamb_start, scale(n, outer)),
add(jamb_end, scale(n, outer)),
add(jamb_end, scale(n, inner)),
add(jamb_start, scale(n, inner)),
];
let mut glass_lines: Vec<(Vec2, Vec2)> = Vec::new();
let count = glass_count.max(1.0);
let mut i: i64 = 0;
while (i as f64) < count {
let spread = thickness / 8.0;
let off = if count == 1.0 {
ref_offset
} else {
ref_offset + ((i as f64) - (count - 1.0) / 2.0) * spread * 2.0
};
glass_lines.push((add(jamb_start, scale(n, off)), add(jamb_end, scale(n, off))));
i += 1;
}
let mut mullion_lines: Vec<(Vec2, Vec2)> = Vec::new();
let wings = wing_count.unwrap_or(1.0).round().min(4.0).max(1.0);
if wings > 1.0 {
let post_depth = thickness * 0.6;
let p_outer = ref_offset + post_depth / 2.0;
let p_inner = ref_offset - post_depth / 2.0;
let wings_n = wings as i64;
for k in 1..wings_n {
let t = k as f64 / wings;
let on_axis = add(jamb_start, scale(sub(jamb_end, jamb_start), t));
mullion_lines.push((add(on_axis, scale(n, p_outer)), add(on_axis, scale(n, p_inner))));
}
}
Some(WindowSymbol { frame: frame_quad, glass_lines, mullion_lines })
}
// --- Batch-Fassaden: roomArea / ceiling (Slice 1) ----------------------------
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn polygon_area_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<f64> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<f64> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<Vec2> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<Option<Vec<Vec2>>> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<bool> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<f64> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<BBox> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let polys: Vec<Vec<Vec2>> = from_js(input_json)?;
let out: Vec<Vec2> = polys.iter().map(|p| outline_centroid(p)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
struct PointInOutlineQuery {
p: Vec2,
outline: Vec<Vec2>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn point_in_outline_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<PointInOutlineQuery> = from_js(input_json)?;
let out: Vec<bool> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<DefaultStepCountQuery> = from_js(input_json)?;
let out: Vec<i64> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<StairGeoQuery> = from_js(input_json)?;
let out: Vec<StairGeometry> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<StairCutQuery> = from_js(input_json)?;
let out: Vec<StairCut> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let geos: Vec<StairGeometry> = from_js(input_json)?;
let out: Vec<StairBBox> = 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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<PointHitsStairQuery> = from_js(input_json)?;
let out: Vec<bool> = 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<Vec2>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn point_in_polygon_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<PointInPolygonQuery> = from_js(input_json)?;
let out: Vec<bool> = 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<WallSegment>,
#[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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<DetectRoomsQuery> = from_js(input_json)?;
let out: Vec<Vec<Vec<Vec2>>> = 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<WallSegment>,
#[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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<RoomFromPointQuery> = from_js(input_json)?;
let out: Vec<Option<Vec<Vec2>>> = 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<WallFace>,
#[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<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<RoomFromFacesQuery> = from_js(input_json)?;
let out: Vec<Option<Vec<Vec2>>> = 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<Vec2>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn rb_point_in_polygon_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<RbPointInPolygonQuery> = from_js(input_json)?;
let out: Vec<bool> = qs.iter().map(|q| rb_point_in_polygon(q.p, &q.poly)).collect();
to_js(&out)
}
// --- Batch-Fassaden: opening (Slice 4) ---------------------------------------
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WallAxisLengthQuery {
start: Vec2,
end: Vec2,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn wall_axis_length_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
let out: Vec<f64> = qs.iter().map(|q| wall_axis_length(q.start, q.end)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpeningQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_interval_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Interval>> = qs
.iter()
.map(|q| opening_interval(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn wall_axis_frame_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
let out: Vec<AxisFrame> = qs.iter().map(|q| wall_axis_frame(q.start, q.end)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_jambs_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Jambs>> = qs
.iter()
.map(|q| opening_jambs(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpeningGapQuadQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_gap_quad_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningGapQuadQuery> = from_js(input_json)?;
let out: Vec<Option<Vec<Vec2>>> = qs
.iter()
.map(|q| opening_gap_quad(q.start, q.end, q.position, q.width, q.thickness, q.ref_offset))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_center_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Vec2>> = qs
.iter()
.map(|q| opening_center(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DoorSymbolQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
#[serde(default)]
swing: Option<String>,
#[serde(default)]
opening_dir: Option<String>,
#[serde(default)]
hinge: Option<String>,
#[serde(default)]
swing_angle: Option<f64>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn door_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<DoorSymbolQuery> = from_js(input_json)?;
let out: Vec<Option<DoorSymbol>> = qs
.iter()
.map(|q| {
door_symbol(
q.start,
q.end,
q.position,
q.width,
q.swing.as_deref(),
q.opening_dir.as_deref(),
q.hinge.as_deref(),
q.swing_angle,
)
})
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WindowSymbolQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
glass_count: f64,
#[serde(default)]
wing_count: Option<f64>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn window_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WindowSymbolQuery> = from_js(input_json)?;
let out: Vec<Option<WindowSymbol>> = qs
.iter()
.map(|q| {
window_symbol(
q.start,
q.end,
q.position,
q.width,
q.thickness,
q.ref_offset,
q.glass_count,
q.wing_count,
)
})
.collect();
to_js(&out)
}
#[cfg(test)]
mod tests {
use super::*;
const T: f64 = 1e-12;
#[test]
fn len_uses_hypot() {
assert!((len(Vec2::new(3.0, 4.0)) - 5.0).abs() < T);
}
#[test]
fn normalize_zero_guard_yields_origin_not_nan() {
let n = normalize(Vec2::new(0.0, 0.0));
assert_eq!(n, Vec2::new(0.0, 0.0));
}
#[test]
fn cross_dot_term_order() {
let p = Vec2::new(1.0, 2.0);
let q = Vec2::new(3.0, 4.0);
assert!((cross(p, q) - (1.0 * 4.0 - 2.0 * 3.0)).abs() < T);
assert!((dot(p, q) - (1.0 * 3.0 + 2.0 * 4.0)).abs() < T);
}
#[test]
fn line_intersect_parallel_is_none() {
let a = Vec2::new(0.0, 0.0);
let da = Vec2::new(1.0, 0.0);
let b = Vec2::new(0.0, 1.0);
let db = Vec2::new(1.0, 0.0);
assert!(line_intersect(a, da, b, db).is_none());
}
#[test]
fn line_intersect_crossing() {
let hit = line_intersect(
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(2.0, -1.0),
Vec2::new(0.0, 1.0),
)
.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());
}
#[test]
fn offset_segment_left_positive() {
// Strecke (0,0)->(1,0), Offset +0.5 → linke Normale (0,1) → y=0.5.
let (a, b) = offset_segment(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), 0.5);
assert!((a.x).abs() < T && (a.y - 0.5).abs() < T);
assert!((b.x - 1.0).abs() < T && (b.y - 0.5).abs() < T);
}
#[test]
fn offset_polyline_right_angle_miter() {
// L-Ecke (0,0)->(1,0)->(1,1), offen, Offset +0.5 (nach innen/links).
// Innerer Gehrungspunkt = Schnitt der beiden verschobenen Kanten bei (0.5,0.5).
let l = [
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(1.0, 1.0),
];
let out = offset_polyline(&l, 0.5, false);
assert_eq!(out.len(), 3);
assert!((out[1].x - 0.5).abs() < 1e-9 && (out[1].y - 0.5).abs() < 1e-9);
}
#[test]
fn fillet_right_angle() {
// Rechter Winkel bei (0,0), Schenkel entlang +x und +y, r=1.
// setback = r/tan(45°) = 1; center auf Winkelhalbierender bei (1,1).
let f = fillet_corner(
Vec2::new(0.0, 0.0),
Vec2::new(5.0, 0.0),
Vec2::new(0.0, 5.0),
1.0,
)
.unwrap();
assert!((f.tangent_a.x - 1.0).abs() < 1e-9 && f.tangent_a.y.abs() < 1e-9);
assert!(f.tangent_b.x.abs() < 1e-9 && (f.tangent_b.y - 1.0).abs() < 1e-9);
assert!((f.center.x - 1.0).abs() < 1e-9 && (f.center.y - 1.0).abs() < 1e-9);
assert!((f.radius - 1.0).abs() < T);
}
#[test]
fn fillet_collinear_is_none() {
// Gestreckt (180°) → kollinear → None.
assert!(fillet_corner(
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(-1.0, 0.0),
0.5,
)
.is_none());
// Zu grosser Radius für die Schenkellänge → None.
assert!(fillet_corner(
Vec2::new(0.0, 0.0),
Vec2::new(0.1, 0.0),
Vec2::new(0.0, 0.1),
10.0,
)
.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<Vec2> = (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);
}
}