From 903dc19cec9e6fdd9b42ece977d48dd95c921ea9 Mon Sep 17 00:00:00 2001 From: Karim Date: Sun, 5 Jul 2026 00:50:38 +0200 Subject: [PATCH] kernel2d-Port Phase 3: Offset (Miter+1e-9-Fallback) + Fillet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - offsetSegment / offsetPolyline: Gehrung via line_intersect, Fallback auf verschobenen Endpunkt an EXAKT 1e-9 (nicht EPS) — Selbstschnitte ungeheilt wie TS. Dedup der Eingabe innerhalb EPS. - filletCorner + Fillet-Struct (serde camelCase): acos/tan/sin/atan2-Kette, Klemmung cos∈[-1,1], None bei kollinear (<1e-4 / π-θ<1e-4) oder zu kurzem Schenkel. Winkel im Diff-Test abs 1e-7 rad (libm-ULP-Drift), Struktur exakt. - 3 Batch-Fassaden (offset_segment/offset_polyline/fillet_corner) + 4 Unit-Tests. - Harness: Offset (rel 1e-9) + Fillet (halb kontrolliert/halb Zufall, Winkel 1e-7) + Golden (L-Ecke, rechter Winkel, kollinear/zu-gross → null). cargo test 14/14, vitest 242 (3 neu, davon 12 Parity) gruen, tsc sauber. --- src-tauri/kernel2d/src/lib.rs | 231 +++++++++++++++++++++++++++ src/geometry/kernel2d.parity.test.ts | 95 +++++++++++ 2 files changed, 326 insertions(+) diff --git a/src-tauri/kernel2d/src/lib.rs b/src-tauri/kernel2d/src/lib.rs index f56d55e..76e4f27 100644 --- a/src-tauri/kernel2d/src/lib.rs +++ b/src-tauri/kernel2d/src/lib.rs @@ -300,6 +300,120 @@ 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 { + // Auf signifikante Kanten reduzieren (Duplikate verwerfen). + let mut clean: Vec = 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 = 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 { + 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, + }) +} + // --- 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 @@ -378,6 +492,31 @@ struct CircleCircleQuery { r2: f64, } +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct OffsetSegQuery { + a: Vec2, + b: Vec2, + d: f64, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct OffsetPolyQuery { + pts: Vec, + d: f64, + closed: bool, +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +struct FilletQuery { + corner: Vec2, + p1: Vec2, + p2: Vec2, + r: f64, +} + #[cfg(feature = "web")] #[wasm_bindgen::prelude::wasm_bindgen] pub fn project_param_batch_json(input_json: &str) -> Result { @@ -501,6 +640,39 @@ pub fn is_ccw_batch_json(input_json: &str) -> Result Result { + console_error_panic_hook::set_once(); + let qs: Vec = 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 { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = 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 { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| fillet_corner(q.corner, q.p1, q.p2, q.r)) + .collect(); + to_js(&out) +} + #[cfg(test)] mod tests { use super::*; @@ -624,4 +796,63 @@ mod tests { 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()); + } } diff --git a/src/geometry/kernel2d.parity.test.ts b/src/geometry/kernel2d.parity.test.ts index 70413a5..9cc943e 100644 --- a/src/geometry/kernel2d.parity.test.ts +++ b/src/geometry/kernel2d.parity.test.ts @@ -18,15 +18,19 @@ import type { Vec2 } from "../model/types"; import { circleCircleIntersect, closestPointOnSegment, + filletCorner, isCCW, lineCircleIntersect, lineSegmentIntersect, + offsetPolyline, + offsetSegment, pointSegmentDistance, projectParam, segmentCircleIntersect, segmentIntersect, segmentPolylineHits, signedArea, + type Fillet, type Hit, } from "./kernel2d"; @@ -215,6 +219,74 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Zufall", () => { expect(wCcw[i], `ccw#${i}`).toBe(isCCW(p)); }); }); + + it("offsetSegment / offsetPolyline (Miter, Struktur + Werte rel 1e-9)", () => { + const rng = mulberry32(7); + const segs = Array.from({ length: N }, () => ({ + a: v(rng), + b: v(rng), + d: (rng() * 2 - 1) * 5, + })); + const wSeg = JSON.parse(K.offset_segment_batch_json(JSON.stringify(segs))) as [Vec2, Vec2][]; + segs.forEach((q, i) => { + const [a, b] = offsetSegment(q.a, q.b, q.d); + expect(closeVec(wSeg[i][0], a) && closeVec(wSeg[i][1], b), `offSeg#${i}`).toBe(true); + }); + + const polys = Array.from({ length: N }, () => { + const n = 3 + Math.floor(rng() * 10); + const pts = Array.from({ length: n }, () => v(rng)); + return { pts, d: (rng() * 2 - 1) * 5, closed: rng() < 0.5 }; + }); + const wPoly = JSON.parse(K.offset_polyline_batch_json(JSON.stringify(polys))) as Vec2[][]; + polys.forEach((q, i) => { + const t = offsetPolyline(q.pts, q.d, q.closed); + expect(wPoly[i].length, `offPoly-len#${i}`).toBe(t.length); + t.forEach((p, j) => expect(closeVec(wPoly[i][j], p), `offPoly#${i}.${j}`).toBe(true)); + }); + }); + + it("filletCorner (Struktur exakt + Werte, Winkel abs 1e-7)", () => { + const rng = mulberry32(8); + // Halb kontrolliert (garantiert gueltige Verrundung), halb Zufall (None-Paritaet). + const qs = Array.from({ length: N }, (_, i) => { + if (i % 2 === 0) { + const corner = v(rng); + const a0 = rng() * Math.PI * 2; + const half = 0.3 + rng() * 0.9; // theta = 2*half ∈ [0.6, 2.4] rad (weg von 0/π) + const legLen = 3 + rng() * 7; + const rMax = legLen * Math.tan(half); + const r = 0.1 + rng() * 0.8 * rMax; + const dir = (ang: number) => ({ x: Math.cos(ang), y: Math.sin(ang) }); + const d1 = dir(a0 + half); + const d2 = dir(a0 - half); + return { + corner, + p1: { x: corner.x + d1.x * legLen, y: corner.y + d1.y * legLen }, + p2: { x: corner.x + d2.x * legLen, y: corner.y + d2.y * legLen }, + r, + }; + } + return { corner: v(rng), p1: v(rng), p2: v(rng), r: 0.1 + rng() * 5 }; + }); + const w = JSON.parse(K.fillet_corner_batch_json(JSON.stringify(qs))) as (Fillet | null)[]; + let valid = 0; + qs.forEach((q, i) => { + const t = filletCorner(q.corner, q.p1, q.p2, q.r); + expect(w[i] === null, `fil-null#${i}`).toBe(t === null); + if (t && w[i]) { + const f = w[i]!; + expect(closeVec(f.center, t.center), `fil-center#${i}`).toBe(true); + expect(closeVec(f.tangentA, t.tangentA), `fil-tA#${i}`).toBe(true); + expect(closeVec(f.tangentB, t.tangentB), `fil-tB#${i}`).toBe(true); + expect(closeNum(f.radius, t.radius), `fil-r#${i}`).toBe(true); + expect(Math.abs(f.startAngle - t.startAngle) <= 1e-7, `fil-sa#${i}`).toBe(true); + expect(Math.abs(f.endAngle - t.endAngle) <= 1e-7, `fil-ea#${i}`).toBe(true); + valid++; + } + }); + expect(valid, "keine gueltige Verrundung — Test waere aussagelos").toBeGreaterThan(50); + }); }); describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => { @@ -259,4 +331,27 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfae expect(Math.abs(wArea[0])).toBeLessThan(1e-12); expect(wCcw[0]).toBe(isCCW(polys[0])); }); + + it("Offset L-Ecke (Gehrung) und Fillet rechter Winkel / kollinear→null / zu gross→null", () => { + const off = [{ pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }], d: 0.5, closed: false }]; + const wOff = JSON.parse(K.offset_polyline_batch_json(JSON.stringify(off))) as Vec2[][]; + const tOff = offsetPolyline(off[0].pts, off[0].d, off[0].closed); + expect(wOff[0].length).toBe(tOff.length); + tOff.forEach((p, j) => expect(closeVec(wOff[0][j], p)).toBe(true)); + + const fil = [ + { corner: { x: 0, y: 0 }, p1: { x: 5, y: 0 }, p2: { x: 0, y: 5 }, r: 1 }, // rechter Winkel + { corner: { x: 0, y: 0 }, p1: { x: 1, y: 0 }, p2: { x: -1, y: 0 }, r: 0.5 }, // kollinear → null + { corner: { x: 0, y: 0 }, p1: { x: 0.1, y: 0 }, p2: { x: 0, y: 0.1 }, r: 10 }, // zu gross → null + ]; + const wFil = JSON.parse(K.fillet_corner_batch_json(JSON.stringify(fil))) as (Fillet | null)[]; + fil.forEach((q, i) => { + const t = filletCorner(q.corner, q.p1, q.p2, q.r); + expect(wFil[i] === null, `golden-fil#${i}`).toBe(t === null); + if (t && wFil[i]) { + expect(closeVec(wFil[i]!.center, t.center)).toBe(true); + expect(closeVec(wFil[i]!.tangentA, t.tangentA)).toBe(true); + } + }); + }); });