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

+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) };
}