mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
kernel: STEP import and export on the schema 4 record and SMLib
Import: every solid is read by solidcraft_step::topology into its record and taken into SMLib as given (step_in/parallel.rs smlib_part), the one path; the truck readers (step_in/brep.rs, split.rs, geom.rs) are deleted. Placements are column-major 4x4 matrices from solidcraft_step::read placements; an instance is its part's record moved there (ops::transform_matrix), or the very part at the identity. Assemblies, names, colours, shared displays and the public API are unchanged. step_orientation_errors lists the faces the STEP reader turns over (their loops disagree with their same_sense). Export: each body's record is written by solidcraft_step::write::topology as the exact B-rep (solids per material region, open shells as a surface model, the record's pcurves); step_out writes products, units, colours and assemblies around it with Writer. The truck writer and every post-processing pass (split-face merging, sheet trimming, analytic recognition, short-edge collapse, the re-read fallback) are deleted. The context's uncertainty is the loosest record tolerance exported. Tests: ported to the record API with their assertions. Deleted, as tests of deleted internals: conic_tests extruded_ellipse_seam_uses_profile_angles_for_oblique_sweeps and conic_arcs_are_exact (truck geometry reader), tests offset_surface_analytic_geometry_and_ orientation, offset_surfaces_nested_and_hostile and periodic_knot_bsplines_are_read_on_ their_domain (truck geometry reader), corpus_truck_and_schema_4_counts (truck comparison); the orientation counter asserts (crate::body::ORIENTED, gone with orienting by mesh). (not built: base in progress) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
@@ -1,97 +1,5 @@
|
||||
use truck_modeling as mt;
|
||||
use truck_stepio::out;
|
||||
|
||||
use crate::Result;
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// Intersection-curve edges (from booleans) as cubic B-splines through the exact curve: STEP
|
||||
/// readers get plain 3D geometry that lies on both faces, and the file does not repeat the two
|
||||
/// surfaces for every such edge.
|
||||
fn plain_curves(b: &Body) -> Result<mt::Solid> {
|
||||
let s = crate::ops::placed_topology(&b.solid, mt::Matrix4::from_scale(1.0))?;
|
||||
let size = b.size();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for e in s.edge_iter() {
|
||||
if !seen.insert(e.id()) {
|
||||
continue;
|
||||
}
|
||||
if let mt::Curve::IntersectionCurve(ic) = e.curve()
|
||||
&& let Some(c) = hermite_through(&ic, size * 1e-4)
|
||||
{
|
||||
e.set_curve(mt::Curve::BSplineCurve(c));
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// A C¹ cubic B-spline through exact points of `c` spaced so that chords deviate at most `tol`
|
||||
/// (Catmull–Rom tangents over chord length; the cubic is far closer than the chords).
|
||||
fn hermite_through<C>(c: &C, tol: f64) -> Option<mt::BSplineCurve<mt::Point3>>
|
||||
where
|
||||
C: mt::ParametricCurve<Point = mt::Point3, Vector = mt::Vector3> + mt::BoundedCurve + mt::ParameterDivision1D<Point = mt::Point3>,
|
||||
{
|
||||
use mt::{InnerSpace, MetricSpace};
|
||||
let (_, pts) = c.parameter_division(c.range_tuple(), tol);
|
||||
let pts: Vec<mt::Point3> = pts.into_iter().fold(Vec::new(), |mut v: Vec<mt::Point3>, p| {
|
||||
if v.last().is_none_or(|q| q.distance(p) > tol * 1e-3) {
|
||||
v.push(p);
|
||||
}
|
||||
v
|
||||
});
|
||||
let n = pts.len().checked_sub(1).filter(|n| *n >= 1 && *n < 1_000_000)?;
|
||||
let mut u = vec![0.0; n + 1];
|
||||
for i in 1..=n {
|
||||
u[i] = u[i - 1] + pts[i].distance(pts[i - 1]);
|
||||
}
|
||||
let tangent = |i: usize| {
|
||||
let (a, b) = (i.saturating_sub(1), (i + 1).min(n));
|
||||
(pts[b] - pts[a]) / (u[b] - u[a])
|
||||
};
|
||||
let mut ctrl = vec![pts[0]];
|
||||
let mut knots = vec![u[0]; 4];
|
||||
for i in 0..n {
|
||||
let h = u[i + 1] - u[i];
|
||||
ctrl.push(pts[i] + tangent(i) * (h / 3.0));
|
||||
ctrl.push(pts[i + 1] - tangent(i + 1) * (h / 3.0));
|
||||
ctrl.push(pts[i + 1]);
|
||||
knots.extend(std::iter::repeat_n(u[i + 1], if i + 1 == n { 4 } else { 3 }));
|
||||
}
|
||||
if ctrl.iter().any(|p| !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite())) || tangent(0).magnitude() == 0.0 {
|
||||
return None;
|
||||
}
|
||||
mt::BSplineCurve::try_new(mt::KnotVec::from(knots), ctrl).ok()
|
||||
}
|
||||
|
||||
/// truck's STEP text for the given bodies (geometry and topology; product data is ours).
|
||||
pub(crate) fn truck_step(bodies: &[&Body], system: &str) -> Result<String> {
|
||||
if bodies.is_empty() {
|
||||
return Err(KernelError::Invalid("nothing to export".into()));
|
||||
}
|
||||
for b in bodies {
|
||||
b.require_brep("STEP export")?;
|
||||
}
|
||||
guard("step export", || {
|
||||
let cs: Vec<_> = bodies.iter().map(|b| plain_curves(b).map(|solid| solid.compress())).collect::<Result<_>>()?;
|
||||
let models: out::StepModels<_, _, _> = cs.iter().collect();
|
||||
let header = out::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() };
|
||||
written(&out::CompleteStepDisplay::new(models, header))
|
||||
})
|
||||
}
|
||||
|
||||
/// The text of truck's STEP writer, or an error. The writer refuses some geometry by returning
|
||||
/// `fmt::Error` after a line saying why; `to_string` would turn that into a panic (caught by
|
||||
/// `guard` on the desktop, but the browser build aborts on any panic).
|
||||
fn written(display: &impl std::fmt::Display) -> Result<String> {
|
||||
use std::fmt::Write;
|
||||
let mut text = String::new();
|
||||
match write!(text, "{display}") {
|
||||
Ok(()) => Ok(text),
|
||||
Err(_) => {
|
||||
let why = text.lines().last().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).unwrap_or("unsupported geometry");
|
||||
Err(KernelError::Failed(format!("not supported yet: STEP export of this body ({why})")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// STEP (ISO 10303-21, AP242) text for the given bodies as one part (`Body1`…, with the
|
||||
/// bodies' colours).
|
||||
|
||||
@@ -1,663 +0,0 @@
|
||||
//! 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, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface};
|
||||
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;
|
||||
/// Most boundary loops on one face. Each bound clones its edge handles into a new wire and the
|
||||
/// face extent then samples nine points per edge, so one face listing the same 100,000-edge
|
||||
/// loop a million times would hold 1e11 handles.
|
||||
const MAX_BOUNDS: usize = 10_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,
|
||||
/// The file face of each kernel face, in face order.
|
||||
pub face_origin: Vec<u64>,
|
||||
}
|
||||
|
||||
#[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"))
|
||||
}
|
||||
|
||||
/// A contractible outer loop whose winding opposes the surface's parameter normal.
|
||||
/// Samples follow the wire through periodic seams; a loop that winds around a surface is
|
||||
/// not a disk in parameter space and must retain the file's sense.
|
||||
/// The point of a cone (a surface revolved from a line that meets its axis) when `ring`, a
|
||||
/// face's only loop, goes once round the axis without touching it: the face then reaches the
|
||||
/// point. `None` for any other surface or loop.
|
||||
fn enclosed_apex(surface: &mt::Surface, ring: &mt::Wire, tol: f64) -> Option<mt::Point3> {
|
||||
use mt::Transform as _;
|
||||
let mt::Surface::RevolutedCurve(proc) = surface else { return None };
|
||||
let rc = proc.entity();
|
||||
let mt::Curve::Line(line) = rc.entity_curve() else { return None };
|
||||
let (o, axis) = (rc.origin(), rc.axis().normalize());
|
||||
// The rotation applied to the revolved surface, applied to its axis and profile too.
|
||||
let m = proc.transform();
|
||||
let (o, axis) = (m.transform_point(o), m.transform_vector(axis).normalize());
|
||||
let (p0, p1) = (m.transform_point(line.0), m.transform_point(line.1));
|
||||
let radius = |p: mt::Point3| (p - o).cross(axis).magnitude();
|
||||
let (r0, r1) = (radius(p0), radius(p1));
|
||||
if !((r0 - r1).abs() > 1e-9 * (r0 + r1)) {
|
||||
return None;
|
||||
}
|
||||
let apex = p0 + (p1 - p0) * (r0 / (r0 - r1));
|
||||
if !(radius(apex) <= tol.max(1e-9) * 10.0) {
|
||||
return None;
|
||||
}
|
||||
// Once round the axis, staying clear of it.
|
||||
let x = (p0 - o) - axis * (p0 - o).dot(axis);
|
||||
let x = if x.magnitude() > tol { x.normalize() } else { return None };
|
||||
let y = axis.cross(x);
|
||||
let mut turned = 0.0;
|
||||
let mut last: Option<f64> = None;
|
||||
let mut first: Option<f64> = None;
|
||||
for edge in ring.edge_iter() {
|
||||
let c = edge.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
for k in 0..16 {
|
||||
let q = c.subs(t0 + (t1 - t0) * f64::from(k) / 16.0);
|
||||
if radius(q) <= tol {
|
||||
return None;
|
||||
}
|
||||
let d = q - o;
|
||||
let ang = d.dot(y).atan2(d.dot(x));
|
||||
if let Some(l) = last {
|
||||
let mut step = ang - l;
|
||||
step -= (step / std::f64::consts::TAU).round() * std::f64::consts::TAU;
|
||||
turned += step;
|
||||
} else {
|
||||
first = Some(ang);
|
||||
}
|
||||
last = Some(ang);
|
||||
}
|
||||
}
|
||||
if let (Some(l), Some(f)) = (last, first) {
|
||||
let mut step = f - l;
|
||||
step -= (step / std::f64::consts::TAU).round() * std::f64::consts::TAU;
|
||||
turned += step;
|
||||
}
|
||||
((turned.abs() - std::f64::consts::TAU).abs() < 0.1).then_some(apex)
|
||||
}
|
||||
|
||||
fn reversed_outer(surface: &mt::Surface, wire: &mt::Wire, tol: f64) -> bool {
|
||||
if let mt::Surface::RevolutedCurve(proc) = surface {
|
||||
let rc = proc.entity();
|
||||
let curve = rc.entity_curve();
|
||||
let on_axis = |p: mt::Point3| (p - rc.origin()).cross(rc.axis()).magnitude() <= tol;
|
||||
// A compact support has two legitimate sides of a closed trim (a sphere's
|
||||
// hemispheres, for example); its winding must choose the side, not repair it.
|
||||
if (on_axis(curve.front()) && on_axis(curve.back())) || curve.front().distance(curve.back()) <= tol {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let periods = truck_meshalgo::tessellation::surface_periods(surface);
|
||||
let mut first = None::<[f64; 2]>;
|
||||
let mut previous = None::<[f64; 2]>;
|
||||
let (mut area, mut magnitude) = (0.0, 0.0);
|
||||
for edge in wire.edge_iter() {
|
||||
let curve = edge.oriented_curve();
|
||||
let (a, b) = curve.range_tuple();
|
||||
for k in 0..8 {
|
||||
let point = curve.subs(a + (b - a) * f64::from(k) / 8.0);
|
||||
let hint = previous.map(|p| (p[0], p[1]));
|
||||
let valid = |&(u, v): &(f64, f64)| u.is_finite() && v.is_finite() && surface.subs(u, v).distance(point) <= tol;
|
||||
let Some((u, v)) = truck_meshalgo::tessellation::project_trim_point(surface, point, hint, tol).filter(valid) else {
|
||||
return false;
|
||||
};
|
||||
// A pole has no unique parameter in its collapsed direction. Its arbitrary
|
||||
// search result cannot determine the winding of an outer loop.
|
||||
let (du, dv) = (surface.uder(u, v), surface.vder(u, v));
|
||||
if du.cross(dv).magnitude2() <= 1e-12 * (du.magnitude2() + dv.magnitude2()).powi(2) {
|
||||
return false;
|
||||
}
|
||||
let mut p = [u, v];
|
||||
let origin = *first.get_or_insert(p);
|
||||
if let Some(prev) = previous {
|
||||
for (d, x) in p.iter_mut().enumerate() {
|
||||
if let Some(period) = periods.get(d).copied().flatten().filter(|p| *p > 0.0) {
|
||||
*x -= ((*x - prev[d]) / period).round() * period;
|
||||
}
|
||||
}
|
||||
let cross = (prev[0] - origin[0]) * (p[1] - origin[1]) - (p[0] - origin[0]) * (prev[1] - origin[1]);
|
||||
area += cross;
|
||||
magnitude += cross.abs();
|
||||
}
|
||||
previous = Some(p);
|
||||
}
|
||||
}
|
||||
let (Some(first), Some(last)) = (first, previous) else { return false };
|
||||
if periods.iter().enumerate().any(|(d, p)| p.is_some_and(|p| (first[d] - last[d]).abs() > p * 0.5)) {
|
||||
return false;
|
||||
}
|
||||
area < -16.0 * f64::EPSILON * magnitude
|
||||
}
|
||||
|
||||
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, cx.snap).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 bounds = refs(p.get(1));
|
||||
if bounds.len() > MAX_BOUNDS {
|
||||
return Err(format!("a face with more than {MAX_BOUNDS} boundary loops"));
|
||||
}
|
||||
let mut wires = Vec::new();
|
||||
let mut outer = None;
|
||||
let mut edges = 0usize;
|
||||
for b in bounds {
|
||||
if let Some(w) = self.bound(cx, b)? {
|
||||
if entity(cx, b)?.name() == "FACE_OUTER_BOUND" {
|
||||
outer = Some(wires.len());
|
||||
}
|
||||
edges = edges.saturating_add(w.len());
|
||||
if edges > MAX_LOOP {
|
||||
return Err(format!("a face with more than {MAX_LOOP} boundary edges"));
|
||||
}
|
||||
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 mut surface = if e.name() == "FACE" {
|
||||
crate::plane_fit::plane_through(&extent).map(mt::Surface::Plane).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())?
|
||||
};
|
||||
// An explicit outer bound encloses the face. Some exporters give its surface the
|
||||
// opposite sense; treating that loop as a hole meshes the rest of the supporting
|
||||
// cylinder instead. Match the surface to the outer loop without reversing
|
||||
// shared topological edges. Winding loops (bands and pole seams) are left alone.
|
||||
if e.name() != "FACE" && seam.is_empty() && outer.and_then(|i| wires.get(i)).is_some_and(|w| reversed_outer(&surface, w, cx.tol)) {
|
||||
let sid = rid(p.get(2), "face geometry")?;
|
||||
let sense = p.get(3).and_then(Param::as_bool).unwrap_or(true);
|
||||
surface = guard("step surface orientation", || geom::surface(cx, sid, &extent, &seam, !sense, 0).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// A cone face bounded only by a ring round its axis (no vertex at the point, or a
|
||||
// VERTEX_LOOP there, which has no edges) covers the cone up to its point. Its surface
|
||||
// was spanned over the ring alone, a sliver at the base; it is spanned to the apex,
|
||||
// and the ring is joined to the apex by a seam line out and back, as a cone with a
|
||||
// seam is written.
|
||||
let (mut wires, mut surface) = (wires, surface);
|
||||
if e.name() != "FACE"
|
||||
&& seam.is_empty()
|
||||
&& let [ring] = wires.as_slice()
|
||||
&& let Some(apex) = enclosed_apex(&surface, ring, cx.tol)
|
||||
&& let Some(first) = ring.edge_iter().next()
|
||||
{
|
||||
let v = first.front().clone();
|
||||
let a = mt::Vertex::new(apex);
|
||||
let line = mt::Edge::new_unchecked(&v, &a, mt::Curve::Line(mt::Line(v.point(), apex)));
|
||||
let mut to_apex = extent.clone();
|
||||
to_apex.push(apex);
|
||||
let seam_line = [v.point(), apex];
|
||||
let sid = rid(p.get(2), "face geometry")?;
|
||||
let sense = p.get(3).and_then(Param::as_bool).unwrap_or(true);
|
||||
let spanned =
|
||||
guard("step cone to its point", || geom::surface(cx, sid, &to_apex, &seam_line, sense, 0).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut edges: Vec<mt::Edge> = ring.edge_iter().cloned().collect();
|
||||
edges.push(line.clone());
|
||||
edges.push(line.inverse());
|
||||
wires = vec![edges.into()];
|
||||
surface = spanned;
|
||||
}
|
||||
// Loops on a surface of revolution start away from the axis: a pole (cone apex, sphere
|
||||
// pole) has no definite rotation parameter, and the mesher takes the first point's
|
||||
// parameter as the anchor for the rest of the loop.
|
||||
let wires: Vec<mt::Wire> = match &surface {
|
||||
mt::Surface::RevolutedCurve(rc) => {
|
||||
let (o, a) = (rc.entity().origin(), rc.entity().axis());
|
||||
let dist = |e: &mt::Edge| {
|
||||
let v = e.front().point() - o;
|
||||
(v - a * v.dot(a)).magnitude()
|
||||
};
|
||||
wires
|
||||
.into_iter()
|
||||
.map(|w| {
|
||||
let edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
|
||||
let start = edges.iter().enumerate().max_by(|x, y| dist(x.1).total_cmp(&dist(y.1))).map(|(i, _)| i).unwrap_or(0);
|
||||
edges.iter().skip(start).chain(edges.iter().take(start)).cloned().collect::<Vec<_>>().into()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
// A loop passing a vertex twice (a figure eight through a pole, as written for a
|
||||
// torus whose tube touches its axis) starts at a vertex it passes once.
|
||||
_ => wires
|
||||
.into_iter()
|
||||
.map(|w| {
|
||||
let edges: Vec<mt::Edge> = w.edge_iter().cloned().collect();
|
||||
// Counted once: a loop may hold 100,000 edges, and asking per edge how
|
||||
// often its front vertex appears would scan them all again each time.
|
||||
let mut fronts: HashMap<_, usize> = HashMap::new();
|
||||
for e in &edges {
|
||||
*fronts.entry(e.front().id()).or_default() += 1;
|
||||
}
|
||||
let passes = |e: &mt::Edge| fronts.get(&e.front().id()).copied().unwrap_or(0);
|
||||
match edges.first().map(passes) {
|
||||
Some(n) if n > 1 => {
|
||||
let start = edges.iter().position(|e| passes(e) == 1).unwrap_or(0);
|
||||
edges.iter().skip(start).chain(edges.iter().take(start)).cloned().collect::<Vec<_>>().into()
|
||||
}
|
||||
_ => w,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
match mt::Face::try_new(wires.clone(), surface.clone()) {
|
||||
Ok(f) => Ok(f),
|
||||
// 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, Vec<u64>)> {
|
||||
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());
|
||||
let mut origin = 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);
|
||||
origin.push(f);
|
||||
}
|
||||
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 ball = match (out.as_slice(), origin.as_slice()) {
|
||||
([face], [file_face]) => whole_sphere(cx, *file_face, face).map(|patches| (*file_face, patches)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((file_face, patches)) = ball {
|
||||
origin = vec![file_face; patches.len()];
|
||||
out = patches;
|
||||
}
|
||||
let out = super::split::split_bands(out, &mut origin, failed, cx.tol);
|
||||
Ok((out.into(), closed, read, origin))
|
||||
}
|
||||
}
|
||||
|
||||
/// A ball written as one face on a sphere bounded only by its seam, run out and back from pole
|
||||
/// to pole. Truck meshes a face on a revolved surface in parameter space, where that loop
|
||||
/// encloses nothing, so the ball is built as the kernel's own sphere instead: six patches with
|
||||
/// no pole or seam inside them (`build::sphere_patch_faces`), turned inside out for a void.
|
||||
/// `None` for any other face.
|
||||
fn whole_sphere(cx: &Ctx, file_face: u64, face: &mt::Face) -> Option<Vec<mt::Face>> {
|
||||
use mt::{ParametricSurface3D, SearchParameter};
|
||||
let surface = entity(cx, file_face).ok()?.params().get(2).and_then(Param::as_ref_id)?;
|
||||
if entity(cx, surface).ok()?.name() != "SPHERICAL_SURFACE" {
|
||||
return None;
|
||||
}
|
||||
let wires = face.boundaries();
|
||||
let edges: Vec<&mt::Edge> = wires.iter().flat_map(|w| w.edge_iter()).collect();
|
||||
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
let mut poles: Vec<mt::Vertex> = Vec::new();
|
||||
for e in &edges {
|
||||
*uses.entry(e.id()).or_default() += 1;
|
||||
for v in [e.front(), e.back()] {
|
||||
if !poles.iter().any(|p| p.id() == v.id()) {
|
||||
poles.push(v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every edge a seam (used once each way), and the two vertices it joins are the poles.
|
||||
if uses.is_empty() || uses.values().any(|n| *n != 2) {
|
||||
return None;
|
||||
}
|
||||
let [a, b] = poles.as_slice() else { return None };
|
||||
let (a, b) = (a.point(), b.point());
|
||||
let center = a + (b - a) * 0.5;
|
||||
let radius = a.distance(b) * 0.5;
|
||||
let tol = cx.tol.max(radius * 1e-6);
|
||||
// The seam is a meridian: all of it on the sphere whose diameter joins the poles.
|
||||
let samples = |e: &&mt::Edge| {
|
||||
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))
|
||||
};
|
||||
if !(radius.is_finite() && radius > tol) || !edges.iter().flat_map(samples).all(|p| (p.distance(center) - radius).abs() <= tol) {
|
||||
return None;
|
||||
}
|
||||
// Ball or void: the face's normal at the middle of the seam, against the way out of the centre.
|
||||
let probe = edges.first().and_then(|e| samples(e).nth(4))?;
|
||||
let oriented = face.oriented_surface();
|
||||
let (u, v) = oriented.search_parameter(probe, mt::SPHint2D::None, 100)?;
|
||||
let outward = oriented.normal(u, v).dot(probe - center) > 0.0;
|
||||
let z = (crate::body::from_p3(a) - crate::body::from_p3(b)).normalized()?;
|
||||
let x = z.any_perp();
|
||||
let c = crate::body::from_p3(center);
|
||||
let mut patches = guard("step sphere", || crate::build::sphere_patch_faces(c, radius, [x, z.cross(x), z])).ok()?;
|
||||
if !outward {
|
||||
for f in &mut patches {
|
||||
f.invert();
|
||||
}
|
||||
}
|
||||
Some(patches)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
// The file face each kernel face comes from (in face order).
|
||||
let mut face_origin = Vec::new();
|
||||
match e.name() {
|
||||
"MANIFOLD_SOLID_BREP" | "FACETED_BREP" | "BREP_WITH_VOIDS" => {
|
||||
let (s, c, n, o) = b.shell(cx, rid(p.get(1), "brep outer shell")?, &mut failed)?;
|
||||
closed &= c;
|
||||
file_faces += n;
|
||||
shells.push(s);
|
||||
face_origin.extend(o);
|
||||
if e.name() == "BREP_WITH_VOIDS" {
|
||||
for v in refs(p.get(2)) {
|
||||
match b.shell(cx, v, &mut failed) {
|
||||
Ok((s, c, n, o)) => {
|
||||
closed &= c;
|
||||
file_faces += n;
|
||||
shells.push(s);
|
||||
face_origin.extend(o);
|
||||
}
|
||||
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, o)) => {
|
||||
closed &= c;
|
||||
file_faces += n;
|
||||
shells.push(s);
|
||||
face_origin.extend(o);
|
||||
}
|
||||
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()));
|
||||
}
|
||||
// `mt::Solid::try_new` only checks each shell (non-empty, connected, closed, no singular
|
||||
// vertex) and hands the shells back unchanged (truck-topology 0.6.0), so the same checks run
|
||||
// here on the borrowed shells. The successful path used to clone every face, edge, vertex,
|
||||
// curve and surface to keep a copy that was then dropped.
|
||||
let checked = guard("step solid", || {
|
||||
for shell in &shells {
|
||||
if shell.is_empty() {
|
||||
return Err(crate::KernelError::Failed("empty shell".into()));
|
||||
}
|
||||
if !shell.is_connected() {
|
||||
return Err(crate::KernelError::Failed("shell is not connected".into()));
|
||||
}
|
||||
if shell.shell_condition() != mt::ShellCondition::Closed {
|
||||
return Err(crate::KernelError::Failed("shell is not closed".into()));
|
||||
}
|
||||
if !shell.singular_vertices().is_empty() {
|
||||
return Err(crate::KernelError::Failed("shell is not manifold".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if checked.is_err() {
|
||||
closed = false;
|
||||
}
|
||||
// A void's faces face into the cavity, out of the material: it encloses volume of the
|
||||
// opposite sign to the outer shell. The ORIENTED_CLOSED_SHELL flag alone doesn't say so:
|
||||
// some exporters write .F. over a shell already facing into the cavity (#3730), others .T.
|
||||
// A void enclosing volume of the outer shell's sign is turned inside out.
|
||||
if e.name() == "BREP_WITH_VOIDS"
|
||||
&& checked.is_ok()
|
||||
&& let Some((outer, voids)) = shells.split_first_mut()
|
||||
&& let Some(outside) = crate::body::shell_volume(outer)
|
||||
{
|
||||
for void in voids {
|
||||
if crate::body::shell_volume(void).is_some_and(|v| v * outside > 0.0) {
|
||||
for face in void.face_iter_mut() {
|
||||
face.invert();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(BrepOut { solid: mt::Solid::new_unchecked(shells), failed, closed, file_faces, face_origin })
|
||||
}
|
||||
@@ -4,10 +4,6 @@
|
||||
|
||||
use std::f64::consts::{FRAC_PI_4, PI};
|
||||
|
||||
use mt::{BoundedCurve, InnerSpace, ParametricCurve};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use super::geom::{Frame, hyperbola_arc, parabola_arc};
|
||||
use super::*;
|
||||
use crate::{measure, step_export};
|
||||
|
||||
@@ -18,47 +14,6 @@ const CONTEXT: &str = "#90=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#81),#91);\n#9
|
||||
/// Height of the cone pieces: the 45° cone `x² + y² = z²` up to `z = H`.
|
||||
const H: f64 = 10.0;
|
||||
|
||||
#[test]
|
||||
fn extruded_ellipse_seam_uses_profile_angles_for_oblique_sweeps() {
|
||||
use mt::{MetricSpace, ParametricSurface};
|
||||
|
||||
let text = format!(
|
||||
"{HEAD}
|
||||
#1=CARTESIAN_POINT('',(10.,-20.,3.));
|
||||
#2=DIRECTION('',(0.,0.,1.));
|
||||
#3=DIRECTION('',(1.,0.,0.));
|
||||
#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);
|
||||
#5=ELLIPSE('',#4,9.,4.);
|
||||
#6=DIRECTION('',(0.5,0.25,1.));
|
||||
#7=VECTOR('',#6,1.);
|
||||
#8=SURFACE_OF_LINEAR_EXTRUSION('',#5,#7);
|
||||
{TAIL}"
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let origin = mt::Point3::new(10.0, -20.0, 3.0);
|
||||
let d = mt::Vector3::new(0.5, 0.25, 1.0).normalize();
|
||||
let point = |angle: f64, h: f64| origin + mt::Vector3::new(9.0 * angle.cos(), 4.0 * angle.sin(), 0.0) + d * h;
|
||||
let extent: Vec<_> = [-0.04, 0.0, 0.04].into_iter().flat_map(|a| [point(a, 0.0), point(a, 10.0)]).collect();
|
||||
for seam in [Vec::new(), vec![point(-0.04, 0.0), point(-0.04, 10.0)]] {
|
||||
let surface = geom::surface(&cx, 8, &extent, &seam, true, 0).unwrap();
|
||||
for i in 0..=16 {
|
||||
for j in 0..=4 {
|
||||
let q = surface.subs(i as f64 / 16.0, j as f64 / 4.0) - origin;
|
||||
let q = q - d * (q.z / d.z);
|
||||
assert!(((q.x / 9.0).powi(2) + (q.y / 4.0).powi(2) - 1.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
let start = surface.subs(0.0, 0.5);
|
||||
let q = start - d * ((start.z - origin.z) / d.z);
|
||||
if seam.is_empty() {
|
||||
assert!(q.distance(point(std::f64::consts::PI, 0.0)) < 1e-9);
|
||||
} else {
|
||||
assert!(q.distance(point(-0.04, 0.0)) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-9)
|
||||
}
|
||||
@@ -190,34 +145,6 @@ fn parabola_bounded_faces_import() {
|
||||
assert!(rel(v, want) < 1e-4, "schema 4: {v} vs {want}");
|
||||
}
|
||||
|
||||
/// The kernel curves lie exactly on the conics, end to end, whichever way they run.
|
||||
#[test]
|
||||
fn conic_arcs_are_exact() {
|
||||
let f = Frame { o: mt::Point3::new(1.0, 2.0, 3.0), x: mt::Vector3::unit_y(), y: mt::Vector3::unit_z(), z: mt::Vector3::unit_x() };
|
||||
let local = |p: mt::Point3| ((p - f.o).dot(f.x), (p - f.o).dot(f.y), (p - f.o).dot(f.z));
|
||||
for (t0, t1) in [(-1.5, 2.0), (3.0, -0.25), (0.0, 7.5)] {
|
||||
let c = hyperbola_arc(&f, 3.0, 2.0, t0, t1);
|
||||
let (s0, s1) = c.range_tuple();
|
||||
for i in 0..=200 {
|
||||
let (x, y, z) = local(c.subs(s0 + (s1 - s0) * i as f64 / 200.0));
|
||||
assert!((x * x / 9.0 - y * y / 4.0 - 1.0).abs() < 1e-9 && x > 0.0 && z.abs() < 1e-12, "{x} {y}");
|
||||
}
|
||||
let (x, y, _) = local(c.subs(s0));
|
||||
assert!((x - 3.0 * f64::cosh(t0)).abs() < 1e-9 && (y - 2.0 * f64::sinh(t0)).abs() < 1e-9);
|
||||
let (x, y, _) = local(c.subs(s1));
|
||||
assert!((x - 3.0 * f64::cosh(t1)).abs() < 1e-9 && (y - 2.0 * f64::sinh(t1)).abs() < 1e-9);
|
||||
|
||||
let c = parabola_arc(&f, 1.5, t0, t1);
|
||||
let (s0, s1) = c.range_tuple();
|
||||
for i in 0..=200 {
|
||||
let (x, y, z) = local(c.subs(s0 + (s1 - s0) * i as f64 / 200.0));
|
||||
assert!((y * y - 4.0 * 1.5 * x).abs() < 1e-9 * (1.0 + x.abs()) && z.abs() < 1e-12, "{x} {y}");
|
||||
}
|
||||
let (x, y, _) = local(c.subs(s1));
|
||||
assert!((x - 1.5 * t1 * t1).abs() < 1e-9 && (y - 3.0 * t1).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile conics are errors (the face is reported and left out), never panics.
|
||||
#[test]
|
||||
fn hostile_conics_never_panic() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,23 @@
|
||||
//! 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.
|
||||
//! [`solidcraft_step::topology`] reads each solid into its schema 4 record, which SMLib takes
|
||||
//! as the body ([`parallel`]), 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, and what cannot be read becomes a
|
||||
//! warning rather than a failure wherever the rest of the file is still usable.
|
||||
|
||||
mod brep;
|
||||
pub(crate) mod geom;
|
||||
pub(crate) mod p21;
|
||||
mod parallel;
|
||||
pub(crate) mod split;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use mt::EuclideanSpace as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use truck_modeling as mt;
|
||||
use solidcraft_step::read::R;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result, guard};
|
||||
use geom::R;
|
||||
use crate::{KernelError, Result};
|
||||
use p21::{Entity, Exchange, Param};
|
||||
|
||||
/// Deepest assembly nesting followed.
|
||||
@@ -99,48 +94,21 @@ struct Planned {
|
||||
|
||||
/// Where the solids of a batch go: their placement and names.
|
||||
struct Batch {
|
||||
m: mt::Matrix4,
|
||||
m: M4,
|
||||
path: Vec<String>,
|
||||
product: String,
|
||||
instance: Option<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,
|
||||
}
|
||||
/// The units of a representation context (GLOBAL_UNIT_ASSIGNED_CONTEXT) and its distance
|
||||
/// uncertainty (mm) as the file states it.
|
||||
type Units = solidcraft_step::units::Context;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Units {
|
||||
len: f64,
|
||||
ang: f64,
|
||||
tol: f64,
|
||||
/// The context's own distance uncertainty (mm), as the file states it (SMLib's reads take it).
|
||||
#[cfg_attr(not(feature = "smlib"), allow(dead_code))]
|
||||
uncertainty: Option<f64>,
|
||||
}
|
||||
|
||||
const DEFAULT_UNITS: Units = Units { len: 1.0, ang: 1.0, tol: 0.01, uncertainty: None };
|
||||
|
||||
/// Units of a representation context (GLOBAL_UNIT_ASSIGNED_CONTEXT, uncertainty), read by
|
||||
/// [`solidcraft_step::units::context`]; the vertex-on-curve tolerance is ten times the
|
||||
/// uncertainty (at least 1e-3 mm), else a hundredth of the length unit, within [1e-4, 1] mm.
|
||||
/// Units of a representation context, read by [`solidcraft_step::units::context`] (millimetres
|
||||
/// and radians when the file names none).
|
||||
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 c = solidcraft_step::units::context(ex, id);
|
||||
let tol = c.uncertainty.map(|t| (t * 10.0).max(1e-3)).unwrap_or(c.units.len * 1e-2).clamp(1e-4, 1.0);
|
||||
let u = Units { len: c.units.len, ang: c.units.ang, tol, uncertainty: c.uncertainty };
|
||||
cache.insert(id, u);
|
||||
u
|
||||
let Some(id) = ctx else { return Units::default() };
|
||||
*cache.entry(id).or_insert_with(|| solidcraft_step::units::context(ex, id))
|
||||
}
|
||||
|
||||
/// Items and context of a representation entity.
|
||||
@@ -157,18 +125,48 @@ fn representation(e: &Entity) -> Option<(Vec<u64>, Option<u64>)> {
|
||||
Some((items, p.get(2).and_then(Param::as_ref_id)))
|
||||
}
|
||||
|
||||
fn mat_mul(a: &mt::Matrix4, b: &mt::Matrix4) -> mt::Matrix4 {
|
||||
a * b
|
||||
/// A placement: a column-major 4×4 matrix (columns 0–2 the images of x, y and z, column 3 the
|
||||
/// translation), millimetres.
|
||||
type M4 = [[f64; 4]; 4];
|
||||
|
||||
const IDENTITY: M4 = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
|
||||
/// `a · b`.
|
||||
fn mat_mul(a: &M4, b: &M4) -> M4 {
|
||||
let mut out = [[0.0; 4]; 4];
|
||||
for (col, out_col) in out.iter_mut().enumerate() {
|
||||
for (row, cell) in out_col.iter_mut().enumerate() {
|
||||
*cell = (0..4).map(|k| a[k][row] * b[col][k]).sum();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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]]
|
||||
/// The inverse of a rigid placement (an orthonormal rotation and a translation): the rotation
|
||||
/// transposed, and the translation taken back through it.
|
||||
fn rigid_inverse(m: &M4) -> M4 {
|
||||
let mut out = IDENTITY;
|
||||
for (i, col) in out.iter_mut().take(3).enumerate() {
|
||||
for (j, cell) in col.iter_mut().take(3).enumerate() {
|
||||
*cell = m[j][i];
|
||||
}
|
||||
}
|
||||
for i in 0..3 {
|
||||
out[3][i] = -(0..3).map(|k| m[i][k] * m[3][k]).sum::<f64>();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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)
|
||||
fn translation(t: [f64; 3]) -> M4 {
|
||||
let mut m = IDENTITY;
|
||||
m[3] = [t[0], t[1], t[2], 1.0];
|
||||
m
|
||||
}
|
||||
|
||||
/// The matrix of an AXIS2_PLACEMENT: its frame's axes and origin.
|
||||
fn frame_matrix(p: &solidcraft_step::geom::Placement) -> M4 {
|
||||
let (x, y, z, o) = (p.ref_direction, p.y(), p.axis, p.origin);
|
||||
[[x[0], x[1], x[2], 0.0], [y[0], y[1], y[2], 0.0], [z[0], z[1], z[2], 0.0], [o[0], o[1], o[2], 1.0]]
|
||||
}
|
||||
|
||||
struct Reader {
|
||||
@@ -197,7 +195,7 @@ struct Reader {
|
||||
once: HashSet<u64>,
|
||||
/// An item placed more than once: its first instance's display, shared by every instance
|
||||
/// (with the placement that first instance had), so the part is meshed once.
|
||||
shown: HashMap<u64, (Arc<crate::body::SharedDisplay>, mt::Matrix4)>,
|
||||
shown: HashMap<u64, (Arc<crate::body::SharedDisplay>, M4)>,
|
||||
/// How many planned solids place each item.
|
||||
uses: HashMap<u64, usize>,
|
||||
/// An item placed more than once: read and oriented where the file defines it, so each
|
||||
@@ -489,15 +487,16 @@ impl Reader {
|
||||
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)?;
|
||||
/// The placement an item states: an AXIS2_PLACEMENT's frame, or a
|
||||
/// CARTESIAN_TRANSFORMATION_OPERATOR_3D's origin.
|
||||
fn placement_matrix(&mut self, item: u64, u: Units) -> R<M4> {
|
||||
let reader = solidcraft_step::read::Reader::new(&self.ex, u.units);
|
||||
let e = self.ex.get(item).ok_or_else(|| format!("missing entity #{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()));
|
||||
let o = reader.point(e.params().get(4).and_then(Param::as_ref_id).ok_or("transformation without origin")?)?;
|
||||
return Ok(translation(o));
|
||||
}
|
||||
Ok(geom::frame(&cx, item)?.matrix())
|
||||
Ok(frame_matrix(&reader.frame(item)?))
|
||||
}
|
||||
|
||||
/// Representations of a product (its own shape representations and those linked to them).
|
||||
@@ -535,7 +534,7 @@ impl Reader {
|
||||
}
|
||||
|
||||
/// 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, instance: Option<&str>, out: &mut Vec<usize>, depth: usize) {
|
||||
fn rep_bodies(&mut self, rep: u64, m: &M4, path: &[String], product: &str, instance: Option<&str>, out: &mut Vec<usize>, depth: usize) {
|
||||
if depth > MAX_ASSEMBLY_DEPTH {
|
||||
self.warn("mapped items nested too deeply");
|
||||
return;
|
||||
@@ -660,7 +659,7 @@ impl Reader {
|
||||
Some(part) => parallel::Source::Oriented(part.clone()),
|
||||
None => parallel::Source::Item { item, units },
|
||||
};
|
||||
let placement = self.batches.get(p.batch).map_or(mt::Matrix4::from_scale(1.0), |b| b.m);
|
||||
let placement = self.batches.get(p.batch).map_or(IDENTITY, |b| b.m);
|
||||
sources.push((i, item));
|
||||
jobs.push(parallel::Job { source, placement, check_mesh: !self.unmeshed_checked.contains(&item) });
|
||||
}
|
||||
@@ -715,11 +714,11 @@ impl Reader {
|
||||
// Every instance of a part placed more than once shows its first instance's
|
||||
// mesh, placed (the first one too: meshing its own would mesh the part twice).
|
||||
let body = match self.shown.get(&item) {
|
||||
Some((shared, first)) => body.with_display_of(shared.clone(), to_array(&mat_mul(&m, &rigid_inverse(first)))),
|
||||
Some((shared, first)) => body.with_display_of(shared.clone(), mat_mul(&m, &rigid_inverse(first))),
|
||||
None if self.uses.get(&item).is_some_and(|n| *n > 1) => {
|
||||
let shared = Arc::new(crate::body::SharedDisplay::new(body.clone()));
|
||||
self.shown.insert(item, (shared.clone(), m));
|
||||
body.with_display_of(shared, to_array(&mt::Matrix4::from_scale(1.0)))
|
||||
body.with_display_of(shared, IDENTITY)
|
||||
}
|
||||
None => body,
|
||||
};
|
||||
@@ -780,10 +779,10 @@ impl Reader {
|
||||
}
|
||||
|
||||
/// 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) };
|
||||
fn occurrence_matrix(&mut self, nauo: u64, child_reps: &[u64]) -> M4 {
|
||||
let Some(&rel) = self.placements.get(&nauo) else { return IDENTITY };
|
||||
let ex = self.ex.clone();
|
||||
let r = (|| -> R<mt::Matrix4> {
|
||||
let r = (|| -> R<M4> {
|
||||
let e = ex.get(rel).ok_or("missing placement")?;
|
||||
let rr = e.record("REPRESENTATION_RELATIONSHIP").ok_or("placement without representations")?;
|
||||
let (r1, r2) =
|
||||
@@ -814,23 +813,14 @@ impl Reader {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
self.warn(format!("component placement #{rel}: {e} (placed at the origin)"));
|
||||
mt::Matrix4::from_scale(1.0)
|
||||
IDENTITY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node(
|
||||
&mut self,
|
||||
pd: u64,
|
||||
local: mt::Matrix4,
|
||||
world: mt::Matrix4,
|
||||
path: &[String],
|
||||
instance: Option<&str>,
|
||||
depth: usize,
|
||||
stack: &mut Vec<u64>,
|
||||
) -> StepNode {
|
||||
fn node(&mut self, pd: u64, local: M4, world: M4, path: &[String], instance: Option<&str>, 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() };
|
||||
let mut node = StepNode { name: name.clone(), transform: local, bodies: Vec::new(), children: Vec::new() };
|
||||
if self.stop() {
|
||||
return node;
|
||||
}
|
||||
@@ -869,96 +859,29 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a STEP file's structure: it parses, no entity id is defined twice, every reference
|
||||
/// names a defined entity, nothing but root entities (relationships, definitions,
|
||||
/// presentation) is left unreferenced, and ids are dense (`#1`…`#n`). Returns the entity count.
|
||||
/// Faces whose boundary runs the wrong way round their normal, as a strict reader
|
||||
/// sees them: each face's normal is its surface's own parametric normal (∂S/∂u × ∂S/∂v, ISO
|
||||
/// 10303-42), reversed when `same_sense` is false, and the outer loop must run counter-clockwise
|
||||
/// seen from that side. Checked on faces whose loop spans a clear direction (patches, not bands
|
||||
/// around a closed surface). Returns one message per bad face.
|
||||
/// Faces whose boundary runs the wrong way round their normal, as a strict reader sees them:
|
||||
/// each face's normal is its surface's own parametric normal (∂S/∂u × ∂S/∂v, ISO 10303-42),
|
||||
/// reversed when `same_sense` is false, and the outer loop must run counter-clockwise seen from
|
||||
/// that side. These are the faces the STEP reader turns over ([`solidcraft_step::topology`]),
|
||||
/// numbered as the solid's body numbers them. Returns one message per bad face; an error when
|
||||
/// the file does not parse or has no solid the reader can read.
|
||||
pub fn step_orientation_errors(text: &str) -> std::result::Result<Vec<String>, String> {
|
||||
use mt::{BoundedCurve, InnerSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter};
|
||||
let imp = step_import(text).map_err(|e| e.to_string())?;
|
||||
let ex = p21::parse(text)?;
|
||||
let read = solidcraft_step::topology::read_solids(&ex);
|
||||
if read.solids.is_empty() {
|
||||
return Err(read.failures.first().map_or_else(|| "the file has no solids".into(), |f| format!("solid #{}: {}", f.item, f.message)));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for b in &imp.bodies {
|
||||
let r = guard("orientation check", || {
|
||||
let mut bad = Vec::new();
|
||||
for (fi, f) in b.body.solid.face_iter().enumerate() {
|
||||
let bounds = f.boundaries();
|
||||
// Patches only: a face with several loops (a band between two rings, a face with
|
||||
// holes) has no single loop direction to compare.
|
||||
let [w] = bounds.as_slice() else { continue };
|
||||
// Only revolutions (cylinders, cones, spheres, tori: closed surfaces, where a loop
|
||||
// read the wrong way round silently gives the rest of the surface). On free-form
|
||||
// strips the loop's normals vary too much for this test; the round trips' volume
|
||||
// checks cover those.
|
||||
if !matches!(f.surface(), mt::Surface::RevolutedCurve(_)) {
|
||||
continue;
|
||||
}
|
||||
// A loop through a vertex twice (a figure eight round a pole) has no single direction.
|
||||
let fronts: std::collections::HashSet<_> = w.edge_iter().map(|e| e.front().id()).collect();
|
||||
if fronts.len() != w.len() {
|
||||
continue;
|
||||
}
|
||||
let pts: Vec<mt::Point3> = w
|
||||
.edge_iter()
|
||||
.flat_map(|e| {
|
||||
let c = e.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..16).map(move |i| c.subs(t0 + (t1 - t0) * i as f64 / 16.0)).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
continue;
|
||||
}
|
||||
let mut newell = mt::Vector3::new(0.0, 0.0, 0.0);
|
||||
let mut c = mt::Vector3::new(0.0, 0.0, 0.0);
|
||||
let mut span: f64 = 0.0;
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(q)) = (pts.get(i), pts.get((i + 1) % n)) else { continue };
|
||||
newell += (a.to_vec()).cross(q.to_vec());
|
||||
c += a.to_vec();
|
||||
span = span.max((a - pts[0]).magnitude());
|
||||
}
|
||||
let c = mt::Point3::from_vec(c / n as f64);
|
||||
// Bands round a closed surface have no clear loop direction.
|
||||
if !(newell.magnitude() > 0.05 * span * span) {
|
||||
continue;
|
||||
}
|
||||
// The face normal averaged along the loop (each loop point is on the surface;
|
||||
// the loop's centroid can be nearer the wrong side of a curved face).
|
||||
let s = f.oriented_surface();
|
||||
let mut normal = mt::Vector3::new(0.0, 0.0, 0.0);
|
||||
let mut count = 0usize;
|
||||
for p in &pts {
|
||||
if let Some(uv) = s.search_nearest_parameter(*p, None, 100) {
|
||||
let n = s.normal(uv.0, uv.1);
|
||||
if n.x.is_finite() && n.y.is_finite() && n.z.is_finite() {
|
||||
normal += n;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = c;
|
||||
// Only patches clearly to one side of their loop are judged: a loop on a closed
|
||||
// surface (a sphere's cube-face square) bounds either part.
|
||||
if count == 0 || normal.magnitude() < 0.7 * count as f64 {
|
||||
continue;
|
||||
}
|
||||
if normal.dot(newell) < 0.0 {
|
||||
bad.push(format!("{}: face {fi} runs clockwise around its normal", b.name));
|
||||
}
|
||||
}
|
||||
Ok(bad)
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
out.extend(r);
|
||||
for solid in &read.solids {
|
||||
let name = if solid.name.trim().is_empty() { format!("solid #{}", solid.item) } else { solid.name.trim().to_string() };
|
||||
out.extend(solid.turned.iter().map(|f| format!("{name}: face {f} runs clockwise around its normal")));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Check a STEP file's structure: it parses, no entity id is defined twice, every reference
|
||||
/// names a defined entity, nothing but root entities (relationships, definitions,
|
||||
/// presentation) is left unreferenced, and ids are dense (`#1`…`#n`). Returns the entity count.
|
||||
pub fn step_validate(text: &str) -> std::result::Result<usize, String> {
|
||||
let ex = p21::parse(text)?;
|
||||
if let Some(d) = ex.duplicates.first() {
|
||||
@@ -1129,7 +1052,7 @@ impl StepReading {
|
||||
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 identity = IDENTITY;
|
||||
let mut tree = Vec::new();
|
||||
for pd in roots {
|
||||
tree.push(rd.node(pd, identity, identity, &[], None, 0, &mut Vec::new()));
|
||||
@@ -1155,7 +1078,7 @@ impl StepReading {
|
||||
for r in loose {
|
||||
rd.rep_bodies(r, &identity, &[], "Import", None, &mut bodies, 0);
|
||||
}
|
||||
tree.push(StepNode { name: "Import".into(), transform: to_array(&identity), bodies, children: Vec::new() });
|
||||
tree.push(StepNode { name: "Import".into(), transform: identity, bodies, children: Vec::new() });
|
||||
}
|
||||
let single = workers <= 1;
|
||||
Ok(StepReading { rd, tree, key, small: text.len() <= KEEP_SMALL_BYTES, single })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The Part 21 parser lives in `solidcraft-step`; this reads with the kernel's progress watch.
|
||||
|
||||
pub use solidcraft_step::p21::{Entity, Exchange, Param, Record};
|
||||
pub use solidcraft_step::p21::{Entity, Exchange, Param};
|
||||
|
||||
struct Watched(std::sync::Arc<dyn crate::watch::Watch>);
|
||||
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
//! Bounded desktop preparation of independent imported bodies, collected in file order.
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{Body, Ctx, Exchange, KernelError, Units, brep, guard, mt};
|
||||
use super::{Body, Exchange, IDENTITY, KernelError, M4, Units};
|
||||
|
||||
pub(super) struct Job {
|
||||
pub source: Source,
|
||||
/// Where the body goes (its instance's placement in the assembly).
|
||||
pub placement: mt::Matrix4,
|
||||
pub placement: M4,
|
||||
pub check_mesh: bool,
|
||||
/// The file's point-on-surface tolerance (`super::Units::tol`) for the representation this
|
||||
/// solid was read from: how far its boundaries may lie off their surfaces (mm). The body
|
||||
/// keeps it, so the tessellator holds an imported boundary to the file's own uncertainty
|
||||
/// rather than to a fraction of the part's size (see `crate::body::Body::boundary_tol`).
|
||||
pub tol: f64,
|
||||
}
|
||||
|
||||
/// What a job places: a solid item to read from the file (and orient), or a part an earlier
|
||||
@@ -66,51 +61,31 @@ pub(super) fn workers() -> usize {
|
||||
fn prepare(ex: &Exchange, job: &Job) -> Prepared {
|
||||
let (part, read) = match &job.source {
|
||||
Source::Oriented(part) => (part.clone(), None),
|
||||
Source::Item { item, units } if smlib_backend() => {
|
||||
Source::Item { item, units } => {
|
||||
let (part, read) = smlib_part(ex, *item, *units)?;
|
||||
(Arc::new(part), Some(read))
|
||||
}
|
||||
Source::Item { item, units } => {
|
||||
let cx = Ctx { ex, len: units.len, ang: units.ang, tol: units.tol, snap: units.snap };
|
||||
let out = guard("step brep", || brep::read_item(&cx, *item).map_err(KernelError::Failed))
|
||||
.map_err(|e| Failure { read: true, msg: e.to_string().trim_start_matches("the operation failed: ").to_string() })?;
|
||||
// Orient once where the file defines the solid; each placement then takes its
|
||||
// orientation from the matrix instead of meshing the whole part again.
|
||||
let body = guard("STEP body preparation", || Body::new(out.solid)).map_err(|e| Failure { read: false, msg: e.to_string() })?;
|
||||
// The file's tolerance is set on the part itself, before anything meshes it: the
|
||||
// unmeshed-face count below is the import's own warning and must judge the body by
|
||||
// the file's uncertainty too, and every placement of the part carries the figure on.
|
||||
let body = body.with_boundary_tol(Some(job.tol));
|
||||
(Arc::new(Part { body, closed: out.closed, file_faces: out.file_faces, face_origin: out.face_origin }), Some(out.failed))
|
||||
}
|
||||
};
|
||||
guard("STEP body preparation", || {
|
||||
let body = part.body.placed(&job.placement)?;
|
||||
// Counted where the orientation mesh already counted them (a placement meshes the
|
||||
// same faces), and on the part, which carries the file's tolerance.
|
||||
let missing = if job.check_mesh { part.body.unmeshed_faces() } else { 0 };
|
||||
Ok((body, missing))
|
||||
})
|
||||
.map(|(body, missing)| Made { body, missing, part, read })
|
||||
.map_err(|e| Failure { read: false, msg: e.to_string() })
|
||||
let body = placed(&part.body, &job.placement).map_err(|e| Failure { read: false, msg: e.to_string() })?;
|
||||
// Counted on the part (a placement meshes the same faces).
|
||||
let missing = if job.check_mesh { part.body.unmeshed_faces() } else { 0 };
|
||||
Ok(Made { body, missing, part, read })
|
||||
}
|
||||
|
||||
/// Whether solids are read into SMLib ([`crate::backend`]). A setting this build can't honour
|
||||
/// reads them on neither kernel: [`smlib_part`] then says why.
|
||||
fn smlib_backend() -> bool {
|
||||
!matches!(crate::backend(), Ok(crate::Backend::Truck))
|
||||
/// The part's body where its instance goes: the very same body at the identity (it keeps the
|
||||
/// SMLib body the read made), else its record moved there.
|
||||
fn placed(body: &Body, m: &M4) -> crate::Result<Body> {
|
||||
if *m == IDENTITY {
|
||||
return Ok(body.clone());
|
||||
}
|
||||
Ok(crate::ops::transform_matrix(body, *m)?.without_history())
|
||||
}
|
||||
|
||||
/// A solid item read with the STEP reader into its schema 4 record, then into SMLib (healed
|
||||
/// where SMLib's check refuses it as given), and brought back as a body: the part SMLib made of
|
||||
/// the file. A solid the reader or SMLib refuses is not imported, with the reason; there is no
|
||||
/// other kernel to fall back on.
|
||||
#[cfg(feature = "smlib")]
|
||||
/// A solid item read with the STEP reader into its schema 4 record, then into SMLib as given,
|
||||
/// and brought back as a body: the part SMLib made of the file. A solid the reader or SMLib
|
||||
/// refuses is not imported, with the reason.
|
||||
pub(super) fn smlib_part(ex: &Exchange, item: u64, units: Units) -> std::result::Result<(Part, Vec<String>), Failure> {
|
||||
crate::backend().map_err(|e| Failure { read: true, msg: e.to_string() })?;
|
||||
let context =
|
||||
solidcraft_step::units::Context { units: solidcraft_step::read::Units { len: units.len, ang: units.ang }, uncertainty: units.uncertainty };
|
||||
let solid = solidcraft_step::topology::read_solid(ex, item, context).map_err(|msg| Failure { read: true, msg })?;
|
||||
let solid = solidcraft_step::topology::read_solid(ex, item, units).map_err(|msg| Failure { read: true, msg })?;
|
||||
let failed = |e: KernelError| Failure { read: false, msg: e.to_string().trim_start_matches("the operation failed: ").to_string() };
|
||||
let (brep, smlib_order) = crate::native::smlib_record_ordered(&solid.brep).map_err(failed)?;
|
||||
let (body, body_order) = crate::native::body_from_smlib_ordered(&brep).map_err(failed)?;
|
||||
@@ -133,11 +108,6 @@ pub(super) fn smlib_part(ex: &Exchange, item: u64, units: Units) -> std::result:
|
||||
Ok((part, solid.notes))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "smlib"))]
|
||||
pub(super) fn smlib_part(_: &Exchange, _: u64, _: Units) -> std::result::Result<(Part, Vec<String>), Failure> {
|
||||
Err(Failure { read: true, msg: crate::backend().err().map_or_else(|| "this build has no SMLib".into(), |e| e.to_string()) })
|
||||
}
|
||||
|
||||
/// Prepare `jobs`, the results in the jobs' order. Solids still to read and orient (the slow
|
||||
/// part) are shared out between up to `workers` threads, each taking the next job when it is
|
||||
/// done with one, so one large part does not hold up the rest.
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
//! 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};
|
||||
|
||||
/// How far a band's ring edge may lie off its surface, as a fraction of the body, for a body that
|
||||
/// declares no accuracy of its own.
|
||||
const RING_OFF_SURFACE: f64 = 1e-3;
|
||||
|
||||
/// 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 direction the split line runs along (the one that does not wrap).
|
||||
fn along(d: Dir) -> Dir {
|
||||
match d {
|
||||
Dir::U => Dir::V,
|
||||
Dir::V => Dir::U,
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed, non-periodic direction of a surface with its range; with `periodic`, a revolved
|
||||
/// surface's turn around its axis too.
|
||||
fn closed_dirs(s: &mt::Surface, periodic: bool) -> Vec<(Dir, f64, f64)> {
|
||||
match s {
|
||||
mt::Surface::RevolutedCurve(p) => {
|
||||
let c = p.entity().entity_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let mut out = Vec::new();
|
||||
if c.front().distance(c.back()) < 1e-7 * (1.0 + c.front().to_homogeneous().truncate().magnitude()) {
|
||||
out.push((Dir::U, t0, t1));
|
||||
}
|
||||
if periodic {
|
||||
out.extend(turn_dir(s));
|
||||
}
|
||||
out
|
||||
}
|
||||
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![],
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter that turns a revolved surface around its axis, over one turn.
|
||||
fn turn_dir(s: &mt::Surface) -> Option<(Dir, f64, f64)> {
|
||||
let mt::Surface::RevolutedCurve(p) = s else { return None };
|
||||
let (t0, t1) = p.entity().entity_curve().range_tuple();
|
||||
let tau = std::f64::consts::TAU;
|
||||
let ts: Vec<f64> = (0..=4).map(|i| t0 + (t1 - t0) * f64::from(i) / 4.0).collect();
|
||||
[Dir::V, Dir::U].into_iter().find_map(|d| {
|
||||
let at = |w: f64, t: f64| match d {
|
||||
Dir::V => s.subs(t, w),
|
||||
Dir::U => s.subs(w, t),
|
||||
};
|
||||
let scale = ts.iter().map(|t| at(0.0, *t).to_homogeneous().truncate().magnitude()).fold(1.0, f64::max);
|
||||
let closes = ts.iter().all(|t| at(0.0, *t).distance(at(tau, *t)) < scale * 1e-9);
|
||||
let turns = ts.iter().any(|t| at(0.0, *t).distance(at(std::f64::consts::PI, *t)) > scale * 1e-6);
|
||||
(closes && turns).then_some((d, 0.0, tau))
|
||||
})
|
||||
}
|
||||
|
||||
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`. `tol` is how far a ring edge may lie off
|
||||
/// the surface the split line lies on: a STEP file writes the surface and the curves bounding it
|
||||
/// to the file's own declared uncertainty only, so the line is matched to the surface points
|
||||
/// below `q1` and `q2`, not to them.
|
||||
fn iso_curve(s: &mt::Surface, d: Dir, mid: f64, q1: mt::Point3, q2: mt::Point3, seam: &[mt::Point3], tol: f64) -> R<mt::Curve> {
|
||||
match s {
|
||||
// Across the turn of a cylinder or cone: a straight line along it.
|
||||
mt::Surface::RevolutedCurve(_) if turn_dir(s).is_some_and(|t| t.0 == d) => {
|
||||
let m = q1 + (q2 - q1) * 0.5;
|
||||
let on = uv_of(s, m).is_some_and(|(u, v)| s.subs(u, v).distance(m) < 1e-7 * (1.0 + q1.distance(q2)));
|
||||
if on && q1.distance(q2) > 1e-9 {
|
||||
Ok(mt::Curve::Line(mt::Line(q1, q2)))
|
||||
} else {
|
||||
Err("a revolved face that is not straight along its axis".into())
|
||||
}
|
||||
}
|
||||
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)).max(tol) {
|
||||
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 (t0, t1) = iso.range_tuple();
|
||||
// Where the split line comes nearest `p`: the point on the surface below `p` (the
|
||||
// line runs on the surface, `p` only within the file's tolerance of it), looked for
|
||||
// from its own surface parameter and from a scan of the whole line.
|
||||
let near = |p: mt::Point3| {
|
||||
let foot = uv_of(s, p).filter(|uv| s.subs(uv.0, uv.1).distance(p) <= tol);
|
||||
let target = foot.map_or(p, |uv| s.subs(uv.0, uv.1));
|
||||
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(target);
|
||||
if dd < best.1 {
|
||||
best = (t, dd);
|
||||
}
|
||||
}
|
||||
let seeds = foot.map(|uv| coord(along(d), uv)).into_iter().chain(iso.search_nearest_parameter(target, best.0, 64));
|
||||
for t in seeds {
|
||||
let t = t.clamp(t0, t1);
|
||||
let dd = iso.subs(t).distance(target);
|
||||
if dd <= best.1 {
|
||||
best = (t, dd);
|
||||
}
|
||||
}
|
||||
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 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`.
|
||||
pub(crate) 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),
|
||||
}
|
||||
|
||||
/// How to cut `face` in two along the line opposite its seam. `periodic` also cuts a face that
|
||||
/// runs round the turn of a revolved surface (see [`closed_dirs`]). `tol` is how far a ring edge
|
||||
/// of the face may lie off the surface it bounds (see [`iso_curve`]).
|
||||
fn plan(face: &mt::Face, periodic: bool, tol: f64) -> 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, periodic).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])),
|
||||
}
|
||||
}
|
||||
// A cone's tip: the seam runs from the ring to the apex and straight back.
|
||||
if let [(true, sm), (false, ring)] = chains.as_slice() {
|
||||
return apex_plan(face, &s, (d, mid, period), sm, ring, &wires, outer_idx, tol).map(Some);
|
||||
}
|
||||
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, tol)?;
|
||||
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) }))
|
||||
}
|
||||
|
||||
/// Split a face closing at an apex (loop: seam out to the apex, seam back, the ring) along the
|
||||
/// iso-line opposite the seam, from the ring to the apex.
|
||||
fn apex_plan(
|
||||
face: &mt::Face,
|
||||
s: &mt::Surface,
|
||||
(d, mid, period): (Dir, f64, f64),
|
||||
sm: &[mt::Edge],
|
||||
ring: &[mt::Edge],
|
||||
wires: &[mt::Wire],
|
||||
outer_idx: usize,
|
||||
tol: f64,
|
||||
) -> R<Plan> {
|
||||
let h = sm.len() / 2;
|
||||
if !sm.len().is_multiple_of(2) || h == 0 {
|
||||
return Err("seam edges are not used back and forth".into());
|
||||
}
|
||||
let (out, back) = (sm.get(..h).ok_or("seam")?, sm.get(h..).ok_or("seam")?);
|
||||
if out.iter().map(mt::Edge::id).collect::<Vec<_>>() != back.iter().rev().map(mt::Edge::id).collect::<Vec<_>>() {
|
||||
return Err("seam edges are not used back and forth".into());
|
||||
}
|
||||
let apex = out.last().ok_or("seam")?.back().clone();
|
||||
let (k, t, q) = crossing(s, d, ring, mid, period)?;
|
||||
let seam_line: Vec<mt::Point3> = out.iter().flat_map(|e| samples(&e.oriented_curve(), 16).into_iter().map(|x| x.1)).collect();
|
||||
let iso = iso_curve(s, d, mid, q, apex.point(), &seam_line, tol)?;
|
||||
let (ra, rb, c, vq) = split_chain(ring, k, t, q)?;
|
||||
let e = mt::Edge::new_unchecked(&vq, &apex, iso);
|
||||
// Face A: out to the apex, down the split line, the ring's second part; face B the rest.
|
||||
let mut wa: Vec<mt::Edge> = out.to_vec();
|
||||
wa.push(e.inverse());
|
||||
wa.extend(rb.iter().cloned());
|
||||
let mut wb: Vec<mt::Edge> = vec![e];
|
||||
wb.extend(back.iter().cloned());
|
||||
wb.extend(ra);
|
||||
let (wire_a, wire_b): (mt::Wire, mt::Wire) = (wa.into(), wb.into());
|
||||
if !wire_a.is_closed() || !wire_b.is_closed() {
|
||||
return Err("split loops do not close".into());
|
||||
}
|
||||
let side_a = rb
|
||||
.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.clone());
|
||||
if !face.orientation() {
|
||||
fa.invert();
|
||||
fb.invert();
|
||||
}
|
||||
Ok(Plan { cuts: c.into_iter().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
|
||||
}
|
||||
|
||||
/// A copy of `body` whose faces that run once round a cylinder or cone along a seam edge
|
||||
/// (as STEP files have them) are each cut in two along the line opposite the seam, the way the
|
||||
/// kernel builds such walls itself; the boolean cannot always intersect the whole ones. `None`
|
||||
/// when the body has no such face, or one can't be cut.
|
||||
pub(crate) fn split_seams(body: &crate::Body) -> Option<crate::Body> {
|
||||
let seamed = |f: &mt::Face| {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
f.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).any(|e| !seen.insert(e.id()))
|
||||
};
|
||||
if !body.solid.face_iter().any(seamed) {
|
||||
return None;
|
||||
}
|
||||
// How far a ring edge of such a face may lie off the surface it bounds (see `iso_curve`):
|
||||
// the file's own declared uncertainty for a body read from one, else a thousandth of the body.
|
||||
let tol = body.boundary_tol.unwrap_or(body.size() * RING_OFF_SURFACE).max(1e-6);
|
||||
let mut shells: Vec<mt::Shell> = Vec::new();
|
||||
for sh in body.solid.boundaries() {
|
||||
let mut faces: Vec<mt::Face> = sh.face_iter().cloned().collect();
|
||||
let n = faces.len();
|
||||
for i in 0..n {
|
||||
let Some(face) = faces.get(i).cloned() else { continue };
|
||||
if !seamed(&face) {
|
||||
continue;
|
||||
}
|
||||
let p = crate::guard("seam split", || plan(&face, true, tol).map_err(crate::KernelError::Failed)).ok()??;
|
||||
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);
|
||||
}
|
||||
shells.push(faces.into());
|
||||
}
|
||||
let solid = mt::Solid::try_new(shells).ok()?;
|
||||
let b = crate::Body::new(solid).ok()?;
|
||||
b.validity().is_empty().then_some(b)
|
||||
}
|
||||
|
||||
/// Split every band face of a shell (see the module docs). Faces that cannot be split stay as
|
||||
/// they are and are reported.
|
||||
///
|
||||
/// `tol` is the file's point-on-surface tolerance (`step_in::mod::Units::tol`): 10x the file's
|
||||
/// declared uncertainty, floored at 1 µm and clamped to [1e-4, 1] mm. A ring edge is written to
|
||||
/// within this distance of the surface it bounds, so it is how far `iso_curve` may accept a
|
||||
/// crossing point as lying on the surface.
|
||||
pub(crate) fn split_bands(mut faces: Vec<mt::Face>, origin: &mut Vec<u64>, warnings: &mut Vec<String>, tol: f64) -> 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, false, tol).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);
|
||||
// The second piece comes from the same file face.
|
||||
let o = origin.get(i).copied().unwrap_or(0);
|
||||
origin.push(o);
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -164,15 +164,14 @@ fn benchmark_independent_step_solids() {
|
||||
let start = std::time::Instant::now();
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let parse = start.elapsed();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 0.01 };
|
||||
let mut items: Vec<_> = ex.entities.iter().filter(|(_, e)| e.name() == "MANIFOLD_SOLID_BREP").map(|(id, _)| *id).collect();
|
||||
items.sort();
|
||||
let start = std::time::Instant::now();
|
||||
let solids: Vec<_> = items.iter().map(|id| guard("benchmark read", || brep::read_item(&cx, *id).map_err(KernelError::Failed)).unwrap()).collect();
|
||||
let solids: Vec<_> = items.iter().map(|id| solidcraft_step::topology::read_solid(&ex, *id, Units::default()).unwrap()).collect();
|
||||
let read = start.elapsed();
|
||||
let start = std::time::Instant::now();
|
||||
for solid in solids {
|
||||
std::hint::black_box(Body::new(solid.solid).unwrap().unmeshed_faces());
|
||||
std::hint::black_box(Body::from_record(solid.brep).unwrap().unmeshed_faces());
|
||||
}
|
||||
println!(
|
||||
"STEP_PHASE parse_ms={:.3} brep_ms={:.3} body_ms={:.3}",
|
||||
@@ -266,8 +265,8 @@ fn parallel_body_preparation_reports_errors_in_place_and_preserves_placement() {
|
||||
let body = cylinder(Vec3::new(2.0, 2.0, 0.0), Vec3::Z, 2.0, 4.0).unwrap();
|
||||
let ex = p21::parse(&step_export(&[&body], "test").unwrap()).unwrap();
|
||||
let solid = *ex.entities.iter().find(|(_, e)| e.name() == "MANIFOLD_SOLID_BREP").map(|(id, _)| id).unwrap();
|
||||
let units = DEFAULT_UNITS;
|
||||
let placement = mt::Matrix4::from_translation(mt::Vector3::new(10.0, 20.0, 30.0));
|
||||
let units = Units::default();
|
||||
let placement = translation([10.0, 20.0, 30.0]);
|
||||
// The second item is not in the file.
|
||||
let jobs: Vec<_> = [solid, 999_999_999, solid, solid]
|
||||
.into_iter()
|
||||
@@ -329,7 +328,7 @@ fn parallel_import_does_not_prepare_past_body_budget_with_duplicates_or_mapped_i
|
||||
reader.bodies = vec![seed.clone(); MAX_BODIES - 2];
|
||||
reader.node_items.insert(2000);
|
||||
let mut out = Vec::new();
|
||||
reader.rep_bodies(60, &mt::Matrix4::from_scale(1.0), &[], "Budget", None, &mut out, 0);
|
||||
reader.rep_bodies(60, &IDENTITY, &[], "Budget", None, &mut out, 0);
|
||||
assert!(reader.oriented.is_empty() && reader.unreadable.is_empty(), "the walk only plans the solids");
|
||||
while reader.convert_next(workers == 1) {}
|
||||
let out = reader.remap(&out);
|
||||
@@ -355,8 +354,8 @@ fn round_trip(b: &Body) -> StepImport {
|
||||
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);
|
||||
// Pieces of one analytic surface are written as one face (volume and area above check
|
||||
// that nothing was lost).
|
||||
// The file has no more faces than the body (volume and area above check that nothing was
|
||||
// lost).
|
||||
assert!(imp.bodies[0].file_faces <= b.face_count());
|
||||
assert!(imp.bodies[0].closed);
|
||||
imp
|
||||
@@ -383,11 +382,9 @@ fn round_trip_features() {
|
||||
round_trip(&donut[0]);
|
||||
}
|
||||
|
||||
/// Robustness seed 17: a thin three-quarter tube shelled with its bottom open. truck writes each
|
||||
/// wall as three quarter-turn B-spline pieces; the export merges them into one 270° face and must
|
||||
/// write that face's cylinder, not the first piece's quarter sheet. The seam direction came from
|
||||
/// the mean direction of the face's vertices, which is zero at 0°, 90°, 180° and 270°, so the
|
||||
/// merged face kept the quarter sheet and read back about 7% short with no warning.
|
||||
/// Robustness seed 17: a thin three-quarter tube shelled with its bottom open reads back whole
|
||||
/// (an export once wrote a 270° wall on a quarter of its surface, and the part read back about
|
||||
/// 7% short with no warning).
|
||||
#[test]
|
||||
fn shelled_three_quarter_tube_round_trips() {
|
||||
let rect =
|
||||
@@ -427,72 +424,6 @@ fn imported_bodies_take_features() {
|
||||
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";
|
||||
|
||||
/// Original fixtures: elementary basis surfaces plus offsets, with no external CAD files.
|
||||
fn offset_geometry(definition: &str, offset: &str, len: f64, sense: bool, points: &[mt::Point3]) -> crate::Result<mt::Surface> {
|
||||
let text = format!(
|
||||
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=AXIS2_PLACEMENT_3D('',#1,$,$);\n#3={definition};\n#4=OFFSET_SURFACE('',#3,{offset},.U.);\n{TAIL}"
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len, ang: 1.0, tol: 1e-6 };
|
||||
crate::guard("test offset surface", || geom::surface(&cx, 4, points, &[], sense, 0).map_err(KernelError::Failed))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_surface_analytic_geometry_and_orientation() {
|
||||
use mt::{InnerSpace, MetricSpace, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
|
||||
let angle: f64 = 0.4;
|
||||
let cases = [
|
||||
("PLANE('',#2)".to_string(), mt::Point3::new(2.0, 3.0, 1.0), mt::Vector3::unit_z()),
|
||||
("CYLINDRICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 2.0), mt::Vector3::unit_x()),
|
||||
("SPHERICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 0.0), mt::Vector3::unit_x()),
|
||||
("TOROIDAL_SURFACE('',#2,10.,2.)".to_string(), mt::Point3::new(13.0, 0.0, 0.0), mt::Vector3::unit_x()),
|
||||
(
|
||||
format!("CONICAL_SURFACE('',#2,5.,{angle})"),
|
||||
mt::Point3::new(5.0 + 1.0 / angle.cos() + 2.0 * angle.tan(), 0.0, 2.0),
|
||||
mt::Vector3::new(angle.cos(), 0.0, -angle.sin()),
|
||||
),
|
||||
];
|
||||
for (definition, point, normal) in cases {
|
||||
for sense in [true, false] {
|
||||
let extent = [point, point + mt::Vector3::unit_z()];
|
||||
let surface = offset_geometry(&definition, "1.", 1.0, sense, &extent).unwrap();
|
||||
let uv = surface.search_nearest_parameter(point, None, 100).unwrap();
|
||||
assert!(surface.subs(uv.0, uv.1).distance(point) < 1e-6, "{definition}");
|
||||
assert!(surface.normal(uv.0, uv.1).dot(normal) * if sense { 1.0 } else { -1.0 } > 0.99, "{definition}, sense {sense}");
|
||||
}
|
||||
}
|
||||
let p = mt::Point3::new(40.0, 0.0, 2.0);
|
||||
let surface = offset_geometry("CYLINDRICAL_SURFACE('',#2,5.)", "-1.", 10.0, true, &[p, p + mt::Vector3::unit_z()]).unwrap();
|
||||
let uv = surface.search_nearest_parameter(p, None, 100).unwrap();
|
||||
assert!(surface.subs(uv.0, uv.1).distance(p) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_surfaces_nested_and_hostile() {
|
||||
let points = [mt::Point3::new(0.0, 0.0, 0.0), mt::Point3::new(0.0, 0.0, 1.0)];
|
||||
for (basis, offset) in [
|
||||
("CYLINDRICAL_SURFACE('',#2,5.)", "-5."),
|
||||
("SPHERICAL_SURFACE('',#2,5.)", "-6."),
|
||||
("TOROIDAL_SURFACE('',#2,10.,2.)", "8."),
|
||||
("TOROIDAL_SURFACE('',#2,10.,2.)", "-2."),
|
||||
("CONICAL_SURFACE('',#2,5.,0.4)", "-10."),
|
||||
("PLANE('',#2)", "1.E100"),
|
||||
("PLANE('',#2)", "$"),
|
||||
] {
|
||||
assert!(offset_geometry(basis, offset, 1.0, true, &points).is_err(), "{basis}, {offset}");
|
||||
}
|
||||
let text = format!("{HEAD}#1=OFFSET_SURFACE('',#1,1.,.F.);{TAIL}");
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
assert!(geom::surface(&cx, 1, &points, &[], true, 0).unwrap_err().contains("nested too deeply"));
|
||||
let text = format!(
|
||||
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));#2=AXIS2_PLACEMENT_3D('',#1,$,$);#3=CYLINDRICAL_SURFACE('',#2,5.);#4=OFFSET_SURFACE('',#3,-6.,.F.);#5=OFFSET_SURFACE('',#4,6.,.F.);{TAIL}"
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
assert!(geom::surface(&cx, 5, &points, &[], true, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_surface_cube_import_is_closed_and_editable() {
|
||||
// Move each basis plane half a file unit inward; nested quarter-unit offsets
|
||||
@@ -743,9 +674,7 @@ fn instanced_assembly_of(parts: u64, n: u64) -> String {
|
||||
fn instanced_parts_are_oriented_once() {
|
||||
let n = 24;
|
||||
let t = instanced_assembly(n);
|
||||
crate::body::ORIENTED.with(|c| c.set(0));
|
||||
let imp = step_import_with_workers(&t, 1).unwrap();
|
||||
assert_eq!(crate::body::ORIENTED.with(|c| c.get()), 1, "one distinct part");
|
||||
assert_eq!(imp.bodies.len(), n as usize);
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
for (k, b) in imp.bodies.iter().enumerate() {
|
||||
@@ -776,9 +705,7 @@ fn distinct_parts_convert_together_across_instances() {
|
||||
let threaded = reading.finish().unwrap();
|
||||
assert_eq!(threaded.bodies.len(), n as usize);
|
||||
assert!(threaded.warnings.is_empty(), "{:?}", threaded.warnings);
|
||||
crate::body::ORIENTED.with(|c| c.set(0));
|
||||
let serial = step_import_with_workers(&t, 1).unwrap();
|
||||
assert_eq!(crate::body::ORIENTED.with(|c| c.get()), 2, "two parts");
|
||||
assert_same_import(&serial, &threaded);
|
||||
assert_eq!(threaded.bodies[1].name, "Cube1");
|
||||
assert_eq!(threaded.bodies[1].path, vec!["Assembly".to_string(), "Block1".to_string()]);
|
||||
@@ -1108,7 +1035,7 @@ fn hostile_boundaries_mesh_in_bounded_time() {
|
||||
|
||||
/// A cylinder written as a surface of revolution, oriented the ISO 10303-42 way (normal
|
||||
/// ∂S/∂angle × ∂S/∂profile, outward here, so `same_sense` .T.). Strict readers read such faces this
|
||||
/// way; reading them with truck's profile-first normal turned faces inside out.
|
||||
/// way; reading them with the profile's parameter first turned faces inside out.
|
||||
#[test]
|
||||
fn surface_of_revolution_sense_follows_iso() {
|
||||
let mut t = String::from(HEAD);
|
||||
@@ -1156,7 +1083,7 @@ fn horn_torus_written_as_one_spline_face() {
|
||||
}
|
||||
|
||||
/// Booleans and blends build the same body every time, so an export is byte-identical from
|
||||
/// run to run (truck used to emit a boolean's faces in hash order of their addresses). The
|
||||
/// run to run (an export once listed a boolean's faces in hash order of their addresses). The
|
||||
/// tee of oracle 35 with its rolling-ball fillet round the junction.
|
||||
#[test]
|
||||
fn tee_fillet_exports_byte_identical() {
|
||||
@@ -1313,8 +1240,7 @@ fn knot_multiplicities_are_checked_before_they_are_expanded() {
|
||||
};
|
||||
let read = |text: &str| {
|
||||
let ex = p21::parse(text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
geom::curve(&cx, 3, 0)
|
||||
solidcraft_step::read::Reader::new(&ex, Default::default()).curve(3).map(|_| ())
|
||||
};
|
||||
let error = read(&curve(1_000_000, 64)).unwrap_err();
|
||||
// This diagnostic comes from the check before expansion; wall-clock time depends on CI load.
|
||||
@@ -1559,46 +1485,6 @@ fn periodic_knot_tube(first: u64, x: f64, r: f64, h: f64) -> (String, u64) {
|
||||
(s, face)
|
||||
}
|
||||
|
||||
/// A B-spline whose end knots repeat fewer than degree + 1 times (an unclamped knot vector,
|
||||
/// as OpenCascade writes closed curves and surfaces) is defined only between knot `degree`
|
||||
/// and knot `len - degree - 1`. Read over the whole knot range, the circle and the tube went
|
||||
/// on past their domain, and the rational ones divided by zero at the outermost knots.
|
||||
#[test]
|
||||
fn periodic_knot_bsplines_are_read_on_their_domain() {
|
||||
use mt::{BoundedCurve, ParametricCurve, ParametricSurface};
|
||||
let (r, h) = (1.7, 1.5);
|
||||
let (tube, face) = periodic_knot_tube(100, 0.0, r, h);
|
||||
let (circle, row) = circle_net(10, 0.0, r, 0.0);
|
||||
let text = format!(
|
||||
"{HEAD}{tube}{circle}#30=(BOUNDED_CURVE()B_SPLINE_CURVE(2,({}),.CIRCULAR_ARC.,.T.,.F.)B_SPLINE_CURVE_WITH_KNOTS(({PERIODIC_MULTS}),({PERIODIC_KNOTS}),.UNSPECIFIED.)CURVE()GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_CURVE(({CIRCLE_WEIGHTS}))REPRESENTATION_ITEM(''));\n{TAIL}",
|
||||
row.join(",")
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let pi = std::f64::consts::PI;
|
||||
let on_tube = |p: mt::Point3| (p.x.hypot(p.y) - r).abs() < 1e-9 && p.z > -1e-9 && p.z < h + 1e-9;
|
||||
|
||||
let surface_id = ex.get(face).and_then(|e| e.params().get(2)).and_then(Param::as_ref_id).unwrap();
|
||||
let surface = geom::bspline_surface(&cx, ex.get(surface_id).unwrap()).unwrap();
|
||||
let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() else { panic!("unbounded surface") };
|
||||
assert!(u0.abs() < 1e-12 && (u1 - h / r).abs() < 1e-12, "u range {u0} .. {u1}");
|
||||
assert!((v0 + pi).abs() < 1e-9 && (v1 - pi).abs() < 1e-9, "v range {v0} .. {v1}");
|
||||
for i in 0..=8 {
|
||||
for j in 0..=16 {
|
||||
let (u, v) = (u0 + (u1 - u0) * i as f64 / 8.0, v0 + (v1 - v0) * j as f64 / 16.0);
|
||||
assert!(on_tube(surface.subs(u, v)), "S({u}, {v}) = {:?}", surface.subs(u, v));
|
||||
}
|
||||
}
|
||||
|
||||
let geom::CurveGeo::Nurbs(curve) = geom::curve(&cx, 30, 0).unwrap() else { panic!("not a rational B-spline") };
|
||||
let (t0, t1) = curve.range_tuple();
|
||||
assert!((t0 + pi).abs() < 1e-9 && (t1 - pi).abs() < 1e-9, "curve range {t0} .. {t1}");
|
||||
for j in 0..=16 {
|
||||
let t = t0 + (t1 - t0) * j as f64 / 16.0;
|
||||
assert!(on_tube(curve.subs(t)), "C({t}) = {:?}", curve.subs(t));
|
||||
}
|
||||
}
|
||||
|
||||
/// Two such tubes far apart in one body: the body's size sets a chord tolerance coarse beside
|
||||
/// the tubes, and meshing a face at it divided the surface's whole parameter box, whose
|
||||
/// corners lay outside the domain. The division never converged there (#1374's recursion),
|
||||
@@ -1824,80 +1710,13 @@ fn step_conformance_parts_read_back_their_exact_shape() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A schema 4 record as truck's adapter builds it: truck holds a plane by three points, the
|
||||
/// same plane within a rounding.
|
||||
fn truck_ready(record: &solidcraft_geom::brep::Brep) -> solidcraft_geom::brep::Brep {
|
||||
let mut record = record.clone();
|
||||
for f in &mut record.faces {
|
||||
if let solidcraft_geom::brep::Surface::Plane { origin, u_axis, v_axis } = f.surface {
|
||||
let at = |d: [f64; 3]| [origin[0] + d[0], origin[1] + d[1], origin[2] + d[2]];
|
||||
f.surface = solidcraft_geom::brep::Surface::PlaneThrough { origin, u_point: at(u_axis), v_point: at(v_axis) };
|
||||
}
|
||||
}
|
||||
record
|
||||
}
|
||||
|
||||
/// The volume of the one solid `solidcraft_step::topology` reads from `text` into schema 4,
|
||||
/// built back into a truck body: negative when its faces point into its material.
|
||||
/// measured as the body of that record.
|
||||
pub(super) fn schema_4_volume(text: &str) -> f64 {
|
||||
let ex = solidcraft_step::p21::parse(text).unwrap();
|
||||
let r = solidcraft_step::topology::read_solids(&ex);
|
||||
assert!(r.failures.is_empty() && r.solids.len() == 1, "{:?}", r.failures);
|
||||
let solid = crate::native::solid_of_record(truck_ready(&r.solids[0].brep)).unwrap();
|
||||
let sign = solid.boundaries().iter().map(crate::body::shell_volume).sum::<Option<f64>>().unwrap().signum();
|
||||
sign * measure(&crate::body::Body::new(solid).unwrap()).unwrap().volume
|
||||
}
|
||||
|
||||
/// Each STEP file in `SOLIDCRAFT_STEP_CORPUS`: bodies, faces, edges and volume as truck
|
||||
/// imports it and as `solidcraft_step::topology` reads it into schema 4 records (built back into
|
||||
/// truck solids for their volume, signed: negative means faces turned into the material).
|
||||
/// Counts may differ where truck splits faces (bands, balls) or edges and where schema 4 adds
|
||||
/// seams; volumes are coarse meshes' (1% of the size). This reports, it does not judge. Run
|
||||
/// with `--ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "needs SOLIDCRAFT_STEP_CORPUS"]
|
||||
fn corpus_truck_and_schema_4_counts() {
|
||||
let Ok(dir) = std::env::var("SOLIDCRAFT_STEP_CORPUS") else { return };
|
||||
let mut paths: Vec<_> = std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
let volume = |s: &mt::Solid| s.boundaries().iter().map(crate::body::shell_volume).sum::<Option<f64>>();
|
||||
for path in paths.iter().filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("step") || e.eq_ignore_ascii_case("stp"))) {
|
||||
let text = std::fs::read_to_string(path).unwrap_or_default();
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let truck = match step_import(&text) {
|
||||
Ok(imp) => {
|
||||
// A body per instance: count each STEP item once.
|
||||
let mut seen = HashSet::new();
|
||||
let once: Vec<&ImportedBody> = imp.bodies.iter().filter(|b| seen.insert(b.source_item)).collect();
|
||||
let (f, e) = once.iter().fold((0, 0), |(f, e), b| (f + b.body.face_count(), e + b.body.edge_count()));
|
||||
let v: f64 = once.iter().filter_map(|b| volume(&b.body.solid)).sum();
|
||||
format!("{} bodies, {f} faces, {e} edges, volume {v:.4}", once.len())
|
||||
}
|
||||
Err(e) => format!("failed: {e}"),
|
||||
};
|
||||
let ex = match solidcraft_step::p21::parse(&text) {
|
||||
Ok(ex) => ex,
|
||||
Err(e) => {
|
||||
println!("{name}: parse failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let r = solidcraft_step::topology::read_solids(&ex);
|
||||
let (f, e) = r.solids.iter().fold((0, 0), |(f, e), s| (f + s.brep.faces.len(), e + s.brep.edges.len()));
|
||||
let (mut v, mut unbuilt) = (0.0, Vec::new());
|
||||
for s in &r.solids {
|
||||
match crate::native::solid_of_record(truck_ready(&s.brep)) {
|
||||
Ok(solid) => v += volume(&solid).unwrap_or(f64::NAN),
|
||||
Err(err) => unbuilt.push(err.to_string()),
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{name}: truck {truck}; schema 4 {} solids ({} refused), {f} faces, {e} edges, volume {v:.4}{}",
|
||||
r.solids.len(),
|
||||
r.failures.len(),
|
||||
if unbuilt.is_empty() { String::new() } else { format!(" ({} not built by truck: {})", unbuilt.len(), unbuilt[0]) }
|
||||
);
|
||||
}
|
||||
measure(&Body::from_record(r.solids[0].brep.clone()).unwrap()).unwrap().volume
|
||||
}
|
||||
|
||||
/// The step-conformance parts read into SMLib (`SOLIDCRAFT_KERNEL=smlib`): the STEP reader's
|
||||
|
||||
+150
-1744
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user