STEP import: a ball bounded only by its seam comes in whole (#1742)

Thanks @onembyte.
This commit is contained in:
franco michetti authored and GitHub committed 2026-10-17 06:46:47 -04:00
1 parent 6bde126a5c
commit fbc00bb2c4
3 files changed
+226 -44

No files matched your search

+49 -44
View File
@@ -2063,6 +2063,15 @@ pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Resu
/// [`sphere_patches_rotated`], every patch swept from profile segments `arcs`.
fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Result<Body> {
guard("sphere", || {
let shell: mt::Shell = sphere_patch_faces(center, radius, rot)?.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?;
record::attach(Body::new(solid)?, &[], Some(vec![record::profile(arcs); 6]), Default::default())
})
}
/// The six outward patch faces of [`sphere_patches_rotated`]; truck calls, so callers guard them.
pub(crate) fn sphere_patch_faces(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Result<Vec<mt::Face>> {
let radius = finite(radius, "radius")?;
let turn = |v: Vec3| rot[0] * v.x + rot[1] * v.y + rot[2] * v.z;
if radius <= 1e-6 || !center.is_finite() {
@@ -2080,50 +2089,46 @@ fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Re
(Vec3::Z, Vec3::X, [[-1.0, -1.0, 1.0], [1.0, -1.0, 1.0], [1.0, 1.0, 1.0], [-1.0, 1.0, 1.0]]),
(-Vec3::Z, Vec3::X, [[-1.0, 1.0, -1.0], [1.0, 1.0, -1.0], [1.0, -1.0, -1.0], [-1.0, -1.0, -1.0]]),
];
guard("sphere", || {
let key = |s: [f64; 3]| ((s[0] > 0.0) as usize) | (((s[1] > 0.0) as usize) << 1) | (((s[2] > 0.0) as usize) << 2);
let mut verts: std::collections::HashMap<usize, mt::Vertex> = std::collections::HashMap::new();
let mut vert = |s: [f64; 3]| verts.entry(key(s)).or_insert_with(|| builder::vertex(p3(corner(s[0], s[1], s[2])))).clone();
let mut edges: std::collections::HashMap<(usize, usize), mt::Edge> = std::collections::HashMap::new();
let mut out: Vec<mt::Face> = Vec::new();
for (dir, axis, cs) in faces {
let (dir, axis) = (turn(dir), turn(axis));
let mut wire: Vec<mt::Edge> = Vec::new();
for i in 0..4 {
let (a, b) = (cs[i], cs[(i + 1) % 4]);
let (ka, kb) = (key(a), key(b));
let e = match edges.get(&(ka.min(kb), ka.max(kb))) {
Some(e) => e.clone(),
None => {
let (lo, hi) = if ka < kb { (a, b) } else { (b, a) };
let (va, vb) = (vert(lo), vert(hi));
let mid = (corner(lo[0], lo[1], lo[2]) + corner(hi[0], hi[1], hi[2])) * 0.5 - center;
let transit = center + mid.normalized().unwrap_or(dir) * radius;
let e = builder::circle_arc(&va, &vb, p3(transit));
edges.insert((ka.min(kb), ka.max(kb)), e.clone());
e
}
};
wire.push(if ka < kb { e } else { e.inverse() });
}
// The meridian runs pole to pole through the side away from the patch.
let (np, sp, back) = (center + axis * radius, center - axis * radius, center - dir * radius);
let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back));
let mut surface =
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(center), v3(axis))));
// Outward normal at the patch centre.
let probe = p3(center + dir * radius);
use mt::{ParametricSurface3D, SearchParameter};
if let Some((u, v)) = surface.search_parameter(probe, mt::SPHint2D::None, 100) {
let n = surface.normal(u, v);
if mt::InnerSpace::dot(n, v3(dir)) < 0.0 {
mt::Invertible::invert(&mut surface);
let key = |s: [f64; 3]| ((s[0] > 0.0) as usize) | (((s[1] > 0.0) as usize) << 1) | (((s[2] > 0.0) as usize) << 2);
let mut verts: std::collections::HashMap<usize, mt::Vertex> = std::collections::HashMap::new();
let mut vert = |s: [f64; 3]| verts.entry(key(s)).or_insert_with(|| builder::vertex(p3(corner(s[0], s[1], s[2])))).clone();
let mut edges: std::collections::HashMap<(usize, usize), mt::Edge> = std::collections::HashMap::new();
let mut out: Vec<mt::Face> = Vec::new();
for (dir, axis, cs) in faces {
let (dir, axis) = (turn(dir), turn(axis));
let mut wire: Vec<mt::Edge> = Vec::new();
for i in 0..4 {
let (a, b) = (cs[i], cs[(i + 1) % 4]);
let (ka, kb) = (key(a), key(b));
let e = match edges.get(&(ka.min(kb), ka.max(kb))) {
Some(e) => e.clone(),
None => {
let (lo, hi) = if ka < kb { (a, b) } else { (b, a) };
let (va, vb) = (vert(lo), vert(hi));
let mid = (corner(lo[0], lo[1], lo[2]) + corner(hi[0], hi[1], hi[2])) * 0.5 - center;
let transit = center + mid.normalized().unwrap_or(dir) * radius;
let e = builder::circle_arc(&va, &vb, p3(transit));
edges.insert((ka.min(kb), ka.max(kb)), e.clone());
e
}
}
out.push(mt::Face::new(vec![wire.into()], surface));
};
wire.push(if ka < kb { e } else { e.inverse() });
}
let shell: mt::Shell = out.into();
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?;
record::attach(Body::new(solid)?, &[], Some(vec![record::profile(arcs); 6]), Default::default())
})
// The meridian runs pole to pole through the side away from the patch.
let (np, sp, back) = (center + axis * radius, center - axis * radius, center - dir * radius);
let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back));
let mut surface =
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(center), v3(axis))));
// Outward normal at the patch centre.
let probe = p3(center + dir * radius);
use mt::{ParametricSurface3D, SearchParameter};
if let Some((u, v)) = surface.search_parameter(probe, mt::SPHint2D::None, 100) {
let n = surface.normal(u, v);
if mt::InnerSpace::dot(n, v3(dir)) < 0.0 {
mt::Invertible::invert(&mut surface);
}
}
out.push(mt::Face::new(vec![wire.into()], surface));
}
Ok(out)
}
+66
View File
@@ -339,11 +339,77 @@ impl Builder {
return Err("no face could be imported".into());
}
let read = out.len();
let ball = match (out.as_slice(), origin.as_slice()) {
([face], [file_face]) => whole_sphere(cx, *file_face, face).map(|patches| (*file_face, patches)),
_ => None,
};
if let Some((file_face, patches)) = ball {
origin = vec![file_face; patches.len()];
out = patches;
}
let out = super::split::split_bands(out, &mut origin, failed);
Ok((out.into(), closed, read, origin))
}
}
/// A ball written as one face on a sphere bounded only by its seam, run out and back from pole
/// to pole. Truck meshes a face on a revolved surface in parameter space, where that loop
/// encloses nothing, so the ball is built as the kernel's own sphere instead: six patches with
/// no pole or seam inside them (`build::sphere_patch_faces`), turned inside out for a void.
/// `None` for any other face.
fn whole_sphere(cx: &Ctx, file_face: u64, face: &mt::Face) -> Option<Vec<mt::Face>> {
use mt::{ParametricSurface3D, SearchParameter};
let surface = entity(cx, file_face).ok()?.params().get(2).and_then(Param::as_ref_id)?;
if entity(cx, surface).ok()?.name() != "SPHERICAL_SURFACE" {
return None;
}
let wires = face.boundaries();
let edges: Vec<&mt::Edge> = wires.iter().flat_map(|w| w.edge_iter()).collect();
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
let mut poles: Vec<mt::Vertex> = Vec::new();
for e in &edges {
*uses.entry(e.id()).or_default() += 1;
for v in [e.front(), e.back()] {
if !poles.iter().any(|p| p.id() == v.id()) {
poles.push(v.clone());
}
}
}
// Every edge a seam (used once each way), and the two vertices it joins are the poles.
if uses.is_empty() || uses.values().any(|n| *n != 2) {
return None;
}
let [a, b] = poles.as_slice() else { return None };
let (a, b) = (a.point(), b.point());
let center = a + (b - a) * 0.5;
let radius = a.distance(b) * 0.5;
let tol = cx.tol.max(radius * 1e-6);
// The seam is a meridian: all of it on the sphere whose diameter joins the poles.
let samples = |e: &&mt::Edge| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
(0..=8).map(move |i| c.subs(t0 + (t1 - t0) * i as f64 / 8.0))
};
if !(radius.is_finite() && radius > tol) || !edges.iter().flat_map(samples).all(|p| (p.distance(center) - radius).abs() <= tol) {
return None;
}
// Ball or void: the face's normal at the middle of the seam, against the way out of the centre.
let probe = edges.first().and_then(|e| samples(e).nth(4))?;
let oriented = face.oriented_surface();
let (u, v) = oriented.search_parameter(probe, mt::SPHint2D::None, 100)?;
let outward = oriented.normal(u, v).dot(probe - center) > 0.0;
let z = (crate::body::from_p3(a) - crate::body::from_p3(b)).normalized()?;
let x = z.any_perp();
let c = crate::body::from_p3(center);
let mut patches = guard("step sphere", || crate::build::sphere_patch_faces(c, radius, [x, z.cross(x), z])).ok()?;
if !outward {
for f in &mut patches {
f.invert();
}
}
Some(patches)
}
/// Read a solid-like representation item: MANIFOLD_SOLID_BREP, BREP_WITH_VOIDS, FACETED_BREP,
/// or a SHELL_BASED_SURFACE_MODEL (closed shells give solids, open ones open bodies).
pub(crate) fn read_item(cx: &Ctx, id: u64) -> R<BrepOut> {
+111
View File
@@ -7,6 +7,117 @@ fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
/// A single spherical face bounded by one meridian edge used in both directions.
/// `end_angle` is measured from the north pole toward the seam; PI is the south pole.
fn seam_sphere_step(center: Vec3, radius: f64, axis: Vec3, reference: Vec3, azimuth: f64, reverse_edge: bool, end_angle: f64) -> String {
let seam = reference * azimuth.cos() + axis.cross(reference) * azimuth.sin();
let end = center + (axis * end_angle.cos() + seam * end_angle.sin()) * radius;
let north = center + axis * radius;
let circle_axis = axis.cross(seam);
let point = |p: Vec3| format!("({:.16},{:.16},{:.16})", p.x, p.y, p.z);
let (start, finish, sense, first, second) = if reverse_edge { (9, 7, "F", 15, 14) } else { (7, 9, "T", 14, 15) };
format!(
"{HEAD}#1=CARTESIAN_POINT('',{});\n#2=DIRECTION('',{});\n#3=DIRECTION('',{});\n#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n#5=SPHERICAL_SURFACE('',#4,{radius:.16});\n#6=CARTESIAN_POINT('',{});\n#7=VERTEX_POINT('',#6);\n#8=CARTESIAN_POINT('',{});\n#9=VERTEX_POINT('',#8);\n#10=DIRECTION('',{});\n#11=AXIS2_PLACEMENT_3D('',#1,#10,#12);\n#12=DIRECTION('',{});\n#13=CIRCLE('',#11,{radius:.16});\n#16=EDGE_CURVE('',#{start},#{finish},#13,.{sense}.);\n#14=ORIENTED_EDGE('',*,*,#16,.T.);\n#15=ORIENTED_EDGE('',*,*,#16,.F.);\n#17=EDGE_LOOP('',(#{first},#{second}));\n#18=FACE_OUTER_BOUND('',#17,.T.);\n#19=ADVANCED_FACE('',(#18),#5,.T.);\n#20=CLOSED_SHELL('',(#19));\n#21=MANIFOLD_SOLID_BREP('Ball',#20);\n#22=SHAPE_REPRESENTATION('',(#21),$);\n{TAIL}",
point(center),
point(axis),
point(reference),
point(north),
point(end),
point(circle_axis),
point(seam),
)
}
fn assert_seam_sphere(text: &str, radius: f64) {
let imp = step_import(text).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
let body = &imp.bodies[0];
assert!(body.closed);
let m = measure(&body.body).unwrap();
let want_volume = 4.0 / 3.0 * std::f64::consts::PI * radius.powi(3);
let want_area = 4.0 * std::f64::consts::PI * radius.powi(2);
assert!(rel(m.volume, want_volume) < 1e-3, "volume {} vs {want_volume}", m.volume);
assert!(rel(m.area, want_area) < 1e-3, "area {} vs {want_area}", m.area);
let mesh = body.body.display_mesh(radius * 1e-3).unwrap();
assert!(!mesh.positions.is_empty() && !mesh.triangles.is_empty());
}
#[test]
fn seam_only_sphere_imports_as_a_whole_ball() {
let text = seam_sphere_step(Vec3::ZERO, 2.0, Vec3::Z, Vec3::X, 0.0, false, std::f64::consts::PI);
assert_seam_sphere(&text, 2.0);
}
#[test]
fn tilted_seam_only_sphere_imports_as_a_whole_ball() {
let axis = Vec3::new(0.0, std::f64::consts::FRAC_1_SQRT_2, std::f64::consts::FRAC_1_SQRT_2);
let text = seam_sphere_step(Vec3::new(30.0, -12.0, 7.0), 1.9845, axis, Vec3::X, 0.7, false, std::f64::consts::PI);
assert_seam_sphere(&text, 1.9845);
}
#[test]
fn reversed_seam_only_sphere_imports_as_a_whole_ball() {
let text = seam_sphere_step(Vec3::ZERO, 2.0, Vec3::Z, Vec3::X, 0.0, true, std::f64::consts::PI);
assert_seam_sphere(&text, 2.0);
}
#[test]
fn seam_only_sphere_whose_placement_axis_misses_its_poles_imports_as_a_whole_ball() {
// The seam runs between poles on the X axis, while the surface's placement points along Z.
let text = seam_sphere_step(Vec3::new(5.0, 6.0, 7.0), 3.0, Vec3::X, Vec3::Y, 0.4, false, std::f64::consts::PI);
let placement = |axis: &str, reference: &str| format!("#2=DIRECTION('',{axis});\n#3=DIRECTION('',{reference});\n");
let (one, zero) = ("1.0000000000000000", "0.0000000000000000");
let (x, y, z) = (format!("({one},{zero},{zero})"), format!("({zero},{one},{zero})"), format!("({zero},{zero},{one})"));
let text = text.replacen(&placement(&x, &y), &placement(&z, &x), 1);
assert!(text.contains(&placement(&z, &x)), "the placement was not replaced");
assert_seam_sphere(&text, 3.0);
}
#[test]
fn seam_only_sphere_void_is_turned_inside_out() {
// A 5 mm ball with a 2 mm ball-shaped void, written as voids are: the void's closed shell
// (faces facing out of it) used through an ORIENTED_CLOSED_SHELL with orientation .F.
let renumber = |text: &str, by: u64| {
let mut out = String::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
out.push(c);
if c == '#' {
let mut n = String::new();
while let Some(d) = chars.next_if(char::is_ascii_digit) {
n.push(d);
}
out += &(n.parse::<u64>().unwrap() + by).to_string();
}
}
out
};
let inner = seam_sphere_step(Vec3::ZERO, 2.0, Vec3::Z, Vec3::X, 0.0, false, std::f64::consts::PI);
let inner = &inner[inner.find("#1=").unwrap()..inner.find("#21=").unwrap()];
let outer = seam_sphere_step(Vec3::ZERO, 5.0, Vec3::Z, Vec3::X, 1.0, false, std::f64::consts::PI);
let brep = "#21=MANIFOLD_SOLID_BREP('Ball',#20);\n";
assert!(outer.contains(brep));
let void = format!("{}#200=ORIENTED_CLOSED_SHELL('',*,#120,.F.);\n#21=BREP_WITH_VOIDS('Hollow',#20,(#200));\n", renumber(inner, 100));
let text = outer.replacen(brep, &void, 1);
let imp = step_import(&text).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
let m = measure(&imp.bodies[0].body).unwrap();
let want = 4.0 / 3.0 * std::f64::consts::PI * (125.0 - 8.0);
assert!(rel(m.volume, want) < 1e-3, "volume {} vs {want}", m.volume);
let area = 4.0 * std::f64::consts::PI * (25.0 + 4.0);
assert!(rel(m.area, area) < 1e-3, "area {} vs {area}", m.area);
}
#[test]
fn non_antipodal_seam_sphere_does_not_panic() {
let text = seam_sphere_step(Vec3::ZERO, 2.0, Vec3::Z, Vec3::X, 0.0, false, 2.5);
let result = std::panic::catch_unwind(|| step_import(&text));
assert!(result.is_ok());
}
#[test]
#[ignore = "manual STEP import timing; run without concurrent builds"]
fn benchmark_independent_step_solids() {