Speed up cold document geometry rebuilds
This commit is contained in:
1 parent
6dd202d277
commit
400f200efa
9 files changed
+443
-35
No files matched your search
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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, ¶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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user