STEP export: a half-turn arc end as a cylinder, and cut faces' sheets bounded by their faces (#1198) (#1438)

STEP export writes a half-turn arc end as a cylinder, and cut faces' sheets bounded by their faces (#1198).
This commit is contained in:
Erikas authored and GitHub committed 2026-10-15 13:31:46 -04:00
1 parent 17b48d9e7e
commit d8a088f46a
2 files changed
+229 -13

No files matched your search

+42
View File
@@ -970,6 +970,48 @@ fn rounded_slot_exports_cylinders() {
assert!(rel(measure(&imp.bodies[0].body).unwrap().volume, want) < 1e-3);
}
/// A slot plate rounded along its top and bottom outlines, with a ramp cut across one end
/// (issue #1198). The other end's half turn is still written as a cylinder (its corners alone
/// point both ways: the seam comes from points along its edges). The faces the ramp crosses keep
/// their B-spline sheets, cut down to the faces: the rounds' sheets reached 35 mm past both ends,
/// and readers bounding the part by its surfaces saw a 60 mm part as 130 mm long.
#[test]
fn ramp_cut_slot_exports_sheets_bounded_by_their_faces() {
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 outline = |z: f64| [Vec3::new(30.0, -10.0, z), Vec3::new(30.0, 10.0, z), Vec3::new(0.0, 0.0, z), Vec3::new(60.0, 0.0, z)];
let rounded = fillet(&fillet(&plate, &outline(4.0), 1.0).unwrap(), &outline(0.0), 1.0).unwrap();
// The ramp: from the top at x = 40 down to z = 2 past the end, across the whole width.
let side = Plane::new(Vec3::new(0.0, -6.0, 0.0), Vec3::X, Vec3::Z).unwrap();
let ramp =
Region2 { outer: Loop2::polygon(&[Vec2::new(40.0, 4.0), Vec2::new(62.0, 2.0), Vec2::new(62.0, 6.0), Vec2::new(40.0, 6.0)]), holes: vec![] };
let tool = extrude(&side, &[ramp], -12.0, 0.0).unwrap().pop().unwrap();
let b = boolean(&rounded, &tool, BoolOp::Cut).unwrap().unwrap();
let text = step_export(&[&b], "t").unwrap();
// Both ends' walls and the three rounds along the sides it leaves whole; the two the ramp
// crosses stay sheets.
assert_eq!(text.matches("CYLINDRICAL_SURFACE(").count(), 5, "{text}");
assert_eq!(text.matches("B_SPLINE_SURFACE(").count(), 2, "{text}");
// No point of the file (control points and placements included) lies past the part's ends.
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}");
}
let imp = round_trip(&b);
assert_eq!(imp.bodies.len(), 1);
assert!(rel(measure(&imp.bodies[0].body).unwrap().volume, measure(&b).unwrap().volume) < 1e-4);
}
/// 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).
+187 -13
View File
@@ -156,6 +156,8 @@ struct Out {
/// 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>,
/// B-spline sheets of the body being copied to write cut down to their faces.
trimmed: HashMap<u64, truck_modeling::Surface>,
/// Faces of the body being copied: exchange id → written id.
faces_out: HashMap<u64, u64>,
}
@@ -233,6 +235,57 @@ impl Out {
}
}
}
/// A B-spline (or NURBS) surface; `None` for other surfaces.
fn write_sheet(&mut self, s: &truck_modeling::Surface) -> Option<u64> {
use truck_modeling::Surface;
let (rows, weights, (ku, kv), (du, dv)): (Vec<Vec<V3>>, Option<Vec<Vec<f64>>>, _, _) = match s {
Surface::BSplineSurface(b) => (
b.control_points().iter().map(|r| r.iter().map(|p| [p.x, p.y, p.z]).collect()).collect(),
None,
b.knot_vecs().clone(),
(b.udegree(), b.vdegree()),
),
Surface::NurbsSurface(n) => {
let b = n.non_rationalized();
let rows: Option<Vec<Vec<V3>>> = b
.control_points()
.iter()
.map(|r| r.iter().map(|p| (p.w.abs() > 1e-300).then(|| [p.x / p.w, p.y / p.w, p.z / p.w])).collect())
.collect();
(
rows?,
Some(b.control_points().iter().map(|r| r.iter().map(|p| p.w).collect()).collect()),
b.knot_vecs().clone(),
(b.udegree(), b.vdegree()),
)
}
_ => return None,
};
let net = rows
.iter()
.map(|r| format!("({})", r.iter().map(|p| format!("#{}", self.point(*p))).collect::<Vec<_>>().join(",")))
.collect::<Vec<_>>()
.join(",");
let list = |v: Vec<String>| v.join(",");
let ((ku, mu), (kv, mv)) = (ku.to_single_multi(), kv.to_single_multi());
let knots = format!(
"({}),({}),({}),({}),.UNSPECIFIED.",
list(mu.iter().map(|m| m.to_string()).collect()),
list(mv.iter().map(|m| m.to_string()).collect()),
list(ku.iter().map(|k| real(*k)).collect()),
list(kv.iter().map(|k| real(*k)).collect()),
);
let base = format!("{du},{dv},({net}),.UNSPECIFIED.,.U.,.U.,.U.");
Some(match weights {
None => self.add_shared(format!("B_SPLINE_SURFACE_WITH_KNOTS('',{base},{knots})")),
Some(w) => {
let w = w.iter().map(|r| format!("({})", list(r.iter().map(|x| real(*x)).collect()))).collect::<Vec<_>>().join(",");
self.add_shared(format!(
"(BOUNDED_SURFACE()B_SPLINE_SURFACE({base})B_SPLINE_SURFACE_WITH_KNOTS({knots})GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE(({w}))REPRESENTATION_ITEM('')SURFACE())"
))
}
})
}
fn placement(&mut self, o: [f64; 3], z: [f64; 3], x: [f64; 3]) -> u64 {
let (po, dz, dx) = (self.point(o), self.direction(z), self.direction(x));
self.add_shared(format!("AXIS2_PLACEMENT_3D('',#{po},#{dz},#{dx})"))
@@ -291,6 +344,11 @@ impl Out {
map.insert(id, new);
continue;
}
if !done && let Some(sheet) = self.trimmed.get(&id).cloned() {
let new = self.write_sheet(&sheet).ok_or_else(|| format!("cannot write the cut sheet #{id}"))?;
map.insert(id, new);
continue;
}
if !done {
stack.push((id, true));
let mut refs = Vec::new();
@@ -723,12 +781,18 @@ fn square_or_ruled(ex: &p21::Exchange, fe: &p21::Entity, kind: &AnalyticKind) ->
/// Points along an edge's curve (its ends for a line, None for a conic it cannot sample).
fn edge_samples(ex: &p21::Exchange, edge: u64) -> Option<Vec<V3>> {
edge_points(ex, edge, 4)
}
/// `n + 1` points along an edge's curve, ends included (its ends for a line, None for a conic it
/// cannot sample).
fn edge_points(ex: &p21::Exchange, edge: u64, n: usize) -> Option<Vec<V3>> {
use truck_modeling::{BoundedCurve, ParametricCurve};
let p = ex.get(edge)?.params();
let ends = [vertex_point(ex, p.get(1)?.as_ref_id()?)?, vertex_point(ex, p.get(2)?.as_ref_id()?)?];
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
let sample = |f: &dyn Fn(f64) -> truck_modeling::Point3, (t0, t1): (f64, f64)| {
(0..=4).map(|k| f(t0 + (t1 - t0) * k as f64 / 4.0)).map(|q| [q.x, q.y, q.z]).collect::<Vec<V3>>()
(0..=n).map(|k| f(t0 + (t1 - t0) * k as f64 / n.max(1) as f64)).map(|q| [q.x, q.y, q.z]).collect::<Vec<V3>>()
};
match crate::step_in::geom::curve(&cx, p.get(3)?.as_ref_id()?, 0).ok()? {
crate::step_in::geom::CurveGeo::Line { .. } => Some(ends.to_vec()),
@@ -1231,14 +1295,18 @@ fn analytic_sheets(ex: &p21::Exchange, merged_surfaces: &std::collections::HashS
let x = match seam_dir {
Some(x) => Some(x),
None => {
// Points along the edges, not just their ends: the corners of a half turn (a
// slot's round end) point both ways and cancel out (#1198). The sheet's own
// middle where even those do.
let mean = loops
.iter()
.flatten()
.filter_map(|(edge, _)| ex.get(*edge)?.params().get(1)?.as_ref_id())
.filter_map(|v| vertex_point(ex, v))
.filter_map(|(edge, _)| edge_samples(ex, *edge))
.flatten()
.filter_map(|q| unit(radial(q)))
.fold([0.0; 3], add);
unit(mul(mean, -1.0))
let (AnalyticKind::Cylinder { x: middle, .. } | AnalyticKind::Cone { x: middle, .. }) = kind else { continue };
Some(unit(mul(mean, -1.0)).unwrap_or(middle))
}
};
if let Some(x) = x {
@@ -1248,9 +1316,113 @@ fn analytic_sheets(ex: &p21::Exchange, merged_surfaces: &std::collections::HashS
out
}
/// 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>)> {
/// Margin left round a face when its sheet is cut down to it, as a share of the face's own
/// parameter range on each side.
const SHEET_MARGIN: f64 = 0.02;
/// B-spline sheets that reach well past the one face on them, cut down to that face's parameter
/// range (and a margin), so readers bound the part by its faces (#1198): a round's sheet runs on
/// past both ends of the edge it rounds, and when a later cut crosses the round, the crossing edge
/// keeps it from being written as a cylinder (see `square_or_ruled`). Sheets written as analytic
/// surfaces, flat ones, sheets under several faces, and those whose face's edges do not all lie
/// on the piece cut out (a face across a closed sheet's seam) are left alone.
fn trimmed_sheets(ex: &p21::Exchange, analytic: &HashMap<u64, V3>) -> HashMap<u64, truck_modeling::Surface> {
let live: Vec<u64> = ex
.entities
.values()
.filter_map(|e| if matches!(e.name(), "CLOSED_SHELL" | "OPEN_SHELL") { e.params().get(1).and_then(Param::as_list) } else { None })
.flatten()
.filter_map(Param::as_ref_id)
.collect();
let mut users: HashMap<u64, usize> = HashMap::new();
for e in live.iter().filter_map(|f| ex.get(*f)) {
if let Some(sid) = e.params().get(2).and_then(Param::as_ref_id) {
*users.entry(sid).or_default() += 1;
}
}
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 users.get(&sid) != Some(&1) || analytic.contains_key(&sid) {
continue;
}
let Some(se) = ex.get(sid) else { continue };
if !(se.has("B_SPLINE_SURFACE") || se.name() == "B_SPLINE_SURFACE_WITH_KNOTS") || planar_bspline(ex, se).is_some() {
continue;
}
let Some(pts) = face_edges(ex, e).and_then(|ls| ls.iter().flatten().map(|(x, _)| edge_points(ex, *x, 16)).collect::<Option<Vec<_>>>()) else {
continue;
};
let pts: Vec<V3> = pts.into_iter().flatten().collect();
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
let Ok(sheet) = crate::step_in::geom::bspline_surface(&cx, se) else { continue };
if let Ok(Some(cut)) = crate::guard("STEP sheet trim", || Ok(trim_sheet(&sheet, &pts))) {
out.insert(sid, cut);
}
}
out
}
/// `sheet` cut down to the parameter range `pts` (points on it) cover, with [`SHEET_MARGIN`];
/// `None` when that would not make it much smaller, or when a point is not on the piece cut out.
fn trim_sheet(sheet: &truck_modeling::Surface, pts: &[V3]) -> Option<truck_modeling::Surface> {
use truck_modeling::{ParametricSurface, SearchNearestParameter, Surface};
let (Some((u0, u1)), Some((v0, v1))) = sheet.try_range_tuple() else { return None };
// Edges made by intersecting surfaces are fitted to them, a micron or so off the sheet; a
// point on the wrong piece of it is far further.
let (lo, hi) = pts.iter().fold(([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]), |(lo, hi), p| {
([lo[0].min(p[0]), lo[1].min(p[1]), lo[2].min(p[2])], [hi[0].max(p[0]), hi[1].max(p[1]), hi[2].max(p[2])])
});
let tol = 1e-3 + 1e-4 * norm(sub(hi, lo));
let near = |s: &Surface, p: V3| -> Option<(f64, f64)> {
let q = truck_modeling::Point3::new(p[0], p[1], p[2]);
let (u, v) = s.search_nearest_parameter(q, None, 100)?;
let r = s.subs(u, v);
(norm(sub([r.x, r.y, r.z], p)) <= tol).then_some((u, v))
};
let (mut ua, mut ub, mut va, mut vb) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
for p in pts {
let (u, v) = near(sheet, *p)?;
(ua, ub, va, vb) = (ua.min(u), ub.max(u), va.min(v), vb.max(v));
}
let (du, dv) = ((ub - ua) * SHEET_MARGIN, (vb - va) * SHEET_MARGIN);
let (ua, ub, va, vb) = ((ua - du).max(u0), (ub + du).min(u1), (va - dv).max(v0), (vb + dv).min(v1));
// Worth it only where a parameter keeps less than four fifths of its range.
let keeps = |a: f64, b: f64, lo: f64, hi: f64| (b - a) / (hi - lo);
if !(ua < ub && va < vb) || (keeps(ua, ub, u0, u1) > 0.8 && keeps(va, vb, v0, v1) > 0.8) {
return None;
}
// The piece between the new ends: `ucut` keeps what lies before the cut and returns the rest.
macro_rules! cut {
($s:expr) => {{
let mut s = $s.clone();
if ua > u0 {
s = s.ucut(ua);
}
if ub < u1 {
s.ucut(ub);
}
if va > v0 {
s = s.vcut(va);
}
if vb < v1 {
s.vcut(vb);
}
s
}};
}
let piece = match sheet {
Surface::BSplineSurface(b) => Surface::BSplineSurface(cut!(b)),
Surface::NurbsSurface(n) => Surface::NurbsSurface(cut!(n)),
_ => return None,
};
pts.iter().all(|p| near(&piece, *p).is_some()).then_some(piece)
}
/// The B-rep entities of one body as truck writes them, parsed, the B-spline sheets to write
/// as cylinders and cones (see `analytic_sheets`) and those to write cut down to their faces
/// (see `trimmed_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, truck_modeling::Surface>, 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}")))?;
collapse_short_edges(&mut ex);
@@ -1270,17 +1442,18 @@ 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 sheets = if merge {
let (sheets, trimmed) = 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
let trimmed = trimmed_sheets(&ex, &sheets);
// Lone sheets rewritten as analytic surfaces, or cut down, count as changes to check too.
MERGES.with(|m| m.set(m.get() + sheets.keys().filter(|s| !merged.contains(s)).count() + trimmed.len()));
(sheets, trimmed)
} else {
Default::default()
};
MERGES.with(|m| m.set(m.get() + before.saturating_sub(shell_face_count(&ex))));
Ok((ex, sheets, paint))
Ok((ex, sheets, trimmed, paint))
}
/// The separate pieces of a body for export, each keeping its face colours: a body of several
@@ -1469,8 +1642,9 @@ fn products_text(products: &[ExportProduct], root: usize, header: &StepHeader, m
let mut solids = Vec::new();
for b in &p.bodies {
for lump in export_lumps(b.body) {
let (ex, merged, face_paint) = brep_exchange(&lump, merge)?;
let (ex, merged, trimmed, face_paint) = brep_exchange(&lump, merge)?;
o.merged = merged;
o.trimmed = trimmed;
o.faces_out.clear();
let mut roots: Vec<u64> =
ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect();