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
@@ -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