diff --git a/crates/solidcore/Cargo.toml b/crates/solidcore/Cargo.toml index dca7ca236..c403248d9 100644 --- a/crates/solidcore/Cargo.toml +++ b/crates/solidcore/Cargo.toml @@ -11,6 +11,11 @@ readme = "README.md" keywords = ["cad", "brep", "nurbs", "geometry", "solid-modeling"] categories = ["graphics", "mathematics", "science"] +[features] +# Kernels start with the curve cache memo off (`Kernel::memoize_curve_caches`), to +# run difftest and the tests in both positions. Results never depend on it. +curve-memo-off = [] + [dependencies] thiserror.workspace = true diff --git a/crates/solidcore/src/boolean/merge/p4/kernel_adapter.rs b/crates/solidcore/src/boolean/merge/p4/kernel_adapter.rs index f156bd461..25b4d7bad 100644 --- a/crates/solidcore/src/boolean/merge/p4/kernel_adapter.rs +++ b/crates/solidcore/src/boolean/merge/p4/kernel_adapter.rs @@ -64,6 +64,7 @@ fn cache_kernel(k: &Kernel, context: &Context) -> Kernel { fillet_point_step: core::cell::Cell::new(k.fillet_point_step.get()), upstream_ub_passed: Vec::new(), cone_parallel_midpoint_test: core::cell::Cell::new(k.cone_parallel_midpoint_test.get()), + memoize_curve_caches: k.memoize_curve_caches, } } diff --git a/crates/solidcore/src/curve/curve/p2.rs b/crates/solidcore/src/curve/curve/p2.rs index 45b347c98..a3c0efbbe 100644 --- a/crates/solidcore/src/curve/curve/p2.rs +++ b/crates/solidcore/src/curve/curve/p2.rs @@ -97,6 +97,10 @@ //! (`SM_NO_CONSTRUCTION`, `SM_NO_DESTRUCTION`) and the debug cache counter are //! not modelled; [`notify`] is the `Notify` override for callers that keep the //! curve's [`AObject`] state. +//! - **Cache memo.** A [`Curve`] carries a memo of its curve cache (`CurveMemo`, see +//! `curve.curve_p5`), which upstream keeps in `SmCacheMgr`. `Clone` and the +//! evaluation copies share it; every `&mut` edit of the geometry (`kind_mut` and the +//! editing methods) starts an empty one. //! - **Arguments.** Out-params become return values; `SmContext` arguments only //! choose where upstream allocates and are dropped, as are arguments upstream //! marks `NotUsed` (`EvaluateSTEP`'s zone tolerance, `sm_TestStep`'s current @@ -163,6 +167,7 @@ use crate::curve::composite_curve::CompositeCurve; use crate::curve::conic::Conic; use crate::curve::crv_in_volume::CrvInVolume; use crate::curve::crv_on_surf::CrvOnSurf; +use crate::curve::curve::p5::CurveMemo; use crate::curve::ellipse::Ellipse; use crate::curve::hermite_curve::HermiteCurve; use crate::curve::hyperbola::Hyperbola; @@ -578,16 +583,20 @@ pub struct Curve { /// the object itself), issued by the context of the first notification that names /// it ([`IdentitySlot`]). notify_identity: IdentitySlot, + /// Not upstream: the memo of the curve's cache ([`CurveMemo`]), shared by clones + /// and emptied by every edit of the geometry. + memo: CurveMemo, } /// The copy constructor: copies the geometry, resets the owner (and the identity: a -/// copy is another object). +/// copy is another object). The copy shares the curve cache memo (same geometry). impl Clone for Curve { fn clone(&self) -> Self { Self { kind: self.kind.clone(), owner: None, notify_identity: IdentitySlot::new(), + memo: self.memo.share(), } } } @@ -621,6 +630,7 @@ impl Curve { owner: self.owner, // an evaluation snapshot is never notified under the original's identity notify_identity: IdentitySlot::new(), + memo: self.memo.share(), } } } @@ -768,6 +778,7 @@ impl Curve { kind, owner: self.owner, notify_identity: IdentitySlot::new(), + memo: self.memo.share(), }) } @@ -779,9 +790,27 @@ impl Curve { kind, owner: None, notify_identity: IdentitySlot::new(), + memo: CurveMemo::new(), } } + /// A copy (as `Clone`) with an empty curve cache memo of its own: the curve a + /// cache keeps of its own, so a memo never holds itself. + #[must_use] + pub(crate) fn clone_detached(&self) -> Self { + Self { + kind: self.kind.clone(), + owner: None, + notify_identity: IdentitySlot::new(), + memo: CurveMemo::new(), + } + } + + /// The curve cache memo (see [`CurveMemo`]). + pub(crate) const fn memo(&self) -> &CurveMemo { + &self.memo + } + /// The key this curve object is notified under, once a context has issued it one /// (the kernel issues it; there is no setter). #[must_use] @@ -803,6 +832,8 @@ impl Curve { /// The concrete kind, mutable. pub fn kind_mut(&mut self) -> &mut CurveKind { + // an edit makes another curve value: start an empty memo + self.memo = CurveMemo::new(); &mut self.kind } @@ -1523,6 +1554,7 @@ impl Curve { /// `SM_ERR` for kinds without the conversion; those of /// [`BSplineCurve::convert_to_2d`]. pub fn convert_to_2d(&mut self, k: &Kernel) -> Result<()> { + self.memo = CurveMemo::new(); match &mut self.kind { CurveKind::BSpline(c) => c.convert_to_2d(), CurveKind::Hyperbola(c) => c.base.convert_to_2d(), @@ -1547,6 +1579,7 @@ impl Curve { /// `SM_ERR` for kinds without the edit; those of /// [`BSplineCurve::edit_parameterization`]. pub fn edit_parameterization(&mut self, k: &Kernel, new_param: &Extent1d) -> Result<()> { + self.memo = CurveMemo::new(); match &mut self.kind { CurveKind::BSpline(c) => c.edit_parameterization(new_param), CurveKind::Hyperbola(c) => c.base.edit_parameterization(new_param), @@ -1576,6 +1609,7 @@ impl Curve { old_interval: &Extent1d, ) -> Result { let owner = self.owner; + self.memo = CurveMemo::new(); match &mut self.kind { CurveKind::BSpline(c) => c.reverse_parameterization(old_interval), CurveKind::Hyperbola(c) => c.reverse_parameterization(old_interval, owner), @@ -1616,6 +1650,7 @@ impl Curve { scale: Option, depth: usize, ) -> Result<()> { + self.memo = CurveMemo::new(); match &mut self.kind { CurveKind::Composite(c) => c.transform_at_depth(k, rotate_n_move, scale, depth), CurveKind::BSpline(c) => c.transform(rotate_n_move, scale), @@ -1639,6 +1674,7 @@ impl Curve { /// /// `SM_ERR` for kinds without the edit; those of [`BSplineCurve::trim`]. pub fn trim(&mut self, k: &Kernel, trim_interval: &mut Extent1d, mode: TrimMode) -> Result<()> { + self.memo = CurveMemo::new(); match &mut self.kind { CurveKind::BSpline(c) => c.trim(k, trim_interval, mode), CurveKind::Hyperbola(c) => c.base.trim(k, trim_interval, mode), diff --git a/crates/solidcore/src/curve/curve/p5.rs b/crates/solidcore/src/curve/curve/p5.rs index 0ccf05958..326b2a39f 100644 --- a/crates/solidcore/src/curve/curve/p5.rs +++ b/crates/solidcore/src/curve/curve/p5.rs @@ -41,9 +41,14 @@ //! copies the geometry and resets the owner (the `Notify` calls are the object //! layer's). //! - **Caches.** Upstream keeps the cache on the curve and reuses it until the curve -//! changes; [`make_curve_cache`] builds a fresh one for the caller (the build is -//! deterministic, so it holds the same spans and tree). A caller that solves on one -//! unchanged curve repeatedly can keep the result (`Curve::global_point_solve_cached`, +//! changes (`SmCacheMgr`, deleted on `SM_NO_PRE_EDIT`). [`make_curve_cache`] keeps a +//! memo of its result in the curve value instead (`CurveMemo`): the build is a +//! deterministic function of the curve's geometry and the kernel's tolerance state +//! (`CurveMemoKey`), so a hit gives the cache a fresh build gives (the same spans and +//! tree, in the caller's handle space). An edit of the geometry through `&mut Curve` +//! starts an empty memo, clones share it, and [`Kernel::memoize_curve_caches`] turns +//! it off. No result depends on the memo. A caller that solves on one unchanged +//! curve repeatedly can also keep the result (`Curve::global_point_solve_cached`, //! used by `GlobalCoincidenceChecker`). [`validate_curve_cache`] is the rest of //! `CacheMakeOrValidate`: the `SM_OC_CURVE` type check and the reuse of an existing //! cache; the context check has no counterpart (a [`Curve`] always @@ -109,6 +114,8 @@ use crate::math::{max2, EFF_ZERO, EFF_ZERO_SQ, EFF_ZERO_SQRT, PI}; use crate::nurbs::b_spline_curve::p2::ArcData; use crate::nurbs::b_spline_curve::{BSplineCurve, GapFix}; use crate::nurbs::b_spline_surface::SurfParamType; +use crate::surface::surface::p2::MAX_GEOMETRY_NESTING; +use std::sync::{Arc, Mutex, OnceLock}; const OP_CLEANUP: &str = "curve::curve::p5::crvs_cleanup"; const OP_CACHE: &str = "curve::curve::p5::validate_curve_cache"; @@ -281,6 +288,159 @@ pub fn make_curve_cache( k: &Kernel, curve: &Curve, handle: Option, +) -> Result> { + // not upstream: the memo of this build in the curve value (see CurveMemo) + let key = CurveMemoKey::of(k, curve); + if let Some(key) = &key { + if let Some(hit) = curve.memo().lookup(key, handle) { + return Ok(hit); + } + } + let cache = build_curve_cache(k, curve, handle)?; + if let Some(key) = key { + if let Some(neutral) = cache.retag::<()>(None) { + curve.memo().store(key, neutral); + } + } + Ok(cache) +} + +/// The kernel and curve state [`make_curve_cache`]'s build reads besides the curve's +/// geometry (not upstream): the tolerances and the context's scalar state. A memo +/// entry is valid for a later build only when its key is equal (the context scalars +/// bit for bit). +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct CurveMemoKey { + tolerances: crate::kernel::Tolerances, + context_bits: [u64; 3], + context_flags: [bool; 2], +} + +impl CurveMemoKey { + /// The key of a build of `curve` under `k`, or `None` when the build must not be + /// memoised: the memo is off, or the curve is not a pure value ([`memo_pure`]). The + /// owner is not an input: the build works on its own copy of the curve, which has + /// none. + fn of(k: &Kernel, curve: &Curve) -> Option { + if !k.memoize_curve_caches || !memo_pure(curve) { + return None; + } + let c = &k.context; + Some(Self { + tolerances: k.tolerances, + context_bits: [ + c.this_zone_tol_3d().to_bits(), + c.stored_model_size_estimate().to_bits(), + c.stored_large_small_size_ratio().to_bits(), + ], + context_flags: [c.doing_boolean(), c.trim_uv_curves_at_corner_drops()], + }) + } +} + +/// Whether `curve`'s cache build reads nothing but the curve's own value, so that a memo +/// of it is valid for every clone: B-spline, line, ellipse, hyperbola, parabola, Hermite +/// and conic curves, and offset, projected and composite curves made only of such +/// curves. Curves on surfaces, iso curves and curves in volumes hold a surface or volume +/// with cache state of its own (the shared surface cache slot, lazy break and offset +/// caches), so they are never memoised. The walk uses an explicit stack and gives up +/// (no memo) past [`MAX_GEOMETRY_NESTING`] levels. +fn memo_pure(curve: &Curve) -> bool { + let mut stack: Vec<(&Curve, usize)> = vec![(curve, 0)]; + while let Some((c, depth)) = stack.pop() { + if depth > MAX_GEOMETRY_NESTING { + return false; + } + match c.kind() { + CurveKind::BSpline(_) + | CurveKind::Line(_) + | CurveKind::Ellipse(_) + | CurveKind::Hyperbola(_) + | CurveKind::Parabola(_) + | CurveKind::Hermite(_) + | CurveKind::Conic(_) => {} + CurveKind::Offset(o) => stack.extend(o.base_curve().map(|b| (b, depth + 1))), + CurveKind::Projected(p) => stack.push((p.curve(), depth + 1)), + CurveKind::Composite(cc) => { + stack.extend(cc.segments().iter().map(|seg| (&seg.curve, depth + 1))); + } + CurveKind::Iso(_) | CurveKind::CrvOnSurf(_) | CurveKind::CrvInVolume(_) => { + return false; + } + } + } + true +} + +/// One memoised build: its key and the cache, without a handle. +#[derive(Debug)] +struct CurveMemoEntry { + key: CurveMemoKey, + cache: CurveCache<()>, +} + +/// The memo of a curve's [`CurveCache`] (not upstream; upstream keeps the cache in +/// `SmCacheMgr`, keyed by the curve object). It lives in the [`Curve`] value: a clone +/// shares it (same geometry, same cache), an edit through `&mut Curve` replaces it with +/// an empty one, and it is freed with the last curve that shares it. So there is at +/// most one cache per live curve value. A hit gives what a fresh build gives, so no +/// result depends on the memo, its fill order or which clone filled it. It adds no +/// auto-trait bound ([`Curve`] is `Send` and not `Sync`, as without it) and needs no +/// `unsafe`. +#[derive(Default)] +pub(crate) struct CurveMemo(OnceLock>>>); + +impl core::fmt::Debug for CurveMemo { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + // the memo is not part of the curve's observable state + f.write_str("CurveMemo") + } +} + +impl CurveMemo { + /// An empty memo of its own. + pub(crate) const fn new() -> Self { + Self(OnceLock::new()) + } + + /// A memo shared with this one (for a clone of the curve). + pub(crate) fn share(&self) -> Self { + let slot = Arc::clone(self.0.get_or_init(Arc::default)); + let shared = OnceLock::new(); + let _ = shared.set(slot); + Self(shared) + } + + /// The memoised cache for `key`, as a copy naming `handle`. + fn lookup( + &self, + key: &CurveMemoKey, + handle: Option, + ) -> Option> { + let slot = self.0.get()?; + let entry = slot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + entry + .as_ref() + .filter(|e| e.key == *key) + .and_then(|e| e.cache.retag(handle)) + } + + fn store(&self, key: CurveMemoKey, cache: CurveCache<()>) { + let slot = self.0.get_or_init(Arc::default); + let mut entry = slot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *entry = Some(CurveMemoEntry { key, cache }); + } +} + +/// [`make_curve_cache`]'s build, without the memo. +fn build_curve_cache( + k: &Kernel, + curve: &Curve, + handle: Option, ) -> Result> { // cast Object to SmCurve and get its interval let ivl = curve.natural_interval(); @@ -301,7 +461,7 @@ pub fn make_curve_cache( // construct new CurveCache Object let mut cache = CurveCache::new( - curve.clone(), + curve.clone_detached(), ivl, CurveCacheOptions { make_full_tree: true, @@ -2419,4 +2579,95 @@ mod tests { let pt = line.evaluate_step_point(k, 0.5).unwrap(); assert!(pt.distance(Vec3::new(3.0, 2.0, 0.0)) < 1e-12); } + + /// A closed cubic through 8 points of an ellipse. + fn ellipse_spline() -> Curve { + let mut pts: Vec = (0..8) + .map(|i| { + let t = core::f64::consts::PI * f64::from(i) / 4.0; + Point3::new(5.0 * t.cos(), 3.0 * t.sin(), 0.5 * t) + }) + .collect(); + pts.push(pts[0]); + let c = BSplineCurve::interpolate_points( + &pts, + None, + 3, + None, + None, + true, + crate::nurbs::crv_fit::p1::ParamMethod::ChordLength, + ) + .unwrap(); + Curve::from(c) + } + + #[test] + fn memo_hit_is_the_fresh_build() { + let k = Kernel { + memoize_curve_caches: true, + ..Kernel::default() + }; + let off = Kernel { + memoize_curve_caches: false, + ..Kernel::default() + }; + for c in [ + bseg([0.0, 0.0, 0.0], [1.0, 2.0, 3.0]), + ellipse_spline(), + hermite([0.0; 3], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]), + ] { + let fresh = format!("{:?}", make_curve_cache(&off, &c, Some(7u32)).unwrap()); + let key = CurveMemoKey::of(&k, &c).unwrap(); + assert!(c.memo().lookup(&key, None::).is_none()); + let miss = format!("{:?}", make_curve_cache(&k, &c, Some(7u32)).unwrap()); + assert!(c.memo().lookup(&key, None::).is_some()); + let hit = format!("{:?}", make_curve_cache(&k, &c, Some(7u32)).unwrap()); + // another handle space and a clone: the same as their fresh builds + let copy = c.clone(); + let hit_copy = format!("{:?}", make_curve_cache(&k, ©, Some(-1i64)).unwrap()); + let fresh_copy = format!("{:?}", make_curve_cache(&off, ©, Some(-1i64)).unwrap()); + assert_eq!(miss, fresh); + assert_eq!(hit, fresh); + assert_eq!(hit_copy, fresh_copy); + // the memo was off for `off`, so it stored nothing + assert!(CurveMemoKey::of(&off, &c).is_none()); + } + } + + #[test] + fn memo_follows_edits_and_kernel_state() { + let k = Kernel { + memoize_curve_caches: true, + ..Kernel::default() + }; + let mut c = ellipse_spline(); + let key = CurveMemoKey::of(&k, &c).unwrap(); + let _ = make_curve_cache(&k, &c, None::).unwrap(); + let copy = c.clone(); + assert!(copy.memo().lookup(&key, None::).is_some()); + // an edit of one copy empties its memo only + c.reverse_parameterization(&k, &c.natural_interval()) + .unwrap(); + assert!(c.memo().lookup(&key, None::).is_none()); + assert!(copy.memo().lookup(&key, None::).is_some()); + let off = Kernel { + memoize_curve_caches: false, + ..Kernel::default() + }; + let edited = format!("{:?}", make_curve_cache(&k, &c, None::).unwrap()); + let edited_fresh = format!("{:?}", make_curve_cache(&off, &c, None::).unwrap()); + assert_eq!(edited, edited_fresh); + // other tolerances do not match the stored key + let mut loose = Kernel { + memoize_curve_caches: true, + ..Kernel::default() + }; + loose.tolerances.zone_tol_3d *= 10.0; + let loose_key = CurveMemoKey::of(&loose, ©).unwrap(); + assert!(copy.memo().lookup(&loose_key, None::).is_none()); + // a Hermite curve is a pure value and memoised + let h = hermite([0.0; 3], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]); + assert!(CurveMemoKey::of(&k, &h).is_some()); + } } diff --git a/crates/solidcore/src/curve/curve_cache.rs b/crates/solidcore/src/curve/curve_cache.rs index 36805e947..45eb084ab 100644 --- a/crates/solidcore/src/curve/curve_cache.rs +++ b/crates/solidcore/src/curve/curve_cache.rs @@ -39,6 +39,9 @@ //! cache store and is dropped whenever the curve changes. The curve's handle in //! the caller's object space (`O`, see [`crate::geom::tree`]) is optional and is //! what the tree reports as its owner object. +//! - **Handle space.** `CurveCache::retag` (not upstream) copies a cache into +//! another handle space; the memo of `curve.curve_p5` (`make_curve_cache`) keeps one +//! handle-free cache per curve value and hands each caller such a copy. //! - **Memory managers.** `m_sBezMgr` / `m_sParentMgr` are `Vec`s; the NLib Bezier //! blocks (`sSegmentBeziersMgr`, `sBezStack`, the working Bezier) are owned //! [`Nurb`] values and a `Vec` used as the split stack (same push/pop order). The @@ -357,6 +360,28 @@ impl CurveCache { } } + /// This cache in another handle space, naming the curve `handle` (not upstream; see + /// [`crate::curve::curve::p5::make_curve_cache`]): what a build with `handle` gives, + /// since the handle is only stored. `None` when the tree names other objects. + pub(crate) fn retag(&self, handle: Option

) -> Option> { + let tree = match &self.tree { + Some(t) => Some(t.retag_curve_tree(handle)?), + None => None, + }; + Some(CurveCache { + curve: self.curve.clone_detached(), + curve_handle: handle, + interval: self.interval, + tree, + continuities: self.continuities.clone(), + tried_analytic: self.tried_analytic, + analytic: self.analytic.as_ref().map(Curve::clone_detached), + spans: self.spans.clone(), + parents: self.parents.clone(), + opts: self.opts, + }) + } + /// Sets the handle of the cached curve, reported as the owner object of the /// tree built afterwards. pub fn set_curve_handle(&mut self, handle: Option) { diff --git a/crates/solidcore/src/geom/tree.rs b/crates/solidcore/src/geom/tree.rs index 74fbf359c..4415c3b12 100644 --- a/crates/solidcore/src/geom/tree.rs +++ b/crates/solidcore/src/geom/tree.rs @@ -43,6 +43,9 @@ //! | [`AuxDataType`], [`NodeGeomType`], [`SplitDir`], [`NodeClass`] | `SmAuxDataType`, `SmNodeGeomType`, `SmSurfParamType` (U/V), `SmNodeClassType` | //! //! Deviations: +//! - **Handle space.** `Tree::retag_curve_tree` (not upstream) copies a curve +//! subdivision tree into another handle space, for the curve cache memo +//! (`curve.curve_p5`). //! - Pointers become arena indices: nodes live in [`Tree::nodes`] ([`NodeId`]) and //! object-list items in [`Tree::lists`] ([`ListId`]), both in allocation order, //! exactly the order of upstream's `SmMemBlockMgr::GetActiveElements`. Resetting @@ -517,6 +520,57 @@ impl Tree { self.owner_curve = owner_curve; } + /// This curve subdivision tree in another handle space, its owner curve named + /// `owner_curve` (not upstream: one memoised curve cache serves callers of every + /// handle space, see [`crate::curve::curve::p5::make_curve_cache`]). `None` when the + /// tree names other objects (object lists, surface data or an owner surface), which + /// a curve cache's tree does not. + pub(crate) fn retag_curve_tree

(&self, owner_curve: Option

) -> Option> { + if self.owner_surface.is_some() || self.lists.iter().any(|l| l.object.is_some()) { + return None; + } + let mut nodes = Vec::with_capacity(self.nodes.len()); + for n in &self.nodes { + let data = match &n.data { + NodeData::None => NodeData::None, + NodeData::ObjectList(l) => NodeData::ObjectList(*l), + NodeData::Curve(c) => NodeData::Curve(*c), + NodeData::Surface(_) => return None, + }; + nodes.push(TreeNode { + parent: n.parent, + child1: n.child1, + child2: n.child2, + bbox: n.bbox, + aux_type: n.aux_type, + data, + geom_type: n.geom_type, + }); + } + let lists = self + .lists + .iter() + .map(|l| ObjectList { + next: l.next, + bbox: l.bbox, + object: None, + }) + .collect(); + Some(Tree { + max_elements_per_node: self.max_elements_per_node, + min_size_ratio: self.min_size_ratio, + node_reduction: self.node_reduction, + owner: self.owner, + top: self.top, + nodes, + lists, + curve_spans: self.curve_spans.clone(), + curve_parents: self.curve_parents.clone(), + owner_curve, + owner_surface: None, + }) + } + /// Sets the handle of the owning surface cache's surface /// (`SmSurfaceCache::GetSurface`, reported by [`Tree::owner_object`] for surface /// cache trees). diff --git a/crates/solidcore/src/kernel.rs b/crates/solidcore/src/kernel.rs index d2cf794f0..5f6b30d24 100644 --- a/crates/solidcore/src/kernel.rs +++ b/crates/solidcore/src/kernel.rs @@ -193,6 +193,10 @@ pub struct Kernel { /// Boolean sets it for its duration (`boolean::merge::p4::manifold_boolean`). A /// [`Cell`] because the intersectors take `&Kernel`. pub cone_parallel_midpoint_test: Cell, + /// Not upstream: whether `make_curve_cache` keeps a memo of each curve cache in the + /// curve value (`true` unless the `curve-memo-off` feature is on). The memo never + /// changes a result, only the time; `false` builds every cache fresh, to check that. + pub memoize_curve_caches: bool, } impl Default for Kernel { @@ -213,6 +217,7 @@ impl Kernel { fillet_point_step: Cell::new(None), upstream_ub_passed: Vec::new(), cone_parallel_midpoint_test: Cell::new(false), + memoize_curve_caches: !cfg!(feature = "curve-memo-off"), } } diff --git a/crates/solidcore/tests/curve_cache_memo.rs b/crates/solidcore/tests/curve_cache_memo.rs new file mode 100644 index 000000000..e40e8cab9 --- /dev/null +++ b/crates/solidcore/tests/curve_cache_memo.rs @@ -0,0 +1,174 @@ +//! The curve cache memo (`Kernel::memoize_curve_caches`) is invisible: booleans of +//! primitives and of two lofted skins give bit-identical results with the memo on and +//! off (every value of the result B-rep, its volume and any error), and a second run on +//! the same kernel, which finds the memo filled, gives the same again. + +use solidcore::boolean::api_brep::{boolean_difference, boolean_intersection, boolean_union}; +use solidcore::curve::curve::Curve; +use solidcore::geom::attribute::{AttributeStore, ObjectKey}; +use solidcore::mass::integrator::properties::{brep_volume, PropertyAttributes}; +use solidcore::math::Point3; +use solidcore::nurbs::b_spline_curve::BSplineCurve; +use solidcore::nurbs::crv_fit::p1::ParamMethod; +use solidcore::primitive::api_primitives as api; +use solidcore::sweep::api_surfaces::create_skin_primitive; +use solidcore::topology::Brep; +use solidcore::Kernel; + +#[derive(Clone, Copy)] +enum Op { + Union, + Intersection, + Difference, +} + +/// A closed cubic section through `n` points of an ellipse at height `z`. +fn section(a: f64, b: f64, z: f64, n: usize) -> solidcore::Result { + let mut pts: Vec = (0..n) + .map(|i| { + let t = 2.0 * std::f64::consts::PI * i as f64 / n as f64; + Point3::new(a * t.cos(), b * t.sin(), z) + }) + .collect(); + pts.extend(pts.first().copied()); + BSplineCurve::interpolate_points(&pts, None, 3, None, None, true, ParamMethod::ChordLength) + .map(Curve::from) +} + +/// A capped ruled skin through `count` elliptic sections from `z0` to `z1`. +fn skin(k: &mut Kernel, a: f64, b: f64, z0: f64, z1: f64, count: usize) -> solidcore::Result { + let cs = (0..count) + .map(|i| { + let s = i as f64 / (count - 1) as f64; + // the sections narrow toward the top, so the skin is not a cylinder + section( + a * (1.0 - 0.3 * s), + b * (1.0 - 0.2 * s), + z0 + s * (z1 - z0), + 12, + ) + }) + .collect::>>()?; + let refs: Vec> = cs.iter().map(Some).collect(); + let counts = vec![1usize; cs.len()]; + create_skin_primitive(k, &counts, &refs, 1, true).map(|s| s.brep) +} + +/// A kernel with the memo on or off (whatever the `curve-memo-off` feature says). +fn kernel(memo: bool) -> Kernel { + Kernel { + memoize_curve_caches: memo, + ..Kernel::default() + } +} + +fn operands(k: &mut Kernel, name: &str) -> solidcore::Result<(Brep, Brep, Op)> { + let p = Point3::new; + Ok(match name { + "box-cyl-hole" => ( + api::create_box(k, p(0.0, 0.0, 0.0), 10.0, 10.0, 10.0)?, + api::create_cylinder(k, p(5.0, 5.0, -1.0), 2.0, 12.0)?, + Op::Difference, + ), + "sphere-and-box" => ( + api::create_sphere(k, p(0.0, 0.0, 0.0), 5.0)?, + api::create_box(k, p(-10.0, -10.0, -10.0), 20.0, 20.0, 12.0)?, + Op::Intersection, + ), + "torus-minus-box" => ( + api::create_torus(k, p(0.0, 0.0, 0.0), 5.0, 1.5)?, + api::create_box(k, p(0.0, -10.0, -10.0), 20.0, 20.0, 20.0)?, + Op::Difference, + ), + "cyl-union-cyl" => ( + api::create_cylinder(k, p(0.0, 0.0, 0.0), 3.0, 10.0)?, + api::create_cylinder(k, p(2.0, 0.0, 2.0), 2.0, 10.0)?, + Op::Union, + ), + "skin-minus-skin" => ( + skin(k, 20.0, 12.0, 0.0, 40.0, 5)?, + skin(k, 18.0, 10.0, 2.0, 38.0, 5)?, + Op::Difference, + ), + "skin-and-box" => ( + skin(k, 20.0, 12.0, 0.0, 40.0, 5)?, + api::create_box(k, p(-30.0, -30.0, 10.0), 60.0, 60.0, 20.0)?, + Op::Intersection, + ), + _ => unreachable!("unknown case {name}"), + }) +} + +const CASES: [&str; 6] = [ + "box-cyl-hole", + "sphere-and-box", + "torus-minus-box", + "cyl-union-cyl", + "skin-minus-skin", + "skin-and-box", +]; + +/// Every observable output of one case as text (`{:?}` prints each `f64` so that it +/// parses back to the same bits). +fn observe(k: &mut Kernel, name: &str) -> String { + let (a, b, op) = match operands(k, name) { + Ok(o) => o, + Err(e) => return format!("operands: {e:?}"), + }; + let result = match op { + Op::Union => boolean_union(k, a, b), + Op::Intersection => boolean_intersection(k, a, b), + Op::Difference => boolean_difference(k, a, b), + }; + let mut brep = match result { + Ok(b) => b, + Err(e) => return format!("boolean: {e:?}"), + }; + let store = AttributeStore::new(); + let key_of = |_| ObjectKey(0); + let attrs = PropertyAttributes { + store: &store, + key_of: &key_of, + }; + let volume = brep_volume(k, &mut brep, 1e-6, &attrs); + format!("{volume:?}\n{brep:?}\n{:?}", k.upstream_ub_passed) +} + +#[test] +fn memo_on_and_off_give_bit_identical_results() { + let mut differ = Vec::new(); + for name in CASES { + let mut off = kernel(false); + let want = observe(&mut off, name); + let mut on = kernel(true); + let first = observe(&mut on, name); + // the same kernel again: geometry made from the same inputs, fresh memos + let second = observe(&mut on, name); + let mut off_again = kernel(false); + let _ = observe(&mut off_again, name); + let second_off = observe(&mut off_again, name); + // every case runs to a volume, so the comparison covers a whole boolean + if !want.starts_with("Ok(") { + differ.push(format!( + "{name}: no result: {}", + want.lines().next().unwrap_or("") + )); + } + if first != want { + differ.push(format!("{name}: memo on differs from memo off")); + } + if second != second_off { + differ.push(format!("{name}: second run differs")); + } + } + assert!(differ.is_empty(), "{differ:#?}"); +} + +#[test] +fn curves_and_breps_stay_send() { + // (`Curve` and `Brep` are not `Sync` on main either: `CrvOnSurf` and the offset + // surface hold `Cell`/`RefCell` caches) + fn assert_send() {} + assert_send::(); + assert_send::(); +} diff --git a/plan/findings/curve.curve_p5--curve-cache-memo.md b/plan/findings/curve.curve_p5--curve-cache-memo.md new file mode 100644 index 000000000..8dace8dae --- /dev/null +++ b/plan/findings/curve.curve_p5--curve-cache-memo.md @@ -0,0 +1,54 @@ +# curve.curve_p5: curve caches are memoised in the curve value (2026-10-25) + +Unit: `curve.curve_p5` (`make_curve_cache`, upstream `SmCurve::CacheMakeOrValidate`), with +`curve.curve_p2` (the `Curve` field), `curve.curve_cache` and `geom.tree` (the handle +copies). Design: option C of issue #982, as refined in its comments. + +**Upstream.** `SmCacheMgr::GetOrCreateObjectCache` keeps one `SmCurveCache` per curve +object in the context's global cache (at most `SM_DEFAULT_MAXCOUNT_CURVECACHE` = 1000). +`SmCurve::Notify` deletes it on `SM_NO_PRE_EDIT` and `SM_NO_DESTRUCTION`. + +**Before.** The port built a new cache for every request unless one call chain kept it +(#539, #753, #951). On the #948 loft at 20 sections (main `4fe6f4d51`, gdb breakpoints +on `Tessellate`), the Rust made 16,918 builds for 22,661 requests, and the C++ 1,393. + +**Now.** `make_curve_cache` keeps a memo of its result in the `Curve` value +(`CurveMemo`): + +- `Clone` and the evaluation copies share the memo. `kind_mut` and every editing method + that writes the geometry start an empty one, so an edit cannot leave a stale entry. +- The entry is keyed by everything the build reads besides the geometry + (`CurveMemoKey`): `Kernel::tolerances` and the context scalars (zone tolerance, model + size estimate, size ratio, `doing_boolean`, `trim_uv_curves_at_corner_drops`). The + owner is not an input, because the build works on an ownerless copy. +- Only pure values are memoised (`memo_pure`): B-spline, line, ellipse, hyperbola, + parabola, Hermite and conic curves, and offset, projected and composite curves made + only of these. Iso curves, curves on surfaces and curves in volumes hold a surface or + volume with cache state of its own, so they always build. +- The entry is handle-free; a hit returns a copy in the caller's handle space + (`CurveCache::retag`, `Tree::retag_curve_tree`). + +**Why no result can depend on it.** The build takes `&Kernel` (no RNG, no UB record) and +writes nothing outside the new cache, so its result is a function of the curve value and +the key. A hit returns that result. Fill order, which clone filled the memo and the +thread do not matter. Memory: one entry per live curve value, freed with its last clone; +no count limit. `Curve` stays `Send` and not `Sync` (as on main). No `unsafe`. + +**How to check it.** `Kernel::memoize_curve_caches` turns the memo off; the +`curve-memo-off` feature of `solidcore` makes that the default, so difftest runs in both +positions: + +```sh +cargo run --profile difftest -p difftest --features cpp -- --unit +cargo run --profile difftest -p difftest --features cpp,solidcore/curve-memo-off -- --unit +``` + +`tests/curve_cache_memo.rs` runs booleans of primitives and lofted skins with the memo +off and on and compares every output bit for bit. + +**After.** Same loft and method: 1,409 builds against the C++ 1,393, the same count per +caller within a few builds (see the PR for the table). + +**Not covered.** The other `CurveCache::new` builders (their own options) build every +time. A hit copies the spans and tree, because the solves take `&mut CurveCache` (the +lazy analytic curve).