Keep prepared assembly geometry through selection changes
This commit is contained in:
1 parent
18d487a9bd
commit
13aac4296d
4 files changed
+168
-12
No files matched your search
@@ -297,6 +297,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
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.
|
||||
- Prepared assembly geometry survives selection and view changes while real geometry and
|
||||
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
|
||||
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
|
||||
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
|
||||
|
||||
## Kernel
|
||||
|
||||
|
||||
+35
-12
@@ -150,6 +150,28 @@ pub struct Preview {
|
||||
pub after: Arc<ModelState>,
|
||||
}
|
||||
|
||||
/// Prepared world geometry depends on design/model identity and placement context, not
|
||||
/// selection or the view revision. Retained Arcs prevent pointer reuse after a design closes.
|
||||
#[derive(Clone)]
|
||||
struct WorldCache {
|
||||
doc: Arc<Document>,
|
||||
model: Arc<solvecraft_doc::ModelState>,
|
||||
pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
active_component: u64,
|
||||
active_occurrence_path: Option<Vec<u64>>,
|
||||
world: Arc<solvecraft_doc::ModelState>,
|
||||
}
|
||||
|
||||
impl WorldCache {
|
||||
fn matches(&self, session: &Session, model: &Arc<solvecraft_doc::ModelState>) -> bool {
|
||||
Arc::ptr_eq(&self.doc, &session.doc)
|
||||
&& Arc::ptr_eq(&self.model, model)
|
||||
&& self.pending_moves == session.pending_moves
|
||||
&& self.active_component == session.active_component
|
||||
&& self.active_occurrence_path == session.active_occurrence_path
|
||||
}
|
||||
}
|
||||
|
||||
/// An editing session on one design.
|
||||
pub struct Session {
|
||||
pub doc: Arc<Document>,
|
||||
@@ -173,7 +195,7 @@ pub struct Session {
|
||||
pub active_occurrence_path: Option<Vec<u64>>,
|
||||
/// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them).
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>,
|
||||
world_cache: std::sync::Mutex<Option<WorldCache>>,
|
||||
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
|
||||
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
|
||||
pub clipboard: Vec<solvecraft_doc::Feature>,
|
||||
@@ -308,10 +330,10 @@ impl Session {
|
||||
return st;
|
||||
}
|
||||
if let Ok(c) = self.world_cache.lock()
|
||||
&& let Some((rev, w)) = c.as_ref()
|
||||
&& *rev == self.revision
|
||||
&& let Some(cached) = c.as_ref()
|
||||
&& cached.matches(self, &st)
|
||||
{
|
||||
return w.clone();
|
||||
return cached.world.clone();
|
||||
}
|
||||
let mut w = if self.pending_moves.is_empty() {
|
||||
solvecraft_doc::world_state(&self.doc, &st)
|
||||
@@ -350,7 +372,14 @@ impl Session {
|
||||
}
|
||||
let w = Arc::new(w);
|
||||
if let Ok(mut c) = self.world_cache.lock() {
|
||||
*c = Some((self.revision, w.clone()));
|
||||
*c = Some(WorldCache {
|
||||
doc: self.doc.clone(),
|
||||
model: st,
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
active_component: self.active_component,
|
||||
active_occurrence_path: self.active_occurrence_path.clone(),
|
||||
world: w.clone(),
|
||||
});
|
||||
}
|
||||
w
|
||||
}
|
||||
@@ -503,7 +532,7 @@ impl Session {
|
||||
active_component: self.active_component,
|
||||
active_occurrence_path: self.active_occurrence_path.clone(),
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
world_cache: Default::default(),
|
||||
world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())),
|
||||
occurrence_visibility_cache: Default::default(),
|
||||
clipboard: self.clipboard.clone(),
|
||||
section: None,
|
||||
@@ -519,12 +548,6 @@ impl Session {
|
||||
pub fn invalidate_view(&mut self, previous_revision: u64) {
|
||||
let old = self.revision;
|
||||
self.revision = old.max(previous_revision) + 1;
|
||||
if let Ok(mut cache) = self.world_cache.lock()
|
||||
&& let Some((revision, _)) = cache.as_mut()
|
||||
&& *revision == old
|
||||
{
|
||||
*revision = self.revision;
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit commands evaluated on a scratch copy without losing this session's file,
|
||||
|
||||
@@ -38,9 +38,11 @@ fn world_placement_cache_preserves_pending_picks_materials_scratch_and_named_joi
|
||||
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));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()), "selection changes must not discard prepared placements");
|
||||
let scratch = session.scratch();
|
||||
assert_eq!(scratch.pending_moves, pending);
|
||||
let scratch_world = scratch.world_state();
|
||||
assert!(Arc::ptr_eq(&world, &scratch_world));
|
||||
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);
|
||||
@@ -63,3 +65,113 @@ fn world_placement_cache_preserves_pending_picks_materials_scratch_and_named_joi
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_visibility_section_and_view_revisions_reuse_world_geometry() {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
let world = session.world_state();
|
||||
let revision = session.revision;
|
||||
run(&mut session, "select.set", json!({"items":[{"type":"body","name":"Block"}]}));
|
||||
assert!(session.revision > revision);
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
run(&mut session, "select.clear", json!({}));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
run(&mut session, "browser.visibility", json!({"items":["Block"],"visible":false}));
|
||||
assert!(!session.body_visible("Block"));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
run(&mut session, "inspect.section", json!({"plane":{"origin":[0,0,2],"normal":[0,0,1]}}));
|
||||
assert!(session.section.is_some());
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
session.invalidate_view(session.revision + 10);
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
assert!(Arc::ptr_eq(&world, &session.scratch().world_state()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_cache_requires_exact_design_model_pending_and_author_context() {
|
||||
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"}));
|
||||
let sketch = run(&mut session, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
let initial = session.world_state();
|
||||
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"capture":false}));
|
||||
let pending = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&initial, &pending));
|
||||
let name = session.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
|
||||
assert!(pending.body(&name).unwrap().mesh().bounds().min.dist(Vec3::new(10., 20., 0.)) < 1e-9);
|
||||
run(&mut session, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
let authored = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&pending, &authored));
|
||||
assert!(authored.sketch(sketch).unwrap().plane.origin.dist(Vec3::new(10., 20., 0.)) < 1e-9);
|
||||
let mut scratch = session.scratch();
|
||||
assert!(Arc::ptr_eq(&authored, &scratch.world_state()));
|
||||
scratch.active_occurrence_path = Some(vec![first]);
|
||||
let scratch_world = scratch.world_state();
|
||||
assert!(!Arc::ptr_eq(&authored, &scratch_world));
|
||||
assert!(scratch_world.sketch(sketch).unwrap().plane.origin.dist(Vec3::ZERO) < 1e-9);
|
||||
assert!(Arc::ptr_eq(&authored, &session.world_state()), "scratch context must not replace the source cache");
|
||||
run(&mut session, "appearance.assign", json!({"bodies":["Block"],"color":"#ff0000"}));
|
||||
let painted = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&authored, &painted));
|
||||
assert_ne!(session.doc.model_body_look(painted.body("Block").unwrap()), None);
|
||||
run(&mut session, "edit.undo", json!({}));
|
||||
let undone = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&painted, &undone));
|
||||
run(&mut session, "edit.redo", json!({}));
|
||||
let redone = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&undone, &redone));
|
||||
assert!(session.doc.model_body_look(redone.body("Block").unwrap()).is_some());
|
||||
let before_edit = session.world_state();
|
||||
run(&mut session, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Other","operation":"new"}));
|
||||
let edited = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&before_edit, &edited));
|
||||
assert!(edited.body("Other").is_some());
|
||||
session.doc = Arc::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||
let replaced = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&edited, &replaced), "equal contents in a different document cannot share the cache key");
|
||||
session.model = crate::doc::Model::default();
|
||||
session.model.evaluate(&session.doc);
|
||||
assert!(!Arc::ptr_eq(&replaced, &session.world_state()), "a new evaluated model must invalidate even when the document Arc is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_scratch_keeps_its_warm_geometry_and_history_stages_invalidate_it() {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
let source = session.world_state();
|
||||
let source_json = session.doc.to_json();
|
||||
let mut scratch = session.scratch();
|
||||
run(&mut scratch, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Draft","operation":"new"}));
|
||||
let warmed = scratch.world_state();
|
||||
assert!(!Arc::ptr_eq(&source, &warmed));
|
||||
assert_eq!(session.doc.to_json(), source_json);
|
||||
assert!(Arc::ptr_eq(&source, &session.world_state()));
|
||||
assert!(source.body("Draft").is_none());
|
||||
let feature = warmed.body("Draft").unwrap().feature;
|
||||
session.accept_computed(scratch);
|
||||
assert!(Arc::ptr_eq(&warmed, &session.world_state()), "adoption/view revision must retain the worker's prepared geometry");
|
||||
session.invalidate_view(session.revision + 20);
|
||||
assert!(Arc::ptr_eq(&warmed, &session.world_state()));
|
||||
run(&mut session, "timeline.rollback", json!({"position":1}));
|
||||
let rolled = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&warmed, &rolled));
|
||||
assert!(rolled.body("Block").is_some());
|
||||
assert!(rolled.body("Draft").is_none());
|
||||
run(&mut session, "timeline.rollback", json!({}));
|
||||
let restored = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&rolled, &restored));
|
||||
assert!(restored.body("Draft").is_some());
|
||||
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":true}));
|
||||
let suppressed = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&restored, &suppressed));
|
||||
assert!(suppressed.body("Draft").is_none());
|
||||
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":false}));
|
||||
assert!(session.world_state().body("Draft").is_some());
|
||||
}
|
||||
@@ -38,6 +38,23 @@ times to stderr without logging the source path. Thumbnails were about 0.1 secon
|
||||
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.
|
||||
|
||||
### Prepared assembly geometry and selection
|
||||
|
||||
Prepared world geometry is now keyed by the document, evaluated model and exact placement
|
||||
context instead of the session revision. Selecting a face or edge, changing visibility,
|
||||
or moving the view no longer discards already prepared assembly bodies and meshes.
|
||||
Geometry edits, pending occurrence moves, authoring context changes, undo/redo and timeline
|
||||
rollback still invalidate the cache. Scratch sessions share prepared geometry while their
|
||||
inputs match, and adopting a computed session retains its warm cache.
|
||||
|
||||
On the private 2,990-body Pi assembly, a matched Linux desktop control probe reduced an
|
||||
edge-selection round trip from 9.24 s to 0.57 s. Initial opening stayed essentially unchanged
|
||||
(33.54 s to 33.32 s); this fixes a separate selection stall, not cold geometry evaluation.
|
||||
Three fresh Windows desktop runs with the corrected cache measured a median 15.87 s to
|
||||
ready: 13.04 s geometry, 2.20 s placement, 0.53 s meshing and 0.07 s thumbnails. These used
|
||||
the optimized `ci` profile and actual Vulkan rendering, with isolated settings and caches.
|
||||
Linux and Windows timings are separate measurements and must not be compared as a speedup.
|
||||
|
||||
`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