STEP: reader

Own ISO 10303-21 parser and AP203/AP214/AP242 geometry reader in the kernel
(step_import): planes, cylinders, cones, spheres, tori, B-spline and rational
surfaces, surfaces of revolution/extrusion; lines, conics, B-spline curves;
breps with voids, faceted breps, shell models; units (SI prefixes, conversion
based), product names, colours, assemblies (NAUO + item-defined
transformations, mapped items). Hostile input is capped and guarded; faces
that cannot be read are left out and reported. Faces that wrap around a
closed non-periodic direction (pipe tori, revolved B-spline bands) are split
for truck's mesher. Bodies are meshed per shell so open imports never panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 21:01:04 +00:00
1 parent 6c819a7b06
commit ed611ed7ba
8 files changed
+3109 -6

No files matched your search

+22 -5
View File
@@ -34,6 +34,23 @@ pub struct FaceInfo {
pub plane_normal: Option<Vec3>,
}
type MeshedShell = truck_topology::Shell<mt::Point3, PolylineCurve<mt::Point3>, Option<PolygonMesh>>;
/// Triangulate each shell (not the solid: truck's solid constructor panics on the open or
/// non-manifold shells an import can produce). Faces whose boundary is not exactly on the
/// surface (imported geometry within the file's tolerance) fail the exact parameter search;
/// such shells are meshed again projecting onto the surface.
fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
solid
.boundaries()
.iter()
.map(|sh| {
let m = sh.triangulation(tol);
if m.face_iter().any(|f| f.surface().is_none()) { sh.robust_triangulation(tol) } else { m }
})
.collect()
}
pub(crate) fn p3(v: Vec3) -> mt::Point3 {
mt::Point3::new(v.x, v.y, v.z)
}
@@ -65,7 +82,7 @@ impl Body {
return Err(KernelError::Failed("empty result".into()));
}
// Orient outward: a closed solid must have positive volume.
let v = guard("orient", || Ok(solid.triangulation(1.0).to_polygon().volume()))?;
let v = guard("orient", || Ok(mesh_shells(&solid, 1.0).iter().map(|s| s.to_polygon().volume()).sum::<f64>()))?;
if v < 0.0 {
solid.not();
}
@@ -98,9 +115,9 @@ impl Body {
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
let tol = tol.max(self.size() * 1e-6);
guard("tessellate", || {
let meshed = self.solid.triangulation(tol);
let meshed = mesh_shells(&self.solid, tol);
let mut out = Mesh::default();
for (fi, face) in meshed.face_iter().enumerate() {
for (fi, face) in meshed.iter().flat_map(|s| s.face_iter()).enumerate() {
let Some(pm) = face.surface() else { continue };
let flip = !face.orientation();
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
@@ -142,7 +159,7 @@ impl Body {
}
}
let mut seen = std::collections::HashMap::new();
for e in meshed.edge_iter() {
for e in meshed.iter().flat_map(|s| s.edge_iter()) {
if seen.contains_key(&e.id()) {
continue;
}
@@ -152,7 +169,7 @@ impl Body {
}
// Which faces each edge bounds.
out.edge_faces = vec![Vec::new(); out.edges.len()];
for (fi, face) in meshed.face_iter().enumerate() {
for (fi, face) in meshed.iter().flat_map(|s| s.face_iter()).enumerate() {
for e in face.edge_iter() {
if let Some(slot) = seen.get(&e.id()).and_then(|i| out.edge_faces.get_mut(*i)) {
let f = u32::try_from(fi).unwrap_or(u32::MAX);
+3 -1
View File
@@ -2,7 +2,7 @@
//!
//! Everything SolveCraft does with solids goes through this crate: building bodies from planar
//! regions (extrude, revolve) and primitives, booleans, fillets and chamfers, tessellation,
//! measurement and STEP export. The implementation uses the `truck` crates (Apache-2.0; see
//! measurement and STEP export and import. The implementation uses the `truck` crates (Apache-2.0; see
//! `plan/adr/0001-geometry-kernel.md`) plus our own local operations (edge fillets and
//! chamfers in `blend.rs`), but no truck type appears in the public API, so the kernel can be
//! replaced without touching the rest of the workspace.
@@ -20,6 +20,7 @@ mod ops;
mod polybool;
mod polyhedron;
mod step;
mod step_in;
mod topo;
pub use blend::{chamfer, fillet};
@@ -30,6 +31,7 @@ pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
pub use polybool::planar_boolean;
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, shell};
pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import};
pub use topo::{TopoCounts, merged_topology, seam_flags};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
+366
View File
@@ -0,0 +1,366 @@
//! STEP topology (ISO 10303-42 topology entities) to kernel solids: breps, shells, faces, loops,
//! edges and vertices. A face that cannot be built is left out and reported; the rest of the
//! body is still imported (as an open body when a face is missing).
use std::collections::HashMap;
use mt::{BoundedCurve, EuclideanSpace, InnerSpace, MetricSpace, ParametricCurve};
use truck_modeling as mt;
use super::Ctx;
use super::geom::{self, R, entity};
use super::p21::Param;
use crate::guard;
/// Most faces in one body.
const MAX_FACES: usize = 500_000;
/// Most edges in one loop.
const MAX_LOOP: usize = 100_000;
/// A body read from a brep entity.
pub(crate) struct BrepOut {
pub solid: mt::Solid,
/// Faces left out (with the reason).
pub failed: Vec<String>,
/// The shells are closed (a solid) rather than open (missing faces, or a surface model).
pub closed: bool,
/// Faces read from the file (before band faces are split for the kernel).
pub file_faces: usize,
}
#[derive(Default)]
struct Builder {
vertices: HashMap<u64, mt::Vertex>,
edges: HashMap<u64, Vec<mt::Edge>>,
poly_edges: HashMap<(u64, u64), mt::Edge>,
faces: usize,
}
fn refs(p: Option<&Param>) -> Vec<u64> {
p.and_then(Param::as_list).map(|v| v.iter().filter_map(Param::as_ref_id).collect()).unwrap_or_default()
}
fn rid(p: Option<&Param>, what: &str) -> R<u64> {
p.and_then(Param::as_ref_id).ok_or_else(|| format!("{what}: expected a reference"))
}
impl Builder {
fn vertex(&mut self, cx: &Ctx, id: u64) -> R<mt::Vertex> {
if let Some(v) = self.vertices.get(&id) {
return Ok(v.clone());
}
let e = entity(cx, id)?;
if e.name() != "VERTEX_POINT" {
return Err(format!("#{id}: expected VERTEX_POINT, found {}", e.name()));
}
let p = geom::point(cx, rid(e.params().get(1), "VERTEX_POINT")?)?;
let v = mt::Vertex::new(p);
self.vertices.insert(id, v.clone());
Ok(v)
}
/// Kernel edges (start to end) of an EDGE_CURVE.
fn edge_curve(&mut self, cx: &Ctx, id: u64) -> R<Vec<mt::Edge>> {
if let Some(e) = self.edges.get(&id) {
return Ok(e.clone());
}
let e = entity(cx, id)?;
if e.name() != "EDGE_CURVE" {
return Err(format!("#{id}: unsupported edge {}", e.name()));
}
let p = e.params();
let (vs_id, ve_id) = (rid(p.get(1), "edge start")?, rid(p.get(2), "edge end")?);
let vs = self.vertex(cx, vs_id)?;
let ve = self.vertex(cx, ve_id)?;
let sense = p.get(4).and_then(Param::as_bool).unwrap_or(true);
let g = geom::curve(cx, rid(p.get(3), "edge geometry")?, 0)?;
let closed = vs_id == ve_id;
let pieces = guard("step edge", || geom::edge_pieces(&g, vs.point(), ve.point(), sense, closed, cx.tol).map_err(crate::KernelError::Failed))
.map_err(|e| e.to_string())?;
let n = pieces.len();
let mut out = Vec::with_capacity(n);
let mut cur = vs.clone();
for (i, c) in pieces.into_iter().enumerate() {
let next = if i + 1 == n { ve.clone() } else { mt::Vertex::new(c.back()) };
out.push(mt::Edge::new_unchecked(&cur, &next, c));
cur = next;
}
self.edges.insert(id, out.clone());
Ok(out)
}
/// Edges of an ORIENTED_EDGE (or a bare EDGE_CURVE) in loop order.
fn oriented_edge(&mut self, cx: &Ctx, id: u64, depth: usize) -> R<Vec<mt::Edge>> {
if depth > 4 {
return Err("oriented edges nested too deeply".into());
}
let e = entity(cx, id)?;
match e.name() {
"ORIENTED_EDGE" => {
let p = e.params();
let inner = self.oriented_edge(cx, rid(p.get(3), "ORIENTED_EDGE")?, depth + 1)?;
if p.get(4).and_then(Param::as_bool).unwrap_or(true) { Ok(inner) } else { Ok(inner.iter().rev().map(mt::Edge::inverse).collect()) }
}
_ => self.edge_curve(cx, id),
}
}
fn poly_loop(&mut self, cx: &Ctx, pts: &[u64]) -> R<Vec<mt::Edge>> {
let n = pts.len();
if !(3..=MAX_LOOP).contains(&n) {
return Err(format!("polygon loop with {n} points"));
}
let mut verts = Vec::with_capacity(n);
for &id in pts {
if let Some(v) = self.vertices.get(&id) {
verts.push(v.clone());
} else {
let v = mt::Vertex::new(geom::point(cx, id)?);
self.vertices.insert(id, v.clone());
verts.push(v);
}
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % n)) else { continue };
if let Some(e) = self.poly_edges.get(&(b, a)) {
out.push(e.inverse());
continue;
}
let (Some(va), Some(vb)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
if va.point().distance(vb.point()) < 1e-12 {
return Err("polygon loop with repeated points".into());
}
let e = mt::Edge::try_new(va, vb, mt::Curve::Line(mt::Line(va.point(), vb.point()))).map_err(|e| format!("polygon edge: {e}"))?;
self.poly_edges.insert((a, b), e.clone());
out.push(e);
}
Ok(out)
}
/// A face bound as a wire (oriented by the bound's orientation), or `None` for a vertex loop.
fn bound(&mut self, cx: &Ctx, id: u64) -> R<Option<mt::Wire>> {
let b = entity(cx, id)?;
if !matches!(b.name(), "FACE_BOUND" | "FACE_OUTER_BOUND") {
return Err(format!("#{id}: unsupported face bound {}", b.name()));
}
let lp_id = rid(b.params().get(1), "face bound loop")?;
let orient = b.params().get(2).and_then(Param::as_bool).unwrap_or(true);
let lp = entity(cx, lp_id)?;
let edges = match lp.name() {
"EDGE_LOOP" => {
let ids = refs(lp.params().get(1));
if ids.is_empty() || ids.len() > MAX_LOOP {
return Err(format!("edge loop with {} edges", ids.len()));
}
let mut v = Vec::new();
for oe in ids {
v.extend(self.oriented_edge(cx, oe, 0)?);
}
v
}
"POLY_LOOP" => self.poly_loop(cx, &refs(lp.params().get(1)))?,
"VERTEX_LOOP" => return Ok(None),
n => return Err(format!("unsupported loop {n}")),
};
let mut w: mt::Wire = edges.into();
if !w.is_closed() {
return Err("face boundary is not a closed loop".into());
}
if !orient {
w.invert();
}
Ok(Some(w))
}
/// A face (ADVANCED_FACE, FACE_SURFACE, ORIENTED_FACE or a polygonal FACE).
fn face(&mut self, cx: &Ctx, id: u64, depth: usize) -> R<mt::Face> {
if depth > 4 {
return Err("oriented faces nested too deeply".into());
}
self.faces += 1;
if self.faces > MAX_FACES {
return Err(format!("more than {MAX_FACES} faces"));
}
let e = entity(cx, id)?;
let p = e.params();
if e.name() == "ORIENTED_FACE" {
let mut f = self.face(cx, rid(p.get(2), "ORIENTED_FACE")?, depth + 1)?;
if !p.get(3).and_then(Param::as_bool).unwrap_or(true) {
f.invert();
}
return Ok(f);
}
if !matches!(e.name(), "ADVANCED_FACE" | "FACE_SURFACE" | "FACE") {
return Err(format!("unsupported face {}", e.name()));
}
let mut wires = Vec::new();
for b in refs(p.get(1)) {
if let Some(w) = self.bound(cx, b)? {
wires.push(w);
}
}
if wires.is_empty() {
return Err("face without boundary edges".into());
}
let extent: Vec<mt::Point3> = wires
.iter()
.flat_map(|w| w.edge_iter())
.flat_map(|e| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
(0..8).map(move |i| c.subs(t0 + (t1 - t0) * i as f64 / 8.0)).collect::<Vec<_>>()
})
.collect();
// Seam edges: used twice by the face's loops.
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
for e in wires.iter().flat_map(|w| w.edge_iter()) {
*uses.entry(e.id()).or_default() += 1;
}
let mut seen = std::collections::HashSet::new();
let seam: Vec<mt::Point3> = wires
.iter()
.flat_map(|w| w.edge_iter())
.filter(|e| uses.get(&e.id()).copied().unwrap_or(0) > 1 && seen.insert(e.id()))
.flat_map(|e| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
(0..=8).map(move |i| c.subs(t0 + (t1 - t0) * i as f64 / 8.0)).collect::<Vec<_>>()
})
.collect();
let surface = if e.name() == "FACE" {
plane_through(&extent).ok_or("polygonal face without a plane")?
} else {
let sid = rid(p.get(2), "face geometry")?;
let sense = p.get(3).and_then(Param::as_bool).unwrap_or(true);
guard("step surface", || geom::surface(cx, sid, &extent, &seam, sense, 0).map_err(crate::KernelError::Failed))
.map_err(|e| e.to_string())?
};
match mt::Face::try_new(wires.clone(), surface.clone()) {
Ok(f) => Ok(f),
// Faces on periodic surfaces run along a seam edge twice: valid in STEP.
Err(truck_topology::errors::Error::NotSimpleWire) => Ok(mt::Face::new_unchecked(wires, surface)),
Err(e) => Err(format!("face: {e}")),
}
}
fn shell(&mut self, cx: &Ctx, id: u64, failed: &mut Vec<String>) -> R<(mt::Shell, bool, usize)> {
let e = entity(cx, id)?;
let p = e.params();
let (faces, flip, closed) = match e.name() {
"CLOSED_SHELL" => (refs(p.get(1)), false, true),
"OPEN_SHELL" => (refs(p.get(1)), false, false),
"ORIENTED_CLOSED_SHELL" | "ORIENTED_OPEN_SHELL" => {
let inner = entity(cx, rid(p.get(2), "oriented shell")?)?;
(refs(inner.params().get(1)), !p.get(3).and_then(Param::as_bool).unwrap_or(true), e.name() == "ORIENTED_CLOSED_SHELL")
}
n => return Err(format!("unsupported shell {n}")),
};
if faces.len() > MAX_FACES {
return Err(format!("shell with {} faces", faces.len()));
}
let mut out = Vec::with_capacity(faces.len());
for f in faces {
match self.face(cx, f, 0) {
Ok(mut face) => {
if flip {
face.invert();
}
out.push(face);
}
Err(err) => {
if self.faces > MAX_FACES {
return Err(err);
}
failed.push(format!("face #{f}: {err}"));
}
}
}
if out.is_empty() {
return Err("no face could be imported".into());
}
let read = out.len();
let out = super::split::split_bands(out, failed);
Ok((out.into(), closed, read))
}
}
/// Plane through a polygon (Newell's normal).
fn plane_through(pts: &[mt::Point3]) -> Option<mt::Surface> {
let n = pts.len();
let mut nrm = mt::Vector3::new(0.0, 0.0, 0.0);
let mut c = mt::Vector3::new(0.0, 0.0, 0.0);
for i in 0..n {
let (a, b) = (pts.get(i)?, pts.get((i + 1) % n)?);
nrm.x += (a.y - b.y) * (a.z + b.z);
nrm.y += (a.z - b.z) * (a.x + b.x);
nrm.z += (a.x - b.x) * (a.y + b.y);
c += a.to_homogeneous().truncate();
}
let len = nrm.magnitude();
if !(len > 1e-12) || n == 0 {
return None;
}
let z = nrm / len;
let o = mt::Point3::from_vec(c / n as f64);
let a = if z.x.abs() < 0.9 { mt::Vector3::unit_x() } else { mt::Vector3::unit_y() };
let x = (a - z * a.dot(z)).normalize();
let y = z.cross(x);
Some(mt::Surface::Plane(mt::Plane::new(o, o + x, o + y)))
}
/// Read a solid-like representation item: MANIFOLD_SOLID_BREP, BREP_WITH_VOIDS, FACETED_BREP,
/// or a SHELL_BASED_SURFACE_MODEL (closed shells give solids, open ones open bodies).
pub(crate) fn read_item(cx: &Ctx, id: u64) -> R<BrepOut> {
let e = entity(cx, id)?;
let p = e.params();
let mut b = Builder::default();
let mut failed = Vec::new();
let mut shells = Vec::new();
let mut closed = true;
let mut file_faces = 0;
match e.name() {
"MANIFOLD_SOLID_BREP" | "FACETED_BREP" | "BREP_WITH_VOIDS" => {
let (s, c, n) = b.shell(cx, rid(p.get(1), "brep outer shell")?, &mut failed)?;
closed &= c;
file_faces += n;
shells.push(s);
if e.name() == "BREP_WITH_VOIDS" {
for v in refs(p.get(2)) {
match b.shell(cx, v, &mut failed) {
Ok((s, c, n)) => {
closed &= c;
file_faces += n;
shells.push(s);
}
Err(err) => failed.push(format!("void shell #{v}: {err}")),
}
}
}
}
"SHELL_BASED_SURFACE_MODEL" => {
for s in refs(p.get(1)) {
match b.shell(cx, s, &mut failed) {
Ok((s, c, n)) => {
closed &= c;
file_faces += n;
shells.push(s);
}
Err(err) => failed.push(format!("shell #{s}: {err}")),
}
}
}
n => return Err(format!("unsupported item {n}")),
}
if shells.is_empty() {
return Err(failed.first().cloned().unwrap_or_else(|| "no shells".into()));
}
let solid = match guard("step solid", || mt::Solid::try_new(shells.clone()).map_err(|e| crate::KernelError::Failed(e.to_string()))) {
Ok(s) => s,
Err(_) => {
closed = false;
mt::Solid::new_unchecked(shells)
}
};
Ok(BrepOut { solid, failed, closed, file_faces })
}
+835
View File
@@ -0,0 +1,835 @@
//! STEP geometry (ISO 10303-42 entities) to kernel curves and surfaces.
//!
//! Points, placements, lines, conics, B-splines (plain and rational), elementary surfaces and
//! swept surfaces. Edge curves are trimmed to their vertices here, so every kernel edge runs
//! exactly from its start vertex to its end vertex. Lengths are scaled to millimetres while
//! reading.
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use mt::{BoundedCurve, Cut, InnerSpace, Invertible, MetricSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter};
use truck_modeling as mt;
use super::Ctx;
use super::p21::{Entity, Param};
/// Geometry nesting we follow (trimmed curve of a surface curve of …).
const MAX_GEOM_DEPTH: usize = 8;
/// Most control points in one B-spline (per direction product for surfaces).
const MAX_CTRL: usize = 1_000_000;
const MAX_COORD: f64 = 1e9;
pub(crate) type R<T> = std::result::Result<T, String>;
/// An orthonormal frame (AXIS2_PLACEMENT_3D).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Frame {
pub o: mt::Point3,
pub x: mt::Vector3,
pub y: mt::Vector3,
pub z: mt::Vector3,
}
impl Frame {
pub fn matrix(&self) -> mt::Matrix4 {
mt::Matrix4::from_cols(self.x.extend(0.0), self.y.extend(0.0), self.z.extend(0.0), self.o.to_homogeneous())
}
fn at(&self, cx: f64, cy: f64, cz: f64) -> mt::Point3 {
self.o + self.x * cx + self.y * cy + self.z * cz
}
}
/// A curve before trimming.
#[derive(Clone, Debug)]
pub(crate) enum CurveGeo {
Line {
p: mt::Point3,
d: mt::Vector3,
},
/// Ellipse (`a == b` for circles) in the frame's XY plane.
Conic {
f: Frame,
a: f64,
b: f64,
},
BSpline(mt::BSplineCurve<mt::Point3>),
Nurbs(mt::NurbsCurve<mt::Vector4>),
}
pub(crate) fn entity<'a>(cx: &Ctx<'a>, id: u64) -> R<&'a Entity> {
cx.ex.get(id).ok_or_else(|| format!("missing entity #{id}"))
}
fn ent_of<'a>(cx: &Ctx<'a>, p: Option<&Param>, what: &str) -> R<(u64, &'a Entity)> {
let id = p.and_then(Param::as_ref_id).ok_or_else(|| format!("{what}: expected a reference"))?;
Ok((id, entity(cx, id)?))
}
fn num(p: Option<&Param>, what: &str) -> R<f64> {
let x = p.and_then(Param::as_f64).ok_or_else(|| format!("{what}: expected a number"))?;
if x.is_finite() && x.abs() < MAX_COORD { Ok(x) } else { Err(format!("{what}: {x} out of range")) }
}
fn length(cx: &Ctx, p: Option<&Param>, what: &str) -> R<f64> {
Ok(num(p, what)? * cx.len)
}
pub(crate) fn point(cx: &Ctx, id: u64) -> R<mt::Point3> {
let e = entity(cx, id)?;
if e.name() != "CARTESIAN_POINT" {
return Err(format!("#{id}: expected CARTESIAN_POINT, found {}", e.name()));
}
let c = e.params().get(1).and_then(Param::as_list).ok_or("CARTESIAN_POINT without coordinates")?;
let g = |i: usize| -> R<f64> { if i < c.len() { num(c.get(i), "coordinate") } else { Ok(0.0) } };
if c.is_empty() || c.len() > 3 {
return Err(format!("#{id}: point with {} coordinates", c.len()));
}
Ok(mt::Point3::new(g(0)?, g(1)?, g(2)?) * cx.len)
}
fn direction(cx: &Ctx, id: u64) -> R<mt::Vector3> {
let e = entity(cx, id)?;
if e.name() != "DIRECTION" {
return Err(format!("#{id}: expected DIRECTION, found {}", e.name()));
}
let c = e.params().get(1).and_then(Param::as_list).ok_or("DIRECTION without ratios")?;
let g = |i: usize| -> R<f64> { if i < c.len() { num(c.get(i), "direction") } else { Ok(0.0) } };
let v = mt::Vector3::new(g(0)?, g(1)?, g(2)?);
let m = v.magnitude();
if m > 1e-12 && m.is_finite() { Ok(v / m) } else { Err(format!("#{id}: zero direction")) }
}
fn any_perpendicular(z: mt::Vector3) -> mt::Vector3 {
let a = if z.x.abs() < 0.9 { mt::Vector3::unit_x() } else { mt::Vector3::unit_y() };
let x = a - z * a.dot(z);
x / x.magnitude()
}
/// AXIS2_PLACEMENT_3D (or 2D, as a frame in the XY plane).
pub(crate) fn frame(cx: &Ctx, id: u64) -> R<Frame> {
let e = entity(cx, id)?;
let p = e.params();
let o = point(cx, p.get(1).and_then(Param::as_ref_id).ok_or("placement without location")?)?;
let (z, rf) = match e.name() {
"AXIS2_PLACEMENT_3D" => {
let z = match p.get(2).and_then(Param::as_ref_id) {
Some(d) => direction(cx, d)?,
None => mt::Vector3::unit_z(),
};
(z, p.get(3).and_then(Param::as_ref_id))
}
"AXIS2_PLACEMENT_2D" => (mt::Vector3::unit_z(), p.get(2).and_then(Param::as_ref_id)),
n => return Err(format!("#{id}: expected AXIS2_PLACEMENT_3D, found {n}")),
};
let x = match rf {
Some(d) => {
let r = direction(cx, d)?;
let x = r - z * r.dot(z);
if x.magnitude() > 1e-9 { x / x.magnitude() } else { any_perpendicular(z) }
}
None => any_perpendicular(z),
};
Ok(Frame { o, x, y: z.cross(x), z })
}
/// AXIS1_PLACEMENT: origin and unit axis.
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")?)?;
let d = match p.get(2).and_then(Param::as_ref_id) {
Some(d) => direction(cx, d)?,
None => mt::Vector3::unit_z(),
};
Ok((o, d))
}
fn knot_vec(mults: Option<&Param>, knots: Option<&Param>) -> R<mt::KnotVec> {
let m: Vec<usize> = mults
.and_then(Param::as_list)
.ok_or("B-spline without knot multiplicities")?
.iter()
.map(|x| x.as_i64().filter(|v| (1..=64).contains(v)).map(|v| v as usize).ok_or_else(|| "bad knot multiplicity".to_string()))
.collect::<R<_>>()?;
let k: Vec<f64> = knots.and_then(Param::as_list).ok_or("B-spline without knots")?.iter().map(|x| num(Some(x), "knot")).collect::<R<_>>()?;
if m.len() != k.len() || k.is_empty() || k.windows(2).any(|w| w.first() > w.get(1)) {
return Err("inconsistent knot vector".into());
}
mt::KnotVec::from_single_multi(k, m).map_err(|e| format!("knot vector: {e}"))
}
fn generated_knots(kind: &str, n_ctrl: usize, degree: usize) -> R<mt::KnotVec> {
if n_ctrl <= degree {
return Err("too few control points".into());
}
let n = n_ctrl + degree + 1;
let v: Vec<f64> = match kind {
"BEZIER" => {
if n_ctrl != degree + 1 {
// Piecewise Bézier: segments of `degree` with full interior multiplicity.
let segs = (n_ctrl - 1) / degree.max(1);
if segs * degree + 1 != n_ctrl {
return Err("Bezier control point count".into());
}
let mut v = vec![0.0; degree + 1];
for s in 1..segs {
v.extend(std::iter::repeat_n(s as f64, degree));
}
v.extend(std::iter::repeat_n(segs as f64, degree + 1));
v
} else {
let mut v = vec![0.0; degree + 1];
v.extend(std::iter::repeat_n(1.0, degree + 1));
v
}
}
"UNIFORM" => (0..n).map(|i| i as f64 - degree as f64).collect(),
_ => {
// Quasi-uniform: clamped ends, uniform interior.
let inner = n_ctrl - degree;
let mut v = vec![0.0; degree + 1];
v.extend((1..inner).map(|i| i as f64));
v.extend(std::iter::repeat_n(inner as f64, degree + 1));
v
}
};
Ok(mt::KnotVec::from(v))
}
fn weights_list(p: Option<&Param>) -> R<Vec<f64>> {
p.and_then(Param::as_list)
.ok_or("rational B-spline without weights")?
.iter()
.map(|w| num(Some(w), "weight").and_then(|w| if w > 0.0 { Ok(w) } else { Err("non-positive weight".into()) }))
.collect()
}
fn bspline_curve(cx: &Ctx, e: &Entity) -> R<CurveGeo> {
// Common part (B_SPLINE_CURVE): degree, control points, form, closed, self-intersect.
let (base, knots, kind): (Vec<Param>, Option<(Option<&Param>, Option<&Param>)>, &str) = if e.is_complex() {
let b = e.record("B_SPLINE_CURVE").ok_or("complex curve without B_SPLINE_CURVE")?;
let mut base = vec![Param::Str(String::new())];
base.extend(b.iter().cloned());
let k = e.record("B_SPLINE_CURVE_WITH_KNOTS").map(|k| (k.first(), k.get(1)));
let kind = if e.has("BEZIER_CURVE") {
"BEZIER"
} else if e.has("UNIFORM_CURVE") {
"UNIFORM"
} else {
"QUASI"
};
(base, k, kind)
} else {
let p = e.params();
let k = (e.name() == "B_SPLINE_CURVE_WITH_KNOTS").then(|| (p.get(6), p.get(7)));
let kind = match e.name() {
"BEZIER_CURVE" => "BEZIER",
"UNIFORM_CURVE" => "UNIFORM",
_ => "QUASI",
};
(p.to_vec(), k, kind)
};
let degree = base.get(1).and_then(Param::as_i64).filter(|d| (1..=32).contains(d)).ok_or("bad B-spline degree")? as usize;
let ctrl_ids = base.get(2).and_then(Param::as_list).ok_or("B-spline without control points")?;
if ctrl_ids.len() > MAX_CTRL {
return Err("B-spline with too many control points".into());
}
let ctrl: Vec<mt::Point3> = ctrl_ids.iter().map(|c| point(cx, c.as_ref_id().ok_or("bad control point")?)).collect::<R<_>>()?;
let kv = match knots {
Some((m, k)) => knot_vec(m, k)?,
None => generated_knots(kind, ctrl.len(), degree)?,
};
if kv.len() != ctrl.len() + degree + 1 {
return Err(format!("B-spline knot count {} does not match {} control points of degree {degree}", kv.len(), ctrl.len()));
}
let bsp = mt::BSplineCurve::try_new(kv, ctrl).map_err(|e| format!("B-spline: {e}"))?;
match e.record("RATIONAL_B_SPLINE_CURVE") {
Some(r) => {
let w = weights_list(r.first())?;
let n = mt::NurbsCurve::<mt::Vector4>::try_from_bspline_and_weights(bsp, w).map_err(|e| format!("rational B-spline: {e}"))?;
Ok(CurveGeo::Nurbs(n))
}
None => Ok(CurveGeo::BSpline(bsp)),
}
}
/// A STEP curve entity (unbounded or bounded).
pub(crate) fn curve(cx: &Ctx, id: u64, depth: usize) -> R<CurveGeo> {
if depth > MAX_GEOM_DEPTH {
return Err("curve definitions nested too deeply".into());
}
let e = entity(cx, id)?;
let p = e.params();
if e.is_complex() {
return bspline_curve(cx, e);
}
match e.name() {
"LINE" => {
let pt = point(cx, p.get(1).and_then(Param::as_ref_id).ok_or("LINE without point")?)?;
let (_, v) = ent_of(cx, p.get(2), "LINE vector")?;
let d = direction(cx, v.params().get(1).and_then(Param::as_ref_id).ok_or("VECTOR without direction")?)?;
Ok(CurveGeo::Line { p: pt, d })
}
"CIRCLE" => {
let f = frame(cx, p.get(1).and_then(Param::as_ref_id).ok_or("CIRCLE without placement")?)?;
let r = length(cx, p.get(2), "radius")?;
if r <= 0.0 {
return Err("circle radius must be positive".into());
}
Ok(CurveGeo::Conic { f, a: r, b: r })
}
"ELLIPSE" => {
let f = frame(cx, p.get(1).and_then(Param::as_ref_id).ok_or("ELLIPSE without placement")?)?;
let (a, b) = (length(cx, p.get(2), "semi axis")?, length(cx, p.get(3), "semi axis")?);
if a <= 0.0 || b <= 0.0 {
return Err("ellipse axes must be positive".into());
}
Ok(CurveGeo::Conic { f, a, b })
}
"B_SPLINE_CURVE_WITH_KNOTS" | "BEZIER_CURVE" | "UNIFORM_CURVE" | "QUASI_UNIFORM_CURVE" => bspline_curve(cx, e),
"POLYLINE" => {
let pts: Vec<mt::Point3> = p
.get(1)
.and_then(Param::as_list)
.ok_or("POLYLINE without points")?
.iter()
.map(|c| point(cx, c.as_ref_id().ok_or("bad polyline point")?))
.collect::<R<_>>()?;
if pts.len() < 2 || pts.len() > MAX_CTRL {
return Err("polyline point count".into());
}
let mut k = vec![0.0];
k.extend((0..pts.len()).map(|i| i as f64));
k.push((pts.len() - 1) as f64);
Ok(CurveGeo::BSpline(mt::BSplineCurve::try_new(mt::KnotVec::from(k), pts).map_err(|e| format!("polyline: {e}"))?))
}
"TRIMMED_CURVE" => curve(cx, p.get(1).and_then(Param::as_ref_id).ok_or("TRIMMED_CURVE without basis")?, depth + 1),
"SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" | "BOUNDED_SURFACE_CURVE" => {
curve(cx, p.get(1).and_then(Param::as_ref_id).ok_or("surface curve without 3D curve")?, depth + 1)
}
n => Err(format!("unsupported curve {n}")),
}
}
/// Rational quadratic NURBS arc of an ellipse from angle `t0` over `sweep > 0`.
pub(crate) fn conic_arc(f: &Frame, a: f64, b: f64, t0: f64, sweep: f64) -> mt::NurbsCurve<mt::Vector4> {
let n = ((sweep / FRAC_PI_2).ceil() as usize).clamp(1, 8);
let dt = sweep / n as f64;
let w = (dt * 0.5).cos();
let at = |c: f64, s: f64| f.at(a * c, b * s, 0.0);
let mut ctrl = Vec::with_capacity(2 * n + 1);
let p0 = at(t0.cos(), t0.sin());
ctrl.push(p0.to_homogeneous());
let mut knots = vec![0.0, 0.0, 0.0];
for i in 0..n {
let ta = t0 + dt * i as f64;
let tm = ta + dt * 0.5;
let tb = ta + dt;
let m = at(tm.cos() / w, tm.sin() / w);
ctrl.push(mt::Vector4::new(m.x * w, m.y * w, m.z * w, w));
ctrl.push(at(tb.cos(), tb.sin()).to_homogeneous());
let k = (i + 1) as f64 / n as f64;
knots.push(k);
knots.push(k);
}
// Last knot pair becomes the clamped end (multiplicity 3).
knots.push(1.0);
mt::NurbsCurve::new(mt::BSplineCurve::new(mt::KnotVec::from(knots), ctrl))
}
fn conic_angle(f: &Frame, a: f64, b: f64, p: mt::Point3) -> f64 {
let d = p - f.o;
(d.dot(f.y) / b).atan2(d.dot(f.x) / a)
}
/// Parameter of the curve point nearest to `p`, and its distance.
fn nearest_param<C>(c: &C, p: mt::Point3) -> Option<(f64, f64)>
where
C: ParametricCurve<Point = mt::Point3> + BoundedCurve + SearchNearestParameter<mt::D1, Point = mt::Point3>,
{
let (t0, t1) = c.range_tuple();
let n = 256;
let mut best = (t0, f64::INFINITY);
for i in 0..=n {
let t = t0 + (t1 - t0) * i as f64 / n as f64;
let d = c.subs(t).distance(p);
if d < best.1 {
best = (t, d);
}
}
if let Some(t) = c.search_nearest_parameter(p, best.0, 64) {
let t = t.clamp(t0, t1);
let d = c.subs(t).distance(p);
if d < best.1 {
best = (t, d);
}
}
best.1.is_finite().then_some(best)
}
/// Sub-curve on `[a, b]` (inside the curve's range).
fn sub_curve<C: Cut + BoundedCurve + Clone>(c: &C, a: f64, b: f64) -> C {
let (t0, t1) = c.range_tuple();
let eps = (t1 - t0).abs() * 1e-9;
let mut out = c.clone();
if a > t0 + eps {
out = out.cut(a);
}
if b < t1 - eps {
let _ = out.cut(b);
}
out
}
/// Pieces of a spline edge from `ps` to `pe` (two pieces for a closed edge).
fn spline_pieces<C>(c: &C, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64) -> R<Vec<C>>
where
C: ParametricCurve<Point = mt::Point3> + BoundedCurve + SearchNearestParameter<mt::D1, Point = mt::Point3> + Cut + Clone + Invertible,
{
let (t0, t1) = c.range_tuple();
let (ts, ds) = nearest_param(c, ps).ok_or("spline evaluation failed")?;
let (te, de) = nearest_param(c, pe).ok_or("spline evaluation failed")?;
if ds > tol || de > tol {
return Err(format!("edge vertices are {:.3e} mm off their curve", ds.max(de)));
}
let span = t1 - t0;
let near = |a: f64, b: f64| (a - b).abs() <= span * 1e-7;
let mut pieces = if closed {
// Whole closed curve starting at the vertex.
if near(ts, t0) || near(ts, t1) {
let m = t0 + span * 0.5;
vec![sub_curve(c, t0, m), sub_curve(c, m, t1)]
} else {
vec![sub_curve(c, ts, t1), sub_curve(c, t0, ts)]
}
} else {
let (mut a, mut b) = if sense { (ts, te) } else { (te, ts) };
// A closed curve traversed across its seam.
if a >= b && c.subs(t0).distance(c.subs(t1)) <= tol {
if near(b, t0) {
b = t1;
} else if near(a, t1) {
a = t0;
} else {
let mut v = vec![sub_curve(c, a, t1), sub_curve(c, t0, b)];
if !sense {
v.reverse();
v.iter_mut().for_each(|x| x.invert());
}
return Ok(v);
}
}
if b - a <= span * 1e-9 {
return Err("degenerate spline edge".into());
}
vec![sub_curve(c, a, b)]
};
if !sense {
pieces.reverse();
pieces.iter_mut().for_each(|x| x.invert());
}
Ok(pieces)
}
/// Kernel curves for an edge from `ps` to `pe`: one piece, or two for a closed edge (so no
/// kernel edge starts and ends at the same vertex).
pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64) -> R<Vec<mt::Curve>> {
match g {
CurveGeo::Line { .. } => {
if closed {
return Err("closed straight edge".into());
}
Ok(vec![mt::Curve::Line(mt::Line(ps, pe))])
}
CurveGeo::Conic { f, a, b } => {
let (a, b) = (*a, *b);
let ts = conic_angle(f, a, b, ps);
let te = conic_angle(f, a, b, pe);
let mut sweep = if sense { te - ts } else { ts - te };
sweep = sweep.rem_euclid(TAU);
if closed || sweep < 1e-12 {
sweep = TAU;
}
let pieces: Vec<(f64, f64)> = if sweep > PI * 1.5 { vec![(0.0, sweep * 0.5), (sweep * 0.5, sweep)] } else { vec![(0.0, sweep)] };
Ok(pieces
.into_iter()
.map(|(s0, s1)| {
if sense {
mt::Curve::NurbsCurve(conic_arc(f, a, b, ts + s0, s1 - s0))
} else {
let mut c = conic_arc(f, a, b, ts - s1, s1 - s0);
c.invert();
mt::Curve::NurbsCurve(c)
}
})
.collect())
}
CurveGeo::BSpline(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::BSplineCurve).collect()),
CurveGeo::Nurbs(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::NurbsCurve).collect()),
}
}
/// A bounded kernel curve for a swept surface's profile (lines are bounded by `extent`).
fn bounded_curve(g: &CurveGeo, extent: &[mt::Point3]) -> R<mt::Curve> {
Ok(match g {
CurveGeo::Line { p, d } => {
let (lo, hi) = span_along(extent, *p, *d);
mt::Curve::Line(mt::Line(*p + *d * lo, *p + *d * hi))
}
CurveGeo::Conic { f, a, b } => mt::Curve::NurbsCurve(conic_arc(f, *a, *b, 0.0, TAU)),
CurveGeo::BSpline(c) => mt::Curve::BSplineCurve(c.clone()),
CurveGeo::Nurbs(c) => mt::Curve::NurbsCurve(c.clone()),
})
}
/// Range of `(q - o)·d` over the points, widened by a margin.
fn span_along(pts: &[mt::Point3], o: mt::Point3, d: mt::Vector3) -> (f64, f64) {
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for q in pts {
let t = (q - o).dot(d);
lo = lo.min(t);
hi = hi.max(t);
}
if !(lo.is_finite() && hi.is_finite()) {
return (-1.0, 1.0);
}
let m = (hi - lo) * 0.25 + 1e-3;
(lo - m, hi + m)
}
/// The angle shared by all the given angles (within 1e-4 rad), if there are any.
fn common_angle(angles: impl Iterator<Item = f64>) -> Option<f64> {
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0usize);
let mut all = Vec::new();
for a in angles {
sx += a.cos();
sy += a.sin();
n += 1;
all.push(a);
}
if n == 0 {
return None;
}
let m = sy.atan2(sx);
let close = all.iter().all(|a| {
let d = (a - m).rem_euclid(TAU);
d.min(TAU - d) < 1e-4
});
close.then_some(m)
}
/// Middle and width of the largest gap between angles (radians, any range).
fn largest_gap(angles: &[f64]) -> Option<(f64, f64)> {
let mut a: Vec<f64> = angles.iter().map(|x| x.rem_euclid(TAU)).filter(|x| x.is_finite()).collect();
if a.is_empty() {
return None;
}
a.sort_by(f64::total_cmp);
let (mut best, mut mid) = (0.0, 0.0);
for i in 0..a.len() {
let (Some(&x), Some(&y)) = (a.get(i), a.get((i + 1) % a.len())) else { continue };
let gap = if i + 1 == a.len() { y + TAU - x } else { y - x };
if gap > best {
best = gap;
mid = x + gap * 0.5;
}
}
Some((mid, best))
}
/// The frame turned about its Z axis so that its X axis (the parameter seam of a revolved
/// surface) lies in the widest angular gap of the face boundary.
fn seam_frame(f: &Frame, extent: &[mt::Point3], seam: &[mt::Point3]) -> Frame {
// A seam edge of the face along the rotation: the parameter seam must be there.
if let Some(m) = common_angle(seam.iter().filter_map(|p| {
let d = p - f.o;
let (x, y) = (d.dot(f.x), d.dot(f.y));
(x.hypot(y) > 1e-6 * (1.0 + d.magnitude())).then(|| y.atan2(x))
})) {
let x = f.x * m.cos() + f.y * m.sin();
return Frame { o: f.o, x, y: f.z.cross(x), z: f.z };
}
let az: Vec<f64> = extent
.iter()
.filter_map(|p| {
let d = p - f.o;
let (x, y) = (d.dot(f.x), d.dot(f.y));
(x.hypot(y) > 1e-9).then(|| y.atan2(x))
})
.collect();
match largest_gap(&az) {
Some((m, gap)) if gap > 1e-3 => {
let x = f.x * m.cos() + f.y * m.sin();
Frame { o: f.o, x, y: f.z.cross(x), z: f.z }
}
_ => *f,
}
}
/// Polar axis for a sphere: far from every boundary point, and (for a face concentrated on
/// one side) with both poles outside the face, so every loop closes in parameter space.
fn sphere_axis(f: &Frame, extent: &[mt::Point3]) -> mt::Vector3 {
let dirs: Vec<mt::Vector3> = extent
.iter()
.filter_map(|p| {
let d = p - f.o;
let m = d.magnitude();
(m > 1e-12).then(|| d / m)
})
.collect();
if dirs.is_empty() {
return f.z;
}
let mean = dirs.iter().fold(mt::Vector3::new(0.0, 0.0, 0.0), |a, d| a + d) / dirs.len() as f64;
let concentrated = mean.magnitude() > 0.3;
let mhat = if concentrated { mean / mean.magnitude() } else { mean };
let n = 400;
let golden = PI * (3.0 - 5f64.sqrt());
let mut best = (f64::NEG_INFINITY, f.z);
// Half of a Fibonacci sphere (an axis and its opposite are the same poles).
for i in 0..n {
let z = 1.0 - (i as f64 + 0.5) / n as f64;
let r = (1.0 - z * z).max(0.0).sqrt();
let t = golden * i as f64;
let a = mt::Vector3::new(r * t.cos(), r * t.sin(), z);
if concentrated && a.dot(mhat).abs() > 0.35 {
continue;
}
let score = dirs.iter().map(|d| 1.0 - d.dot(a).abs()).fold(f64::INFINITY, f64::min);
if score > best.0 {
best = (score, a);
}
}
best.1
}
/// Tube angle of a point on a torus.
fn tube_angle(f: &Frame, big: f64, p: mt::Point3) -> f64 {
let d = p - f.o;
let z = d.dot(f.z);
let rho = (d - f.z * z).magnitude();
z.atan2(rho - big)
}
fn revolved(curve: mt::Curve, o: mt::Point3, axis: mt::Vector3) -> mt::Surface {
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(curve, o, axis)))
}
/// A surface of revolution whose normal at the profile point `(u, 0)` points along `expected`.
/// The rotation is reversed rather than the surface inverted: truck's inverted `Processor`
/// swaps the parameters but not the search hints, which breaks meshing.
fn revolved_oriented(curve: mt::Curve, o: mt::Point3, axis: mt::Vector3, u: f64, expected: mt::Vector3) -> mt::Surface {
let s = revolved(curve.clone(), o, axis);
if s.normal(u, 0.0).dot(expected) >= 0.0 {
return s;
}
revolved(curve, o, -axis)
}
fn bspline_surface(cx: &Ctx, e: &Entity) -> R<mt::Surface> {
let (base, knots, kind): (Vec<Param>, Option<[Option<&Param>; 4]>, &str) = if e.is_complex() {
let b = e.record("B_SPLINE_SURFACE").ok_or("complex surface without B_SPLINE_SURFACE")?;
let mut base = vec![Param::Str(String::new())];
base.extend(b.iter().cloned());
let k = e.record("B_SPLINE_SURFACE_WITH_KNOTS").map(|k| [k.first(), k.get(1), k.get(2), k.get(3)]);
let kind = if e.has("BEZIER_SURFACE") {
"BEZIER"
} else if e.has("UNIFORM_SURFACE") {
"UNIFORM"
} else {
"QUASI"
};
(base, k, kind)
} else {
let p = e.params();
let k = (e.name() == "B_SPLINE_SURFACE_WITH_KNOTS").then(|| [p.get(8), p.get(9), p.get(10), p.get(11)]);
let kind = match e.name() {
"BEZIER_SURFACE" => "BEZIER",
"UNIFORM_SURFACE" => "UNIFORM",
_ => "QUASI",
};
(p.to_vec(), k, kind)
};
let deg = |i: usize| base.get(i).and_then(Param::as_i64).filter(|d| (1..=32).contains(d)).map(|d| d as usize).ok_or("bad B-spline degree");
let (du, dv) = (deg(1)?, deg(2)?);
let rows = base.get(3).and_then(Param::as_list).ok_or("B-spline surface without control points")?;
let mut ctrl: Vec<Vec<mt::Point3>> = Vec::with_capacity(rows.len());
let mut total = 0usize;
for r in rows {
let r = r.as_list().ok_or("bad control point row")?;
total += r.len();
if total > MAX_CTRL {
return Err("B-spline surface with too many control points".into());
}
ctrl.push(r.iter().map(|c| point(cx, c.as_ref_id().ok_or("bad control point")?)).collect::<R<_>>()?);
}
let nu = ctrl.len();
let nv = ctrl.first().map(Vec::len).unwrap_or(0);
if nu == 0 || ctrl.iter().any(|r| r.len() != nv) {
return Err("ragged B-spline control net".into());
}
let (ku, kv) = match knots {
Some([mu, mv, ku, kv]) => (knot_vec(mu, ku)?, knot_vec(mv, kv)?),
None => (generated_knots(kind, nu, du)?, generated_knots(kind, nv, dv)?),
};
if ku.len() != nu + du + 1 || kv.len() != nv + dv + 1 {
return Err("B-spline surface knot counts do not match the control net".into());
}
let bsp = mt::BSplineSurface::try_new((ku, kv), ctrl).map_err(|e| format!("B-spline surface: {e}"))?;
match e.record("RATIONAL_B_SPLINE_SURFACE") {
Some(r) => {
let w: Vec<Vec<f64>> = r
.first()
.and_then(Param::as_list)
.ok_or("rational surface without weights")?
.iter()
.map(|row| weights_list(Some(row)))
.collect::<R<_>>()?;
if w.len() != nu || w.iter().any(|r| r.len() != nv) {
return Err("weights do not match the control net".into());
}
let n = mt::NurbsSurface::<mt::Vector4>::try_from_bspline_and_weights(bsp, w).map_err(|e| format!("rational surface: {e}"))?;
Ok(mt::Surface::NurbsSurface(n))
}
None => Ok(mt::Surface::BSplineSurface(bsp)),
}
}
/// A STEP surface entity as a kernel surface whose normal is the STEP surface normal.
/// `extent` holds points of the face boundary (bounds the analytic surfaces' parameter ranges).
/// `sense` false gives the opposite normal (a face with `same_sense = .F.`).
/// `seam` holds points of the face's seam edges (edges its loops use twice).
pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Point3], sense: bool, depth: usize) -> R<mt::Surface> {
let sign = if sense { 1.0 } else { -1.0 };
let flip = |mut s: mt::Surface| {
if !sense {
s.invert();
}
s
};
if depth > MAX_GEOM_DEPTH {
return Err("surface definitions nested too deeply".into());
}
let e = entity(cx, id)?;
if e.is_complex() {
return bspline_surface(cx, e).map(flip);
}
let p = e.params();
let placement = || -> R<Frame> { frame(cx, p.get(1).and_then(Param::as_ref_id).ok_or("surface without placement")?) };
match e.name() {
"PLANE" => {
let f = placement()?;
Ok(flip(mt::Surface::Plane(mt::Plane::new(f.o, f.o + f.x, f.o + f.y))))
}
"CYLINDRICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
let r = length(cx, p.get(2), "radius")?;
if r <= 0.0 {
return Err("cylinder radius must be positive".into());
}
let (lo, hi) = span_along(extent, f.o, f.z);
let line = mt::Curve::Line(mt::Line(f.at(r, 0.0, lo), f.at(r, 0.0, hi)));
Ok(revolved_oriented(line, f.o, f.z, 0.5, f.x * sign))
}
"CONICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
let r = length(cx, p.get(2), "radius")?;
let a = num(p.get(3), "semi angle")? * cx.ang;
if r < 0.0 || !(a.abs() > 1e-9 && a.abs() < FRAC_PI_2 - 1e-9) {
return Err("bad cone".into());
}
let t = a.tan();
let (mut lo, mut hi) = span_along(extent, f.o, f.z);
// Stop at the apex.
let apex = -r / t;
if t > 0.0 {
lo = lo.max(apex);
} else {
hi = hi.min(apex);
}
if hi - lo <= 1e-9 {
return Err("cone face has no extent".into());
}
let line = mt::Curve::Line(mt::Line(f.at(r + lo * t, 0.0, lo), f.at(r + hi * t, 0.0, hi)));
Ok(revolved_oriented(line, f.o, f.z, 0.5, (f.x * a.cos() - f.z * a.sin()) * sign))
}
"SPHERICAL_SURFACE" => {
let f = placement()?;
let r = length(cx, p.get(2), "radius")?;
if r <= 0.0 {
return Err("sphere radius must be positive".into());
}
// With seam edges the file's own parameterisation fits the loops; else pick poles
// away from the boundary.
let f = if seam.is_empty() {
let z = sphere_axis(&f, extent);
let x = any_perpendicular(z);
seam_frame(&Frame { o: f.o, x, y: z.cross(x), z }, extent, seam)
} else {
seam_frame(&f, extent, seam)
};
let half = Frame { o: f.o, x: f.x, y: f.z, z: -f.y };
let arc = conic_arc(&half, r, r, -FRAC_PI_2, PI);
let um = (arc.range_tuple().0 + arc.range_tuple().1) * 0.5;
Ok(revolved_oriented(mt::Curve::NurbsCurve(arc), f.o, f.z, um, f.x * sign))
}
"TOROIDAL_SURFACE" => {
let f = placement()?;
let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")?);
if small <= 0.0 || big < 0.0 {
return Err("bad torus radii".into());
}
// Seam edges either run around the axis (constant tube angle) or around the tube
// (constant azimuth); each fixes one parameter seam.
let tube_seam: Vec<mt::Point3> =
seam.iter().copied().filter(|p| common_angle(std::iter::once(tube_angle(&f, big, *p))).is_some()).collect();
let phis_seam = common_angle(tube_seam.iter().map(|p| tube_angle(&f, big, *p)));
let az_seam: Vec<mt::Point3> = if phis_seam.is_some() { Vec::new() } else { seam.to_vec() };
let f = seam_frame(&f, extent, &az_seam);
let phis: Vec<f64> = extent.iter().map(|p| tube_angle(&f, big, *p)).collect();
let phi0 = match (phis_seam, largest_gap(&phis)) {
(Some(m), _) => m,
(None, Some((m, _))) => m,
(None, None) => 0.0,
};
let tube = Frame { o: f.at(big, 0.0, 0.0), x: f.x, y: f.z, z: -f.y };
let circle = conic_arc(&tube, small, small, phi0, TAU);
let u0 = circle.range_tuple().0;
let outward = f.x * phi0.cos() + f.z * phi0.sin();
Ok(revolved_oriented(mt::Curve::NurbsCurve(circle), f.o, f.z, u0, outward * sign))
}
"B_SPLINE_SURFACE_WITH_KNOTS" | "BEZIER_SURFACE" | "UNIFORM_SURFACE" | "QUASI_UNIFORM_SURFACE" => bspline_surface(cx, e).map(flip),
"SURFACE_OF_REVOLUTION" => {
let g = curve(cx, p.get(1).and_then(Param::as_ref_id).ok_or("revolution without profile")?, depth + 1)?;
let (o, axis) = axis1(cx, p.get(2).and_then(Param::as_ref_id).ok_or("revolution without axis")?)?;
// Reversed sense: turn the other way round the axis.
Ok(revolved(bounded_curve(&g, extent)?, 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)?;
let (_, v) = ent_of(cx, p.get(2), "extrusion vector")?;
let d = direction(cx, v.params().get(1).and_then(Param::as_ref_id).ok_or("VECTOR without direction")?)?;
let nurbs: mt::NurbsCurve<mt::Vector4> = match bounded_curve(&g, extent)? {
mt::Curve::Line(l) => mt::BSplineCurve::new(mt::KnotVec::bezier_knot(1), vec![l.0, l.1]).into(),
mt::Curve::BSplineCurve(c) => c.into(),
mt::Curve::NurbsCurve(c) => c,
mt::Curve::IntersectionCurve(_) => return Err("unsupported extrusion profile".into()),
};
let (t0, t1) = nurbs.range_tuple();
let profile: Vec<mt::Point3> = (0..=32).map(|i| nurbs.subs(t0 + (t1 - t0) * i as f64 / 32.0)).collect();
let (plo, phi) = span_along(&profile, mt::Point3::new(0.0, 0.0, 0.0), d);
let (elo, ehi) = span_along(extent, mt::Point3::new(0.0, 0.0, 0.0), d);
let (lo, hi) = (elo - phi, ehi - plo);
let bsp = nurbs.into_non_rationalized();
let shift = |w: &mt::Vector4, s: f64| mt::Vector4::new(w.x + d.x * s * w.w, w.y + d.y * s * w.w, w.z + d.z * s * w.w, w.w);
let ctrl: Vec<Vec<mt::Vector4>> = bsp.control_points().iter().map(|w| vec![shift(w, lo), shift(w, hi)]).collect();
let s =
mt::BSplineSurface::try_new((bsp.knot_vec().clone(), mt::KnotVec::bezier_knot(1)), ctrl).map_err(|e| format!("extrusion: {e}"))?;
Ok(flip(mt::Surface::NurbsSurface(mt::NurbsSurface::new(s))))
}
"RECTANGULAR_TRIMMED_SURFACE" | "CURVE_BOUNDED_SURFACE" => {
surface(cx, p.get(1).and_then(Param::as_ref_id).ok_or("trimmed surface without basis")?, extent, seam, sense, depth + 1)
}
n => Err(format!("unsupported surface {n}")),
}
}
+638
View File
@@ -0,0 +1,638 @@
//! STEP import (ISO 10303-21 files, AP203/AP214/AP242 geometry).
//!
//! Our own reader, written from the public standards: [`p21`] parses the exchange structure,
//! [`geom`] and [`brep`] turn geometry and topology into kernel solids, and this module walks
//! the product structure (products, shape representations, assemblies with their placements,
//! units, colours). STEP files are untrusted: every count and nesting depth is capped, every
//! truck call runs under [`guard`], and what cannot be read becomes a warning rather than a
//! failure wherever the rest of the file is still usable.
mod brep;
mod geom;
pub(crate) mod p21;
mod split;
use std::collections::{BTreeMap, HashMap, HashSet};
use serde::{Deserialize, Serialize};
use truck_modeling as mt;
use crate::body::Body;
use crate::{KernelError, Result, guard};
use geom::R;
use p21::{Entity, Exchange, Param};
/// Deepest assembly nesting followed.
const MAX_ASSEMBLY_DEPTH: usize = 32;
/// Most body instances in one import.
const MAX_BODIES: usize = 10_000;
/// A body read from a STEP file.
#[derive(Clone, Debug)]
pub struct ImportedBody {
/// Solid name (the brep's label), else the product name.
pub name: String,
pub body: Body,
/// Surface colour (linear-ish RGB 0..1) from the file's presentation styles.
pub color: Option<[f32; 3]>,
/// Product names from the root to the part that holds the body.
pub path: Vec<String>,
/// The body is a closed solid (false: faces were left out, or the file had a surface model).
pub closed: bool,
/// Faces read from the file. The body can have more: a face that wraps all the way around
/// a closed surface is split in two for the kernel.
pub file_faces: usize,
}
/// One product occurrence in the file's assembly tree.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StepNode {
pub name: String,
/// Placement in the parent (column-major 4×4, millimetres).
pub transform: [[f64; 4]; 4],
/// Indices into [`StepImport::bodies`] of this product's own bodies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bodies: Vec<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<StepNode>,
}
/// Result of [`step_import`].
#[derive(Clone, Debug)]
pub struct StepImport {
pub bodies: Vec<ImportedBody>,
/// Root products (an assembly tree; a plain part is a single root).
pub tree: Vec<StepNode>,
/// What could not be read (unsupported entities, faces left out…).
pub warnings: Vec<String>,
/// FILE_SCHEMA (e.g. `AUTOMOTIVE_DESIGN`, `CONFIG_CONTROL_DESIGN`, `AP242_…`).
pub schema: String,
/// FILE_NAME originating system.
pub system: String,
}
/// Reading context: the file and the units of the representation being read.
pub(crate) struct Ctx<'a> {
pub ex: &'a Exchange,
/// Millimetres per file length unit.
pub len: f64,
/// Radians per file angle unit.
pub ang: f64,
/// Vertex-on-curve tolerance (mm).
pub tol: f64,
}
#[derive(Clone, Copy)]
struct Units {
len: f64,
ang: f64,
tol: f64,
}
const DEFAULT_UNITS: Units = Units { len: 1.0, ang: 1.0, tol: 0.01 };
fn si_prefix(p: Option<&Param>) -> f64 {
match p.and_then(Param::as_enum) {
Some("EXA") => 1e18,
Some("PETA") => 1e15,
Some("TERA") => 1e12,
Some("GIGA") => 1e9,
Some("MEGA") => 1e6,
Some("KILO") => 1e3,
Some("HECTO") => 1e2,
Some("DECA") => 1e1,
Some("DECI") => 1e-1,
Some("CENTI") => 1e-2,
Some("MILLI") => 1e-3,
Some("MICRO") => 1e-6,
Some("NANO") => 1e-9,
Some("PICO") => 1e-12,
Some("FEMTO") => 1e-15,
Some("ATTO") => 1e-18,
_ => 1.0,
}
}
/// A unit's size: millimetres for lengths, radians for plane angles.
fn unit_factor(ex: &Exchange, id: u64, depth: usize) -> Option<(&'static str, f64)> {
if depth > 4 {
return None;
}
let e = ex.get(id)?;
let kind = if e.has("LENGTH_UNIT") {
"length"
} else if e.has("PLANE_ANGLE_UNIT") {
"angle"
} else {
"other"
};
if let Some(si) = e.record("SI_UNIT") {
let base = match si.get(1).and_then(Param::as_enum) {
Some("METRE") => 1000.0,
Some("RADIAN") => 1.0,
_ => return Some((kind, 1.0)),
};
return Some((kind, si_prefix(si.first()) * base));
}
if let Some(cb) = e.record("CONVERSION_BASED_UNIT") {
let m = ex.get(cb.get(1)?.as_ref_id()?)?;
let mp = m.params();
let v = mp.first()?.as_f64()?;
let (_, f) = unit_factor(ex, mp.get(1)?.as_ref_id()?, depth + 1)?;
let x = v * f;
return (x.is_finite() && x > 0.0).then_some((kind, x));
}
Some((kind, 1.0))
}
/// Units of a representation context (GLOBAL_UNIT_ASSIGNED_CONTEXT, uncertainty).
fn context_units(ex: &Exchange, ctx: Option<u64>, cache: &mut HashMap<u64, Units>) -> Units {
let Some(id) = ctx else { return DEFAULT_UNITS };
if let Some(u) = cache.get(&id) {
return *u;
}
let mut u = DEFAULT_UNITS;
let mut tol_file = None;
if let Some(e) = ex.get(id) {
if let Some(units) = e.record("GLOBAL_UNIT_ASSIGNED_CONTEXT").and_then(|r| r.first()).and_then(Param::as_list) {
for uid in units.iter().filter_map(Param::as_ref_id) {
match unit_factor(ex, uid, 0) {
Some(("length", f)) if f.is_finite() && f > 0.0 => u.len = f,
Some(("angle", f)) if f.is_finite() && f > 0.0 => u.ang = f,
_ => {}
}
}
}
if let Some(unc) = e.record("GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT").and_then(|r| r.first()).and_then(Param::as_list) {
for uid in unc.iter().filter_map(Param::as_ref_id) {
if let Some(m) = ex.get(uid)
&& let Some(v) = m.params().first().and_then(Param::as_f64)
{
let f = m.params().get(1).and_then(Param::as_ref_id).and_then(|x| unit_factor(ex, x, 0)).map(|x| x.1);
tol_file = Some(v * f.unwrap_or(u.len));
}
}
}
}
u.tol = tol_file.filter(|t| t.is_finite() && *t > 0.0).map(|t| (t * 10.0).max(1e-3)).unwrap_or(u.len * 1e-2).clamp(1e-4, 1.0);
cache.insert(id, u);
u
}
/// Items and context of a representation entity.
fn representation(e: &Entity) -> Option<(Vec<u64>, Option<u64>)> {
let p = if e.is_complex() {
e.record("REPRESENTATION")
.or_else(|| e.records.iter().find(|r| r.name.ends_with("REPRESENTATION") && r.params.len() >= 3).map(|r| r.params.as_slice()))?
} else if e.name().ends_with("REPRESENTATION") {
e.params()
} else {
return None;
};
let items = p.get(1)?.as_list()?.iter().filter_map(Param::as_ref_id).collect();
Some((items, p.get(2).and_then(Param::as_ref_id)))
}
fn mat_mul(a: &mt::Matrix4, b: &mt::Matrix4) -> mt::Matrix4 {
a * b
}
fn to_array(m: &mt::Matrix4) -> [[f64; 4]; 4] {
[[m.x.x, m.x.y, m.x.z, m.x.w], [m.y.x, m.y.y, m.y.z, m.y.w], [m.z.x, m.z.y, m.z.z, m.z.w], [m.w.x, m.w.y, m.w.z, m.w.w]]
}
/// Inverse of a rigid placement matrix (orthonormal rotation + translation).
fn rigid_inverse(m: &mt::Matrix4) -> mt::Matrix4 {
use mt::SquareMatrix;
m.invert().unwrap_or_else(mt::Matrix4::identity)
}
struct Reader<'a> {
ex: &'a Exchange,
units: HashMap<u64, Units>,
warnings: BTreeMap<String, usize>,
/// Representation → its items and context.
reps: HashMap<u64, (Vec<u64>, Option<u64>)>,
/// Plain representation relationships (same product, no placement).
rep_links: HashMap<u64, Vec<u64>>,
/// Product definition → its shape representations.
pd_reps: HashMap<u64, Vec<u64>>,
/// Product definition → child occurrences (NAUO ids).
children: HashMap<u64, Vec<u64>>,
/// NAUO → placement relationship entity.
placements: HashMap<u64, u64>,
/// Styled item → colour.
colors: HashMap<u64, [f32; 3]>,
/// Converted items (brep id → solid, or the error).
solids: HashMap<u64, std::result::Result<(mt::Solid, bool, usize), String>>,
used_items: HashSet<u64>,
/// Items already instanced for the product being read.
node_items: HashSet<u64>,
bodies: Vec<ImportedBody>,
}
impl<'a> Reader<'a> {
fn warn(&mut self, msg: impl Into<String>) {
*self.warnings.entry(msg.into()).or_default() += 1;
}
fn index(&mut self) {
let ex = self.ex;
let mut pds: HashMap<u64, u64> = HashMap::new(); // PDS → definition
let mut sdrs: Vec<(u64, u64)> = Vec::new(); // (PDS, rep)
let mut cdsrs: Vec<(u64, u64)> = Vec::new(); // (relationship, PDS)
let mut nauos: Vec<(u64, u64, u64)> = Vec::new(); // (id, parent, child)
let mut ids: Vec<&u64> = ex.entities.keys().collect();
ids.sort();
for &id in ids {
let Some(e) = ex.get(id) else { continue };
if let Some(r) = representation(e) {
self.reps.insert(id, r);
}
let p = e.params();
let rf = |i: usize| p.get(i).and_then(Param::as_ref_id);
match e.name() {
"PRODUCT_DEFINITION_SHAPE" | "PROPERTY_DEFINITION" => {
if let Some(d) = rf(2) {
pds.insert(id, d);
}
}
"SHAPE_DEFINITION_REPRESENTATION" => {
if let (Some(d), Some(r)) = (rf(0), rf(1)) {
sdrs.push((d, r));
}
}
"CONTEXT_DEPENDENT_SHAPE_REPRESENTATION" => {
if let (Some(r), Some(d)) = (rf(0), rf(1)) {
cdsrs.push((r, d));
}
}
"NEXT_ASSEMBLY_USAGE_OCCURRENCE" | "ASSEMBLY_COMPONENT_USAGE" | "SPECIFIED_HIGHER_USAGE_OCCURRENCE" => {
if let (Some(a), Some(b)) = (rf(3), rf(4)) {
nauos.push((id, a, b));
}
}
"SHAPE_REPRESENTATION_RELATIONSHIP" | "REPRESENTATION_RELATIONSHIP" if !e.is_complex() => {
if let (Some(a), Some(b)) = (rf(2), rf(3)) {
self.rep_links.entry(a).or_default().push(b);
self.rep_links.entry(b).or_default().push(a);
}
}
"STYLED_ITEM" | "OVER_RIDING_STYLED_ITEM" => {
if let Some(item) = rf(2)
&& let Some(c) = self.find_colour(p.get(1))
{
self.colors.entry(item).or_insert(c);
}
}
_ => {}
}
}
for (d, r) in sdrs {
if let Some(&pd) = pds.get(&d) {
self.pd_reps.entry(pd).or_default().push(r);
}
}
for (r, d) in cdsrs {
if let Some(&nauo) = pds.get(&d) {
self.placements.insert(nauo, r);
}
}
for (id, parent, child) in nauos {
if parent != child {
self.children.entry(parent).or_default().push(id);
}
}
}
/// First colour reachable from a style list (bounded search).
fn find_colour(&self, styles: Option<&Param>) -> Option<[f32; 3]> {
let mut queue: Vec<u64> = styles?.as_list()?.iter().filter_map(Param::as_ref_id).collect();
let mut seen = HashSet::new();
while let Some(id) = queue.pop() {
if seen.len() > 64 || !seen.insert(id) {
continue;
}
let e = self.ex.get(id)?;
if e.name() == "COLOUR_RGB" {
let p = e.params();
let c = |i: usize| p.get(i).and_then(Param::as_f64).map(|x| x.clamp(0.0, 1.0) as f32);
return Some([c(1)?, c(2)?, c(3)?]);
}
if e.name() == "DRAUGHTING_PRE_DEFINED_COLOUR" {
return Some(match e.params().first().and_then(Param::as_str).unwrap_or("") {
"red" => [1.0, 0.0, 0.0],
"green" => [0.0, 1.0, 0.0],
"blue" => [0.0, 0.0, 1.0],
"yellow" => [1.0, 1.0, 0.0],
"magenta" => [1.0, 0.0, 1.0],
"cyan" => [0.0, 1.0, 1.0],
"black" => [0.0, 0.0, 0.0],
_ => [1.0, 1.0, 1.0],
});
}
fn walk(p: &Param, out: &mut Vec<u64>, depth: usize) {
match p {
Param::Ref(r) => out.push(*r),
Param::List(v) | Param::Typed(_, v) if depth < 4 => v.iter().for_each(|x| walk(x, out, depth + 1)),
_ => {}
}
}
for r in &e.records {
for p in &r.params {
walk(p, &mut queue, 0);
}
}
}
None
}
fn units_of(&mut self, rep: u64) -> Units {
let ctx = self.reps.get(&rep).and_then(|r| r.1);
context_units(self.ex, ctx, &mut self.units)
}
fn placement_matrix(&mut self, item: u64, u: Units) -> R<mt::Matrix4> {
let cx = Ctx { ex: self.ex, len: u.len, ang: u.ang, tol: u.tol };
let e = geom::entity(&cx, item)?;
if e.name() == "CARTESIAN_TRANSFORMATION_OPERATOR_3D" {
let p = e.params();
let o = geom::point(&cx, p.get(4).and_then(Param::as_ref_id).ok_or("transformation without origin")?)?;
return Ok(mt::Matrix4::from_translation(o.to_homogeneous().truncate()));
}
Ok(geom::frame(&cx, item)?.matrix())
}
/// Representations of a product (its own shape representations and those linked to them).
fn rep_closure(&self, roots: &[u64]) -> Vec<u64> {
let mut seen: Vec<u64> = Vec::new();
let mut stack: Vec<u64> = roots.to_vec();
while let Some(r) = stack.pop() {
if seen.contains(&r) || seen.len() > 1024 {
continue;
}
seen.push(r);
if let Some(l) = self.rep_links.get(&r) {
stack.extend(l.iter().copied());
}
}
seen
}
fn item_solid(&mut self, item: u64, u: Units) -> Option<(mt::Solid, bool, usize)> {
if let Some(r) = self.solids.get(&item) {
return r.as_ref().ok().cloned();
}
let cx = Ctx { ex: self.ex, len: u.len, ang: u.ang, tol: u.tol };
let r = match guard("step brep", || brep::read_item(&cx, item).map_err(KernelError::Failed)) {
Ok(out) => {
let label = self.label(item);
for f in &out.failed {
self.warn(format!("{label}: {f}"));
}
if !out.closed {
self.warn(format!("{label}: imported as an open body (the shell is not closed)"));
}
Ok((out.solid, out.closed, out.file_faces))
}
Err(e) => {
let label = self.label(item);
let msg = e.to_string().trim_start_matches("the operation failed: ").to_string();
self.warn(format!("{label}: not imported: {msg}"));
Err(msg)
}
};
self.solids.insert(item, r.clone());
r.ok()
}
fn label(&self, item: u64) -> String {
let name = self.ex.get(item).and_then(|e| e.params().first()).and_then(Param::as_str).unwrap_or("").trim().to_string();
if name.is_empty() { format!("solid #{item}") } else { format!("solid '{name}'") }
}
/// Bodies of a representation's items (following mapped items), placed by `m`.
fn rep_bodies(&mut self, rep: u64, m: &mt::Matrix4, path: &[String], product: &str, out: &mut Vec<usize>, depth: usize) {
if depth > MAX_ASSEMBLY_DEPTH {
self.warn("mapped items nested too deeply");
return;
}
let u = self.units_of(rep);
let items = self.reps.get(&rep).map(|r| r.0.clone()).unwrap_or_default();
for item in items {
let Some(e) = self.ex.get(item) else { continue };
match e.name() {
"MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL" => {
self.used_items.insert(item);
if !self.node_items.insert(item) {
continue;
}
if self.bodies.len() >= MAX_BODIES {
self.warn(format!("more than {MAX_BODIES} bodies: the rest are left out"));
return;
}
let Some((solid, closed, file_faces)) = self.item_solid(item, u) else { continue };
let identity = *m == mt::Matrix4::from_scale(1.0);
let placed = if identity {
Ok(solid)
} else {
let mm = *m;
guard("step placement", || Ok(mt::builder::transformed(&solid, mm)))
};
let body = placed.and_then(Body::new);
match body {
Ok(body) => {
let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string();
let name = if own.is_empty() { product.to_string() } else { own };
out.push(self.bodies.len());
self.bodies.push(ImportedBody {
name,
body,
color: self.colors.get(&item).copied(),
path: path.to_vec(),
closed,
file_faces,
});
}
Err(err) => {
let label = self.label(item);
self.warn(format!("{label}: not imported: {err}"));
}
}
}
"MAPPED_ITEM" => {
let p = e.params();
let (Some(map), Some(target)) = (p.get(1).and_then(Param::as_ref_id), p.get(2).and_then(Param::as_ref_id)) else { continue };
let Some(me) = self.ex.get(map) else { continue };
let (Some(origin), Some(mapped)) =
(me.params().first().and_then(Param::as_ref_id), me.params().get(1).and_then(Param::as_ref_id))
else {
continue;
};
let mu = self.units_of(mapped);
match (self.placement_matrix(target, u), self.placement_matrix(origin, mu)) {
(Ok(t), Ok(o)) => {
let mm = mat_mul(m, &mat_mul(&t, &rigid_inverse(&o)));
for r in self.rep_closure(&[mapped]) {
self.rep_bodies(r, &mm, path, product, out, depth + 1);
}
}
(Err(e), _) | (_, Err(e)) => self.warn(format!("mapped item #{item}: {e}")),
}
}
_ => {}
}
}
}
fn product_name(&self, pd: u64) -> String {
let name = (|| {
let pdf = self.ex.get(self.ex.get(pd)?.params().get(2)?.as_ref_id()?)?;
let prod = self.ex.get(pdf.params().get(2)?.as_ref_id()?)?;
let p = prod.params();
let n = p.get(1).and_then(Param::as_str).filter(|s| !s.trim().is_empty()).or_else(|| p.first().and_then(Param::as_str))?;
Some(n.trim().to_string())
})();
name.filter(|n| !n.is_empty()).unwrap_or_else(|| "Import".into())
}
/// Placement of a child occurrence in its parent.
fn occurrence_matrix(&mut self, nauo: u64, child_reps: &[u64]) -> mt::Matrix4 {
let Some(&rel) = self.placements.get(&nauo) else { return mt::Matrix4::from_scale(1.0) };
let r = (|| -> R<mt::Matrix4> {
let e = self.ex.get(rel).ok_or("missing placement")?;
let rr = e.record("REPRESENTATION_RELATIONSHIP").ok_or("placement without representations")?;
let (r1, r2) =
(rr.get(2).and_then(Param::as_ref_id).ok_or("bad placement")?, rr.get(3).and_then(Param::as_ref_id).ok_or("bad placement")?);
let op = e
.record("REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION")
.and_then(|x| x.first())
.and_then(Param::as_ref_id)
.ok_or("placement without transformation")?;
let ope = self.ex.get(op).ok_or("missing transformation")?;
let (u1, u2) = (self.units_of(r1), self.units_of(r2));
if ope.name() == "ITEM_DEFINED_TRANSFORMATION" {
let p = ope.params();
let (i1, i2) = (
p.get(2).and_then(Param::as_ref_id).ok_or("bad transformation")?,
p.get(3).and_then(Param::as_ref_id).ok_or("bad transformation")?,
);
let m1 = self.placement_matrix(i1, u1)?;
let m2 = self.placement_matrix(i2, u2)?;
// rep_1 is normally the component: map its item onto the assembly's item.
let child_is_1 = child_reps.contains(&r1) || !child_reps.contains(&r2);
Ok(if child_is_1 { mat_mul(&m2, &rigid_inverse(&m1)) } else { mat_mul(&m1, &rigid_inverse(&m2)) })
} else {
self.placement_matrix(op, u2)
}
})();
match r {
Ok(m) => m,
Err(e) => {
self.warn(format!("component placement #{rel}: {e} (placed at the origin)"));
mt::Matrix4::from_scale(1.0)
}
}
}
fn node(&mut self, pd: u64, local: mt::Matrix4, world: mt::Matrix4, path: &[String], depth: usize, stack: &mut Vec<u64>) -> StepNode {
let name = self.product_name(pd);
let mut node = StepNode { name: name.clone(), transform: to_array(&local), bodies: Vec::new(), children: Vec::new() };
if depth > MAX_ASSEMBLY_DEPTH || stack.contains(&pd) {
self.warn("assembly nested too deeply or recursive: some components were left out");
return node;
}
stack.push(pd);
let mut path = path.to_vec();
path.push(name.clone());
let own = self.pd_reps.get(&pd).cloned().unwrap_or_default();
let reps = self.rep_closure(&own);
let mut bodies = Vec::new();
self.node_items.clear();
for r in &reps {
self.rep_bodies(*r, &world, &path, &name, &mut bodies, 0);
}
node.bodies = bodies;
for nauo in self.children.get(&pd).cloned().unwrap_or_default() {
let Some(child) = self.ex.get(nauo).and_then(|e| e.params().get(4)).and_then(Param::as_ref_id) else { continue };
let child_own = self.pd_reps.get(&child).cloned().unwrap_or_default();
let child_reps = self.rep_closure(&child_own);
let m = self.occurrence_matrix(nauo, &child_reps);
let w = mat_mul(&world, &m);
let c = self.node(child, m, w, &path, depth + 1, stack);
node.children.push(c);
}
stack.pop();
node
}
}
/// Read a STEP file: its solids (as bodies, placed by the assembly structure) and the tree.
pub fn step_import(text: &str) -> Result<StepImport> {
let ex = p21::parse(text).map_err(KernelError::Invalid)?;
let mut rd = Reader {
ex: &ex,
units: HashMap::new(),
warnings: BTreeMap::new(),
reps: HashMap::new(),
rep_links: HashMap::new(),
pd_reps: HashMap::new(),
children: HashMap::new(),
placements: HashMap::new(),
colors: HashMap::new(),
solids: HashMap::new(),
used_items: HashSet::new(),
node_items: HashSet::new(),
bodies: Vec::new(),
};
rd.index();
// Roots: product definitions that are nobody's component.
let child_pds: HashSet<u64> =
rd.children.values().flatten().filter_map(|n| ex.get(*n).and_then(|e| e.params().get(4)).and_then(Param::as_ref_id)).collect();
let mut roots: Vec<u64> =
ex.entities.iter().filter(|(id, e)| e.name() == "PRODUCT_DEFINITION" && !child_pds.contains(id)).map(|(id, _)| *id).collect();
roots.sort();
let identity = mt::Matrix4::from_scale(1.0);
let mut tree = Vec::new();
for pd in roots {
let n = rd.node(pd, identity, identity, &[], 0, &mut Vec::new());
if !n.bodies.is_empty() || !n.children.is_empty() {
tree.push(n);
}
}
// Solids outside any product structure (bare geometry files).
let mut loose: Vec<u64> = rd
.reps
.iter()
.flat_map(|(r, (items, _))| items.iter().map(move |i| (*r, *i)))
.filter(|(_, i)| !rd.used_items.contains(i))
.filter(|(_, i)| {
ex.get(*i).is_some_and(|e| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL"))
})
.map(|(r, _)| r)
.collect();
loose.sort();
loose.dedup();
if !loose.is_empty() {
let mut bodies = Vec::new();
rd.node_items = rd.used_items.clone();
for r in loose {
rd.rep_bodies(r, &identity, &[], "Import", &mut bodies, 0);
}
if !bodies.is_empty() {
tree.push(StepNode { name: "Import".into(), transform: to_array(&identity), bodies, children: Vec::new() });
}
}
if let Some(first) = ex.duplicates.first() {
rd.warn(format!("{} entity ids are defined twice (first #{first}); the first definitions were used", ex.duplicates.len()));
}
let warnings: Vec<String> = rd.warnings.iter().map(|(m, n)| if *n > 1 { format!("{m} (×{n})") } else { m.clone() }).collect();
if rd.bodies.is_empty() {
let why = warnings.first().cloned().unwrap_or_else(|| "the file has no solids".into());
return Err(KernelError::Failed(format!("STEP import: no bodies ({why})")));
}
Ok(StepImport { bodies: rd.bodies, tree, warnings, schema: ex.schemas.join(", "), system: ex.originating_system.clone() })
}
#[cfg(test)]
mod tests;
+551
View File
@@ -0,0 +1,551 @@
//! ISO 10303-21 ("Part 21") exchange structure parser: the HEADER and the DATA section's entity
//! instances, simple (`#1=NAME(...)`) and complex (`#2=(A(...) B(...))`). Written from the
//! public standard. Input is hostile: sizes, entity counts and nesting depth are capped and every
//! error is a message, never a panic.
use std::collections::HashMap;
/// Largest STEP text we parse.
pub const MAX_BYTES: usize = 512 << 20;
/// Most entity instances in one file.
pub const MAX_ENTITIES: usize = 20_000_000;
/// Deepest parameter list nesting.
const MAX_DEPTH: usize = 64;
/// A parameter value.
#[derive(Clone, Debug, PartialEq)]
pub enum Param {
Int(i64),
Real(f64),
Str(String),
/// `.NAME.` (`.T.`/`.F.` included).
Enum(String),
Ref(u64),
List(Vec<Param>),
/// `NAME(params)` (a typed value such as `LENGTH_MEASURE(1.)`).
Typed(String, Vec<Param>),
Binary(String),
/// `$`
Null,
/// `*`
Derived,
}
impl Param {
pub fn as_f64(&self) -> Option<f64> {
match self {
Param::Real(x) => Some(*x).filter(|x| x.is_finite()),
Param::Int(i) => Some(*i as f64),
Param::Typed(_, v) if v.len() == 1 => v.first().and_then(Param::as_f64),
_ => None,
}
}
pub fn as_i64(&self) -> Option<i64> {
match self {
Param::Int(i) => Some(*i),
Param::Real(x) if x.fract() == 0.0 && x.abs() < 9e15 => Some(*x as i64),
_ => None,
}
}
pub fn as_ref_id(&self) -> Option<u64> {
match self {
Param::Ref(r) => Some(*r),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Param::Str(s) => Some(s),
_ => None,
}
}
pub fn as_enum(&self) -> Option<&str> {
match self {
Param::Enum(s) => Some(s),
_ => None,
}
}
/// `.T.` → true, `.F.` → false (`.U.` and anything else → None).
pub fn as_bool(&self) -> Option<bool> {
match self.as_enum()? {
"T" => Some(true),
"F" => Some(false),
_ => None,
}
}
pub fn as_list(&self) -> Option<&[Param]> {
match self {
Param::List(v) => Some(v),
_ => None,
}
}
}
/// One record of an entity instance: a simple instance has one, a complex one several.
#[derive(Clone, Debug, PartialEq)]
pub struct Record {
pub name: String,
pub params: Vec<Param>,
}
/// An entity instance.
#[derive(Clone, Debug, PartialEq)]
pub struct Entity {
pub records: Vec<Record>,
}
impl Entity {
pub fn is_complex(&self) -> bool {
self.records.len() > 1
}
/// Name of a simple instance (the first record of a complex one).
pub fn name(&self) -> &str {
self.records.first().map(|r| r.name.as_str()).unwrap_or("")
}
/// Parameters of the record called `name`.
pub fn record(&self, name: &str) -> Option<&[Param]> {
self.records.iter().find(|r| r.name == name).map(|r| r.params.as_slice())
}
pub fn has(&self, name: &str) -> bool {
self.records.iter().any(|r| r.name == name)
}
/// Parameters of a simple instance.
pub fn params(&self) -> &[Param] {
self.records.first().map(|r| r.params.as_slice()).unwrap_or(&[])
}
}
/// A parsed exchange file.
#[derive(Clone, Debug, Default)]
pub struct Exchange {
/// FILE_SCHEMA names.
pub schemas: Vec<String>,
/// FILE_NAME originating system.
pub originating_system: String,
pub entities: HashMap<u64, Entity>,
/// Ids defined more than once (the first definition is kept).
pub duplicates: Vec<u64>,
}
impl Exchange {
pub fn get(&self, id: u64) -> Option<&Entity> {
self.entities.get(&id)
}
}
struct Lexer<'a> {
s: &'a [u8],
i: usize,
}
fn err<T>(msg: impl Into<String>) -> Result<T, String> {
Err(msg.into())
}
impl<'a> Lexer<'a> {
fn line(&self) -> usize {
self.s.get(..self.i).map(|b| b.iter().filter(|c| **c == b'\n').count() + 1).unwrap_or(0)
}
fn fail<T>(&self, msg: &str) -> Result<T, String> {
err(format!("STEP syntax error at line {}: {msg}", self.line()))
}
fn peek(&self) -> Option<u8> {
self.s.get(self.i).copied()
}
/// Skip whitespace and comments.
fn ws(&mut self) -> Result<(), String> {
loop {
match self.peek() {
Some(c) if c.is_ascii_whitespace() => self.i += 1,
Some(b'/') if self.s.get(self.i + 1) == Some(&b'*') => {
let rest = self.s.get(self.i + 2..).unwrap_or(&[]);
match rest.windows(2).position(|w| w == b"*/") {
Some(p) => self.i += p + 4,
None => return self.fail("unterminated comment"),
}
}
_ => return Ok(()),
}
}
}
fn eat(&mut self, c: u8) -> Result<bool, String> {
self.ws()?;
if self.peek() == Some(c) {
self.i += 1;
Ok(true)
} else {
Ok(false)
}
}
fn expect(&mut self, c: u8) -> Result<(), String> {
if self.eat(c)? { Ok(()) } else { self.fail(&format!("expected `{}`", c as char)) }
}
fn keyword(&mut self) -> Result<String, String> {
self.ws()?;
let st = self.i;
if self.peek() == Some(b'!') {
self.i += 1;
}
while let Some(c) = self.peek() {
if c.is_ascii_alphanumeric() || c == b'_' || c == b'-' {
self.i += 1;
} else {
break;
}
}
if self.i == st {
return self.fail("expected a keyword");
}
Ok(String::from_utf8_lossy(self.s.get(st..self.i).unwrap_or(&[])).to_ascii_uppercase())
}
fn number(&mut self) -> Result<Param, String> {
let st = self.i;
while let Some(c) = self.peek() {
if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.' | b'E' | b'e') {
self.i += 1;
} else {
break;
}
}
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
if !t.contains(['.', 'E', 'e'])
&& let Ok(i) = t.parse::<i64>()
{
return Ok(Param::Int(i));
}
// STEP allows `1.` and `1.E-3`.
let fixed = t.replace(".E", ".0E").replace(".e", ".0e");
let fixed = if fixed.ends_with('.') { format!("{fixed}0") } else { fixed };
match fixed.parse::<f64>() {
Ok(x) => Ok(Param::Real(x)),
Err(_) => self.fail(&format!("bad number `{t}`")),
}
}
fn string(&mut self) -> Result<String, String> {
// Opening quote consumed by the caller.
let mut raw = Vec::new();
loop {
match self.peek() {
None => return self.fail("unterminated string"),
Some(b'\'') => {
if self.s.get(self.i + 1) == Some(&b'\'') {
raw.push(b'\'');
self.i += 2;
} else {
self.i += 1;
break;
}
}
Some(b'\n') | Some(b'\r') => self.i += 1,
Some(c) => {
raw.push(c);
self.i += 1;
}
}
}
Ok(decode_string(&raw))
}
fn param(&mut self, depth: usize) -> Result<Param, String> {
if depth > MAX_DEPTH {
return self.fail("parameters nested too deeply");
}
self.ws()?;
let Some(c) = self.peek() else { return self.fail("unexpected end of file") };
match c {
b'$' => {
self.i += 1;
Ok(Param::Null)
}
b'*' => {
self.i += 1;
Ok(Param::Derived)
}
b'\'' => {
self.i += 1;
Ok(Param::Str(self.string()?))
}
b'"' => {
self.i += 1;
let st = self.i;
while self.peek().is_some_and(|c| c != b'"') {
self.i += 1;
}
let v = String::from_utf8_lossy(self.s.get(st..self.i).unwrap_or(&[])).to_string();
self.expect(b'"')?;
Ok(Param::Binary(v))
}
b'#' => {
self.i += 1;
let st = self.i;
while self.peek().is_some_and(|c| c.is_ascii_digit()) {
self.i += 1;
}
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
t.parse::<u64>().map(Param::Ref).or_else(|_| self.fail("bad entity reference"))
}
b'.' if self.s.get(self.i + 1).is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') => {
self.i += 1;
let st = self.i;
while self.peek().is_some_and(|c| c != b'.') {
self.i += 1;
}
let v = String::from_utf8_lossy(self.s.get(st..self.i).unwrap_or(&[])).trim().to_ascii_uppercase();
self.expect(b'.')?;
Ok(Param::Enum(v))
}
b'(' => {
self.i += 1;
Ok(Param::List(self.list_rest(depth + 1)?))
}
c if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.') => self.number(),
c if c.is_ascii_alphabetic() || c == b'!' => {
let name = self.keyword()?;
self.expect(b'(')?;
Ok(Param::Typed(name, self.list_rest(depth + 1)?))
}
_ => self.fail(&format!("unexpected `{}`", c as char)),
}
}
/// Parameters after an opening `(` up to and including the `)`.
fn list_rest(&mut self, depth: usize) -> Result<Vec<Param>, String> {
let mut v = Vec::new();
if self.eat(b')')? {
return Ok(v);
}
loop {
v.push(self.param(depth)?);
if self.eat(b',')? {
continue;
}
self.expect(b')')?;
return Ok(v);
}
}
fn record(&mut self) -> Result<Record, String> {
let name = self.keyword()?;
self.expect(b'(')?;
Ok(Record { name, params: self.list_rest(1)? })
}
}
/// Decode the Part 21 string escapes (`\X\hh`, `\X2\…\X0\`, `\X4\…\X0\`, `\S\c`, `\\`).
fn decode_string(raw: &[u8]) -> String {
let mut out = String::new();
let mut i = 0;
let hex = |b: &[u8]| std::str::from_utf8(b).ok().and_then(|t| u32::from_str_radix(t, 16).ok());
while let Some(&c) = raw.get(i) {
if c == b'\\' {
let rest = raw.get(i..).unwrap_or(&[]);
if rest.starts_with(b"\\\\") {
out.push('\\');
i += 2;
continue;
}
if rest.starts_with(b"\\X\\")
&& let Some(v) = rest.get(3..5).and_then(hex)
{
out.push(char::from_u32(v).unwrap_or('?'));
i += 5;
continue;
}
for (tag, width) in [(&b"\\X2\\"[..], 4usize), (&b"\\X4\\"[..], 8)] {
if rest.starts_with(tag)
&& let Some(end) = rest.windows(4).position(|w| w == b"\\X0\\")
{
let body = rest.get(4..end).unwrap_or(&[]);
let units: Vec<u32> = body.chunks(width).filter_map(hex).collect();
if width == 4 {
let u16s: Vec<u16> = units.iter().map(|u| *u as u16).collect();
out.push_str(&String::from_utf16_lossy(&u16s));
} else {
out.extend(units.iter().map(|u| char::from_u32(*u).unwrap_or('?')));
}
i += end + 4;
break;
}
}
if i < raw.len() && raw.get(i) == Some(&b'\\') {
if rest.starts_with(b"\\S\\")
&& let Some(&ch) = rest.get(3)
{
out.push(char::from_u32(ch as u32 + 128).unwrap_or('?'));
i += 4;
continue;
}
out.push('\\');
i += 1;
}
continue;
}
// Plain bytes: keep UTF-8 runs intact.
let st = i;
while raw.get(i).is_some_and(|c| *c != b'\\') {
i += 1;
}
out.push_str(&String::from_utf8_lossy(raw.get(st..i).unwrap_or(&[])));
}
out
}
/// Parse a Part 21 file.
pub fn parse(text: &str) -> Result<Exchange, String> {
if text.len() > MAX_BYTES {
return err(format!("STEP file too large ({} MB, limit {} MB)", text.len() >> 20, MAX_BYTES >> 20));
}
let mut lx = Lexer { s: text.as_bytes(), i: 0 };
let magic = lx.keyword()?;
if magic != "ISO-10303-21" {
return err("not a STEP file (no ISO-10303-21 header)");
}
lx.expect(b';')?;
let mut ex = Exchange::default();
loop {
lx.ws()?;
if lx.peek().is_none() {
return err("STEP file ends without END-ISO-10303-21");
}
let kw = lx.keyword()?;
match kw.as_str() {
"HEADER" => {
lx.expect(b';')?;
loop {
let k = lx.keyword()?;
if k == "ENDSEC" {
lx.expect(b';')?;
break;
}
lx.expect(b'(')?;
let params = lx.list_rest(1)?;
lx.expect(b';')?;
match k.as_str() {
"FILE_SCHEMA" => {
if let Some(Param::List(v)) = params.first() {
ex.schemas = v.iter().filter_map(|p| p.as_str().map(str::to_string)).collect();
}
}
"FILE_NAME" => {
ex.originating_system = params.get(5).and_then(Param::as_str).unwrap_or("").to_string();
}
_ => {}
}
}
}
"DATA" => {
// Optional `DATA('name', ('schema'));`
if lx.eat(b'(')? {
lx.list_rest(1)?;
}
lx.expect(b';')?;
loop {
lx.ws()?;
if lx.peek() != Some(b'#') {
let k = lx.keyword()?;
if k == "ENDSEC" {
lx.expect(b';')?;
break;
}
return lx.fail(&format!("unexpected `{k}` in DATA"));
}
lx.i += 1;
let st = lx.i;
while lx.peek().is_some_and(|c| c.is_ascii_digit()) {
lx.i += 1;
}
let id: u64 = match std::str::from_utf8(lx.s.get(st..lx.i).unwrap_or(&[])).ok().and_then(|t| t.parse().ok()) {
Some(id) => id,
None => return lx.fail("bad entity id"),
};
lx.expect(b'=')?;
let records = if lx.eat(b'(')? {
let mut rs = Vec::new();
while !lx.eat(b')')? {
if rs.len() > 64 {
return lx.fail("complex entity with too many parts");
}
rs.push(lx.record()?);
}
rs
} else {
vec![lx.record()?]
};
lx.expect(b';')?;
if ex.entities.len() >= MAX_ENTITIES {
return err(format!("STEP file has more than {MAX_ENTITIES} entities"));
}
// A duplicate id is invalid; some writers emit them for geometry nobody
// reads. Keep the first definition and report it.
match ex.entities.entry(id) {
std::collections::hash_map::Entry::Occupied(_) => ex.duplicates.push(id),
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(Entity { records });
}
}
}
}
"END-ISO-10303-21" => {
lx.expect(b';')?;
return Ok(ex);
}
// ANCHOR / REFERENCE / SIGNATURE sections (Part 21 edition 3): skip to ENDSEC.
"ANCHOR" | "REFERENCE" | "SIGNATURE" => {
let rest = lx.s.get(lx.i..).unwrap_or(&[]);
match rest.windows(7).position(|w| w == b"ENDSEC;") {
Some(p) => lx.i += p + 7,
None => return lx.fail("unterminated section"),
}
}
_ => return lx.fail(&format!("unexpected `{kw}`")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_and_complex() {
let t = "ISO-10303-21;\nHEADER;FILE_DESCRIPTION((''),'2;1');\nFILE_NAME('a','',(''),(''),'','sys','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n#1=CARTESIAN_POINT('p',(0.,1.5E1,-2));\n/* c */ #2=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\n#3=X('it''s','\\X2\\00E9\\X0\\',$,#1,LENGTH_MEASURE(1.E-3),\"0F\");\nENDSEC;\nEND-ISO-10303-21;\n";
let ex = parse(t).unwrap();
assert_eq!(ex.schemas, vec!["AUTOMOTIVE_DESIGN"]);
assert_eq!(ex.originating_system, "sys");
let p = ex.get(1).unwrap();
assert_eq!(p.name(), "CARTESIAN_POINT");
let xyz: Vec<f64> = p.params()[1].as_list().unwrap().iter().filter_map(Param::as_f64).collect();
assert_eq!(xyz, vec![0.0, 15.0, -2.0]);
let u = ex.get(2).unwrap();
assert!(u.is_complex() && u.has("SI_UNIT"));
assert_eq!(u.record("SI_UNIT").unwrap()[0].as_enum(), Some("MILLI"));
let x = ex.get(3).unwrap().params();
assert_eq!(x[0].as_str(), Some("it's"));
assert_eq!(x[1].as_str(), Some("\u{e9}"));
assert_eq!(x[2], Param::Null);
assert_eq!(x[3].as_ref_id(), Some(1));
assert_eq!(x[4].as_f64(), Some(1e-3));
}
#[test]
fn hostile_text_is_an_error() {
for t in [
"",
"garbage",
"ISO-10303-21;",
"ISO-10303-21;DATA;#1=A(",
"ISO-10303-21;DATA;#1=A('x",
"ISO-10303-21;DATA;#1=A(/*",
"ISO-10303-21;DATA;#=A();ENDSEC;END-ISO-10303-21;",
"ISO-10303-21;DATA;#99999999999999999999999=A();ENDSEC;END-ISO-10303-21;",
"ISO-10303-21;DATA;#1=A(1e999999.);ENDSEC;END-ISO-10303-21;",
] {
let _ = parse(t);
}
let deep = format!("ISO-10303-21;DATA;#1=A({}{});ENDSEC;END-ISO-10303-21;", "(".repeat(10_000), ")".repeat(10_000));
assert!(parse(&deep).is_err());
let dup = parse("ISO-10303-21;DATA;#1=A(1);#1=B();ENDSEC;END-ISO-10303-21;").unwrap();
assert_eq!(dup.duplicates, vec![1]);
assert_eq!(dup.get(1).unwrap().name(), "A");
assert_eq!(decode_string(b"\\X\\E9"), "\u{e9}");
assert_eq!(decode_string(b"a\\X2\\00E900E8\\X0\\b"), "a\u{e9}\u{e8}b");
assert_eq!(decode_string(b"\\"), "\\");
}
}
+409
View File
@@ -0,0 +1,409 @@
//! Splitting band faces that wrap all the way around a closed, non-periodic parameter direction.
//!
//! A STEP face may run once around a closed surface and close its loop with a seam edge used
//! twice (a pipe segment of a torus, a revolved B-spline band). Truck meshes faces in parameter
//! space and only treats the rotation of a revolved surface as periodic, so such a face is cut
//! in two along the parameter line opposite its seam; the ring edges it crosses are cut in every
//! face that uses them.
use std::collections::HashMap;
use mt::{BoundedCurve, BoundedSurface, InnerSpace, Invertible, MetricSpace, ParametricCurve, ParametricSurface, SearchNearestParameter};
use truck_modeling as mt;
use super::geom::{Frame, R, conic_arc};
/// Samples per edge when looking for the split crossing.
const SAMPLES: usize = 48;
/// Which surface parameter wraps around.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Dir {
U,
V,
}
fn uv_of(s: &mt::Surface, p: mt::Point3) -> Option<(f64, f64)> {
s.search_nearest_parameter(p, None, 100).filter(|(u, v)| u.is_finite() && v.is_finite())
}
fn coord(d: Dir, uv: (f64, f64)) -> f64 {
match d {
Dir::U => uv.0,
Dir::V => uv.1,
}
}
/// The closed, non-periodic direction of a surface with its range.
fn closed_dirs(s: &mt::Surface) -> Vec<(Dir, f64, f64)> {
match s {
mt::Surface::RevolutedCurve(p) => {
let c = p.entity().entity_curve();
let (t0, t1) = c.range_tuple();
if c.front().distance(c.back()) < 1e-7 * (1.0 + c.front().to_homogeneous().truncate().magnitude()) {
vec![(Dir::U, t0, t1)]
} else {
vec![]
}
}
mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_) => {
let ((u0, u1), (v0, v1)) = match s {
mt::Surface::BSplineSurface(b) => b.range_tuple(),
mt::Surface::NurbsSurface(n) => n.range_tuple(),
_ => return vec![],
};
let size = (0..=4)
.flat_map(|i| (0..=4).map(move |j| (i, j)))
.map(|(i, j)| s.subs(u0 + (u1 - u0) * i as f64 / 4.0, v0 + (v1 - v0) * j as f64 / 4.0).to_homogeneous().truncate().magnitude())
.fold(1.0, f64::max);
let tol = size * 1e-7;
let mut out = Vec::new();
if (0..=8).all(|k| {
let v = v0 + (v1 - v0) * k as f64 / 8.0;
s.subs(u0, v).distance(s.subs(u1, v)) < tol
}) {
out.push((Dir::U, u0, u1));
}
if (0..=8).all(|k| {
let u = u0 + (u1 - u0) * k as f64 / 8.0;
s.subs(u, v0).distance(s.subs(u, v1)) < tol
}) {
out.push((Dir::V, v0, v1));
}
out
}
mt::Surface::Plane(_) => vec![],
}
}
fn samples(c: &mt::Curve, n: usize) -> Vec<(f64, mt::Point3)> {
let (t0, t1) = c.range_tuple();
(0..=n).map(|i| t0 + (t1 - t0) * i as f64 / n as f64).map(|t| (t, c.subs(t))).collect()
}
/// Where a ring chain crosses `w = mid`: (edge index in the chain, parameter on the edge's
/// absolute curve, point).
fn crossing(s: &mt::Surface, d: Dir, chain: &[mt::Edge], mid: f64, period: f64) -> R<(usize, f64, mt::Point3)> {
for (k, e) in chain.iter().enumerate() {
let c = e.curve();
let pts = samples(&c, SAMPLES);
let ws: Vec<Option<f64>> = pts.iter().map(|(_, p)| uv_of(s, *p).map(|uv| coord(d, uv))).collect();
for i in 0..SAMPLES {
let (Some(Some(wa)), Some(Some(wb)), Some((ta, _)), Some((tb, _))) = (ws.get(i), ws.get(i + 1), pts.get(i), pts.get(i + 1)) else {
continue;
};
if (wb - wa).abs() > period * 0.5 || (wa - mid) * (wb - mid) > 0.0 {
continue;
}
// Bisection on the curve parameter.
let (mut lo, mut hi, mut flo) = (*ta, *tb, wa - mid);
for _ in 0..60 {
let m = 0.5 * (lo + hi);
let Some(w) = uv_of(s, c.subs(m)).map(|uv| coord(d, uv)) else { break };
let f = w - mid;
if (f - flo).abs() > period * 0.5 {
break;
}
if f * flo <= 0.0 {
hi = m;
} else {
lo = m;
flo = f;
}
}
let t = 0.5 * (lo + hi);
return Ok((k, t, c.subs(t)));
}
}
Err("no crossing with the split line".into())
}
/// Iso-parameter edge curve `w = mid` from `q1` to `q2`.
fn iso_curve(s: &mt::Surface, d: Dir, mid: f64, q1: mt::Point3, q2: mt::Point3, seam: &[mt::Point3]) -> R<mt::Curve> {
match s {
mt::Surface::RevolutedCurve(p) => {
// A circle around the axis, turning opposite to the seam (which runs from the q2
// side to the q1 side).
let (o, a) = (p.entity().origin(), p.entity().axis());
let az = |x: mt::Point3, f: &Frame| {
let v = x - f.o;
v.dot(f.y).atan2(v.dot(f.x))
};
let c = o + a * (q1 - o).dot(a);
let r = (q1 - c).magnitude();
if !(r > 1e-9) {
return Err("split line on the axis".into());
}
let x = (q1 - c) / r;
let f = Frame { o: c, x, y: a.cross(x), z: a };
let mut seam_turn = 0.0;
for w in seam.windows(2) {
if let (Some(p0), Some(p1)) = (w.first(), w.get(1)) {
let d = az(*p1, &f) - az(*p0, &f);
seam_turn += (d + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
}
}
let sweep = -seam_turn;
if sweep.abs() < 1e-9 {
return Err("degenerate split line".into());
}
let f = if sweep > 0.0 { f } else { Frame { o: c, x, y: -a.cross(x), z: -a } };
let arc = conic_arc(&f, r, r, 0.0, sweep.abs());
if arc.back().distance(q2) > 1e-6 * (1.0 + r) {
return Err("split line does not reach the other ring".into());
}
Ok(mt::Curve::NurbsCurve(arc))
}
mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_) => {
let hom: mt::BSplineSurface<mt::Vector4> = match s {
mt::Surface::BSplineSurface(b) => mt::BSplineSurface::new(
b.knot_vecs().clone(),
b.control_points().iter().map(|row| row.iter().map(|p| p.to_homogeneous()).collect()).collect(),
),
mt::Surface::NurbsSurface(n) => n.non_rationalized().clone(),
_ => return Err("unsupported surface".into()),
};
let iso = mt::Curve::NurbsCurve(iso_bsp(&hom, mid, d == Dir::V)?);
let near = |p: mt::Point3| {
let (t0, t1) = iso.range_tuple();
let mut best = (t0, f64::INFINITY);
for i in 0..=256 {
let t = t0 + (t1 - t0) * i as f64 / 256.0;
let dd = iso.subs(t).distance(p);
if dd < best.1 {
best = (t, dd);
}
}
if let Some(t) = iso.search_nearest_parameter(p, best.0, 64) {
let t = t.clamp(t0, t1);
if iso.subs(t).distance(p) <= best.1 {
best = (t, iso.subs(t).distance(p));
}
}
best
};
let ((ta, da), (tb, db)) = (near(q1), near(q2));
let scale = 1.0 + q1.to_homogeneous().truncate().magnitude();
if da > 1e-5 * scale || db > 1e-5 * scale || (ta - tb).abs() < 1e-12 {
return Err("split line does not reach the rings".into());
}
let (lo, hi) = if ta < tb { (ta, tb) } else { (tb, ta) };
let (t0, t1) = iso.range_tuple();
let mut c = iso;
let eps = (t1 - t0) * 1e-9;
if lo > t0 + eps {
c = mt::Cut::cut(&mut c, lo);
}
if hi < t1 - eps {
let _ = mt::Cut::cut(&mut c, hi);
}
if ta > tb {
c.invert();
}
Ok(c)
}
mt::Surface::Plane(_) => Err("planes do not wrap".into()),
}
}
/// The iso-curve of a homogeneous B-spline surface at `v = w` (`along_u`) or `u = w`.
fn iso_bsp(b: &mt::BSplineSurface<mt::Vector4>, w: f64, along_u: bool) -> R<mt::NurbsCurve<mt::Vector4>> {
let (ku, kv) = b.knot_vecs();
let ctrl = b.control_points();
let nu = ctrl.len();
let nv = ctrl.first().map(Vec::len).unwrap_or(0);
let zero = mt::Vector4::new(0.0, 0.0, 0.0, 0.0);
let pts: Vec<mt::Vector4> = if along_u {
let deg = kv.len().checked_sub(nv + 1).ok_or("bad knot vector")?;
let basis = kv.try_bspline_basis_functions(deg, w).map_err(|e| e.to_string())?;
ctrl.iter().map(|row| row.iter().zip(&basis).fold(zero, |acc, (p, f)| acc + *p * *f)).collect()
} else {
let deg = ku.len().checked_sub(nu + 1).ok_or("bad knot vector")?;
let basis = ku.try_bspline_basis_functions(deg, w).map_err(|e| e.to_string())?;
(0..nv).map(|j| ctrl.iter().zip(&basis).fold(zero, |acc, (row, f)| acc + row.get(j).copied().unwrap_or(zero) * *f)).collect()
};
let knots = if along_u { ku.clone() } else { kv.clone() };
mt::BSplineCurve::try_new(knots, pts).map(mt::NurbsCurve::new).map_err(|e| e.to_string())
}
type Cut = (mt::Edge, (mt::Edge, mt::Edge));
/// Split a chain at parameter `t` of its edge `k` (absolute curve): the edges before and after
/// the split point, the edge cut (unless the point is an existing vertex) and the split vertex.
fn split_chain(chain: &[mt::Edge], k: usize, t: f64, q: mt::Point3) -> R<(Vec<mt::Edge>, Vec<mt::Edge>, Option<Cut>, mt::Vertex)> {
let e = chain.get(k).ok_or("bad crossing")?;
let (t0, t1) = e.curve().range_tuple();
let eps = (t1 - t0).abs() * 1e-7;
let at_front = (t - t0).abs() <= eps;
let at_back = (t - t1).abs() <= eps;
if at_front || at_back {
// The split point is a vertex already: split the chain between edges.
let v = if at_front { e.absolute_front().clone() } else { e.absolute_back().clone() };
let first_end = at_front != e.orientation(); // the point is this edge's end in loop order
let cut_at = if first_end { k + 1 } else { k };
let before = chain.iter().take(cut_at).cloned().collect();
let after = chain.iter().skip(cut_at).cloned().collect();
return Ok((before, after, None, v));
}
let v = mt::Vertex::new(q);
let (a, b) = e.cut_with_parameter(&v, t).ok_or("cannot cut a ring edge at the split line")?;
let mut before: Vec<mt::Edge> = chain.iter().take(k).cloned().collect();
before.push(a.clone());
let mut after = vec![b.clone()];
after.extend(chain.iter().skip(k + 1).cloned());
Ok((before, after, Some((e.clone(), (a, b))), v))
}
struct Plan {
/// Edge cuts: (edge as used in the band's loop, its two pieces in that loop's order).
cuts: Vec<Cut>,
faces: (mt::Face, mt::Face),
}
fn plan(face: &mt::Face) -> R<Option<Plan>> {
let s = face.surface();
let wires = face.absolute_boundaries().clone();
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
for e in wires.iter().flat_map(|w| w.edge_iter()) {
*uses.entry(e.id()).or_default() += 1;
}
let is_seam = |e: &mt::Edge| uses.get(&e.id()).copied().unwrap_or(0) > 1;
if !wires.iter().flat_map(|w| w.edge_iter()).any(is_seam) {
return Ok(None);
}
// The closed direction whose boundary carries the seam.
let seam_pts: Vec<mt::Point3> =
wires.iter().flat_map(|w| w.edge_iter()).filter(|e| is_seam(e)).flat_map(|e| samples(&e.curve(), 4).into_iter().map(|x| x.1)).collect();
let Some((d, w0, w1)) = closed_dirs(&s).into_iter().find(|(d, w0, w1)| {
let p = w1 - w0;
seam_pts.iter().all(|q| uv_of(&s, *q).map(|uv| coord(*d, uv)).is_some_and(|w| (w - w0).abs().min((w - w1).abs()) < p * 1e-4))
}) else {
return Ok(None);
};
let (period, mid) = (w1 - w0, 0.5 * (w0 + w1));
let outer_idx = wires.iter().position(|w| w.edge_iter().any(is_seam)).ok_or("no seam loop")?;
if wires.iter().enumerate().any(|(i, w)| i != outer_idx && w.edge_iter().any(is_seam)) {
return Err("seam edges in several loops".into());
}
let outer: Vec<mt::Edge> = wires.get(outer_idx).ok_or("no seam loop")?.edge_iter().cloned().collect();
let n = outer.len();
let start = (0..n)
.find(|&i| outer.get(i).is_some_and(is_seam) && outer.get((i + n - 1) % n).is_some_and(|e| !is_seam(e)))
.ok_or("loop made only of seam edges")?;
let rot: Vec<mt::Edge> = (0..n).filter_map(|i| outer.get((start + i) % n).cloned()).collect();
let mut chains: Vec<(bool, Vec<mt::Edge>)> = Vec::new();
for e in rot {
let seam = is_seam(&e);
match chains.last_mut() {
Some((s0, c)) if *s0 == seam => c.push(e),
_ => chains.push((seam, vec![e])),
}
}
let [(true, s1), (false, r1), (true, s2), (false, r2)] = chains.as_slice() else {
return Err(format!("unsupported seam loop layout ({} runs)", chains.len()));
};
let s2_ids: Vec<mt::EdgeID> = s2.iter().rev().map(mt::Edge::id).collect();
if s1.iter().map(mt::Edge::id).collect::<Vec<_>>() != s2_ids {
return Err("seam edges are not used back and forth".into());
}
let (k1, t1, q1) = crossing(&s, d, r1, mid, period)?;
let (k2, t2, q2) = crossing(&s, d, r2, mid, period)?;
let seam_line: Vec<mt::Point3> = s1.iter().flat_map(|e| samples(&e.oriented_curve(), 16).into_iter().map(|x| x.1)).collect();
let iso = iso_curve(&s, d, mid, q1, q2, &seam_line)?;
let (r1a, r1b, c1, v1) = split_chain(r1, k1, t1, q1)?;
let (r2a, r2b, c2, v2) = split_chain(r2, k2, t2, q2)?;
let e_split = mt::Edge::new_unchecked(&v1, &v2, iso);
// Face A: split, rest of ring 2, seam, start of ring 1. Face B: the other half.
let mut wa: Vec<mt::Edge> = vec![e_split.clone()];
wa.extend(r2b);
wa.extend(s1.iter().cloned());
wa.extend(r1a.iter().cloned());
let mut wb: Vec<mt::Edge> = vec![e_split.inverse()];
wb.extend(r1b);
wb.extend(s2.iter().cloned());
wb.extend(r2a);
let wire_a: mt::Wire = wa.into();
let wire_b: mt::Wire = wb.into();
if !wire_a.is_closed() || !wire_b.is_closed() {
return Err("split loops do not close".into());
}
// Holes go to the half that holds them.
let side_a = r1a
.iter()
.flat_map(|e| samples(&e.oriented_curve(), 4))
.filter_map(|(_, p)| uv_of(&s, p).map(|uv| coord(d, uv)))
.find(|w| (w - mid).abs() > period * 1e-3)
.map(|w| w < mid)
.ok_or("cannot place the split halves")?;
let (mut ha, mut hb) = (vec![wire_a], vec![wire_b]);
for (i, w) in wires.iter().enumerate() {
if i == outer_idx {
continue;
}
let ws: Vec<f64> = w.edge_iter().flat_map(|e| samples(&e.curve(), 4)).filter_map(|(_, p)| uv_of(&s, p).map(|uv| coord(d, uv))).collect();
if ws.iter().all(|x| (*x < mid) == side_a) {
ha.push(w.clone());
} else if ws.iter().all(|x| (*x < mid) != side_a) {
hb.push(w.clone());
} else {
return Err("a hole crosses the split line".into());
}
}
let mut fa = mt::Face::new_unchecked(ha, s.clone());
let mut fb = mt::Face::new_unchecked(hb, s);
if !face.orientation() {
fa.invert();
fb.invert();
}
Ok(Some(Plan { cuts: c1.into_iter().chain(c2).collect(), faces: (fa, fb) }))
}
/// Replace cut edges in a face's loops.
fn recut(face: &mt::Face, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))]) -> mt::Face {
let touches = face.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).any(|e| cuts.iter().any(|(c, _)| c.id() == e.id()));
if !touches {
return face.clone();
}
let wires: Vec<mt::Wire> = face
.absolute_boundaries()
.iter()
.map(|w| {
let mut out: Vec<mt::Edge> = Vec::new();
for e in w.edge_iter() {
match cuts.iter().find(|(c, _)| c.id() == e.id()) {
Some((c, (a, b))) if c.orientation() == e.orientation() => out.extend([a.clone(), b.clone()]),
Some((_, (a, b))) => out.extend([b.inverse(), a.inverse()]),
None => out.push(e.clone()),
}
}
out.into()
})
.collect();
let mut f = mt::Face::new_unchecked(wires, face.surface());
if !face.orientation() {
f.invert();
}
f
}
/// Split every band face of a shell (see the module docs). Faces that cannot be split stay as
/// they are and are reported.
pub(crate) fn split_bands(mut faces: Vec<mt::Face>, warnings: &mut Vec<String>) -> Vec<mt::Face> {
let n = faces.len();
for i in 0..n {
let Some(face) = faces.get(i).cloned() else { continue };
match crate::guard("step band split", || plan(&face).map_err(crate::KernelError::Failed)) {
Ok(Some(p)) => {
faces = faces.iter().map(|f| recut(f, &p.cuts)).collect();
if let Some(slot) = faces.get_mut(i) {
*slot = p.faces.0;
}
faces.push(p.faces.1);
}
Ok(None) => {}
Err(e) => warnings
.push(format!("face wrapping around a closed surface kept whole: {}", e.to_string().trim_start_matches("the operation failed: "))),
}
}
faces
}
+285
View File
@@ -0,0 +1,285 @@
use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use super::*;
use crate::{BoolOp, boolean, box_solid, cylinder, fillet, measure, revolve, sphere, step_export, torus};
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
/// Export → import → same volume, area and face count.
fn round_trip(b: &Body) -> StepImport {
let text = step_export(&[b], "test").unwrap();
let imp = step_import(&text).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
// truck's writer repeats entity ids for the surfaces of intersection curves (unused here).
assert!(imp.warnings.iter().all(|w| w.contains("defined twice")), "{:?}", 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());
assert!(imp.bodies[0].closed);
imp
}
#[test]
fn round_trip_primitives() {
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 round_trip_features() {
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);
let edge = Vec3::new(20.0, 0.0, 10.0);
round_trip(&fillet(&plate, &[edge], 3.0).unwrap());
let ring = Region2 { outer: Loop2::circle(Vec2::new(10.0, 0.0), 3.0), holes: vec![] };
let donut = revolve(&Plane::XZ, &[ring], Vec2::ZERO, Vec2::Y, 1.5).unwrap();
round_trip(&donut[0]);
}
#[test]
fn several_bodies_and_names() {
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).unwrap();
let text = step_export(&[&a, &b], "test").unwrap();
let imp = step_import(&text).unwrap();
assert_eq!(imp.bodies.len(), 2);
assert!(imp.bodies.iter().all(|b| !b.name.is_empty()));
let vol: f64 = imp.bodies.iter().map(|b| measure(&b.body).unwrap().volume).sum();
assert!(rel(vol, 2000.0) < 1e-6);
}
#[test]
fn imported_bodies_take_features() {
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let imp = step_import(&step_export(&[&plate], "t").unwrap()).unwrap();
let body = &imp.bodies[0].body;
// Fillet an edge, cut a hole through it.
let rounded = fillet(body, &[Vec3::new(20.0, 0.0, 10.0)], 2.0).unwrap();
let v_fillet = 40.0 * (4.0 - std::f64::consts::PI);
assert!(rel(measure(&rounded).unwrap().volume, 12000.0 - v_fillet) < 1e-3);
let pin = cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap();
let holed = boolean(&rounded, &pin, BoolOp::Cut).unwrap().unwrap();
let want = 12000.0 - v_fillet - std::f64::consts::PI * 25.0 * 10.0;
assert!(rel(measure(&holed).unwrap().volume, want) < 1e-3);
}
const HEAD: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('t','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n";
const TAIL: &str = "ENDSEC;\nEND-ISO-10303-21;\n";
/// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items.
fn cube_entities() -> String {
let mut s = String::new();
let pts = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), (0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)];
for (i, (x, y, z)) in pts.iter().enumerate() {
s += &format!("#{}=CARTESIAN_POINT('',({x}.,{y}.,{z}.));\n#{}=VERTEX_POINT('',#{});\n", 200 + i, 210 + i, 200 + i);
}
// 12 edges as lines (direction vector filler; trimmed by vertices).
let edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)];
s += "#290=DIRECTION('',(1.,0.,0.));\n#291=VECTOR('',#290,1.);\n";
for (k, (a, b)) in edges.iter().enumerate() {
s += &format!("#{}=LINE('',#{},#291);\n#{}=EDGE_CURVE('',#{},#{},#{},.T.);\n", 300 + k, 200 + a, 320 + k, 210 + a, 210 + b, 300 + k);
}
// Faces: (edge, forward?) loops counter-clockwise seen from outside, and plane frames.
let faces: [(&[(usize, bool)], (i32, i32, i32), (i32, i32, i32)); 6] = [
(&[(3, false), (2, false), (1, false), (0, false)], (0, 0, 0), (0, 0, -1)),
(&[(4, true), (5, true), (6, true), (7, true)], (0, 0, 1), (0, 0, 1)),
(&[(0, true), (9, true), (4, false), (8, false)], (0, 0, 0), (0, -1, 0)),
(&[(1, true), (10, true), (5, false), (9, false)], (1, 0, 0), (1, 0, 0)),
(&[(2, true), (11, true), (6, false), (10, false)], (1, 1, 0), (0, 1, 0)),
(&[(3, true), (8, true), (7, false), (11, false)], (0, 1, 0), (-1, 0, 0)),
];
let mut face_ids = Vec::new();
for (f, (lp, o, n)) in faces.iter().enumerate() {
let base = 400 + f * 20;
let oes: Vec<String> = lp
.iter()
.enumerate()
.map(|(j, (e, fwd))| {
s += &format!("#{}=ORIENTED_EDGE('',*,*,#{},{});\n", base + j, 320 + e, if *fwd { ".T." } else { ".F." });
format!("#{}", base + j)
})
.collect();
s += &format!("#{}=EDGE_LOOP('',({}));\n#{}=FACE_OUTER_BOUND('',#{},.T.);\n", base + 5, oes.join(","), base + 6, base + 5);
s += &format!("#{}=CARTESIAN_POINT('',({}.,{}.,{}.));\n#{}=DIRECTION('',({}.,{}.,{}.));\n", base + 7, o.0, o.1, o.2, base + 8, n.0, n.1, n.2);
s += &format!("#{}=AXIS2_PLACEMENT_3D('',#{},#{},$);\n#{}=PLANE('',#{});\n", base + 9, base + 7, base + 8, base + 10, base + 9);
s += &format!("#{}=ADVANCED_FACE('',(#{}),#{},.T.);\n", base + 11, base + 6, base + 10);
face_ids.push(format!("#{}", base + 11));
}
s += &format!("#100=CLOSED_SHELL('',({}));\n#101=MANIFOLD_SOLID_BREP('Cube',#100);\n", face_ids.join(","));
s
}
fn context(id: u64, unit: &str) -> String {
format!(
"#{id}=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{},#{}))REPRESENTATION_CONTEXT('',''));\n#{}=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{},'','');\n#{}={unit};\n#{}=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n",
id + 1,
id + 2,
id + 3,
id + 1,
id + 2,
id + 2,
id + 3
)
}
fn product(pd: u64, name: &str) -> String {
format!(
"#{}=PRODUCT('{name}','{name}','',(#{}));\n#{}=PRODUCT_CONTEXT('',#9,'mechanical');\n#{}=PRODUCT_DEFINITION_FORMATION('','',#{});\n#{pd}=PRODUCT_DEFINITION('design','',#{},#8);\n#{}=PRODUCT_DEFINITION_SHAPE('','',#{pd});\n",
pd + 1,
pd + 2,
pd + 2,
pd + 3,
pd + 1,
pd + 3,
pd + 4
)
}
#[test]
fn hand_written_part_in_inches_with_colour() {
let mut t = String::from(HEAD);
t += "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\n#9=APPLICATION_CONTEXT('');\n";
t += &cube_entities();
t += &context(
20,
"(CONVERSION_BASED_UNIT('INCH',#30)LENGTH_UNIT()NAMED_UNIT(*));\n#30=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#31);\n#31=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
);
t += &product(50, "Widget");
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#62);\n#62=SHAPE_REPRESENTATION('',(),#20);\n#63=SHAPE_REPRESENTATION_RELATIONSHIP('','',#62,#60);\n";
t += "#70=STYLED_ITEM('',(#71),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.BOTH.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('',1.,0.5,0.);\n";
t += TAIL;
let imp = step_import(&t).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 1);
let b = &imp.bodies[0];
assert_eq!(b.name, "Cube");
assert_eq!(b.path, vec!["Widget".to_string()]);
assert_eq!(b.color, Some([1.0, 0.5, 0.0]));
let m = measure(&b.body).unwrap();
assert!(rel(m.volume, 25.4f64.powi(3)) < 1e-9, "{}", m.volume);
assert_eq!(m.faces, 6);
assert_eq!(imp.tree.len(), 1);
assert_eq!(imp.tree[0].name, "Widget");
}
#[test]
fn hand_written_assembly() {
let mut t = String::from(HEAD);
t += "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\n#9=APPLICATION_CONTEXT('');\n";
t += &cube_entities();
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))");
t += &context(30, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
// Part (cube, in cm) and assembly (mm) with two placed instances of it.
t += &product(50, "Block");
t += &product(70, "Assembly");
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101,#65),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
t += "#65=AXIS2_PLACEMENT_3D('',#66,$,$);\n#66=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += "#80=SHAPE_REPRESENTATION('',(#81,#84),#30);\n#81=AXIS2_PLACEMENT_3D('',#82,$,$);\n#82=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += "#84=AXIS2_PLACEMENT_3D('',#85,#86,#87);\n#85=CARTESIAN_POINT('',(100.,0.,0.));\n#86=DIRECTION('',(0.,0.,1.));\n#87=DIRECTION('',(0.,1.,0.));\n";
t += "#88=SHAPE_DEFINITION_REPRESENTATION(#74,#80);\n";
for (k, item) in [(0u64, 81u64), (1, 84)] {
let n = 600 + k * 10;
t += &format!("#{n}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('{k}','Block:{k}','',#70,#50,$);\n");
t += &format!("#{}=PRODUCT_DEFINITION_SHAPE('','',#{n});\n", n + 1);
t += &format!("#{}=ITEM_DEFINED_TRANSFORMATION('','',#65,#{item});\n", n + 2);
t += &format!(
"#{}=(REPRESENTATION_RELATIONSHIP('','',#60,#80)REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{})SHAPE_REPRESENTATION_RELATIONSHIP());\n",
n + 3,
n + 2
);
t += &format!("#{}=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{},#{});\n", n + 4, n + 3, n + 1);
}
t += TAIL;
let imp = step_import(&t).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 2);
let boxes: Vec<_> = imp.bodies.iter().map(|b| measure(&b.body).unwrap()).collect();
for m in &boxes {
assert!(rel(m.volume, 1000.0) < 1e-9, "{}", m.volume);
}
// First at the origin, second moved 100 mm along X and turned 90° about Z.
assert!(boxes[0].bbox.min.dist(Vec3::ZERO) < 1e-6 && boxes[0].bbox.max.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-6);
assert!(boxes[1].bbox.min.dist(Vec3::new(90.0, 0.0, 0.0)) < 1e-6, "{:?}", boxes[1].bbox);
assert!(boxes[1].bbox.max.dist(Vec3::new(100.0, 10.0, 10.0)) < 1e-6, "{:?}", boxes[1].bbox);
assert_eq!(imp.tree.len(), 1);
let root = &imp.tree[0];
assert_eq!(root.name, "Assembly");
assert_eq!(root.children.len(), 2);
assert!(root.children.iter().all(|c| c.name == "Block" && c.bodies.len() == 1));
assert_eq!(imp.bodies[1].path, vec!["Assembly".to_string(), "Block".to_string()]);
assert!((root.children[1].transform[3][0] - 100.0).abs() < 1e-9);
}
#[test]
fn unsupported_face_is_left_out_and_reported() {
let mut t = String::from(HEAD);
let cube = cube_entities().replace("#410=PLANE('',#409);", "#410=OFFSET_SURFACE('',#409,1.,.F.);");
t += &cube;
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101),#20);\n";
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
t += TAIL;
let imp = step_import(&t).unwrap();
assert_eq!(imp.bodies.len(), 1);
assert!(!imp.bodies[0].closed);
assert_eq!(imp.bodies[0].body.face_count(), 5);
assert!(imp.warnings.iter().any(|w| w.contains("OFFSET_SURFACE")), "{:?}", imp.warnings);
assert!(imp.warnings.iter().any(|w| w.contains("open body")), "{:?}", imp.warnings);
}
#[test]
fn hostile_files_never_panic() {
let good = step_export(&[&box_solid(Vec3::ZERO, Vec3::new(5.0, 5.0, 5.0)).unwrap()], "t").unwrap();
let mut cases: Vec<String> = vec![
String::new(),
"ISO-10303-21;".into(),
format!("{HEAD}{TAIL}"),
format!("{HEAD}#1=MANIFOLD_SOLID_BREP('',#1);\n#2=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#1),#3);\n{TAIL}"),
format!(
"{HEAD}#1=MANIFOLD_SOLID_BREP('',#2);\n#2=CLOSED_SHELL('',(#3));\n#3=ADVANCED_FACE('',(#4),#5,.T.);\n#4=FACE_BOUND('',#6,.T.);\n#6=EDGE_LOOP('',(#7));\n#7=ORIENTED_EDGE('',*,*,#7,.T.);\n#5=PLANE('',#5);\n#9=SHAPE_REPRESENTATION('',(#1),#10);\n{TAIL}"
),
format!(
"{HEAD}#1=CARTESIAN_POINT('',(1.E308,1.E308,1.E308,1.,2.));\n#2=MANIFOLD_SOLID_BREP('',#1);\n#3=SHAPE_REPRESENTATION('',(#2),$);\n{TAIL}"
),
format!(
"{HEAD}#1=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#2,#2,$);\n#2=PRODUCT_DEFINITION('','',#3,#3);\n#3=PRODUCT_DEFINITION_FORMATION('','',#3);\n{TAIL}"
),
];
// Truncations and byte flips of a real file.
for cut in (0..good.len()).step_by(97) {
cases.push(good.get(..cut).unwrap_or("").to_string());
}
for (i, from, to) in [(0, "1.0", "-1.0"), (1, "#1", "#9999"), (2, ".T.", ".F."), (3, "(0.0", "(nan"), (4, "B_SPLINE", "X_SPLINE")] {
let _ = i;
cases.push(good.replacen(from, to, 3));
}
cases.push(good.replace("CLOSED_SHELL", "OPEN_SHELL"));
for c in &cases {
let r = std::panic::catch_unwind(|| step_import(c));
assert!(r.is_ok(), "panicked on {:?}", c.get(..c.len().min(200)));
}
}
#[test]
fn assembly_cycles_are_bounded() {
let mut t = String::from(HEAD);
t += "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\n#9=APPLICATION_CONTEXT('');\n";
t += &cube_entities();
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
t += &product(50, "A");
t += &product(70, "B");
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
// A contains B contains A.
t += "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','','',#70,#50,$);\n";
t += TAIL;
let r = step_import(&t);
// Every product is somebody's component: the solid is still imported (outside the tree).
let imp = r.unwrap();
assert_eq!(imp.bodies.len(), 1);
}