diff --git a/crates/doc/src/config_certificates.rs b/crates/doc/src/config_certificates.rs index 4588344d..a44d2234 100644 --- a/crates/doc/src/config_certificates.rs +++ b/crates/doc/src/config_certificates.rs @@ -68,12 +68,12 @@ pub(crate) fn cap_key(outer: &[String], holes: &[Vec]) -> String { } format!("{:x}", hash.finalize()) } -pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option { +fn exact_region_signature(curves: &ExactSketchCurves, region: &solvecraft_geom::Region2) -> Option { if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::() > 4096 || region.holes.len() > 128 { return None; } let signature = |outline: &solvecraft_geom::Loop2| { - let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::>>()?; + let mut ids = outline.segs.iter().map(|segment| curves.find(segment)).collect::>>()?; ids.sort(); ids.dedup(); Some(ids) @@ -112,6 +112,7 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_k } } } + let curves = ExactSketchCurves::new(sketch); let mut counts = std::collections::BTreeMap::new(); let mut bindings = Vec::with_capacity(bodies.len()); for body in bodies { @@ -121,7 +122,7 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_k let signature = if let Some(FaceSource::ExtrudeCap { region, high, .. }) = source { let signature = match &cached { Some((previous, signature)) if *previous == region => signature.clone(), - _ => exact_region_signature(sketch, region), + _ => exact_region_signature(&curves, region), }; cached = Some((region, signature.clone())); if let Some((outer, holes)) = &signature { @@ -307,36 +308,55 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solvec /// Match a generated segment to one exact analytic sketch curve. Coincident /// candidates are ambiguous and never resolved by distance or candidate order. -pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option { - if sketch.sketch.curves.len() > 2000 { - return None; - } - let mut found = None; - for (index, curve) in sketch.sketch.curves.iter().enumerate() { - if curve.construction - || !matches!( - curve.kind, - solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Arc { .. } | solvecraft_sketch::CurveKind::Circle { .. } - ) - { - continue; +pub(crate) struct ExactSketchCurves<'a> { + segments: Vec<(&'a str, Vec)>, +} + +impl<'a> ExactSketchCurves<'a> { + /// Prepare only bounded analytic curves, once per certification pass. Point lookup + /// and segment allocation must not be repeated for every generated face. + pub(crate) fn new(sketch: &'a SolvedSketch) -> Self { + let mut segments = Vec::new(); + if sketch.sketch.curves.len() > 2000 { + return Self { segments }; } - let matches = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind { - sketch - .sketch - .point(c) - .is_some_and(|center| exact_segment_contains(&Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }, generated)) - } else { - sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated)) - }; - if matches { - if found.is_some() { - return None; + for (index, curve) in sketch.sketch.curves.iter().enumerate() { + if curve.construction + || !matches!( + curve.kind, + solvecraft_sketch::CurveKind::Line { .. } + | solvecraft_sketch::CurveKind::Arc { .. } + | solvecraft_sketch::CurveKind::Circle { .. } + ) + { + continue; } - found = Some(curve.id.clone()); + let originals = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind { + sketch + .sketch + .point(c) + .map(|center| vec![Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }]) + .unwrap_or_default() + } else { + sketch.sketch.segs(index) + }; + segments.push((curve.id.as_str(), originals)); } + Self { segments } + } + + pub(crate) fn find(&self, generated: &Seg2) -> Option { + let mut found = None; + for (id, originals) in &self.segments { + if originals.iter().any(|original| exact_segment_contains(original, generated)) { + if found.is_some() { + return None; + } + found = Some(*id); + } + } + found.map(str::to_owned) } - found } fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool { @@ -486,6 +506,73 @@ mod shell_tests; mod tests { use super::*; use serde_json::json; + + fn solved(mut sketch: solvecraft_sketch::Sketch) -> SolvedSketch { + let report = solvecraft_sketch::solve(&mut sketch); + SolvedSketch { feature: 1, name: "sources".into(), plane: solvecraft_geom::Plane::XY, sketch, report, profiles: Vec::new() } + } + + #[test] + fn prepared_sources_keep_exact_identity_and_reject_ambiguity() { + use solvecraft_geom::Vec2; + let mut sketch = solvecraft_sketch::Sketch::new(); + sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("line")).unwrap(); + sketch.add_circle(Vec2::new(20., 0.), 2., None, Some("circle")).unwrap(); + let construction = sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("construction")).unwrap(); + sketch.curves[construction].construction = true; + let sketch = solved(sketch); + let curves = ExactSketchCurves::new(&sketch); + let partial = Seg2::Line { a: Vec2::new(8., 0.), b: Vec2::new(2., 0.) }; + assert_eq!(curves.find(&partial).as_deref(), Some("line")); + assert_eq!(curves.find(&Seg2::Arc { center: Vec2::new(20., 0.), radius: 2., start: 1., sweep: -2. }).as_deref(), Some("circle")); + assert_eq!(curves.find(&Seg2::Line { a: Vec2::ZERO, b: Vec2::new(11., 0.) }), None); + let mut ambiguous = sketch.clone(); + ambiguous.sketch.add_line(Vec2::new(1., 0.), Vec2::new(9., 0.), None, None, Some("overlap")).unwrap(); + assert_eq!(ExactSketchCurves::new(&ambiguous).find(&partial), None); + // A prepared pass borrows one immutable evaluated sketch, so an upstream edit + // necessarily prepares fresh sources rather than retaining stale coordinates. + let mut moved = sketch.clone(); + moved.sketch = solvecraft_sketch::Sketch::new(); + moved.sketch.add_line(Vec2::new(0., 1.), Vec2::new(10., 1.), None, None, Some("line")).unwrap(); + assert_eq!(ExactSketchCurves::new(&moved).find(&partial), None); + } + + #[test] + #[ignore = "manual optimized certification microbenchmark; no timing assertion"] + fn benchmark_prepared_extrude_sources() { + use solvecraft_geom::Vec2; + let mut sketch = solvecraft_sketch::Sketch::new(); + for i in 0..1000 { + let y = i as f64; + sketch.add_line(Vec2::new(0., y), Vec2::new(10., y), None, None, None).unwrap(); + } + let sketch = solved(sketch); + let queries: Vec<_> = (0..1000).map(|i| Seg2::Line { a: Vec2::new(2., i as f64), b: Vec2::new(8., i as f64) }).collect(); + let start = std::time::Instant::now(); + let old: Vec<_> = queries + .iter() + .map(|generated| { + let mut found = None; + for (index, curve) in sketch.sketch.curves.iter().enumerate() { + if sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated)) { + if found.is_some() { + return None; + } + found = Some(curve.id.clone()); + } + } + found + }) + .collect(); + let old_ms = start.elapsed().as_secs_f64() * 1000.; + let start = std::time::Instant::now(); + let curves = ExactSketchCurves::new(&sketch); + let new: Vec<_> = queries.iter().map(|query| curves.find(query)).collect(); + let new_ms = start.elapsed().as_secs_f64() * 1000.; + assert_eq!(old, new); + assert!(new.iter().all(Option::is_some)); + eprintln!("1000 analytic sources / 1000 face segments: repeated={old_ms:.3}ms prepared={new_ms:.3}ms (includes preparation)"); + } #[test] fn certificate_budgets_apply_before_saved_target_and_paint_decode() { for certificate in [ diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index c4fd05ce..e67e2db2 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1800,6 +1800,7 @@ fn feature_tools_with_constructor( } if *operation == Operation::NewBody && extent.taper.is_none() { let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000); + let source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss)); tools = tools .into_iter() .enumerate() @@ -1810,7 +1811,7 @@ fn feature_tools_with_constructor( .flatten() .map(|source| match source { Some(kernel::FaceSource::ExtrudeSide { segment, .. }) if cap_bindings.is_some() => { - crate::config::exact_sketch_curve(&ss, segment) + source_curves.as_ref().and_then(|curves| curves.find(segment)) } _ => None, }) diff --git a/docs/perf.md b/docs/perf.md index f42bc219..f9969f2d 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -1,5 +1,30 @@ # Design loading +## Analytic extrusion source preparation + +New-body extrusion certificates now prepare the sketch's bounded analytic segments once +per certification pass instead of allocating segments and resolving their points for each +generated cap boundary and side face. Saved designs run this same code during cold rebuilds. +Exact containment tolerances, ambiguous-source rejection and the certification work budget +are unchanged. Prepared segments borrow an immutable evaluated sketch and are not retained +across edits. + +The reproducible microbenchmark is: + +```sh +cargo test -p solvecraft-doc --profile ci benchmark_prepared_extrude_sources -- --ignored --nocapture +``` + +Three Windows x64 optimized `ci` runs, during other compilation, compared the previous +segment reconstruction loop and the prepared matcher in the same process. For 1,000 +analytic lines and 1,000 generated face segments, median time fell from **45.90 ms to +12.30 ms** (73% less, about 3.7x faster). Preparation is included and every returned source +identity is compared. This measures certification only, not complete extrusion, file-open +latency or Boolean operations; it does not establish an end-to-end loading speedup. +Remaining cold geometry rebuild and import costs are tracked in +[#255](https://github.com/bherbruck/solidcraft/issues/255); certification work is +[#254](https://github.com/bherbruck/solidcraft/issues/254). + ## Cold geometry rebuilds after 0.3.1 Three alternating cold-process comparisons on the same Windows machine and optimized