Compare commits

...

1 Commits

Author SHA1 Message Date
Brennen Herbruck 05c4cabc33 STEP import: edges and vertices covered at SMLib's zones, the body's tolerance raised to hold them, gaps measured at their widest
Port of #13271, #13275 and #13456 (#11997, #12757) onto the reader in solidcore-step:
- An edge's tolerance covers its gap three times over and a vertex's six (a record tolerance is SMLib's intersection tolerance, twice the zone it checks gaps against), up to SMLib's 1 mm; an edge or vertex is refused only where 1.5 times its gap, the margin before, passes 1 mm. The STEP reader's edges and the SMLib trims' gap cover keep this least margin too (a NIST FTC 07 edge 0.64 mm off its face, covered at 0.96 mm, stays covered).
- A body whose edges or vertices need more than its file states raises its tolerance to hold them within SMLib's 1000:1 ratio and its vertices' gaps, and says so.
- An edge's gap is measured in each knot span of its curve and pcurve and refined at its peaks, not only at 16 even points.

Co-authored-by: dudedagger42 <gsbulkley@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:54:12 -05:00
4 changed files with 363 additions and 58 deletions
+11 -4
View File
@@ -100,11 +100,19 @@ fn parabola_piece_volume() -> f64 {
+ (0.4 * q.powf(2.5) - 2.0 / 3.0 * c * c * q.powf(1.5)) / (4.0 * c * c)
}
/// The conic edge's pcurve on the cone is fitted: between its knots its image lies up to 4.9e-7
/// mm off the edge, nearly the 5e-7 mm zone of the file's 1e-6, so the edge's tolerance covers
/// it. The gap is the reader's, not the file's, so no note (#12757); SMLib's check passes the body.
fn covers_the_conics_pcurve(imp: &StepImport) {
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 1);
crate::validation::check_kept(&imp.bodies[0].body).unwrap_or_else(|e| panic!("{e}"));
}
fn import_piece(text: &str, want: f64) -> StepImport {
assert_eq!(step_orientation_errors(text).unwrap(), Vec::<String>::new());
let imp = step_import(text).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 1);
covers_the_conics_pcurve(&imp);
assert!(imp.bodies[0].closed);
let m = measure(&imp.bodies[0].body).unwrap();
assert_eq!(m.faces, 3);
@@ -118,8 +126,7 @@ fn round_trip(imp: &StepImport, want: f64) {
let text = step_export(&[&imp.bodies[0].body], "conic").unwrap();
step_validate(&text).unwrap();
let back = step_import(&text).unwrap();
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
assert_eq!(back.bodies.len(), 1);
covers_the_conics_pcurve(&back);
assert!(back.bodies[0].closed);
let v = measure(&back.bodies[0].body).unwrap().volume;
assert!(rel(v, want) < 1e-4, "round trip volume {v} vs {want}");
+139 -5
View File
@@ -1493,7 +1493,10 @@ fn nearly_round_spindle_torus_pin_end_in_halves_imports_whole() {
t += &context(70, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))").replace("LENGTH_MEASURE(1.E-6)", "LENGTH_MEASURE(1.E-5)");
t += TAIL;
let (volume, area) = pin_with_cylinder(major, minor);
assert_closed_measures(&t, volume, area, &[]);
// The apex lies 3.6e-5 mm off the pole, past the file's 1e-5: the body's tolerance covers it,
// as SMLib checks a vertex's gap from its faces against the body's.
let note = "solid 'Pin': the file states its geometry within 0.00001 mm, but its edges and vertices need tolerances up to 2.174e-4 mm and its vertices lie up to 3.623e-5 mm off their faces: the body's tolerance is 7.246e-5 mm";
assert_closed_measures(&t, volume, area, &[note]);
}
/// The lemon of a spindle torus (the tube's part across the axis: a closed surface with a pole
@@ -2041,7 +2044,10 @@ fn extruded_ellipse_patch_crossing_profile_seam_stays_trimmed() {
);
assert!(text.contains(&old));
let text = text.replacen(&old, &surface, 1);
let imported = step_import(&text).unwrap();
let mut imported = step_import(&text).unwrap();
// The ellipse's edges lie up to 6e-4 mm off the patch, past the zone the file's 1e-3
// gives them in SMLib, so their tolerances are raised; that note is not this test's.
imported.warnings.retain(|w| !w.contains("edges lie off their faces' surfaces"));
// Against its loop (same_sense false) the face is turned over, and the import says so.
let open = "solid 'Strip': imported as an open body (the shell is not closed)";
let turned = "solid 'Strip': 1 faces turned over: their loops disagree with their same_sense";
@@ -2180,9 +2186,22 @@ fn dome_with_edges_raised(dz: f64) -> String {
patch_with_edges_raised(dz, false)
}
/// [`dome_with_edges_raised`] with only the edges' inner control points raised: the vertices lie
/// on the dome's corners and each edge bows off it between them, up to `0.75 dz` at its middle,
/// as an intersection curve written looser than its file states does.
fn dome_with_edges_bowed(dz: f64) -> String {
patch_with_edges(dz, false, false)
}
/// [`dome_with_edges_raised`], or with `flat` the plane z = 0 in its place: its edges' pcurves
/// are then their control points' projections onto it, in closed form.
fn patch_with_edges_raised(dz: f64, flat: bool) -> String {
patch_with_edges(dz, flat, true)
}
/// [`patch_with_edges_raised`]; with `ends` false the vertices and the edges' end control points
/// stay on the patch.
fn patch_with_edges(dz: f64, flat: bool, ends: bool) -> String {
let z = |i: usize, j: usize| if !flat && (1..=2).contains(&i) && (1..=2).contains(&j) { 6.0 } else { 0.0 };
let mut t = String::from(HEAD);
let mut rows = Vec::new();
@@ -2205,12 +2224,13 @@ fn patch_with_edges_raised(dz: f64, flat: bool) -> String {
// i = 3, then back; each side's control points are the patch's, raised.
let sides: [[(usize, usize); 4]; 4] =
[[(0, 0), (1, 0), (2, 0), (3, 0)], [(3, 0), (3, 1), (3, 2), (3, 3)], [(3, 3), (2, 3), (1, 3), (0, 3)], [(0, 3), (0, 2), (0, 1), (0, 0)]];
let p = |(i, j): (usize, usize)| format!("({}.,{}.,{})", i * 10, j * 10, z(i, j) + dz);
let p = |(i, j): (usize, usize), dz: f64| format!("({}.,{}.,{})", i * 10, j * 10, z(i, j) + dz);
let end_dz = if ends { dz } else { 0.0 };
for (k, side) in sides.iter().enumerate() {
t += &format!("#{}=CARTESIAN_POINT('',{});\n#{}=VERTEX_POINT('',#{});\n", 200 + k, p(side[0]), 210 + k, 200 + k);
t += &format!("#{}=CARTESIAN_POINT('',{});\n#{}=VERTEX_POINT('',#{});\n", 200 + k, p(side[0], end_dz), 210 + k, 200 + k);
let pts: Vec<String> = (0..4).map(|m| format!("#{}", 300 + k * 4 + m)).collect();
for (m, &q) in side.iter().enumerate() {
t += &format!("#{}=CARTESIAN_POINT('',{});\n", 300 + k * 4 + m, p(q));
t += &format!("#{}=CARTESIAN_POINT('',{});\n", 300 + k * 4 + m, p(q, if m == 0 || m == 3 { end_dz } else { dz }));
}
t += &format!("#{}=B_SPLINE_CURVE_WITH_KNOTS('',3,({}),.UNSPECIFIED.,.F.,.F.,(4,4),(0.,1.),.UNSPECIFIED.);\n", 400 + k, pts.join(","));
}
@@ -2241,6 +2261,16 @@ fn edges_written_off_their_spline_face_carry_their_gap_as_their_tolerance() {
assert!(imp.warnings.iter().any(|w| w.contains("4 edges lie off their faces' surfaces")), "{:?}", imp.warnings);
let r = imp.bodies[0].body.record();
assert!(r.edges.iter().all(|e| e.tolerance >= 2e-4 && e.tolerance < 1e-3), "{:?}", r.edges.iter().map(|e| e.tolerance).collect::<Vec<_>>());
// A record tolerance is SMLib's intersection tolerance, twice the zone a gap must lie within,
// and SMLib checks a vertex's gaps against half its zone (#11997): with its vertices on the
// dome's corners and its edges bowed off between them, the body checks there (at 1.5 times
// the gap SMLib refused it, "Bad Gap"). Edges up to 1e-4 off keep within SMLib's 1000:1 to
// the body's 1e-6; vertices off their faces, or edges farther off, need the body's tolerance
// raised.
for dz in [2e-5, 1e-4] {
let imp = step_import(&dome_with_edges_bowed(dz)).unwrap();
crate::validation::check_kept(&imp.bodies[0].body).unwrap_or_else(|e| panic!("edges {dz} mm off: {e}"));
}
// Far off (1.5 mm) is refused, naming the gap.
let far = step_import(&dome_with_edges_raised(1.5));
let refused = match &far {
@@ -2250,6 +2280,110 @@ fn edges_written_off_their_spline_face_carry_their_gap_as_their_tolerance() {
assert!(refused, "{far:?}");
}
/// A file whose edges or vertices lie farther off their faces than its stated uncertainty allows
/// SMLib (#11997: a body written with 1e-6 whose intersection edges lie 5.6e-4 off a spline face).
/// SMLib checks each vertex's gap from its faces against the body's tolerance, and takes no edge
/// or vertex zone of 1000 times the body's: such a body was refused before any operation. The
/// body's tolerance now covers both, and the body checks.
#[test]
fn edges_farther_off_than_the_stated_uncertainty_allows_raise_the_bodys_tolerance() {
let tolerances = |imp: &crate::step_in::StepImport| {
let r = imp.bodies[0].body.record();
let widest = r.edges.iter().map(|e| e.tolerance).chain(r.vertices.iter().map(|v| v.tolerance)).fold(0.0, f64::max);
(r.tolerance, widest)
};
// Edges bowed up to 3.75e-4 mm off between vertices on the dome, 375 times the file's 1e-6:
// the body's tolerance keeps the widest edge or vertex within SMLib's ratio, no more.
let imp = step_import(&dome_with_edges_bowed(5e-4)).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
crate::validation::check_kept(&imp.bodies[0].body).unwrap();
let (body, widest) = tolerances(&imp);
assert!(body >= widest / 1000.0 && body <= widest / 100.0, "body {body}, widest {widest}");
assert!(imp.warnings.iter().any(|w| w.contains("the body's tolerance is")), "{:?}", imp.warnings);
// Edges and vertices 5e-4 mm off: the body's tolerance covers the vertices' gaps.
let imp = step_import(&dome_with_edges_raised(5e-4)).unwrap();
crate::validation::check_kept(&imp.bodies[0].body).unwrap();
let (body, _) = tolerances(&imp);
assert!((5e-4..=1.1e-3).contains(&body), "body {body}");
// Within what the stated uncertainty allows, the body keeps it.
let imp = step_import(&dome_with_edges_bowed(5e-5)).unwrap();
assert_eq!(tolerances(&imp).0, 1e-6, "{:?}", imp.warnings);
}
/// The dome of [`dome_with_edges_bowed`] whose four edges are polylines along its flat
/// boundary, 64 spans each, with one inner point `dz` mm above it: each edge bumps off the dome
/// over 1/32 of its length, peaking halfway between two of the points the reader
/// samples its gaps at (#12757).
fn dome_with_narrow_bumps(dz: f64) -> String {
let base = patch_with_edges(0.0, false, false);
let sides: [[(usize, usize); 2]; 4] = [[(0, 0), (3, 0)], [(3, 0), (3, 3)], [(3, 3), (0, 3)], [(0, 3), (0, 0)]];
let mut t = String::new();
for line in base.lines() {
// Each side's cubic, #400..#403, is replaced by its polyline (points from #1000).
let side = (0..4).find(|k| line.starts_with(&format!("#{}=B_SPLINE_CURVE_WITH_KNOTS", 400 + k)));
let Some(k) = side else {
t += line;
t += "\n";
continue;
};
let [(i0, j0), (i1, j1)] = sides[k];
let n = 65;
let mut ids = Vec::new();
for m in 0..n {
let f = m as f64 / (n - 1) as f64;
let (x, y) = ((i0 as f64 + (i1 as f64 - i0 as f64) * f) * 10.0, (j0 as f64 + (j1 as f64 - j0 as f64) * f) * 10.0);
let id = 1000 + k * 100 + m;
t += &format!("#{id}=CARTESIAN_POINT('',({x:.17E},{y:.17E},{:.17E}));\n", if m == 2 { dz } else { 0.0 });
ids.push(format!("#{id}"));
}
let knots: Vec<String> = (0..n).map(|m| format!("{:.17E}", m as f64 / (n - 1) as f64)).collect();
let mults: Vec<String> = (0..n).map(|m| if m == 0 || m == n - 1 { "2" } else { "1" }.to_string()).collect();
t += &format!(
"#{}=B_SPLINE_CURVE_WITH_KNOTS('',1,({}),.UNSPECIFIED.,.F.,.F.,({}),({}),.UNSPECIFIED.);\n",
400 + k,
ids.join(","),
mults.join(","),
knots.join(",")
);
}
t
}
#[test]
fn an_edges_gap_between_the_points_sampled_is_covered() {
// Each edge bumps 2e-5 mm off the dome, narrowly, between two of the reader's samples
// (#12757: 15 edges of an imported body lay up to 1.7 times farther off than sampled, and
// SMLib refused it, "Bad Gap"). Its tolerance covers the peak, and the body checks.
let imp = step_import(&dome_with_narrow_bumps(2e-5)).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let r = imp.bodies[0].body.record();
assert!(r.edges.iter().all(|e| e.tolerance >= 2e-5), "{:?}", r.edges.iter().map(|e| e.tolerance).collect::<Vec<_>>());
crate::validation::check_kept(&imp.bodies[0].body).unwrap_or_else(|e| panic!("{e}"));
}
/// A vertex's tolerance is six times its gap (#11997) but never past SMLib's 1 mm: the ends of
/// edges 0.2 mm off their face take 1 mm and are kept, as they were at 1.5 times the gap. (The
/// vertex 0.19 mm off its LINE of #3352 needs no raise now: the edge is read as the segment
/// between its vertices, and the part still reads.)
#[test]
fn raised_vertex_tolerances_stay_within_smlib_limit() {
let slipped = step_import(include_str!("../../../../tests/kernel-cases/import/3352-vertex-off-its-edge-curve/input.step")).unwrap();
assert_eq!(slipped.bodies.len(), 1, "{:?}", slipped.warnings);
let imp = step_import(&dome_with_edges_bowed(0.3)).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let r = imp.bodies[0].body.record();
let most = r.vertices.iter().map(|v| v.tolerance).chain(r.edges.iter().map(|e| e.tolerance)).fold(0.0, f64::max);
assert!(most > 0.2 && most <= 1.0, "bowed edges: {most}");
// Edges bowed about 0.6 mm off: three times the gap passes SMLib's 1 mm, so they take 1 mm and
// are kept, as they were at 1.5 times it (a NIST FTC 07 edge 0.64 mm off its face).
let far = step_import(&dome_with_edges_bowed(0.8)).unwrap();
assert_eq!(far.bodies.len(), 1, "{:?}", far.warnings);
assert!(!far.warnings.iter().any(|w| w.contains("left out") || w.contains("imported as surfaces")), "{:?}", far.warnings);
let r = far.bodies[0].body.record();
let most = r.edges.iter().map(|e| e.tolerance).fold(0.0, f64::max);
assert!(most > 0.5 && most <= 1.0, "edges bowed farther: {most}");
}
#[test]
fn spline_edges_off_their_plane_by_less_than_the_file_states_get_their_closed_form() {
// 0.8e-6 mm off, inside the file's 1e-6 mm but past the half of it a closed-form pcurve is
+10 -4
View File
@@ -66,6 +66,10 @@ fn trims(face: &TrimFace<'_>) -> Result<Vec<Vec<Curve2>>, String> {
Ok(result)
}
/// The least margin an edge's tolerance covers its gap with where [`SURFACE_GAP_MARGIN`] would
/// pass SMLib's limit: it takes the limit instead, as the STEP reader does.
const LEAST_GAP_MARGIN: f64 = 1.5;
/// Each bare use's edge measured against the face's surface as SMLib measures it (over the
/// surface's own domain: an edge run a few um past its patch measures how far), and its
/// `EDGE_TOLERANCE` in `arrays` raised to cover that gap with [`SURFACE_GAP_MARGIN`]: SMLib keeps
@@ -85,15 +89,17 @@ fn cover_gaps(body: &crate::Brep, face: &TrimFace<'_>, bare: &HashSet<(u32, u32,
let mut raised = false;
for e in edges {
let gap = body.edge_face_distance(e, 0).map_err(|e| e.to_string())?;
let need = SURFACE_GAP_MARGIN * gap;
// The margin up to SMLib's limit; refused only where even the least margin, the reader's
// (1.5), would pass it.
if !(LEAST_GAP_MARGIN * gap <= crate::flat::MAX_TOLERANCE) {
return Err(format!("an edge lies {gap} mm off the face's surface"));
}
let need = (SURFACE_GAP_MARGIN * gap).min(crate::flat::MAX_TOLERANCE);
let tolerances = arrays.reals.get_mut(&crate::flat::EDGE_TOLERANCE).ok_or("edge tolerances")?;
let given = tolerances.get_mut(e).ok_or("edge reference")?;
if need <= *given {
continue;
}
if !(need <= crate::flat::MAX_TOLERANCE) {
return Err(format!("an edge lies {gap} mm off the face's surface"));
}
*given = need;
raised = true;
}
+203 -45
View File
@@ -25,7 +25,9 @@
//! when the file has none, never below [`MIN_TOLERANCE`]) is the brep's tolerance and every
//! vertex's and edge's to start with. A vertex measurably off its curve or surface, or an edge
//! whose curve lies measurably off a face's surface, gets that distance (with margin) as its
//! tolerance, and the solid's notes say how many and how far.
//! tolerance, and the solid's notes say how many and how far. When those lie farther off than the
//! file's uncertainty allows SMLib (a file that understates it), the body's tolerance is raised to
//! cover them ([`BODY_TOLERANCE_RATIO`], [`BODY_VERTEX_GAP_MARGIN`]).
//!
//! Pcurves are the closed forms the 3D edges have on our surfaces ([`pcurve::exact_pcurves`]); a
//! use with none gets its pcurve from the kernel ([`read_solids_with`], SMLib's own trims). The
@@ -83,18 +85,54 @@ const MAX_SAMPLES: usize = 4096;
/// EDGE_TOLERANCE and VERTEX_TOLERANCE, `solidcraft_smlib::flat::MAX_TOLERANCE`). Geometry that
/// would need more is refused, with how far off it lies.
const MAX_TOLERANCE: f64 = 1.0;
/// A vertex's tolerance over its largest gap from its curves and surfaces.
const VERTEX_GAP_MARGIN: f64 = 1.5;
/// Intervals an edge's curve is measured over against each face's surface it bounds.
/// The most an edge's or vertex's tolerance may be over its body's. SMLib's check takes no edge
/// or vertex zone (half its record tolerance) of 1000 times the body's or more, and its trims
/// raise an edge's zone to no more than twice its gap, under its record tolerance: a body over
/// `t / 1000` holds an edge or vertex of tolerance `t`, and 250 keeps a margin of four. A body
/// whose edges lie farther off than its file's uncertainty allows (a file that understates it,
/// #11997) gets the tolerance that keeps them within SMLib's ratio.
const BODY_TOLERANCE_RATIO: f64 = 250.0;
/// A body's tolerance over the largest gap of a vertex from its faces: SMLib checks that gap
/// against the body's intersection tolerance, its record tolerance, whatever the vertex's own.
const BODY_VERTEX_GAP_MARGIN: f64 = 2.0;
/// A vertex's tolerance over its largest gap from its curves and surfaces: a record tolerance is
/// SMLib's intersection tolerance, twice the zone (the bridge halves it), and SMLib checks a
/// vertex's gaps against half its zone, so a vertex covers its gap with margin at twice an edge's.
const VERTEX_GAP_MARGIN: f64 = 2.0 * pcurve::SURFACE_GAP_MARGIN;
/// The least margin a vertex is kept with: one whose gap [`VERTEX_GAP_MARGIN`] would carry past
/// [`MAX_TOLERANCE`] takes that limit instead, and is refused only past this one, as before the
/// margin was raised (a 0.19 mm slip in a file of 0.01 mm, #3352).
const VERTEX_GAP_LEAST_MARGIN: f64 = 1.5;
/// The tolerance a vertex `gap` mm off its curves and surfaces takes: [`VERTEX_GAP_MARGIN`] times
/// it, at most SMLib's [`MAX_TOLERANCE`]; `None` when even [`VERTEX_GAP_LEAST_MARGIN`] would pass it.
fn vertex_tolerance(gap: f64) -> Option<f64> {
(VERTEX_GAP_LEAST_MARGIN * gap <= MAX_TOLERANCE).then(|| (VERTEX_GAP_MARGIN * gap).min(MAX_TOLERANCE))
}
/// Intervals an edge's curve is measured over against each face's surface it bounds, at the
/// least: each knot span of the edge's curve and pcurve is measured too ([`widest_gap`]).
const EDGE_GAP_SAMPLES: usize = 16;
/// The least span of a face's parameter box along a direction of its surface that is not
/// closed on itself, where its natural range has room ([`face_domain`]): 50 times the 2e-5 mm
/// within which SMLib takes two of a surface's sides for one (#12757: a planar sliver face
/// 1e-5 mm wide).
const OPEN_DOMAIN_SPAN: f64 = 1e-3;
/// An edge's tolerance over the largest gap measured between its pcurves and its curve, as the
/// record check measures it: with margin, as a vertex's.
const EDGE_GAP_MARGIN: f64 = 1.5;
/// Points measured inside each knot span between two breaks ([`widest_gap`]).
const EDGE_GAP_SPAN_SAMPLES: usize = 4;
/// Breaks past which an edge's spans are measured at evenly spaced points instead ([`widest_gap`]).
const EDGE_GAP_MAX_BREAKS: usize = 1024;
/// Local maxima of the measured gaps refined to the gap's peak ([`widest_gap`]).
const EDGE_GAP_REFINED: usize = 4;
/// Golden-section steps refining a local maximum: its bracket shrinks to 0.618^24 = 1e-5 of
/// the span around it.
const EDGE_GAP_REFINE_STEPS: usize = 24;
/// An edge's tolerance over the largest gap measured between its pcurves and its curve: SMLib's
/// zone (half the record's) covers the gap with margin, as a surface's.
const EDGE_GAP_MARGIN: f64 = pcurve::SURFACE_GAP_MARGIN;
/// The least margin an edge is kept with: one whose gap its margin would carry past
/// [`MAX_TOLERANCE`] takes that limit instead, and is refused only past this one, as before the
/// margin was raised (a NIST FTC 07 edge 0.64 mm off its face, covered at 0.96 mm before).
const EDGE_GAP_LEAST_MARGIN: f64 = 1.5;
/// A solid (or sheet) read from the file.
#[derive(Clone, Debug)]
pub struct Solid {
@@ -605,8 +643,11 @@ struct Builder<'b, 'a> {
lines_between_vertices: usize,
/// Inner loops written running the way the outer loop runs, run the other way.
inner_loops_turned: usize,
/// Edges whose tolerance was raised to the gap their curve has to a face's surface.
/// Edges whose tolerance was raised to a gap of the file's own, or past its uncertainty,
/// that their curve has to a face's surface.
raised_edges: HashSet<u32>,
/// The largest gap of a raised edge.
widest_gap: f64,
/// The line edges of POLY_LOOPs, by their two CARTESIAN_POINTs (lower id first): faces that
/// share a side share its edge, as a faceted B-rep's do.
poly_edges: HashMap<(u64, u64), u32>,
@@ -630,6 +671,7 @@ impl<'b, 'a> Builder<'b, 'a> {
lines_between_vertices: 0,
inner_loops_turned: 0,
raised_edges: HashSet::new(),
widest_gap: 0.0,
poly_edges: HashMap::new(),
}
}
@@ -656,11 +698,11 @@ impl<'b, 'a> Builder<'b, 'a> {
/// A vertex `gap` mm from where its edge's curve puts it: refused if far off, else its
/// tolerance covers the gap.
fn raise_vertex(&mut self, v: u32, gap: f64, edge: u64) -> R<()> {
if !(VERTEX_GAP_MARGIN * gap <= MAX_TOLERANCE) {
let Some(tolerance) = vertex_tolerance(gap) else {
return Err(format!("edge #{edge}: a vertex lies {gap} mm off its curve"));
}
};
if let Some(x) = self.vertices.get_mut(v as usize) {
x.tolerance = x.tolerance.max(VERTEX_GAP_MARGIN * gap);
x.tolerance = x.tolerance.max(tolerance);
}
Ok(())
}
@@ -699,33 +741,43 @@ impl<'b, 'a> Builder<'b, 'a> {
None => (&edge.curve, edge.interval),
};
let eval = CurveEval::new(curve).map_err(|m| m.to_string())?;
let mut gap = 0.0f64;
for k in 0..=EDGE_GAP_SAMPLES {
let t = if k == EDGE_GAP_SAMPLES { t1 } else { t0 + (t1 - t0) * (k as f64 / EDGE_GAP_SAMPLES as f64) };
let Some(p) = eval.point(t) else { continue };
let gap = widest_gap([t0, t1], &[curve_breaks(curve)], |t| {
let p = eval.point(t)?;
// A point with no nearest point is the pcurves' to refuse, with why.
let Some(s) = on.project_with(&starts, p, None, f64::INFINITY).and_then(|q| on.point(q)) else { continue };
gap = gap.max(s.dist(p));
}
self.cover_edge(e, gap, pcurve::SURFACE_GAP_MARGIN)?;
let s = on.project_with(&starts, p, None, f64::INFINITY).and_then(|q| on.point(q))?;
Some(s.dist(p))
});
self.cover_edge(e, gap, pcurve::SURFACE_GAP_MARGIN, true)?;
}
}
Ok(())
}
/// Edge `e`'s tolerance covers `gap` with `margin`: refused if far off.
fn cover_edge(&mut self, e: u32, gap: f64, margin: f64) -> R<()> {
/// Edge `e`'s tolerance covers `gap` with `margin`: refused if far off. The solid's notes
/// count it when the gap is the file's own (`own`: its curve off its face's surface) or past
/// the file's uncertainty; a gap within it to a pcurve the reader made is covered unnoted.
fn cover_edge(&mut self, e: u32, gap: f64, margin: f64, own: bool) -> R<()> {
let Some(x) = self.edges.get_mut(e as usize) else { return Err("edge reference".into()) };
if margin * gap <= x.tolerance {
return Ok(());
}
if !(margin * gap <= MAX_TOLERANCE) {
if !(EDGE_GAP_LEAST_MARGIN * gap <= MAX_TOLERANCE) {
return Err(format!("an edge lies {gap} mm off the face's surface"));
}
x.tolerance = margin * gap;
let (tolerance, ends) = (x.tolerance, x.vertices);
self.raised_edges.insert(e);
// A vertex's tolerance covers every edge that meets it, as in any tolerant record.
let wanted = (margin * gap).min(MAX_TOLERANCE);
if wanted <= x.tolerance {
return Ok(());
}
x.tolerance = wanted;
let ends = x.vertices;
// A gap within the file's uncertainty to a pcurve this reader made is that pcurve
// wandering between its knots, not the file's: covered, but not the user's to hear
// about (#12757).
if own || gap > self.tol {
self.raised_edges.insert(e);
self.widest_gap = self.widest_gap.max(gap);
}
// A vertex's tolerance covers every edge that meets it, as in any tolerant record: its
// gap from the face at the edge's end is the edge's there, checked as a vertex's (an edge
// kept at its margin is within a vertex's least one, so this is never refused).
let tolerance = vertex_tolerance(gap).unwrap_or(MAX_TOLERANCE);
for v in ends {
if let Some(vx) = self.vertices.get_mut(v as usize) {
vx.tolerance = vx.tolerance.max(tolerance);
@@ -749,16 +801,16 @@ impl<'b, 'a> Builder<'b, 'a> {
None => (&edge.curve, edge.interval),
};
let eval = CurveEval::new(curve).map_err(|m| m.to_string())?;
let mut gap = 0.0f64;
for k in 0..=EDGE_GAP_SAMPLES {
let t = if k == EDGE_GAP_SAMPLES { t1 } else { t0 + (t1 - t0) * (k as f64 / EDGE_GAP_SAMPLES as f64) };
let pc_knots = match c {
Curve2::Spline { knots, .. } | Curve2::RationalSpline { knots, .. } => knots.as_slice(),
};
let gap = widest_gap([t0, t1], &[curve_breaks(curve), pc_knots], |t| {
// A point a pcurve or surface lacks is the record check's to name.
let (Some(q), Some(p)) = (solidcore_ops::pcurve::eval(c, t), eval.point(t)) else { continue };
if let Some(s) = on.point(q) {
gap = gap.max(s.dist(p));
}
}
self.cover_edge(e, gap, EDGE_GAP_MARGIN)?;
let q = solidcore_ops::pcurve::eval(c, t)?;
let p = eval.point(t)?;
Some(on.point(q)?.dist(p))
});
self.cover_edge(e, gap, EDGE_GAP_MARGIN, false)?;
}
}
Ok(())
@@ -1271,11 +1323,11 @@ impl<'b, 'a> Builder<'b, 'a> {
return Err(format!("pcurves failed on {failed} of {} faces; first: {first}", self.faces.len()));
}
if !self.raised_edges.is_empty() {
let most = self.raised_edges.iter().filter_map(|&e| self.edges.get(e as usize)).map(|e| e.tolerance).fold(0.0, f64::max);
notes.push(format!(
"{} edges lie off their faces' surfaces by more than {} mm: their tolerances are the gaps they have (up to {most:.3e} mm)",
"{} edges lie off their faces' surfaces farther than {} mm covers: their tolerances cover the gaps they have (up to {:.3e} mm)",
self.raised_edges.len(),
self.tol
self.tol,
self.widest_gap
));
}
let flipped = turned.iter().filter(|t| **t).count();
@@ -1458,14 +1510,49 @@ impl<'b, 'a> Builder<'b, 'a> {
{
let p = v3(x.point);
let gap = surface.project(p, None, f64::INFINITY).and_then(|q| surface.point(q)).map_or(f64::INFINITY, |s| s.dist(p));
if !(VERTEX_GAP_MARGIN * gap <= MAX_TOLERANCE) {
let Some(tolerance) = vertex_tolerance(gap) else {
return Err(format!("face #{}: a vertex loop's vertex lies {gap} mm off its surface", face.step));
}
x.tolerance = x.tolerance.max(VERTEX_GAP_MARGIN * gap);
};
x.tolerance = x.tolerance.max(tolerance);
}
}
}
// The body's tolerance covers its raised vertices' gaps from their faces and keeps its
// edges' and vertices' tolerances within SMLib's ratio (#11997).
let mut vertex_gap = 0.0f64;
for &f in &face_order {
let Some(face) = self.faces.get(f as usize) else { continue };
let mut raised = Vec::new();
for l in &face.loops {
match l {
LoopB::Vertex(v) => raised.push(*v),
LoopB::Edges(es) => raised.extend(es.iter().filter_map(|&(e, _)| self.edges.get(e as usize)).flat_map(|x| x.vertices)),
}
}
raised.retain(|&v| self.vertices.get(v as usize).is_some_and(|x| x.tolerance > self.tol));
if raised.is_empty() {
continue;
}
let surface = SurfaceEval::new(&face.surface).map_err(|m| m.to_string())?;
for v in raised {
let Some(x) = self.vertices.get(v as usize) else { continue };
let p = v3(x.point);
// A vertex the surface does not take is the record check's to name.
if let Some(s) = surface.project(p, None, f64::INFINITY).and_then(|q| surface.point(q)) {
vertex_gap = vertex_gap.max(s.dist(p));
}
}
}
let widest = self.edges.iter().map(|e| e.tolerance).chain(self.vertices.iter().map(|v| v.tolerance)).fold(0.0, f64::max);
let tolerance = self.tol.max(widest / BODY_TOLERANCE_RATIO).max(BODY_VERTEX_GAP_MARGIN * vertex_gap);
if tolerance > self.tol {
notes.push(format!(
"the file states its geometry within {} mm, but its edges and vertices need tolerances up to {widest:.3e} mm and its vertices lie up to {vertex_gap:.3e} mm off their faces: the body's tolerance is {tolerance:.3e} mm",
self.tol
));
}
let mut vertices = Vec::with_capacity(vertex_order.len());
for &v in &vertex_order {
vertices.push(self.vertices.get(v as usize).cloned().ok_or("vertex reference")?);
@@ -1531,7 +1618,7 @@ impl<'b, 'a> Builder<'b, 'a> {
}
out_shells.push(Shell::Faces { faces, wires: Vec::new() });
}
let brep = Brep { version: brep::VERSION, tolerance: self.tol, regions, shells: out_shells, faces, edges, vertices };
let brep = Brep { version: brep::VERSION, tolerance, regions, shells: out_shells, faces, edges, vertices };
solidcore_ops::record_check::validate(&brep).map_err(|e| format!("the record is invalid: {e}"))?;
solidcore_ops::record_check::agreement(&brep).map_err(|e| format!("the record's geometry disagrees: {e}"))?;
Ok(Solid { item, name, product: None, brep, face_origin, sheet, notes, turned: turned_faces })
@@ -1882,6 +1969,77 @@ fn loop_area(l: &[(u32, bool)], pcs: &[Curve2], periodic: [bool; 2]) -> Option<f
(area.abs() > 1e-9 * magnitude && magnitude > 0.0).then_some(0.5 * area)
}
/// The knots of a spline edge curve, where its shape can change; none for a curve in closed form.
fn curve_breaks(c: &BCurve) -> &[f64] {
match c {
BCurve::Spline { knots, .. } | BCurve::RationalSpline { knots, .. } => knots,
_ => &[],
}
}
/// The widest of `gap(t)` over `[t0, t1]`, as SMLib's check finds it: measured at
/// [`EDGE_GAP_SAMPLES`] even intervals and at [`EDGE_GAP_SPAN_SAMPLES`] points inside each span
/// between the `breaks` (knots of the edge's curve and pcurve, where a gap can rise and fall
/// between two even samples: #12757's edges lay up to 1.7 times farther off than 16 samples
/// found), then its [`EDGE_GAP_REFINED`] largest local maxima refined by golden section. A `t`
/// where `gap` has no value is skipped.
fn widest_gap(interval: [f64; 2], breaks: &[&[f64]], gap: impl Fn(f64) -> Option<f64>) -> f64 {
let [t0, t1] = interval;
if !(t0 < t1) {
return gap(t0).unwrap_or(0.0);
}
let mut cuts: Vec<f64> = (0..=EDGE_GAP_SAMPLES).map(|k| t0 + (t1 - t0) * (k as f64 / EDGE_GAP_SAMPLES as f64)).collect();
let inner: Vec<f64> = breaks.iter().flat_map(|b| b.iter().copied()).filter(|t| *t > t0 && *t < t1).collect();
if inner.len() <= EDGE_GAP_MAX_BREAKS {
cuts.extend(inner);
} else {
cuts.extend((1..EDGE_GAP_MAX_BREAKS).map(|k| t0 + (t1 - t0) * (k as f64 / EDGE_GAP_MAX_BREAKS as f64)));
}
cuts.sort_by(f64::total_cmp);
cuts.dedup();
let mut ts = Vec::new();
for w in cuts.windows(2) {
let [a, b] = [w.first().copied().unwrap_or(t0), w.get(1).copied().unwrap_or(t1)];
ts.extend((0..=EDGE_GAP_SPAN_SAMPLES).map(|k| a + (b - a) * (k as f64 / (EDGE_GAP_SPAN_SAMPLES + 1) as f64)));
}
ts.push(t1);
let samples: Vec<(f64, f64)> = ts.iter().filter_map(|&t| gap(t).map(|g| (t, g))).collect();
let mut widest = samples.iter().fold(0.0f64, |m, s| m.max(s.1));
// Local maxima, largest first, each refined between its neighbours.
let g_at = |i: usize| samples.get(i).map_or(0.0, |s| s.1);
let t_at = |i: usize| samples.get(i).map_or(t0, |s| s.0);
let mut peaks: Vec<usize> = (0..samples.len())
.filter(|&i| {
let g = g_at(i);
g > 0.0 && (i == 0 || g_at(i - 1) <= g) && samples.get(i + 1).is_none_or(|n| n.1 <= g)
})
.collect();
peaks.sort_by(|a, b| g_at(*b).total_cmp(&g_at(*a)));
const PHI: f64 = 0.618_033_988_749_894_9;
for &i in peaks.iter().take(EDGE_GAP_REFINED) {
let mut a = if i == 0 { t_at(i) } else { t_at(i - 1) };
let mut b = samples.get(i + 1).map_or(t_at(i), |n| n.0);
let at = |t: f64| gap(t).unwrap_or(0.0);
let (mut c, mut d) = (b - PHI * (b - a), a + PHI * (b - a));
let (mut gc, mut gd) = (at(c), at(d));
for _ in 0..EDGE_GAP_REFINE_STEPS {
if gc >= gd {
b = d;
(d, gd) = (c, gc);
c = b - PHI * (b - a);
gc = at(c);
} else {
a = c;
(c, gc) = (d, gd);
d = a + PHI * (b - a);
gd = at(d);
}
}
widest = widest.max(gc).max(gd);
}
widest
}
/// The parameter box around every pcurve's control points, padded a little, within the
/// surface's natural domain (at most one turn where periodic).
fn face_domain(surface: &BSurface, pcs: &[Vec<Curve2>]) -> R<[[f64; 2]; 2]> {