Prepare independent STEP bodies concurrently on desktop (#312)

This commit is contained in:
selune authored and GitHub committed 2026-10-12 11:46:32 -04:00
1 parent a9e4830ded
commit 65a99608d9
5 files changed
+417 -79

No files matched your search

+104 -79
View File
@@ -10,6 +10,7 @@
mod brep;
pub(crate) mod geom;
pub(crate) mod p21;
mod parallel;
mod split;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -243,9 +244,32 @@ struct Reader<'a> {
/// Items whose meshing was checked.
unmeshed_checked: HashSet<u64>,
bodies: Vec<ImportedBody>,
workers: usize,
}
impl<'a> Reader<'a> {
fn new(ex: &'a Exchange, workers: usize) -> Self {
Self {
ex,
units: HashMap::new(),
warnings: BTreeMap::new(),
reps: HashMap::new(),
rep_links: HashMap::new(),
pd_reps: HashMap::new(),
children: HashMap::new(),
placements: HashMap::new(),
colors: HashMap::new(),
opacity: HashMap::new(),
solids: HashMap::new(),
used_items: HashSet::new(),
node_items: HashSet::new(),
visits: 0,
unmeshed_checked: HashSet::new(),
bodies: Vec::new(),
workers,
}
}
fn warn(&mut self, msg: impl Into<String>) {
*self.warnings.entry(msg.into()).or_default() += 1;
}
@@ -468,71 +492,85 @@ impl<'a> Reader<'a> {
return;
}
let u = self.units_of(rep);
let items = self.reps.get(&rep).map(|r| r.0.clone()).unwrap_or_default();
for item in items {
let mut items = self.reps.get(&rep).map(|r| r.0.clone()).unwrap_or_default().into_iter().peekable();
while let Some(item) = items.next() {
let Some(e) = self.ex.get(item) else { continue };
match e.name() {
"MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL" => {
self.used_items.insert(item);
if !self.node_items.insert(item) {
continue;
}
if self.bodies.len() >= MAX_BODIES {
self.warn(format!("more than {MAX_BODIES} bodies: the rest are left out"));
return;
}
let Some((solid, closed, file_faces, face_origin)) = self.item_solid(item, u) else { continue };
let identity = *m == mt::Matrix4::from_scale(1.0);
let placed = if identity {
Ok(solid)
} else {
let mm = *m;
guard("step placement", || Ok(mt::builder::transformed(&solid, mm)))
};
let body = placed.and_then(Body::new);
match body {
Ok(body) => {
let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string();
let name = if own.is_empty() { product.to_string() } else { own };
// Faces the mesher gives up on (or that the budget cut short) are reported.
if self.unmeshed_checked.insert(item) {
let n = body.unmeshed_faces();
if n > 0 {
self.warn(format!("solid '{name}': {n} face(s) could not be meshed and are not shown"));
}
}
out.push(self.bodies.len());
let color = self.colors.get(&item).copied();
// See-through bodies and faces with colours of their own.
let faces = face_origin
.iter()
.enumerate()
.filter_map(|(i, f)| {
let c = self.colors.get(f)?;
Some(crate::FacePaint { face: i, color: *c, opacity: self.opacity.get(f).copied().unwrap_or(1.0) })
})
.collect();
let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces };
let body = body.with_color(color).with_paint(Some(paint));
let identities = face_origin
.iter()
.map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None }))
.collect();
let body = body.with_face_sources(Some(std::sync::Arc::new(identities)));
self.bodies.push(ImportedBody {
name,
body,
color,
path: path.to_vec(),
closed,
file_faces,
source_item: item,
face_origin,
});
// Only batch consecutive solids: mapped items must retain traversal and
// first-use unit/cache order. Bound temporary geometry independently of file size.
let mut batch = vec![item];
let capacity = MAX_BODIES.saturating_sub(self.bodies.len()).min(64);
while batch.len() < capacity
&& items.peek().is_some_and(|id| {
self.ex.get(*id).is_some_and(|e| {
matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL")
})
})
{
if let Some(next) = items.next() {
batch.push(next);
}
Err(err) => {
let label = self.label(item);
self.warn(format!("{label}: not imported: {err}"));
}
let mut sources = Vec::new();
let mut solids = Vec::new();
for item in batch {
self.used_items.insert(item);
if !self.node_items.insert(item) {
continue;
}
if self.bodies.len() >= MAX_BODIES {
self.warn(format!("more than {MAX_BODIES} bodies: the rest are left out"));
return;
}
let Some((solid, closed, file_faces, face_origin)) = self.item_solid(item, u) else { continue };
sources.push((item, closed, file_faces, face_origin));
solids.push(parallel::Job { solid, check_mesh: !self.unmeshed_checked.contains(&item) });
}
let prepared = parallel::bodies(&solids, m, self.workers);
for ((item, closed, file_faces, face_origin), body) in sources.into_iter().zip(prepared) {
let Some(e) = self.ex.get(item) else { continue };
match body {
Ok((body, missing)) => {
let own = e.params().first().and_then(Param::as_str).unwrap_or("").trim().to_string();
let name = if own.is_empty() { product.to_string() } else { own };
// Faces the mesher gives up on (or that the budget cut short) are reported.
if self.unmeshed_checked.insert(item) && missing > 0 {
self.warn(format!("solid '{name}': {missing} face(s) could not be meshed and are not shown"));
}
out.push(self.bodies.len());
let color = self.colors.get(&item).copied();
// See-through bodies and faces with colours of their own.
let faces = face_origin
.iter()
.enumerate()
.filter_map(|(i, f)| {
let c = self.colors.get(f)?;
Some(crate::FacePaint { face: i, color: *c, opacity: self.opacity.get(f).copied().unwrap_or(1.0) })
})
.collect();
let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces };
let body = body.with_color(color).with_paint(Some(paint));
let identities = face_origin
.iter()
.map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None }))
.collect();
let body = body.with_face_sources(Some(std::sync::Arc::new(identities)));
self.bodies.push(ImportedBody {
name,
body,
color,
path: path.to_vec(),
closed,
file_faces,
source_item: item,
face_origin,
});
}
Err(err) => {
let label = self.label(item);
self.warn(format!("{label}: not imported: {err}"));
}
}
}
}
@@ -826,25 +864,12 @@ pub fn step_import_shared(text: &str) -> Result<std::sync::Arc<StepImport>> {
/// Read a STEP file: its solids (as bodies, placed by the assembly structure) and the tree.
pub fn step_import(text: &str) -> Result<StepImport> {
step_import_with_workers(text, parallel::workers())
}
fn step_import_with_workers(text: &str, workers: usize) -> Result<StepImport> {
let ex = p21::parse(text).map_err(KernelError::Invalid)?;
let mut rd = Reader {
ex: &ex,
units: HashMap::new(),
warnings: BTreeMap::new(),
reps: HashMap::new(),
rep_links: HashMap::new(),
pd_reps: HashMap::new(),
children: HashMap::new(),
placements: HashMap::new(),
colors: HashMap::new(),
opacity: HashMap::new(),
solids: HashMap::new(),
used_items: HashSet::new(),
node_items: HashSet::new(),
visits: 0,
unmeshed_checked: HashSet::new(),
bodies: Vec::new(),
};
let mut rd = Reader::new(&ex, workers);
rd.index();
// Roots: product definitions that are nobody's component.
let child_pds: HashSet<u64> =
+60
View File
@@ -0,0 +1,60 @@
//! Bounded desktop preparation of independent imported bodies, collected in file order.
use super::{Body, Result, guard, mt};
pub(super) struct Job {
pub solid: mt::Solid,
pub check_mesh: bool,
}
type Prepared = Result<(Body, usize)>;
pub(super) fn workers() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::available_parallelism().map_or(1, |n| n.get().min(8))
}
#[cfg(target_arch = "wasm32")]
{
1
}
}
fn prepare(job: &Job, placement: &mt::Matrix4) -> Prepared {
guard("STEP body preparation", || {
let solid = if *placement == mt::Matrix4::from_scale(1.0) { job.solid.clone() } else { mt::builder::transformed(&job.solid, *placement) };
let body = Body::new(solid)?;
let missing = if job.check_mesh { body.unmeshed_faces() } else { 0 };
Ok((body, missing))
})
}
pub(super) fn bodies(solids: &[Job], placement: &mt::Matrix4, workers: usize) -> Vec<Prepared> {
#[cfg(not(target_arch = "wasm32"))]
if solids.len() >= 4 && workers > 1 {
return std::thread::scope(|scope| {
let size = solids.len().div_ceil(workers.min(8));
let mut pending = Vec::new();
for chunk in solids.chunks(size) {
let handle = std::thread::Builder::new()
.name("step-body".into())
.spawn_scoped(scope, move || chunk.iter().map(|solid| prepare(solid, placement)).collect::<Vec<_>>())
.ok();
pending.push((chunk, handle));
}
let mut output = Vec::with_capacity(solids.len());
for (chunk, handle) in pending {
match handle {
Some(handle) => match handle.join() {
Ok(results) => output.extend(results),
Err(_) => output.extend(chunk.iter().map(|_| Err(crate::KernelError::Internal("STEP body worker panicked".into())))),
},
// Resource exhaustion must not lose an import: use the serial path.
None => output.extend(chunk.iter().map(|solid| prepare(solid, placement))),
}
}
output
});
}
let _ = workers;
solids.iter().map(|solid| prepare(solid, placement)).collect()
}
+196
View File
@@ -7,6 +7,195 @@ 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 = std::env::var("SOLVECRAFT_STEP_BENCH_COUNT").ok().map(|s| s.parse::<usize>().unwrap().clamp(1, 512)).unwrap_or(48);
let kind = std::env::var("SOLVECRAFT_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) = std::env::var("SOLVECRAFT_STEP_BENCH_FIXTURE") {
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 = 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, 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", &mut out, 0);
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();
@@ -301,6 +490,13 @@ fn hand_written_assembly() {
}
t += TAIL;
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();