732 lines
39 KiB
Rust
732 lines
39 KiB
Rust
//! Independent analytic reference: the centre of a ball tangent to two perpendicular
|
||
//! cylinders lies on the intersection of their radius-offset cylinders.
|
||
|
||
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
|
||
|
||
use super::*;
|
||
use crate::blend::plane_normal;
|
||
use crate::issue_851_tests::planned_volume;
|
||
use crate::{Body, BoolOp, boolean, cylinder, fillet};
|
||
|
||
#[test]
|
||
fn issue_1303_chamfer_open_rim_after_end_chamfer() {
|
||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
|
||
let rim = Region2 {
|
||
outer: Loop2 {
|
||
segs: vec![
|
||
Seg2::Line { a: Vec2::new(31.0, -15.0), b: Vec2::new(31.0, -11.75) },
|
||
Seg2::Line { a: Vec2::new(31.0, -11.75), b: Vec2::new(0.0, -11.75) },
|
||
Seg2::Arc { center: Vec2::ZERO, radius: 11.75, start: -std::f64::consts::FRAC_PI_2, sweep: -std::f64::consts::PI },
|
||
Seg2::Line { a: Vec2::new(0.0, 11.75), b: Vec2::new(31.0, 11.75) },
|
||
Seg2::Line { a: Vec2::new(31.0, 11.75), b: Vec2::new(31.0, 15.0) },
|
||
Seg2::Line { a: Vec2::new(31.0, 15.0), b: Vec2::new(0.0, 15.0) },
|
||
Seg2::Arc { center: Vec2::ZERO, radius: 15.0, start: std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
|
||
Seg2::Line { a: Vec2::new(0.0, -15.0), b: Vec2::new(31.0, -15.0) },
|
||
],
|
||
},
|
||
holes: vec![],
|
||
};
|
||
let mut body = crate::extrude(&Plane::XY, &[rim], 0.0, 13.0).unwrap().pop().unwrap();
|
||
// Exact local end bevels, meeting at the four-face vertices (30, +/-11.75, 13).
|
||
body = crate::blend_corner::chamfer(
|
||
&body,
|
||
&[Vec3::new(31.0, 13.375, 13.0), Vec3::new(31.0, -13.375, 13.0), Vec3::new(31.0, 11.75, 8.0), Vec3::new(31.0, -11.75, 8.0)],
|
||
1.0,
|
||
)
|
||
.unwrap();
|
||
let picks = [Vec3::new(-11.75, 0.0, 13.0), Vec3::new(15.0, 11.75, 13.0), Vec3::new(15.0, -11.75, 13.0)];
|
||
let result = crate::chamfer(&body, &picks, 1.0).unwrap();
|
||
assert!(result.validity().is_empty(), "{:?}", result.validity());
|
||
assert_eq!(result.crossed_face_loops().unwrap(), 0);
|
||
// At height 12 + u, the removed half-ring area is pi/2 * (2*r*u + u^2).
|
||
// The straight strips end at min(31-u, 30+v), 0 <= v <= u. Their integral
|
||
// over 0 <= u <= 1 is 30 + 1/6; this includes both split end sections.
|
||
let expected = 30.0 + 1.0 / 6.0 + std::f64::consts::PI * (11.75 / 2.0 + 1.0 / 6.0);
|
||
let volume = |b: &Body| b.tessellate_with(0.0001, true).unwrap().measure().volume;
|
||
let removed = volume(&body) - volume(&result);
|
||
assert!((removed - expected).abs() < 0.02, "removed {removed}, expected {expected}");
|
||
for p in [Vec3::new(30.5, 12.25, 12.5), Vec3::new(30.5, -12.25, 12.5)] {
|
||
assert!(result.solid.vertex_iter().any(|v| from_p3(v.point()).dist(p) < 1e-6));
|
||
}
|
||
for selection in [vec![picks[0]], picks.into_iter().rev().collect()] {
|
||
let other = crate::chamfer(&body, &selection, 1.0).unwrap();
|
||
assert!(other.validity().is_empty(), "{:?}", other.validity());
|
||
assert!((volume(&body) - volume(&other) - expected).abs() < 0.02);
|
||
}
|
||
assert!(crate::chamfer(&body, &picks, 4.0).is_err());
|
||
}
|
||
|
||
fn tee(scale: f64, origin: Vec3) -> Body {
|
||
let main = cylinder(origin + Vec3::new(-40.0, 0.0, 0.0) * scale, Vec3::X, 15.0 * scale, 80.0 * scale).unwrap();
|
||
let branch = cylinder(origin, Vec3::Z, 10.0 * scale, 35.0 * scale).unwrap();
|
||
boolean(&main, &branch, BoolOp::Union).unwrap().unwrap()
|
||
}
|
||
|
||
fn analytic_section(theta: f64, scale: f64, origin: Vec3) -> (Vec3, Vec3, Vec3) {
|
||
let centre = Vec3::new(13.0 * theta.cos(), 13.0 * theta.sin(), (324.0 - 169.0 * theta.sin().powi(2)).sqrt());
|
||
let main = Vec3::new(centre.x, centre.y * 15.0 / 18.0, centre.z * 15.0 / 18.0);
|
||
let branch = Vec3::new(centre.x * 10.0 / 13.0, centre.y * 10.0 / 13.0, centre.z);
|
||
(origin + centre * scale, origin + main * scale, origin + branch * scale)
|
||
}
|
||
|
||
fn nearest_blend(surfaces: &[mt::Surface], point: Vec3) -> (f64, Vec3) {
|
||
surfaces
|
||
.iter()
|
||
.filter_map(|s| {
|
||
let (u, v) = s.search_nearest_parameter(p3(point), None, 100)?;
|
||
let actual = from_p3(s.subs(u, v));
|
||
let n = s.normal(u, v);
|
||
Some((actual.dist(point), Vec3::new(n.x, n.y, n.z).normalized()?))
|
||
})
|
||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||
.unwrap()
|
||
}
|
||
|
||
fn distance_to_analytic_arc(theta: f64, point: Vec3) -> f64 {
|
||
let (centre, main, branch) = analytic_section(theta, 1.0, Vec3::ZERO);
|
||
let a = (main - centre).normalized().unwrap();
|
||
let b = (branch - centre).normalized().unwrap();
|
||
let plane = a.cross(b).normalized().unwrap();
|
||
let radial = point - centre;
|
||
let projected = radial - plane * radial.dot(plane);
|
||
let direction = projected.normalized().unwrap();
|
||
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
|
||
let from_main = a.dot(direction).clamp(-1.0, 1.0).acos();
|
||
let from_branch = b.dot(direction).clamp(-1.0, 1.0).acos();
|
||
if (from_main + from_branch - angle).abs() < 1e-7 { point.dist(centre + direction * 3.0) } else { point.dist(main).min(point.dist(branch)) }
|
||
}
|
||
|
||
fn distance_to_analytic_band(point: Vec3) -> f64 {
|
||
// The arc's XY azimuth stays between its two contacts' azimuths. Their difference
|
||
// for this tee is less than 0.1 rad; bracket generously around the query azimuth.
|
||
let azimuth = point.y.atan2(point.x);
|
||
let (mut lo, mut hi) = (azimuth - 0.3, azimuth + 0.3);
|
||
for _ in 0..55 {
|
||
let a = lo + (hi - lo) * 0.38196601125;
|
||
let b = lo + (hi - lo) * 0.61803398875;
|
||
if distance_to_analytic_arc(a, point) < distance_to_analytic_arc(b, point) {
|
||
hi = b;
|
||
} else {
|
||
lo = a;
|
||
}
|
||
}
|
||
distance_to_analytic_arc((lo + hi) * 0.5, point)
|
||
}
|
||
|
||
#[test]
|
||
fn fitted_contact_edges_coincide_with_their_surface_boundaries() {
|
||
let sections: Vec<Vec<Section>> = (0..4)
|
||
.map(|k| {
|
||
(0..SAMPLES)
|
||
.map(|i| {
|
||
let theta = std::f64::consts::FRAC_PI_2 * (k as f64 + i as f64 / (SAMPLES - 1) as f64);
|
||
let (c, q1, q2) = analytic_section(theta, 1.0, Vec3::ZERO);
|
||
Section { c, q1, q2 }
|
||
})
|
||
.collect()
|
||
})
|
||
.collect();
|
||
for k in 0..sections.len() {
|
||
let fit = crate::guard("contact consistency", || fit_sections(§ions, k, true, true)).unwrap();
|
||
for (side, curve) in fit.contacts.iter().enumerate() {
|
||
let (lo, hi) = curve.range_tuple();
|
||
for i in 0..=200 {
|
||
let parameter = lo + (hi - lo) * i as f64 / 200.0;
|
||
let edge = from_p3(curve.subs(parameter));
|
||
let surface = from_p3(fit.surface.subs(side as f64, parameter));
|
||
assert!(edge.dist(surface) < 1e-9, "contact-to-surface gap {}", edge.dist(surface));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rolling_ball_matches_analytic_tee_and_contacts() {
|
||
for (scale, origin) in [(1.0, Vec3::ZERO), (0.25, Vec3::new(23.0, -17.0, 9.0))] {
|
||
let body = tee(scale, origin);
|
||
let point = origin + Vec3::new(0.0, 10.0, 125f64.sqrt()) * scale;
|
||
let blend = fillet(&body, &[point], 3.0 * scale).unwrap();
|
||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||
assert!(!surfaces.is_empty());
|
||
let mut distance: f64 = 0.0;
|
||
let mut tangent_angle: f64 = 0.0;
|
||
for i in 0..96 {
|
||
// Offset the angular samples from patch seams; contact endpoints are tested below.
|
||
let theta = std::f64::consts::TAU * (i as f64 + 0.37) / 96.0;
|
||
let (centre, main, branch) = analytic_section(theta, scale, origin);
|
||
let a = (main - centre).normalized().unwrap();
|
||
let b = (branch - centre).normalized().unwrap();
|
||
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
|
||
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||
// Spherical linear interpolation of the radial unit vectors, independent of
|
||
// the production rational control-point construction and fitting parameters.
|
||
let radial = (a * ((1.0 - fraction) * angle).sin() + b * (fraction * angle).sin()) / angle.sin();
|
||
let exact = centre + radial * (3.0 * scale);
|
||
let (error, normal) = nearest_blend(&surfaces, exact);
|
||
distance = distance.max(error);
|
||
if fraction == 0.0 || fraction == 1.0 {
|
||
let want = (centre - exact).normalized().unwrap();
|
||
tangent_angle = tangent_angle.max(normal.dot(want).abs().min(1.0).acos().to_degrees());
|
||
}
|
||
}
|
||
}
|
||
assert!(distance <= 0.005, "scale {scale}: analytic surface deviation {distance} mm");
|
||
assert!(tangent_angle <= 0.5, "scale {scale}: contact normals differ by {tangent_angle} degrees");
|
||
|
||
// The reverse direction uses points on the actual trimmed face mesh, so an
|
||
// untrimmed neighbouring surface cannot conceal an overshooting blend patch.
|
||
let mesh = blend.tessellate_with(0.002, true).unwrap();
|
||
let mut reverse_distance: f64 = 0.0;
|
||
let mut probes = 0;
|
||
for point in mesh.positions.iter().step_by(19) {
|
||
let p = (*point - origin) / scale;
|
||
if (p.x * p.x + p.y * p.y).sqrt() <= 10.000001
|
||
|| (p.y * p.y + p.z * p.z).sqrt() <= 15.000001
|
||
|| !(10.0..20.0).contains(&p.z)
|
||
|| p.x.abs() > 16.0
|
||
{
|
||
continue;
|
||
}
|
||
probes += 1;
|
||
reverse_distance = reverse_distance.max(distance_to_analytic_band(p) * scale);
|
||
}
|
||
assert!(probes > 300, "only {probes} trimmed surface points checked");
|
||
assert!(reverse_distance <= 0.005, "scale {scale}: reverse analytic deviation {reverse_distance} mm");
|
||
eprintln!("scale {scale}: surface {distance:.9} mm, reverse {reverse_distance:.9} mm, contact normals {tangent_angle:.6} degrees");
|
||
|
||
let mut contact_count = 0;
|
||
for edge in Body::unique_edges(&blend.solid) {
|
||
let curve = edge.oriented_curve();
|
||
let (lo, hi) = curve.range_tuple();
|
||
let endpoints = [from_p3(curve.subs(lo)), from_p3(curve.subs(hi))].map(|p| (p - origin) / scale);
|
||
if endpoints.iter().any(|p| !(9.0..20.0).contains(&p.z) || p.x.abs() > 16.0) {
|
||
continue;
|
||
}
|
||
let branch = endpoints.iter().all(|p| ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 1e-5);
|
||
let main = endpoints.iter().all(|p| ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 1e-5);
|
||
if !branch && !main {
|
||
continue;
|
||
}
|
||
contact_count += 1;
|
||
for i in 0..=200 {
|
||
let p = (from_p3(curve.subs(lo + (hi - lo) * i as f64 / 200.0)) - origin) / scale;
|
||
let radius_error = if branch { ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() } else { ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() };
|
||
assert!(radius_error * scale <= 0.002, "supporting cylinder deviation {} mm", radius_error * scale);
|
||
}
|
||
}
|
||
assert!(contact_count >= 4, "only {contact_count} contacts checked");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() {
|
||
let body = tee(1.0, Vec3::ZERO);
|
||
let solid = body.deep_copy();
|
||
let ids: Vec<_> = Body::unique_edges(&solid)
|
||
.iter()
|
||
.filter(|edge| {
|
||
let curve = edge.oriented_curve();
|
||
let (lo, hi) = curve.range_tuple();
|
||
let p = from_p3(curve.subs((lo + hi) * 0.5));
|
||
p.z > 10.0 && ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 0.01 && ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 0.01
|
||
})
|
||
.map(mt::Edge::id)
|
||
.collect();
|
||
assert!(!ids.is_empty());
|
||
let before = crate::step_export(&[&body], "unchanged").unwrap();
|
||
let input_before = crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap();
|
||
// Four sections deliberately undersample the saddle; production starts at 24.
|
||
let error = crate::guard("limited rolling ball", || curve_blend_with_limit(&solid, &ids, body.size(), 3.0, true, 4)).unwrap_err();
|
||
assert!(error.to_string().contains("accuracy and section limits"), "{error}");
|
||
assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before);
|
||
assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before);
|
||
}
|
||
|
||
/// Plan-view construction of a round between two overlapping circles (a concave vertical edge
|
||
/// where two Z cylinders meet): the ball centre `f`, the circle and fillet arc angles, and the
|
||
/// area the round fills in, all independent of the kernel.
|
||
struct Gap {
|
||
/// Points along the round's arc in plan, contacts included.
|
||
arc: Vec<Vec3>,
|
||
/// Arc angles on circle 1, circle 2 and the fillet between the corner and the contacts.
|
||
angles: [f64; 3],
|
||
area: f64,
|
||
}
|
||
|
||
fn gap(c1: Vec3, r1: f64, c2: Vec3, r2: f64, corner: Vec3, r: f64) -> Gap {
|
||
// The ball centre is r from both circles, on the corner's side of the centre line.
|
||
let (a, b) = (r1 + r, r2 + r);
|
||
let d = c1.dist(c2);
|
||
let u = (c2 - c1) * (1.0 / d);
|
||
let along = (a * a - b * b + d * d) / (2.0 * d);
|
||
let mut side = Vec3::new(-u.y, u.x, 0.0);
|
||
if (corner - c1).dot(side) < 0.0 {
|
||
side = side * -1.0;
|
||
}
|
||
let f = c1 + u * along + side * (a * a - along * along).sqrt();
|
||
let (t1, t2) = (c1 + (f - c1) * (r1 / a), c2 + (f - c2) * (r2 / b));
|
||
let angle = |c: Vec3, p: Vec3, q: Vec3| (p - c).normalized().unwrap().dot((q - c).normalized().unwrap()).clamp(-1.0, 1.0).acos();
|
||
let angles = [angle(c1, t1, corner), angle(c2, corner, t2), angle(f, t2, t1)];
|
||
// Shoelace over the three short arcs: circle 1 to the corner, circle 2 on, fillet back.
|
||
let mut ring = Vec::new();
|
||
for (c, p, q) in [(c1, t1, corner), (c2, corner, t2), (f, t2, t1)] {
|
||
let (p0, q0) = (p - c, q - c);
|
||
let (a0, a1) = (p0.y.atan2(p0.x), q0.y.atan2(q0.x));
|
||
let mut sweep = a1 - a0;
|
||
while sweep > std::f64::consts::PI {
|
||
sweep -= std::f64::consts::TAU;
|
||
}
|
||
while sweep < -std::f64::consts::PI {
|
||
sweep += std::f64::consts::TAU;
|
||
}
|
||
let rr = p0.len();
|
||
for i in 0..20_000 {
|
||
let t = a0 + sweep * i as f64 / 20_000.0;
|
||
ring.push(c + Vec3::new(t.cos(), t.sin(), 0.0) * rr);
|
||
}
|
||
}
|
||
let area = ring.iter().zip(ring.iter().cycle().skip(1)).map(|(p, q)| p.x * q.y - q.x * p.y).sum::<f64>().abs() * 0.5;
|
||
let (p, q) = ((t1 - f).normalized().unwrap(), (t2 - f).normalized().unwrap());
|
||
let arc = (0..=8).map(|i| angles[2] * i as f64 / 8.0).map(|t| f + (p * ((angles[2] - t).sin()) + q * t.sin()) * (r / angles[2].sin())).collect();
|
||
Gap { arc, angles, area }
|
||
}
|
||
|
||
#[test]
|
||
fn rolling_ball_along_a_straight_edge_between_cylinders_trims_curved_end_seams() {
|
||
// Two Z cylinders crossing square to each other: the vertical lines where they meet are
|
||
// concave edges between two curved faces, and the blend ends on the planar caps, where the
|
||
// seams are arcs.
|
||
let (r1, r2, h, r) = (17.0, 9.5, 10.0, 6.0);
|
||
let (c1, c2) = (Vec3::ZERO, Vec3::new(r1, r2, 0.0));
|
||
let a = cylinder(c1, Vec3::Z, r1, h).unwrap();
|
||
let b = cylinder(c2, Vec3::Z, r2, h).unwrap();
|
||
let body = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||
// The corner: one of the two points where the circles cross.
|
||
let d = c1.dist(c2);
|
||
let u = (c2 - c1) * (1.0 / d);
|
||
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||
let corner = c1 + u * along + Vec3::new(-u.y, u.x, 0.0) * (r1 * r1 - along * along).sqrt();
|
||
let g = gap(c1, r1, c2, r2, corner, r);
|
||
let blend = fillet(&body, &[corner + Vec3::Z * (h / 2.0)], r).unwrap();
|
||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||
// One blend face; the edge is replaced by two contact lines and two end arcs.
|
||
let (before, after) = (crate::measure(&body).unwrap(), crate::measure(&blend).unwrap());
|
||
assert_eq!((after.faces, after.edges, after.vertices), (before.faces + 1, before.edges + 3, before.vertices + 2), "{before:?} -> {after:?}");
|
||
// The round fills the gap over the full height. The caps gain it twice; the walls lose
|
||
// their arcs to the contacts and gain the round.
|
||
let (m0, m1) = (body.tessellate_with(0.0005, true).unwrap().measure(), blend.tessellate_with(0.0005, true).unwrap().measure());
|
||
let volume = h * g.area;
|
||
assert!((m1.volume - m0.volume - volume).abs() < volume * 3e-3, "added volume {} vs {volume}", m1.volume - m0.volume);
|
||
let [a1, a2, af] = g.angles;
|
||
let area = 2.0 * g.area + h * (r * af - r1 * a1 - r2 * a2);
|
||
assert!((m1.area - m0.area - area).abs() < area.abs() * 3e-3, "added area {} vs {area}", m1.area - m0.area);
|
||
// The round is the analytic arc at every height, from contact to contact.
|
||
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||
let mut worst: f64 = 0.0;
|
||
for z in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||
for p in &g.arc {
|
||
worst = worst.max(nearest_blend(&surfaces, *p + Vec3::Z * (h * z)).0);
|
||
}
|
||
}
|
||
assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round");
|
||
}
|
||
|
||
#[test]
|
||
fn closed_curved_seam_is_not_supported() {
|
||
// A closed seam (one edge starting and ending at the same vertex, as a full cap circle can
|
||
// be in imported geometry) has no "between" two contacts, so trimming it is refused rather
|
||
// than guessed.
|
||
let body = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0).unwrap();
|
||
let arc = body.solid.edge_iter().find(|e| !matches!(e.curve(), mt::Curve::Line(_))).expect("a cap arc");
|
||
let circle = mt::Edge::new_unchecked(arc.front(), arc.front(), arc.curve());
|
||
assert_eq!(circle.front(), circle.back());
|
||
// Contacts on the circle itself: a third and two thirds of the way round.
|
||
let (lo, hi) = circle.curve().range_tuple();
|
||
let on = |f: f64| mt::Vertex::new(circle.curve().subs(lo + (hi - lo) * f));
|
||
for (a, b) in [(Some(on(1.0 / 3.0)), None), (None, Some(on(2.0 / 3.0))), (Some(on(1.0 / 3.0)), Some(on(2.0 / 3.0))), (None, None)] {
|
||
let err = trim_curved_seam(&circle, a, b, 20.0).unwrap_err();
|
||
assert!(err.to_string().contains("not supported yet: a closed curved edge"), "{err}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rolling_ball_past_the_end_seam_is_too_large_and_reduces() {
|
||
// The crossing cylinders of the test above with the small one's centre inside the large
|
||
// one: a round of 3 would reach past the end of the cap arc it trims, so it is too large
|
||
// (a smaller one fits), and the largest that fits is the analytic round over the full height
|
||
// (checked on its surface: its added volume is too small beside the body's to measure).
|
||
let (r1, r2, d, h) = (17.0, 9.5, 12.0, 10.0);
|
||
let (c1, c2) = (Vec3::ZERO, Vec3::new(d, 0.0, 0.0));
|
||
let body = boolean(&cylinder(c1, Vec3::Z, r1, h).unwrap(), &cylinder(c2, Vec3::Z, r2, h).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||
let corner = Vec3::new(along, (r1 * r1 - along * along).sqrt(), 0.0);
|
||
let at = [corner + Vec3::Z * (h / 2.0)];
|
||
let e = fillet(&body, &at, 3.0).unwrap_err();
|
||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||
let (r, blend) = crate::fillet_max(&body, &at, 3.0).unwrap();
|
||
assert!(r > 1.5 && r < 3.0, "{r}");
|
||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||
let g = gap(c1, r1, c2, r2, corner, r);
|
||
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||
let mut worst: f64 = 0.0;
|
||
for z in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||
for p in &g.arc {
|
||
worst = worst.max(nearest_blend(&surfaces, *p + Vec3::Z * (h * z)).0);
|
||
}
|
||
}
|
||
assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round at r {r}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_refused_edge_is_not_rolled_again_after_blends_far_from_it() {
|
||
// The crossing cylinders' seam refuses a round of 3 (too large). A bar joined to them has
|
||
// two straight edges at its far end that round on their own. Each blend there leaves the
|
||
// seam's surroundings as they were, so the seam is tried once, not once per blend, and the
|
||
// refusal is the same.
|
||
let (r1, r2, d, h) = (17.0, 9.5, 12.0, 10.0);
|
||
let body = boolean(&cylinder(Vec3::ZERO, Vec3::Z, r1, h).unwrap(), &cylinder(Vec3::new(d, 0.0, 0.0), Vec3::Z, r2, h).unwrap(), BoolOp::Union)
|
||
.unwrap()
|
||
.unwrap();
|
||
let bar = crate::box_solid(Vec3::new(-80.0, -5.0, 0.0), Vec3::new(-10.0, 5.0, h)).unwrap();
|
||
let body = boolean(&body, &bar, BoolOp::Union).unwrap().unwrap();
|
||
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||
let seam = Vec3::new(along, (r1 * r1 - along * along).sqrt(), h / 2.0);
|
||
let ends = [Vec3::new(-80.0, -5.0, h / 2.0), Vec3::new(-80.0, 5.0, h / 2.0)];
|
||
crate::blend::ROLLS.with(|n| n.set(0));
|
||
let e = fillet(&body, &[seam, ends[0], ends[1]], 3.0).unwrap_err();
|
||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||
let rolls = crate::blend::ROLLS.with(|n| n.get());
|
||
assert_eq!(rolls, 1, "the seam was rolled {rolls} times");
|
||
// The far edges alone still round.
|
||
assert!(fillet(&body, &ends, 3.0).is_ok());
|
||
}
|
||
|
||
/// At a knife edge (the plane and the wall's normals opposite) the ball's centre is the same
|
||
/// whichever face is side 1: `r` inside the plane and `r` outside the wall's cylinder.
|
||
#[test]
|
||
fn knife_edge_ball_centre_does_not_depend_on_the_side_order() {
|
||
const R: f64 = 21.0;
|
||
let tip = Vec3::new(62.0 + 1.0 / 6.0, 0.0, 5.0);
|
||
let slab = crate::box_solid(Vec3::new(0.0, -R, 0.0), Vec3::new(tip.x, 0.0, 10.0)).unwrap();
|
||
let disc = cylinder(Vec3::new(tip.x, -R, 0.0), Vec3::Z, R, 10.0).unwrap();
|
||
let body = boolean(&slab, &disc, BoolOp::Cut).unwrap().unwrap();
|
||
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
|
||
let plane = faces.iter().copied().find(|f| plane_normal(f).is_some_and(|n| n.y > 0.99)).unwrap();
|
||
let wall = faces.iter().copied().find(|f| plane_normal(f).is_none()).unwrap();
|
||
for r in [0.5, 2.0, 5.0] {
|
||
let centre = Vec3::new(tip.x - (4.0 * R * r).sqrt(), -r, tip.z);
|
||
// `cusp` is +1 when n1 × t points into the cusp: the wall's normal is −y, the plane's +y.
|
||
for (f1, f2, cusp) in [(wall, plane, 1.0), (plane, wall, -1.0)] {
|
||
let s = solve(&[f1], &[f2], tip, Vec3::Z, r, -1.0, cusp, None, 1e-6).unwrap();
|
||
assert!(s.c.dist(centre) < 1e-6, "r={r} cusp={cusp}: centre {:?}, expected {centre:?}", s.c);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #896: the seams where rounds run into the faces beside them are picked to be rounded again.
|
||
/// The faces meet tangentially there, so there is nothing to round: the refusal says so, rather
|
||
/// than that blending such an edge isn't supported yet.
|
||
#[test]
|
||
fn a_smooth_seam_is_refused_as_having_no_corner() {
|
||
let b = crate::box_solid(Vec3::ZERO, Vec3::new(150.0, 200.0, 70.0)).unwrap();
|
||
let mut edges = Vec::new();
|
||
for (x, y) in [(0.0, 0.0), (150.0, 0.0), (0.0, 200.0), (150.0, 200.0)] {
|
||
edges.push(Vec3::new(x, y, 35.0));
|
||
}
|
||
for (a, z) in [(0.0, 0.0), (150.0, 0.0), (0.0, 70.0), (150.0, 70.0)] {
|
||
edges.push(Vec3::new(a, 100.0, z));
|
||
}
|
||
for (y, z) in [(0.0, 0.0), (200.0, 0.0), (0.0, 70.0), (200.0, 70.0)] {
|
||
edges.push(Vec3::new(75.0, y, z));
|
||
}
|
||
let rounded = fillet(&b, &edges, 10.0).unwrap();
|
||
// The seam on the top face beside the round of the edge along y at x = 0, and the seam
|
||
// with the end face x = 0 beside the round of the top edge along y.
|
||
for seam in [Vec3::new(10.0, 100.0, 70.0), Vec3::new(0.0, 100.0, 60.0)] {
|
||
for chamfer in [false, true] {
|
||
let r = if chamfer { crate::chamfer(&rounded, &[seam], 1.0) } else { fillet(&rounded, &[seam], 1.0) };
|
||
match r {
|
||
Err(KernelError::Invalid(m)) => assert!(m.contains("smooth") && m.contains("no corner"), "{m}"),
|
||
other => panic!("{seam:?}: {:?}", other.map(|_| "a body")),
|
||
}
|
||
}
|
||
}
|
||
// A sharp edge of a box with one round still takes a fillet beside the round's seams.
|
||
let one = fillet(&crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(), &[Vec3::new(40.0, 30.0, 10.0)], 5.0).unwrap();
|
||
assert!(fillet(&one, &[Vec3::new(20.0, 0.0, 20.0)], 1.0).is_ok());
|
||
assert!(matches!(fillet(&one, &[Vec3::new(35.0, 30.0, 10.0)], 1.0), Err(KernelError::Invalid(_))));
|
||
}
|
||
|
||
/// 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). The arc meets the planar side walls x = 0 and
|
||
/// x = 40 at an angle, not square. 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 = crate::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)
|
||
}
|
||
|
||
/// Volume the blend removes along the bottom arc: the corner's cross-section at each distance
|
||
/// ρ from the axis (`depth(x)`, x ∈ [0, size] from the wall into the material), times the arc
|
||
/// of radius ρ between the side walls.
|
||
fn removed_volume(side: f64, size: f64, depth: impl Fn(f64) -> f64) -> f64 {
|
||
let steps = 4000;
|
||
let h = size / steps as f64;
|
||
(0..=steps)
|
||
.map(|i| {
|
||
let x = i as f64 * h;
|
||
let rho = 40.0 + side * x;
|
||
let w = if i == 0 || i == steps {
|
||
1.0
|
||
} else if i % 2 == 1 {
|
||
4.0
|
||
} else {
|
||
2.0
|
||
};
|
||
w * depth(x) * 2.0 * rho * (20.0 / rho).asin()
|
||
})
|
||
.sum::<f64>()
|
||
* h
|
||
/ 3.0
|
||
}
|
||
|
||
#[test]
|
||
fn rolling_ball_along_an_arc_ends_in_side_walls_at_an_angle() {
|
||
// The bottom edge of an arc wall ends in the side walls at an angle (#556). The round is the
|
||
// torus about the cylinder's axis (centre circle of radius 40 ± r at height r), trimmed by
|
||
// the side walls; the chamfer is the cone between the two contacts.
|
||
let r = 2.0;
|
||
for concave in [true, false] {
|
||
let (body, mid, axis, side) = arc_walled_box(concave);
|
||
let v0 = planned_volume(&body);
|
||
for round in [true, false] {
|
||
let what = format!("{} {}", if concave { "concave" } else { "convex" }, if round { "fillet" } else { "chamfer" });
|
||
let blend = if round { fillet(&body, &[mid], r) } else { crate::chamfer(&body, &[mid], r) }.unwrap_or_else(|e| panic!("{what}: {e}"));
|
||
assert!(blend.validity().is_empty(), "{what}: {:?}", blend.validity());
|
||
let volume = planned_volume(&blend);
|
||
let removed =
|
||
if round { removed_volume(side, r, |x| r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) } else { removed_volume(side, r, |x| r - x) };
|
||
assert!((v0 - volume - removed).abs() < removed * 3e-3, "{what}: removed {} vs {removed}", v0 - volume);
|
||
// The blend face stays between the side walls and, for the round, on the torus.
|
||
let surfaces: Vec<_> =
|
||
blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||
assert!(!surfaces.is_empty(), "{what}: no blend face");
|
||
let mesh = blend.tessellate_with(0.0005, true).unwrap();
|
||
let (lo, hi) = mesh.positions.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x)));
|
||
assert!(lo > -1e-3 && hi < 40.0 + 1e-3, "{what}: the blend runs past the side walls: x {lo}..{hi}");
|
||
if round {
|
||
// Sections of the round across the arc: the quarter circle about the centre
|
||
// ring (radius 40 ± r about the axis, at height r) from contact to contact.
|
||
let ring = 40.0 + side * r;
|
||
let mut worst: f64 = 0.0;
|
||
for i in 0..=40 {
|
||
let x = 40.0 * i as f64 / 40.0;
|
||
for a in 1..8 {
|
||
let t = std::f64::consts::FRAC_PI_2 * a as f64 / 8.0;
|
||
let (rho, z) = (ring - side * r * t.cos(), r - r * t.sin());
|
||
let dx = x - axis.x;
|
||
let dy = (rho * rho - dx * dx).sqrt() * if concave { 1.0 } else { -1.0 };
|
||
let p = Vec3::new(x, axis.y + dy, z);
|
||
worst = worst.max(nearest_blend(&surfaces, p).0);
|
||
}
|
||
}
|
||
assert!(worst < 1e-3, "{what}: blend surface is {worst} mm off the torus");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rolling_ball_past_a_slanted_end_face_is_too_large_and_reduces() {
|
||
// On the outward-curving wall a round of 4 would run its bottom contact into the back face
|
||
// before it reaches the side wall (the contact circle of radius 36 meets x = 0 at y ≈ 40.07):
|
||
// too large, and the largest that fits rounds the edge.
|
||
let (body, mid, _, _) = arc_walled_box(false);
|
||
let e = fillet(&body, &[mid], 4.0).unwrap_err();
|
||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||
let (r, blend) = crate::fillet_max(&body, &[mid], 4.0).unwrap();
|
||
assert!(r > 3.0 && r < 4.0, "{r}");
|
||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||
}
|
||
|
||
#[test]
|
||
fn rolling_ball_runs_into_a_knife_edge_tip() {
|
||
// The corner of a block outside a cylinder of radius 21 about (21, −21): its wall meets the
|
||
// back face y = 0 tangentially at (21, 0) and the left face x = 0 at (0, −21), knife-edge
|
||
// tips at both ends of the arc. The round of the bottom arc runs into both, cut off by the
|
||
// end faces' planes, which touch the wall contact at the tips without crossing it: at
|
||
// distance ρ from the axis it runs from x = 0 to y = 0, ρ (asin(21 / ρ) − acos(21 / ρ)).
|
||
let block = crate::box_solid(Vec3::new(0.0, -21.0, 0.0), Vec3::new(21.0, 0.0, 10.0)).unwrap();
|
||
let body = boolean(&block, &cylinder(Vec3::new(21.0, -21.0, -1.0), Vec3::Z, 21.0, 12.0).unwrap(), BoolOp::Cut).unwrap().unwrap();
|
||
let mid = Vec3::new(21.0 - 21.0 * std::f64::consts::FRAC_1_SQRT_2, -21.0 + 21.0 * std::f64::consts::FRAC_1_SQRT_2, 0.0);
|
||
let r = 2.0;
|
||
let blend = fillet(&body, &[mid], r).unwrap();
|
||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||
let (n, h) = (4000, r / 4000.0);
|
||
let removed = (0..=n)
|
||
.map(|i| {
|
||
let x = i as f64 * h;
|
||
let rho = 21.0 + x;
|
||
let w = if i == 0 || i == n {
|
||
1.0
|
||
} else if i % 2 == 1 {
|
||
4.0
|
||
} else {
|
||
2.0
|
||
};
|
||
w * (r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) * rho * ((21.0 / rho).asin() - (21.0 / rho).acos())
|
||
})
|
||
.sum::<f64>()
|
||
* h
|
||
/ 3.0;
|
||
let got = planned_volume(&body) - planned_volume(&blend);
|
||
assert!((got - removed).abs() < removed * 5e-3, "removed {got} vs {removed}");
|
||
}
|
||
|
||
#[test]
|
||
fn rolling_ball_turns_about_a_rounded_corner_of_its_radius() {
|
||
// The box with the inward-curving wall, its 60° corner at x = 0 rounded (r 2) first. The
|
||
// bottom edges then run: the side wall's, the corner round's (an arc of radius 2) and the
|
||
// arc wall's into the slanted side wall at x = 40. Rounded again with r 2 the ball can't
|
||
// roll round the corner: it turns about one point there, and the blend is a piece of sphere.
|
||
// It is what the rolling round tends to as its radius tends to the corner's, too large past
|
||
// it, and its mesh is closed.
|
||
let (body, _, _, _) = arc_walled_box(true);
|
||
let body = fillet(&body, &[Vec3::new(0.0, 4.641016151377546, 10.0)], 2.0).unwrap();
|
||
// The corner round's centre is 4 from the corner along its bisector, (2, 8.105): its arc's
|
||
// middle is 2 back toward the corner.
|
||
let ends = [Vec3::new(0.0, 25.0, 0.0), Vec3::new(1.0, 6.373, 0.0), Vec3::new(20.0, 10.0, 0.0)];
|
||
let v0 = planned_volume(&body);
|
||
let removed = |r: f64| {
|
||
let b = fillet(&body, &ends, r).unwrap_or_else(|e| panic!("r {r}: {e}"));
|
||
assert!(b.validity().is_empty(), "r {r}: {:?}", b.validity());
|
||
v0 - planned_volume(&b)
|
||
};
|
||
let (a, b, at) = (removed(1.96), removed(1.98), removed(2.0));
|
||
let expected = 2.0 * b - a;
|
||
assert!((at - expected).abs() < expected * 2e-3, "removed {at} at r 2, {a} and {b} before it");
|
||
let e = fillet(&body, &ends, 2.5).unwrap_err();
|
||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||
let (r, _) = crate::fillet_max(&body, &ends, 2.5).unwrap();
|
||
assert!(r > 1.5 && r <= 2.0 + 1e-9, "{r}");
|
||
}
|
||
|
||
#[test]
|
||
fn rolling_ball_along_a_straight_edge_ends_in_a_curved_face() {
|
||
// The box with the inward-curving wall: the bottom edge of the side wall x = 0 runs from the
|
||
// back to the curved wall (#556). The round of radius 2 along it is cut off by that wall:
|
||
// at d from the side wall it runs from y = −30 + √(40² − (d − 20)²) to the back at y = 40.
|
||
let (body, _, _, _) = arc_walled_box(true);
|
||
let r = 2.0;
|
||
for round in [true, false] {
|
||
let blend =
|
||
if round { fillet(&body, &[Vec3::new(0.0, 25.0, 0.0)], r) } else { crate::chamfer(&body, &[Vec3::new(0.0, 25.0, 0.0)], r) }.unwrap();
|
||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||
let (n, h) = (4000, r / 4000.0);
|
||
let removed = (0..=n)
|
||
.map(|i| {
|
||
let d = i as f64 * h;
|
||
let w = if i == 0 || i == n {
|
||
1.0
|
||
} else if i % 2 == 1 {
|
||
4.0
|
||
} else {
|
||
2.0
|
||
};
|
||
let depth = if round { r - (r * r - (r - d) * (r - d)).max(0.0).sqrt() } else { r - d };
|
||
w * depth * (40.0 - (-30.0 + (1600.0 - (d - 20.0) * (d - 20.0)).sqrt()))
|
||
})
|
||
.sum::<f64>()
|
||
* h
|
||
/ 3.0;
|
||
let (m0, m1) = (body.tessellate_with(0.0005, true).unwrap(), blend.tessellate_with(0.0005, true).unwrap());
|
||
let got = m0.measure().volume - m1.measure().volume;
|
||
assert!((got - removed).abs() < removed * 3e-3, "round {round}: removed {got} vs {removed}");
|
||
let volume_about = |o: Vec3| {
|
||
let at = |i: u32| m1.positions[i as usize] - o;
|
||
m1.triangles.iter().map(|t| at(t[0]).dot(at(t[1]).cross(at(t[2]))) / 6.0).sum::<f64>()
|
||
};
|
||
let shift = volume_about(Vec3::ZERO) - volume_about(Vec3::new(-100.0, 50.0, 30.0));
|
||
assert!(shift.abs() < 1e-2, "round {round}: the mesh is not closed (volume moves {shift} with the origin)");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn chamfer_a_chain_of_a_line_and_an_arc_into_a_slanted_end() {
|
||
// A 40 × 40 block, 20 high, whose front runs straight for 20 and then along an arc of
|
||
// radius 29 about (20, 29), tangent to it, up to (40, 8): the right face meets the arc at an
|
||
// angle. The top front chain chamfered by 2 (the planes' and the cone's sections are 2 × 2
|
||
// corners, 2 − w high at w in from the edge): 2 a millimetre along the line, and along the
|
||
// arc out to the right face, at each distance ρ from its axis, the height times the arc of
|
||
// radius ρ inside x ≤ 40.
|
||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
|
||
let start = -std::f64::consts::FRAC_PI_2;
|
||
let end = (-21.0f64).atan2(20.0);
|
||
let segs = vec![
|
||
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(20.0, 0.0) },
|
||
Seg2::Arc { center: Vec2::new(20.0, 29.0), radius: 29.0, start, sweep: end - start },
|
||
Seg2::Line { a: Vec2::new(40.0, 8.0), b: Vec2::new(40.0, 40.0) },
|
||
Seg2::Line { a: Vec2::new(40.0, 40.0), b: Vec2::new(0.0, 40.0) },
|
||
Seg2::Line { a: Vec2::new(0.0, 40.0), b: Vec2::new(0.0, 0.0) },
|
||
];
|
||
let body = crate::extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
|
||
let volume = |b: &Body| b.tessellate_with(0.0005, true).unwrap().measure().volume;
|
||
let made = crate::chamfer(&body, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap();
|
||
let n = 2000;
|
||
let h = 2.0 / n as f64;
|
||
let arc = (0..=n)
|
||
.map(|i| {
|
||
let rho = 27.0 + i as f64 * h;
|
||
let w = if i == 0 || i == n { 1.0 } else { [2.0, 4.0][i % 2] };
|
||
w * (rho - 27.0) * rho * (20.0 / rho).asin()
|
||
})
|
||
.sum::<f64>()
|
||
* h
|
||
/ 3.0;
|
||
let want = 40.0 + arc;
|
||
let got = volume(&body) - volume(&made);
|
||
assert!((got - want).abs() < want * 1e-3, "removed {got} vs {want}");
|
||
}
|
||
|
||
/// #1667: an R1 round around the end of a long bar whose four long edges are R5. The chain is
|
||
/// 290 mm lines and quarter turns of 7.85 mm, so the lines' sections are spaced far wider than
|
||
/// the turns'; borrowing the turns' sections for the lines' ends made their strips overshoot and
|
||
/// fold back past the joints, the strips' meshes folded with them, and the measured volume came
|
||
/// out thousands of mm³ off. The R1 pipe is exact: the corner's section, `1 - π/4`, along the
|
||
/// lines, and round each turn's axis at the section's centroid (Pappus).
|
||
#[test]
|
||
fn round_along_long_lines_and_small_turns_measures_its_volume() {
|
||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
|
||
let (w, d, h) = (300.0, 60.0, 40.0);
|
||
let bar = crate::build::box_solid(Vec3::new(-w / 2.0, 0.0, 0.0), Vec3::new(w / 2.0, d, h)).unwrap();
|
||
let long = [Vec3::new(-w / 2.0, d / 2.0, 0.0), Vec3::new(w / 2.0, d / 2.0, 0.0), Vec3::new(-w / 2.0, d / 2.0, h), Vec3::new(w / 2.0, d / 2.0, h)];
|
||
let rounded = fillet(&bar, &long, 5.0).unwrap();
|
||
// The rolling ball along the end face's loop (the exact blends would take this loop; a
|
||
// loop next to free-form faces, as in the issue, comes here).
|
||
let ids: Vec<mt::EdgeID> = crate::body::Body::unique_edges(&rounded.solid)
|
||
.into_iter()
|
||
.filter(|e| [e.front(), e.back()].iter().all(|v| crate::blend::vtx(v).y.abs() < 1e-9))
|
||
.map(|e| e.id())
|
||
.collect();
|
||
assert_eq!(ids.len(), 8);
|
||
let (solid, _) = crate::guard("test", || curve_blend(&rounded.solid, &ids, rounded.size(), 1.0, true)).unwrap();
|
||
let body = Body::new(solid).unwrap();
|
||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||
let section = 1.0 - FRAC_PI_4;
|
||
// The section's centroid lies this far from its corner along each side.
|
||
let inset = (5.0 / 6.0 - FRAC_PI_4) / section;
|
||
let lines = 2.0 * (w - 10.0) + 2.0 * (h - 10.0);
|
||
let turns = 4.0 * FRAC_PI_2 * (5.0 - inset);
|
||
let want = w * d * h - 4.0 * 25.0 * section * d - section * (lines + turns);
|
||
let m = crate::measure(&body).unwrap();
|
||
assert!((m.volume - want).abs() < 1e-5 * want, "{} vs {want}", m.volume);
|
||
// Centred across the bar, as the part is.
|
||
assert!(m.centroid.x.abs() < 1e-3, "centre of mass x {}", m.centroid.x);
|
||
}
|