diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 985aec5e..fee89dd2 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -747,6 +747,8 @@ pub struct Model { pub cache: Arc>, /// Evaluate everything from scratch (no reuse; tests compare the two). pub no_cache: bool, + /// The imported texts' digests the last `evaluate` fingerprinted the features with. + step_digests: StepDigests, } /// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a @@ -762,12 +764,12 @@ fn param_names(f: &Feature) -> Vec { names } -fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_back: bool) -> u64 { +fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_back: bool, steps: &StepDigests) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); prev.hash(&mut h); rolled_back.hash(&mut h); vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h); - feature_json(f).hash(&mut h); + feature_json(f, steps).hash(&mut h); for n in param_names(f) { n.hash(&mut h); match vals.get(&n) { @@ -782,6 +784,44 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_ba h.finish() } +/// The digests of the Import features' STEP texts, by feature id, with the text each was taken +/// from. Every evaluation fingerprints every feature, and writing out a design's imported files +/// each time made every command as slow as the files are large: a text is shared (`Arc`), so +/// its digest is taken once and kept while the feature holds that same text. +#[derive(Clone, Debug, Default)] +struct StepDigests(std::collections::HashMap, u64)>); + +impl StepDigests { + /// The digests of `doc`'s imported texts, reusing `known` for texts it has seen. + fn of(doc: &Document, known: &StepDigests) -> StepDigests { + let texts = doc.features.iter().filter_map(|f| match &f.kind { + FeatureKind::Import { step, .. } => Some((f.id, step)), + _ => None, + }); + StepDigests( + texts + .map(|(id, step)| { + let digest = known.get(id, step).unwrap_or_else(|| step_text_digest(step)); + (id, (step.clone(), digest)) + }) + .collect(), + ) + } + + /// The digest of `step`, when it is the text feature `id` was last seen with. + fn get(&self, id: u64, step: &Arc) -> Option { + self.0.get(&id).filter(|(text, _)| Arc::ptr_eq(text, step)).map(|(_, digest)| *digest) + } +} + +fn step_text_digest(text: &str) -> u64 { + #[cfg(test)] + cost_tests::count(|c| c.digested += 1); + let mut h = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut h); + h.finish() +} + /// Positions and radii are fingerprinted to this quantum (mm): far below anything a design /// can show, far above the solver's rounding, so a sketch solved again to the same geometry /// reads as the same input. @@ -806,8 +846,17 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) { } } -/// A feature as fingerprinted: its definition, a sketch's stored solve quantized. -fn feature_json(f: &Feature) -> String { +/// A feature as fingerprinted: its definition, a sketch's stored solve quantized, an imported +/// file's text by its digest. +fn feature_json(f: &Feature, steps: &StepDigests) -> String { + if let FeatureKind::Import { step, .. } = &f.kind { + let mut g = f.clone(); + if let FeatureKind::Import { step: text, .. } = &mut g.kind { + let digest = steps.get(f.id, step).unwrap_or_else(|| step_text_digest(step)); + *text = Arc::from(format!("{digest:016x}:{}", step.len())); + } + return serde_json::to_string(&g).unwrap_or_default(); + } if let FeatureKind::Sketch { .. } = &f.kind { let mut g = f.clone(); if let FeatureKind::Sketch { sketch, .. } = &mut g.kind { @@ -866,8 +915,8 @@ fn evaluation_values(doc: &Document) -> (BTreeMap, BTreeMap, f: &Feature, prev: u64, rolled_back: bool) -> u64 { - let mut fp = fingerprint(prev, f, vals, rolled_back) ^ placement_digest(doc, f); +fn chain_fingerprint(doc: &Document, vals: &BTreeMap, f: &Feature, prev: u64, rolled_back: bool, steps: &StepDigests) -> u64 { + let mut fp = fingerprint(prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f); // Sheet metal features also depend on the rules (and the parameters they use). if f.kind.is_sheet() { let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -906,7 +955,7 @@ fn context_digest(doc: &Document, vals: &BTreeMap) -> u64 { for f in doc.features.iter().filter(|f| { matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. }) }) { - fingerprint(0, f, vals, false).hash(&mut h); + fingerprint(0, f, vals, false, &StepDigests::default()).hash(&mut h); } doc.units.hash(&mut h); serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h); @@ -1089,10 +1138,11 @@ impl Model { } // The fingerprints from the first stored sketch on. let vals = evaluation_values(doc).0; + let steps = StepDigests::of(doc, &self.step_digests); let marker = doc.marker.unwrap_or(usize::MAX); let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint); for (i, f) in doc.features.iter().enumerate().skip(first) { - prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker); + prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker, &steps); if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) { r.fingerprint = prev_fp; } @@ -1120,6 +1170,7 @@ impl Model { let mut restored = 0; let marker = doc.marker.unwrap_or(usize::MAX); let ctx = context_digest(doc, &vals); + self.step_digests = StepDigests::of(doc, &self.step_digests); // Each feature's component, for `Document::legacy_body_component` on every body after // every feature without searching the timeline for each (the first feature with an id). let mut feature_component: std::collections::HashMap = std::collections::HashMap::with_capacity(doc.features.len()); @@ -1131,7 +1182,7 @@ impl Model { let rolled_back = i >= marker; // Opening was cancelled: the rest is skipped and the result discarded. let cancelled = watched.step(i); - let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back); + let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests); prev_fp = fp; if reuse && let Some(old) = self.results.get(i) @@ -1162,7 +1213,7 @@ impl Model { continue; } // The same feature on the same inputs: its earlier result. - let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state); + let key = cache_key(f, fp, fingerprint(0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f), ctx, &state); if !self.no_cache && let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key)) { @@ -4293,6 +4344,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let anchor = crate::face_anchor::plane_face_anchor(plane); let fst = match anchor { Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?, + // A sketch that reads no geometry (a fixed plane, no projections) needs no other + // component's bodies moved into its frame: placing every body of a large + // assembly again is what made each sketch edit as slow as the design is big. + None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st), None => state_in_frame(doc, st, f.component), }; let (plane, face_warning) = match (anchor, plane) { @@ -4456,6 +4511,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?; doc.resolve_attached_plane(vals, plane, f.component, st, &fst)? } + // A plane of numbers alone reads no body: placing them all is the design's size. + None if plane_is_fixed(plane) => doc.resolve_plane_in(vals, plane, 0, Some(st))?, // Picks on bodies of other (moved) components are where those bodies are shown. None => doc.resolve_plane_in(vals, plane, 0, Some(&construction_state(doc, st, f.component)))?, }; @@ -5038,6 +5095,31 @@ fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std: state_in_frame(doc, st, comp) } +/// Whether `plane` is given by the origin planes and numbers alone. +fn plane_is_fixed(plane: &PlaneRef) -> bool { + match plane { + PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } => true, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_is_fixed(base), + _ => false, + } +} + +/// Whether resolving `plane` reads the model's bodies or sketches (a face, edges, points), +/// rather than only the origin planes, numbers and construction planes. +fn plane_reads_geometry(plane: &PlaneRef) -> bool { + match plane { + PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } | PlaneRef::Construction { .. } => false, + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_reads_geometry(base), + PlaneRef::Midplane { .. } + | PlaneRef::TwoEdges { .. } + | PlaneRef::ThreePoints { .. } + | PlaneRef::Tangent { .. } + | PlaneRef::AlongPath { .. } + | PlaneRef::Perpendicular { .. } + | PlaneRef::Face { .. } => true, + } +} + pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> { state_in_frame_at(doc, st, comp, doc.component_transform(comp)) } @@ -5114,6 +5196,8 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram if !crate::is_identity(m) && let Ok(t) = kernel::transform_matrix(&b.body, *m) { + #[cfg(test)] + cost_tests::count(|c| c.moved += 1); // Moving a body keeps its faces (and their order): it keeps their names. let names = crate::naming::NameCell::same(b); let mut nb = ModelBody::new(b.name.clone(), t, b.feature); @@ -5409,6 +5493,10 @@ mod chain_tests { } } +#[cfg(test)] +#[path = "eval_cost_tests.rs"] +mod cost_tests; + #[cfg(test)] mod body_name_tests { use super::*; diff --git a/crates/doc/src/eval_cost_tests.rs b/crates/doc/src/eval_cost_tests.rs new file mode 100644 index 00000000..a598ea88 --- /dev/null +++ b/crates/doc/src/eval_cost_tests.rs @@ -0,0 +1,161 @@ +//! What an evaluation costs after an edit at the end of a large design: the features before +//! the edit are reused, so the edit's cost must not grow with the bodies and imported files +//! the design already holds. A sketch on a fixed plane once moved every body of every placed +//! component into its frame, and every evaluation wrote every imported STEP file out again to +//! fingerprint it, so each sketch command took longer the more parts were inserted. + +use std::cell::Cell; +use std::sync::Arc; + +use solidcraft_geom::{Plane, Vec2, Vec3}; +use solidcraft_kernel as kernel; +use solidcraft_sketch::Sketch; + +use crate::*; + +/// Work counted on this thread: bodies moved into a component's frame, STEP texts digested. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(super) struct Counts { + pub moved: usize, + pub digested: usize, +} + +thread_local! { + static COUNTS: Cell = const { Cell::new(Counts { moved: 0, digested: 0 }) }; +} + +pub(super) fn count(f: impl FnOnce(&mut Counts)) { + COUNTS.with(|c| { + let mut n = c.get(); + f(&mut n); + c.set(n); + }); +} + +/// The work `f` does on this thread. +fn counted(f: impl FnOnce()) -> Counts { + COUNTS.with(|c| c.set(Counts::default())); + f(); + COUNTS.with(Cell::get) +} + +/// `parts` imported files of two boxes each, every one in its own component placed away from +/// the origin (as inserted parts are). +fn placed_imports(parts: usize) -> Document { + let mut doc = Document::new("placed imports"); + for i in 0..parts { + let x = 30.0 * i as f64; + let a = kernel::box_solid(Vec3::new(x, 0.0, 0.0), Vec3::new(x + 10.0, 10.0, 10.0)).unwrap(); + let b = kernel::box_solid(Vec3::new(x, 20.0, 0.0), Vec3::new(x + 5.0, 25.0, 5.0)).unwrap(); + let step: Arc = kernel::step_export(&[&a, &b], "test").unwrap().into(); + let comp = doc.add_component(Some(&format!("Part {i}")), 0).unwrap(); + let o = doc.occurrences.iter_mut().find(|o| o.component == comp).unwrap(); + o.transform = rigid(Vec3::new(0.0, 0.0, 5.0 * i as f64), Vec3::ZERO, Vec3::Z, 0.05 * i as f64); + let kind = FeatureKind::Import { file: format!("part{i}.step"), step, components: Vec::new(), skip: Vec::new() }; + let id = doc.add_feature(kind, Some(&format!("Part {i}"))).unwrap(); + doc.feature_mut(id).unwrap().component = comp; + } + doc +} + +fn line_sketch(plane: PlaneRef, to: Vec2) -> FeatureKind { + let mut sk = Sketch::new(); + let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap(); + let b = sk.add_point(to, None).unwrap(); + sk.add_line_pts(a, b, None).unwrap(); + FeatureKind::Sketch { plane, sketch: sk } +} + +#[test] +fn a_sketch_on_a_fixed_plane_costs_the_same_in_a_large_placed_design() { + let parts = 12; + let mut doc = placed_imports(parts); + let mut model = Model::new(); + model.evaluate(&doc); + assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some())); + assert_eq!(model.state().bodies.len(), parts * 2); + + // A construction plane, then sketches on the origin planes, on an offset of one and on the + // construction plane, as commands make them one after another: no body is moved and no + // file digested again. + let plane = doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Origin { name: "XZ".into() } }, Some("Datum")).unwrap(); + assert_eq!(counted(|| model.evaluate(&doc)), Counts::default(), "construction plane"); + assert!(model.result(plane).unwrap().error.is_none()); + let datum = doc.feature(plane).unwrap().name.clone(); + let planes = [ + PlaneRef::Origin { name: "XY".into() }, + PlaneRef::Offset { base: Box::new(PlaneRef::Origin { name: "YZ".into() }), distance: "5 mm".into() }, + PlaneRef::Construction { name: datum.into() }, + ]; + for (k, plane) in planes.into_iter().enumerate() { + let id = doc.add_feature(line_sketch(plane, Vec2::new(10.0, 1.0 + k as f64)), None).unwrap(); + let work = counted(|| model.evaluate(&doc)); + assert_eq!(work, Counts::default(), "sketch {k}"); + assert_eq!(model.last_recomputed, 1); + let r = model.result(id).unwrap(); + assert!(r.error.is_none(), "{:?}", r.error); + assert_eq!(model.state().bodies.len(), parts * 2); + // Editing the sketch again recomputes it alone, for the same cost. + if let Ok(sk) = doc.sketch_mut(id) + && let Some(p) = sk.points.get_mut(1) + { + p.pos = Vec2::new(12.0, 2.0); + } + let work = counted(|| model.evaluate(&doc)); + assert_eq!(work, Counts::default(), "sketch {k} edited"); + assert_eq!(model.last_recomputed, 1); + } +} + +#[test] +fn a_sketch_on_a_face_still_sees_the_placed_bodies() { + let parts = 3; + let mut doc = placed_imports(parts); + let mut model = Model::new(); + model.evaluate(&doc); + // A face pick reads the model as the sketch's component sees it: the other components' + // bodies are moved into its frame, as before. + let top = Plane::new(Vec3::new(0.0, 0.0, 10.0), Vec3::X, Vec3::Y).unwrap(); + let face = PlaneRef::Face { plane: top, at: Vec3::new(5.0, 5.0, 10.0), name: None, anchor: None }; + doc.add_feature(line_sketch(face, Vec2::new(3.0, 3.0)), None).unwrap(); + let work = counted(|| model.evaluate(&doc)); + assert!(work.moved >= parts * 2 - 2, "{work:?}"); + assert_eq!(work.digested, 0); +} + +#[test] +fn an_imported_file_is_fingerprinted_by_its_text() { + let doc = placed_imports(2); + let mut model = Model::new(); + let work = counted(|| model.evaluate(&doc)); + assert_eq!(work.digested, 2); + let fingerprints = |m: &Model| m.results.iter().map(|r| r.fingerprint).collect::>(); + let before = fingerprints(&model); + + // The same text held by another copy (an undo state, a design opened again): the same + // fingerprints, and nothing evaluated again. + let mut copy = doc.clone(); + for f in &mut copy.features { + if let FeatureKind::Import { step, .. } = &mut f.kind { + *step = Arc::from(step.to_string()); + } + } + let work = counted(|| model.evaluate(©)); + assert_eq!(work.digested, 2); + assert_eq!(fingerprints(&model), before); + assert_eq!(model.last_recomputed, 0); + + // Other text: that feature and the ones after it are evaluated again. + let other = kernel::box_solid(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap(); + let text: Arc = kernel::step_export(&[&other], "test").unwrap().into(); + if let Some(FeatureKind::Import { step, .. }) = copy.features.last_mut().map(|f| &mut f.kind) { + *step = text; + } + let work = counted(|| model.evaluate(©)); + assert_eq!(work.digested, 1); + let after = fingerprints(&model); + assert_eq!(after.first(), before.first()); + assert_ne!(after.last(), before.last()); + assert_eq!(model.last_recomputed, 1); + assert_eq!(model.state().bodies.len(), 3); +}