From d8a088f46a9f03b3f50c0d542076c719b18bc3a0 Mon Sep 17 00:00:00 2001 From: Erikas <5955795+erkexzcx@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:31:46 +0300 Subject: [PATCH] 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). --- crates/kernel/src/step_in/tests.rs | 42 ++++++ crates/kernel/src/step_out.rs | 200 +++++++++++++++++++++++++++-- 2 files changed, 229 insertions(+), 13 deletions(-) diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index 9bad51c6..6012461c 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -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). diff --git a/crates/kernel/src/step_out.rs b/crates/kernel/src/step_out.rs index b36c255a..c45e46a6 100644 --- a/crates/kernel/src/step_out.rs +++ b/crates/kernel/src/step_out.rs @@ -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, + /// B-spline sheets of the body being copied to write cut down to their faces. + trimmed: HashMap, /// Faces of the body being copied: exchange id → written id. faces_out: HashMap, } @@ -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 { + use truck_modeling::Surface; + let (rows, weights, (ku, kv), (du, dv)): (Vec>, Option>>, _, _) = 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>> = 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::>().join(","))) + .collect::>() + .join(","); + let list = |v: Vec| 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::>().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> { + 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> { 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::>() + (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::>() }; 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, HashMap)> { +/// 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) -> HashMap { + let live: Vec = 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 = 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::>>()) else { + continue; + }; + let pts: Vec = 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 { + 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, HashMap, HashMap)> { 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 = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect();