Files
OneCAD/crates/kernel/src/curveblend_law_tests.rs
T

316 lines
16 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.
//! Chord-width and radius-law blends along curved edges: the width across the blend at every
//! station, and the radius along the edge, measured on the body the kernel built against values
//! worked out from the input geometry alone (the two cylinders' axes, the arc's axis).
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
use solidcraft_geom::Vec3;
use super::*;
use crate::blend::vtx;
use crate::blend_variable::{RadiusSpline, fillet_chord, fillet_variable, fillet_variable_points, fillet_variable_spline};
use crate::{Body, BoolOp, boolean, box_solid, cylinder, fillet, measure, step_export};
/// An oblique boss: a rod of radius 8 whose axis leans 26.57° from the plate's normal, standing on
/// a 5 mm plate. The edge where its side meets the plate's top plane is an ellipse whose two faces
/// run from 63° to 117° apart around it.
fn oblique_boss() -> (Body, Vec3, Vec3) {
let plate = box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 5.0)).unwrap();
let base = Vec3::new(0.0, 0.0, -10.0);
let axis = Vec3::new(0.5, 0.0, 1.0).normalized().unwrap();
let rod = cylinder(base, axis, 8.0, 30.0).unwrap();
(boolean(&plate, &rod, BoolOp::Union).unwrap().unwrap(), base, axis)
}
/// A 40 × 40 × 20 box whose front wall is an arc of radius 40 about a Z axis: `concave` cuts a
/// cylinder away from the front (the wall curves into the part), otherwise the part is the box
/// within the cylinder (the wall curves out). With the bottom arc's midpoint, the axis's base
/// point and the side of the wall the material is on (+1 away from the axis, −1 toward it).
fn arc_walled_box(concave: bool) -> (Body, Vec3, Vec3, f64) {
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
let (axis, op, side) =
if concave { (Vec3::new(20.0, -30.0, 0.0), BoolOp::Cut, 1.0) } else { (Vec3::new(20.0, 70.0, 0.0), BoolOp::Intersect, -1.0) };
let tool = cylinder(axis, Vec3::Z, 40.0, 20.0).unwrap();
let body = boolean(&block, &tool, op).unwrap().unwrap();
(body, axis + Vec3::new(0.0, side * 40.0, 0.0), axis, side)
}
/// The blend face a blend built over `input`: the fitted surface that is on neither of the faces
/// it runs between (a boolean leaves the curved faces it kept as fitted surfaces themselves).
fn blend_face(input: &Body, body: &Body) -> Option<mt::Face> {
use mt::ParametricSurface;
let on_input = |p: Vec3| input.solid.face_iter().any(|f| project(f, p, None).is_some_and(|(q, _, _)| q.dist(p) <= 1e-6));
body.solid
.face_iter()
.filter(|f| matches!(f.oriented_surface(), mt::Surface::NurbsSurface(_)))
.find(|f| !on_input(from_p3(f.surface().subs(0.5, 0.5))))
.cloned()
}
/// The edges of the blend face's boundary, each once, with the point at the middle of it (the
/// blend is bounded by its two contacts and, at each end, an arc or the curve of an end face).
fn blend_boundary(input: &Body, body: &Body) -> Vec<(mt::Edge, Vec3)> {
let Some(f) = blend_face(input, body) else { return Vec::new() };
let mut out: Vec<(mt::Edge, Vec3)> = Vec::new();
for w in f.absolute_boundaries() {
for e in w.edge_iter() {
if out.iter().any(|(x, _)| x.id() == e.id()) {
continue;
}
let c = e.oriented_curve();
let (lo, hi) = c.range_tuple();
out.push((e.absolute_clone(), from_p3(c.subs((lo + hi) * 0.5))));
}
}
out
}
/// The edges of a contact: the boundary of the blend face whose middle point `side` picks out.
fn contact(input: &Body, body: &Body, side: &dyn Fn(Vec3) -> bool) -> Vec<mt::Edge> {
blend_boundary(input, body).into_iter().filter(|(_, m)| side(*m)).map(|(e, _)| e).collect()
}
/// The fraction of the way along `edge` (its absolute front to its back) of the point of it
/// nearest `q`.
fn along_fraction(edge: &mt::Edge, q: Vec3) -> Option<f64> {
let e = edge.absolute_clone();
let c = e.oriented_curve();
let (lo, hi) = c.range_tuple();
let steps = 512;
let mut at = vec![from_p3(c.subs(lo))];
let mut run = vec![0.0];
for i in 1..=steps {
let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / steps as f64));
run.push(run.last().copied()? + at.last().copied()?.dist(p));
at.push(p);
}
let total = *run.last()?;
let d = |i: usize| at.get(i).map(|p| p.dist(q)).unwrap_or(f64::INFINITY);
let hint = (0..=steps).min_by(|x, y| d(*x).total_cmp(&d(*y))).unwrap_or(0);
let t = c.search_nearest_parameter(p3(q), lo + (hi - lo) * hint as f64 / steps as f64, 100)?;
let i = (((t - lo) / (hi - lo)) * steps as f64).clamp(0.0, steps as f64);
let (k, f) = (i.floor() as usize, i.fract());
let (Some(a), Some(b)) = (run.get(k), run.get((k + 1).min(steps))) else { return None };
Some((a + (b - a) * f) / total)
}
/// The azimuth of `p` about the Z axis through `axis`.
fn azimuth(axis: Vec3, p: Vec3) -> f64 {
(p.y - axis.y).atan2(p.x - axis.x)
}
/// The point of the curves running round the Z axis through `axis` at the azimuth `phi`.
fn at_azimuth(edges: &[mt::Edge], axis: Vec3, phi: f64) -> Option<Vec3> {
let off = |p: Vec3| {
let mut d = azimuth(axis, p) - phi;
while d > std::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d < -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
d
};
let (mut best, mut best_d) = (Vec3::ZERO, f64::INFINITY);
for e in edges {
let c = e.oriented_curve();
let (lo, hi) = c.range_tuple();
let steps = 512;
let mut at = lo;
let mut dist = f64::INFINITY;
for i in 0..=steps {
let t = lo + (hi - lo) * i as f64 / steps as f64;
let d = off(from_p3(c.subs(t))).abs();
if d < dist {
(at, dist) = (t, d);
}
}
let step = (hi - lo) / steps as f64;
let (mut x0, mut x1) = ((at - step).max(lo), (at + step).min(hi));
let g = 0.618_033_988_749_894_9;
for _ in 0..80 {
let (a, b) = (x1 - g * (x1 - x0), x0 + g * (x1 - x0));
if off(from_p3(c.subs(a))).abs() < off(from_p3(c.subs(b))).abs() {
x1 = b;
} else {
x0 = a;
}
}
let t = 0.5 * (x0 + x1);
let d = off(from_p3(c.subs(t))).abs();
if d < best_d {
(best, best_d) = (from_p3(c.subs(t)), d);
}
}
(best_d < 1e-6).then_some(best)
}
/// A chord fillet along the cylinder-into-plane edge of an oblique boss — the two faces run from
/// 63° to 117° apart around it. The blend's chain stops at the seams between the patches a boolean
/// left of the rod's surface, where the end face is tangent to the rod; the cap that cuts the strip
/// back there is only placed for one radius, so a width that varies along the edge is refused
/// rather than approximated (the fixed radius of `fillet` along the same edge rolls).
#[test]
fn a_chord_fillet_along_an_oblique_boss_edge_is_refused() {
let (body, _, _) = oblique_boss();
let pick = Vec3::new(7.5 + 8.944_271_909_999_16, 0.0, 5.0);
// A fixed radius rolls along the same chain: the edge itself is supported.
assert!(fillet(&body, &[pick], 2.5).is_ok());
let e = fillet_chord(&body, &[pick], 4.0).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
}
/// A chord fillet along an arc whose two faces meet at a right angle: the width across the blend
/// is the chord, so the round is the one of radius `chord / √2` (checked against the constant
/// fillet of that radius) and its two contacts are that far apart at every station.
#[test]
fn chord_fillet_along_an_arc_edge() {
let (chord, r) = (3.0, 3.0 / 2f64.sqrt());
let (body, mid, axis, _) = arc_walled_box(false);
let blended = fillet_chord(&body, &[mid], chord).unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
assert_eq!(blended.unmeshed_faces(), 0);
let plain = fillet(&body, &[mid], r).unwrap();
let (made, exact) = (measure(&blended).unwrap().volume, measure(&plain).unwrap().volume);
assert!((made - exact).abs() < exact * 1e-6, "volume {made} vs {exact}");
// The contact on the plane and the one on the wall at the same azimuth: the width across.
let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6);
let wall = contact(&body, &blended, &|m| m.z.abs() >= 1e-6);
assert!(!plane.is_empty() && !wall.is_empty(), "the contacts are not the plane's and the wall's");
let mid_azimuth = azimuth(axis, mid);
let mut worst: f64 = 0.0f64;
let mut stations = 0;
for i in 1..12 {
let phi = mid_azimuth + (i as f64 / 12.0 - 0.5) * 0.9;
let (Some(p), Some(w)) = (at_azimuth(&plane, axis, phi), at_azimuth(&wall, axis, phi)) else { continue };
stations += 1;
worst = worst.max((p.dist(w) - chord).abs());
}
eprintln!("arc chord {chord}: worst width error {worst:.6} mm at {stations} stations");
assert!(stations >= 9, "only {stations} stations measured");
assert!(worst < 0.01, "the width across the blend is {worst} mm off the chord {chord}");
}
/// A chord fillet on a straight edge between planar faces is still the exact local round: the
/// same body, bit for bit, as the fillet of the radius the chord gives (a right angle: chord c is
/// r = c / √2).
#[test]
fn chord_fillet_on_a_straight_edge_is_unchanged() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let (at, chord) = (Vec3::new(10.0, 10.0, 30.0), 4.0);
let f = fillet_chord(&b, &[at], chord).unwrap();
let k = fillet(&b, &[at], chord / 2f64.sqrt()).unwrap();
assert_eq!(step_export(&[&f], "part").unwrap(), step_export(&[&k], "part").unwrap());
assert_eq!(f.unmeshed_faces(), 0);
}
/// A variable radius along an arc edge: a ball whose radius is the law's at each station's fraction
/// of the edge's length rolls along it, and the setback on the planar face — the plane and the
/// wall meet at a right angle, so it is the section's radius — follows the law from end to end.
#[test]
fn variable_radius_along_an_arc_edge_follows_the_law() {
let (body, mid, axis, _) = arc_walled_box(false);
let edge = crate::surfaces::edge_near(&body, mid).unwrap();
// Started from the edge's back end: the law runs the other way, as the positions are from the
// end nearest `start`.
let start = vtx(edge.absolute_back());
// A wider law (2 → 4, the radius doubling over the arc) is out of reach of the fitted strip:
// the sections' own last spans cannot be held to the fit's accuracy there. That is refused,
// not approximated.
for wide in [4.0, 5.0] {
let e = fillet_variable(&body, mid, start, 2.0, wide).unwrap_err();
assert!(e.to_string().contains("accuracy and section limits"), "2 -> {wide}: {e}");
}
let (r0, r1) = (2.0, 3.4);
let blended = fillet_variable(&body, mid, start, r0, r1).unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
assert_eq!(blended.unmeshed_faces(), 0);
let law = RadiusSpline::through(&[(0.0, r0), (1.0, r1)], false);
// The law runs from the end nearest `start`: the chain's front here is the far end.
let want = if vtx(edge.absolute_back()).dist(start) < vtx(edge.absolute_front()).dist(start) { law.reversed() } else { law.clone() };
// `start` is the chain's back end, so the law is read back to front there: r0 at the back, r1
// at the front.
assert!((want.eval(1.0) - r0).abs() < 1e-12 && (want.eval(0.0) - r1).abs() < 1e-12);
// The blend's contact on the bottom plane is at 40 − r from the axis.
let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6);
assert!(!plane.is_empty(), "no contact on the bottom plane");
let mut worst: f64 = 0.0;
let mut stations = 0;
let mut shown = Vec::new();
for e in &plane {
let c = e.oriented_curve();
let (lo, hi) = c.range_tuple();
for i in 0..=32 {
let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / 32.0));
let measured = 40.0 - (p - axis).len();
let along = along_fraction(&edge, p).unwrap();
// Leave the two ends, where the caps cut the contact: the blend closes on the end
// faces there.
if !(0.04..=0.96).contains(&along) {
continue;
}
stations += 1;
if shown.len() < 3 || (0.45..0.55).contains(&along) {
shown.push((along, measured, want.eval(along)));
}
worst = worst.max((measured - want.eval(along)).abs());
}
}
eprintln!("arc variable {r0} -> {r1}: worst radius error {worst:.6} mm at {stations} stations; law vs measured {shown:?}");
assert!(stations >= 24, "only {stations} stations measured");
assert!(worst < 0.02, "the section radius is {worst} mm off the law");
// Wider, still inside what the strip holds: the same body as the exact straight-edge case of
// that law is not built here, but the radius is the law's at every station measured.
}
/// A radius law that runs to zero at an end of a curved edge: refused by name. The blend would
/// close to the edge's end point, but the fitted strip pinches to a point there and the last span
/// of it cannot be held to the fit's accuracy: the straight-edge fillet, which closes exactly, is
/// the way (see `variable_radius_down_to_zero_at_one_end`).
#[test]
fn a_law_running_to_zero_along_a_curved_edge_is_refused() {
let (body, mid, _, _) = arc_walled_box(false);
let edge = crate::surfaces::edge_near(&body, mid).unwrap();
for start in [vtx(edge.absolute_front()), vtx(edge.absolute_back())] {
let e = fillet_variable(&body, mid, start, 3.0, 0.0).unwrap_err();
assert!(e.to_string().contains("runs to zero along a curved edge"), "{e}");
let law = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii: vec![0.0, 3.0] };
assert!(fillet_variable_spline(&body, mid, mid, &law).is_err());
// A law that stays clear of zero along it rolls: the blend is closed, a three-sided face
// where nothing closes it.
let made = fillet_variable(&body, mid, start, 3.0, 2.4).unwrap();
assert!(made.validity().is_empty(), "{:?}", made.validity());
let mesh = made.tessellate_with(5e-4, true).unwrap();
let about = |o: Vec3| {
mesh.triangles
.iter()
.map(|t| (mesh.positions[t[0] as usize] - o).dot((mesh.positions[t[1] as usize] - o).cross(mesh.positions[t[2] as usize] - o)) / 6.0)
.sum::<f64>()
};
assert!((about(Vec3::ZERO) - about(Vec3::new(-100.0, 50.0, 30.0))).abs() < 1e-2, "the mesh is not closed");
}
}
/// What a chord or a law the local geometry cannot hold is refused with: an error naming the size,
/// never a silent approximation and never a panic.
#[test]
fn a_chord_or_radius_the_faces_cannot_hold_is_refused() {
let (body, mid, _, _) = arc_walled_box(false);
// A chord of 30 across a 40 mm wide wall (r ≈ 21, past the side walls and the block).
let e = fillet_chord(&body, &[mid], 30.0).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
assert!(fillet_chord(&body, &[mid], 3.0 / 2f64.sqrt()).is_ok());
// A radius law too large for the wall.
assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 2.0, 30.0).is_err());
assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 20.0, 20.0).is_err());
// Non-finite, negative and zero-at-both-ends laws: refused before the geometry is touched.
let bad = |radii: Vec<f64>| RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii };
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::NAN])).is_err());
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::INFINITY])).is_err());
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, -1.0])).is_err());
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![0.0, 0.0])).is_err());
assert!(fillet_variable_spline(&body, mid, mid, &RadiusSpline { degree: 1, knots: vec![0.0, 0.5, 1.0], radii: vec![2.0, 3.0] }).is_err());
assert!(fillet_variable_points(&body, mid, mid, &[(0.0, 2.0), (1.5, 3.0)], false).is_err());
// An edge the body does not have is refused by name, not approximated.
assert!(fillet_chord(&body, &[Vec3::new(500.0, 500.0, 500.0)], 3.0).is_err());
}