STEP export: Fusion round-trip fixes (sense, analytic surfaces, horn torus)

- Surfaces of revolution follow ISO 10303-42 strictly in reader and writer: the
  normal is d/d(angle) x d/d(profile), the opposite of truck's revolution, as
  Fusion reads it (fixes Fusion inverting fillet sphere corners and the dimple).
  step_orientation_errors flags faces whose loop runs clockwise round their
  normal; engine, oracle and kernel round trips assert it.
- Revolved faces with analytic profiles are written as native PLANE,
  CYLINDRICAL/CONICAL/SPHERICAL/TOROIDAL surfaces, and the pieces truck splits
  a revolution into are merged back into one face per surface (with a seam
  edge for full turns), so face counts match other systems.
- A horn torus (tight sweep: tube touching its axis) is written as one rational
  B-spline face whose loop passes the pole twice, with truck's zero-length pole
  edges collapsed, instead of a degenerate surface of revolution; the reader
  starts such figure-eight loops off the pole (Fusion's own part 50 now imports
  with the right volume).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 04:23:46 +00:00
1 parent 178fc47ed7
commit 7382ebd6e8
12 files changed
+958 -85

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ pub use polybool::planar_boolean;
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_validate};
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
+16 -1
View File
@@ -255,7 +255,22 @@ impl Builder {
})
.collect()
}
_ => wires,
// A loop passing a vertex twice (a figure eight through a pole, as written for a
// torus whose tube touches its axis) starts at a vertex it passes once.
_ => wires
.into_iter()
.map(|w| {
let edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
let passes = |e: &mt::Edge| edges.iter().filter(|x| x.front().id() == e.front().id()).count();
match edges.first().map(passes) {
Some(n) if n > 1 => {
let start = edges.iter().position(|e| passes(e) == 1).unwrap_or(0);
edges.iter().skip(start).chain(edges.iter().take(start)).cloned().collect::<Vec<_>>().into()
}
_ => w,
}
})
.collect(),
};
match mt::Face::try_new(wires.clone(), surface.clone()) {
Ok(f) => Ok(f),
+5 -3
View File
@@ -133,7 +133,7 @@ pub(crate) fn frame(cx: &Ctx, id: u64) -> R<Frame> {
}
/// AXIS1_PLACEMENT: origin and unit axis.
fn axis1(cx: &Ctx, id: u64) -> R<(mt::Point3, mt::Vector3)> {
pub(crate) fn axis1(cx: &Ctx, id: u64) -> R<(mt::Point3, mt::Vector3)> {
let e = entity(cx, id)?;
let p = e.params();
let o = point(cx, p.get(1).and_then(Param::as_ref_id).ok_or("axis without location")?)?;
@@ -875,8 +875,10 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
CurveGeo::Line { p, d } => revolved_line(*p, *d, o, axis, extent)?,
_ => bounded_curve(&g, extent)?,
};
// Reversed sense: turn the other way round the axis.
Ok(revolved(profile, o, axis * sign))
// ISO 10303-42 parameterises a surface of revolution by the angle u and the profile
// v, so its normal is ∂S/∂angle × ∂S/∂profile: the opposite of truck's revolution
// (profile first). Fusion reads it this way. Reversed sense turns it back.
Ok(revolved(profile, o, -axis * sign))
}
"SURFACE_OF_LINEAR_EXTRUSION" => {
let g = curve(cx, p.get(1).and_then(Param::as_ref_id).ok_or("extrusion without profile")?, depth + 1)?;
+84 -1
View File
@@ -8,12 +8,13 @@
//! failure wherever the rest of the file is still usable.
mod brep;
mod geom;
pub(crate) mod geom;
pub(crate) mod p21;
mod split;
use std::collections::{BTreeMap, HashMap, HashSet};
use mt::EuclideanSpace as _;
use serde::{Deserialize, Serialize};
use truck_modeling as mt;
@@ -588,6 +589,88 @@ impl<'a> Reader<'a> {
/// Check a STEP file's structure: it parses, no entity id is defined twice, every reference
/// names a defined entity, nothing but root entities (relationships, definitions,
/// presentation) is left unreferenced, and ids are dense (`#1`…`#n`). Returns the entity count.
/// Faces whose boundary runs the wrong way round their normal, as a strict reader (Fusion)
/// sees them: each face's normal is its surface's own parametric normal (∂S/∂u × ∂S/∂v, ISO
/// 10303-42), reversed when `same_sense` is false, and the outer loop must run counter-clockwise
/// seen from that side. Checked on faces whose loop spans a clear direction (patches, not bands
/// around a closed surface). Returns one message per bad face.
pub fn step_orientation_errors(text: &str) -> std::result::Result<Vec<String>, String> {
use mt::{BoundedCurve, InnerSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter};
let imp = step_import(text).map_err(|e| e.to_string())?;
let mut out = Vec::new();
for b in &imp.bodies {
let r = guard("orientation check", || {
let mut bad = Vec::new();
for (fi, f) in b.body.solid.face_iter().enumerate() {
let bounds = f.boundaries();
// Patches only: a face with several loops (a band between two rings, a face with
// holes) has no single loop direction to compare.
let [w] = bounds.as_slice() else { continue };
// A loop through a vertex twice (a figure eight round a pole) has no single direction.
let fronts: std::collections::HashSet<_> = w.edge_iter().map(|e| e.front().id()).collect();
if fronts.len() != w.len() {
continue;
}
let pts: Vec<mt::Point3> = w
.edge_iter()
.flat_map(|e| {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
(0..16).map(move |i| c.subs(t0 + (t1 - t0) * i as f64 / 16.0)).collect::<Vec<_>>()
})
.collect();
let n = pts.len();
if n < 3 {
continue;
}
let mut newell = mt::Vector3::new(0.0, 0.0, 0.0);
let mut c = mt::Vector3::new(0.0, 0.0, 0.0);
let mut span: f64 = 0.0;
for i in 0..n {
let (Some(a), Some(q)) = (pts.get(i), pts.get((i + 1) % n)) else { continue };
newell += (a.to_vec()).cross(q.to_vec());
c += a.to_vec();
span = span.max((a - pts[0]).magnitude());
}
let c = mt::Point3::from_vec(c / n as f64);
// Bands round a closed surface have no clear loop direction.
if !(newell.magnitude() > 0.05 * span * span) {
continue;
}
// The face normal averaged along the loop (each loop point is on the surface;
// the loop's centroid can be nearer the wrong side of a curved face).
let s = f.oriented_surface();
let mut normal = mt::Vector3::new(0.0, 0.0, 0.0);
let mut count = 0usize;
let mut prev = None;
for p in &pts {
if let Some(uv) = s.search_nearest_parameter(*p, prev, 100) {
prev = Some(uv);
let n = s.normal(uv.0, uv.1);
if n.x.is_finite() && n.y.is_finite() && n.z.is_finite() {
normal += n;
count += 1;
}
}
}
let _ = c;
// Only patches clearly to one side of their loop are judged: a loop on a closed
// surface (a sphere's cube-face square) bounds either part.
if count == 0 || normal.magnitude() < 0.7 * count as f64 {
continue;
}
if normal.dot(newell) < 0.0 {
bad.push(format!("{}: face {fi} runs clockwise around its normal", b.name));
}
}
Ok(bad)
})
.map_err(|e| e.to_string())?;
out.extend(r);
}
Ok(out)
}
pub fn step_validate(text: &str) -> std::result::Result<usize, String> {
let ex = p21::parse(text)?;
if let Some(d) = ex.duplicates.first() {
+50 -1
View File
@@ -11,13 +11,16 @@ fn rel(a: f64, b: f64) -> f64 {
fn round_trip(b: &Body) -> StepImport {
let text = step_export(&[b], "test").unwrap();
step_validate(&text).unwrap();
assert_eq!(crate::step_orientation_errors(&text).unwrap(), Vec::<String>::new());
let imp = step_import(&text).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
let (m0, m1) = (measure(b).unwrap(), measure(&imp.bodies[0].body).unwrap());
assert!(rel(m1.volume, m0.volume) < 1e-3, "volume {} vs {}", m1.volume, m0.volume);
assert!(rel(m1.area, m0.area) < 1e-3, "area {} vs {}", m1.area, m0.area);
assert_eq!(imp.bodies[0].file_faces, b.face_count());
// Pieces of one analytic surface are written as one face (volume and area above check
// that nothing was lost).
assert!(imp.bodies[0].file_faces <= b.face_count());
assert!(imp.bodies[0].closed);
imp
}
@@ -434,3 +437,49 @@ fn hostile_boundaries_mesh_in_bounded_time() {
}
assert!(t0.elapsed().as_secs_f64() < 60.0, "took {:?}", t0.elapsed());
}
/// A cylinder written as a surface of revolution, oriented the ISO 10303-42 way (normal
/// ∂S/∂angle × ∂S/∂profile, outward here, so `same_sense` .T.). Fusion reads such faces this
/// way; reading them with truck's profile-first normal turned faces inside out.
#[test]
fn surface_of_revolution_sense_follows_iso() {
let mut t = String::from(HEAD);
t += "#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=DIRECTION('',(0.,0.,1.));\n#3=DIRECTION('',(1.,0.,0.));\n#4=AXIS1_PLACEMENT('',#1,#2);\n";
t += "#5=CARTESIAN_POINT('',(5.,0.,0.));\n#6=VECTOR('',#2,1.);\n#7=LINE('',#5,#6);\n#8=SURFACE_OF_REVOLUTION('',#7,#4);\n";
t += "#10=CARTESIAN_POINT('',(5.,0.,10.));\n#11=VERTEX_POINT('',#5);\n#12=VERTEX_POINT('',#10);\n";
t += "#13=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n#14=CIRCLE('',#13,5.);\n#15=CARTESIAN_POINT('',(0.,0.,10.));\n#16=AXIS2_PLACEMENT_3D('',#15,#2,#3);\n#17=CIRCLE('',#16,5.);\n";
t += "#20=EDGE_CURVE('',#11,#11,#14,.T.);\n#21=EDGE_CURVE('',#12,#12,#17,.T.);\n#22=EDGE_CURVE('',#11,#12,#7,.T.);\n";
t += "#30=ORIENTED_EDGE('',*,*,#20,.T.);\n#31=ORIENTED_EDGE('',*,*,#22,.T.);\n#32=ORIENTED_EDGE('',*,*,#21,.F.);\n#33=ORIENTED_EDGE('',*,*,#22,.F.);\n";
t += "#34=EDGE_LOOP('',(#30,#31,#32,#33));\n#35=FACE_OUTER_BOUND('',#34,.T.);\n#36=ADVANCED_FACE('',(#35),#8,.T.);\n";
t += "#40=PLANE('',#13);\n#41=ORIENTED_EDGE('',*,*,#20,.F.);\n#42=EDGE_LOOP('',(#41));\n#43=FACE_OUTER_BOUND('',#42,.T.);\n#44=ADVANCED_FACE('',(#43),#40,.F.);\n";
t += "#50=PLANE('',#16);\n#51=ORIENTED_EDGE('',*,*,#21,.T.);\n#52=EDGE_LOOP('',(#51));\n#53=FACE_OUTER_BOUND('',#52,.T.);\n#54=ADVANCED_FACE('',(#53),#50,.T.);\n";
t += "#60=CLOSED_SHELL('',(#36,#44,#54));\n#61=MANIFOLD_SOLID_BREP('Cyl',#60);\n#62=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#61),#70);\n";
t += &context(70, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
t += TAIL;
assert_eq!(step_orientation_errors(&t).unwrap(), Vec::<String>::new());
let imp = step_import(&t).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
let m = measure(&imp.bodies[0].body).unwrap();
let want = std::f64::consts::PI * 25.0 * 10.0;
assert!(rel(m.volume, want) < 1e-3, "{} vs {want}", m.volume);
// The same face with the opposite sense is inside out.
let flipped = t.replace("#36=ADVANCED_FACE('',(#35),#8,.T.);", "#36=ADVANCED_FACE('',(#35),#8,.F.);");
let bad = step_import(&flipped).unwrap();
let v = measure(&bad.bodies[0].body).map(|m| m.volume).unwrap_or(0.0);
assert!(!bad.warnings.is_empty() || rel(v, want) > 1e-2, "{v}");
}
/// A tube revolved about a line it touches (a horn torus, as a tight sweep makes) is written as
/// one rational B-spline face whose loop passes the pole twice, with no zero-length edge there:
/// no TOROIDAL_SURFACE (it would be degenerate) and no surface of revolution.
#[test]
fn horn_torus_written_as_one_spline_face() {
let prof = Region2 { outer: Loop2::circle(Vec2::new(5.0, 0.0), 5.0), holes: vec![] };
let b = revolve(&Plane::XZ, &[prof], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
let text = step_export(&[&b], "t").unwrap();
assert!(!text.contains("TOROIDAL_SURFACE") && !text.contains("SURFACE_OF_REVOLUTION"));
assert_eq!(text.matches("ADVANCED_FACE(").count(), 3, "{text}");
let imp = round_trip(&b);
let v = std::f64::consts::PI * 25.0 * std::f64::consts::TAU * 5.0 / 4.0;
assert!(rel(measure(&imp.bodies[0].body).unwrap().volume, v) < 1e-3);
}
+702 -1
View File
@@ -147,6 +147,8 @@ struct Out {
lines: Vec<String>,
/// Geometry text → id (shared entities).
shared: HashMap<String, u64>,
/// Surfaces written with the opposite normal to the one they replace.
flipped: std::collections::HashSet<u64>,
}
impl Out {
@@ -169,6 +171,59 @@ impl Out {
fn direction(&mut self, d: [f64; 3]) -> u64 {
self.add_shared(format!("DIRECTION('',({},{},{}))", real(d[0]), real(d[1]), real(d[2])))
}
fn write_analytic(&mut self, a: &Analytic) -> u64 {
match a.kind {
AnalyticKind::Plane { o, z, x } => {
let ax = self.placement(o, z, x);
self.add_shared(format!("PLANE('',#{ax})"))
}
AnalyticKind::Cylinder { o, z, x, r } => {
let ax = self.placement(o, z, x);
self.add_shared(format!("CYLINDRICAL_SURFACE('',#{ax},{})", real(r)))
}
AnalyticKind::Cone { o, z, x, r, angle } => {
let ax = self.placement(o, z, x);
self.add_shared(format!("CONICAL_SURFACE('',#{ax},{},{})", real(r), real(angle)))
}
AnalyticKind::Sphere { o, z, x, r } => {
let ax = self.placement(o, z, x);
self.add_shared(format!("SPHERICAL_SURFACE('',#{ax},{})", real(r)))
}
AnalyticKind::Torus { o, z, x, big, small } => {
let ax = self.placement(o, z, x);
self.add_shared(format!("TOROIDAL_SURFACE('',#{ax},{},{})", real(big), real(small)))
}
AnalyticKind::HornTorus { o, z, x, big, small } => {
// Nine-point rational quadratic circles (quarter arcs, corner weights √½): the
// tube from the pole round and back, and the turn about z from x.
let y = cross(z, x);
let ring = |k: usize| {
let ang = std::f64::consts::FRAC_PI_4 * k as f64;
let (scale, w) = if k % 2 == 1 { (std::f64::consts::SQRT_2, std::f64::consts::FRAC_1_SQRT_2) } else { (1.0, 1.0) };
(ang.cos() * scale, ang.sin() * scale, w)
};
let (mut rows, mut weights) = (Vec::new(), Vec::new());
for i in 0..9 {
let (ci, si, wi) = ring(i);
let (rho, h) = (big - small * ci, small * si);
let (mut row, mut wrow) = (Vec::new(), Vec::new());
for j in 0..9 {
let (cj, sj, wj) = ring(j);
let pt = add(add(o, mul(z, h)), add(mul(x, rho * cj), mul(y, rho * sj)));
row.push(format!("#{}", self.point(pt)));
wrow.push(real(wi * wj));
}
rows.push(format!("({})", row.join(",")));
weights.push(format!("({})", wrow.join(",")));
}
self.add_shared(format!(
"(BOUNDED_SURFACE()B_SPLINE_SURFACE(2,2,({}),.UNSPECIFIED.,.T.,.T.,.F.)B_SPLINE_SURFACE_WITH_KNOTS((3,2,2,2,3),(3,2,2,2,3),(0.,0.25,0.5,0.75,1.),(0.,0.25,0.5,0.75,1.),.UNSPECIFIED.)GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE(({}))REPRESENTATION_ITEM('')SURFACE())",
rows.join(","),
weights.join(",")
))
}
}
}
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})"))
@@ -177,6 +232,7 @@ impl Out {
/// Copy a solid entity of a parsed file and everything it uses; returns its new id.
fn copy_solid(&mut self, ex: &p21::Exchange, root: u64, name: &str) -> std::result::Result<u64, String> {
let mut map: HashMap<u64, u64> = HashMap::new();
let seams = horn_seams(ex);
// Iterative post-order walk (children before parents).
let mut stack: Vec<(u64, bool)> = vec![(root, false)];
let mut guard = 0usize;
@@ -189,6 +245,24 @@ impl Out {
continue;
}
let e = ex.get(id).ok_or_else(|| format!("missing #{id}"))?;
// Revolutions with analytic profiles become cylinders, cones, spheres, tori and
// planes (what other systems recognise), with the face sense corrected.
if !done
&& e.name() == "SURFACE_OF_REVOLUTION"
&& let Some(mut a) = analytic_revolution(ex, e)
{
if let AnalyticKind::HornTorus { x, .. } = &mut a.kind
&& let Some(s) = seams.get(&id)
{
*x = *s;
}
let new = self.write_analytic(&a);
if a.flip {
self.flipped.insert(new);
}
map.insert(id, new);
continue;
}
if !done {
stack.push((id, true));
let mut refs = Vec::new();
@@ -223,6 +297,13 @@ impl Out {
if is_root && let Some(first) = ps.first_mut() {
*first = Param::Str(name.to_string());
}
// A face on a surface written with the opposite normal flips its sense.
if ename == "ADVANCED_FACE"
&& ps.get(2).and_then(Param::as_ref_id).and_then(|g| map.get(&g)).is_some_and(|g| self.flipped.contains(g))
&& let Some(Param::Enum(sense)) = ps.get_mut(3)
{
*sense = if sense == "T" { "F".into() } else { "T".into() };
}
params(&ps, &map, &mut text)?;
text.push(')');
}
@@ -234,10 +315,630 @@ impl Out {
}
}
/// An analytic surface found behind a surface of revolution.
struct Analytic {
kind: AnalyticKind,
/// The analytic surface's normal is opposite to the revolution's (ISO 10303-42 normals).
flip: bool,
}
enum AnalyticKind {
Plane {
o: V3,
z: V3,
x: V3,
},
Cylinder {
o: V3,
z: V3,
x: V3,
r: f64,
},
Cone {
o: V3,
z: V3,
x: V3,
r: f64,
angle: f64,
},
Sphere {
o: V3,
z: V3,
x: V3,
r: f64,
},
Torus {
o: V3,
z: V3,
x: V3,
big: f64,
small: f64,
},
/// A torus whose tube reaches its axis (a horn torus, as a tight sweep makes): no valid
/// TOROIDAL_SURFACE, so a rational B-spline (tube angle from the pole at the axis, then the
/// turn from x), as other systems write it. x is the turn's parameter seam.
HornTorus {
o: V3,
z: V3,
x: V3,
big: f64,
small: f64,
},
}
type V3 = [f64; 3];
fn sub(a: V3, b: V3) -> V3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: V3, b: V3) -> V3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn mul(a: V3, k: f64) -> V3 {
[a[0] * k, a[1] * k, a[2] * k]
}
fn dot(a: V3, b: V3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: V3, b: V3) -> V3 {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn norm(a: V3) -> f64 {
dot(a, a).sqrt()
}
fn unit(a: V3) -> Option<V3> {
let n = norm(a);
(n > 1e-12 && n.is_finite()).then(|| mul(a, 1.0 / n))
}
/// Circle through three points: centre, radius, unit normal.
fn circle3(a: V3, b: V3, c: V3) -> Option<(V3, f64, V3)> {
let (ab, ac) = (sub(b, a), sub(c, a));
let n = cross(ab, ac);
let n2 = dot(n, n);
if !(n2 > 1e-24) {
return None;
}
let t = add(mul(cross(n, ab), dot(ac, ac)), mul(cross(ac, n), dot(ab, ab)));
let center = add(a, mul(t, 0.5 / n2));
Some((center, norm(sub(a, center)), unit(n)?))
}
/// The analytic surface a surface of revolution is, if its profile is a line or a circular arc
/// in a plane through the axis.
fn analytic_revolution(ex: &p21::Exchange, e: &p21::Entity) -> Option<Analytic> {
use crate::step_in::geom::{self, CurveGeo};
use truck_modeling::{BoundedCurve, ParametricCurve};
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
let p = e.params();
let (ao, ad) = geom::axis1(&cx, p.get(2)?.as_ref_id()?).ok()?;
let (oa, a) = ([ao.x, ao.y, ao.z], [ad.x, ad.y, ad.z]);
let g = geom::curve(&cx, p.get(1)?.as_ref_id()?, 0).ok()?;
let radial = |q: V3| {
let v = sub(q, oa);
sub(v, mul(a, dot(v, a)))
};
let on_axis = |q: V3| add(oa, mul(a, dot(sub(q, oa), a)));
// Profile samples and tangents.
let eval: Box<dyn Fn(f64) -> V3> = match &g {
CurveGeo::Line { p, d } => {
let (p, d) = ([p.x, p.y, p.z], [d.x, d.y, d.z]);
Box::new(move |t| add(p, mul(d, t)))
}
CurveGeo::BSpline(c) => {
let c = c.clone();
let (t0, t1) = c.range_tuple();
Box::new(move |s| {
let q = c.subs(t0 + (t1 - t0) * s);
[q.x, q.y, q.z]
})
}
CurveGeo::Nurbs(c) => {
let c = c.clone();
let (t0, t1) = c.range_tuple();
Box::new(move |s| {
let q = c.subs(t0 + (t1 - t0) * s);
[q.x, q.y, q.z]
})
}
CurveGeo::Conic { .. } => return None,
};
// A sample point off the axis, its revolution normal (∂S/∂angle × ∂S/∂profile).
let sample =
[0.37, 0.61, 0.23, 0.79].into_iter().map(|t| (eval(t), sub(eval(t + 1e-4), eval(t - 1e-4)))).find(|(q, _)| norm(radial(*q)) > 1e-6)?;
let (q, dq) = sample;
let n_rev = cross(cross(a, sub(q, oa)), dq);
let eq = unit(radial(q))?;
let (kind, n_an) = match &g {
CurveGeo::Line { d, .. } => {
let d = unit([d.x, d.y, d.z])?;
// Coplanar with the axis?
if dot(cross(d, a), eq).abs() > 1e-9 {
return None;
}
let (da, dr) = (dot(d, a), dot(d, eq));
if dr.abs() < 1e-12 {
let r = norm(radial(q));
(AnalyticKind::Cylinder { o: oa, z: a, x: eq, r }, eq)
} else if da.abs() < 1e-12 {
(AnalyticKind::Plane { o: on_axis(q), z: a, x: eq }, a)
} else {
// Open the cone along +z: the radius grows that way.
let z = if dr * da > 0.0 { a } else { mul(a, -1.0) };
let angle = (dr.abs() / da.abs()).atan();
let r = norm(radial(q));
let n = sub(mul(eq, angle.cos()), mul(z, angle.sin()));
(AnalyticKind::Cone { o: on_axis(q), z, x: eq, r, angle }, n)
}
}
_ => {
let (c, r, nc) = circle3(eval(0.0), eval(0.5), eval(1.0))?;
// Every sample on the circle, the circle's plane through the axis.
if (0..=8).any(|i| (norm(sub(eval(i as f64 / 8.0), c)) - r).abs() > 1e-7 * (1.0 + r)) {
return None;
}
if dot(nc, a).abs() > 1e-9 || dot(sub(c, oa), nc).abs() > 1e-7 * (1.0 + r) {
return None;
}
let big = norm(radial(c));
if big < 1e-9 * (1.0 + r) {
(AnalyticKind::Sphere { o: c, z: a, x: eq, r }, sub(q, c))
} else {
let ec = unit(radial(c))?;
let o = on_axis(c);
if (big - r).abs() <= 1e-6 * (1.0 + r) {
(AnalyticKind::HornTorus { o, z: a, x: mul(ec, -1.0), big: r, small: r }, sub(q, add(o, mul(eq, r))))
} else if big < r {
// A spindle torus (tube crossing the axis) stays a surface of revolution.
return None;
} else {
(AnalyticKind::Torus { o, z: a, x: ec, big, small: r }, sub(q, add(o, mul(eq, big))))
}
}
}
};
let flip = dot(n_rev, n_an) < 0.0;
if !dot(n_rev, n_an).is_finite() || dot(n_rev, n_an).abs() < 1e-12 {
return None;
}
Some(Analytic { kind, flip })
}
/// Two analytic surfaces are the same surface (same kind, frame-independent parameters).
fn same_analytic(a: &AnalyticKind, b: &AnalyticKind) -> bool {
let close = |x: f64, y: f64| (x - y).abs() <= 1e-7 * (1.0 + x.abs().max(y.abs()));
let same_pt = |p: V3, q: V3| norm(sub(p, q)) <= 1e-7 * (1.0 + norm(p));
let parallel = |p: V3, q: V3| norm(cross(p, q)) <= 1e-9;
let on_line = |p: V3, o: V3, d: V3| norm(cross(sub(p, o), d)) <= 1e-7 * (1.0 + norm(p));
match (a, b) {
(AnalyticKind::Cylinder { o, z, r, .. }, AnalyticKind::Cylinder { o: o2, z: z2, r: r2, .. }) => {
parallel(*z, *z2) && on_line(*o2, *o, *z) && close(*r, *r2)
}
(AnalyticKind::Cone { o, z, r, angle, .. }, AnalyticKind::Cone { o: o2, z: z2, r: r2, angle: a2, .. }) => {
// Same apex, axis direction and angle.
let apex = |o: V3, z: V3, r: f64, an: f64| sub(o, mul(z, r / an.tan()));
dot(*z, *z2) > 1.0 - 1e-9 && close(*angle, *a2) && same_pt(apex(*o, *z, *r, *angle), apex(*o2, *z2, *r2, *a2))
}
(AnalyticKind::Sphere { o, r, .. }, AnalyticKind::Sphere { o: o2, r: r2, .. }) => same_pt(*o, *o2) && close(*r, *r2),
(AnalyticKind::Torus { o, z, big, small, .. }, AnalyticKind::Torus { o: o2, z: z2, big: b2, small: s2, .. }) => {
same_pt(*o, *o2) && parallel(*z, *z2) && close(*big, *b2) && close(*small, *s2)
}
(AnalyticKind::HornTorus { o, z, big, .. }, AnalyticKind::HornTorus { o: o2, z: z2, big: b2, .. }) => {
same_pt(*o, *o2) && parallel(*z, *z2) && close(*big, *b2)
}
(AnalyticKind::Plane { o, z, .. }, AnalyticKind::Plane { o: o2, z: z2, .. }) => {
parallel(*z, *z2) && dot(sub(*o2, *o), *z).abs() <= 1e-7 * (1.0 + norm(*o))
}
_ => false,
}
}
/// The outward normal sign of a face relative to its analytic surface's normal.
fn face_normal_sign(a: &AnalyticKind, z_written: V3, flip: bool, sense: bool) -> f64 {
// Planes compare their written normal directly; the others are always outward-normal kinds.
let base = match a {
AnalyticKind::Plane { z, .. } => dot(*z, z_written).signum(),
_ => 1.0,
};
base * if flip != sense { 1.0 } else { -1.0 }
}
fn vertex_point(ex: &p21::Exchange, v: u64) -> Option<V3> {
let p = ex.get(ex.get(v)?.params().get(1)?.as_ref_id()?)?;
let c = p.params().get(1)?.as_list()?;
Some([c.first()?.as_f64()?, c.get(1)?.as_f64()?, c.get(2)?.as_f64()?])
}
/// For each horn torus revolution, a turn direction away from its faces (the B-spline's
/// parameter seam must not cross them): opposite their vertices' mean direction about the axis.
fn horn_seams(ex: &p21::Exchange) -> HashMap<u64, V3> {
let mut out = HashMap::new();
for fe in ex.entities.values() {
if !matches!(fe.name(), "FACE_SURFACE" | "ADVANCED_FACE") {
continue;
}
let Some(sid) = fe.params().get(2).and_then(Param::as_ref_id) else { continue };
let Some(se) = ex.get(sid).filter(|e| e.name() == "SURFACE_OF_REVOLUTION") else { continue };
let Some(Analytic { kind: AnalyticKind::HornTorus { o, z, .. }, .. }) = analytic_revolution(ex, se) else { continue };
let Some(loops) = face_edges(ex, fe) else { continue };
let mut mean = [0.0; 3];
for (e, _) in loops.iter().flatten() {
for k in [1, 2] {
let Some(q) = ex.get(*e).and_then(|e| e.params().get(k)).and_then(Param::as_ref_id).and_then(|v| vertex_point(ex, v)) else {
continue;
};
let v = sub(q, o);
if let Some(r) = unit(sub(v, mul(z, dot(v, z)))) {
mean = add(mean, r);
}
}
}
if let Some(m) = unit(mean) {
out.insert(sid, mul(m, -1.0));
}
}
out
}
/// Zero-length edges (truck sweeps a profile point on the axis into one) removed and their
/// vertices joined: other systems reject a pole written as an ordinary edge.
fn collapse_short_edges(ex: &mut p21::Exchange) {
let ends = |e: &p21::Entity| Some((e.params().get(1)?.as_ref_id()?, e.params().get(2)?.as_ref_id()?));
let mut join: HashMap<u64, u64> = HashMap::new();
let mut dead = std::collections::HashSet::new();
let mut ids: Vec<u64> = ex.entities.iter().filter(|(_, e)| e.name() == "EDGE_CURVE").map(|(i, _)| *i).collect();
ids.sort();
for id in ids {
let Some(e) = ex.get(id) else { continue };
let Some((a, b)) = ends(e) else { continue };
let (Some(pa), Some(pb)) = (vertex_point(ex, a), vertex_point(ex, b)) else { continue };
if a == b || norm(sub(pa, pb)) > 1e-6 * (1.0 + norm(pa)) {
continue;
}
// The curve itself must be short too (not a whole circle between two coincident vertices).
let short = e.params().get(3).and_then(Param::as_ref_id).and_then(|c| {
use truck_modeling::{BoundedCurve, ParametricCurve};
let cx = crate::step_in::Ctx { ex, len: 1.0, ang: 1.0, tol: 1e-6 };
let mid = match crate::step_in::geom::curve(&cx, c, 0).ok()? {
crate::step_in::geom::CurveGeo::Line { .. } => return Some(true),
crate::step_in::geom::CurveGeo::BSpline(c) => c.subs((c.range_tuple().0 + c.range_tuple().1) / 2.0),
crate::step_in::geom::CurveGeo::Nurbs(c) => c.subs((c.range_tuple().0 + c.range_tuple().1) / 2.0),
crate::step_in::geom::CurveGeo::Conic { .. } => return Some(false),
};
Some(norm(sub([mid.x, mid.y, mid.z], pa)) <= 1e-6 * (1.0 + norm(pa)))
});
if short != Some(true) {
continue;
}
let find = |join: &HashMap<u64, u64>, mut v: u64| {
while let Some(n) = join.get(&v) {
v = *n;
}
v
};
let (ra, rb) = (find(&join, a), find(&join, b));
if ra != rb {
join.insert(rb, ra);
}
dead.insert(id);
}
if dead.is_empty() {
return;
}
// A loop made only of such edges (a pole loop) keeps them all.
let oe_edge = |ex: &p21::Exchange, oe: &Param| oe.as_ref_id().and_then(|o| ex.get(o)).and_then(|o| o.params().get(3)).and_then(Param::as_ref_id);
let loops: Vec<u64> = ex.entities.iter().filter(|(_, e)| e.name() == "EDGE_LOOP").map(|(i, _)| *i).collect();
for l in &loops {
let Some(list) = ex.get(*l).and_then(|e| e.params().get(1)).and_then(Param::as_list) else { continue };
if list.iter().all(|oe| oe_edge(ex, oe).is_some_and(|e| dead.contains(&e))) {
return;
}
}
for l in loops {
let Some(list) = ex.get(l).and_then(|e| e.params().get(1)).and_then(Param::as_list) else { continue };
let kept: Vec<Param> = list.iter().filter(|oe| !oe_edge(ex, oe).is_some_and(|e| dead.contains(&e))).cloned().collect();
if let Some(r) = ex.entities.get_mut(&l).and_then(|e| e.records.first_mut())
&& let Some(slot) = r.params.get_mut(1)
{
*slot = Param::List(kept);
}
}
let find = |mut v: u64| {
while let Some(n) = join.get(&v) {
v = *n;
}
v
};
for e in ex.entities.values_mut() {
if e.name() != "EDGE_CURVE" {
continue;
}
if let Some(r) = e.records.first_mut() {
for k in [1, 2] {
if let Some(slot) = r.params.get_mut(k)
&& let Some(v) = slot.as_ref_id()
{
*slot = Param::Ref(find(v));
}
}
}
}
}
/// Oriented edges (edge id, forward) of a face's bounds.
fn face_edges(ex: &p21::Exchange, face: &p21::Entity) -> Option<Vec<Vec<(u64, bool)>>> {
let mut loops = Vec::new();
for b in face.params().get(1)?.as_list()? {
let fb = ex.get(b.as_ref_id()?)?;
let lp = ex.get(fb.params().get(1)?.as_ref_id()?)?;
if lp.name() != "EDGE_LOOP" {
return None;
}
let orient = fb.params().get(2)?.as_bool()?;
let mut v = Vec::new();
for oe in lp.params().get(1)?.as_list()? {
let oe = ex.get(oe.as_ref_id()?)?;
if oe.name() != "ORIENTED_EDGE" {
return None;
}
v.push((oe.params().get(3)?.as_ref_id()?, oe.params().get(4)?.as_bool()?));
}
if !orient {
v.reverse();
v.iter_mut().for_each(|e| e.1 = !e.1);
}
loops.push(v);
}
Some(loops)
}
/// 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) {
let mut next = ex.entities.keys().max().copied().unwrap_or(0) + 1;
let shells: Vec<u64> = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "CLOSED_SHELL" | "OPEN_SHELL")).map(|(i, _)| *i).collect();
for sh in shells {
for _round in 0..64 {
let Some(faces) = ex
.get(sh)
.and_then(|e| e.params().get(1))
.and_then(Param::as_list)
.map(|l| l.iter().filter_map(Param::as_ref_id).collect::<Vec<u64>>())
else {
break;
};
// Analytic description and normal sign of each face on a revolution.
let info: Vec<Option<(AnalyticKind, f64)>> = faces
.iter()
.map(|f| {
let fe = ex.get(*f)?;
if !matches!(fe.name(), "FACE_SURFACE" | "ADVANCED_FACE") {
return None;
}
let se = ex.get(fe.params().get(2)?.as_ref_id()?)?;
if se.name() != "SURFACE_OF_REVOLUTION" {
return None;
}
let an = analytic_revolution(ex, se)?;
let sense = fe.params().get(3)?.as_bool()?;
let z = match an.kind {
AnalyticKind::Plane { z, .. } => z,
_ => [0.0; 3],
};
let sign = face_normal_sign(&an.kind, z, an.flip, sense);
// Planes: normalise to one written direction for comparisons.
Some((an.kind, sign))
})
.collect();
let mut merged = false;
'pairs: for i in 0..faces.len() {
for j in (i + 1)..faces.len() {
let (Some(Some((ka, sa))), Some(Some((kb, sb)))) = (info.get(i), info.get(j)) else { continue };
if !same_analytic(ka, kb) {
continue;
}
let plane_dir = |k: &AnalyticKind| match k {
AnalyticKind::Plane { z, .. } => *z,
_ => [0.0, 0.0, 1.0],
};
let (na, nb) = (mul(plane_dir(ka), *sa), mul(plane_dir(kb), *sb));
if dot(na, nb) <= 0.0 {
continue;
}
let (Some(&fa), Some(&fb)) = (faces.get(i), faces.get(j)) else { continue };
let (Some(ea), Some(eb)) = (ex.get(fa).and_then(|f| face_edges(ex, f)), ex.get(fb).and_then(|f| face_edges(ex, f))) else {
continue;
};
let all: Vec<(u64, bool)> = ea.into_iter().chain(eb).flatten().collect();
let shared: std::collections::HashSet<u64> =
all.iter().filter(|(e, d)| all.iter().any(|(e2, d2)| e2 == e && d2 != d)).map(|(e, _)| *e).collect();
if shared.is_empty() {
continue;
}
// Chain oriented edges into loops by vertices (None: they do not close).
let ends = |(e, d): (u64, bool)| -> Option<(u64, u64)> {
let p = ex.get(e)?.params();
let (s0, s1) = (p.get(1)?.as_ref_id()?, p.get(2)?.as_ref_id()?);
Some(if d { (s0, s1) } else { (s1, s0) })
};
let chain = |mut pool: Vec<(u64, bool)>| -> Option<Vec<Vec<(u64, bool)>>> {
let mut loops = Vec::new();
while let Some(first) = pool.pop() {
let (start, mut at) = ends(first)?;
let mut lp = vec![first];
while at != start {
if lp.len() > 100_000 {
return None;
}
let k = pool.iter().position(|x| ends(*x).is_some_and(|(s, _)| s == at))?;
let x = pool.remove(k);
at = ends(x)?.1;
lp.push(x);
}
loops.push(lp);
}
Some(loops)
};
// A full turn keeps one shared edge as the face's seam, used both ways (as other
// systems write cylinders and cone tips); otherwise the shared edges go.
// (A horn torus band needs no seam: its rings meet at the pole.)
let mut loops = None;
if shared.len() >= 2 && !matches!(ka, AnalyticKind::HornTorus { .. }) {
let mut cands: Vec<u64> = shared.iter().copied().collect();
cands.sort();
for keep in cands {
let pool: Vec<(u64, bool)> = all.iter().copied().filter(|(e, _)| *e == keep || !shared.contains(e)).collect();
let Some(mut l) = chain(pool) else { continue };
// The seam may come out as its own back-and-forth loop: splice it into
// the loop that passes its start vertex.
if l.len() == 2
&& let Some(si) = l.iter().position(|x| x.len() == 2 && x.iter().all(|(e, _)| *e == keep))
{
let mut seam_loop = l.remove(si);
// Start the seam pair at the vertex it shares with the main loop.
for _ in 0..2 {
let on_main = seam_loop
.first()
.and_then(|f| ends(*f))
.is_some_and(|(v, _)| l.first().is_some_and(|m| m.iter().any(|x| ends(*x).is_some_and(|(_, e)| e == v))));
if on_main {
break;
}
seam_loop.rotate_left(1);
}
let Some(&first) = seam_loop.first() else { continue };
let Some((v, _)) = ends(first) else { continue };
if let Some(main) = l.first_mut()
&& let Some(pos) = main.iter().position(|x| ends(*x).is_some_and(|(_, e)| e == v))
{
for (k, x) in seam_loop.into_iter().enumerate() {
main.insert(pos + 1 + k, x);
}
let merged_loop = std::mem::take(main);
l = vec![merged_loop];
}
}
if l.len() == 1 {
loops = Some(l);
break;
}
}
}
if loops.is_none() {
let rest: Vec<(u64, bool)> = all.iter().copied().filter(|(e, _)| !shared.contains(e)).collect();
loops = chain(rest)
.map(|mut l| {
// Rings meeting at a vertex (both ends of a horn torus band meet at
// its pole) form one figure-eight loop through it.
while l.len() > 1 {
let Some(second) = l.pop() else { break };
let at = l.iter().enumerate().find_map(|(li, lp)| {
lp.iter().enumerate().find_map(|(k, x)| {
let v = ends(*x)?.1;
let s = second.iter().position(|y| ends(*y).is_some_and(|(a, _)| a == v))?;
Some((li, k, s))
})
});
let Some((li, k, s)) = at else {
l.push(second);
break;
};
let mut second = second;
second.rotate_left(s);
if let Some(lp) = l.get_mut(li) {
for (n, x) in second.into_iter().enumerate() {
lp.insert(k + 1 + n, x);
}
}
}
l
})
.filter(|l| l.len() == 1);
}
let Some(loops) = loops else { continue };
// A loop of seams only (a whole sphere) bounds nothing: keep the faces apart.
if loops.iter().all(|l| l.iter().all(|(e, _)| l.iter().filter(|(e2, _)| e2 == e).count() > 1)) {
continue;
}
// Write the merged face.
let mut bounds = Vec::new();
for lp in loops {
let mut oes = Vec::new();
for (e, d) in lp {
let id = next;
next += 1;
ex.entities.insert(
id,
p21::Entity {
records: vec![p21::Record {
name: "ORIENTED_EDGE".into(),
params: vec![
Param::Str(String::new()),
Param::Derived,
Param::Derived,
Param::Ref(e),
Param::Enum(if d { "T" } else { "F" }.into()),
],
}],
},
);
oes.push(Param::Ref(id));
}
let lid = next;
next += 1;
ex.entities.insert(
lid,
p21::Entity {
records: vec![p21::Record { name: "EDGE_LOOP".into(), params: vec![Param::Str(String::new()), Param::List(oes)] }],
},
);
let bid = next;
next += 1;
ex.entities.insert(
bid,
p21::Entity {
records: vec![p21::Record {
name: "FACE_BOUND".into(),
params: vec![Param::Str(String::new()), Param::Ref(lid), Param::Enum("T".into())],
}],
},
);
bounds.push(Param::Ref(bid));
}
let Some(old) = ex.get(fa).cloned() else { continue };
let mut params = old.params().to_vec();
if let Some(slot) = params.get_mut(1) {
*slot = Param::List(bounds);
}
let nid = next;
next += 1;
ex.entities.insert(nid, p21::Entity { records: vec![p21::Record { name: old.name().to_string(), params }] });
let new_list: Vec<Param> = faces.iter().filter(|f| **f != fb).map(|f| Param::Ref(if *f == fa { nid } else { *f })).collect();
if let Some(she) = ex.entities.get_mut(&sh)
&& let Some(r) = she.records.first_mut()
&& let Some(slot) = r.params.get_mut(1)
{
*slot = Param::List(new_list);
}
merged = true;
break 'pairs;
}
}
if !merged {
break;
}
}
}
}
/// The B-rep entities of one body as truck writes them, parsed.
fn brep_exchange(b: &Body) -> Result<p21::Exchange> {
let text = crate::step::truck_step(&[b], "SolveCraft")?;
p21::parse(&text).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))
let mut ex = p21::parse(&text).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?;
collapse_short_edges(&mut ex);
merge_split_faces(&mut ex);
Ok(ex)
}
fn rigid_frame(m: &[[f64; 4]; 4]) -> Result<([f64; 3], [f64; 3], [f64; 3])> {