Files
jyviko dc38349cbf Layer split stage 1: the B-rep record, native body file and Vec/Plane come from solidcore-record (#11496)
* Kernel: the B-rep record and the native body file come from solidcore-record

SolidCraft's portable B-rep record (`solidcraft_geom::brep`: brep.rs, topology, history,
envelope, part, curve2) and the native body file (`solidcraft_kernel::native`: the tagged binary
codec and the container) move to solidcore's `solidcore-record` crate: geometry as data, with
structural validation and no maths (solidcore's docs/layers.md, stage 1). Every caller uses
`solidcore_record::brep` and `solidcore_record::native`; the old copies are deleted.

What computes from coordinates stays with the kernel, in geom's `brep`:
- `brep::check`: the record's geometric checks (orthonormal frames, non-degenerate planes,
  unit revolution axes, non-singular placements). `check::validate` and
  `check::validate_built` are the record's validation and these together, and every caller of
  `Brep::validate` uses them, so what is accepted is unchanged;
- `brep::label`: a surface's natural ranges (a cone's apex), `curve_interval`,
  `edge_curve_interval` and `form_turns`;
- `brep::pcurve::eval`: a pcurve's point;
- `geometry::check`, `placement::moved` and `split_edges` become functions on the record.

The kernel's native module keeps the conversion between a body and its saved record
(metadata.rs) and checks the decoded record's geometry. `native/analytic.rs`, which no module
declared, is deleted.

solidcore-record is pinned to its rev on solidcore's `layer-record` branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT

* Geometry: Vec2, Vec3, Plane and the profile types come from solidcore-record

The plain data of vectors and points, sketch planes and planar profiles (`Seg2`, `Loop2`,
`Region2`) moves to `solidcore-record`, with its constants and component-wise operators only
(owner decision, solidcore layer split). Their geometry stays in geom as traits:
`Vec2Geom`, `Vec3Geom`, `PlaneGeom`, `Seg2Geom`, `Loop2Geom`, `Region2Geom`, `Seg2ArcFit`,
`Loop2ArcFit` and `Region2ArcFit`, in scope with `use solidcraft_geom::prelude::*`. Every caller
names the types from `solidcore_record`; geom no longer defines or re-exports them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT

* Kernel: pin solidcore-record to solidcore main f5e8e9a29

solidcore-record is merged on solidcore main (tangency-labs/solidcore#2519); its crate there is
identical to the rev pinned before but for its clippy configuration. The lock is regenerated
against the merged rev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvoxKMbMFSQU8tzRtoJhtT

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:04:26 -05:00

411 lines
20 KiB
Rust
Raw Permalink 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 solidcore_record::Vec3;
use solidcraft_geom::prelude::*;
use solidcraft_step::p21;
use solidcraft_step::write::real;
#[test]
fn islands_are_solids_of_their_own_not_voids() {
use solidcore_record::{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(solidcore_record::Vec3::ZERO, solidcore_record::Vec3::new(10.0, 20.0, 5.0)).unwrap();
let pin = cylinder(solidcore_record::Vec3::ZERO, solidcore_record::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(solidcore_record::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());
}
}