STEP import: a face bounded by seams alone runs its loop the other way, never turns over

A loop that uses each of its edges once each way (a whole torus within its tube and ring seams)
is the same set of uses run backwards, so it says nothing of the face's side. One written
clockwise in (u, v) turned the face over, against its same_sense: the torus came in inside out
with a warning. Such a loop is now reversed and the face keeps its side.

Test: seam_only_torus_imports_as_a_whole_ring, both orders of the two seams, volume 2π²Rr².

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-31 09:39:30 -04:00
committed by jyviko
parent b0d1ba3457
commit 77f127c61e
2 changed files with 59 additions and 3 deletions
+37
View File
@@ -144,6 +144,43 @@ fn non_antipodal_seam_sphere_imports_as_a_whole_ball() {
assert_seam_sphere(&text, 2.0);
}
/// A whole ring torus (major radius 10, minor 3, about Z) written as one face bounded only by
/// seams: its tube circle at u = 0 and its ring circle at v = 0, each run out and back, in
/// either order round the loop. Each is the whole torus, volume 2π²Rr² and area 4π²Rr.
#[test]
fn seam_only_torus_imports_as_a_whole_ring() {
use std::f64::consts::PI;
let (major, minor) = (10.0_f64, 3.0_f64);
let outer = major + minor;
let tube = format!(
"#14=CARTESIAN_POINT('',({major:.16},0.,0.));\n#15=DIRECTION('',(0.,-1.,0.));\n#16=AXIS2_PLACEMENT_3D('',#14,#15,#3);\n#17=CIRCLE('',#16,{minor:.16});\n"
);
let ring = format!("#18=CIRCLE('',#4,{outer:.16});\n#19=EDGE_CURVE('',#11,#11,#18,.T.);\n");
let cases = [
("tube then ring", format!("{tube}#20=EDGE_CURVE('',#11,#11,#17,.T.);\n{ring}#30=EDGE_LOOP('',(#21,#22,#23,#24));\n")),
("ring then tube", format!("{tube}#20=EDGE_CURVE('',#11,#11,#17,.T.);\n{ring}#30=EDGE_LOOP('',(#22,#21,#24,#23));\n")),
];
for (what, edges) in cases {
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=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n";
t += &format!("#5=TOROIDAL_SURFACE('',#4,{major:.16},{minor:.16});\n#10=CARTESIAN_POINT('',({outer:.16},0.,0.));\n#11=VERTEX_POINT('',#10);\n");
t += &edges;
t += "#21=ORIENTED_EDGE('',*,*,#20,.T.);\n#22=ORIENTED_EDGE('',*,*,#19,.T.);\n#23=ORIENTED_EDGE('',*,*,#20,.F.);\n#24=ORIENTED_EDGE('',*,*,#19,.F.);\n";
t += "#31=FACE_OUTER_BOUND('',#30,.T.);\n#32=ADVANCED_FACE('',(#31),#5,.T.);\n#33=CLOSED_SHELL('',(#32));\n#34=MANIFOLD_SOLID_BREP('Ring',#33);\n";
t += "#35=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#34),#70);\n";
t += &context(70, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
t += TAIL;
let imp = step_import(&t).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(imp.bodies.len(), 1, "{what}: {:?}", imp.warnings);
assert!(imp.warnings.is_empty(), "{what}: {:?}", imp.warnings);
assert!(imp.bodies[0].closed, "{what}");
let m = measure(&imp.bodies[0].body).unwrap();
let (volume, area) = (2.0 * PI * PI * major * minor * minor, 4.0 * PI * PI * major * minor);
assert!(rel(m.volume, volume) < 1e-3, "{what}: volume {} vs {volume}", m.volume);
assert!(rel(m.area, area) < 1e-3, "{what}: area {} vs {area}", m.area);
}
}
#[test]
#[ignore = "manual STEP import timing; run without concurrent builds"]
fn benchmark_independent_step_solids() {
+22 -3
View File
@@ -1433,16 +1433,24 @@ impl<'b, 'a> Builder<'b, 'a> {
surface,
BSurface::Plane { .. } | BSurface::Cylinder { .. } | BSurface::Cone { .. } | BSurface::Spline { .. } | BSurface::RationalSpline { .. }
);
let flip = can_turn && shapes.get(outer).copied().flatten().is_some_and(|a| a < 0.0);
if flip {
let clockwise = shapes.get(outer).copied().flatten().is_some_and(|a| a < 0.0);
// A face bounded by seam uses alone (a whole torus within its two seams) has loops that
// run each edge both ways: run backwards they are the same uses, so they say nothing of
// the face's side. One running clockwise is run the other way; the face keeps its side.
let seams_only = edge_loops.iter().all(|l| seam_only(l));
let flip = can_turn && clockwise && !seams_only;
let rerun = flip || (clockwise && seams_only);
if rerun {
for l in &mut face.loops {
if let LoopB::Edges(es) = l {
reverse(es);
}
}
}
if flip {
face.forward = !face.forward;
}
let pcs: Vec<Vec<Curve2>> = if flip { pcs.into_iter().map(|l| l.into_iter().rev().collect()).collect() } else { pcs };
let pcs: Vec<Vec<Curve2>> = if rerun { pcs.into_iter().map(|l| l.into_iter().rev().collect()).collect() } else { pcs };
let domain = face_domain(&surface, &pcs)?;
Ok((pcs, domain, flip, form))
}
@@ -1530,6 +1538,17 @@ fn natural_box(s: &BSurface) -> Option<[[f64; 2]; 2]> {
}
}
/// Every edge of the loop is used in it exactly twice, once each way: a loop of seam uses alone.
fn seam_only(uses: &[(u32, bool)]) -> bool {
let mut ways: HashMap<u32, [u32; 2]> = HashMap::with_capacity(uses.len() / 2);
for &(e, forward) in uses {
if let Some(n) = ways.entry(e).or_default().get_mut(usize::from(forward)) {
*n += 1;
}
}
!uses.is_empty() && ways.values().all(|n| *n == [1, 1])
}
/// The loop run the other way.
fn reverse(uses: &mut [(u32, bool)]) {
uses.reverse();