498 lines
22 KiB
Rust
498 lines
22 KiB
Rust
//! Continuity diagnostics across the seams between a body's faces: do the two faces that meet
|
||
//! at an edge share a curve (G0), a tangent plane (G1) and a curvature (G2)?
|
||
//!
|
||
//! The stations are taken on the edge itself — the curve both faces are supposed to share — so
|
||
//! the numbers describe the B-rep and not a tessellation: each station is projected onto each
|
||
//! face's own surface, and the two projections are compared. The gap is the distance between
|
||
//! them (G0); the normal angle is the angle between the faces' outward normals there (G1); the
|
||
//! curvature jump is the difference of their normal curvatures in the normal section square to
|
||
//! the edge, from the second fundamental form of each surface (G2). A rolling-ball fillet is
|
||
//! tangent to the faces it joins but is not curvature continuous: where it meets a plane its
|
||
//! seam reads 0° and 1 / radius, which no chord tolerance would reveal.
|
||
//!
|
||
//! [`crate::smooth_edges`] decides whether a join needs a *display* line; this module measures
|
||
//! the join, and backs the engine's `inspect.continuity`.
|
||
|
||
use serde::Serialize;
|
||
use solidcraft_geom::Vec3;
|
||
use truck_modeling as mt;
|
||
|
||
use crate::body::{Body, from_p3, p3};
|
||
use crate::{KernelError, Result};
|
||
|
||
/// Bodies with more edges than this are refused rather than sampled for minutes.
|
||
const MAX_EDGES: usize = 4096;
|
||
/// Most faces a body may have and still be analysed.
|
||
const MAX_FACES: usize = 50_000;
|
||
/// Stations one call may take over all its edges; edges past the budget are left out and
|
||
/// counted in [`ContinuityReport::skipped`].
|
||
const MAX_STATIONS: usize = 200_000;
|
||
/// Most edges one pick list may name (the full list always fits the station budget).
|
||
const MAX_PICKS: usize = 512;
|
||
/// Samples used to find the edge nearest a picked point (as in `surfaces::edge_near`).
|
||
const PICK_SAMPLES: usize = 32;
|
||
|
||
/// Least and most stations along one edge (`inspect.continuity`'s `samples`): three are the two
|
||
/// ends and the middle.
|
||
pub const MIN_SAMPLES: usize = 3;
|
||
pub const MAX_SAMPLES: usize = 257;
|
||
|
||
/// How an edge is picked: a point on (or near) it, or its index in the display mesh's edge
|
||
/// order ([`Body::edges`]), which is what a viewport pick carries.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub enum EdgePick {
|
||
Point(Vec3),
|
||
Index(usize),
|
||
}
|
||
|
||
/// What counts as continuous across a seam.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct ContinuityTolerances {
|
||
/// Largest position gap that still counts as coincident (G0), mm.
|
||
pub gap_mm: f64,
|
||
/// Largest angle between the faces' normals that still counts as tangent (G1), degrees.
|
||
pub angle_deg: f64,
|
||
/// Largest curvature jump that still counts as curvature continuous (G2), 1/mm.
|
||
pub curvature_per_mm: f64,
|
||
}
|
||
|
||
/// The worst value of one metric along an edge, with where it was found.
|
||
#[derive(Clone, Copy, Debug, Serialize)]
|
||
pub struct SeamWorst {
|
||
pub value: f64,
|
||
/// The station that gave the worst value, in the body's coordinates.
|
||
pub at: Vec3,
|
||
/// How far along the edge it is: 0 at the edge's start, 1 at its end.
|
||
pub edge_fraction: f64,
|
||
}
|
||
|
||
/// Which test a seam fails, from the best continuity to the worst: [`SeamVerdict::Smooth`] is
|
||
/// within every tolerance (G2 or better), then a tangent seam whose curvature jumps (G1), then
|
||
/// a visible crease (G0 only), and finally faces that do not meet at all.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum SeamVerdict {
|
||
Smooth,
|
||
CurvatureJump,
|
||
Sharp,
|
||
Gap,
|
||
}
|
||
|
||
/// One edge's diagnostics: the worst of each metric along it, and where.
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct SeamReport {
|
||
/// Index in the display mesh's edge order ([`Body::edges`]).
|
||
pub edge: usize,
|
||
/// Length of the edge, mm.
|
||
pub length: f64,
|
||
/// Stations the values were taken at.
|
||
pub samples: usize,
|
||
pub gap_mm: SeamWorst,
|
||
pub normal_angle_deg: SeamWorst,
|
||
pub curvature_jump_per_mm: SeamWorst,
|
||
/// What the tolerances make of the seam.
|
||
pub verdict: SeamVerdict,
|
||
/// The stations in order: what a viewport shades.
|
||
pub points: Vec<Vec3>,
|
||
}
|
||
|
||
/// The seam diagnostics of one body.
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct ContinuityReport {
|
||
pub seams: Vec<SeamReport>,
|
||
/// Edges left out: not a seam between two faces (an open boundary, or a periodic face's own
|
||
/// seam, which is one face meeting itself), or past the station budget.
|
||
pub skipped: usize,
|
||
/// Edges that could not be sampled at all: no station of theirs projected onto both faces.
|
||
pub unmeasured: usize,
|
||
}
|
||
|
||
/// The edge indices `picks` mean, in display-mesh order and without repeats. A point pick takes
|
||
/// the edge nearest it (within the distance the kernel re-finds an edge from); an index pick
|
||
/// must be an existing index. An empty list means every two-sided seam of the body, and a pick
|
||
/// of an edge that is not a seam between two faces is refused, not guessed at.
|
||
pub fn pick_edges(body: &Body, picks: &[EdgePick]) -> Result<Vec<usize>> {
|
||
body.require_brep("continuity")?;
|
||
if picks.len() > MAX_PICKS {
|
||
return Err(KernelError::Invalid(format!("pick at most {MAX_PICKS} edges")));
|
||
}
|
||
crate::guard("continuity picks", || {
|
||
let (edges, adjacent) = topology(body)?;
|
||
if picks.is_empty() {
|
||
return Ok((0..edges.len()).filter(|i| two_sided(&adjacent, *i)).collect());
|
||
}
|
||
let reach = body.size() * 0.05 + 1e-6;
|
||
let mut out: Vec<usize> = Vec::new();
|
||
for pick in picks {
|
||
let index = match *pick {
|
||
EdgePick::Index(index) => index,
|
||
EdgePick::Point(p) => nearest_edge(&edges, p, reach)?,
|
||
};
|
||
if !two_sided(&adjacent, index) {
|
||
return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces")));
|
||
}
|
||
if !out.contains(&index) {
|
||
out.push(index);
|
||
}
|
||
}
|
||
out.sort_unstable();
|
||
Ok(out)
|
||
})
|
||
}
|
||
|
||
/// Sample the two-sided seams `edges` of `body` (indices in display-mesh order; empty: every
|
||
/// one of them) at `samples` stations along each edge's own parameter, the ends included, with
|
||
/// the tolerances the seams are judged against.
|
||
pub fn seam_diagnostics(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result<ContinuityReport> {
|
||
body.require_brep("continuity")?;
|
||
if !(MIN_SAMPLES..=MAX_SAMPLES).contains(&samples) {
|
||
return Err(KernelError::Invalid(format!("`samples` must be {MIN_SAMPLES}…{MAX_SAMPLES}")));
|
||
}
|
||
for (name, value) in [("gap", tolerances.gap_mm), ("angle", tolerances.angle_deg), ("curvature", tolerances.curvature_per_mm)] {
|
||
// A range test also throws out NaN (both comparisons are false).
|
||
if !(0.0..1e9).contains(&value) {
|
||
return Err(KernelError::Invalid(format!("the {name} tolerance must be a finite number, 0 or more")));
|
||
}
|
||
}
|
||
crate::guard("continuity", || measure(body, edges, samples, tolerances))
|
||
}
|
||
|
||
/// The body's unique edges and the faces meeting at each of them, either under the caps.
|
||
fn topology(body: &Body) -> Result<(Vec<mt::Edge>, Vec<Vec<usize>>)> {
|
||
if body.solid.face_iter().take(MAX_FACES + 1).count() > MAX_FACES {
|
||
return Err(KernelError::TooLarge(format!("the body has more than {MAX_FACES} faces")));
|
||
}
|
||
let (edges, adjacent) = crate::body::edge_order(body.solid.face_iter());
|
||
if edges.len() > MAX_EDGES {
|
||
return Err(KernelError::TooLarge(format!("the body has more than {MAX_EDGES} edges")));
|
||
}
|
||
Ok((edges, adjacent))
|
||
}
|
||
|
||
/// Is the edge at `index` shared by (exactly) two faces of the body?
|
||
fn two_sided(adjacent: &[Vec<usize>], index: usize) -> bool {
|
||
adjacent.get(index).is_some_and(|faces| faces.len() == 2)
|
||
}
|
||
|
||
/// The index of the edge whose curve comes nearest `p`, within `reach`.
|
||
fn nearest_edge(edges: &[mt::Edge], p: Vec3, reach: f64) -> Result<usize> {
|
||
use mt::ParametricCurve;
|
||
let mut best: Option<(f64, usize)> = None;
|
||
for (index, edge) in edges.iter().enumerate() {
|
||
let curve = edge.curve();
|
||
let Some((t0, t1)) = curve.try_range_tuple() else { continue };
|
||
if !(t0.is_finite() && t1.is_finite() && t1 > t0) {
|
||
continue;
|
||
}
|
||
for k in 0..=PICK_SAMPLES {
|
||
let q = from_p3(curve.subs(t0 + (t1 - t0) * k as f64 / PICK_SAMPLES as f64));
|
||
let d = q.dist(p);
|
||
if d.is_finite() && best.is_none_or(|(bd, _)| d < bd) {
|
||
best = Some((d, index));
|
||
}
|
||
}
|
||
}
|
||
match best {
|
||
Some((d, index)) if d <= reach => Ok(index),
|
||
_ => Err(KernelError::Invalid(format!("no edge of the body is within {reach:.3} mm of ({}, {}, {})", p.x, p.y, p.z))),
|
||
}
|
||
}
|
||
|
||
fn measure(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result<ContinuityReport> {
|
||
let (list, adjacent) = topology(body)?;
|
||
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
|
||
let mut selected: Vec<usize> = Vec::new();
|
||
let mut skipped = 0usize;
|
||
if edges.is_empty() {
|
||
for index in 0..list.len() {
|
||
if two_sided(&adjacent, index) {
|
||
selected.push(index);
|
||
} else {
|
||
skipped += 1;
|
||
}
|
||
}
|
||
} else {
|
||
selected.extend(edges.iter().copied());
|
||
selected.sort_unstable();
|
||
selected.dedup();
|
||
for &index in &selected {
|
||
if index >= list.len() {
|
||
return Err(KernelError::Invalid(format!("the body has no edge {index}")));
|
||
}
|
||
if !two_sided(&adjacent, index) {
|
||
return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces")));
|
||
}
|
||
}
|
||
}
|
||
let mut seams = Vec::new();
|
||
let mut unmeasured = 0usize;
|
||
let mut budget = MAX_STATIONS;
|
||
for index in selected {
|
||
let Some(face_pair) = adjacent.get(index).filter(|faces| faces.len() == 2) else { continue };
|
||
let (Some(&first), Some(&second)) = (face_pair.first(), face_pair.get(1)) else { continue };
|
||
let (Some(a), Some(b)) = (faces.get(first).copied(), faces.get(second).copied()) else { continue };
|
||
let Some(edge) = list.get(index) else { continue };
|
||
// Every edge costs `samples` stations; a run out of budget stops cleanly.
|
||
if budget < samples {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
budget -= samples;
|
||
match seam(index, edge, a, b, samples, tolerances) {
|
||
Some(report) => seams.push(report),
|
||
None => unmeasured += 1,
|
||
}
|
||
}
|
||
Ok(ContinuityReport { seams, skipped, unmeasured })
|
||
}
|
||
|
||
/// One edge's worst gap, normal angle and curvature jump.
|
||
fn seam(index: usize, edge: &mt::Edge, face_a: &mt::Face, face_b: &mt::Face, samples: usize, tolerances: ContinuityTolerances) -> Option<SeamReport> {
|
||
use mt::ParametricCurve;
|
||
let curve = edge.curve();
|
||
let (t0, t1) = curve.try_range_tuple()?;
|
||
// The station fractions: 0 at the edge's start, 1 at its end.
|
||
let steps = samples.checked_sub(1)?;
|
||
let fraction = |k: usize| k as f64 / steps as f64;
|
||
let mut on_a = FaceSurface::new(face_a);
|
||
let mut on_b = FaceSurface::new(face_b);
|
||
let mut gap = None;
|
||
let mut angle = None;
|
||
let mut jump = None;
|
||
let mut points = Vec::with_capacity(samples);
|
||
for k in 0..samples {
|
||
let f = fraction(k);
|
||
let at = from_p3(curve.subs(t0 + (t1 - t0) * f));
|
||
if !at.is_finite() {
|
||
continue;
|
||
}
|
||
// Both projections and both curvatures, or the station says nothing.
|
||
let (Some((qa, na, uv_a)), Some((qb, nb, uv_b))) = (on_a.project(at), on_b.project(at)) else { continue };
|
||
let tangent = curve.der(t0 + (t1 - t0) * f);
|
||
let Some(tangent) = Vec3::new(tangent.x, tangent.y, tangent.z).normalized() else { continue };
|
||
// The normal sections square to the seam: one direction per face, in its tangent plane.
|
||
let (Some(ka), Some(kb)) = (on_a.normal_curvature(uv_a, na, across(na, tangent)), on_b.normal_curvature(uv_b, nb, across(nb, tangent)))
|
||
else {
|
||
continue;
|
||
};
|
||
// The faces' outward normals agree on a smooth seam; where they are opposed (a knife
|
||
// edge) the curvatures are compared in one frame, since bending away from one face is
|
||
// bending towards the other.
|
||
let signed = if na.dot(nb) < 0.0 { ka + kb } else { ka - kb };
|
||
points.push(at);
|
||
keep(&mut gap, qa.dist(qb), at, f);
|
||
keep(&mut angle, na.dot(nb).clamp(-1.0, 1.0).acos().to_degrees(), at, f);
|
||
keep(&mut jump, signed.abs(), at, f);
|
||
}
|
||
let (gap, angle, jump) = (gap?, angle?, jump?);
|
||
let verdict = if gap.value > tolerances.gap_mm {
|
||
SeamVerdict::Gap
|
||
} else if angle.value > tolerances.angle_deg {
|
||
SeamVerdict::Sharp
|
||
} else if jump.value > tolerances.curvature_per_mm {
|
||
SeamVerdict::CurvatureJump
|
||
} else {
|
||
SeamVerdict::Smooth
|
||
};
|
||
let length = points.windows(2).map(|w| w.first().zip(w.get(1)).map_or(0.0, |(a, b)| a.dist(*b))).sum();
|
||
Some(SeamReport {
|
||
edge: index,
|
||
length,
|
||
samples: points.len(),
|
||
gap_mm: gap,
|
||
normal_angle_deg: angle,
|
||
curvature_jump_per_mm: jump,
|
||
verdict,
|
||
points,
|
||
})
|
||
}
|
||
|
||
/// The direction in which a face's curvature is read at a seam: across the edge, square to it,
|
||
/// inside the face's tangent plane (a zero vector where the edge runs along the normal, which
|
||
/// leaves that station unmeasured).
|
||
fn across(normal: Vec3, tangent: Vec3) -> Vec3 {
|
||
normal.cross(tangent)
|
||
}
|
||
|
||
/// Keep the largest value seen, with where it was found.
|
||
fn keep(worst: &mut Option<SeamWorst>, value: f64, at: Vec3, edge_fraction: f64) {
|
||
if value.is_finite() && worst.is_none_or(|w| value > w.value) {
|
||
*worst = Some(SeamWorst { value, at, edge_fraction });
|
||
}
|
||
}
|
||
|
||
/// A face's surface, kept over the stations of one edge so each search can start at the last
|
||
/// answer (stations run along the edge, so the parameters move smoothly).
|
||
struct FaceSurface {
|
||
surface: mt::Surface,
|
||
hint: Option<(f64, f64)>,
|
||
}
|
||
|
||
impl FaceSurface {
|
||
/// The face's own outward-facing surface (`oriented_surface`: a reversed face's surface is
|
||
/// inverted, so its normal is the material's outward normal).
|
||
fn new(face: &mt::Face) -> Self {
|
||
Self { surface: face.oriented_surface(), hint: None }
|
||
}
|
||
|
||
/// The nearest point of the surface to `p`, the outward unit normal there and the
|
||
/// parameters. `None` where the search or the normal is unusable.
|
||
fn project(&mut self, p: Vec3) -> Option<(Vec3, Vec3, (f64, f64))> {
|
||
use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
|
||
let uv = self.surface.search_nearest_parameter(p3(p), self.hint, 100).or_else(|| self.surface.search_nearest_parameter(p3(p), None, 100))?;
|
||
if !(uv.0.is_finite() && uv.1.is_finite()) {
|
||
return None;
|
||
}
|
||
let q = from_p3(self.surface.subs(uv.0, uv.1));
|
||
let n = self.surface.normal(uv.0, uv.1);
|
||
if !q.is_finite() {
|
||
return None;
|
||
}
|
||
let normal = Vec3::new(n.x, n.y, n.z).normalized()?;
|
||
self.hint = Some(uv);
|
||
Some((q, normal, uv))
|
||
}
|
||
|
||
/// The normal curvature of the face at `uv` along `direction` (a unit vector of its tangent
|
||
/// plane), from the second fundamental form. A surface point `r(u, v)` with unit normal `n`
|
||
/// crossed that way has curvature `κ = n · r''` of the curve whose tangent there is
|
||
/// `direction`: a plane reads 0 and a cylinder of radius `R`, across its axis, −1 / R
|
||
/// (bending towards its axis). `None` at a degenerate parameter point (a cone's apex), where
|
||
/// the surface has no curvature.
|
||
fn normal_curvature(&self, uv: (f64, f64), normal: Vec3, direction: Vec3) -> Option<f64> {
|
||
use mt::ParametricSurface;
|
||
let ru = vec3(self.surface.uder(uv.0, uv.1));
|
||
let rv = vec3(self.surface.vder(uv.0, uv.1));
|
||
let (ru_len, rv_len) = (ru.len(), rv.len());
|
||
if !(ru_len > 0.0 && rv_len > 0.0) {
|
||
return None;
|
||
}
|
||
// Solve the direction in the unit basis (r_u / |r_u|, r_v / |r_v|): the curve then runs
|
||
// its parameters at du/ds = α / |r_u| and dv/ds = β / |r_v|, and
|
||
// `r'' = r_uu (du/ds)² + 2 r_uv (du/ds)(dv/ds) + r_vv (dv/ds)²`.
|
||
let cos = (ru.dot(rv) / (ru_len * rv_len)).clamp(-1.0, 1.0);
|
||
let sin2 = 1.0 - cos * cos;
|
||
if !(sin2 > 1e-12) {
|
||
return None;
|
||
}
|
||
let (cu, cv) = (direction.dot(ru) / ru_len, direction.dot(rv) / rv_len);
|
||
let (du, dv) = ((cu - cv * cos) / sin2 / ru_len, (cv - cu * cos) / sin2 / rv_len);
|
||
if !(du.is_finite() && dv.is_finite()) {
|
||
return None;
|
||
}
|
||
// `direction` is a unit vector: divide by the speed the parameters imply (they only miss
|
||
// it by rounding), so κ does not depend on how the surface is parameterized.
|
||
let speed = (ru * du + rv * dv).len();
|
||
if !(speed > 1e-6) {
|
||
return None;
|
||
}
|
||
let second = normal.dot(
|
||
vec3(self.surface.uuder(uv.0, uv.1)) * (du * du)
|
||
+ vec3(self.surface.uvder(uv.0, uv.1)) * (2.0 * du * dv)
|
||
+ vec3(self.surface.vvder(uv.0, uv.1)) * (dv * dv),
|
||
);
|
||
(second / (speed * speed)).is_finite().then_some(second / (speed * speed))
|
||
}
|
||
}
|
||
|
||
/// A kernel point as a vector.
|
||
fn vec3(p: mt::Vector3) -> Vec3 {
|
||
Vec3::new(p.x, p.y, p.z)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(clippy::unwrap_used)]
|
||
mod tests {
|
||
use super::*;
|
||
use solidcraft_geom::Plane;
|
||
|
||
fn tolerances() -> ContinuityTolerances {
|
||
ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 0.01 }
|
||
}
|
||
|
||
fn box_body() -> Body {
|
||
crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0)).unwrap()
|
||
}
|
||
|
||
fn report(body: &Body) -> ContinuityReport {
|
||
seam_diagnostics(body, &[], 33, tolerances()).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn a_box_edge_is_a_gap_free_right_angle() {
|
||
let body = box_body();
|
||
let r = report(&body);
|
||
assert_eq!(r.seams.len(), 12, "a box has twelve edges");
|
||
assert_eq!(r.skipped, 0);
|
||
for seam in &r.seams {
|
||
assert!(seam.gap_mm.value < 1e-6, "gap {}", seam.gap_mm.value);
|
||
assert!((seam.normal_angle_deg.value - 90.0).abs() < 1e-9, "angle {}", seam.normal_angle_deg.value);
|
||
assert!(seam.curvature_jump_per_mm.value < 1e-9, "jump {}", seam.curvature_jump_per_mm.value);
|
||
assert_eq!(seam.verdict, SeamVerdict::Sharp);
|
||
assert_eq!(seam.samples, 33);
|
||
assert!((seam.length - 10.0).abs() < 1e-9);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_fillet_seam_is_tangent_with_the_fillets_curvature_jump() {
|
||
let body = box_body();
|
||
let rounded = crate::fillet(&body, &[Vec3::new(10.0, 10.0, 5.0)], 4.0).unwrap();
|
||
let r = report(&rounded);
|
||
// The rounded edge is replaced by the blend face: two tangent seams along it, the two
|
||
// arcs where the blend meets the ends of the edge, and the box's other eleven edges.
|
||
assert_eq!(r.seams.len(), 15);
|
||
let tangent: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect();
|
||
assert_eq!(tangent.len(), 2, "the fillet meets its two neighbours tangentially");
|
||
for seam in &tangent {
|
||
assert!(seam.gap_mm.value < 1e-6);
|
||
assert!(seam.curvature_jump_per_mm.value.is_finite());
|
||
assert!((seam.curvature_jump_per_mm.value - 0.25).abs() < 1e-6, "1 / radius, got {}", seam.curvature_jump_per_mm.value);
|
||
assert_eq!(seam.verdict, SeamVerdict::CurvatureJump);
|
||
}
|
||
assert_eq!(r.seams.iter().filter(|s| s.verdict == SeamVerdict::Sharp).count(), 13);
|
||
}
|
||
|
||
#[test]
|
||
fn a_line_tangent_into_an_arc_reports_one_over_its_radius() {
|
||
use solidcraft_geom::{Loop2, Region2, Seg2, Vec2};
|
||
let v = |x: f64, y: f64| Vec2::new(x, y);
|
||
let outer = Loop2 {
|
||
segs: vec![
|
||
Seg2::Arc { center: v(10.0, 0.0), radius: 5.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
|
||
Seg2::Line { a: v(10.0, 5.0), b: v(-10.0, 5.0) },
|
||
Seg2::Arc { center: v(-10.0, 0.0), radius: 5.0, start: std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
|
||
Seg2::Line { a: v(-10.0, -5.0), b: v(10.0, -5.0) },
|
||
],
|
||
};
|
||
let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 3.0).unwrap().remove(0);
|
||
let r = report(&body);
|
||
let joins: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect();
|
||
assert_eq!(joins.len(), 4, "a slot has four line-to-arc joins");
|
||
for seam in joins {
|
||
assert!(
|
||
seam.curvature_jump_per_mm.value > 0.19 && seam.curvature_jump_per_mm.value < 0.21,
|
||
"1 / 5, got {}",
|
||
seam.curvature_jump_per_mm.value
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn picks_refuse_what_is_not_there() {
|
||
let body = box_body();
|
||
let all = pick_edges(&body, &[]).unwrap();
|
||
assert_eq!(all.len(), 12);
|
||
// The edge up the corner at (0, 0), beside a point in the middle of a face.
|
||
let picked = pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 0.0, 5.0)), EdgePick::Index(all[0])]).unwrap();
|
||
assert_eq!(picked.len(), 2);
|
||
assert!(pick_edges(&body, &[EdgePick::Index(999)]).is_err());
|
||
assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 5.0, 5.0))]).is_err(), "a point on a face is not on an edge");
|
||
assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(500.0, 0.0, 0.0))]).is_err());
|
||
assert!(seam_diagnostics(&body, &[], 2, tolerances()).is_err());
|
||
assert!(seam_diagnostics(&body, &[], 258, tolerances()).is_err());
|
||
assert!(seam_diagnostics(&body, &[], 33, ContinuityTolerances { gap_mm: f64::NAN, ..tolerances() }).is_err());
|
||
assert!(seam_diagnostics(&body, &[999], 33, tolerances()).is_err());
|
||
}
|
||
}
|