Large STEP imports: no more multi-minute hang, shared STEP text, cancel and progress (#925)

This commit is contained in:
bherbruck authored and GitHub committed 2026-10-14 05:01:12 -04:00
1 parent dfbf7dd6a8
commit 2f91338d3d
18 files changed
+525 -45

No files matched your search

+116
View File
@@ -915,3 +915,119 @@ fn faces_with_too_many_bounds_are_refused() {
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");
}