IGES 5.3 import and export; OBJ import; STL reading hardened

- IGES import (kernel iges_in, from the published specification). The file
  is restated as STEP and read by the STEP reader, so an IGES file becomes
  the same Import base feature.
  - Manifold solid B-reps (186/514/510/508/504/502).
  - Trimmed (144), bounded (143) and bare B-spline surfaces, sewn into a
    solid by shared edges, with face orientation made consistent.
  - Curves: lines, arcs, ellipses, composite and rational B-spline curves.
  - Surfaces: planes, analytic surfaces 190-198, B-spline surfaces,
    surfaces of revolution and tabulated cylinders.
  - Transformations, units, colours, names (406 form 15 or labels).
  Open, insert and drop .igs/.iges like STEP.
- IGES export (kernel iges_out): each body as a manifold solid B-rep on the
  surfaces a STEP export uses, with name properties and colours.
  ExportCommand to .igs.
- OBJ import as mesh bodies: objects/groups, polygons fanned, negative
  indices, bad lines reported.
- STL: binary files whose header starts with "solid" and those with a zero
  triangle count read as binary; non-finite vertices dropped; facet
  normals ignored; ASCII with non-UTF-8 names. Mesh bodies get consistent
  winding (stray inverted facets turned over).
- Tests: primitives and features round-trip through IGES (volume, area,
  name, colour). Six trimmed patches sew into a closed box. Hostile IGES,
  OBJ and STL inputs never panic. STL quirks. The engine exports a design
  to IGES and opens it again. All 76 oracle parts round-trip through IGES
  with matching volume (local check).

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 15:17:25 +00:00
1 parent 7a2eb059c4
commit 51ffaf9945
13 files changed
+2615 -31

No files matched your search

File diff suppressed because it is too large. Load diff
+143
View File
@@ -0,0 +1,143 @@
use super::*;
use crate::{Body, BoolOp, ExportBody, StepHeader, boolean, box_solid, cylinder, fillet, iges_export_bodies, measure, sphere, torus};
use solvecraft_geom::Vec3;
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
/// Export a body to IGES and read it back: same volume and area, its name and colour.
fn round_trip(b: &Body) -> crate::StepImport {
let body = ExportBody { name: "Bracket left 1".into(), body: b, color: Some([1.0, 0.5, 0.0]) };
let text = iges_export_bodies(&[body], &StepHeader { file_name: "t.igs".into(), ..Default::default() }).unwrap();
for l in text.lines() {
assert_eq!(l.chars().count(), 80, "{l}");
}
let imp = iges_import(&text).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 1);
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!(imp.bodies[0].closed);
assert_eq!(imp.bodies[0].name, "Bracket left 1");
assert_eq!(imp.bodies[0].color, Some([1.0, 0.5, 0.0]));
imp
}
#[test]
fn primitives_round_trip_through_iges() {
round_trip(&box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap());
round_trip(&cylinder(Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.0, 1.0, 1.0), 5.0, 12.0).unwrap());
round_trip(&sphere(Vec3::new(1.0, 2.0, 3.0), 10.0).unwrap());
round_trip(&torus(Vec3::ZERO, 20.0, 4.0).unwrap());
}
#[test]
fn features_round_trip_through_iges() {
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let pin = cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap();
let holed = boolean(&plate, &pin, BoolOp::Cut).unwrap().unwrap();
round_trip(&holed);
round_trip(&fillet(&plate, &[Vec3::new(20.0, 0.0, 10.0)], 3.0).unwrap());
}
/// Hostile files fail cleanly: garbage, truncation, missing and circular references, huge
/// counts.
#[test]
fn hostile_iges_never_panics() {
let good = iges_export_bodies(
&[ExportBody { name: "B".into(), body: &box_solid(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)).unwrap(), color: None }],
&StepHeader::default(),
)
.unwrap();
let mut cases: Vec<String> = vec![String::new(), "garbage".into(), "\u{0}\u{ff}".into(), good[..good.len() / 2].to_string()];
// Every parameter line with its numbers bumped, scrambled or blown up.
for (k, f) in [|s: &str| s.replace('1', "9"), |s: &str| s.replace(',', ";"), |s: &str| s.replace("0,", "999999999,")].iter().enumerate() {
let t: String = good
.lines()
.map(|l| if l.as_bytes().get(72) == Some(&b'P') && l.len() >= 64 { format!("{}{}", f(&l[..64]), &l[64..]) } else { l.to_string() })
.collect::<Vec<_>>()
.join("\n");
let _ = k;
cases.push(t);
}
// A transformation and a composite curve that refer to themselves.
cases.push(
[
format!("{:<72}S{:>7}", "", 1),
format!("{:<72}G{:>7}", "1H,,1H;;", 1),
format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 124, 1, 0, 0, 0, 0, 1, 0, "00000000", 1),
format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 124, 0, 0, 1, 0, 0, 0, "", 0, 2),
format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 102, 2, 0, 0, 0, 0, 1, 0, "00000000", 3),
format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 102, 0, 0, 1, 0, 0, 0, "", 0, 4),
format!("{:<64}{:>8}P{:>7}", "124,1.,0.,0.,0.,0.,1.,0.,0.,0.,0.,1.,0.;", 1, 1),
format!("{:<64}{:>8}P{:>7}", "102,1,3;", 3, 2),
format!("{:<72}T{:>7}", "S 1G 1D 4P 2", 1),
]
.join("\n"),
);
for c in cases {
let r = std::panic::catch_unwind(|| iges_import(&c).map(|i| i.bodies.len()));
assert!(r.is_ok(), "panicked on a hostile file");
}
}
/// An IGES file from (type, form, parameters) entities, in order (pointers are 2k + 1).
fn iges_file(ents: &[(i64, i64, String)]) -> String {
let mut out = vec![format!("{:<72}S{:>7}", "test", 1), format!("{:<72}G{:>7}", "1H,,1H;,,,,,,,,,,,1.,2,2HMM,,,,1.E-6;", 1)];
let mut p_lines: Vec<(usize, String)> = Vec::new();
let mut starts = Vec::new();
for (k, (t, _, p)) in ents.iter().enumerate() {
starts.push(p_lines.len() + 1);
let text = format!("{t},{p};");
let chars: Vec<char> = text.chars().collect();
for c in chars.chunks(64) {
p_lines.push((2 * k + 1, c.iter().collect()));
}
}
for (k, (t, form, _)) in ents.iter().enumerate() {
let count = p_lines.iter().filter(|(d, _)| *d == 2 * k + 1).count();
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, starts[k], 0, 0, 0, 0, 0, 0, "00000000", 2 * k + 1));
out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, 0, 0, count, form, 0, 0, "", 0, 2 * k + 2));
}
for (i, (d, l)) in p_lines.iter().enumerate() {
out.push(format!("{l:<64}{d:>8}P{:>7}", i + 1));
}
out.push(format!("{:<72}T{:>7}", "", 1));
out.join("\n")
}
/// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose
/// boundaries are lines: sewn into a closed solid.
#[test]
fn trimmed_surfaces_sew_into_a_solid() {
let c =
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
// Faces as corner quads (counter-clockwise seen from outside).
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut ents: Vec<(i64, i64, String)> = Vec::new();
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
for q in quads {
let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3]));
// Patch: u from a to b, v from a to d (normal (b - a) × (d - a) outward).
let surf = format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(cc));
ents.push((128, 0, surf));
let s_de = 2 * ents.len() - 1;
let mut lines = Vec::new();
for (p0, p1) in [(a, b), (b, cc), (cc, d), (d, a)] {
ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1))));
lines.push(2 * ents.len() - 1);
}
ents.push((102, 0, format!("4,{}", lines.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(","))));
let comp = 2 * ents.len() - 1;
ents.push((142, 0, format!("0,{s_de},0,{comp},2")));
let cos = 2 * ents.len() - 1;
ents.push((144, 0, format!("{s_de},1,0,{cos}")));
}
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
}
+711
View File
@@ -0,0 +1,711 @@
//! IGES 5.3 export, written from the published specification: each body as a manifold solid
//! B-rep (186) with its shell, faces, loops, edge and vertex lists, on analytic surfaces
//! (planes, cylinders, cones, spheres, tori), surfaces of revolution and extrusion, and
//! rational B-splines, with body names (406 name properties) and colours (314). The bodies are
//! first written as STEP (so faces are merged and surfaces recognised exactly as in a STEP
//! export) and restated entity by entity.
use std::collections::HashMap;
use std::fmt::Write as _;
use crate::step_in::p21::{self, Exchange, Param};
use crate::{ExportBody, KernelError, Result, StepHeader};
type V3 = [f64; 3];
fn sub(a: V3, b: V3) -> V3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
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-300 && n.is_finite()).then(|| a.map(|x| x / n))
}
/// A real in IGES free format (always with a decimal point).
fn real(x: f64) -> String {
let x = if x.is_finite() { x } else { 0.0 };
let s = format!("{x:?}");
let s = s.replace('e', "E");
if s.contains('.') {
s
} else if let Some((m, e)) = s.split_once('E') {
format!("{m}.E{e}")
} else {
format!("{s}.")
}
}
/// A Hollerith string.
fn holl(s: &str) -> String {
let clean: String = s.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c } else { '_' }).collect();
format!("{}H{clean}", clean.len())
}
struct Ent {
typ: i64,
form: i64,
params: Vec<String>,
xform: i64,
color: i64,
label: String,
dependent: bool,
}
#[derive(Default)]
struct W {
ents: Vec<Ent>,
colors: HashMap<[u32; 3], i64>,
}
impl W {
fn add(&mut self, typ: i64, form: i64, params: Vec<String>) -> i64 {
self.ents.push(Ent { typ, form, params, xform: 0, color: 0, label: String::new(), dependent: true });
2 * self.ents.len() as i64 - 1
}
fn get_mut(&mut self, de: i64) -> Option<&mut Ent> {
usize::try_from((de - 1) / 2).ok().and_then(|i| self.ents.get_mut(i))
}
fn point(&mut self, p: V3) -> i64 {
self.add(116, 0, vec![real(p[0]), real(p[1]), real(p[2])])
}
fn direction(&mut self, d: V3) -> i64 {
self.add(123, 0, vec![real(d[0]), real(d[1]), real(d[2])])
}
/// Transformation (124) taking a frame's local coordinates to the model.
fn frame(&mut self, o: V3, x: V3, z: V3) -> i64 {
let y = cross(z, x);
let rows: Vec<String> = (0..3).flat_map(|i| [real(x[i]), real(y[i]), real(z[i]), real(o[i])]).collect();
self.add(124, 0, rows)
}
fn color(&mut self, c: [f32; 3]) -> i64 {
let key = c.map(f32::to_bits);
if let Some(d) = self.colors.get(&key) {
return *d;
}
let d = self.add(314, 0, vec![real(c[0] as f64 * 100.0), real(c[1] as f64 * 100.0), real(c[2] as f64 * 100.0), holl("")]);
if let Some(e) = self.get_mut(d) {
e.dependent = false;
}
self.colors.insert(key, d);
d
}
}
/// Reading the STEP exchange.
struct S<'a> {
ex: &'a Exchange,
}
impl<'a> S<'a> {
fn e(&self, id: u64) -> Result<&'a p21::Entity> {
self.ex.get(id).ok_or_else(|| KernelError::Failed(format!("IGES export: missing #{id}")))
}
fn r(&self, p: Option<&Param>) -> Result<u64> {
p.and_then(Param::as_ref_id).ok_or_else(|| KernelError::Failed("IGES export: bad reference".into()))
}
fn f(&self, p: Option<&Param>) -> Result<f64> {
p.and_then(Param::as_f64).ok_or_else(|| KernelError::Failed("IGES export: bad number".into()))
}
fn list(&self, p: Option<&'a Param>) -> Result<&'a [Param]> {
p.and_then(Param::as_list).ok_or_else(|| KernelError::Failed("IGES export: bad list".into()))
}
fn point(&self, id: u64) -> Result<V3> {
let e = self.e(id)?;
let c = self.list(e.params().get(1))?;
Ok([self.f(c.first())?, self.f(c.get(1))?, self.f(c.get(2))?])
}
fn dir(&self, id: u64) -> Result<V3> {
let d = self.point(id)?;
unit(d).ok_or_else(|| KernelError::Failed("IGES export: zero direction".into()))
}
fn vertex(&self, id: u64) -> Result<V3> {
self.point(self.r(self.e(id)?.params().get(1))?)
}
/// AXIS2_PLACEMENT_3D: origin, z, x.
fn placement(&self, id: u64) -> Result<(V3, V3, V3)> {
let p = self.e(id)?.params();
let o = self.point(self.r(p.get(1))?)?;
let z = match p.get(2).and_then(Param::as_ref_id) {
Some(d) => self.dir(d)?,
None => [0.0, 0.0, 1.0],
};
let x0 = match p.get(3).and_then(Param::as_ref_id) {
Some(d) => self.dir(d)?,
None => [1.0, 0.0, 0.0],
};
let x = unit(sub(x0, z.map(|c| c * dot(x0, z)))).unwrap_or_else(|| {
let a = if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
unit(sub(a, z.map(|c| c * dot(a, z)))).unwrap_or([1.0, 0.0, 0.0])
});
Ok((o, z, x))
}
}
/// B-spline data of a STEP curve or surface record set: degrees, control rows, weights, knots.
struct Spline {
deg: Vec<usize>,
pts: Vec<Vec<V3>>,
weights: Option<Vec<Vec<f64>>>,
knots: Vec<Vec<f64>>,
}
fn expand(mults: &[Param], knots: &[Param]) -> Option<Vec<f64>> {
let mut out = Vec::new();
for (m, k) in mults.iter().zip(knots) {
let (m, k) = (m.as_i64()?, k.as_f64()?);
if !(1..=64).contains(&m) {
return None;
}
for _ in 0..m {
out.push(k);
}
}
Some(out)
}
fn spline(s: &S, e: &p21::Entity, surface: bool) -> Result<Spline> {
let bad = || KernelError::Failed("IGES export: unreadable B-spline".into());
let (base, kn): (Vec<Param>, Vec<Param>) = if e.is_complex() {
let b = e.record(if surface { "B_SPLINE_SURFACE" } else { "B_SPLINE_CURVE" }).ok_or_else(bad)?;
let k = e.record(if surface { "B_SPLINE_SURFACE_WITH_KNOTS" } else { "B_SPLINE_CURVE_WITH_KNOTS" }).ok_or_else(bad)?;
let mut v = vec![Param::Str(String::new())];
v.extend(b.iter().cloned());
(v, k.to_vec())
} else {
let p = e.params();
let skip = if surface { 8 } else { 6 };
(p.to_vec(), p.get(skip..).map(<[Param]>::to_vec).unwrap_or_default())
};
let deg_n = if surface { 2 } else { 1 };
let deg: Vec<usize> = (0..deg_n)
.map(|i| base.get(1 + i).and_then(Param::as_i64).filter(|d| (1..=32).contains(d)).map(|d| d as usize).ok_or_else(bad))
.collect::<Result<_>>()?;
let ctrl = base.get(1 + deg_n).and_then(Param::as_list).ok_or_else(bad)?;
let pts: Vec<Vec<V3>> = if surface {
ctrl.iter()
.map(|row| row.as_list().ok_or_else(bad)?.iter().map(|c| s.point(c.as_ref_id().ok_or_else(bad)?)).collect::<Result<Vec<_>>>())
.collect::<Result<_>>()?
} else {
vec![ctrl.iter().map(|c| s.point(c.as_ref_id().ok_or_else(bad)?)).collect::<Result<Vec<_>>>()?]
};
let knots: Vec<Vec<f64>> = if surface {
let (mu, mv, ku, kv) = (kn.first(), kn.get(1), kn.get(2), kn.get(3));
vec![
expand(mu.and_then(Param::as_list).ok_or_else(bad)?, ku.and_then(Param::as_list).ok_or_else(bad)?).ok_or_else(bad)?,
expand(mv.and_then(Param::as_list).ok_or_else(bad)?, kv.and_then(Param::as_list).ok_or_else(bad)?).ok_or_else(bad)?,
]
} else {
vec![expand(kn.first().and_then(Param::as_list).ok_or_else(bad)?, kn.get(1).and_then(Param::as_list).ok_or_else(bad)?).ok_or_else(bad)?]
};
let weights = match e.record(if surface { "RATIONAL_B_SPLINE_SURFACE" } else { "RATIONAL_B_SPLINE_CURVE" }) {
Some(r) => {
let w = r.first().and_then(Param::as_list).ok_or_else(bad)?;
Some(if surface {
w.iter()
.map(|row| row.as_list().ok_or_else(bad)?.iter().map(|x| x.as_f64().ok_or_else(bad)).collect::<Result<Vec<_>>>())
.collect::<Result<_>>()?
} else {
vec![w.iter().map(|x| x.as_f64().ok_or_else(bad)).collect::<Result<Vec<_>>>()?]
})
}
None => None,
};
Ok(Spline { deg, pts, weights, knots })
}
/// A curve as an IGES entity running from `a` to `b` (its direction in the STEP file), the DE
/// pointer.
fn curve(w: &mut W, s: &S, id: u64, a: V3, b: V3, closed: bool) -> Result<i64> {
let e = s.e(id)?;
match e.name() {
"LINE" => Ok(w.add(110, 0, [a, b].iter().flatten().map(|x| real(*x)).collect())),
"CIRCLE" | "ELLIPSE" => {
let p = e.params();
let (o, z, x) = s.placement(s.r(p.get(1))?)?;
let y = cross(z, x);
let local = |q: V3| {
let v = sub(q, o);
[dot(v, x), dot(v, y)]
};
let (la, lb) = (local(a), if closed { local(a) } else { local(b) });
let tr = w.frame(o, x, z);
let de = if e.name() == "CIRCLE" {
w.add(100, 0, vec![real(0.0), real(0.0), real(0.0), real(la[0]), real(la[1]), real(lb[0]), real(lb[1])])
} else {
let (ra, rb) = (s.f(p.get(2))?, s.f(p.get(3))?);
// A x² + C y² + F = 0 with F = -1: A = 1/ra², C = 1/rb².
w.add(
104,
1,
vec![
real(1.0 / (ra * ra)),
real(0.0),
real(1.0 / (rb * rb)),
real(0.0),
real(0.0),
real(-1.0),
real(0.0),
real(la[0]),
real(la[1]),
real(lb[0]),
real(lb[1]),
],
)
};
if let Some(ent) = w.get_mut(de) {
ent.xform = tr;
}
Ok(de)
}
_ if e.has("B_SPLINE_CURVE") || e.name() == "B_SPLINE_CURVE_WITH_KNOTS" => {
let sp = spline(s, e, false)?;
let (Some(pts), Some(knots), Some(&deg)) = (sp.pts.first(), sp.knots.first(), sp.deg.first()) else {
return Err(KernelError::Failed("IGES export: empty B-spline".into()));
};
let k = pts.len().saturating_sub(1);
let ws = sp.weights.as_ref().and_then(|w| w.first().cloned()).unwrap_or_else(|| vec![1.0; pts.len()]);
let rational = ws.iter().any(|w| (w - 1.0).abs() > 1e-12);
let (t0, t1) = (knots.get(deg).copied().unwrap_or(0.0), knots.get(pts.len()).copied().unwrap_or(1.0));
let mut p = vec![k.to_string(), deg.to_string(), "0".into(), "0".into(), if rational { "0" } else { "1" }.into(), "0".into()];
p.extend(knots.iter().map(|x| real(*x)));
p.extend(ws.iter().map(|x| real(*x)));
p.extend(pts.iter().flatten().map(|x| real(*x)));
p.extend([real(t0), real(t1), real(0.0), real(0.0), real(0.0)]);
Ok(w.add(126, 0, p))
}
n => Err(KernelError::Failed(format!("IGES export: curve {n} is not written yet"))),
}
}
/// Start point of a STEP curve (for extrusion directrices).
fn curve_start(s: &S, id: u64) -> Result<V3> {
let e = s.e(id)?;
match e.name() {
"LINE" => s.point(s.r(e.params().get(1))?),
"CIRCLE" => {
let (o, _, x) = s.placement(s.r(e.params().get(1))?)?;
let r = s.f(e.params().get(2))?;
Ok([o[0] + r * x[0], o[1] + r * x[1], o[2] + r * x[2]])
}
_ => {
let sp = spline(s, e, false)?;
sp.pts.first().and_then(|r| r.first()).copied().ok_or_else(|| KernelError::Failed("IGES export: empty curve".into()))
}
}
}
/// A curve written whole (a revolution's generatrix, an extrusion's directrix).
fn whole_curve(w: &mut W, s: &S, id: u64) -> Result<i64> {
let e = s.e(id)?;
match e.name() {
"LINE" => {
let p = e.params();
let o = s.point(s.r(p.get(1))?)?;
let v = s.e(s.r(p.get(2))?)?;
let d = s.dir(s.r(v.params().get(1))?)?;
let m = s.f(v.params().get(2))?;
curve(w, s, id, o, [o[0] + d[0] * m, o[1] + d[1] * m, o[2] + d[2] * m], false)
}
"CIRCLE" | "ELLIPSE" => {
let a = curve_start(s, id)?;
curve(w, s, id, a, a, true)
}
_ => curve(w, s, id, [0.0; 3], [0.0; 3], false),
}
}
fn surface(w: &mut W, s: &S, id: u64) -> Result<i64> {
let e = s.e(id)?;
let p = e.params();
let analytic = |w: &mut W, typ: i64, place: u64, extra: &[f64]| -> Result<i64> {
let (o, z, x) = s.placement(place)?;
let (po, pz, px) = (w.point(o), w.direction(z), w.direction(x));
let mut v = vec![po.to_string()];
match typ {
190 => v.extend([pz.to_string(), px.to_string()]),
192 => v.extend([pz.to_string(), real(extra.first().copied().unwrap_or(0.0)), px.to_string()]),
194 => v.extend([
pz.to_string(),
real(extra.first().copied().unwrap_or(0.0)),
real(extra.get(1).copied().unwrap_or(0.0).to_degrees()),
px.to_string(),
]),
196 => v.extend([real(extra.first().copied().unwrap_or(0.0)), pz.to_string(), px.to_string()]),
_ => v.extend([pz.to_string(), real(extra.first().copied().unwrap_or(0.0)), real(extra.get(1).copied().unwrap_or(0.0)), px.to_string()]),
}
Ok(w.add(typ, 1, v))
};
match e.name() {
"PLANE" => analytic(w, 190, s.r(p.get(1))?, &[]),
"CYLINDRICAL_SURFACE" => analytic(w, 192, s.r(p.get(1))?, &[s.f(p.get(2))?]),
"CONICAL_SURFACE" => analytic(w, 194, s.r(p.get(1))?, &[s.f(p.get(2))?, s.f(p.get(3))?]),
"SPHERICAL_SURFACE" => analytic(w, 196, s.r(p.get(1))?, &[s.f(p.get(2))?]),
"TOROIDAL_SURFACE" => analytic(w, 198, s.r(p.get(1))?, &[s.f(p.get(2))?, s.f(p.get(3))?]),
"SURFACE_OF_REVOLUTION" => {
let gen_curve = whole_curve(w, s, s.r(p.get(1))?)?;
let ax = s.e(s.r(p.get(2))?)?;
let o = s.point(s.r(ax.params().get(1))?)?;
let d = s.dir(s.r(ax.params().get(2))?)?;
let axis = w.add(110, 0, [o, [o[0] + d[0], o[1] + d[1], o[2] + d[2]]].iter().flatten().map(|x| real(*x)).collect());
Ok(w.add(120, 0, vec![axis.to_string(), gen_curve.to_string(), real(0.0), real(std::f64::consts::TAU)]))
}
"SURFACE_OF_LINEAR_EXTRUSION" => {
let c = s.r(p.get(1))?;
let dir = whole_curve(w, s, c)?;
let start = curve_start(s, c)?;
let v = s.e(s.r(p.get(2))?)?;
let d = s.dir(s.r(v.params().get(1))?)?;
let m = s.f(v.params().get(2))?;
Ok(w.add(122, 0, vec![dir.to_string(), real(start[0] + d[0] * m), real(start[1] + d[1] * m), real(start[2] + d[2] * m)]))
}
_ if e.has("B_SPLINE_SURFACE") || e.name() == "B_SPLINE_SURFACE_WITH_KNOTS" => {
let sp = spline(s, e, true)?;
let (Some(&du), Some(&dv), Some(ku), Some(kv)) = (sp.deg.first(), sp.deg.get(1), sp.knots.first(), sp.knots.get(1)) else {
return Err(KernelError::Failed("IGES export: bad B-spline surface".into()));
};
let nu = sp.pts.len();
let nv = sp.pts.first().map(Vec::len).unwrap_or(0);
if nu == 0 || nv == 0 || sp.pts.iter().any(|r| r.len() != nv) {
return Err(KernelError::Failed("IGES export: ragged B-spline surface".into()));
}
let wt = |i: usize, j: usize| sp.weights.as_ref().and_then(|w| w.get(i)?.get(j).copied()).unwrap_or(1.0);
let rational = (0..nu).any(|i| (0..nv).any(|j| (wt(i, j) - 1.0).abs() > 1e-12));
let mut v = vec![
(nu - 1).to_string(),
(nv - 1).to_string(),
du.to_string(),
dv.to_string(),
"0".into(),
"0".into(),
if rational { "0" } else { "1" }.into(),
"0".into(),
"0".into(),
];
v.extend(ku.iter().map(|x| real(*x)));
v.extend(kv.iter().map(|x| real(*x)));
// The first index runs fastest.
for j in 0..nv {
for i in 0..nu {
v.push(real(wt(i, j)));
}
}
for j in 0..nv {
for i in 0..nu {
let q = sp.pts.get(i).and_then(|r| r.get(j)).copied().unwrap_or([0.0; 3]);
v.extend(q.map(real));
}
}
let range = |k: &Vec<f64>, d: usize, n: usize| (k.get(d).copied().unwrap_or(0.0), k.get(n).copied().unwrap_or(1.0));
let ((u0, u1), (v0, v1)) = (range(ku, du, nu), range(kv, dv, nv));
v.extend([real(u0), real(u1), real(v0), real(v1)]);
Ok(w.add(128, 0, v))
}
n => Err(KernelError::Failed(format!("IGES export: surface {n} is not written yet"))),
}
}
/// Colours of styled items (solids and faces).
fn colours(ex: &Exchange) -> HashMap<u64, [f32; 3]> {
let mut out = HashMap::new();
for e in ex.entities.values() {
if e.name() != "STYLED_ITEM" {
continue;
}
let Some(item) = e.params().get(2).and_then(Param::as_ref_id) else { continue };
let mut queue: Vec<u64> =
e.params().get(1).and_then(Param::as_list).map(|l| l.iter().filter_map(Param::as_ref_id).collect()).unwrap_or_default();
let mut seen = 0;
while let Some(id) = queue.pop() {
seen += 1;
if seen > 64 {
break;
}
let Some(s) = ex.get(id) else { continue };
if s.name() == "COLOUR_RGB" {
let c = |i: usize| s.params().get(i).and_then(Param::as_f64).map(|x| x.clamp(0.0, 1.0) as f32);
if let (Some(r), Some(g), Some(b)) = (c(1), c(2), c(3)) {
out.insert(item, [r, g, b]);
}
break;
}
for r in &s.records {
for p in &r.params {
match p {
Param::Ref(x) => queue.push(*x),
Param::List(v) => queue.extend(v.iter().filter_map(Param::as_ref_id)),
_ => {}
}
}
}
}
}
out
}
/// A shell (514) with its vertex list, edge list, loops and faces.
fn shell(w: &mut W, s: &S, id: u64, colors: &HashMap<u64, [f32; 3]>) -> Result<i64> {
let sh = s.e(id)?;
let faces = s.list(sh.params().get(1))?;
// Vertices and edges of the shell, in first-use order.
let mut vidx: HashMap<u64, usize> = HashMap::new();
let mut verts: Vec<V3> = Vec::new();
let mut eidx: HashMap<u64, usize> = HashMap::new();
let mut edges: Vec<(u64, bool, usize, usize)> = Vec::new(); // curve, same sense, start, end
let mut face_data = Vec::new();
for f in faces {
let fid = s.r(Some(f))?;
let fe = s.e(fid)?;
let sense = fe.params().get(3).and_then(Param::as_bool).unwrap_or(true);
let mut loops: Vec<Vec<(u64, bool)>> = Vec::new();
let bounds = s.list(fe.params().get(1))?;
// The outer bound first.
let mut ordered: Vec<&Param> =
bounds.iter().filter(|b| b.as_ref_id().and_then(|x| s.ex.get(x)).is_some_and(|e| e.name() == "FACE_OUTER_BOUND")).collect();
ordered.extend(bounds.iter().filter(|b| b.as_ref_id().and_then(|x| s.ex.get(x)).is_some_and(|e| e.name() != "FACE_OUTER_BOUND")));
for b in ordered {
let be = s.e(s.r(Some(b))?)?;
let orient = be.params().get(2).and_then(Param::as_bool).unwrap_or(true);
let lp = s.e(s.r(be.params().get(1))?)?;
if lp.name() != "EDGE_LOOP" {
continue;
}
let mut l = Vec::new();
for oe in s.list(lp.params().get(1))? {
let oe = s.e(s.r(Some(oe))?)?;
let ec = s.r(oe.params().get(3))?;
let d = oe.params().get(4).and_then(Param::as_bool).unwrap_or(true);
#[allow(clippy::map_entry)] // the edge's vertices are numbered before it
if !eidx.contains_key(&ec) {
let ee = s.e(ec)?;
let (v0, v1) = (s.r(ee.params().get(1))?, s.r(ee.params().get(2))?);
let mut vi = |v: u64| -> Result<usize> {
if let Some(i) = vidx.get(&v) {
return Ok(*i);
}
verts.push(s.vertex(v)?);
vidx.insert(v, verts.len() - 1);
Ok(verts.len() - 1)
};
let (a, bb) = (vi(v0)?, vi(v1)?);
let same = ee.params().get(4).and_then(Param::as_bool).unwrap_or(true);
eidx.insert(ec, edges.len());
edges.push((s.r(ee.params().get(3))?, same, a, bb));
}
l.push((ec, d));
}
// IGES loops run counter-clockwise about the surface normal.
if orient != sense {
l.reverse();
for x in &mut l {
x.1 = !x.1;
}
}
loops.push(l);
}
face_data.push((fid, s.r(fe.params().get(2))?, sense, loops));
}
let mut vp = vec![verts.len().to_string()];
vp.extend(verts.iter().flatten().map(|x| real(*x)));
let vlist = w.add(502, 1, vp);
// Edges: the IGES edge runs along its curve (from the STEP edge's start when same sense).
let mut ep = vec![edges.len().to_string()];
for &(c, same, a, b) in &edges {
let (sv, tv) = if same { (a, b) } else { (b, a) };
let (pa, pb) = (verts.get(sv).copied().unwrap_or([0.0; 3]), verts.get(tv).copied().unwrap_or([0.0; 3]));
let cd = curve(w, s, c, pa, pb, sv == tv)?;
ep.extend([cd.to_string(), vlist.to_string(), (sv + 1).to_string(), vlist.to_string(), (tv + 1).to_string()]);
}
let elist = w.add(504, 1, ep);
let mut sp = vec![face_data.len().to_string()];
for (fid, surf, sense, loops) in face_data {
let sd = surface(w, s, surf)?;
let mut lids = Vec::new();
for l in loops {
let mut lp = vec![l.len().to_string()];
for (ec, d) in l {
let k = eidx.get(&ec).copied().unwrap_or(0);
let same = edges.get(k).is_none_or(|e| e.1);
lp.extend(["0".into(), elist.to_string(), (k + 1).to_string(), if d == same { "1" } else { "0" }.into(), "0".into()]);
}
lids.push(w.add(508, 1, lp).to_string());
}
let mut fp = vec![sd.to_string(), lids.len().to_string(), "1".into()];
fp.extend(lids);
let fd = w.add(510, 1, fp);
if let Some(c) = colors.get(&fid) {
let cd = w.color(*c);
if let Some(e) = w.get_mut(fd) {
e.color = -cd;
}
}
sp.extend([fd.to_string(), if sense { "1" } else { "0" }.into()]);
}
Ok(w.add(514, 1, sp))
}
/// IGES text (5.3, millimetres) for bodies.
pub fn iges_export_bodies(bodies: &[ExportBody], header: &StepHeader) -> Result<String> {
let step = crate::step_export_bodies(bodies, header)?;
let ex = p21::parse(&step).map_err(|e| KernelError::Failed(format!("IGES export: {e}")))?;
let s = S { ex: &ex };
let colors = colours(&ex);
let mut w = W::default();
let mut solids: Vec<u64> =
ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect();
solids.sort();
let mut max_coord: f64 = 1.0;
for p in ex.entities.values().filter(|e| e.name() == "CARTESIAN_POINT") {
if let Some(c) = p.params().get(1).and_then(Param::as_list) {
for x in c.iter().filter_map(Param::as_f64) {
max_coord = max_coord.max(x.abs());
}
}
}
for id in solids {
let e = s.e(id)?;
let name = e.params().first().and_then(Param::as_str).unwrap_or("").to_string();
let outer = shell(&mut w, &s, s.r(e.params().get(1))?, &colors)?;
let mut p = vec![outer.to_string(), "1".into()];
let voids: Vec<i64> = if e.name() == "BREP_WITH_VOIDS" {
s.list(e.params().get(2))?.iter().map(|v| shell(&mut w, &s, s.r(Some(v))?, &colors)).collect::<Result<_>>()?
} else {
Vec::new()
};
p.push(voids.len().to_string());
for v in voids {
p.extend([v.to_string(), "1".into()]);
}
// The body's name as a name property (406 form 15).
let np = w.add(406, 15, vec!["1".into(), holl(&name)]);
p.extend(["0".into(), "1".into(), np.to_string()]);
let d = w.add(186, 0, p);
let c = colors.get(&id).map(|c| w.color(*c));
if let Some(ent) = w.get_mut(d) {
ent.dependent = false;
ent.label = name.chars().filter(|c| c.is_ascii_alphanumeric()).take(8).collect();
if let Some(c) = c {
ent.color = -c;
}
}
}
if w.ents.is_empty() {
return Err(KernelError::Invalid("nothing to export".into()));
}
Ok(layout(&w, header, max_coord))
}
/// The file: start, global, directory and parameter sections and terminator.
fn layout(w: &W, header: &StepHeader, max_coord: f64) -> String {
let mut out = String::new();
let line = |out: &mut String, data: &str, sect: char, seq: usize| {
let _ = writeln!(out, "{data:<72}{sect}{seq:>7}");
};
line(&mut out, "SolveCraft IGES export", 'S', 1);
let stamp = {
let t = header.time_stamp.replace(['-', ':', 'T'], "");
let t: String = t.chars().filter(char::is_ascii_digit).collect();
if t.len() >= 14 { format!("{}.{}", &t[..8], &t[8..14]) } else { "20000101.000000".to_string() }
};
let g = [
"1H,".to_string(),
"1H;".to_string(),
holl("SolveCraft"),
holl(&header.file_name),
holl("SolveCraft"),
holl("SolveCraft IGES 5.3"),
"32".into(),
"38".into(),
"6".into(),
"308".into(),
"15".into(),
holl(&header.file_name),
real(1.0),
"2".into(),
holl("MM"),
"1".into(),
real(1.0),
holl(&stamp),
real(1e-6),
real(max_coord),
holl(&header.author),
holl(&header.organization),
"11".into(),
"0".into(),
holl(&stamp),
];
let mut gseq = 0;
for chunk in wrap(&g.iter().map(String::as_str).collect::<Vec<_>>(), ',', ';', 72) {
gseq += 1;
line(&mut out, &chunk, 'G', gseq);
}
// Parameter lines per entity.
let mut pdata: Vec<Vec<String>> = Vec::with_capacity(w.ents.len());
for e in &w.ents {
let mut fields = vec![e.typ.to_string()];
fields.extend(e.params.iter().cloned());
pdata.push(wrap(&fields.iter().map(String::as_str).collect::<Vec<_>>(), ',', ';', 64));
}
let mut pstart = Vec::with_capacity(w.ents.len());
let mut next = 1;
for p in &pdata {
pstart.push(next);
next += p.len();
}
let f8 = |x: i64| format!("{x:>8}");
for (k, e) in w.ents.iter().enumerate() {
let status = if e.dependent { "00010000" } else { "00000000" };
let a = format!("{}{}{}{}{}{}{}{}{status}", f8(e.typ), f8(pstart[k] as i64), f8(0), f8(0), f8(0), f8(0), f8(e.xform), f8(0));
let b = format!("{}{}{}{}{}{}{}{:>8}{}", f8(e.typ), f8(0), f8(e.color), f8(pdata[k].len() as i64), f8(e.form), f8(0), f8(0), e.label, f8(0));
line(&mut out, &a, 'D', 2 * k + 1);
line(&mut out, &b, 'D', 2 * k + 2);
}
let mut pseq = 0;
for (k, p) in pdata.iter().enumerate() {
for l in p {
pseq += 1;
line(&mut out, &format!("{l:<64}{:>8}", 2 * k + 1), 'P', pseq);
}
}
line(&mut out, &format!("S{:>7}G{:>7}D{:>7}P{:>7}", 1, gseq, 2 * w.ents.len(), pseq), 'T', 1);
out
}
/// Fields joined by `delim`, ended by `end`, broken into lines of at most `width` columns
/// between fields (a longer field is split, as strings may be).
fn wrap(fields: &[&str], delim: char, end: char, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut cur = String::new();
for (i, f) in fields.iter().enumerate() {
let sep = if i + 1 == fields.len() { end } else { delim };
let item = format!("{f}{sep}");
if cur.len() + item.len() > width && !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
}
if item.len() > width {
let chars: Vec<char> = item.chars().collect();
for c in chars.chunks(width) {
let piece: String = c.iter().collect();
if piece.len() == width {
lines.push(piece);
} else {
cur = piece;
}
}
continue;
}
cur.push_str(&item);
}
if !cur.is_empty() {
lines.push(cur);
}
lines
}
+4
View File
@@ -20,6 +20,8 @@ mod curveblend;
mod freeform;
mod heal;
mod helix;
mod iges_in;
mod iges_out;
mod loopblend;
mod measure;
mod meshbody;
@@ -39,6 +41,8 @@ pub use blend::{chamfer, fillet};
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
pub use helix::{loft_to_point, sweep_helix};
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
pub use iges_out::iges_export_bodies;
pub use measure::{BodyMeasure, measure};
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
+43
View File
@@ -95,6 +95,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
if out.triangles.is_empty() {
return Err(KernelError::Invalid("mesh has no triangles".into()));
}
unify_winding(&mut out);
if out.volume6() < 0.0 && is_closed(&out) {
for t in &mut out.triangles {
t.swap(1, 2);
@@ -103,6 +104,48 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None })
}
/// Wind every connected piece one way: a triangle that runs a side in the same direction as
/// its neighbour is turned over (files with stray inverted facets). Sides shared by more than
/// two triangles are left alone.
fn unify_winding(m: &mut TriMesh) {
let mut sides: HashMap<(u32, u32), Vec<usize>> = HashMap::new();
for (i, t) in m.triangles.iter().enumerate() {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
sides.entry((a.min(b), a.max(b))).or_default().push(i);
}
}
let runs = |t: &[u32; 3], a: u32, b: u32| [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])].contains(&(a, b));
let mut done = vec![false; m.triangles.len()];
for start in 0..m.triangles.len() {
if done[start] {
continue;
}
done[start] = true;
let mut stack = vec![start];
while let Some(i) = stack.pop() {
let Some(t) = m.triangles.get(i).copied() else { continue };
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
let Some(list) = sides.get(&(a.min(b), a.max(b))) else { continue };
if list.len() != 2 {
continue;
}
for &j in list {
if j == i || done[j] {
continue;
}
done[j] = true;
if let Some(u) = m.triangles.get_mut(j)
&& runs(u, a, b)
{
u.swap(1, 2);
}
stack.push(j);
}
}
}
}
}
/// Every triangle side is shared by exactly two triangles in opposite directions.
fn is_closed(m: &TriMesh) -> bool {
let mut count: HashMap<(u32, u32), i32> = HashMap::new();