diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 22d0b7f4..e8280901 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -459,6 +459,8 @@ impl ModelState { pub struct EvalCache { map: std::collections::HashMap, order: std::collections::VecDeque, + /// 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, + /// 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 { 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::()).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(), }, ); diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index cdcc3c3b..7e2a3ac7 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -20,6 +20,13 @@ pub(crate) type Solid = mt::Solid; #[derive(Clone, Debug)] pub struct Body { pub(crate) solid: std::sync::Arc, + /// 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>>>, /// 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 { + 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 { + 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 { let mut seen = std::collections::HashSet::new(); diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs index 718f2489..e08411cf 100644 --- a/crates/kernel/src/meshbody.rs +++ b/crates/kernel/src/meshbody.rs @@ -103,6 +103,8 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result { } 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, diff --git a/crates/kernel/src/native.rs b/crates/kernel/src/native.rs index 490d17bd..585b924a 100644 --- a/crates/kernel/src/native.rs +++ b/crates/kernel/src/native.rs @@ -84,6 +84,8 @@ pub fn decode_native_body(bytes: &[u8]) -> Result { 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, diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index b9ff3c39..a072ab72 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -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>> { + // 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> = 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> { a.require_brep("a boolean")?; b.require_brep("a boolean")?; @@ -405,6 +444,33 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { // 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> { 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> { } 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> = 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) -> Result> { } } -/// 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 { 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 { 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; diff --git a/crates/kernel/src/prism_fast_path_tests.rs b/crates/kernel/src/prism_fast_path_tests.rs new file mode 100644 index 00000000..37231e0a --- /dev/null +++ b/crates/kernel/src/prism_fast_path_tests.rs @@ -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"); +} diff --git a/crates/kernel/src/record.rs b/crates/kernel/src/record.rs index dcb8cdef..5f6e2702 100644 --- a/crates/kernel/src/record.rs +++ b/crates/kernel/src/record.rs @@ -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>, + edges: Option<&HashMap>>, + flip: bool, + coarse: Option<(f64, usize)>, +) -> Result { + 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)> + 'a { edge_order(body.solid.face_iter()).0.into_iter().zip(&h.edges).map(|(e, p)| (e.id(), p.origins.clone())) diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index b948aba8..e4a82a34 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -500,6 +500,8 @@ pub(crate) fn split_edges_at(body: &Body, points: &[Vec3]) -> Result { 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, diff --git a/crates/kernel/src/step_in/brep.rs b/crates/kernel/src/step_in/brep.rs index 33f110eb..cd40bb3b 100644 --- a/crates/kernel/src/step_in/brep.rs +++ b/crates/kernel/src/step_in/brep.rs @@ -395,12 +395,29 @@ pub(crate) fn read_item(cx: &Ctx, id: u64) -> R { 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 }) } diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 6fa7eb57..6070e017 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -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); + struct Reader<'a> { ex: &'a Exchange, units: HashMap, @@ -234,8 +242,12 @@ struct Reader<'a> { colors: HashMap, /// Opacity of styled items with a transparency (1 = opaque). opacity: HashMap, - /// Converted items (brep id → solid, or the error). - solids: HashMap), 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, 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, used_items: HashSet, /// Items already instanced for the product being read. node_items: HashSet, @@ -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 = HashMap::new(); + let mut item_rep: HashMap = 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 = HashMap::new(); + let mut seen: HashSet = HashSet::new(); + let mut queue: Vec = 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)> { + /// Representations a MAPPED_ITEM walks (empty for any other item). + fn mapped_targets(&self, item: u64) -> Vec { + 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> { 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) }); } diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index 20827bc4..f943651a 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -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); diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 366fc14f..7579f025 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -31,6 +31,8 @@ pub(crate) fn surface_body(faces: Vec) -> 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, diff --git a/docs/perf.md b/docs/perf.md index e75ec9ea..99b78b92 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -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 --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 --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,