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:
1 parent
178fc47ed7
commit
7382ebd6e8
12 files changed
+958
-85
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user