perf: prism pairs before the mesh, bodies measured once, and a STEP import that copies less (#714)
A cut or an intersection between prisms (a plate and the tools through it) is now decided in 2D before either operand is meshed. A body's size and volume are measured once, a placement reads its orientation from its matrix, the evaluation cache is bounded to 64 MiB, and STEP import copies fewer shells. Hole plates evaluate 20-30% faster with less memory, and models are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a1cb8d4d36
commit
2bb24d1d2a
13 files changed
+630
-71
No files matched your search
@@ -395,12 +395,29 @@ pub(crate) fn read_item(cx: &Ctx, id: u64) -> R<BrepOut> {
|
||||
if shells.is_empty() {
|
||||
return Err(failed.first().cloned().unwrap_or_else(|| "no shells".into()));
|
||||
}
|
||||
let solid = match guard("step solid", || mt::Solid::try_new(shells.clone()).map_err(|e| crate::KernelError::Failed(e.to_string()))) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
closed = false;
|
||||
mt::Solid::new_unchecked(shells)
|
||||
// `mt::Solid::try_new` only checks each shell (non-empty, connected, closed, no singular
|
||||
// vertex) and hands the shells back unchanged (truck-topology 0.6.0), so the same checks run
|
||||
// here on the borrowed shells. The successful path used to clone every face, edge, vertex,
|
||||
// curve and surface to keep a copy that was then dropped.
|
||||
let checked = guard("step solid", || {
|
||||
for shell in &shells {
|
||||
if shell.is_empty() {
|
||||
return Err(crate::KernelError::Failed("empty shell".into()));
|
||||
}
|
||||
if !shell.is_connected() {
|
||||
return Err(crate::KernelError::Failed("shell is not connected".into()));
|
||||
}
|
||||
if shell.shell_condition() != mt::ShellCondition::Closed {
|
||||
return Err(crate::KernelError::Failed("shell is not closed".into()));
|
||||
}
|
||||
if !shell.singular_vertices().is_empty() {
|
||||
return Err(crate::KernelError::Failed("shell is not manifold".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(BrepOut { solid, failed, closed, file_faces, face_origin })
|
||||
Ok(())
|
||||
});
|
||||
if checked.is_err() {
|
||||
closed = false;
|
||||
}
|
||||
Ok(BrepOut { solid: mt::Solid::new_unchecked(shells), failed, closed, file_faces, face_origin })
|
||||
}
|
||||
@@ -14,6 +14,7 @@ mod parallel;
|
||||
mod split;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use mt::EuclideanSpace as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -31,6 +32,9 @@ const MAX_BODIES: usize = 10_000;
|
||||
/// Most product occurrences and mapped representations visited (a shared sub-assembly used
|
||||
/// twice at every level would otherwise grow exponentially).
|
||||
const MAX_VISITS: usize = 100_000;
|
||||
/// Most representation entries walked while deciding which items are converted exactly once. A
|
||||
/// larger file keeps every conversion instead of paying for the walk.
|
||||
const MAX_CLOSURE_WORK: usize = 1 << 16;
|
||||
|
||||
/// A body read from a STEP file.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -216,6 +220,10 @@ fn rigid_inverse(m: &mt::Matrix4) -> mt::Matrix4 {
|
||||
m.invert().unwrap_or_else(mt::Matrix4::identity)
|
||||
}
|
||||
|
||||
/// One converted representation item: the truck solid, whether its shells are closed, the
|
||||
/// faces the file had, and the file face of each kernel face (band faces split into two).
|
||||
type SolidEntry = (mt::Solid, bool, usize, Vec<u64>);
|
||||
|
||||
struct Reader<'a> {
|
||||
ex: &'a Exchange,
|
||||
units: HashMap<u64, Units>,
|
||||
@@ -234,8 +242,12 @@ struct Reader<'a> {
|
||||
colors: HashMap<u64, [f32; 3]>,
|
||||
/// Opacity of styled items with a transparency (1 = opaque).
|
||||
opacity: HashMap<u64, f32>,
|
||||
/// Converted items (brep id → solid, or the error).
|
||||
solids: HashMap<u64, std::result::Result<(mt::Solid, bool, usize, Vec<u64>), String>>,
|
||||
/// Converted items (brep id → entry, or the error). Held behind `Arc` so the conversion is
|
||||
/// handed on without copying wherever the file cannot ask for the item a second time.
|
||||
solids: HashMap<u64, std::result::Result<Arc<SolidEntry>, String>>,
|
||||
/// Items the file converts exactly once (see `index`): keeping their solids would hold a
|
||||
/// second copy of the whole import for nothing.
|
||||
once: HashSet<u64>,
|
||||
used_items: HashSet<u64>,
|
||||
/// Items already instanced for the product being read.
|
||||
node_items: HashSet<u64>,
|
||||
@@ -261,6 +273,7 @@ impl<'a> Reader<'a> {
|
||||
colors: HashMap::new(),
|
||||
opacity: HashMap::new(),
|
||||
solids: HashMap::new(),
|
||||
once: HashSet::new(),
|
||||
used_items: HashSet::new(),
|
||||
node_items: HashSet::new(),
|
||||
visits: 0,
|
||||
@@ -280,11 +293,22 @@ impl<'a> Reader<'a> {
|
||||
let mut sdrs: Vec<(u64, u64)> = Vec::new(); // (PDS, rep)
|
||||
let mut cdsrs: Vec<(u64, u64)> = Vec::new(); // (relationship, PDS)
|
||||
let mut nauos: Vec<(u64, u64, u64)> = Vec::new(); // (id, parent, child)
|
||||
// Item → how many shape representations list it, and the one representation the item
|
||||
// comes from, to tell items the file converts exactly once (see `once`).
|
||||
let mut item_reps: HashMap<u64, usize> = HashMap::new();
|
||||
let mut item_rep: HashMap<u64, u64> = HashMap::new();
|
||||
// True as soon as the file can walk a product definition twice: every occurrence of a
|
||||
// component makes the reader convert the component's items again.
|
||||
let mut repeated = false;
|
||||
let mut ids: Vec<&u64> = ex.entities.keys().collect();
|
||||
ids.sort();
|
||||
for &id in ids {
|
||||
let Some(e) = ex.get(id) else { continue };
|
||||
if let Some(r) = representation(e) {
|
||||
for item in &r.0 {
|
||||
*item_reps.entry(*item).or_default() += 1;
|
||||
item_rep.entry(*item).or_insert(id);
|
||||
}
|
||||
self.reps.insert(id, r);
|
||||
}
|
||||
let p = e.params();
|
||||
@@ -301,11 +325,13 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
"CONTEXT_DEPENDENT_SHAPE_REPRESENTATION" => {
|
||||
repeated = true;
|
||||
if let (Some(r), Some(d)) = (rf(0), rf(1)) {
|
||||
cdsrs.push((r, d));
|
||||
}
|
||||
}
|
||||
"NEXT_ASSEMBLY_USAGE_OCCURRENCE" | "ASSEMBLY_COMPONENT_USAGE" | "SPECIFIED_HIGHER_USAGE_OCCURRENCE" => {
|
||||
repeated = true;
|
||||
if let (Some(a), Some(b)) = (rf(3), rf(4)) {
|
||||
nauos.push((id, a, b));
|
||||
}
|
||||
@@ -344,6 +370,52 @@ impl<'a> Reader<'a> {
|
||||
self.children.entry(parent).or_default().push(id);
|
||||
}
|
||||
}
|
||||
// A file without component occurrences walks each product definition once, so a
|
||||
// representation reached from exactly one of them is walked once too. The walk follows
|
||||
// MAPPED_ITEM targets as the reader does: a mapping in one product can walk a
|
||||
// representation a second time from another product's node. An item that only one such
|
||||
// walk lists is converted exactly once and needs no cache entry; anything else keeps
|
||||
// every conversion, as before. A file too large to walk cheaply keeps every conversion
|
||||
// as well.
|
||||
if !repeated {
|
||||
let mut entries: HashMap<u64, usize> = HashMap::new();
|
||||
let mut seen: HashSet<u64> = HashSet::new();
|
||||
let mut queue: Vec<u64> = Vec::new();
|
||||
let mut work = 0usize;
|
||||
let mut within_budget = true;
|
||||
'pds: for reps in self.pd_reps.values() {
|
||||
seen.clear();
|
||||
queue.clear();
|
||||
queue.extend(self.rep_closure(reps));
|
||||
while let Some(r) = queue.pop() {
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
work += 1;
|
||||
if work > MAX_CLOSURE_WORK {
|
||||
within_budget = false;
|
||||
break 'pds;
|
||||
}
|
||||
*entries.entry(r).or_default() += 1;
|
||||
for item in self.reps.get(&r).map(|x| &x.0).into_iter().flatten() {
|
||||
queue.extend(self.mapped_targets(*item));
|
||||
}
|
||||
}
|
||||
}
|
||||
if within_budget {
|
||||
let mut once = HashSet::new();
|
||||
for (&item, &n) in &item_reps {
|
||||
if n != 1 {
|
||||
continue;
|
||||
}
|
||||
let Some(rep) = item_rep.get(&item) else { continue };
|
||||
if entries.get(rep).copied() == Some(1) {
|
||||
once.insert(item);
|
||||
}
|
||||
}
|
||||
self.once = once;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First colour reachable from a style list (bounded search).
|
||||
@@ -448,7 +520,20 @@ impl<'a> Reader<'a> {
|
||||
seen
|
||||
}
|
||||
|
||||
fn item_solid(&mut self, item: u64, u: Units) -> Option<(mt::Solid, bool, usize, Vec<u64>)> {
|
||||
/// Representations a MAPPED_ITEM walks (empty for any other item).
|
||||
fn mapped_targets(&self, item: u64) -> Vec<u64> {
|
||||
let Some(e) = self.ex.get(item).filter(|e| e.name() == "MAPPED_ITEM") else { return Vec::new() };
|
||||
let mapped = e
|
||||
.params()
|
||||
.get(1)
|
||||
.and_then(Param::as_ref_id)
|
||||
.and_then(|map| self.ex.get(map))
|
||||
.and_then(|map| map.params().get(1))
|
||||
.and_then(Param::as_ref_id);
|
||||
mapped.map(|rep| self.rep_closure(&[rep])).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn item_solid(&mut self, item: u64, u: Units) -> Option<Arc<SolidEntry>> {
|
||||
if let Some(r) = self.solids.get(&item) {
|
||||
return r.as_ref().ok().cloned();
|
||||
}
|
||||
@@ -471,8 +556,13 @@ impl<'a> Reader<'a> {
|
||||
Err(msg)
|
||||
}
|
||||
};
|
||||
self.solids.insert(item, r.clone());
|
||||
r.ok()
|
||||
let entry = r.map(Arc::new);
|
||||
// An item the file converts exactly once hands its only reference on and keeps no copy;
|
||||
// anything it may ask for again keeps its conversion.
|
||||
if !self.once.contains(&item) {
|
||||
self.solids.insert(item, entry.clone());
|
||||
}
|
||||
entry.ok()
|
||||
}
|
||||
|
||||
fn label(&self, item: u64) -> String {
|
||||
@@ -523,7 +613,14 @@ impl<'a> Reader<'a> {
|
||||
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 Some(entry) = self.item_solid(item, u) else { continue };
|
||||
// A one-use item is the only reference to its entry, so the solid moves
|
||||
// out; an item whose conversion is cached for a later instance needs its
|
||||
// own copy.
|
||||
let (solid, closed, file_faces, face_origin) = match Arc::try_unwrap(entry) {
|
||||
Ok(entry) => entry,
|
||||
Err(shared) => (*shared).clone(),
|
||||
};
|
||||
sources.push((item, closed, file_faces, face_origin));
|
||||
solids.push(parallel::Job { solid, check_mesh: !self.unmeshed_checked.contains(&item) });
|
||||
}
|
||||
|
||||
@@ -461,8 +461,9 @@ fn hand_written_part_in_inches_with_colour() {
|
||||
assert_eq!(imp.tree[0].name, "Widget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_written_assembly() {
|
||||
/// 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();
|
||||
@@ -489,6 +490,12 @@ fn hand_written_assembly() {
|
||||
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)");
|
||||
@@ -516,6 +523,43 @@ fn hand_written_assembly() {
|
||||
assert!((root.children[1].transform[3][0] - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// 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 = p21::parse(&flat).unwrap();
|
||||
let mut rd = Reader::new(&ex, 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 = p21::parse(&assembly).unwrap();
|
||||
let mut rd = Reader::new(&ex, 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);
|
||||
|
||||
Reference in new issue
Block a user