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::().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::>(), "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), (¶llel.schema, ¶llel.system)); assert_eq!(serial.bodies.len(), parallel.bodies.len()); assert_eq!( step_export(&serial.bodies.iter().map(|b| &b.body).collect::>(), "equivalence").unwrap(), step_export(¶llel.bodies.iter().map(|b| &b.body).collect::>(), "equivalence").unwrap() ); for (a, b) in serial.bodies.iter().zip(¶llel.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::>(), "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::::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]); } #[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 { 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 = 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::>(), ["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 = 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 = (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::::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 = (0..n).map(|i| format!("{i}.")).collect(); let mults: Vec = 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::>().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::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 = 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::>(), 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"); }