use super::*; use crate::{Body, BoolOp, ExportBody, StepHeader, boolean, box_solid, cylinder, fillet, iges_export_bodies, measure, sphere, torus}; use solidcraft_geom::Vec3; fn rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-9) } /// Export a body to IGES and read it back: same volume and area, its name and colour. fn round_trip(b: &Body) -> crate::StepImport { let body = ExportBody { name: "Bracket left 1".into(), body: b, color: Some([1.0, 0.5, 0.0]) }; let text = iges_export_bodies(&[body], &StepHeader { file_name: "t.igs".into(), ..Default::default() }).unwrap(); for l in text.lines() { assert_eq!(l.chars().count(), 80, "{l}"); } let imp = iges_import(&text).unwrap(); assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); assert_eq!(imp.bodies.len(), 1); 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); assert!(imp.bodies[0].closed); assert_eq!(imp.bodies[0].name, "Bracket left 1"); assert_eq!(imp.bodies[0].color, Some([1.0, 0.5, 0.0])); imp } #[test] fn primitives_round_trip_through_iges() { 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 features_round_trip_through_iges() { 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); round_trip(&fillet(&plate, &[Vec3::new(20.0, 0.0, 10.0)], 3.0).unwrap()); } /// Hostile files fail cleanly: garbage, truncation, missing and circular references, huge /// counts. #[test] fn hostile_iges_never_panics() { let good = iges_export_bodies( &[ExportBody { name: "B".into(), body: &box_solid(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)).unwrap(), color: None }], &StepHeader::default(), ) .unwrap(); let mut cases: Vec = vec![String::new(), "garbage".into(), "\u{0}\u{ff}".into(), good[..good.len() / 2].to_string()]; // Every parameter line with its numbers bumped, scrambled or blown up. for (k, f) in [|s: &str| s.replace('1', "9"), |s: &str| s.replace(',', ";"), |s: &str| s.replace("0,", "999999999,")].iter().enumerate() { let t: String = good .lines() .map(|l| if l.as_bytes().get(72) == Some(&b'P') && l.len() >= 64 { format!("{}{}", f(&l[..64]), &l[64..]) } else { l.to_string() }) .collect::>() .join("\n"); let _ = k; cases.push(t); } // A transformation and a composite curve that refer to themselves. cases.push( [ format!("{:<72}S{:>7}", "", 1), format!("{:<72}G{:>7}", "1H,,1H;;", 1), format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 124, 1, 0, 0, 0, 0, 1, 0, "00000000", 1), format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 124, 0, 0, 1, 0, 0, 0, "", 0, 2), format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 102, 2, 0, 0, 0, 0, 1, 0, "00000000", 3), format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 102, 0, 0, 1, 0, 0, 0, "", 0, 4), format!("{:<64}{:>8}P{:>7}", "124,1.,0.,0.,0.,0.,1.,0.,0.,0.,0.,1.,0.;", 1, 1), format!("{:<64}{:>8}P{:>7}", "102,1,3;", 3, 2), format!("{:<72}T{:>7}", "S 1G 1D 4P 2", 1), ] .join("\n"), ); for c in cases { let r = std::panic::catch_unwind(|| iges_import(&c).map(|i| i.bodies.len())); assert!(r.is_ok(), "panicked on a hostile file"); } } /// An IGES file from (type, form, parameters) entities, in order (pointers are 2k + 1). fn iges_file(ents: &[(i64, i64, String)]) -> String { let mut out = vec![format!("{:<72}S{:>7}", "test", 1), format!("{:<72}G{:>7}", "1H,,1H;,,,,,,,,,,,1.,2,2HMM,,,,1.E-6;", 1)]; let mut p_lines: Vec<(usize, String)> = Vec::new(); let mut starts = Vec::new(); for (k, (t, _, p)) in ents.iter().enumerate() { starts.push(p_lines.len() + 1); let text = format!("{t},{p};"); let chars: Vec = text.chars().collect(); for c in chars.chunks(64) { p_lines.push((2 * k + 1, c.iter().collect())); } } for (k, (t, form, _)) in ents.iter().enumerate() { let count = p_lines.iter().filter(|(d, _)| *d == 2 * k + 1).count(); out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, starts[k], 0, 0, 0, 0, 0, 0, "00000000", 2 * k + 1)); out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", t, 0, 0, count, form, 0, 0, "", 0, 2 * k + 2)); } for (i, (d, l)) in p_lines.iter().enumerate() { out.push(format!("{l:<64}{d:>8}P{:>7}", i + 1)); } out.push(format!("{:<72}T{:>7}", "", 1)); out.join("\n") } /// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose /// boundaries are lines: sewn into a closed solid. #[test] fn trimmed_surfaces_sew_into_a_solid() { let c = |i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] }; // Faces as corner quads (counter-clockwise seen from outside). let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]]; let mut ents: Vec<(i64, i64, String)> = Vec::new(); let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]); for q in quads { let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3])); // Patch: u from a to b, v from a to d (normal (b - a) × (d - a) outward). let surf = format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(cc)); ents.push((128, 0, surf)); let s_de = 2 * ents.len() - 1; let mut lines = Vec::new(); for (p0, p1) in [(a, b), (b, cc), (cc, d), (d, a)] { ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1)))); lines.push(2 * ents.len() - 1); } ents.push((102, 0, format!("4,{}", lines.iter().map(|x| x.to_string()).collect::>().join(",")))); let comp = 2 * ents.len() - 1; ents.push((142, 0, format!("0,{s_de},0,{comp},2"))); let cos = 2 * ents.len() - 1; ents.push((144, 0, format!("{s_de},1,0,{cos}"))); } let imp = iges_import(&iges_file(&ents)).unwrap(); assert!(imp.warnings.is_empty(), "{:?}", imp.warnings); assert!(imp.bodies[0].closed); let m = measure(&imp.bodies[0].body).unwrap(); assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area); } #[test] fn a_drill_point_round_trips_through_iges() { // A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the // apex and straight back). use solidcraft_geom::{Loop2, Plane, Region2, Vec2}; let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap(); let profile = Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] }; let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // Positive profile heights are world +Z. let hole = crate::revolve(&plane, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap(); let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap(); let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0); assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3); round_trip(&drilled); } /// A composite curve (102) names its children by pointer, and nothing stops a tree whose every /// node names the same child twice. Twenty levels under the depth cap is a million leaf visits /// and thirty is 2^30; only counting the work sees that, because the depth never grows past the /// cap and a node that yields no segment trips no bound on the output. #[test] fn composite_curve_fan_out_is_bounded() { let quad = |z: f64| { let c = [[0.0, 0.0, z], [10.0, 0.0, z], [10.0, 20.0, z], [0.0, 20.0, z]]; let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]); format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(c[0]), fmt(c[1]), fmt(c[3]), fmt(c[2])) }; let mut ents: Vec<(i64, i64, String)> = vec![(128, 0, quad(0.0)), (110, 0, "0.,0.,0.,10.,0.,0.".into())]; let (surface, mut child) = (1, 3); // Twenty composites, each naming the one below it twice. for _ in 0..20 { ents.push((102, 0, format!("2,{child},{child}"))); child = 2 * ents.len() - 1; } ents.push((142, 0, format!("0,{surface},0,{child},2"))); let cos = 2 * ents.len() - 1; ents.push((144, 0, format!("{surface},1,0,{cos}"))); let reported = match iges_to_step(&iges_file(&ents)) { Ok((_, warnings)) => warnings.join(" "), Err(error) => error, }; // Check the work-budget rejection, not elapsed time: CI runs test binaries concurrently. assert!(reported.contains("too complex") || reported.contains("too many segments"), "{reported}"); } /// Nothing stops directory entries from all naming the same parameter range, and each one /// materialises it as its own string and token list. Ten thousand entries over a million /// shared lines retained about a gigabyte each. #[test] fn directory_entries_cannot_share_their_way_past_the_file_size() { let mut out = vec![format!("{:<72}S{:>7}", "test", 1), format!("{:<72}G{:>7}", "1H,,1H;,,,,,,,,,,,1.,2,2HMM,,,,1.E-6;", 1)]; // 1,000 entries, each claiming the same 2,000 parameter lines. for k in 0..1_000 { out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 110, 1, 0, 0, 0, 0, 0, 0, "00000000", 2 * k + 1)); out.push(format!("{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}{:>8}D{:>7}", 110, 0, 0, 2_000, 0, 0, 0, "", 0, 2 * k + 2)); } for i in 0..2_000 { out.push(format!("{:<64}{:>8}P{:>7}", "0.,0.,0.,1.,0.,0.;", 1, i + 1)); } out.push(format!("{:<72}T{:>7}", "", 1)); let t = std::time::Instant::now(); let error = iges_to_step(&out.join("\n")).unwrap_err(); assert!(error.contains("parameter data is too large"), "{error}"); assert!(t.elapsed() < std::time::Duration::from_secs(10), "took {:?}", t.elapsed()); }