STEP import: 65/66 Fusion files; bounded meshing

Meshing (vendored truck-meshalgo) works within a budget so hostile files
cannot hang: bounded edge and surface divisions (point caps, depth and
round limits, NaN-safe), and a grid budget per face shared out per shell.
Surface grids refine only the direction that bends; rulings left undivided
get cells comparable to the other direction, so trimmed cylinders (holes,
tee joints) no longer lose volume to long triangles. The helical sweep
imports in 12 s instead of 7 minutes.

Small sphere patches get poles away from them even when the file runs a
seam through the patch (a pyramid's corner blend). Faces the mesher cannot
mesh are reported as import warnings. Test: wild B-spline and far-off
boundaries import in bounded time.

STEP corpus 65/66 (50-sweep-tight-bend: a horn-torus bend whose inner side
collapses to a point; Fusion's own area for it is also off).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 01:28:16 +00:00
1 parent 7a0cc85392
commit 1c10d1e56a
7 files changed
+359 -76

No files matched your search

+15
View File
@@ -291,6 +291,21 @@ impl Body {
})
}
/// Faces with no triangles in a coarse tessellation (the mesher failed or gave up).
pub fn unmeshed_faces(&self) -> usize {
if self.is_mesh() {
return 0;
}
let Ok(m) = self.tessellate((self.size() * 1e-2).max(1e-3)) else { return self.face_count() };
let mut has = vec![false; self.face_count()];
for f in &m.tri_face {
if let Some(h) = has.get_mut(*f as usize) {
*h = true;
}
}
has.iter().filter(|h| !**h).count()
}
/// Display mesh: like [`Body::tessellate`], with seam edges flagged.
pub fn display_mesh(&self, tol: f64) -> Result<Mesh> {
let mut m = self.tessellate(tol)?;
+21 -1
View File
@@ -566,6 +566,23 @@ fn seam_frame(f: &Frame, extent: &[mt::Point3], seam: &[mt::Point3]) -> Frame {
}
}
/// Length of the mean unit direction from the frame origin to the points (1: all in one
/// direction, 0: spread all round).
fn mean_direction(f: &Frame, pts: &[mt::Point3]) -> f64 {
let dirs: Vec<mt::Vector3> = pts
.iter()
.filter_map(|p| {
let d = p - f.o;
let m = d.magnitude();
(m > 1e-12).then(|| d / m)
})
.collect();
if dirs.is_empty() {
return 0.0;
}
(dirs.iter().fold(mt::Vector3::new(0.0, 0.0, 0.0), |a, d| a + d) / dirs.len() as f64).magnitude()
}
/// Polar axis for a sphere: far from every boundary point, and (for a face concentrated on
/// one side) with both poles outside the face, so every loop closes in parameter space.
fn sphere_axis(f: &Frame, extent: &[mt::Point3]) -> mt::Vector3 {
@@ -810,7 +827,10 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
// With seam edges the file's own parameterisation fits the loops; else pick poles
// away from the boundary.
let f = if seam.is_empty() {
// A small patch (a corner blend) gets poles away from it even when the file runs a
// seam through it: a seam ending at a pole on the patch boundary leaves the mesher
// no way to tell the patch from the rest of the sphere.
let f = if seam.is_empty() || mean_direction(&f, extent) > 0.3 {
let z = sphere_axis(&f, extent);
let x = any_perpendicular(z);
seam_frame(&Frame { o: f.o, x, y: z.cross(x), z }, extent, seam)
+10
View File
@@ -233,6 +233,8 @@ struct Reader<'a> {
node_items: HashSet<u64>,
/// Occurrences and mapped representations visited so far.
visits: usize,
/// Items whose meshing was checked.
unmeshed_checked: HashSet<u64>,
bodies: Vec<ImportedBody>,
}
@@ -454,6 +456,13 @@ impl<'a> Reader<'a> {
Ok(body) => {
let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string();
let name = if own.is_empty() { product.to_string() } else { own };
// Faces the mesher gives up on (or that the budget cut short) are reported.
if self.unmeshed_checked.insert(item) {
let n = body.unmeshed_faces();
if n > 0 {
self.warn(format!("solid '{name}': {n} face(s) could not be meshed and are not shown"));
}
}
out.push(self.bodies.len());
let color = self.colors.get(&item).copied();
self.bodies.push(ImportedBody { name, body: body.with_color(color), color, path: path.to_vec(), closed, file_faces });
@@ -682,6 +691,7 @@ pub fn step_import(text: &str) -> Result<StepImport> {
used_items: HashSet::new(),
node_items: HashSet::new(),
visits: 0,
unmeshed_checked: HashSet::new(),
bodies: Vec::new(),
};
rd.index();
+67
View File
@@ -367,3 +367,70 @@ fn mirrored_revolved_faces_round_trip() {
round_trip(&boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap());
}
}
/// Faces whose boundaries are nowhere near their surfaces, or wild B-spline surfaces, mesh
/// within bounded work (the mesher once looped for ever on such input) and are reported if
/// they cannot be meshed.
#[test]
fn hostile_boundaries_mesh_in_bounded_time() {
let t0 = std::time::Instant::now();
// A wild 24 × 24 B-spline patch (alternating ±50 mm) bounded by four straight edges that
// lie far from it, plus a plane face bounded by a circle 500 mm off the plane.
let n = 24;
let mut t = String::from(HEAD);
let mut rows = Vec::new();
for i in 0..n {
let mut row = Vec::new();
for j in 0..n {
let id = 1000 + i * n + j;
let z = if (i * 7 + j * 13) % 2 == 0 { 50.0 } else { -50.0 };
t += &format!("#{id}=CARTESIAN_POINT('',({}.,{}.,{z}));\n", i * 10, j * 10);
row.push(format!("#{id}"));
}
rows.push(format!("({})", row.join(",")));
}
let knots = |m: usize| {
let mut mult = vec!["4".to_string()];
mult.extend(std::iter::repeat_n("1".to_string(), m - 4));
mult.push("4".into());
let ks: Vec<String> = (0..=(m - 3)).map(|k| format!("{k}.")).collect();
(mult.join(","), ks.join(","))
};
let (mu, ku) = knots(n);
t += &format!(
"#10=B_SPLINE_SURFACE_WITH_KNOTS('',3,3,({}),.UNSPECIFIED.,.F.,.F.,.F.,({mu}),({mu}),({ku}),({ku}),.UNSPECIFIED.);\n",
rows.join(",")
);
let corners = [(0, 0), (390, 0), (390, 390), (0, 390)];
for (k, (x, y)) in corners.iter().enumerate() {
t += &format!("#{}=CARTESIAN_POINT('',({x}.,{y}.,300.));\n#{}=VERTEX_POINT('',#{});\n", 20 + k, 30 + k, 20 + k);
}
t += "#40=DIRECTION('',(1.,0.,0.));\n#41=VECTOR('',#40,1.);\n";
for k in 0..4 {
t += &format!(
"#{}=LINE('',#{},#41);\n#{}=EDGE_CURVE('',#{},#{},#{},.T.);\n#{}=ORIENTED_EDGE('',*,*,#{},.T.);\n",
50 + k,
20 + k,
60 + k,
30 + k,
30 + (k + 1) % 4,
50 + k,
70 + k,
60 + k
);
}
t += "#80=EDGE_LOOP('',(#70,#71,#72,#73));\n#81=FACE_OUTER_BOUND('',#80,.T.);\n#82=ADVANCED_FACE('',(#81),#10,.T.);\n";
t += "#90=CARTESIAN_POINT('',(0.,0.,0.));\n#91=DIRECTION('',(0.,0.,1.));\n#92=AXIS2_PLACEMENT_3D('',#90,#91,#40);\n#93=PLANE('',#92);\n";
t += "#94=CARTESIAN_POINT('',(0.,0.,500.));\n#95=AXIS2_PLACEMENT_3D('',#94,#91,#40);\n#96=CIRCLE('',#95,30.);\n#97=CARTESIAN_POINT('',(30.,0.,500.));\n#98=VERTEX_POINT('',#97);\n";
t += "#99=EDGE_CURVE('',#98,#98,#96,.T.);\n#100=ORIENTED_EDGE('',*,*,#99,.T.);\n#101=EDGE_LOOP('',(#100));\n#102=FACE_OUTER_BOUND('',#101,.T.);\n#103=ADVANCED_FACE('',(#102),#93,.T.);\n";
t += "#110=OPEN_SHELL('',(#82,#103));\n#111=SHELL_BASED_SURFACE_MODEL('Wild',(#110));\n#112=SHAPE_REPRESENTATION('',(#111),$);\n";
t += TAIL;
let r = std::panic::catch_unwind(|| step_import(&t));
assert!(r.is_ok(), "panicked");
if let Ok(Ok(imp)) = r {
for b in &imp.bodies {
let _ = measure(&b.body);
}
}
assert!(t0.elapsed().as_secs_f64() < 60.0, "took {:?}", t0.elapsed());
}