Files
solvecraft/crates/kernel/src/step_out.rs
T
jyviko 319c0bc9f7 Profiler: kernel operations and STEP/IGES import stages are spans
Booleans, blends, sweeps, lofts, shell/draft/offset, healing and sewing,
SMLib conversions, meshing and measuring; the import as parse, index,
per-solid read, placement and mesh check, convert batches and finish.
The existing per-step boolean/tessellate capture names its spans too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:40:49 -04:00

410 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! STEP (ISO 10303-21) export: AP242 product structure, assemblies, names and colours.
//!
//! Each body's record is written as it is, the exact B-rep, by
//! [`solidcraft_step::write::topology`]: its material regions as solids, its open shells as a
//! surface model. Around it this writes one header and context, a product per part with its
//! shape representation, named solids, surface and face colours, and assemblies as
//! next-assembly-usage occurrences placed by item-defined transformations (the structure the
//! CAx-IF recommends and our reader follows). Identical geometric entities (points, directions,
//! placements, curves, surfaces) are written once and referenced; ids are dense from `#1`.
use std::fmt::Write as _;
use solidcraft_step::p21::Param;
use solidcraft_step::write::{GeomWriter, Writer, string, topology};
use crate::body::Body;
use crate::{KernelError, Result};
/// A named, optionally coloured body to export (coordinates in its part's frame, mm).
pub struct ExportBody<'a> {
pub name: String,
pub body: &'a Body,
pub color: Option<[f32; 3]>,
}
/// A part or assembly: its own bodies and placed child products.
pub struct ExportProduct<'a> {
pub name: String,
pub bodies: Vec<ExportBody<'a>>,
/// (child product index, rigid placement in this product, column-major 4×4, occurrence name).
pub children: Vec<(usize, [[f64; 4]; 4], String)>,
}
/// Header fields.
#[derive(Clone, Debug, Default)]
pub struct StepHeader {
/// FILE_NAME name (the file's own name).
pub file_name: String,
/// FILE_NAME time stamp (ISO 8601), empty when unknown.
pub time_stamp: String,
pub author: String,
pub organization: String,
/// FILE_DESCRIPTION text; `SolidCraft model` when empty.
pub description: String,
}
/// The least distance uncertainty written (mm).
const MIN_UNCERTAINTY: f64 = 1e-6;
fn s(text: &str) -> Param {
Param::Str(text.to_string())
}
fn r(id: u64) -> Param {
Param::Ref(id)
}
fn refs(ids: &[u64]) -> Param {
Param::List(ids.iter().map(|i| r(*i)).collect())
}
/// The placement's origin, z and x axes; refused unless it is a rotation and a translation.
fn rigid_frame(m: &[[f64; 4]; 4]) -> Result<([f64; 3], [f64; 3], [f64; 3])> {
let x = [m[0][0], m[0][1], m[0][2]];
let y = [m[1][0], m[1][1], m[1][2]];
let z = [m[2][0], m[2][1], m[2][2]];
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let cross = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]];
let ok = (dot(x, x) - 1.0).abs() < 1e-6 && (dot(y, y) - 1.0).abs() < 1e-6 && dot(x, y).abs() < 1e-6 && dot(cross, z) > 0.999_999;
if !ok || m.iter().flatten().any(|v| !v.is_finite()) {
return Err(KernelError::Invalid("component placements must be rigid (rotation and translation) to export as STEP".into()));
}
Ok(([m[3][0], m[3][1], m[3][2]], z, x))
}
/// STEP text (AP242) for products with bodies and assembly placements; `root` is the top product.
pub fn step_export_products(products: &[ExportProduct], root: usize, header: &StepHeader) -> Result<String> {
let _span = solidcraft_profile::span("kernel", "step_export_products");
if products.is_empty() || root >= products.len() {
return Err(KernelError::Invalid("nothing to export".into()));
}
if products.iter().all(|p| p.bodies.is_empty()) {
return Err(KernelError::Invalid("nothing to export".into()));
}
for p in products {
for b in &p.bodies {
b.body.require_brep("STEP export")?;
}
if p.children.iter().any(|(c, _, _)| *c >= products.len()) {
return Err(KernelError::Invalid("bad component reference".into()));
}
}
// No product may contain itself.
fn cyclic(products: &[ExportProduct], p: usize, stack: &mut Vec<usize>) -> bool {
if stack.contains(&p) || stack.len() > 64 {
return true;
}
stack.push(p);
let bad = products.get(p).is_some_and(|x| x.children.iter().any(|(c, _, _)| cyclic(products, *c, stack)));
stack.pop();
bad
}
if cyclic(products, root, &mut Vec::new()) {
return Err(KernelError::Invalid("the component tree is recursive".into()));
}
// Products reachable from the root.
let mut reach = vec![false; products.len()];
let mut todo = vec![root];
while let Some(p) = todo.pop() {
if let Some(seen) = reach.get_mut(p)
&& !*seen
{
*seen = true;
if let Some(x) = products.get(p) {
todo.extend(x.children.iter().map(|(c, _, _)| *c));
}
}
}
let reached: Vec<(usize, &ExportProduct)> = products.iter().enumerate().filter(|(i, _)| reach.get(*i).copied().unwrap_or(false)).collect();
// The context states the models' accuracy: the loosest of their records' tolerances.
let uncertainty =
reached.iter().flat_map(|(_, p)| &p.bodies).map(|b| b.body.record().tolerance).filter(|t| t.is_finite()).fold(MIN_UNCERTAINTY, f64::max);
let mut w = Writer::new();
let app = w.add_distinct("APPLICATION_CONTEXT", &[s("managed model based 3d engineering")]);
w.add_distinct(
"APPLICATION_PROTOCOL_DEFINITION",
&[s("international standard"), s("ap242_managed_model_based_3d_engineering"), Param::Int(2014), r(app)],
);
let pctx = w.add_distinct("PRODUCT_CONTEXT", &[s(""), r(app), s("mechanical")]);
let pdctx = w.add_distinct("PRODUCT_DEFINITION_CONTEXT", &[s("part definition"), r(app), s("design")]);
let mm = w.add_complex(&[
("LENGTH_UNIT", vec![]),
("NAMED_UNIT", vec![Param::Derived]),
("SI_UNIT", vec![Param::Enum("MILLI".into()), Param::Enum("METRE".into())]),
]);
let rad = w.add_complex(&[
("NAMED_UNIT", vec![Param::Derived]),
("PLANE_ANGLE_UNIT", vec![]),
("SI_UNIT", vec![Param::Null, Param::Enum("RADIAN".into())]),
]);
let sr = w.add_complex(&[
("NAMED_UNIT", vec![Param::Derived]),
("SI_UNIT", vec![Param::Null, Param::Enum("STERADIAN".into())]),
("SOLID_ANGLE_UNIT", vec![]),
]);
let unc = w.add_distinct(
"UNCERTAINTY_MEASURE_WITH_UNIT",
&[Param::Typed("LENGTH_MEASURE".into(), vec![Param::Real(uncertainty)]), r(mm), s("distance_accuracy_value"), s("confusion accuracy")],
);
let ctx = w.add_complex(&[
("GEOMETRIC_REPRESENTATION_CONTEXT", vec![Param::Int(3)]),
("GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT", vec![refs(&[unc])]),
("GLOBAL_UNIT_ASSIGNED_CONTEXT", vec![refs(&[mm, rad, sr])]),
("REPRESENTATION_CONTEXT", vec![s("Context #1"), s("3D Context with UNIT and UNCERTAINTY")]),
]);
let origin = GeomWriter::new(&mut w).placement(&solidcraft_step::geom::Placement {
origin: [0.0; 3],
axis: [0.0, 0.0, 1.0],
ref_direction: [1.0, 0.0, 0.0],
});
// Placements of children inside each parent, made before the parent's representation.
let mut placements: Vec<Vec<u64>> = vec![Vec::new(); products.len()];
for &(pi, p) in &reached {
for (_, m, _) in &p.children {
let (t, z, x) = rigid_frame(m)?;
let mut g = GeomWriter::new(&mut w);
let (pt, dz, dx) = (g.point(t), g.direction(z), g.direction(x));
// Its own entity: it is an item of the parent's representation.
let a = w.add_distinct("AXIS2_PLACEMENT_3D", &[s(""), r(pt), r(dz), r(dx)]);
if let Some(v) = placements.get_mut(pi) {
v.push(a);
}
}
}
let mut pd = vec![0u64; products.len()];
let mut shape_rep = vec![0u64; products.len()];
let mut product_ids = Vec::new();
let mut styled = Vec::new();
for &(pi, p) in &reached {
let name = if p.name.trim().is_empty() { "Part".to_string() } else { p.name.clone() };
let prod = w.add_distinct("PRODUCT", &[s(&name), s(&name), s(""), refs(&[pctx])]);
product_ids.push(prod);
let pdf = w.add_distinct("PRODUCT_DEFINITION_FORMATION", &[s(""), s(""), r(prod)]);
let d = w.add_distinct("PRODUCT_DEFINITION", &[s("design"), s(""), r(pdf), r(pdctx)]);
let pds = w.add_distinct("PRODUCT_DEFINITION_SHAPE", &[s(""), s(""), r(d)]);
let mut items = vec![origin];
items.extend(placements.get(pi).into_iter().flatten().copied());
let srep = w.add_distinct("SHAPE_REPRESENTATION", &[s(&name), refs(&items), r(ctx)]);
w.add_distinct("SHAPE_DEFINITION_REPRESENTATION", &[r(pds), r(srep)]);
if let Some(slot) = pd.get_mut(pi) {
*slot = d;
}
if let Some(slot) = shape_rep.get_mut(pi) {
*slot = srep;
}
let (mut solids, mut sheets) = (Vec::new(), Vec::new());
for b in &p.bodies {
let written = topology::brep(&mut GeomWriter::new(&mut w), b.body.record(), &b.name)
.map_err(|e| KernelError::Failed(format!("STEP export of '{}': {e}", b.name)))?;
let opacity = b.body.paint().map_or(1.0, |p| p.opacity);
let items: Vec<u64> = written.solids.iter().copied().chain(written.sheets).collect();
if let Some(c) = b.color {
for item in &items {
styled.push(style(&mut w, *item, c, opacity));
}
}
// Faces with colours of their own.
for f in b.body.paint().map(|p| p.faces.as_slice()).unwrap_or(&[]) {
if let Some(&face) = written.faces.get(f.face).filter(|id| **id != 0) {
styled.push(style(&mut w, face, f.color, f.opacity));
}
}
solids.extend(written.solids);
sheets.extend(written.sheets);
}
for (kind, list) in [("ADVANCED_BREP_SHAPE_REPRESENTATION", solids), ("MANIFOLD_SURFACE_SHAPE_REPRESENTATION", sheets)] {
if list.is_empty() {
continue;
}
let rep = w.add_distinct(kind, &[s(&name), refs(&[list, vec![origin]].concat()), r(ctx)]);
w.add_distinct("SHAPE_REPRESENTATION_RELATIONSHIP", &[s(""), s(""), r(srep), r(rep)]);
}
}
if !product_ids.is_empty() {
w.add_distinct("PRODUCT_RELATED_PRODUCT_CATEGORY", &[s("part"), s(""), refs(&product_ids)]);
}
// Assembly occurrences.
for &(pi, p) in &reached {
for (k, (ci, _, oname)) in p.children.iter().enumerate() {
let (Some(&parent_pd), Some(&child_pd), Some(&parent_sr), Some(&child_sr), Some(&target)) =
(pd.get(pi), pd.get(*ci), shape_rep.get(pi), shape_rep.get(*ci), placements.get(pi).and_then(|v| v.get(k)))
else {
continue;
};
let nm = if oname.trim().is_empty() {
format!("{}:{}", products.get(*ci).map(|c| c.name.as_str()).unwrap_or(""), k + 1)
} else {
oname.clone()
};
let nauo = w.add_distinct("NEXT_ASSEMBLY_USAGE_OCCURRENCE", &[s(&nm), s(&nm), s(""), r(parent_pd), r(child_pd), Param::Null]);
let pds = w.add_distinct("PRODUCT_DEFINITION_SHAPE", &[s(""), s(""), r(nauo)]);
let idt = w.add_distinct("ITEM_DEFINED_TRANSFORMATION", &[s(""), s(""), r(origin), r(target)]);
let rr = w.add_complex(&[
("REPRESENTATION_RELATIONSHIP", vec![s(""), s(""), r(child_sr), r(parent_sr)]),
("REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION", vec![r(idt)]),
("SHAPE_REPRESENTATION_RELATIONSHIP", vec![]),
]);
w.add_distinct("CONTEXT_DEPENDENT_SHAPE_REPRESENTATION", &[r(rr), r(pds)]);
}
}
if !styled.is_empty() {
w.add_distinct("MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION", &[s(""), refs(&styled), r(ctx)]);
}
let mut text = String::new();
text.push_str("ISO-10303-21;\nHEADER;\n");
let description = if header.description.is_empty() { "SolidCraft model" } else { header.description.as_str() };
let _ = writeln!(text, "FILE_DESCRIPTION(({}),'2;1');", string(description));
let _ = writeln!(
text,
"FILE_NAME({},{},({}),({}),'SolidCraft','SolidCraft','');",
string(&header.file_name),
string(&header.time_stamp),
string(&header.author),
string(&header.organization)
);
text.push_str("FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 1 1 4 }'));\nENDSEC;\nDATA;\n");
text.push_str(&w.data());
text.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
Ok(text)
}
/// A surface colour for an item (STYLED_ITEM chain; the styles are shared).
fn style(w: &mut Writer, item: u64, c: [f32; 3], opacity: f32) -> u64 {
let rgb = w.add("COLOUR_RGB", &[s(""), Param::Real(c[0] as f64), Param::Real(c[1] as f64), Param::Real(c[2] as f64)]);
let fasc = w.add("FILL_AREA_STYLE_COLOUR", &[s(""), r(rgb)]);
let fas = w.add("FILL_AREA_STYLE", &[s(""), refs(&[fasc])]);
let ssfa = w.add("SURFACE_STYLE_FILL_AREA", &[r(fas)]);
// See-through looks add a transparency (1 = invisible) to the side style.
let sides = if opacity < 1.0 {
let tr = w.add("SURFACE_STYLE_TRANSPARENT", &[Param::Real((1.0 - opacity as f64).clamp(0.0, 1.0))]);
vec![ssfa, tr]
} else {
vec![ssfa]
};
let sss = w.add("SURFACE_SIDE_STYLE", &[s(""), refs(&sides)]);
let ssu = w.add("SURFACE_STYLE_USAGE", &[Param::Enum("BOTH".into()), r(sss)]);
let psa = w.add("PRESENTATION_STYLE_ASSIGNMENT", &[refs(&[ssu])]);
w.add_distinct("STYLED_ITEM", &[s("color"), refs(&[psa]), r(item)])
}
/// STEP text for bodies of one part (names `Body1`…).
pub fn step_export_bodies(bodies: &[ExportBody], header: &StepHeader) -> Result<String> {
let _span = solidcraft_profile::span("kernel", "step_export_bodies");
let name = if header.file_name.is_empty() {
"Part".to_string()
} else {
header.file_name.trim_end_matches(".step").trim_end_matches(".stp").to_string()
};
let p = ExportProduct {
name,
bodies: bodies.iter().map(|b| ExportBody { name: b.name.clone(), body: b.body, color: b.color }).collect(),
children: Vec::new(),
};
step_export_products(&[p], 0, header)
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_geom::Vec3;
use solidcraft_step::p21;
use solidcraft_step::write::real;
#[test]
fn islands_are_solids_of_their_own_not_voids() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let square = |a: f64, b: f64| Loop2::polygon(&[Vec2::new(a, a), Vec2::new(b, a), Vec2::new(b, b), Vec2::new(a, b)]);
// A plate with a square hole, and an island inside the hole (a letter's counter).
let plate = Region2 { outer: square(0.0, 30.0), holes: vec![square(10.0, 20.0).reversed()] };
let island = Region2 { outer: square(12.0, 18.0), holes: vec![] };
let body = crate::extrude_planar_regions(&Plane::XY, &[plate, island], 0.0, 2.0).unwrap();
assert_eq!(body.shell_count(), 2);
let text = crate::step_export(&[&body], "test").unwrap();
assert_eq!(text.matches("MANIFOLD_SOLID_BREP(").count(), 2, "{text}");
assert_eq!(text.matches("BREP_WITH_VOIDS").count(), 0);
let imp = crate::step_import(&text).unwrap();
let v: f64 = imp.bodies.iter().map(|b| crate::measure(&b.body).unwrap().volume).sum();
assert!((v - (800.0 + 36.0) * 2.0).abs() < 1e-6, "{v}");
// A real void stays a void.
let hollow = crate::boolean(
&crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(),
&crate::box_solid(Vec3::new(3.0, 3.0, 3.0), Vec3::new(7.0, 7.0, 7.0)).unwrap(),
crate::BoolOp::Cut,
)
.unwrap()
.unwrap();
assert_eq!(hollow.shell_count(), 2);
let text = crate::step_export(&[&hollow], "test").unwrap();
assert_eq!(text.matches("BREP_WITH_VOIDS").count(), 1, "{text}");
}
#[test]
fn numbers_and_strings() {
assert_eq!(real(1.0), "1.0");
assert_eq!(real(1e-6), "1.E-6");
assert_eq!(real(-2.5e20), "-2.5E20");
assert_eq!(real(3.0e100), "3.E100");
assert_eq!(string("it's"), "'it''s'");
assert_eq!(string("é"), "'\\X2\\00E9\\X0\\'");
let ex = p21::parse(&format!("ISO-10303-21;DATA;#1=A({},{});ENDSEC;END-ISO-10303-21;", string("a'b\\é"), real(1e-6))).unwrap();
let p = ex.get(1).unwrap().params();
assert_eq!(p[0].as_str(), Some("a'b\\é"));
assert_eq!(p[1].as_f64(), Some(1e-6));
// Names of several non-ASCII characters in a row read back whole (#2344).
for name in ["圆角", "凸台-拉伸7", "ネジ穴", "Gehäuse 😀"] {
let ex = p21::parse(&format!("ISO-10303-21;DATA;#1=A({});ENDSEC;END-ISO-10303-21;", string(name))).unwrap();
assert_eq!(ex.get(1).unwrap().params()[0].as_str(), Some(name));
}
}
#[test]
fn assembly_names_colours_round_trip() {
use crate::{box_solid, cylinder, measure, step_import, step_validate};
let bx = box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(10.0, 20.0, 5.0)).unwrap();
let pin = cylinder(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::Z, 2.0, 8.0).unwrap();
let rot90 = [[0.0, 1.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [50.0, 0.0, 0.0, 1.0]];
let shift = [[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, 5.0, 1.0]];
let products = vec![
ExportProduct {
name: "Root Ä".into(),
bodies: vec![ExportBody { name: "Base".into(), body: &bx, color: Some([1.0, 0.0, 0.0]) }],
children: vec![(1, shift, "Pin:1".into()), (1, rot90, "Pin:2".into())],
},
ExportProduct { name: "Pin".into(), bodies: vec![ExportBody { name: "Shaft".into(), body: &pin, color: None }], children: vec![] },
];
let text = step_export_products(&products, 0, &StepHeader { file_name: "a.step".into(), ..Default::default() }).unwrap();
step_validate(&text).unwrap();
assert!(text.contains("AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF"));
assert_eq!(text.matches("NEXT_ASSEMBLY_USAGE_OCCURRENCE").count(), 2);
// Shared geometry: the unit Z direction is written once.
assert_eq!(text.matches("DIRECTION('',(0.0,0.0,1.0))").count(), 1);
let imp = step_import(&text).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 3);
let names: Vec<&str> = imp.bodies.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, ["Base", "Shaft", "Shaft"]);
assert_eq!(imp.bodies[0].color, Some([1.0, 0.0, 0.0]));
assert_eq!(imp.tree[0].name, "Root Ä");
assert_eq!(imp.tree[0].children.len(), 2);
let m: Vec<_> = imp.bodies.iter().map(|b| measure(&b.body).unwrap()).collect();
assert!((m[1].bbox.min.z - 5.0).abs() < 1e-6 && (m[1].bbox.max.z - 13.0).abs() < 1e-6);
// Rotated 90° about Z and moved 50 along X: centre at (50, 0).
assert!(m[2].centroid.dist(solidcraft_geom::Vec3::new(50.0, 0.0, 4.0)) < 1e-3, "{:?}", m[2].centroid);
// Recursion and bad placements are errors.
let cyc = vec![ExportProduct {
name: "A".into(),
bodies: vec![ExportBody { name: "B".into(), body: &bx, color: None }],
children: vec![(0, shift, String::new())],
}];
assert!(step_export_products(&cyc, 0, &StepHeader::default()).is_err());
let scaled = [[2.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; 4]];
let bad = vec![
ExportProduct { name: "A".into(), bodies: vec![], children: vec![(1, scaled, String::new())] },
ExportProduct { name: "B".into(), bodies: vec![ExportBody { name: "B".into(), body: &bx, color: None }], children: vec![] },
];
assert!(step_export_products(&bad, 0, &StepHeader::default()).is_err());
}
}