kernel2d-Port Phase 3: Offset (Miter+1e-9-Fallback) + Fillet

- 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.
This commit is contained in:
2026-07-05 00:50:38 +02:00
parent 09c5178c85
commit 903dc19cec
2 changed files with 326 additions and 0 deletions
+231
View File
@@ -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<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,
})
}
// --- 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<Vec2>,
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<String, wasm_bindgen::JsValue> {
@@ -501,6 +640,39 @@ pub fn is_ccw_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsVal
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(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());
}
}
+95
View File
@@ -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);
}
});
});
});