Files
OneCAD/crates/kernel/src/step_in/tests.rs
T

1375 lines
74 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.
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use super::*;
use crate::{BoolOp, boolean, box_solid, cylinder, extrude, fillet, measure, revolve, sphere, step_export, torus};
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
#[test]
#[ignore = "manual STEP import timing; run without concurrent builds"]
fn benchmark_independent_step_solids() {
let count = solidcraft_geom::env::var("STEP_BENCH_COUNT").map(|s| s.parse::<usize>().unwrap().clamp(1, 512)).unwrap_or(48);
let kind = solidcraft_geom::env::var("STEP_BENCH_KIND").unwrap_or_else(|| "cylinder".into());
let bodies: Vec<_> = (0..count)
.map(|i| {
let at = Vec3::new((i % 8) as f64 * 60.0, (i / 8) as f64 * 60.0, 0.0);
match kind.as_str() {
"torus" => torus(at, 20.0, 4.0).unwrap(),
"box" => box_solid(at, at + Vec3::new(10.0, 15.0, 12.0)).unwrap(),
_ => cylinder(at, Vec3::Z, 5.0, 12.0).unwrap(),
}
})
.collect();
let text = step_export(&bodies.iter().collect::<Vec<_>>(), "independent bodies").unwrap();
if let Ok(path) = solidcraft_geom::env::var("STEP_BENCH_FIXTURE").ok_or(()) {
std::fs::write(path, &text).unwrap();
}
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 read = start.elapsed();
let start = std::time::Instant::now();
for solid in solids {
std::hint::black_box(Body::new(solid.solid).unwrap().unmeshed_faces());
}
println!(
"STEP_PHASE parse_ms={:.3} brep_ms={:.3} body_ms={:.3}",
parse.as_secs_f64() * 1000.0,
read.as_secs_f64() * 1000.0,
start.elapsed().as_secs_f64() * 1000.0
);
for run in 0..5 {
for workers in if run % 2 == 0 { [1, 8] } else { [8, 1] } {
let start = std::time::Instant::now();
let imported = step_import_with_workers(&text, workers).unwrap();
let elapsed = start.elapsed();
assert_eq!(imported.bodies.len(), count);
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
println!(
"STEP_BENCH kind={kind} run={run} workers={workers} bodies={count} bytes={} ms={:.3}",
text.len(),
elapsed.as_secs_f64() * 1000.0
);
std::hint::black_box(imported);
}
}
}
fn assert_same_import(serial: &StepImport, parallel: &StepImport) {
assert_eq!(serial.tree, parallel.tree);
assert_eq!(serial.warnings, parallel.warnings);
assert_eq!((&serial.schema, &serial.system), (&parallel.schema, &parallel.system));
assert_eq!(serial.bodies.len(), parallel.bodies.len());
assert_eq!(
step_export(&serial.bodies.iter().map(|b| &b.body).collect::<Vec<_>>(), "equivalence").unwrap(),
step_export(&parallel.bodies.iter().map(|b| &b.body).collect::<Vec<_>>(), "equivalence").unwrap()
);
for (a, b) in serial.bodies.iter().zip(&parallel.bodies) {
assert_eq!(
(&a.name, &a.path, a.source_item, &a.face_origin, a.closed, a.file_faces, a.color),
(&b.name, &b.path, b.source_item, &b.face_origin, b.closed, b.file_faces, b.color)
);
assert_eq!(a.body.paint(), b.body.paint());
assert_eq!(a.body.face_sources, b.body.face_sources);
assert_same_measure(&measure(&a.body).unwrap(), &measure(&b.body).unwrap());
let (a, b) = (a.body.display_mesh(0.05).unwrap(), b.body.display_mesh(0.05).unwrap());
assert_eq!(a.positions, b.positions);
assert_eq!(a.normals, b.normals);
assert_eq!(a.triangles, b.triangles);
assert_eq!(a.tri_face, b.tri_face);
assert_eq!(a.edges, b.edges);
assert_eq!(a.seams, b.seams);
assert_eq!(a.smooth_edges, b.smooth_edges);
assert_eq!(a.sharp_edges, b.sharp_edges);
assert_eq!(a.edge_faces, b.edge_faces);
}
}
fn assert_same_measure(a: &crate::BodyMeasure, b: &crate::BodyMeasure) {
assert_eq!((a.volume, a.area, a.centroid, a.bbox.min, a.bbox.max), (b.volume, b.area, b.centroid, b.bbox.min, b.bbox.max));
assert_eq!((a.faces, a.edges, a.vertices, a.shells, &a.merged), (b.faces, b.edges, b.vertices, b.shells, &b.merged));
}
#[test]
fn parallel_import_retains_units_order_sources_and_partial_failure_across_batches() {
let mut text = String::from(HEAD);
text += "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\n#9=APPLICATION_CONTEXT('');\n";
text += &cube_entities();
text += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))");
text += &product(50, "Batch");
let mut refs = Vec::new();
for i in 0..70 {
let id = 2000 + i;
text += &format!("#{id}=MANIFOLD_SOLID_BREP('Cube{i}',#100);\n");
refs.push(format!("#{id}"));
}
refs.insert(3, "#3000".into());
refs.insert(8, "#2000".into());
text += "#3000=MANIFOLD_SOLID_BREP('Broken',#999999);\n";
text += &format!("#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',({}),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n", refs.join(","));
text += TAIL;
let serial = step_import_with_workers(&text, 1).unwrap();
assert_eq!(serial.bodies.len(), 70);
assert!(serial.warnings.iter().any(|warning| warning.contains("Broken")));
assert_eq!(serial.bodies.first().unwrap().name, "Cube0");
assert_eq!(serial.bodies.last().unwrap().name, "Cube69");
assert!((measure(&serial.bodies[0].body).unwrap().volume - 1000.0).abs() < 1e-9);
for workers in [2, 3, 8, usize::MAX] {
assert_same_import(&serial, &step_import_with_workers(&text, workers).unwrap());
}
}
#[test]
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 solids = vec![body.solid.as_ref().clone(), mt::Solid::new(Vec::new()), body.solid.as_ref().clone(), body.solid.as_ref().clone()];
let placement = mt::Matrix4::from_translation(mt::Vector3::new(10.0, 20.0, 30.0));
let jobs: Vec<_> = solids.into_iter().map(|solid| parallel::Job { solid, check_mesh: true }).collect();
let serial = parallel::bodies(&jobs, &placement, 1);
let threaded = parallel::bodies(&jobs, &placement, 4);
assert_eq!(serial.len(), 4);
assert_eq!(threaded.len(), 4);
for (index, (a, b)) in serial.iter().zip(&threaded).enumerate() {
if index == 1 {
assert_eq!(a.as_ref().unwrap_err(), b.as_ref().unwrap_err());
} else {
assert_eq!(a.as_ref().unwrap().1, b.as_ref().unwrap().1);
let a = measure(&a.as_ref().unwrap().0).unwrap();
let b = measure(&b.as_ref().unwrap().0).unwrap();
assert_same_measure(&a, &b);
// Curved bounds come from the measurement tessellation, not analytic extrema.
assert!(a.bbox.min.dist(Vec3::new(10.0, 20.0, 30.0)) < 1e-3);
}
}
}
#[test]
fn parallel_curved_import_keeps_face_colours_and_exact_display_meshes() {
let bodies: Vec<_> = (0..8)
.map(|i| {
let body = cylinder(Vec3::new(i as f64 * 20.0, 0.0, 0.0), Vec3::Z, 5.0, 12.0).unwrap();
body.with_color(Some([0.2, 0.4, 0.6]))
.with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.1, 0.3], opacity: 0.5 }] }))
})
.collect();
let text = step_export(&bodies.iter().collect::<Vec<_>>(), "coloured cylinders").unwrap();
let serial = step_import_with_workers(&text, 1).unwrap();
assert_eq!(serial.bodies.len(), 8);
assert!(serial.bodies.iter().all(|b| b.color == Some([0.2, 0.4, 0.6])));
assert!(serial.bodies.iter().all(|b| b.body.paint().is_some_and(|p| !p.faces.is_empty())));
for workers in [2, 3, 8] {
assert_same_import(&serial, &step_import_with_workers(&text, workers).unwrap());
}
}
#[test]
fn parallel_import_does_not_prepare_past_body_budget_with_duplicates_or_mapped_items() {
let text = format!(
"{HEAD}{}#2000=MANIFOLD_SOLID_BREP('Duplicate',#100);\n#2001=MANIFOLD_SOLID_BREP('First',#100);\n#2002=MANIFOLD_SOLID_BREP('Second',#100);\n#2003=MANIFOLD_SOLID_BREP('Beyond budget',#999999);\n#3000=MAPPED_ITEM('',#999998,#999997);\n#60=SHAPE_REPRESENTATION('',(#2000,#2000,#2001,#3000,#2002,#2003),$);\n{TAIL}",
cube_entities()
);
let ex = std::sync::Arc::new(p21::parse(&text).unwrap());
let seed =
step_import_with_workers(&format!("{HEAD}{}#60=SHAPE_REPRESENTATION('',(#101),$);\n{TAIL}", cube_entities()), 1).unwrap().bodies.remove(0);
for workers in [1, 8] {
let mut reader = Reader::new(ex.clone(), workers);
reader.index();
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);
assert!(reader.solids.is_empty(), "the walk only plans the solids");
while reader.convert_next(workers == 1) {}
let out = reader.remap(&out);
assert_eq!(reader.bodies.len(), MAX_BODIES);
assert_eq!(out, vec![MAX_BODIES - 2, MAX_BODIES - 1]);
assert_eq!(reader.bodies.last().unwrap().name, "Second");
assert_eq!(reader.solids.len(), 2);
assert!(!reader.solids.contains_key(&2003));
assert!(!reader.used_items.contains(&3000));
assert_eq!(reader.warnings.len(), 1);
assert!(reader.warnings.keys().next().unwrap().contains("more than"));
}
}
/// Export → import → same volume, area and face count.
fn round_trip(b: &Body) -> StepImport {
let text = step_export(&[b], "test").unwrap();
step_validate(&text).unwrap();
assert_eq!(crate::step_orientation_errors(&text).unwrap(), Vec::<String>::new());
let imp = step_import(&text).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
let (m0, m1) = (measure(b).unwrap(), measure(&imp.bodies[0].body).unwrap());
assert!(rel(m1.volume, m0.volume) < 1e-3, "volume {} vs {}", m1.volume, m0.volume);
assert!(rel(m1.area, m0.area) < 1e-3, "area {} vs {}", m1.area, m0.area);
// Pieces of one analytic surface are written as one face (volume and area above check
// that nothing was lost).
assert!(imp.bodies[0].file_faces <= b.face_count());
assert!(imp.bodies[0].closed);
imp
}
#[test]
fn round_trip_primitives() {
round_trip(&box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap());
round_trip(&cylinder(Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.0, 1.0, 1.0), 5.0, 12.0).unwrap());
round_trip(&sphere(Vec3::new(1.0, 2.0, 3.0), 10.0).unwrap());
round_trip(&torus(Vec3::ZERO, 20.0, 4.0).unwrap());
}
#[test]
fn round_trip_features() {
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let pin = cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap();
let holed = boolean(&plate, &pin, BoolOp::Cut).unwrap().unwrap();
round_trip(&holed);
let edge = Vec3::new(20.0, 0.0, 10.0);
round_trip(&fillet(&plate, &[edge], 3.0).unwrap());
let ring = Region2 { outer: Loop2::circle(Vec2::new(10.0, 0.0), 3.0), holes: vec![] };
let donut = revolve(&Plane::XZ, &[ring], Vec2::ZERO, Vec2::Y, 1.5).unwrap();
round_trip(&donut[0]);
}
/// 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.
#[test]
fn shelled_three_quarter_tube_round_trips() {
let rect =
Region2 { outer: Loop2::polygon(&[Vec2::new(10.0, 0.0), Vec2::new(12.5, 0.0), Vec2::new(12.5, 27.5), Vec2::new(10.0, 27.5)]), holes: vec![] };
let tube = revolve(&Plane::XZ, &[rect], Vec2::ZERO, Vec2::Y, 1.5 * std::f64::consts::PI).unwrap().pop().unwrap();
let b = crate::shell(&tube, &[Vec3::new(-7.182422893157272, 7.182422893245109, 0.0)], 1.0).unwrap();
round_trip(&b);
}
#[test]
fn several_bodies_and_names() {
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).unwrap();
let text = step_export(&[&a, &b], "test").unwrap();
let imp = step_import(&text).unwrap();
assert_eq!(imp.bodies.len(), 2);
assert!(imp.bodies.iter().all(|b| !b.name.is_empty()));
let vol: f64 = imp.bodies.iter().map(|b| measure(&b.body).unwrap().volume).sum();
assert!(rel(vol, 2000.0) < 1e-6);
}
#[test]
fn imported_bodies_take_features() {
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
let imp = step_import(&step_export(&[&plate], "t").unwrap()).unwrap();
let body = &imp.bodies[0].body;
// Fillet an edge, cut a hole through it.
let rounded = fillet(body, &[Vec3::new(20.0, 0.0, 10.0)], 2.0).unwrap();
let v_fillet = 40.0 * (4.0 - std::f64::consts::PI);
assert!(rel(measure(&rounded).unwrap().volume, 12000.0 - v_fillet) < 1e-3);
let pin = cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).unwrap();
let holed = boolean(&rounded, &pin, BoolOp::Cut).unwrap().unwrap();
let want = 12000.0 - v_fillet - std::f64::consts::PI * 25.0 * 10.0;
assert!(rel(measure(&holed).unwrap().volume, want) < 1e-3);
}
const HEAD: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('t','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n";
const TAIL: &str = "ENDSEC;\nEND-ISO-10303-21;\n";
/// 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
// bring its supporting surface back to the original unit cube boundary.
let mut entities = cube_entities();
let origins = [(0.0, 0.0, 0.5), (0.0, 0.0, 0.5), (0.0, 0.5, 0.0), (0.5, 0.0, 0.0), (1.0, 0.5, 0.0), (0.5, 1.0, 0.0)];
for (f, (x, y, z)) in origins.into_iter().enumerate() {
let base = 400 + f * 20;
let extra = 1000 + f * 10;
entities = entities
.replace(&format!("#{}=PLANE('',#{});", base + 10, base + 9), &format!("#{}=OFFSET_SURFACE('',#{},0.25,.F.);", base + 10, extra + 3));
entities += &format!(
"#{extra}=CARTESIAN_POINT('',({x},{y},{z}));\n#{}=AXIS2_PLACEMENT_3D('',#{extra},#{},$);\n#{}=PLANE('',#{});\n#{}=OFFSET_SURFACE('',#{},0.25,.F.);\n",
extra + 1,
base + 8,
extra + 2,
extra + 1,
extra + 3,
extra + 2
);
}
entities += &context(10, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))");
entities += "#9=ADVANCED_BREP_SHAPE_REPRESENTATION('Offsets',(#101),#10);\n";
let import = step_import(&format!("{HEAD}{entities}{TAIL}")).unwrap();
assert!(import.warnings.is_empty(), "{:?}", import.warnings);
assert!(import.bodies[0].closed);
assert_eq!(import.bodies[0].file_faces, 6);
let body = &import.bodies[0].body;
assert!(rel(measure(body).unwrap().volume, 1000.0) < 1e-6);
let cut = cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap();
let result = boolean(body, &cut, BoolOp::Cut).unwrap().unwrap();
assert!(rel(measure(&result).unwrap().volume, 1000.0 - 40.0 * std::f64::consts::PI) < 1e-3);
}
/// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items.
fn cube_entities() -> String {
let mut s = String::new();
let pts = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0), (0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)];
for (i, (x, y, z)) in pts.iter().enumerate() {
s += &format!("#{}=CARTESIAN_POINT('',({x}.,{y}.,{z}.));\n#{}=VERTEX_POINT('',#{});\n", 200 + i, 210 + i, 200 + i);
}
// 12 edges as lines (direction vector filler; trimmed by vertices).
let edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)];
s += "#290=DIRECTION('',(1.,0.,0.));\n#291=VECTOR('',#290,1.);\n";
for (k, (a, b)) in edges.iter().enumerate() {
s += &format!("#{}=LINE('',#{},#291);\n#{}=EDGE_CURVE('',#{},#{},#{},.T.);\n", 300 + k, 200 + a, 320 + k, 210 + a, 210 + b, 300 + k);
}
// Faces: (edge, forward?) loops counter-clockwise seen from outside, and plane frames.
let faces: [(&[(usize, bool)], (i32, i32, i32), (i32, i32, i32)); 6] = [
(&[(3, false), (2, false), (1, false), (0, false)], (0, 0, 0), (0, 0, -1)),
(&[(4, true), (5, true), (6, true), (7, true)], (0, 0, 1), (0, 0, 1)),
(&[(0, true), (9, true), (4, false), (8, false)], (0, 0, 0), (0, -1, 0)),
(&[(1, true), (10, true), (5, false), (9, false)], (1, 0, 0), (1, 0, 0)),
(&[(2, true), (11, true), (6, false), (10, false)], (1, 1, 0), (0, 1, 0)),
(&[(3, true), (8, true), (7, false), (11, false)], (0, 1, 0), (-1, 0, 0)),
];
let mut face_ids = Vec::new();
for (f, (lp, o, n)) in faces.iter().enumerate() {
let base = 400 + f * 20;
let oes: Vec<String> = lp
.iter()
.enumerate()
.map(|(j, (e, fwd))| {
s += &format!("#{}=ORIENTED_EDGE('',*,*,#{},{});\n", base + j, 320 + e, if *fwd { ".T." } else { ".F." });
format!("#{}", base + j)
})
.collect();
s += &format!("#{}=EDGE_LOOP('',({}));\n#{}=FACE_OUTER_BOUND('',#{},.T.);\n", base + 5, oes.join(","), base + 6, base + 5);
s += &format!("#{}=CARTESIAN_POINT('',({}.,{}.,{}.));\n#{}=DIRECTION('',({}.,{}.,{}.));\n", base + 7, o.0, o.1, o.2, base + 8, n.0, n.1, n.2);
s += &format!("#{}=AXIS2_PLACEMENT_3D('',#{},#{},$);\n#{}=PLANE('',#{});\n", base + 9, base + 7, base + 8, base + 10, base + 9);
s += &format!("#{}=ADVANCED_FACE('',(#{}),#{},.T.);\n", base + 11, base + 6, base + 10);
face_ids.push(format!("#{}", base + 11));
}
s += &format!("#100=CLOSED_SHELL('',({}));\n#101=MANIFOLD_SOLID_BREP('Cube',#100);\n", face_ids.join(","));
s
}
fn context(id: u64, unit: &str) -> String {
format!(
"#{id}=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{},#{}))REPRESENTATION_CONTEXT('',''));\n#{}=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#{},'','');\n#{}={unit};\n#{}=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));\n",
id + 1,
id + 2,
id + 3,
id + 1,
id + 2,
id + 2,
id + 3
)
}
fn product(pd: u64, name: &str) -> String {
format!(
"#{}=PRODUCT('{name}','{name}','',(#{}));\n#{}=PRODUCT_CONTEXT('',#9,'mechanical');\n#{}=PRODUCT_DEFINITION_FORMATION('','',#{});\n#{pd}=PRODUCT_DEFINITION('design','',#{},#8);\n#{}=PRODUCT_DEFINITION_SHAPE('','',#{pd});\n",
pd + 1,
pd + 2,
pd + 2,
pd + 3,
pd + 1,
pd + 3,
pd + 4
)
}
#[test]
fn hand_written_part_in_inches_with_colour() {
let mut t = String::from(HEAD);
t += "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\n#9=APPLICATION_CONTEXT('');\n";
t += &cube_entities();
t += &context(
20,
"(CONVERSION_BASED_UNIT('INCH',#30)LENGTH_UNIT()NAMED_UNIT(*));\n#30=LENGTH_MEASURE_WITH_UNIT(LENGTH_MEASURE(25.4),#31);\n#31=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))",
);
t += &product(50, "Widget");
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#62);\n#62=SHAPE_REPRESENTATION('',(),#20);\n#63=SHAPE_REPRESENTATION_RELATIONSHIP('','',#62,#60);\n";
t += "#70=STYLED_ITEM('',(#71),#101);\n#71=PRESENTATION_STYLE_ASSIGNMENT((#72));\n#72=SURFACE_STYLE_USAGE(.BOTH.,#73);\n#73=SURFACE_SIDE_STYLE('',(#74));\n#74=SURFACE_STYLE_FILL_AREA(#75);\n#75=FILL_AREA_STYLE('',(#76));\n#76=FILL_AREA_STYLE_COLOUR('',#77);\n#77=COLOUR_RGB('',1.,0.5,0.);\n";
t += TAIL;
let imp = step_import(&t).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 1);
let b = &imp.bodies[0];
assert_eq!(b.name, "Cube");
assert_eq!(b.path, vec!["Widget".to_string()]);
assert_eq!(b.instance, None, "a root part is no occurrence");
assert_eq!(b.color, Some([1.0, 0.5, 0.0]));
let m = measure(&b.body).unwrap();
assert!(rel(m.volume, 25.4f64.powi(3)) < 1e-9, "{}", m.volume);
assert_eq!(m.faces, 6);
assert_eq!(imp.tree.len(), 1);
assert_eq!(imp.tree[0].name, "Widget");
}
/// A cube part (in cm) placed twice in a millimetre assembly: the file asks one brep item for
/// two bodies.
fn two_instance_assembly() -> String {
let mut t = String::from(HEAD);
t += "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\n#9=APPLICATION_CONTEXT('');\n";
t += &cube_entities();
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))");
t += &context(30, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
// Part (cube, in cm) and assembly (mm) with two placed instances of it.
t += &product(50, "Block");
t += &product(70, "Assembly");
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101,#65),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
t += "#65=AXIS2_PLACEMENT_3D('',#66,$,$);\n#66=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += "#80=SHAPE_REPRESENTATION('',(#81,#84),#30);\n#81=AXIS2_PLACEMENT_3D('',#82,$,$);\n#82=CARTESIAN_POINT('',(0.,0.,0.));\n";
t += "#84=AXIS2_PLACEMENT_3D('',#85,#86,#87);\n#85=CARTESIAN_POINT('',(100.,0.,0.));\n#86=DIRECTION('',(0.,0.,1.));\n#87=DIRECTION('',(0.,1.,0.));\n";
t += "#88=SHAPE_DEFINITION_REPRESENTATION(#74,#80);\n";
for (k, item) in [(0u64, 81u64), (1, 84)] {
let n = 600 + k * 10;
t += &format!("#{n}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('{k}','Block:{k}','',#70,#50,$);\n");
t += &format!("#{}=PRODUCT_DEFINITION_SHAPE('','',#{n});\n", n + 1);
t += &format!("#{}=ITEM_DEFINED_TRANSFORMATION('','',#65,#{item});\n", n + 2);
t += &format!(
"#{}=(REPRESENTATION_RELATIONSHIP('','',#60,#80)REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{})SHAPE_REPRESENTATION_RELATIONSHIP());\n",
n + 3,
n + 2
);
t += &format!("#{}=CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{},#{});\n", n + 4, n + 3, n + 1);
}
t += TAIL;
t
}
#[test]
fn hand_written_assembly() {
let t = two_instance_assembly();
let imp = step_import(&t).unwrap();
assert_same_import(&step_import_with_workers(&t, 1).unwrap(), &step_import_with_workers(&t, 8).unwrap());
let expanded = t.replace("(#101,#65)", "(#101,#2000,#2001,#2002,#2003,#65)");
let extra: String = (0..4).map(|i| format!("#{}=MANIFOLD_SOLID_BREP('Extra{i}',#100);\n", 2000 + i)).collect();
let expanded = expanded.replace(TAIL, &(extra + TAIL));
let serial = step_import_with_workers(&expanded, 1).unwrap();
assert_eq!(serial.bodies.len(), 10);
assert_same_import(&serial, &step_import_with_workers(&expanded, 8).unwrap());
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 2);
let boxes: Vec<_> = imp.bodies.iter().map(|b| measure(&b.body).unwrap()).collect();
for m in &boxes {
assert!(rel(m.volume, 1000.0) < 1e-9, "{}", m.volume);
}
// First at the origin, second moved 100 mm along X and turned 90° about Z.
assert!(boxes[0].bbox.min.dist(Vec3::ZERO) < 1e-6 && boxes[0].bbox.max.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-6);
assert!(boxes[1].bbox.min.dist(Vec3::new(90.0, 0.0, 0.0)) < 1e-6, "{:?}", boxes[1].bbox);
assert!(boxes[1].bbox.max.dist(Vec3::new(100.0, 10.0, 10.0)) < 1e-6, "{:?}", boxes[1].bbox);
assert_eq!(imp.tree.len(), 1);
let root = &imp.tree[0];
assert_eq!(root.name, "Assembly");
assert_eq!(root.children.len(), 2);
assert!(root.children.iter().all(|c| c.name == "Block" && c.bodies.len() == 1));
assert_eq!(imp.bodies[1].path, vec!["Assembly".to_string(), "Block".to_string()]);
assert_eq!([imp.bodies[0].instance.as_deref(), imp.bodies[1].instance.as_deref()], [Some("Block:0"), Some("Block:1")]);
assert!((root.children[1].transform[3][0] - 100.0).abs() < 1e-9);
}
/// A part an assembly places twice is meshed once: the second instance shows the first one's
/// display mesh, placed (the same triangles and faces, at its own place), and keeps its own
/// placed geometry for everything else.
#[test]
fn later_instances_show_the_first_instances_mesh_placed() {
let imp = step_import(&two_instance_assembly()).unwrap();
let (first, second) = (&imp.bodies[0].body, &imp.bodies[1].body);
assert!(first.display_of.is_none() && second.display_of.is_some());
let a = first.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap();
let b = second.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap();
assert_eq!(a.triangles, b.triangles);
assert_eq!(a.tri_face, b.tri_face);
assert_eq!(a.edges.len(), b.edges.len());
// Turned 90 degrees about Z and moved 100 mm along X.
for (p, q) in a.positions.iter().zip(&b.positions) {
assert!(q.dist(Vec3::new(100.0 - p.y, p.x, p.z)) < 1e-9, "{p:?} -> {q:?}");
}
let own = measure(second).unwrap().bbox;
let shown = b.bounds();
assert!(shown.min.dist(own.min) < 1e-6 && shown.max.dist(own.max) < 1e-6, "{shown:?} vs {own:?}");
// Anything that changes what the body shows drops the shared mesh.
let split = second.clone().with_split_lines(Some(std::sync::Arc::new(vec![vec![Vec3::new(91.0, 0.0, 5.0), Vec3::new(91.0, 10.0, 5.0)]])));
assert!(split.display_of.is_none());
}
/// A body knows the product that holds it and the assembly occurrence it is, next to the name of
/// its solid: a caller can name bodies after the part or the instance when the solids carry the
/// name of the last feature that made them (`Cut-Extrude3`), which says nothing about the part.
#[test]
fn bodies_know_their_product_and_instance() {
use crate::step_out::{ExportBody, ExportProduct, StepHeader, step_export_products};
let plate = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 2.0)).unwrap();
let post = box_solid(Vec3::ZERO, Vec3::new(2.0, 2.0, 8.0)).unwrap();
let at = |x: f64| [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [x, 0.0, 0.0, 1.0]];
let part = |name: &str, label: &str, body| ExportProduct {
name: name.into(),
bodies: vec![ExportBody { name: label.into(), body, color: None }],
children: Vec::new(),
};
let products = vec![
ExportProduct {
name: "Frame".into(),
bodies: Vec::new(),
children: vec![(1, at(0.0), "Plate-1".into()), (2, at(20.0), "Post-1".into()), (2, at(30.0), "Post-2".into())],
},
part("Plate", "Cut-Extrude3", &plate),
part("Post", "Boss-Extrude1", &post),
];
let text = step_export_products(&products, 0, &StepHeader::default()).unwrap();
let imp = step_import(&text).unwrap();
let seen: Vec<(&str, Vec<&str>, Option<&str>)> =
imp.bodies.iter().map(|b| (b.name.as_str(), b.path.iter().map(String::as_str).collect(), b.instance.as_deref())).collect();
assert_eq!(
seen,
[
("Cut-Extrude3", vec!["Frame", "Plate"], Some("Plate-1")),
("Boss-Extrude1", vec!["Frame", "Post"], Some("Post-1")),
("Boss-Extrude1", vec!["Frame", "Post"], Some("Post-2")),
]
);
// The same product's two placements are two occurrences, told apart by their names.
assert_eq!(imp.tree[0].children.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Plate", "Post", "Post"]);
}
/// The import keeps a conversion only where the file can ask for the item again. A flat file of
/// separate solids converts each item exactly once (no second copy of the whole import is held);
/// an assembly that instances one item keeps it, and each instance still gets geometry of its
/// own. Both files import to the same bodies either way.
#[test]
fn repeated_items_are_shared_and_one_use_items_are_not() {
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).unwrap();
let flat = step_export(&[&a, &b], "test").unwrap();
let ex = std::sync::Arc::new(p21::parse(&flat).unwrap());
let mut rd = Reader::new(ex.clone(), 1);
rd.index();
assert_eq!(rd.once.len(), 2, "each solid of a flat file is converted once");
let imp = step_import(&flat).unwrap();
assert_eq!(imp.bodies.len(), 2);
assert!(imp.bodies.iter().all(|b| b.closed));
let vol: f64 = imp.bodies.iter().map(|b| measure(&b.body).unwrap().volume).sum();
assert!(rel(vol, 2000.0) < 1e-6, "{vol}");
let assembly = two_instance_assembly();
let ex = std::sync::Arc::new(p21::parse(&assembly).unwrap());
let mut rd = Reader::new(ex.clone(), 1);
rd.index();
assert!(rd.once.is_empty(), "an assembly may convert an item again for the second instance");
let imp = step_import(&assembly).unwrap();
assert_eq!(imp.bodies.len(), 2);
assert!(imp.bodies.iter().all(|b| b.name == "Cube" && b.closed));
let boxes: Vec<_> = imp.bodies.iter().map(|b| measure(&b.body).unwrap()).collect();
for m in &boxes {
assert!(rel(m.volume, 1000.0) < 1e-9, "{}", m.volume);
}
// Each instance owns its placed geometry: the two bodies are not one shared solid.
assert!(!imp.bodies[0].body.same(&imp.bodies[1].body));
// The two instances sit at their own placements, so the second conversion was not skipped.
assert!(boxes[1].bbox.min.dist(Vec3::new(90.0, 0.0, 0.0)) < 1e-6, "{:?}", boxes[1].bbox);
}
#[test]
fn unsupported_face_is_left_out_and_reported() {
let mut t = String::from(HEAD);
let cube = cube_entities().replace("#410=PLANE('',#409);", "#410=OFFSET_SURFACE('',#409,1.,.F.);");
t += &cube;
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101),#20);\n";
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
t += TAIL;
let imp = step_import(&t).unwrap();
assert_eq!(imp.bodies.len(), 1);
assert!(!imp.bodies[0].closed);
assert_eq!(imp.bodies[0].body.face_count(), 5);
assert!(imp.warnings.iter().any(|w| w.contains("OFFSET_SURFACE")), "{:?}", imp.warnings);
assert!(imp.warnings.iter().any(|w| w.contains("open body")), "{:?}", imp.warnings);
}
#[test]
fn hostile_files_never_panic() {
let good = step_export(&[&box_solid(Vec3::ZERO, Vec3::new(5.0, 5.0, 5.0)).unwrap()], "t").unwrap();
let mut cases: Vec<String> = vec![
String::new(),
"ISO-10303-21;".into(),
format!("{HEAD}{TAIL}"),
format!("{HEAD}#1=MANIFOLD_SOLID_BREP('',#1);\n#2=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#1),#3);\n{TAIL}"),
format!(
"{HEAD}#1=MANIFOLD_SOLID_BREP('',#2);\n#2=CLOSED_SHELL('',(#3));\n#3=ADVANCED_FACE('',(#4),#5,.T.);\n#4=FACE_BOUND('',#6,.T.);\n#6=EDGE_LOOP('',(#7));\n#7=ORIENTED_EDGE('',*,*,#7,.T.);\n#5=PLANE('',#5);\n#9=SHAPE_REPRESENTATION('',(#1),#10);\n{TAIL}"
),
format!(
"{HEAD}#1=CARTESIAN_POINT('',(1.E308,1.E308,1.E308,1.,2.));\n#2=MANIFOLD_SOLID_BREP('',#1);\n#3=SHAPE_REPRESENTATION('',(#2),$);\n{TAIL}"
),
format!(
"{HEAD}#1=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#2,#2,$);\n#2=PRODUCT_DEFINITION('','',#3,#3);\n#3=PRODUCT_DEFINITION_FORMATION('','',#3);\n{TAIL}"
),
];
// Truncations and byte flips of a real file.
for cut in (0..good.len()).step_by(97) {
cases.push(good.get(..cut).unwrap_or("").to_string());
}
for (i, from, to) in [(0, "1.0", "-1.0"), (1, "#1", "#9999"), (2, ".T.", ".F."), (3, "(0.0", "(nan"), (4, "B_SPLINE", "X_SPLINE")] {
let _ = i;
cases.push(good.replacen(from, to, 3));
}
cases.push(good.replace("CLOSED_SHELL", "OPEN_SHELL"));
for c in &cases {
let r = std::panic::catch_unwind(|| step_import(c));
assert!(r.is_ok(), "panicked on {:?}", c.get(..c.len().min(200)));
}
}
#[test]
fn assembly_cycles_are_bounded() {
let mut t = String::from(HEAD);
t += "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\n#9=APPLICATION_CONTEXT('');\n";
t += &cube_entities();
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
t += &product(50, "A");
t += &product(70, "B");
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#54,#60);\n";
// A contains B contains A.
t += "#90=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#50,#70,$);\n#91=NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','','',#70,#50,$);\n";
t += TAIL;
let r = step_import(&t);
// Every product is somebody's component: the solid is still imported (outside the tree).
let imp = r.unwrap();
assert_eq!(imp.bodies.len(), 1);
}
#[test]
fn exponential_assemblies_are_bounded() {
// P0 uses P1 twice, P1 uses P2 twice, … 30 levels: 2^30 occurrences if followed blindly.
let mut t = String::from(HEAD);
t += "#8=PRODUCT_DEFINITION_CONTEXT('part definition',#9,'design');\n#9=APPLICATION_CONTEXT('');\n";
t += &cube_entities();
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
let levels = 30u64;
for l in 0..=levels {
t += &product(1000 + l * 10, &format!("P{l}"));
if l < levels {
for k in 0..2 {
t += &format!("#{}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('','','',#{},#{},$);\n", 2000 + l * 10 + k, 1000 + l * 10, 1000 + (l + 1) * 10);
}
}
}
t += &format!("#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101),#20);\n#61=SHAPE_DEFINITION_REPRESENTATION(#{},#60);\n", 1000 + levels * 10 + 4);
t += TAIL;
let imp = step_import(&t).unwrap();
assert!(!imp.bodies.is_empty() && imp.bodies.len() <= MAX_BODIES);
assert!(imp.warnings.iter().any(|w| w.contains("left out")), "{:?}", imp.warnings);
}
#[test]
fn validator_catches_broken_files() {
assert!(step_validate(&format!("{HEAD}#1=SHAPE_DEFINITION_REPRESENTATION(#2);\n#2=B();\n{TAIL}")).is_ok());
assert!(
step_validate(&format!("{HEAD}#1=SHAPE_DEFINITION_REPRESENTATION(#2);\n#2=B();\n#3=C();\n{TAIL}")).unwrap_err().contains("not referenced")
);
assert!(step_validate(&format!("{HEAD}#1=SHAPE_DEFINITION_REPRESENTATION(#2);\n#2=B();\n#2=C();\n{TAIL}")).unwrap_err().contains("duplicate"));
assert!(step_validate(&format!("{HEAD}#1=A((#7));\n{TAIL}")).unwrap_err().contains("undefined"));
assert!(step_validate(&format!("{HEAD}#1=SHAPE_DEFINITION_REPRESENTATION(#5);\n#5=B();\n{TAIL}")).unwrap_err().contains("dense"));
}
#[test]
fn split_band_edges_are_seams() {
// A quarter pipe (torus R 10, r 3) whose face wraps around the tube with a seam edge: it is
// split for the kernel, and the split edge is drawn as a seam.
let mut t = String::from(HEAD);
t += "#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=DIRECTION('',(0.,0.,1.));\n#3=DIRECTION('',(1.,0.,0.));\n#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n";
t += "#5=TOROIDAL_SURFACE('',#4,10.,3.);\n";
t += "#10=CARTESIAN_POINT('',(13.,0.,0.));\n#11=CARTESIAN_POINT('',(0.,13.,0.));\n#12=VERTEX_POINT('',#10);\n#13=VERTEX_POINT('',#11);\n";
// Tube circles at azimuth 0 (plane XZ) and 90 degrees (plane YZ), the seam around Z.
t += "#20=CARTESIAN_POINT('',(10.,0.,0.));\n#21=DIRECTION('',(0.,-1.,0.));\n#22=AXIS2_PLACEMENT_3D('',#20,#21,#3);\n#23=CIRCLE('',#22,3.);\n";
t += "#24=CARTESIAN_POINT('',(0.,10.,0.));\n#25=DIRECTION('',(1.,0.,0.));\n#26=DIRECTION('',(0.,1.,0.));\n#27=AXIS2_PLACEMENT_3D('',#24,#25,#26);\n#28=CIRCLE('',#27,3.);\n";
t += "#29=CIRCLE('',#4,13.);\n";
t += "#30=EDGE_CURVE('',#12,#12,#23,.T.);\n#31=EDGE_CURVE('',#13,#13,#28,.T.);\n#32=EDGE_CURVE('',#12,#13,#29,.T.);\n";
t += "#40=ORIENTED_EDGE('',*,*,#30,.T.);\n#41=ORIENTED_EDGE('',*,*,#32,.T.);\n#42=ORIENTED_EDGE('',*,*,#31,.F.);\n#43=ORIENTED_EDGE('',*,*,#32,.F.);\n";
t += "#44=EDGE_LOOP('',(#40,#41,#42,#43));\n#45=FACE_OUTER_BOUND('',#44,.T.);\n#46=ADVANCED_FACE('',(#45),#5,.F.);\n";
t += "#47=PLANE('',#22);\n#48=ORIENTED_EDGE('',*,*,#30,.F.);\n#49=EDGE_LOOP('',(#48));\n#50=FACE_OUTER_BOUND('',#49,.T.);\n#51=ADVANCED_FACE('',(#50),#47,.T.);\n";
t += "#52=PLANE('',#27);\n#53=ORIENTED_EDGE('',*,*,#31,.T.);\n#54=EDGE_LOOP('',(#53));\n#55=FACE_OUTER_BOUND('',#54,.T.);\n#56=ADVANCED_FACE('',(#55),#52,.T.);\n";
t += "#60=CLOSED_SHELL('',(#46,#51,#56));\n#61=MANIFOLD_SOLID_BREP('Pipe',#60);\n#62=SHAPE_REPRESENTATION('',(#61),$);\n";
t += TAIL;
let imp = step_import(&t).unwrap();
let ib = &imp.bodies[0];
assert_eq!(ib.file_faces, 3);
assert_eq!(ib.body.face_count(), 4, "{:?}", imp.warnings);
// The two halves of the tube face make a quarter torus: area π²·R·r.
let curved: f64 = ib.body.faces(1e-3).unwrap().iter().filter(|f| f.plane_normal.is_none()).map(|f| f.area).sum();
let want = std::f64::consts::PI.powi(2) * 30.0;
assert!(rel(curved, want) < 2e-3, "{curved} vs {want}");
let m = ib.body.display_mesh(ib.body.size() * 1e-3).unwrap();
// Seams: the file's own seam (inside the tube face) and the split edge.
assert!(m.seams.iter().filter(|s| **s).count() >= 2, "{:?}", m.seams);
}
/// Revolved faces of a mirrored tool keep their orientation through STEP (the writer used to
/// drop an inverted or mirrored revolution's sense).
#[test]
fn mirrored_revolved_faces_round_trip() {
for mirror in [false, true] {
let pl = Plane { origin: Vec3::new(20.0, 15.0, 0.0), ..Plane::XZ };
let prof =
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 4.0), Vec2::new(3.0, 5.0), Vec2::new(3.0, 12.0), Vec2::new(0.0, 12.0)]), holes: vec![] };
let mut tool = revolve(&pl, &[prof], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap();
if mirror {
tool =
crate::transform_matrix(&tool, [[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, 16.0, 1.0]]).unwrap();
}
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
round_trip(&boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap());
}
}
/// Faces whose boundaries are nowhere near their surfaces, or wild B-spline surfaces, mesh
/// within bounded work (the mesher once looped for ever on such input) and are reported if
/// they cannot be meshed.
#[test]
fn hostile_boundaries_mesh_in_bounded_time() {
let t0 = std::time::Instant::now();
// A wild 12 × 12 B-spline patch (alternating ±50 mm) bounded by four straight edges that
// lie far from it, plus a plane face bounded by a circle 500 mm off the plane. The bound
// catches a hang, not a slow machine (it took ~50 s at 24 × 24 with the machine loaded).
let n = 12;
let mut t = String::from(HEAD);
let mut rows = Vec::new();
for i in 0..n {
let mut row = Vec::new();
for j in 0..n {
let id = 1000 + i * n + j;
let z = if (i * 7 + j * 13) % 2 == 0 { 50.0 } else { -50.0 };
t += &format!("#{id}=CARTESIAN_POINT('',({}.,{}.,{z}));\n", i * 10, j * 10);
row.push(format!("#{id}"));
}
rows.push(format!("({})", row.join(",")));
}
let knots = |m: usize| {
let mut mult = vec!["4".to_string()];
mult.extend(std::iter::repeat_n("1".to_string(), m - 4));
mult.push("4".into());
let ks: Vec<String> = (0..=(m - 3)).map(|k| format!("{k}.")).collect();
(mult.join(","), ks.join(","))
};
let (mu, ku) = knots(n);
t += &format!(
"#10=B_SPLINE_SURFACE_WITH_KNOTS('',3,3,({}),.UNSPECIFIED.,.F.,.F.,.F.,({mu}),({mu}),({ku}),({ku}),.UNSPECIFIED.);\n",
rows.join(",")
);
let corners = [(0, 0), (110, 0), (110, 110), (0, 110)];
for (k, (x, y)) in corners.iter().enumerate() {
t += &format!("#{}=CARTESIAN_POINT('',({x}.,{y}.,300.));\n#{}=VERTEX_POINT('',#{});\n", 20 + k, 30 + k, 20 + k);
}
t += "#40=DIRECTION('',(1.,0.,0.));\n#41=VECTOR('',#40,1.);\n";
for k in 0..4 {
t += &format!(
"#{}=LINE('',#{},#41);\n#{}=EDGE_CURVE('',#{},#{},#{},.T.);\n#{}=ORIENTED_EDGE('',*,*,#{},.T.);\n",
50 + k,
20 + k,
60 + k,
30 + k,
30 + (k + 1) % 4,
50 + k,
70 + k,
60 + k
);
}
t += "#80=EDGE_LOOP('',(#70,#71,#72,#73));\n#81=FACE_OUTER_BOUND('',#80,.T.);\n#82=ADVANCED_FACE('',(#81),#10,.T.);\n";
t += "#90=CARTESIAN_POINT('',(0.,0.,0.));\n#91=DIRECTION('',(0.,0.,1.));\n#92=AXIS2_PLACEMENT_3D('',#90,#91,#40);\n#93=PLANE('',#92);\n";
t += "#94=CARTESIAN_POINT('',(0.,0.,500.));\n#95=AXIS2_PLACEMENT_3D('',#94,#91,#40);\n#96=CIRCLE('',#95,30.);\n#97=CARTESIAN_POINT('',(30.,0.,500.));\n#98=VERTEX_POINT('',#97);\n";
t += "#99=EDGE_CURVE('',#98,#98,#96,.T.);\n#100=ORIENTED_EDGE('',*,*,#99,.T.);\n#101=EDGE_LOOP('',(#100));\n#102=FACE_OUTER_BOUND('',#101,.T.);\n#103=ADVANCED_FACE('',(#102),#93,.T.);\n";
t += "#110=OPEN_SHELL('',(#82,#103));\n#111=SHELL_BASED_SURFACE_MODEL('Wild',(#110));\n#112=SHAPE_REPRESENTATION('',(#111),$);\n";
t += TAIL;
let r = std::panic::catch_unwind(|| step_import(&t));
assert!(r.is_ok(), "panicked");
if let Ok(Ok(imp)) = r {
for b in &imp.bodies {
let _ = measure(&b.body);
}
}
assert!(t0.elapsed().as_secs_f64() < 180.0, "took {:?}", t0.elapsed());
}
/// 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.). Fusion reads such faces this
/// way; reading them with truck's profile-first normal turned faces inside out.
#[test]
fn surface_of_revolution_sense_follows_iso() {
let mut t = String::from(HEAD);
t += "#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=DIRECTION('',(0.,0.,1.));\n#3=DIRECTION('',(1.,0.,0.));\n#4=AXIS1_PLACEMENT('',#1,#2);\n";
t += "#5=CARTESIAN_POINT('',(5.,0.,0.));\n#6=VECTOR('',#2,1.);\n#7=LINE('',#5,#6);\n#8=SURFACE_OF_REVOLUTION('',#7,#4);\n";
t += "#10=CARTESIAN_POINT('',(5.,0.,10.));\n#11=VERTEX_POINT('',#5);\n#12=VERTEX_POINT('',#10);\n";
t += "#13=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n#14=CIRCLE('',#13,5.);\n#15=CARTESIAN_POINT('',(0.,0.,10.));\n#16=AXIS2_PLACEMENT_3D('',#15,#2,#3);\n#17=CIRCLE('',#16,5.);\n";
t += "#20=EDGE_CURVE('',#11,#11,#14,.T.);\n#21=EDGE_CURVE('',#12,#12,#17,.T.);\n#22=EDGE_CURVE('',#11,#12,#7,.T.);\n";
t += "#30=ORIENTED_EDGE('',*,*,#20,.T.);\n#31=ORIENTED_EDGE('',*,*,#22,.T.);\n#32=ORIENTED_EDGE('',*,*,#21,.F.);\n#33=ORIENTED_EDGE('',*,*,#22,.F.);\n";
t += "#34=EDGE_LOOP('',(#30,#31,#32,#33));\n#35=FACE_OUTER_BOUND('',#34,.T.);\n#36=ADVANCED_FACE('',(#35),#8,.T.);\n";
t += "#40=PLANE('',#13);\n#41=ORIENTED_EDGE('',*,*,#20,.F.);\n#42=EDGE_LOOP('',(#41));\n#43=FACE_OUTER_BOUND('',#42,.T.);\n#44=ADVANCED_FACE('',(#43),#40,.F.);\n";
t += "#50=PLANE('',#16);\n#51=ORIENTED_EDGE('',*,*,#21,.T.);\n#52=EDGE_LOOP('',(#51));\n#53=FACE_OUTER_BOUND('',#52,.T.);\n#54=ADVANCED_FACE('',(#53),#50,.T.);\n";
t += "#60=CLOSED_SHELL('',(#36,#44,#54));\n#61=MANIFOLD_SOLID_BREP('Cyl',#60);\n#62=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#61),#70);\n";
t += &context(70, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
t += TAIL;
assert_eq!(step_orientation_errors(&t).unwrap(), Vec::<String>::new());
let imp = step_import(&t).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
let m = measure(&imp.bodies[0].body).unwrap();
let want = std::f64::consts::PI * 25.0 * 10.0;
assert!(rel(m.volume, want) < 1e-3, "{} vs {want}", m.volume);
// The same face with the opposite sense is inside out.
let flipped = t.replace("#36=ADVANCED_FACE('',(#35),#8,.T.);", "#36=ADVANCED_FACE('',(#35),#8,.F.);");
let bad = step_import(&flipped).unwrap();
let v = measure(&bad.bodies[0].body).map(|m| m.volume).unwrap_or(0.0);
assert!(!bad.warnings.is_empty() || rel(v, want) > 1e-2, "{v}");
}
/// A tube revolved about a line it touches (a horn torus, as a tight sweep makes) is written as
/// one rational B-spline face whose loop passes the pole twice, with no zero-length edge there:
/// no TOROIDAL_SURFACE (it would be degenerate) and no surface of revolution.
#[test]
fn horn_torus_written_as_one_spline_face() {
let prof = Region2 { outer: Loop2::circle(Vec2::new(5.0, 0.0), 5.0), holes: vec![] };
let b = revolve(&Plane::XZ, &[prof], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
let text = step_export(&[&b], "t").unwrap();
assert!(!text.contains("TOROIDAL_SURFACE") && !text.contains("SURFACE_OF_REVOLUTION"));
assert_eq!(text.matches("ADVANCED_FACE(").count(), 3, "{text}");
let imp = round_trip(&b);
let v = std::f64::consts::PI * 25.0 * std::f64::consts::TAU * 5.0 / 4.0;
assert!(rel(measure(&imp.bodies[0].body).unwrap().volume, v) < 1e-3);
}
/// 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
/// tee of oracle 35 with its rolling-ball fillet round the junction.
#[test]
fn tee_fillet_exports_byte_identical() {
let main = cylinder(Vec3::new(-40.0, 0.0, 0.0), Vec3::X, 15.0, 80.0).unwrap();
let branch = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 35.0).unwrap();
let export = || {
let tee = boolean(&main, &branch, BoolOp::Union).unwrap().unwrap();
let f = fillet(&tee, &[Vec3::new(0.0, 10.0, 125f64.sqrt())], 3.0).unwrap();
step_export(&[&f], "tee").unwrap()
};
let first = export();
for _ in 0..2 {
assert!(export() == first, "export differs between runs");
}
}
/// A plate with a hole glued to its mirror image: the faces split at the mirror plane and the
/// hole's B-spline halves are written as one plane or one cylinder each, as other systems
/// count them.
#[test]
fn glued_halves_export_as_whole_faces() {
let half = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(30.0, 40.0), Vec2::new(0.0, 40.0)]),
holes: vec![Loop2::circle(Vec2::new(15.0, 20.0), 4.0).reversed()],
};
let right = extrude(&Plane::XY, &[half], 0.0, 10.0).unwrap().pop().unwrap();
let left = crate::transform_matrix(&right, [[-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]]).unwrap();
let b = boolean(&right, &left, BoolOp::Union).unwrap().unwrap();
let text = step_export(&[&b], "t").unwrap();
assert!(!text.contains("B_SPLINE_SURFACE"), "{text}");
assert_eq!(text.matches("CYLINDRICAL_SURFACE(").count(), 2);
let imp = round_trip(&b);
// Box sides and caps (6) and two holes.
assert_eq!(imp.bodies[0].file_faces, 8);
}
/// A slot plate with a round on its top outline (issue #612): the extruded arc ends and the
/// rounds along the straight edges are lone B-spline sheets, written as cylinders bounded by
/// their faces. The rounds' sheets reach far past their edges, and a reader bounding the part by
/// its surfaces saw a 60 mm part as 130 mm long.
#[test]
fn rounded_slot_exports_cylinders() {
use solidcraft_geom::Seg2;
use std::f64::consts::{FRAC_PI_2, PI};
let outer = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(10.0, -10.0), b: Vec2::new(50.0, -10.0) },
Seg2::Arc { center: Vec2::new(50.0, 0.0), radius: 10.0, start: -FRAC_PI_2, sweep: PI },
Seg2::Line { a: Vec2::new(50.0, 10.0), b: Vec2::new(10.0, 10.0) },
Seg2::Arc { center: Vec2::new(10.0, 0.0), radius: 10.0, start: FRAC_PI_2, sweep: PI },
],
};
let plate = extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 4.0).unwrap().pop().unwrap();
let edges = [Vec3::new(30.0, -10.0, 4.0), Vec3::new(30.0, 10.0, 4.0), Vec3::new(0.0, 0.0, 4.0), Vec3::new(60.0, 0.0, 4.0)];
let b = fillet(&plate, &edges, 1.0).unwrap();
let text = step_export(&[&b], "t").unwrap();
assert!(!text.contains("B_SPLINE_SURFACE"), "{text}");
assert_eq!(text.matches("CYLINDRICAL_SURFACE(").count(), 4);
assert_eq!(text.matches("TOROIDAL_SURFACE(").count(), 2);
// No point of the file (surface placements included) lies outside the part.
let imp = round_trip(&b);
for line in text.lines().filter(|l| l.contains("CARTESIAN_POINT")) {
let Some(xyz) = line.split("((").nth(1).and_then(|r| r.split("))").next()) else { continue };
let x: f64 = xyz.split(',').next().unwrap().trim().parse().unwrap();
assert!((-1e-6..=60.0 + 1e-6).contains(&x), "{line}");
}
// The volume analytically: the plate less the round's corner section swept along the edges
// (Pappus: the section's centroid runs 0.2234 r inside the edge round the arcs).
let section = 1.0 - PI / 4.0;
let centroid = (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI));
let want = (800.0 + PI * 100.0) * 4.0 - section * (80.0 + std::f64::consts::TAU * (10.0 - centroid));
assert!(rel(measure(&imp.bodies[0].body).unwrap().volume, want) < 1e-3);
}
/// A slot plate rounded along its top and bottom outlines, with a ramp cut across one end
/// (issue #1198). The other end's half turn is still written as a cylinder (its corners alone
/// point both ways: the seam comes from points along its edges). The faces the ramp crosses keep
/// their B-spline sheets, cut down to the faces: the rounds' sheets reached 35 mm past both ends,
/// and readers bounding the part by its surfaces saw a 60 mm part as 130 mm long.
#[test]
fn ramp_cut_slot_exports_sheets_bounded_by_their_faces() {
use solidcraft_geom::Seg2;
use std::f64::consts::{FRAC_PI_2, PI};
let outer = Loop2 {
segs: vec![
Seg2::Line { a: Vec2::new(10.0, -10.0), b: Vec2::new(50.0, -10.0) },
Seg2::Arc { center: Vec2::new(50.0, 0.0), radius: 10.0, start: -FRAC_PI_2, sweep: PI },
Seg2::Line { a: Vec2::new(50.0, 10.0), b: Vec2::new(10.0, 10.0) },
Seg2::Arc { center: Vec2::new(10.0, 0.0), radius: 10.0, start: FRAC_PI_2, sweep: PI },
],
};
let plate = extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 4.0).unwrap().pop().unwrap();
let outline = |z: f64| [Vec3::new(30.0, -10.0, z), Vec3::new(30.0, 10.0, z), Vec3::new(0.0, 0.0, z), Vec3::new(60.0, 0.0, z)];
let rounded = fillet(&fillet(&plate, &outline(4.0), 1.0).unwrap(), &outline(0.0), 1.0).unwrap();
// The ramp: from the top at x = 40 down to z = 2 past the end, across the whole width.
let side = Plane::new(Vec3::new(0.0, -6.0, 0.0), Vec3::X, Vec3::Z).unwrap();
let ramp =
Region2 { outer: Loop2::polygon(&[Vec2::new(40.0, 4.0), Vec2::new(62.0, 2.0), Vec2::new(62.0, 6.0), Vec2::new(40.0, 6.0)]), holes: vec![] };
let tool = extrude(&side, &[ramp], -12.0, 0.0).unwrap().pop().unwrap();
let b = boolean(&rounded, &tool, BoolOp::Cut).unwrap().unwrap();
let text = step_export(&[&b], "t").unwrap();
// Both ends' walls and the three rounds along the sides it leaves whole; the two the ramp
// crosses stay sheets.
assert_eq!(text.matches("CYLINDRICAL_SURFACE(").count(), 5, "{text}");
assert_eq!(text.matches("B_SPLINE_SURFACE(").count(), 2, "{text}");
// No point of the file (control points and placements included) lies past the part's ends.
for line in text.lines().filter(|l| l.contains("CARTESIAN_POINT")) {
let Some(xyz) = line.split("((").nth(1).and_then(|r| r.split("))").next()) else { continue };
let x: f64 = xyz.split(',').next().unwrap().trim().parse().unwrap();
assert!((-1e-6..=60.0 + 1e-6).contains(&x), "{line}");
}
let imp = round_trip(&b);
assert_eq!(imp.bodies.len(), 1);
assert!(rel(measure(&imp.bodies[0].body).unwrap().volume, measure(&b).unwrap().volume) < 1e-4);
}
/// A countersink cut into a plate (issue #612): the cone is a tapered extrusion of a disc, a
/// B-spline sheet per half, written as one CONICAL_SURFACE face (the sheets reached below the
/// plate).
#[test]
fn countersink_exports_one_cone() {
use std::f64::consts::FRAC_PI_4;
let plate = box_solid(Vec3::new(-10.0, -10.0, 0.0), Vec3::new(10.0, 10.0, 4.0)).unwrap();
// A 90° countersink, radius 4 at the top face, down from 1 mm above it to radius 1.8, and the
// 2 mm drill through.
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.0), holes: vec![] };
let cone = crate::extrude_tapered(&Plane::XY.offset(5.0), &disc, 3.2, -1.0, -FRAC_PI_4).unwrap();
let drill = cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 2.0, 6.0).unwrap();
let b = boolean(&boolean(&plate, &cone, BoolOp::Cut).unwrap().unwrap(), &drill, BoolOp::Cut).unwrap().unwrap();
let text = step_export(&[&b], "t").unwrap();
assert!(!text.contains("B_SPLINE_SURFACE"), "{text}");
assert_eq!(text.matches("CONICAL_SURFACE(").count(), 1, "{text}");
assert_eq!(text.matches("CYLINDRICAL_SURFACE(").count(), 1, "{text}");
let imp = round_trip(&b);
// Six box faces, the countersink and the drill.
assert_eq!(imp.bodies[0].file_faces, 8);
}
/// Knot multiplicities are expanded into one value each before the knot count is compared with
/// the control points, so the sum has to be checked first: 1,000,000 knots of multiplicity 64
/// against two control points asked for a 500 MB vector (20,000,000 for 10 GB) only to reject
/// it afterwards, and an allocation failure aborts the process where an error does not.
#[test]
fn knot_multiplicities_are_checked_before_they_are_expanded() {
let curve = |n: usize, mult: usize| {
let knots: Vec<String> = (0..n).map(|i| format!("{i}.")).collect();
let mults: Vec<String> = std::iter::repeat_n(mult.to_string(), n).collect();
format!(
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=CARTESIAN_POINT('',(1.,0.,0.));\n\
#3=B_SPLINE_CURVE_WITH_KNOTS('',1,(#1,#2),.UNSPECIFIED.,.F.,.F.,({}),({}),.UNSPECIFIED.);\n{TAIL}",
mults.join(","),
knots.join(",")
)
};
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)
};
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.
assert!(error.contains("knot multiplicities do not match"), "{error}");
// A degree-1 curve with two control points needs exactly four knot values.
assert!(read(&curve(2, 2)).is_ok());
assert!(read(&curve(4, 1)).is_ok());
assert!(read(&curve(2, 3)).is_err());
}
/// One `ADVANCED_FACE` may list any number of bounds, and each one clones its edge handles into
/// a new wire that the face extent then samples. A face naming the same loop a million times
/// held 1e11 handles.
#[test]
fn faces_with_too_many_bounds_are_refused() {
let good = step_export(&[&box_solid(Vec3::ZERO, Vec3::new(5.0, 5.0, 5.0)).unwrap()], "t").unwrap();
// One extra face on the cube's shell, listing its outer bound 100,000 times.
let bounds = std::iter::repeat_n("#900001", 100_000).collect::<Vec<_>>().join(",");
let mut extra = format!("#900000=ADVANCED_FACE('',({bounds}),#900010,.T.);\n");
extra += "#900001=FACE_OUTER_BOUND('',#900002,.T.);\n#900002=EDGE_LOOP('',(#900003));\n";
extra += "#900003=ORIENTED_EDGE('',*,*,#900004,.T.);\n#900004=EDGE_CURVE('',#900005,#900005,#900007,.T.);\n";
extra += "#900005=VERTEX_POINT('',#900006);\n#900006=CARTESIAN_POINT('',(0.,0.,0.));\n";
extra += "#900007=LINE('',#900006,#900008);\n#900008=VECTOR('',#900009,1.);\n#900009=DIRECTION('',(1.,0.,0.));\n";
extra += "#900010=PLANE('',#900011);\n#900011=AXIS2_PLACEMENT_3D('',#900006,#900012,#900009);\n#900012=DIRECTION('',(0.,0.,1.));\n";
let text =
good.replacen("CLOSED_SHELL('',(", "CLOSED_SHELL('',(#900000,", 1).replacen("ENDSEC;\nEND-ISO", &format!("{extra}ENDSEC;\nEND-ISO"), 1);
let start = std::time::Instant::now();
let imp = step_import(&text).unwrap();
assert!(imp.warnings.iter().any(|w| w.contains("boundary loops")), "{:?}", imp.warnings);
assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed());
}
/// A board-like assembly for issue #883: `SOLIDCRAFT_STEP_PCB_FIXTURE=path` writes it (with
/// `SOLIDCRAFT_STEP_PCB_COMPONENTS` and `SOLIDCRAFT_STEP_PCB_PINS`; 600 and 15 give 52 MB). Every component is
/// its own product with a package body and its own pins (nothing shared), the worst case for
/// a large import.
#[test]
#[ignore = "manual fixture generator for large STEP imports"]
fn generate_board_assembly_fixture() {
use crate::step_out::{ExportBody, ExportProduct, StepHeader, step_export_products};
let count: usize = solidcraft_geom::env::var("STEP_PCB_COMPONENTS").map(|s| s.parse().unwrap()).unwrap_or(500);
let pins: usize = solidcraft_geom::env::var("STEP_PCB_PINS").map(|s| s.parse().unwrap()).unwrap_or(16);
let mut owned: Vec<Vec<Body>> = Vec::new();
for c in 0..count {
let w = 4.0 + (c % 7) as f64 * 0.37;
let d = 3.0 + (c % 5) as f64 * 0.29;
let mut bodies = vec![fillet_box(w, d, 1.2 + (c % 3) as f64 * 0.1)];
for p in 0..pins {
let side = if p % 2 == 0 { -0.6 } else { d + 0.6 };
let x = 0.3 + (p / 2) as f64 * (w - 0.6) / (pins / 2).max(1) as f64 + c as f64 * 1e-4;
bodies.push(cylinder(Vec3::new(x, side, -1.5), Vec3::Z, 0.25, 2.0 + (p % 3) as f64 * 0.01).unwrap());
}
owned.push(bodies);
}
let mut products: Vec<ExportProduct> = owned
.iter()
.enumerate()
.map(|(c, bodies)| ExportProduct {
name: format!("U{c}"),
bodies: bodies.iter().enumerate().map(|(k, b)| ExportBody { name: format!("U{c}_{k}"), body: b, color: Some([0.2, 0.2, 0.2]) }).collect(),
children: Vec::new(),
})
.collect();
let side = (count as f64).sqrt().ceil() as usize;
let children = (0..count)
.map(|c| {
let (x, y) = ((c % side) as f64 * 12.0, (c / side) as f64 * 10.0);
(c, [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [x, y, 1.6, 1.0]], format!("U{c}"))
})
.collect();
products.push(ExportProduct { name: "Board".into(), bodies: Vec::new(), children });
let root = products.len() - 1;
let text = step_export_products(&products, root, &StepHeader { file_name: "board.step".into(), ..Default::default() }).unwrap();
let path = solidcraft_geom::env::var("STEP_PCB_FIXTURE").unwrap_or_else(|| "board.step".into());
std::fs::write(&path, &text).unwrap();
println!("wrote {path}: {} bytes, {} components", text.len(), count);
}
fn fillet_box(w: f64, d: f64, h: f64) -> Body {
let b = box_solid(Vec3::ZERO, Vec3::new(w, d, h)).unwrap();
let vertical = [Vec3::new(0.0, 0.0, h / 2.0), Vec3::new(w, 0.0, h / 2.0), Vec3::new(w, d, h / 2.0), Vec3::new(0.0, d, h / 2.0)];
let top = [Vec3::new(w / 2.0, 0.0, h), Vec3::new(w, d / 2.0, h), Vec3::new(w / 2.0, d, h), Vec3::new(0.0, d / 2.0, h)];
let b = fillet(&b, &vertical, 0.3).unwrap_or(b);
fillet(&b, &top, 0.1).unwrap_or(b)
}
/// Independent boxes as one STEP file (about 3 KB a box).
fn boxes_step(count: usize, name: &str) -> String {
let bodies: Vec<_> = (0..count)
.map(|i| {
let at = Vec3::new((i % 32) as f64 * 12.0, (i / 32) as f64 * 12.0, 0.0);
box_solid(at, at + Vec3::new(10.0, 10.0, 10.0)).unwrap()
})
.collect();
step_export(&bodies.iter().collect::<Vec<_>>(), name).unwrap()
}
struct TestWatch {
cancel_after: usize,
reports: std::sync::atomic::AtomicUsize,
last: std::sync::Mutex<(&'static str, usize, usize)>,
}
impl crate::watch::Watch for TestWatch {
fn cancelled(&self) -> bool {
self.reports.load(std::sync::atomic::Ordering::Relaxed) >= self.cancel_after
}
fn progress(&self, stage: &'static str, done: usize, total: usize) {
self.reports.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
*self.last.lock().unwrap() = (stage, done, total);
}
}
#[test]
fn watched_import_reports_progress_and_stops_on_cancel_without_caching() {
let text = boxes_step(40, "watched import");
let watch = Arc::new(TestWatch { cancel_after: usize::MAX, reports: Default::default(), last: std::sync::Mutex::new(("", 0, 0)) });
let imp = crate::watch::watching(watch.clone(), || step_import(&text)).unwrap();
assert_eq!(imp.bodies.len(), 40);
let (stage, done, total) = *watch.last.lock().unwrap();
assert_eq!((stage, total), ("converting", 40));
assert!(done <= total && watch.reports.load(std::sync::atomic::Ordering::Relaxed) > 1);
// Cancelled part way: an error, no partial result, and nothing cached for the text.
let text = boxes_step(41, "cancelled import");
let watch = Arc::new(TestWatch { cancel_after: 3, reports: Default::default(), last: std::sync::Mutex::new(("", 0, 0)) });
let err = crate::watch::watching(watch.clone(), || step_import_shared(&text).map(|_| ())).unwrap_err();
assert!(err.to_string().contains("cancelled"), "{err}");
let (_, done, _) = *watch.last.lock().unwrap();
assert!(done < 41, "stopped before converting everything ({done})");
// Unwatched, the same text imports in full (no cached failure or partial import).
assert_eq!(step_import_shared(&text).unwrap().bodies.len(), 41);
// Without a watch nothing is reported or stopped.
assert_eq!(step_import(&boxes_step(2, "unwatched")).unwrap().bodies.len(), 2);
}
#[test]
fn large_imports_are_freed_with_their_last_user() {
let text = boxes_step(400, "large shared import");
assert!(text.len() > KEEP_SMALL_BYTES, "{} bytes", text.len());
let first = step_import_shared(&text).unwrap();
let again = step_import_shared(&text).unwrap();
assert!(Arc::ptr_eq(&first, &again), "read once while it is in use");
let weak = Arc::downgrade(&first);
drop((first, again));
assert!(weak.upgrade().is_none(), "the cache must not keep a large import alive after its design is gone");
}
/// A three-quarter turn of a rectangle about Z: planar end caps, revolved cylinders and annuli.
fn three_quarter_ring() -> Body {
let rect =
Region2 { outer: Loop2::polygon(&[Vec2::new(5.0, 0.0), Vec2::new(25.0, 0.0), Vec2::new(25.0, 5.0), Vec2::new(5.0, 5.0)]), holes: vec![] };
revolve(&Plane::XZ, &[rect], Vec2::ZERO, Vec2::Y, 1.5 * std::f64::consts::PI).unwrap().pop().unwrap()
}
fn scaled(b: &Body, x: f64, y: f64, z: f64) -> Body {
crate::transform_matrix(b, [[x, 0.0, 0.0, 0.0], [0.0, y, 0.0, 0.0], [0.0, 0.0, z, 0.0], [0.0, 0.0, 0.0, 1.0]]).unwrap()
}
/// A scale across the revolution axis that differs from the scale along it (how a shell or an
/// offset moves a revolved face) keeps the faces surfaces of revolution. The writer refused it
/// ("non-uniform scale"), and the refusal surfaced as a panic in export (robustness seeds 19, 24).
#[test]
fn revolved_faces_scaled_across_the_axis_round_trip() {
let ring = three_quarter_ring();
let v0 = measure(&ring).unwrap().volume;
let b = scaled(&ring, 1.5, 1.5, 0.5);
assert!(rel(measure(&b).unwrap().volume, v0 * 1.5 * 1.5 * 0.5) < 1e-3);
round_trip(&b);
}
/// A transform that does not keep the faces surfaces of revolution: export may refuse the body,
/// but with an error, never a panic (the browser build aborts on any panic), and anything it does
/// write must read back as the same solid.
#[test]
fn revolved_faces_skewed_never_panic_on_export() {
let b = scaled(&three_quarter_ring(), 1.5, 1.0, 1.0);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| step_export(&[&b], "t")));
let Ok(r) = r else { panic!("STEP export panicked") };
match r {
Err(e) => assert!(e.to_string().contains("not supported yet"), "{e}"),
Ok(_) => {
round_trip(&b);
}
}
}
/// Weights of a rational quadratic full circle through nine control points.
const CIRCLE_WEIGHTS: &str = "1.,0.707106781187,1.,0.707106781187,1.,0.707106781187,1.,0.707106781187,1.";
/// Angle knots as OpenCascade writes a closed (periodic) quadratic circle: the end knots
/// appear once, so the B-spline is defined from knot 2 to knot 9 only, -π to π.
const PERIODIC_MULTS: &str = "1,2,2,2,2,2,1";
const PERIODIC_KNOTS: &str = "-4.712388980385,-3.14159265359,-1.570796326795,0.,1.570796326795,3.14159265359,4.712388980385";
/// Nine control points of a quadratic circle of radius `r` about (`x`, 0, `z`), from angle 0.
fn circle_net(first: u64, x: f64, r: f64, z: f64) -> (String, Vec<String>) {
let (mut s, mut ids) = (String::new(), Vec::new());
for i in 0..9u64 {
let a = i as f64 * std::f64::consts::FRAC_PI_4;
let q = if i % 2 == 1 { r * std::f64::consts::SQRT_2 } else { r };
s += &format!("#{}=CARTESIAN_POINT('',({:?},{:?},{z:?}));\n", first + i, x + q * a.cos(), q * a.sin());
ids.push(format!("#{}", first + i));
}
(s, ids)
}
/// A tube face of radius `r` and length `h` about the Z axis through (`x`, 0, 0), written the
/// way OpenCascade writes one: a rational degree (1, 2) B-spline surface with the periodic
/// angle knots above, bounded by its two rims and a seam. Entities from `first` on; returns
/// them and the face's id.
fn periodic_knot_tube(first: u64, x: f64, r: f64, h: f64) -> (String, u64) {
let id = |k: u64| first + k;
let (net0, row0) = circle_net(id(40), x, r, 0.0);
let (net1, row1) = circle_net(id(50), x, r, h);
let (p0, p1, v0, v1) = (id(0), id(1), id(2), id(3));
let (c0, c1, up, down, ref_dir) = (id(4), id(5), id(6), id(7), id(8));
let (a0, a1, rim0, rim1) = (id(9), id(10), id(11), id(12));
let (e0, e1, along, seam_line, seam) = (id(13), id(14), id(15), id(16), id(17));
let (o0, o1, o2, o3, lp, bound, surface, face) = (id(18), id(19), id(20), id(21), id(22), id(23), id(24), id(25));
let mut s = net0 + &net1;
let xr = x + r;
s += &format!("#{p0}=CARTESIAN_POINT('',({xr:?},0.,0.));\n#{p1}=CARTESIAN_POINT('',({xr:?},0.,{h:?}));\n");
s += &format!("#{v0}=VERTEX_POINT('',#{p0});\n#{v1}=VERTEX_POINT('',#{p1});\n");
s += &format!("#{c0}=CARTESIAN_POINT('',({x:?},0.,0.));\n#{c1}=CARTESIAN_POINT('',({x:?},0.,{h:?}));\n");
s += &format!("#{up}=DIRECTION('',(0.,0.,1.));\n#{down}=DIRECTION('',(0.,0.,-1.));\n#{ref_dir}=DIRECTION('',(1.,0.,0.));\n");
s += &format!("#{a0}=AXIS2_PLACEMENT_3D('',#{c0},#{up},#{ref_dir});\n#{a1}=AXIS2_PLACEMENT_3D('',#{c1},#{down},#{ref_dir});\n");
s += &format!("#{rim0}=CIRCLE('',#{a0},{r:?});\n#{rim1}=CIRCLE('',#{a1},{r:?});\n");
s += &format!("#{e0}=EDGE_CURVE('',#{v0},#{v0},#{rim0},.T.);\n#{e1}=EDGE_CURVE('',#{v1},#{v1},#{rim1},.T.);\n");
s += &format!("#{along}=VECTOR('',#{up},1.);\n#{seam_line}=LINE('',#{p0},#{along});\n#{seam}=EDGE_CURVE('',#{v0},#{v1},#{seam_line},.T.);\n");
s += &format!(
"#{o0}=ORIENTED_EDGE('',*,*,#{e0},.F.);\n#{o1}=ORIENTED_EDGE('',*,*,#{seam},.T.);\n#{o2}=ORIENTED_EDGE('',*,*,#{e1},.F.);\n#{o3}=ORIENTED_EDGE('',*,*,#{seam},.F.);\n"
);
s += &format!("#{lp}=EDGE_LOOP('',(#{o0},#{o1},#{o2},#{o3}));\n#{bound}=FACE_BOUND('',#{lp},.T.);\n");
s += &format!(
"#{surface}=(BOUNDED_SURFACE()B_SPLINE_SURFACE(1,2,(({}),({})),.UNSPECIFIED.,.F.,.T.,.F.)B_SPLINE_SURFACE_WITH_KNOTS((2,2),({PERIODIC_MULTS}),(0.,{:?}),({PERIODIC_KNOTS}),.UNSPECIFIED.)GEOMETRIC_REPRESENTATION_ITEM()RATIONAL_B_SPLINE_SURFACE((({CIRCLE_WEIGHTS}),({CIRCLE_WEIGHTS})))REPRESENTATION_ITEM('')SURFACE());\n",
row0.join(","),
row1.join(","),
h / r
);
s += &format!("#{face}=ADVANCED_FACE('',(#{bound}),#{surface},.T.);\n");
(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),
/// and the import grew by gigabytes without end.
#[test]
fn periodic_knot_tube_faces_import_and_mesh() {
let t0 = std::time::Instant::now();
let (r, h) = (1.7, 1.5);
let (a, fa) = periodic_knot_tube(100, 0.0, r, h);
let (b, fb) = periodic_knot_tube(200, 700.0, r, h);
let text = format!(
"{HEAD}{a}{b}#1=OPEN_SHELL('',(#{fa},#{fb}));\n#2=SHELL_BASED_SURFACE_MODEL('Tubes',(#1));\n#3=SHAPE_REPRESENTATION('',(#2),$);\n{TAIL}"
);
let imp = step_import(&text).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let m = measure(&imp.bodies[0].body).unwrap();
let area = 2.0 * std::f64::consts::TAU * r * h;
assert!(rel(m.area, area) < 0.02, "area {} (expected {area})", m.area);
assert!(t0.elapsed().as_secs_f64() < 60.0, "took {:?}", t0.elapsed());
}