Speed up cold document geometry rebuilds

This commit is contained in:
bherbruck committed 2026-10-11 20:22:19 -04:00
1 parent 6dd202d277
commit 400f200efa
9 files changed
+443 -35

No files matched your search

+9
View File
@@ -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
+93 -3
View File
@@ -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::*;
+2
View File
@@ -651,3 +651,5 @@ mod split_face_tests;
mod tests;
#[cfg(test)]
mod thread_tests;
#[cfg(test)]
mod world_placement_cache_tests;
@@ -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, &params).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);
}
+87 -1
View File
@@ -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)]);
+78 -30
View File
@@ -120,6 +120,11 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
/// `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<Vec<[(Vec3, Vec3); 3]>> {
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<Vec<[(Vec3, Vec3); 3]>> {
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<Vec<[(Vec3,
// Edges on the face's boundary are shared with the next face: never split them.
let k = |x: &V| ((x.1.0 * 1e9).round() as i64, (x.1.1 * 1e9).round() as i64);
let ek = |a: &V, b: &V| if k(a) < k(b) { (k(a), k(b)) } else { (k(b), k(a)) };
// Only exact finite values identify reusable evaluations. In particular,
// the rounded UV keys used for boundary incidence cannot identify a point
// whose derivatives or midpoint may be reused.
let vertex_key = |v: &V| {
let values = [v.0.x, v.0.y, v.0.z, v.1.0, v.1.1, v.2.x, v.2.y, v.2.z];
values.iter().all(|x| x.is_finite()).then(|| values.map(f64::to_bits))
};
let cache_limit = cap * 3;
let mut singular_cache = std::collections::HashMap::<(u64, u64), bool>::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<Vec<[(Vec3,
*uses.entry(e).or_default() += 1;
}
}
let at = |a: &V, b: &V| -> Option<V> {
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<V>>::new();
let mut at = |a: &V, b: &V| -> Option<V> {
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<V> {
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;
+53
View File
@@ -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);
}
+18 -1
View File
@@ -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 })
}
+38
View File
@@ -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.