From 400f200efafc2367bf10029bdbc7cf13e360eb99 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sun, 11 Oct 2026 20:22:19 -0400 Subject: [PATCH] Speed up cold document geometry rebuilds --- ROADMAP.md | 9 ++ crates/doc/src/eval.rs | 96 +++++++++++++++- crates/engine/src/lib.rs | 2 + .../engine/src/world_placement_cache_tests.rs | 65 +++++++++++ crates/geom/src/profile.rs | 88 +++++++++++++- crates/kernel/src/body.rs | 108 +++++++++++++----- crates/kernel/src/refine_cache_tests.rs | 53 +++++++++ crates/ui-egui/src/file_load.rs | 19 ++- docs/perf.md | 38 ++++++ 9 files changed, 443 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/world_placement_cache_tests.rs create mode 100644 crates/kernel/src/refine_cache_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 56b90838..73e24051 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -289,6 +289,15 @@ headline counts in-scope tabs only and lists the deferred ones separately. continuous collision detection; local thin walls and small details remain limitations. See [contact-set limits and recovery](docs/contact-sets.md). +## Document-opening performance + +- Cold rebuild profiling separates parsing, geometry, placement, display meshing and + thumbnails. Straight-line closest-point projection and exact curved-surface evaluation + reuse reduce repeated work without relaxing geometry checks. Three alternating optimized + benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22% + on a local case assembly; shape measurements and topology match the original build. + See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain. + ## Kernel truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 75cd9cf2..d9859394 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3436,9 +3436,18 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { let name = doc.world_body_name(st, &b.name, comp, &path); // A body moved in from another component keeps its place (its offset). let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m); - let body = - if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) }; - let mut placed = ModelBody::new(name, body, b.feature); + // Identical geometry also shares its display mesh cell with the definition. + // A changed placement keeps a distinct mesh, at that placement's coordinates. + let mut placed = if crate::is_identity(&bm) { + let mut placed = b.clone(); + placed.name = name; + placed + } else { + let body = kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()); + ModelBody::new(name, body, b.feature) + }; + // World states keep their previous display-only evaluation-key semantics. + placed.key = 0; placed.source_name = Some(b.definition_name().to_string()); placed.source_transform = crate::mat_mul(&bm, &b.source_transform); placed.component = comp; @@ -3481,6 +3490,87 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState { out } +#[cfg(test)] +mod world_placement_cache_tests { + use super::*; + + #[test] + fn placed_real_feature_retains_names_from_its_original_feature_context() { + let mut doc = Document::new("authored names"); + let component = doc.add_component(Some("Part"), 0).unwrap(); + let feature = doc + .add_feature( + FeatureKind::Box { + corner: crate::point_expr(Vec3::ZERO), + length: "2 mm".into(), + width: "3 mm".into(), + height: "4 mm".into(), + operation: crate::Operation::NewBody, + }, + Some("AuthoredBox"), + ) + .unwrap(); + doc.feature_mut(feature).unwrap().component = component; + let matrix = crate::rigid(Vec3::new(15., -2., 8.), Vec3::ZERO, Vec3::X, 0.7); + doc.occurrences.iter_mut().find(|o| o.component == component).unwrap().transform = matrix; + let mut model = Model::default(); + model.evaluate(&doc); + let state = model.state(); + let source = &state.bodies[0]; + assert!(source.names.origin.is_some()); + assert!(source.mesh.get().is_none()); + let world = world_state(&doc, &state); + assert!(source.mesh.get().is_some(), "source naming is prepared before adoption, as before"); + let names = crate::naming::face_names(&world.bodies[0]); + assert!(Arc::ptr_eq(&names, &crate::naming::face_names(source))); + assert!(names.iter().all(|name| name.starts_with(&format!("F{feature}:")))); + let local = source.mesh(); + let placed = world.bodies[0].mesh(); + assert_eq!(local.tri_face, placed.tri_face); + let mapped: Vec<_> = local.positions.iter().map(|p| crate::apply_point(&matrix, *p)).collect(); + assert!(mapped.iter().all(|p| placed.positions.iter().any(|q| p.dist(*q) < 1e-8))); + assert!(placed.positions.iter().all(|p| mapped.iter().any(|q| p.dist(*q) < 1e-8))); + } + + #[test] + fn identity_shares_mesh_but_rotated_instances_keep_distinct_physical_coordinates_and_source_names() { + let mut doc = Document::new("placements"); + let component = doc.add_component(Some("Part"), 0).unwrap(); + doc.body_components.insert("Block".into(), component); + let matrix = crate::rigid(Vec3::new(10., 20., 0.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2); + let second = doc.add_occurrence(component, 0, matrix).unwrap(); + let mut state = ModelState::default(); + state.bodies.push(ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1)); + state.bodies[0].key = 1234; + let source = &state.bodies[0]; + let world = world_state(&doc, &state); + assert_eq!(world.bodies.len(), 2); + let first = world.body("Block").unwrap(); + let second_name = doc.world_body_name(&state, "Block", component, &[second]); + let placed = world.body(&second_name).unwrap(); + assert!(source.mesh.get().is_some(), "persistent source names stay prepared before adoption"); + assert!(Arc::ptr_eq(&source.mesh, &first.mesh), "unchanged geometry must retain the shared display mesh cell"); + assert_eq!(first.key, 0); + assert_eq!(placed.key, 0); + assert!(!Arc::ptr_eq(&source.mesh, &placed.mesh), "a transformed mesh cannot be reused without mapping coordinates"); + assert_eq!(first.source_name.as_deref(), Some("Block")); + assert_eq!(placed.source_name.as_deref(), Some("Block")); + assert_eq!(placed.source_transform, matrix); + assert_eq!(doc.model_body_component(placed), component); + let bounds = placed.mesh().bounds(); + assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9); + assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9); + let names = crate::naming::face_names(source); + assert!(Arc::ptr_eq(&names, &crate::naming::face_names(first))); + assert!(Arc::ptr_eq(&names, &crate::naming::face_names(placed))); + let original_mesh = source.mesh(); + assert!(Arc::ptr_eq(&original_mesh, &first.mesh())); + let again = world_state(&doc, &state); + assert!(Arc::ptr_eq(&original_mesh, &again.body("Block").unwrap().mesh())); + assert!(Arc::ptr_eq(&names, &crate::naming::face_names(again.body(&second_name).unwrap()))); + } +} + #[cfg(test)] mod chain_tests { use super::*; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bccd68ab..ffe95302 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -651,3 +651,5 @@ mod split_face_tests; mod tests; #[cfg(test)] mod thread_tests; +#[cfg(test)] +mod world_placement_cache_tests; diff --git a/crates/engine/src/world_placement_cache_tests.rs b/crates/engine/src/world_placement_cache_tests.rs new file mode 100644 index 00000000..34d1b2a6 --- /dev/null +++ b/crates/engine/src/world_placement_cache_tests.rs @@ -0,0 +1,65 @@ +//! Placement-cache changes must retain physical picking, pending positions and named joints. +use crate::{Session, geom::Vec3}; +use serde_json::{Value, json}; +use std::sync::Arc; + +fn run(session: &mut Session, command: &str, params: Value) -> Value { + session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}")) +} + +#[test] +fn world_placement_cache_preserves_pending_picks_materials_scratch_and_named_joint_frames() { + let mut session = Session::default(); + let created = run(&mut session, "component.create", json!({"name":"Part"})); + let component = created["component"].as_u64().unwrap(); + let first = created["occurrence"].as_u64().unwrap(); + run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"})); + run(&mut session, "material.assign", json!({"bodies":["Block"],"material":"Brass"})); + let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap(); + run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"axis":[0,0,1],"angle":"90 deg","capture":false})); + let pending = session.pending_moves.clone(); + let path = vec![second]; + let placement = session.occurrence_placement(&path).unwrap(); + let name = placement.bodies[0].world.clone(); + let world = session.world_state(); + assert!(Arc::ptr_eq(&world, &session.world_state())); + let source_state = session.model.state(); + let source = source_state.body("Block").unwrap(); + let placed = world.body(&name).unwrap(); + assert_eq!(placed.source_name.as_deref(), Some("Block")); + assert_eq!(placed.component, component); + let bounds = placed.mesh().bounds(); + assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9); + assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9); + assert_eq!(session.doc.model_body_look(placed), session.doc.model_body_look(source)); + let source_names = crate::doc::naming::face_names(source); + assert_eq!(source_names, crate::doc::naming::face_names(placed)); + let pick = Vec3::new(8.5, 21., 4.); + let top = placed.body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap().index; + run(&mut session, "select.set", json!({"items":[{"type":"face","body":name,"index":top,"point":[pick.x,pick.y,pick.z]}]})); + assert!(matches!(&session.selection[0], crate::Sel::Face { body, index, .. } if body == &name && *index == top)); + let scratch = session.scratch(); + assert_eq!(scratch.pending_moves, pending); + let scratch_world = scratch.world_state(); + let scratch_body = scratch_world.body(&name).unwrap(); + assert!(scratch_body.mesh().bounds().min.dist(bounds.min) < 1e-9); + assert_eq!(crate::doc::naming::face_names(scratch_body), source_names); + run(&mut session, "component.capture_position", json!({})); + run(&mut session, "joint.origin", json!({"name":"Top joint","occurrence":second,"face":[pick.x,pick.y,pick.z]})); + let joint = session.doc.assembly.origins[0].origin.clone(); + let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap(); + let before_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame); + assert!(crate::doc::apply_point(&before_joint, Vec3::ZERO).dist(pick) < 1e-9); + run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":true})); + let moved = session.world_state(); + assert!(!Arc::ptr_eq(&world, &moved), "a changed transform must invalidate placed meshes"); + assert!(moved.body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9); + assert!(world.body(&name).unwrap().mesh().bounds().min.dist(bounds.min) < 1e-9, "prior snapshots must remain immutable"); + assert_eq!(crate::doc::naming::face_names(moved.body(&name).unwrap()), source_names); + let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap(); + let after_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame); + assert!(crate::doc::apply_point(&after_joint, Vec3::ZERO).dist(pick + Vec3::new(5., 0., 0.)) < 1e-9); + let reopened = Session::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap()); + assert!(reopened.world_state().body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9); + assert_eq!(reopened.model.results.iter().filter(|r| r.error.is_some()).count(), 0); +} diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 71b82358..348c9076 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -244,8 +244,24 @@ impl Seg2 { } } } - /// Parameter of the point of the segment closest to `p` (sampling, then Newton). + /// Parameter of the closest point: exact projection for lines, + /// sampling followed by Newton for the remaining curves. pub fn closest_param(&self, p: Vec2) -> f64 { + let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite(); + match *self { + Seg2::Line { a, b } if finite(a) && finite(b) && finite(p) => { + let d = b - a; + if a == b { + return 0.0; + } + let denominator = d.len2(); + let t = (p - a).dot(d) / denominator; + if denominator > 0.0 && denominator.is_finite() && t.is_finite() { + return t.clamp(0.0, 1.0); + } + } + _ => {} + } let n = 64; let mut t = (0..=n).map(|i| i as f64 / n as f64).min_by(|x, y| self.point_at(*x).dist(p).total_cmp(&self.point_at(*y).dist(p))).unwrap_or(0.0); @@ -540,6 +556,76 @@ impl Region2 { mod tests { use super::*; + #[test] + fn closest_line_projection_and_degeneracy() { + let line = Seg2::Line { a: Vec2::new(-3.0, 4.0), b: Vec2::new(7.0, 4.0) }; + for (p, t) in [(Vec2::new(-9.0, 20.0), 0.0), (Vec2::new(2.0, -5.0), 0.5), (Vec2::new(15.0, 4.0), 1.0)] { + assert_eq!(line.closest_param(p), t); + assert_eq!(line.reversed().closest_param(p), 1.0 - t); + } + let a = Vec2::new(1e100, -1e100); + let b = a + Vec2::new(1e90, 2e90); + let large = Seg2::Line { a, b }; + assert!((large.closest_param(a.lerp(b, 0.5)) - 0.5).abs() < 1e-6); + assert_eq!(Seg2::Line { a, b: a }.closest_param(Vec2::ZERO), 0.0); + let tiny = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-12, 0.0) }; + assert_eq!(tiny.closest_param(Vec2::new(0.5e-12, 1.0)), 0.5); + for line in [line, line.reversed(), tiny] { + for k in 0..53 { + let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0); + let closest = line.point_at(line.closest_param(p)).dist(p); + let dense = (0..=8192).map(|i| line.point_at(i as f64 / 8192.0).dist(p)).fold(f64::INFINITY, f64::min); + assert!(closest <= dense + 1e-12); + } + } + } + + fn sampled_closest(s: &Seg2, p: Vec2) -> f64 { + let mut t = (0..=64).map(|i| i as f64 / 64.0).min_by(|x, y| s.point_at(*x).dist(p).total_cmp(&s.point_at(*y).dist(p))).unwrap_or(0.0); + for _ in 0..30 { + let (q, d) = (s.point_at(t), s.derivative(t)); + let (d2, h) = ((s.derivative((t + 1e-6).min(1.0)) - s.derivative((t - 1e-6).max(0.0))) / 2e-6, q - p); + let df = d.dot(d) + h.dot(d2); + if df.abs() < 1e-300 { + break; + } + let nt = (t - h.dot(d) / df).clamp(0.0, 1.0); + if (nt - t).abs() < 1e-14 { + t = nt; + break; + } + t = nt; + } + t + } + + #[test] + fn closest_projection_preserves_clearance_tolerance_and_generic_fallback() { + use std::f64::consts::TAU; + let curves = [ + Seg2::Line { a: Vec2::new(-4.0, 7.0), b: Vec2::new(3.0, -2.0) }, + Seg2::Arc { center: Vec2::new(3.0, -2.0), radius: 6.0, start: 5.9, sweep: 1.3 }, + Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.2, sweep: -TAU }, + Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: TAU }, + Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 0.0 }, + Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1e-12 }, + Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(2.0, 7.0), p2: Vec2::new(6.0, -3.0), p3: Vec2::new(8.0, 4.0) }, + ]; + for (i, s) in curves.iter().enumerate() { + for k in 0..101 { + let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0); + let old = sampled_closest(s, p); + let new = s.closest_param(p); + if i > 0 { + assert_eq!(new.to_bits(), old.to_bits()); + } + assert!(s.point_at(new).dist(p) <= s.point_at(old).dist(p) + 1e-10); + let on_curve = s.point_at(k as f64 / 100.0); + assert!(s.point_at(s.closest_param(on_curve)).dist(on_curve) < 1e-9); + } + } + } + #[test] fn triangulated_area() { let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]); diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 3506cc71..6e671c97 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -120,6 +120,11 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec { /// `None` when nothing needs splitting or the parameters don't match the surface. #[allow(clippy::type_complexity)] fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option> { + refined_with_cache(pm, surf, tol, true) +} + +#[allow(clippy::type_complexity)] +fn refined_with_cache(pm: &PolygonMesh, surf: &mt::Surface, tol: f64, memoize: bool) -> Option> { use mt::{ParametricSurface, ParametricSurface3D}; let (pos, uvs, nor) = (pm.positions(), pm.uv_coords(), pm.normals()); if uvs.is_empty() { @@ -148,6 +153,15 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option::new(); let mut changed = false; for _ in 0..4 { let mut uses: std::collections::HashMap<((i64, i64), (i64, i64)), u32> = std::collections::HashMap::new(); @@ -156,34 +170,60 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option Option { - if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 { - return None; + let mut edge_cache = std::collections::HashMap::<([u64; 8], [u64; 8]), Option>::new(); + let mut at = |a: &V, b: &V| -> Option { + let key = if memoize { vertex_key(a).zip(vertex_key(b)).map(|(a, b)| if a < b { (a, b) } else { (b, a) }) } else { None }; + if let Some(key) = key + && let Some(value) = edge_cache.get(&key) + { + return *value; } - // Not at a degenerate point (a cone's apex: its parameters there are arbitrary). - let singular = |x: &V| { - let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1)); - let c = du.cross(dv); - c.x * c.x + c.y * c.y + c.z * c.z - <= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300 + let mut calculate = || -> Option { + if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 { + return None; + } + // Not at a degenerate point (a cone's apex: its parameters there are arbitrary). + let mut singular = |x: &V| { + let key = (x.1.0.to_bits(), x.1.1.to_bits()); + let cacheable = memoize && x.1.0.is_finite() && x.1.1.is_finite(); + if cacheable && let Some(value) = singular_cache.get(&key) { + return *value; + } + let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1)); + let c = du.cross(dv); + let value = c.x * c.x + c.y * c.y + c.z * c.z + <= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300; + if cacheable && singular_cache.len() < cache_limit { + singular_cache.insert(key, value); + } + value + }; + if singular(a) || singular(b) { + return None; + } + let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0); + let q = from_p3(surf.subs(u, v)); + // Within tolerance, or so far off that the parameters wrapped (a seam). + let sag = q.dist((a.0 + b.0) * 0.5); + if sag <= tol || sag >= 0.5 * a.0.dist(b.0) { + return None; + } + let n = surf.normal(u, v); + let mut n = Vec3::new(n.x, n.y, n.z); + // Keep the mesh's normal side. + if n.dot(a.2 + b.2) < 0.0 { + n = -n; + } + Some((q, (u, v), n)) }; - if singular(a) || singular(b) { - return None; + let value = calculate(); + if let Some(key) = key + && edge_cache.len() < cache_limit + && value.is_none_or(|v| vertex_key(&v).is_some()) + { + edge_cache.insert(key, value); } - let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0); - let q = from_p3(surf.subs(u, v)); - // Within tolerance, or so far off that the parameters wrapped (a seam). - let sag = q.dist((a.0 + b.0) * 0.5); - if sag <= tol || sag >= 0.5 * a.0.dist(b.0) { - return None; - } - let n = surf.normal(u, v); - let mut n = Vec3::new(n.x, n.y, n.z); - // Keep the mesh's normal side. - if n.dot(a.2 + b.2) < 0.0 { - n = -n; - } - Some((q, (u, v), n)) + value }; let mut next = Vec::with_capacity(tris.len()); let mut any = false; @@ -658,11 +698,15 @@ impl Body { let flip = !face.orientation(); let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX); // Curved faces: split triangles that cut through the surface. - if refine - && let Some(sf) = solid_faces.get(fi) - && !matches!(sf.surface(), mt::Surface::Plane(_)) - && let Some(tris) = refined(&pm, &sf.surface(), tol) - { + let refinement = if refine { + solid_faces.get(fi).and_then(|sf| { + let surface = sf.surface(); + if matches!(surface, mt::Surface::Plane(_)) { None } else { refined(&pm, &surface, tol) } + }) + } else { + None + }; + if let Some(tris) = refinement { let fid = u32::try_from(fi).unwrap_or(u32::MAX); for t in tris { let mut idx = [0u32; 3]; @@ -1029,3 +1073,7 @@ impl Body { #[cfg(test)] #[path = "body_lump_tests.rs"] mod lump_tests; + +#[cfg(test)] +#[path = "refine_cache_tests.rs"] +mod refine_cache_tests; diff --git a/crates/kernel/src/refine_cache_tests.rs b/crates/kernel/src/refine_cache_tests.rs new file mode 100644 index 00000000..d6702df1 --- /dev/null +++ b/crates/kernel/src/refine_cache_tests.rs @@ -0,0 +1,53 @@ +use super::*; + +fn bits(v: Vec3) -> [u64; 3] { + [v.x.to_bits(), v.y.to_bits(), v.z.to_bits()] +} + +fn check_body(body: &Body) -> usize { + let meshes = mesh_shells(&body.solid, 0.5); + let mut changed = 0; + for (face, meshed) in body.solid.face_iter().zip(meshes.iter().flat_map(|shell| shell.face_iter())) { + let Some(mesh) = meshed.surface() else { continue }; + let surface = face.surface(); + let cached = refined_with_cache(&mesh, &surface, 0.002, true); + let original = refined_with_cache(&mesh, &surface, 0.002, false); + match (cached, original) { + (None, None) => {} + (Some(cached), Some(original)) => { + changed += 1; + assert_eq!(cached.len(), original.len(), "triangle count/winding must not change"); + for (cached, original) in cached.into_iter().zip(original) { + for ((point, normal), (old_point, old_normal)) in cached.into_iter().zip(original) { + assert_eq!(bits(point), bits(old_point), "every refined coordinate must remain bit-identical"); + assert_eq!(bits(normal), bits(old_normal), "every refined normal must remain bit-identical"); + } + } + } + _ => panic!("memoization changed the refine/no-refine decision"), + } + } + changed +} + +#[test] +fn exact_refinement_cache_preserves_periodic_seams_and_normals() { + let cylinder = crate::cylinder(Vec3::ZERO, Vec3::Z, 15., 8.).unwrap(); + let torus = crate::torus(Vec3::ZERO, 18., 4.).unwrap(); + assert!(check_body(&cylinder) > 0, "the fixture must actually refine"); + assert!(check_body(&torus) > 0, "the periodic fixture must actually refine"); +} + +#[test] +fn exact_refinement_cache_preserves_singular_poles_and_placed_surfaces() { + let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap(); + assert!(check_body(&sphere) > 0, "the polar fixture must actually refine"); + let placed = crate::transform(&sphere, Vec3::new(120., -85., 40.), Vec3::ZERO, Vec3::new(1., 2., 3.), 0.371).unwrap(); + assert!(check_body(&placed) > 0); +} + +#[test] +fn exact_refinement_cache_keeps_unnecessary_planar_refinement_absent() { + let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap(); + assert_eq!(check_body(&body), 0); +} diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index 5d2644d3..9e1afa77 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -102,6 +102,8 @@ fn start(app: &mut SolveApp) { #[cfg(not(target_arch = "wasm32"))] fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job { + let profile = std::env::var_os("SOLVECRAFT_PROFILE_OPEN").is_some(); + let began = std::time::Instant::now(); if cancelled.load(Ordering::Relaxed) { return Err("file operation cancelled".into()); } @@ -113,16 +115,31 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can "file.insert_step" }; let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?; + let geometry_ms = began.elapsed().as_secs_f64() * 1000.0; + let view_began = std::time::Instant::now(); phase.store(1, Ordering::Relaxed); + let world = session.world_state(); + let placement_ms = view_began.elapsed().as_secs_f64() * 1000.0; + let mesh_began = std::time::Instant::now(); // Fit, picking and the first viewport frame must not trigger cold tessellation on the UI. - for b in &session.world_state().bodies { + for b in &world.bodies { if cancelled.load(Ordering::Relaxed) { return Err("file operation cancelled".into()); } let _ = b.mesh(); } + let tessellation_ms = mesh_began.elapsed().as_secs_f64() * 1000.0; phase.store(2, Ordering::Relaxed); + let view_ms = view_began.elapsed().as_secs_f64() * 1000.0; + let thumbnail_began = std::time::Instant::now(); let image = if insert { None } else { crate::home::remember(&session) }; + if profile { + eprintln!( + "SolidCraft open phases: geometry_ms={geometry_ms:.3} placement_ms={placement_ms:.3} tessellation_ms={tessellation_ms:.3} display_warmup_ms={view_ms:.3} thumbnail_ms={:.3} total_ms={:.3}", + thumbnail_began.elapsed().as_secs_f64() * 1000.0, + began.elapsed().as_secs_f64() * 1000.0 + ); + } Ok(Made { session, result, image }) } diff --git a/docs/perf.md b/docs/perf.md index f3eba901..577af9f6 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -1,5 +1,43 @@ # Design loading +## Cold geometry rebuilds after 0.3.1 + +Three alternating cold-process comparisons on the same Windows machine and optimized +`ci` profile, with no concurrent build, compare the unchanged 0.3.1 source with the +straight-line projection and exact surface-refinement reuse changes: + +| Saved design | Before, median evaluation | After | Reduction | +|---|---:|---:|---:| +| Public sensor enclosure, 21 features, 52 KB | 11.96 s | 8.85 s | 26.0% | +| Local Pi case assembly, 25 features, 2,990 bodies | 14.75 s | 11.57 s | 21.6% | + +These are geometry rebuild times, not complete desktop opening times. The public fixture +can be generated with `solvecraft-cli run examples/acceptance/sensor_enclosure.json +--save sensor.solvecraft --quiet`, then measured with `solvecraft-cli bench-open +sensor.solvecraft`. Each benchmark starts a new process; this does not measure a warm +timeline-cache hit. The local assembly is private and is not part of the repository. + +Straight segments now use guarded algebraic closest-point projection instead of sampling +65 points and running Newton iterations. Other curve types retain their existing behavior. +Curved-face refinement reuses derivative and midpoint evaluations with exact finite input +keys and bounded, local caches. It keeps the original tolerance, seam and singularity +checks, refinement passes, and triangle limits. + +Identity assembly placements retain the definition body's shared display-mesh cache. +Changed placements continue to build separate meshes in their own coordinates. Face-name +preparation remains eager during placement: deferring it merely shifted expensive work to +thumbnails and the first face query, so that approach was excluded. + +Both saved fixtures retain exactly the same body names, face/edge/vertex/shell counts, +bounds, volumes, and surface areas. Focused tests compare cached and uncached refinement +coordinates and normals bit for bit on periodic, singular and transformed surfaces. + +For desktop phase profiling, set `SOLVECRAFT_PROFILE_OPEN=1` before launching the app. +Interactive Open reports geometry, placement, tessellation, thumbnail and total worker +times to stderr without logging the source path. Thumbnails were about 0.1 seconds of +the slow assembly's open and were not the main bottleneck. Further cold Boolean and +import costs remain; these changes do not make all documents open instantly. + `solvecraft-cli bench-open design.solvecraft` reports JSON lines for file reading, JSON decoding and migrations (including input-name repair), timeline evaluation, individual features, and world placement/display meshing. `--parse-only` stops before geometry evaluation.