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
+42
-5
@@ -459,6 +459,8 @@ impl ModelState {
|
||||
pub struct EvalCache {
|
||||
map: std::collections::HashMap<u64, CachedEval>,
|
||||
order: std::collections::VecDeque<u64>,
|
||||
/// What the stored results cost together.
|
||||
bytes: usize,
|
||||
pub hits: usize,
|
||||
}
|
||||
|
||||
@@ -469,11 +471,20 @@ struct CachedEval {
|
||||
boolean_diagnostics: kernel::BooleanDiagnostics,
|
||||
ms: f64,
|
||||
state: Arc<ModelState>,
|
||||
/// What the state costs, worked out once when it was stored (see
|
||||
/// [`ModelState::retained_bytes`]).
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
/// Results kept (oldest dropped first).
|
||||
const CACHE_ENTRIES: usize = 4096;
|
||||
|
||||
/// Bytes of results kept (oldest dropped first). A cached result is as big as the model it
|
||||
/// holds, so bounding the cache by entry count alone let a long editing session keep every
|
||||
/// superseded state: the entries nothing else refers to any more are exactly the ones a byte
|
||||
/// budget drops.
|
||||
const CACHE_BYTES: usize = 64 << 20;
|
||||
|
||||
impl EvalCache {
|
||||
fn get(&mut self, key: u64) -> Option<CachedEval> {
|
||||
let r = self.map.get(&key).cloned();
|
||||
@@ -483,18 +494,43 @@ impl EvalCache {
|
||||
r
|
||||
}
|
||||
fn put(&mut self, key: u64, e: CachedEval) {
|
||||
if self.map.insert(key, e).is_none() {
|
||||
self.order.push_back(key);
|
||||
let bytes = e.bytes;
|
||||
match self.map.insert(key, e) {
|
||||
Some(old) => self.bytes = self.bytes.saturating_sub(old.bytes),
|
||||
None => self.order.push_back(key),
|
||||
}
|
||||
while self.order.len() > CACHE_ENTRIES {
|
||||
if let Some(k) = self.order.pop_front() {
|
||||
self.map.remove(&k);
|
||||
self.bytes = self.bytes.saturating_add(bytes);
|
||||
// One result is always kept, so a single state larger than the budget cannot empty the
|
||||
// cache and force a full recompute on every undo.
|
||||
while self.order.len() > CACHE_ENTRIES || (self.bytes > CACHE_BYTES && self.order.len() > 1) {
|
||||
if let Some(k) = self.order.pop_front()
|
||||
&& let Some(old) = self.map.remove(&k)
|
||||
{
|
||||
self.bytes = self.bytes.saturating_sub(old.bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SolvedSketch {
|
||||
/// Rough heap footprint (see [`ModelState::retained_bytes`]).
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
let entities = self.sketch.points.len() + self.sketch.curves.len() + self.sketch.constraints.len() + self.sketch.links.len();
|
||||
let loops: usize = self.profiles.iter().map(|p| p.region.outer.segs.len() + p.region.holes.iter().map(|h| h.segs.len()).sum::<usize>()).sum();
|
||||
entities * 192 + loops * 64 + 512
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
/// Rough heap footprint, for the evaluation cache's byte budget. The bodies' B-rep
|
||||
/// topology is what makes a kept timeline snapshot expensive, so it dominates; sketches
|
||||
/// and the rest are counted from their entity counts.
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
let bodies: usize = self.bodies.iter().map(|b| b.body.retained_bytes()).sum();
|
||||
let sketches: usize = self.sketches.iter().map(|s| s.retained_bytes()).sum();
|
||||
bodies + sketches + self.sheets.len() * 4096 + self.threads.len() * 192 + self.construct.len() * 192
|
||||
}
|
||||
|
||||
pub fn body(&self, name: &str) -> Option<&ModelBody> {
|
||||
self.bodies.iter().find(|b| b.name == name)
|
||||
}
|
||||
@@ -1048,6 +1084,7 @@ impl Model {
|
||||
warning: warning.clone(),
|
||||
boolean_diagnostics: boolean_diagnostics.clone(),
|
||||
ms,
|
||||
bytes: state.retained_bytes(),
|
||||
state: state.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
+96
-12
@@ -20,6 +20,13 @@ pub(crate) type Solid = mt::Solid;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Body {
|
||||
pub(crate) solid: std::sync::Arc<Solid>,
|
||||
/// Bounding-box diagonal, measured by [`Body::new`] while it read the vertices anyway; 0
|
||||
/// means "not measured, walk the geometry". Every kernel tolerance is a fraction of it.
|
||||
pub(crate) size: f64,
|
||||
/// The last volume measured for this solid and the tolerance it was measured at, shared by
|
||||
/// clones because they share the solid. A boolean measures its operands, and a later boolean
|
||||
/// takes the result as an operand again, so the same body is measured twice otherwise.
|
||||
pub(crate) volume: Option<std::sync::Arc<std::sync::Mutex<Option<(u64, f64)>>>>,
|
||||
/// Missing-face check from the orientation mesh, reusable at exactly its tolerance.
|
||||
pub(crate) coarse_missing: Option<(f64, usize)>,
|
||||
/// Set for mesh bodies (`solid` is then empty).
|
||||
@@ -441,6 +448,25 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
|
||||
(pts.first().copied().unwrap_or_default(), len)
|
||||
}
|
||||
|
||||
/// Deflection the orientation mesh uses: coarse enough for a large solid, fine enough that a
|
||||
/// small one still tessellates. `unmeshed_faces` counts at the same value, so the count the
|
||||
/// orientation mesh produced is reused instead of meshing the body again.
|
||||
fn orientation_tolerance(span: f64) -> f64 {
|
||||
(span * 0.01).clamp(1e-3, 1.0)
|
||||
}
|
||||
|
||||
/// Bounding-box diagonal of a solid's vertices (1 when it has no finite extent).
|
||||
fn span_of(solid: &Solid) -> f64 {
|
||||
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = -lo;
|
||||
for v in solid.vertex_iter() {
|
||||
let p = from_p3(v.point());
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// The very same body (shared data), not just an equal-looking one. Its history (how an
|
||||
/// operation made it) is not part of it: an operation that leaves a body as it is (a cut by
|
||||
@@ -466,17 +492,8 @@ impl Body {
|
||||
let mut solid = rephase_revolved(solid);
|
||||
// Orient outward: a closed solid must have positive volume (meshed finely enough that
|
||||
// every face comes out: a face missing from a coarse mesh can flip the sign).
|
||||
let span = {
|
||||
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = -lo;
|
||||
for v in solid.vertex_iter() {
|
||||
let p = from_p3(v.point());
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
|
||||
};
|
||||
let tol = (span * 0.01).clamp(1e-3, 1.0);
|
||||
let span = span_of(&solid);
|
||||
let tol = orientation_tolerance(span);
|
||||
let orient = |tol: f64| {
|
||||
guard("orient", || {
|
||||
let shells = mesh_shells(&solid, tol);
|
||||
@@ -502,6 +519,8 @@ impl Body {
|
||||
}
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: (v >= 0.0).then_some((tol, missing)),
|
||||
mesh: None,
|
||||
color: None,
|
||||
@@ -513,6 +532,35 @@ impl Body {
|
||||
})
|
||||
}
|
||||
|
||||
/// Like [`Body::new`], for a solid made by placing an already-oriented body (see
|
||||
/// [`crate::ops::transform_matrix`]): a placement keeps the faces pointing the way they did
|
||||
/// unless it mirrors them, so its orientation is read from the matrix rather than meshed
|
||||
/// again. Only for closed solids; `coarse` is the input's cached missing-face count, which a
|
||||
/// rigid placement preserves exactly.
|
||||
pub(crate) fn new_placed(solid: Solid, flip: bool, coarse: Option<(f64, usize)>) -> Result<Body> {
|
||||
if solid.boundaries().is_empty() {
|
||||
return Err(KernelError::Failed("empty result".into()));
|
||||
}
|
||||
let mut solid = rephase_revolved(solid);
|
||||
let span = span_of(&solid);
|
||||
if flip {
|
||||
solid.not();
|
||||
}
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: coarse,
|
||||
mesh: None,
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
history: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
self
|
||||
@@ -843,8 +891,13 @@ impl Body {
|
||||
self
|
||||
}
|
||||
|
||||
/// Characteristic size (bounding box diagonal) for tolerances.
|
||||
/// Characteristic size (bounding box diagonal) for tolerances. [`Body::new`] measured it
|
||||
/// while it read the vertices anyway, so the answer is cached; a body built by hand carries
|
||||
/// 0 and is walked here once, and mesh bodies always measure their triangles.
|
||||
pub fn size(&self) -> f64 {
|
||||
if self.mesh.is_none() && self.size > 0.0 {
|
||||
return self.size;
|
||||
}
|
||||
let mut b = solidcraft_geom::Aabb3::EMPTY;
|
||||
if let Some(m) = &self.mesh {
|
||||
for p in &m.positions {
|
||||
@@ -858,6 +911,37 @@ impl Body {
|
||||
if d.is_finite() && d > 0.0 { d } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The body's volume from a mesh at `tol`, measured once per (solid, tolerance): clones of
|
||||
/// a body share the measurement, so an operand a later boolean takes again is not meshed
|
||||
/// twice for the same number. `None` when the body cannot be meshed.
|
||||
pub fn volume_at(&self, tol: f64) -> Option<f64> {
|
||||
let key = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
|
||||
let key = key.to_bits();
|
||||
if let Some(cell) = &self.volume
|
||||
&& let Ok(cached) = cell.lock()
|
||||
&& let Some((cached_tol, volume)) = *cached
|
||||
&& cached_tol == key
|
||||
{
|
||||
return Some(volume);
|
||||
}
|
||||
let volume = self.tessellate(f64::from_bits(key)).ok()?.measure().volume;
|
||||
if let Some(cell) = &self.volume
|
||||
&& let Ok(mut slot) = cell.lock()
|
||||
{
|
||||
*slot = Some((key, volume));
|
||||
}
|
||||
Some(volume)
|
||||
}
|
||||
|
||||
/// Rough heap footprint, for the evaluation cache's byte budget: the B-rep topology is what
|
||||
/// makes a kept timeline snapshot expensive, so it dominates the estimate.
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
if let Some(m) = &self.mesh {
|
||||
return m.positions.len() * 24 + m.triangles.len() * 12;
|
||||
}
|
||||
self.solid.face_iter().count() * 2048 + self.solid.edge_iter().count() * 512 + self.solid.vertex_iter().count() * 96
|
||||
}
|
||||
|
||||
/// Distinct edges in a stable order (the order of `edges()` and `tessellate().edges`).
|
||||
pub(crate) fn unique_edges(solid: &Solid) -> Vec<mt::Edge> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
@@ -103,6 +103,8 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
}
|
||||
Ok(Body {
|
||||
solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())),
|
||||
size: 0.0,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: Some(Arc::new(out)),
|
||||
color: None,
|
||||
@@ -197,6 +199,8 @@ impl Body {
|
||||
}
|
||||
Some(Body {
|
||||
solid: self.solid.clone(),
|
||||
size: 0.0,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: Some(Arc::new(out)),
|
||||
color: self.color,
|
||||
|
||||
@@ -84,6 +84,8 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
|
||||
let solid = adapter::decode(saved.solid)?;
|
||||
let mut body = Body {
|
||||
solid: Arc::new(solid),
|
||||
size: 0.0,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: None,
|
||||
|
||||
+112
-38
@@ -35,7 +35,9 @@ pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
|
||||
const JITTER: [[f64; 3]; 4] = [[0.0, 0.0, 0.0], [0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]];
|
||||
|
||||
fn volume(b: &Body) -> f64 {
|
||||
b.tessellate(b.size() * 2e-3).map(|m| m.measure().volume).unwrap_or(f64::NAN)
|
||||
// Measured once per (solid, tolerance) and shared by clones, so an operand a later
|
||||
// boolean takes again is not meshed twice for the same number.
|
||||
b.volume_at(b.size() * 2e-3).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Halton low-discrepancy value (base `b`) for index `i`.
|
||||
@@ -393,6 +395,43 @@ fn meeting_size(a: &Body, b: &Body) -> f64 {
|
||||
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
|
||||
}
|
||||
|
||||
/// Whether a result volume is plausible for `op`: within the bounds the operands' volumes
|
||||
/// leave, plus the slack the meshed volumes are measured to.
|
||||
fn plausible(v: f64, op: BoolOp, va: f64, vb: f64, slack: f64) -> bool {
|
||||
v > 0.0
|
||||
&& match op {
|
||||
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
|
||||
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
|
||||
BoolOp::Intersect => v <= va.min(vb) + slack,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prism route's result: `Some` when both operands are prisms along one direction (see
|
||||
/// [`crate::prism::prism_boolean`]), with a union's slab seams healed as the general B-rep
|
||||
/// boolean does.
|
||||
fn prism_result(a: &Body, b: &Body, op: BoolOp, size: f64, counts: &[(usize, usize)]) -> Option<Result<Option<Body>>> {
|
||||
// Slab-by-slab prism booleans sew a closed solid, but leave cap and wall pieces along slab
|
||||
// boundaries: a cut along Z through a box whose side wall already has a port in it splits
|
||||
// that wall into strips at the port's top and bottom, though the cut never touches it. Merge
|
||||
// those coplanar faces for every operation, just as the general B-rep boolean does, retaining
|
||||
// intentional Split Face lines. Authored boundaries (painted faces, regularization
|
||||
// protection) keep their pieces.
|
||||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
let authored = |x: &Body| x.regularization_protected || x.paint().is_some_and(|p| !p.faces.is_empty());
|
||||
crate::prism::prism_boolean(a, b, op).map(|result| {
|
||||
result.map(|body| {
|
||||
body.map(|body| {
|
||||
if op == BoolOp::Union {
|
||||
guard("heal prism boolean", || recorded::heal_body(&body, (*body.solid).clone(), size, &keep, counts)).unwrap_or(body)
|
||||
} else if authored(a) || authored(b) {
|
||||
body
|
||||
} else {
|
||||
guard("heal prism boolean", || recorded::heal_prism_result(&body, size, &keep, counts)).unwrap_or(body)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
@@ -405,6 +444,33 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
// floor) is otherwise spanned by triangles cutting through it, which reads as missing
|
||||
// material.
|
||||
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
|
||||
// A prism pair (a plate and the tool through it) is decided exactly in 2D, with no mesh of
|
||||
// either operand. The sampled shortcuts below need a refined mesh of both, and that mesh is
|
||||
// most of what a boolean costs: a pattern or a multi-profile sketch rebuilds the whole
|
||||
// target's mesh once per tool. Only a cut or an intersection meets its tool often enough to
|
||||
// be worth asking first - parts that stay apart are settled far more cheaply by the shell
|
||||
// join the sampled shortcut does. A pair the route cannot answer still takes the path below,
|
||||
// unchanged.
|
||||
let early_prism = matches!(op, BoolOp::Cut | BoolOp::Intersect);
|
||||
let prism = early_prism.then(|| prism_result(a, b, op, size, &counts)).flatten();
|
||||
if let Some(Ok(Some(body))) = &prism {
|
||||
let (va0, vb0) = (volume(a), volume(b));
|
||||
let (v0, slack0) = (volume(body), 2e-3 * (va0 + vb0) + 1e-9);
|
||||
// A tool that misses its target, or covers it whole, leaves it as it is, and the
|
||||
// sampled shortcut below answers that by reusing the operand itself: the very same body.
|
||||
// The 2D route rebuilds it instead - the same shape on new faces - which would turn an
|
||||
// untouched body in a timeline into a different solid. Those pairs go back to the path
|
||||
// below, which decides them, and the identity they keep, exactly as it always has.
|
||||
let unchanged = 1e-4 * (va0 + vb0);
|
||||
let rebuilt_from_an_operand = match op {
|
||||
BoolOp::Cut => v0 >= va0 - unchanged,
|
||||
BoolOp::Intersect => (v0 - va0).abs() <= unchanged || (v0 - vb0).abs() <= unchanged,
|
||||
BoolOp::Union => false,
|
||||
};
|
||||
if !rebuilt_from_an_operand && plausible(v0, op, va0, vb0, slack0) {
|
||||
return Ok(Some(body.clone()));
|
||||
}
|
||||
}
|
||||
let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok());
|
||||
let (va, vb) = match &meshes {
|
||||
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
|
||||
@@ -429,12 +495,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
let check = |r: &Body| -> (bool, f64) {
|
||||
let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) };
|
||||
let v = mr.measure().volume;
|
||||
let bounds = v > 0.0
|
||||
&& match op {
|
||||
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
|
||||
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
|
||||
BoolOp::Intersect => v <= va.min(vb) + slack,
|
||||
};
|
||||
let bounds = plausible(v, op, va, vb, slack);
|
||||
if !bounds {
|
||||
return (false, v);
|
||||
}
|
||||
@@ -461,42 +522,26 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
}
|
||||
let mut last = String::new();
|
||||
// Coincident faces make the intersection fail after many retries; push them apart first.
|
||||
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume
|
||||
// bounds are checked).
|
||||
if let Some(Ok(r)) = crate::prism::prism_boolean(a, b, op) {
|
||||
// Slab-by-slab prism booleans sew a closed solid, but leave cap and wall pieces
|
||||
// along slab boundaries: a cut along Z through a box whose side wall already has a
|
||||
// port in it splits that wall into strips at the port's top and bottom, though the
|
||||
// cut never touches it. Merge those coplanar faces for every operation, just as the
|
||||
// general B-rep boolean does, retaining intentional Split Face lines. Authored
|
||||
// boundaries (painted faces, regularization protection) keep their pieces.
|
||||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
let authored = |x: &Body| x.regularization_protected || x.paint().is_some_and(|p| !p.faces.is_empty());
|
||||
let r = if op == BoolOp::Union {
|
||||
r.map(|body| guard("heal prism boolean", || recorded::heal_body(&body, (*body.solid).clone(), size, &keep, &counts)).unwrap_or(body))
|
||||
} else if authored(a) || authored(b) {
|
||||
r
|
||||
} else {
|
||||
r.map(|body| guard("heal prism boolean", || recorded::heal_prism_result(&body, size, &keep, &counts)).unwrap_or(body))
|
||||
};
|
||||
match &r {
|
||||
Some(body) => {
|
||||
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume bounds
|
||||
// are checked). For a cut or an intersection this already ran above; the other operations
|
||||
// ask here, after the sampled shortcut, which is where that shortcut is cheapest.
|
||||
let prism = prism.or_else(|| (!early_prism).then(|| prism_result(a, b, op, size, &counts)).flatten());
|
||||
if let Some(result) = prism {
|
||||
match result {
|
||||
Ok(Some(body)) => {
|
||||
// Measured as the operands were: a coarser mesh of a plate with many holes
|
||||
// overstates its volume by more than the slack.
|
||||
let v = match &meshes {
|
||||
(Some(_), Some(_)) => body.tessellate_with(tol_m, true).map(|m| m.measure().volume).unwrap_or(f64::NAN),
|
||||
_ => volume(body),
|
||||
_ => volume(&body),
|
||||
};
|
||||
let bounds = match op {
|
||||
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
|
||||
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
|
||||
BoolOp::Intersect => v <= va.min(vb) + slack,
|
||||
};
|
||||
if v > 0.0 && bounds {
|
||||
return Ok(r);
|
||||
if plausible(v, op, va, vb, slack) {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
None => return Ok(None),
|
||||
Ok(None) => return Ok(None),
|
||||
// A failed prism attempt keeps trying the other strategies, as it did before.
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// Bodies touching along one whole face (a part joined with its mirror image).
|
||||
@@ -855,7 +900,20 @@ pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
|
||||
/// Determinant of the linear part of a column-major 4x4 matrix: negative means the placement
|
||||
/// mirrors, which turns a solid inside out.
|
||||
fn det3(m: &[[f64; 4]; 4]) -> f64 {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
|
||||
/// Whether the linear part is a rigid motion (rotation or reflection: no scale or shear).
|
||||
fn rigid_placement(m: &[[f64; 4]; 4]) -> bool {
|
||||
let r = [Vec3::new(m[0][0], m[0][1], m[0][2]), Vec3::new(m[1][0], m[1][1], m[1][2]), Vec3::new(m[2][0], m[2][1], m[2][2])];
|
||||
(0..3).all(|i| (r[i].dot(r[i]) - 1.0).abs() < 1e-9) && r[0].dot(r[1]).abs() < 1e-9 && r[0].dot(r[2]).abs() < 1e-9 && r[1].dot(r[2]).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// Affine transform by a column-major 4x4 matrix (rotations, translations, reflections).
|
||||
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
if m.iter().flatten().any(|x| !x.is_finite()) {
|
||||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||||
@@ -879,7 +937,19 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
for (copy, face) in solid.face_iter().zip(body.solid.face_iter()) {
|
||||
rec.whole(copy, face);
|
||||
}
|
||||
let placed = record::rebuilt(solid, &[&body.solid], &rec)?;
|
||||
let (faces, edges) = rec.history(&solid, &[&body.solid]).unzip();
|
||||
let counts = record::solid_counts(&[&body.solid]);
|
||||
// A rigid placement leaves every face's mesh, and so the missing-face count, as it was;
|
||||
// a mirrored one turns the solid inside out. Either way the orientation is read from the
|
||||
// matrix instead of meshing the whole copy again.
|
||||
let placed = record::build_placed(
|
||||
solid,
|
||||
&counts,
|
||||
faces,
|
||||
edges.as_ref(),
|
||||
det3(&m) < 0.0,
|
||||
rigid_placement(&m).then_some(body.coarse_missing).flatten(),
|
||||
)?;
|
||||
Ok(placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body))
|
||||
})
|
||||
}
|
||||
@@ -1031,6 +1101,10 @@ pub(crate) fn half_box(body: &Body, plane: &solidcraft_geom::Plane, positive: bo
|
||||
crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "prism_fast_path_tests.rs"]
|
||||
mod prism_fast_path_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "placement_tests.rs"]
|
||||
mod placement_tests;
|
||||
@@ -0,0 +1,59 @@
|
||||
//! The prism fast path: `boolean_whole` asks the exact 2D prism route before it meshes both
|
||||
//! operands, so a plate and the tool through it are decided without a mesh of either.
|
||||
use super::*;
|
||||
use crate::{BoolOp, boolean, box_solid, cylinder, measure};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// A cylindrical tool through a plate: the exact prism route answers the pair, and the material
|
||||
/// it removes is the tool's own.
|
||||
#[test]
|
||||
fn a_hole_through_a_plate_removes_the_tool_volume() {
|
||||
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).expect("plate");
|
||||
let tool = cylinder(Vec3::new(20.0, 15.0, -1.0), Vec3::Z, 5.0, 12.0).expect("tool");
|
||||
let cut = boolean(&plate, &tool, BoolOp::Cut).expect("cut").expect("a hole leaves material");
|
||||
let m = measure(&cut).expect("measure");
|
||||
let want = 40.0 * 30.0 * 10.0 - PI * 25.0 * 10.0;
|
||||
assert!((m.volume - want).abs() < want * 5e-3, "{} vs {want}", m.volume);
|
||||
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
|
||||
}
|
||||
|
||||
/// Boxes that stay apart: the union keeps both, whichever route decides it.
|
||||
#[test]
|
||||
fn bodies_that_stay_apart_join_into_one_body_with_both_lumps() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).expect("a");
|
||||
let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).expect("b");
|
||||
let joined = boolean(&a, &b, BoolOp::Union).expect("union").expect("two boxes");
|
||||
assert!((measure(&joined).expect("measure").volume - 2000.0).abs() < 1e-6);
|
||||
assert_eq!(joined.lumps().expect("lumps").len(), 2);
|
||||
}
|
||||
|
||||
/// A tool wholly inside its target leaves a closed void, not a through cut.
|
||||
#[test]
|
||||
fn a_tool_wholly_inside_leaves_a_closed_void() {
|
||||
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 40.0)).expect("block");
|
||||
let inner = box_solid(Vec3::new(15.0, 15.0, 15.0), Vec3::new(25.0, 25.0, 25.0)).expect("inner");
|
||||
let cut = boolean(&block, &inner, BoolOp::Cut).expect("cut").expect("a void leaves material");
|
||||
assert!((measure(&cut).expect("measure").volume - 63000.0).abs() < 1e-6);
|
||||
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
|
||||
}
|
||||
|
||||
/// A tool that misses its target, in section or along the extrusion, leaves it as it is: the
|
||||
/// sampled shortcut answers that by reusing the operand itself, and the 2D route must not
|
||||
/// rebuild it into a look-alike solid on the way past - an untouched body in a timeline has to
|
||||
/// stay the very same body.
|
||||
#[test]
|
||||
fn a_cut_that_changes_nothing_keeps_the_original_body() {
|
||||
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).expect("plate");
|
||||
let past_the_end = box_solid(Vec3::new(200.0, 200.0, -5.0), Vec3::new(210.0, 210.0, 20.0)).expect("tool");
|
||||
assert!(
|
||||
boolean(&plate, &past_the_end, BoolOp::Cut).expect("cut").expect("material stays").same(&plate),
|
||||
"a tool clear of the plate must leave the very same body"
|
||||
);
|
||||
let inside_the_height = box_solid(Vec3::new(200.0, 200.0, 5.0), Vec3::new(210.0, 210.0, 20.0)).expect("tool");
|
||||
assert!(
|
||||
boolean(&plate, &inside_the_height, BoolOp::Cut).expect("cut").expect("material stays").same(&plate),
|
||||
"a tool that only overlaps the plate's height must leave the very same body"
|
||||
);
|
||||
let covers_it = box_solid(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(50.0, 40.0, 20.0)).expect("tool");
|
||||
assert!(boolean(&plate, &covers_it, BoolOp::Cut).expect("cut").is_none(), "a tool over the whole plate removes it");
|
||||
}
|
||||
@@ -136,6 +136,20 @@ pub(crate) fn build(
|
||||
Ok(traced(Body::new(solid)?, counts, faces, edges))
|
||||
}
|
||||
|
||||
/// [`build`] for a solid made by placing an already-oriented copy: the orientation comes from
|
||||
/// the placement matrix instead of meshing the whole solid again (see
|
||||
/// [`crate::body::Body::new_placed`]).
|
||||
pub(crate) fn build_placed(
|
||||
solid: crate::body::Solid,
|
||||
counts: &[(usize, usize)],
|
||||
faces: Option<Vec<FaceProv>>,
|
||||
edges: Option<&HashMap<mt::EdgeID, Vec<EdgeOrigin>>>,
|
||||
flip: bool,
|
||||
coarse: Option<(f64, usize)>,
|
||||
) -> Result<Body> {
|
||||
Ok(traced(Body::new_placed(solid, flip, coarse)?, counts, faces, edges))
|
||||
}
|
||||
|
||||
/// The origins of `body`'s edges by identity, from its history `h` (edges in its edge order).
|
||||
pub(crate) fn edges_by_identity<'a>(body: &'a Body, h: &'a History) -> impl Iterator<Item = (mt::EdgeID, Vec<EdgeOrigin>)> + 'a {
|
||||
edge_order(body.solid.face_iter()).0.into_iter().zip(&h.edges).map(|(e, p)| (e.id(), p.origins.clone()))
|
||||
|
||||
@@ -500,6 +500,8 @@ pub(crate) fn split_edges_at(body: &Body, points: &[Vec3]) -> Result<Body> {
|
||||
let solid = mt::Solid::new_unchecked(shells);
|
||||
let split = Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: 0.0,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: body.color,
|
||||
@@ -996,6 +998,8 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
|
||||
let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect());
|
||||
let split = Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: 0.0,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: body.color,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -31,6 +31,8 @@ pub(crate) fn surface_body(faces: Vec<mt::Face>) -> Body {
|
||||
fn surface_of(shell: mt::Shell) -> Body {
|
||||
Body {
|
||||
solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])),
|
||||
size: 0.0,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: None,
|
||||
|
||||
+122
-1
@@ -1,5 +1,45 @@
|
||||
# Design loading
|
||||
|
||||
## STEP import: shells and item solids without copies (2026-10-13)
|
||||
|
||||
A file that lists one product definition many times (a bolt used 70 times, a panel in several
|
||||
assemblies) is imported item by item, and every item's B-rep was deep copied twice before it was
|
||||
handed on: once by the caller of `Solid::try_new`, which clones the shells so that the failure
|
||||
path still has them, and once by the reader's own cache, which kept the solid for a later item
|
||||
that might list the same definition.
|
||||
|
||||
`read_item` now runs those four checks (non-empty, connected, closed, no singular vertices) on
|
||||
borrowed shells, so a shell that passes is moved into the solid instead of cloned. The reader
|
||||
keeps each prepared solid in an `Arc`, and an item counts as *once* when one representation
|
||||
lists it and exactly one product-definition walk reaches it: that item's solid is moved out of
|
||||
the cache with `Arc::try_unwrap` instead of copied, because no later item can ask for it. The
|
||||
walk follows `MAPPED_ITEM` targets too, so an item reached through a mapping in another product's
|
||||
node is still converted once - a second conversion would repeat that item's warnings. A file
|
||||
whose walk exceeds the work budget (`MAX_CLOSURE_WORK`, 65 536 nodes) falls back wholesale to
|
||||
the copying path, as does any file with component occurrences, which walks a definition again per
|
||||
instance.
|
||||
|
||||
Nothing observable changed. Fifteen fixtures (primitives, a void, a fillet, a revolve, colours,
|
||||
centimetre units, 70 duplicates of one definition, a broken file with mappings, a two-instance
|
||||
assembly and an expanded one; 99 bodies) give the same import dump - name, path, closed flag,
|
||||
file faces, face origin, topology and merged counts, bounding box, volume, area, centroid, paint,
|
||||
warnings and tree - apart from the per-feature milliseconds, and all fifteen STEP in/out round
|
||||
trips are byte-identical.
|
||||
|
||||
Two generated flat STEP files, timed like the table above (same binary pair, no other work
|
||||
running); four alternating runs each, two for the 31 MB file:
|
||||
|
||||
| File | Baseline peak RSS | Patched peak RSS | Import |
|
||||
|---|---:|---:|---:|
|
||||
| 2500 boxes, 9.5 MB | 150.1 MB | 145.9 MB | 1.81 s → 1.74 s |
|
||||
| 2500 filleted boxes, 31 MB | 428 MB | 433 MB | 70.3 s → 71.0 s |
|
||||
|
||||
The first row's ranges do not overlap (150.0-150.6 MB against 145.0-146.0 MB), and the saving is
|
||||
the shell copy that is no longer made for every item. The second file's runs land between 422 MB
|
||||
and 444 MB either way, so two runs each do not resolve it: there the conversion of the geometry
|
||||
dominates the per-item copy. An item that keeps every conversion - an assembly, or a file over the
|
||||
work budget - is unchanged.
|
||||
|
||||
## Parallel STEP body preparation (2026-10-12)
|
||||
|
||||
Profiling an original 48-cylinder STEP fixture before changing the importer measured
|
||||
@@ -438,7 +478,7 @@ each body is identified by the evaluation that produced it (bodies a feature pas
|
||||
unchanged keep their identity). The same feature on the same inputs is never evaluated twice:
|
||||
setting a value back, undo, redo, a preview followed by the same command, and the repeated
|
||||
evaluations while a script builds the design all reuse earlier results. The cache is shared by a
|
||||
model and its copies (previews, scratch sessions) and keeps the last 4096 results.
|
||||
model and its copies (previews, scratch sessions), and keeps the last 4096 results within 64 MiB.
|
||||
|
||||
An edit still recomputes the edited feature and every later feature whose starting model
|
||||
changed; in these parts every later feature works on the edited body, so early edits recompute
|
||||
@@ -450,6 +490,87 @@ its inputs are not exactly the old ones. Oracle 59's first parameter edit fails
|
||||
expression the bench's `* 1.02` can't scale) and is left out.
|
||||
|
||||
|
||||
## Prism pairs, body measurements and the evaluation cache budget (2026-10-13)
|
||||
|
||||
A design whose features repeat one shape (a hole pattern, a plate cut by many tools, a part built
|
||||
by one growing body) pays several per-feature costs that a rebuild was paying again and again.
|
||||
|
||||
**A body's size is measured once, not once per call.** Every kernel tolerance is a fraction of
|
||||
the body's bounding-box diagonal, and `Body::size()` walked every vertex of the solid to work it
|
||||
out. `Body::new` already reads those vertices to orient the body, so it keeps the diagonal it
|
||||
measured there; a body built by hand, or a mesh body, still measures once.
|
||||
|
||||
**The same volume is not measured twice.** A boolean measures both operands' volumes to bound its
|
||||
result, and the body it returns becomes the next feature's operand, so a timeline of *n* cuts
|
||||
meshed every intermediate body twice. `Body::volume_at(tol)` keeps the last measurement for the
|
||||
solid, shared by its clones (they share the solid), and the bounds ask at the same tolerance every
|
||||
time.
|
||||
|
||||
**A prism pair is decided in 2D before either operand is meshed.** The general boolean builds a
|
||||
refined mesh of both operands before it can decide anything, and that mesh is most of its cost: a
|
||||
pattern, or a sketch with several profiles, rebuilds the whole target's mesh once per tool. A
|
||||
plate and a tool through it are prisms along one direction, which `prism::prism_boolean` decides
|
||||
exactly from their 2D sections - and that route ran *after* the meshes. A cut or an intersection
|
||||
now asks it first and returns without building them. A pair the route cannot answer takes the old
|
||||
path unchanged, meshes and all; a union still asks after the sampled shortcut. One answer is sent
|
||||
back to that shortcut on purpose: when the 2D route returns one of its operands (a tool that
|
||||
misses its target, or covers it whole), the sampled path returns that very body, so an untouched
|
||||
body keeps its identity in the timeline (`ops::prism_fast_path_tests`).
|
||||
|
||||
**A placement reads its orientation from its matrix.** Placing a body (a pattern instance, an
|
||||
aligned copy) used to mesh the whole copy to decide which way its faces point and to count the
|
||||
faces its coarse mesh misses. A rigid placement leaves both as they were; a mirrored one (a
|
||||
negative determinant) turns the solid inside out. Both are now read from the matrix, so only the
|
||||
geometry is transformed.
|
||||
|
||||
**The evaluation cache is bounded by the bytes it keeps.** It held the last 4096 results, and a
|
||||
result is as big as the model it holds, so a long editing session kept every superseded state:
|
||||
nothing dropped the entries nothing referred to any more. It now also holds at most 64 MiB, oldest
|
||||
first, and always keeps the newest result, so a single evaluation is never thrown away in the same
|
||||
breath.
|
||||
|
||||
Release `solidcraft-cli`, `eval <design> --quiet`, wall time of the whole process and peak RSS
|
||||
from `/usr/bin/time -v`, alternating the baseline and patched binaries, median of 12 runs each
|
||||
(8 for the 400-hole plate and the vent plate), no other build or test running (Fedora Linux, AMD
|
||||
Ryzen 7 5700X, 8 cores / 16 threads, 32 GB):
|
||||
|
||||
| Design | Baseline | Patched | Change | Baseline peak RSS | Patched peak RSS |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| 400 × 400 × 6 mm box, one sketch with 100 circles cut through it | 1.24 s | 0.83 s | -33% | 151 MB | 119 MB |
|
||||
| The same plate with 400 circles in the sketch | 5.67 s | 4.04 s | -29% | 556 MB | 481 MB |
|
||||
| Vent plate, a 10 × 8 pattern of one cut | 0.10 s | 0.08 s | -20% | 22 MB | 22 MB |
|
||||
| 14 × 14 pattern of a filleted box (196 copies) | 6.70 s | 6.52 s | -2.6% | 67 MB | 67 MB |
|
||||
| 60 sketches, 60 extrudes into one growing body | 8.80 s | 8.88 s | +0.9% | 225 MB | 225 MB |
|
||||
| `examples/acceptance/sensor_enclosure.json` | 3.81 s | 3.30 s | -13% | 198 MB | 196 MB |
|
||||
|
||||
The hole plates are what the prism route is for: one boolean whose tool is a prism with 100 or 400
|
||||
sections, in a sketch with that many circles, so the tool's mesh alone is what the route saves.
|
||||
A variant of the patched tree with the route asked in the old order - after the operands' meshes -
|
||||
timed the 100-hole plate at 1.27 s and 157 MB (range 1.22-1.30 s), the baseline's own numbers
|
||||
(1.25 s and 156 MB over the same runs, range 1.21-1.33 s). The two plate rows, and the peak they
|
||||
lose, are the prism route.
|
||||
|
||||
The last three designs are the other side: their booleans are not that case. The 196-copy design
|
||||
and the 60-sketch design are within 2.6% and 0.9% with overlapping ranges. The sensor enclosure's
|
||||
medians differ by 13%, but its runs land anywhere between 3.0 s and 5.0 s and the two ranges
|
||||
overlap (3.01-4.97 s against 3.00-4.89 s), so its row is not a result either way and is left as
|
||||
measured. The body measurements, the placement and the cache budget are not what these rows
|
||||
measure: they stop work the corpus repeats too rarely to see (a hidden vertex walk, an operand
|
||||
meshed twice for one number, an orientation mesh per placement, a cache with no byte bound).
|
||||
|
||||
No model changed. The evaluation dump of 21 designs (10 to 400 circles in a plate, 16 × 16 to
|
||||
31 × 31 box grids with and without fillets, 2500-body grids, assemblies of 40 to 500 bodies, 30 and
|
||||
60 sketches, a 196-instance pattern, the bundled sample and the sensor enclosure) is the same model
|
||||
before and after - bodies, volumes, areas, centres, bounding boxes, topology counts and warnings -
|
||||
apart from the per-feature milliseconds. The
|
||||
kernel's test suite passes, including four new tests for the prism route and its identity rule.
|
||||
|
||||
Reproduce after compiling a release build, without other builds running: time
|
||||
`target/release/solidcraft-cli eval <design> --quiet` with `/usr/bin/time -v`, alternating the
|
||||
baseline and patched binaries, and compare the middle of the runs. The corpus (20 generated designs:
|
||||
hole plates, box grids, assemblies, many-sketch parts) and the measuring scripts are in
|
||||
https://github.com/sahenjp/solidcraft-perf under `perf/`.
|
||||
|
||||
## Desktop loading and recent thumbnails (2026-10-10)
|
||||
|
||||
The Arduino Uno enclosure (19 features, 34 KB) and Raspberry Pi 5 case (24 features,
|
||||
|
||||
Reference in new issue
Block a user