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 <[email protected]> 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
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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])> {
|
||||
|
||||
Reference in new issue
Block a user