STEP import: a ball bounded only by its seam comes in whole (#1742)
Thanks @onembyte.
This commit is contained in:
1 parent
6bde126a5c
commit
fbc00bb2c4
3 files changed
+226
-44
No files matched your search
+49
-44
@@ -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)
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user