STEP export: extruded arcs, straight rounds and countersinks as cylinders and cones (#612) (#1139)

Ruled B-spline sheets that are circular cylinders or cones are written as CYLINDRICAL_SURFACE and CONICAL_SURFACE, so other readers no longer bound the part by sheets that reach past its faces.
This commit is contained in:
Erikas authored and GitHub committed 2026-10-14 13:40:10 -04:00
1 parent 2c754c430b
commit 2e6951135a
2 files changed
+191 -64

No files matched your search

+60
View File
@@ -863,6 +863,66 @@ fn glued_halves_export_as_whole_faces() {
assert_eq!(imp.bodies[0].file_faces, 8);
}
/// A slot plate with a round on its top outline (issue #612): the extruded arc ends and the
/// rounds along the straight edges are lone B-spline sheets, written as cylinders bounded by
/// their faces. The rounds' sheets reach far past their edges, and a reader bounding the part by
/// its surfaces saw a 60 mm part as 130 mm long.
#[test]
fn rounded_slot_exports_cylinders() {
use solidcraft_geom::Seg2;
use std::f64::consts::{FRAC_PI_2, PI};
let outer = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(10.0, -10.0), b: Vec2::new(50.0, -10.0) },
Seg2::Arc { center: Vec2::new(50.0, 0.0), radius: 10.0, start: -FRAC_PI_2, sweep: PI },
Seg2::Line { a: Vec2::new(50.0, 10.0), b: Vec2::new(10.0, 10.0) },
Seg2::Arc { center: Vec2::new(10.0, 0.0), radius: 10.0, start: FRAC_PI_2, sweep: PI },
],
};
let plate = extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 4.0).unwrap().pop().unwrap();
let edges = [Vec3::new(30.0, -10.0, 4.0), Vec3::new(30.0, 10.0, 4.0), Vec3::new(0.0, 0.0, 4.0), Vec3::new(60.0, 0.0, 4.0)];
let b = fillet(&plate, &edges, 1.0).unwrap();
let text = step_export(&[&b], "t").unwrap();
assert!(!text.contains("B_SPLINE_SURFACE"), "{text}");
assert_eq!(text.matches("CYLINDRICAL_SURFACE(").count(), 4);
assert_eq!(text.matches("TOROIDAL_SURFACE(").count(), 2);
// No point of the file (surface placements included) lies outside the part.
let imp = round_trip(&b);
for line in text.lines().filter(|l| l.contains("CARTESIAN_POINT")) {
let Some(xyz) = line.split("((").nth(1).and_then(|r| r.split("))").next()) else { continue };
let x: f64 = xyz.split(',').next().unwrap().trim().parse().unwrap();
assert!((-1e-6..=60.0 + 1e-6).contains(&x), "{line}");
}
// The volume analytically: the plate less the round's corner section swept along the edges
// (Pappus: the section's centroid runs 0.2234 r inside the edge round the arcs).
let section = 1.0 - PI / 4.0;
let centroid = (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI));
let want = (800.0 + PI * 100.0) * 4.0 - section * (80.0 + std::f64::consts::TAU * (10.0 - centroid));
assert!(rel(measure(&imp.bodies[0].body).unwrap().volume, want) < 1e-3);
}
/// A countersink cut into a plate (issue #612): the cone is a tapered extrusion of a disc, a
/// B-spline sheet per half, written as one CONICAL_SURFACE face (the sheets reached below the
/// plate).
#[test]
fn countersink_exports_one_cone() {
use std::f64::consts::FRAC_PI_4;
let plate = box_solid(Vec3::new(-10.0, -10.0, 0.0), Vec3::new(10.0, 10.0, 4.0)).unwrap();
// A 90° countersink, radius 4 at the top face, down from 1 mm above it to radius 1.8, and the
// 2 mm drill through.
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.0), holes: vec![] };
let cone = crate::extrude_tapered(&Plane::XY.offset(5.0), &disc, 3.2, -1.0, -FRAC_PI_4).unwrap();
let drill = cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 2.0, 6.0).unwrap();
let b = boolean(&boolean(&plate, &cone, BoolOp::Cut).unwrap().unwrap(), &drill, BoolOp::Cut).unwrap().unwrap();
let text = step_export(&[&b], "t").unwrap();
assert!(!text.contains("B_SPLINE_SURFACE"), "{text}");
assert_eq!(text.matches("CONICAL_SURFACE(").count(), 1, "{text}");
assert_eq!(text.matches("CYLINDRICAL_SURFACE(").count(), 1, "{text}");
let imp = round_trip(&b);
// Six box faces, the countersink and the drill.
assert_eq!(imp.bodies[0].file_faces, 8);
}
/// Knot multiplicities are expanded into one value each before the knot count is compared with
/// the control points, so the sum has to be checked first: 1,000,000 knots of multiplicity 64
/// against two control points asked for a 500 MB vector (20,000,000 for 10 GB) only to reject
+131 -64
View File
@@ -151,8 +151,8 @@ struct Out {
shared: HashMap<String, u64>,
/// Surfaces written with the opposite normal to the one they replace.
flipped: std::collections::HashSet<u64>,
/// B-spline cylinder sheets of the body being copied under merged faces, with the direction
/// of the cylinder's parameter seam.
/// B-spline cylinder and cone sheets of the body being copied to write as analytic surfaces,
/// with the direction of their parameter seam.
merged: HashMap<u64, V3>,
/// Faces of the body being copied: exchange id → written id.
faces_out: HashMap<u64, u64>,
@@ -270,13 +270,13 @@ impl Out {
map.insert(id, new);
continue;
}
// Flat B-spline sheets are planes; cylindrical ones become cylinders when their
// pieces were merged into one face (a lone piece stays as written).
// Flat B-spline sheets are planes; ruled ones become cylinders and cones when
// `analytic_sheets` chose them (their pieces merged, or a lone piece's edges allow).
if !done
&& let Some(a) = planar_bspline(ex, e).map(|kind| Analytic { kind, flip: false }).or_else(|| {
let seam = self.merged.get(&id)?;
let mut a = cylindrical_bspline(ex, e)?;
if let AnalyticKind::Cylinder { x, .. } = &mut a.kind {
let mut a = ruled_bspline(ex, e)?;
if let AnalyticKind::Cylinder { x, .. } | AnalyticKind::Cone { x, .. } = &mut a.kind {
*x = *seam;
}
Some(a)
@@ -609,10 +609,11 @@ fn planar_bspline(ex: &p21::Exchange, e: &p21::Entity) -> Option<AnalyticKind> {
Some(AnalyticKind::Plane { o, z, x })
}
/// A B-spline surface that is a circular cylinder (a circle or arc swept along a line, as truck
/// makes for extruded round profiles), with its normal direction relative to the cylinder's
/// outward normal.
fn cylindrical_bspline(ex: &p21::Exchange, e: &p21::Entity) -> Option<Analytic> {
/// A B-spline surface that is a circular cylinder or cone: straight rulings between two coaxial
/// circles or arcs (or an arc and the cone's apex), as truck makes for extruded round profiles,
/// rounds along straight edges and countersinks. With its normal direction relative to the
/// surface's outward normal.
fn ruled_bspline(ex: &p21::Exchange, e: &p21::Entity) -> Option<Analytic> {
use truck_modeling::{ParametricSurface, ParametricSurface3D};
if !(e.has("B_SPLINE_SURFACE") || e.name() == "B_SPLINE_SURFACE_WITH_KNOTS") {
return None;
@@ -624,45 +625,98 @@ fn cylindrical_bspline(ex: &p21::Exchange, e: &p21::Entity) -> Option<Analytic>
let p = s.subs(u, v);
[p.x, p.y, p.z]
};
// Which parameter sweeps along a line: the same translation from one end to the other at
// every point of the profile.
// Which parameter runs along the rulings: straight from one end to the other at every point
// of the profile.
for along_v in [true, false] {
let point = |t: f64, w: f64| if along_v { at(u0 + (u1 - u0) * t, v0 + (v1 - v0) * w) } else { at(u0 + (u1 - u0) * w, v0 + (v1 - v0) * t) };
let prof: Vec<V3> = (0..=8).map(|k| point(k as f64 / 8.0, 0.0)).collect();
let d = sub(point(0.0, 1.0), point(0.0, 0.0));
let len = norm(d);
let scale = prof.iter().map(|p| norm(sub(*p, prof[0]))).fold(len, f64::max);
let grid: Vec<Vec<V3>> = (0..=4).map(|m| (0..=8).map(|k| point(k as f64 / 8.0, m as f64 / 4.0)).collect()).collect();
let (Some(p0), Some(p1)) = (grid.first(), grid.last()) else { continue };
let Some(&first) = p0.first() else { continue };
let scale = grid.iter().flatten().map(|p| norm(sub(*p, first))).fold(0.0, f64::max);
let tol = 1e-7 * (1.0 + scale);
if len < tol
|| (0..=8).any(|k| {
let t = k as f64 / 8.0;
(0..=4).any(|m| norm(sub(sub(point(t, m as f64 / 4.0), point(t, 0.0)), mul(d, m as f64 / 4.0))) > tol)
})
{
let straight = (0..=8).all(|k| {
let (Some(&a), Some(&b)) = (p0.get(k), p1.get(k)) else { return false };
let Some(d) = unit(sub(b, a)) else { return false };
grid.iter().all(|row| row.get(k).is_some_and(|q| norm(cross(sub(*q, a), d)) <= tol))
});
if !straight {
continue;
}
let (Some(&a), Some(&b), Some(&c)) = (prof.first(), prof.get(4), prof.get(8)) else { continue };
let (centre, r, n) = circle3(a, b, c).or_else(|| circle3(a, prof[2], b))?;
let z = unit(d)?;
if norm(cross(n, z)) > 1e-7 || prof.iter().any(|p| (norm(sub(*p, centre)) - r).abs() > tol) {
// Each end: a circle (centre, radius, axis) or a single point (a cone's apex).
let ring = |prof: &[V3]| -> Option<(V3, f64, Option<V3>)> {
let &a = prof.first()?;
if prof.iter().all(|p| norm(sub(*p, a)) <= tol) {
return Some((a, 0.0, None));
}
let (&b, &c, &h) = (prof.get(4)?, prof.get(8)?, prof.get(2)?);
let (centre, r, n) = circle3(a, b, c).or_else(|| circle3(a, h, b))?;
prof.iter().all(|p| (norm(sub(*p, centre)) - r).abs() <= tol).then_some((centre, r, Some(n)))
};
let ((c0, r0, n0), (c1, r1, n1)) = (ring(p0)?, ring(p1)?);
let axis = unit(sub(c1, c0))?;
if [n0, n1].iter().flatten().any(|n| norm(cross(*n, axis)) > 1e-7) || (n0.is_none() && n1.is_none()) {
return None;
}
let height = norm(sub(c1, c0));
// The radius grows along z (a cylinder keeps the sweep's direction).
let (o, z, r, angle) = if (r1 - r0).abs() <= tol {
(c0, axis, r0, 0.0)
} else if r1 > r0 {
(c1, axis, r1, ((r1 - r0) / height).atan())
} else {
(c0, mul(axis, -1.0), r0, ((r0 - r1) / height).atan())
};
let radius_at = |q: V3| r + dot(sub(q, o), z) * angle.tan();
let radial = |q: V3| {
let v = sub(q, o);
sub(v, mul(z, dot(v, z)))
};
if grid.iter().flatten().any(|q| (norm(radial(*q)) - radius_at(*q)).abs() > tol) {
return None;
}
// The surface's own normal against the outward one, mid-face.
let (um, vm) = ((u0 + u1) / 2.0, (v0 + v1) / 2.0);
let nn = s.normal(um, vm);
let q = at(um, vm);
let radial = sub(sub(q, centre), mul(z, dot(sub(q, centre), z)));
let flip = dot([nn.x, nn.y, nn.z], radial) < 0.0;
let eq = unit(radial(q))?;
let outward = sub(mul(eq, angle.cos()), mul(z, angle.sin()));
let flip = dot([nn.x, nn.y, nn.z], outward) < 0.0;
// The parameter seam opposite the middle of the sheet, away from its edges.
let x = unit(mul(radial, -1.0)).or_else(|| unit(sub(a, centre)))?;
return Some(Analytic { kind: AnalyticKind::Cylinder { o: centre, z, x, r }, flip });
let x = mul(eq, -1.0);
let kind = if angle == 0.0 { AnalyticKind::Cylinder { o, z, x, r } } else { AnalyticKind::Cone { o, z, x, r, angle } };
return Some(Analytic { kind, flip });
}
None
}
/// A B-spline surface that is really a plane or a cylinder.
/// A B-spline surface that is really a plane, a cylinder or a cone.
fn analytic_bspline(ex: &p21::Exchange, e: &p21::Entity) -> Option<Analytic> {
planar_bspline(ex, e).map(|kind| Analytic { kind, flip: false }).or_else(|| cylindrical_bspline(ex, e))
planar_bspline(ex, e).map(|kind| Analytic { kind, flip: false }).or_else(|| ruled_bspline(ex, e))
}
/// Every edge of a face on a cylinder or cone is a circle square to its axis or one of its
/// rulings (a line along the axis, or through a cone's apex). Faces bounded by other curves (a
/// hole crossing a tube wall) keep their B-spline sheet: readers lay those curves out far more
/// slowly on the analytic surface than on the sheet they came with.
fn square_or_ruled(ex: &p21::Exchange, fe: &p21::Entity, kind: &AnalyticKind) -> bool {
let (z, apex) = match *kind {
AnalyticKind::Cylinder { z, .. } => (z, None),
AnalyticKind::Cone { o, z, r, angle, .. } if angle.tan() > 1e-12 => (z, Some(sub(o, mul(z, r / angle.tan())))),
_ => return false,
};
face_edges(ex, fe).is_some_and(|ls| {
ls.iter().flatten().all(|(x, _)| {
edge_samples(ex, *x).is_some_and(|pts| {
let Some(&q0) = pts.first() else { return false };
let tol = 1e-7 * (1.0 + norm(q0));
let ruling = match apex {
None => pts.iter().all(|q| norm(cross(sub(*q, q0), z)) <= tol),
Some(a) => pts.iter().all(|q| norm(cross(sub(*q, a), sub(q0, a))) <= tol * (1.0 + norm(sub(q0, a)))),
};
ruling || pts.iter().all(|q| dot(sub(*q, q0), z).abs() <= tol)
})
})
})
}
/// Points along an edge's curve (its ends for a line, None for a conic it cannot sample).
@@ -833,8 +887,9 @@ fn face_edges(ex: &p21::Exchange, face: &p21::Entity) -> Option<Vec<Vec<(u64, bo
/// Faces that truck splits (a revolution in two or three turns) joined back into one face per
/// analytic surface, by dropping the edges between them. Faces whose union would lose all its
/// edges, or whose leftover edges do not close into loops, stay apart.
fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>, keep: &[Vec<Vec3>]) -> HashMap<u64, V3> {
/// edges, or whose leftover edges do not close into loops, stay apart. Returns the surfaces of
/// merged faces.
fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>, keep: &[Vec<Vec3>]) -> std::collections::HashSet<u64> {
// An edge on a line the user split along: every sample within a hair of the polyline.
let on_split = |ex: &p21::Exchange, e: u64| -> bool {
let Some(pts) = edge_samples(ex, e) else { return false };
@@ -876,21 +931,10 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>,
let v = |a: truck_modeling::Vector3| [a.x, a.y, a.z];
Some(Analytic { kind: AnalyticKind::Plane { o: [f.o.x, f.o.y, f.o.z], z: v(f.z), x: v(f.x) }, flip: false })
}
// Pieces of a B-spline cylinder or cone merge only when their edges
// are circles square to the axis and rulings (an extruded hole).
_ => analytic_bspline(ex, se).filter(|a| match a.kind {
// Pieces of a B-spline cylinder merge only when their curved edges
// are circles square to the axis (an extruded hole): crossing
// curves (a hole through a tube wall) read far more slowly on a
// cylinder than on the sheet they came with.
AnalyticKind::Cylinder { z, .. } => face_edges(ex, fe).is_some_and(|ls| {
ls.iter().flatten().all(|(x, _)| {
edge_samples(ex, *x).is_some_and(|pts| {
let Some(&q0) = pts.first() else { return false };
let tol = 1e-7 * (1.0 + norm(q0));
pts.iter().all(|q| dot(sub(*q, q0), z).abs() <= tol)
|| pts.iter().all(|q| norm(cross(sub(*q, q0), z)) <= tol)
})
})
}),
AnalyticKind::Cylinder { .. } | AnalyticKind::Cone { .. } => square_or_ruled(ex, fe, &a.kind),
_ => true,
}),
}
@@ -1137,10 +1181,16 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>,
}
}
}
// B-spline cylinder sheets under merged faces are written as cylinders (the sheet covers only
// its own piece), with the parameter seam on the face's seam edge for a full turn, else
// opposite the face's middle. (Faces replaced by merges stay in the exchange unreferenced:
// only the shells' faces count.)
merged_surfaces
}
/// B-spline cylinder and cone sheets written as CYLINDRICAL_SURFACE and CONICAL_SURFACE (the
/// sheet covers only its own piece, and a fillet's may reach far past it), with the direction of
/// the parameter seam: on the face's seam edge for a full turn, else opposite the face's middle.
/// Sheets under faces merged from pieces always; a lone piece when its edges are circles square
/// to the axis and rulings. (Faces replaced by merges stay in the exchange unreferenced: only the
/// shells' faces count.)
fn analytic_sheets(ex: &p21::Exchange, merged_surfaces: &std::collections::HashSet<u64>) -> HashMap<u64, V3> {
let live: Vec<u64> = ex
.entities
.values()
@@ -1151,12 +1201,16 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>,
let mut out = HashMap::new();
for e in live.iter().filter_map(|f| ex.get(*f)) {
let Some(sid) = e.params().get(2).and_then(Param::as_ref_id) else { continue };
if !merged_surfaces.contains(&sid) || out.contains_key(&sid) {
if out.contains_key(&sid) {
continue;
}
let Some(Analytic { kind: AnalyticKind::Cylinder { o, z, .. }, .. }) = ex.get(sid).and_then(|se| cylindrical_bspline(ex, se)) else {
let Some(Analytic { kind, .. }) = ex.get(sid).and_then(|se| ruled_bspline(ex, se)) else {
continue;
};
let (AnalyticKind::Cylinder { o, z, .. } | AnalyticKind::Cone { o, z, .. }) = kind else { continue };
if !merged_surfaces.contains(&sid) && !square_or_ruled(ex, e, &kind) {
continue;
}
let Some(loops) = face_edges(ex, e) else { continue };
let radial = |q: V3| {
let v = sub(q, o);
@@ -1167,8 +1221,13 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>,
.flat_map(|l| l.iter().map(move |(x, _)| (l, *x)))
.find(|(l, x)| l.iter().filter(|(y, _)| y == x).count() > 1)
.map(|(_, x)| x);
let x = match seam.and_then(|edge| ex.get(edge)?.params().get(1)?.as_ref_id()).and_then(|v| vertex_point(ex, v)) {
Some(q) => unit(radial(q)),
// (A cone tip's seam has one end at the apex, on the axis.)
let seam_dir = seam.and_then(|edge| {
let p = ex.get(edge)?.params();
[p.get(1), p.get(2)].into_iter().filter_map(|v| vertex_point(ex, v?.as_ref_id()?)).find_map(|q| unit(radial(q)))
});
let x = match seam_dir {
Some(x) => Some(x),
None => {
let mean = loops
.iter()
@@ -1187,8 +1246,8 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>,
out
}
/// The B-rep entities of one body as truck writes them, parsed, and the surfaces of faces
/// merged from pieces.
/// The B-rep entities of one body as truck writes them, parsed, and the B-spline sheets to write
/// as cylinders and cones (see `analytic_sheets`). Without `merge` the file is truck's, as is.
fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap<u64, V3>, HashMap<u64, FaceLook>)> {
let text = crate::step::truck_step(&[b], "SolidCraft")?;
let mut ex = p21::parse(&text).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?;
@@ -1209,9 +1268,17 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap<u64, V
}
let before = shell_face_count(&ex);
let keep = b.split_lines().map(|l| l.as_slice()).unwrap_or(&[]);
let merged = if merge { merge_split_faces(&mut ex, &mut paint, keep) } else { Default::default() };
let sheets = if merge {
let merged = merge_split_faces(&mut ex, &mut paint, keep);
let sheets = analytic_sheets(&ex, &merged);
// Lone sheets rewritten as analytic surfaces count as changes to check too.
MERGES.with(|m| m.set(m.get() + sheets.keys().filter(|s| !merged.contains(s)).count()));
sheets
} else {
Default::default()
};
MERGES.with(|m| m.set(m.get() + before.saturating_sub(shell_face_count(&ex))));
Ok((ex, merged, paint))
Ok((ex, sheets, paint))
}
/// The separate pieces of a body for export, each keeping its face colours: a body of several
@@ -1277,9 +1344,9 @@ fn rigid_frame(m: &[[f64; 4]; 4]) -> Result<([f64; 3], [f64; 3], [f64; 3])> {
pub fn step_export_products(products: &[ExportProduct], root: usize, header: &StepHeader) -> Result<String> {
MERGES.with(|m| m.set(0));
let text = products_text(products, root, header, true)?;
// Faces merged per surface must read back as cleanly as the pieces they came from (a merge
// can leave a loop the reader cannot lay out); otherwise the pieces are written. Without a
// merge there is nothing to check.
// Faces merged per surface, and sheets rewritten as cylinders and cones, must read back as
// cleanly as truck's pieces (a merge can leave a loop the reader cannot lay out); otherwise
// the pieces are written as truck made them. Without a change there is nothing to check.
let clean = |t: &str| crate::step_in::step_import(t).is_ok_and(|i| i.warnings.is_empty());
if MERGES.with(|m| m.get()) > 0 && !clean(&text) {
let plain = products_text(products, root, header, false)?;
@@ -1291,7 +1358,7 @@ pub fn step_export_products(products: &[ExportProduct], root: usize, header: &St
}
thread_local! {
/// Faces merged while writing the current export.
/// Faces merged and sheets rewritten while writing the current export.
static MERGES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}