Evaluation cache: every feature evaluation is kept by a key of its own inputs, the document context it reads and a digest of the model it starts from (bodies identified by the evaluation that made them, kept while passed through), shared with previews and scratch copies; setting a value back, undo, redo and repeated evaluations reuse results (a 20 × 20 patterned plate builds in 1.7 s instead of 7.5 s; undo/redo of a parameter edit 0 ms); cached and from-scratch evaluation compared in a test; numbers in docs/perf.md (solvecraft-cli bench-edit). Designs read now always get feature input names and occurrences filled in
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
b78cb886b8
commit
a01f191d25
6 files changed
+325
-7
No files matched your search
@@ -57,6 +57,8 @@ fn main() -> ExitCode {
|
|||||||
Some("mcp") => cmd_mcp(&args[1..]),
|
Some("mcp") => cmd_mcp(&args[1..]),
|
||||||
// Hidden, for the kill-mid-save test: save a large design over and over.
|
// Hidden, for the kill-mid-save test: save a large design over and over.
|
||||||
Some("save-stress") => cmd_save_stress(&args[1..]),
|
Some("save-stress") => cmd_save_stress(&args[1..]),
|
||||||
|
// Hidden: time recomputes after parameter edits (docs/perf.md).
|
||||||
|
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||||
Some("--version" | "-V") => {
|
Some("--version" | "-V") => {
|
||||||
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -241,3 +243,33 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `bench-edit <script.json|recipe.json> <param> <expr>`: build the design, then time a
|
||||||
|
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
|
||||||
|
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||||
|
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
|
||||||
|
return Err("usage: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
||||||
|
};
|
||||||
|
let v = read_json(path)?;
|
||||||
|
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
|
||||||
|
let mut s = Session::default();
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
s.run_script(&script).map_err(|e| e.to_string())?;
|
||||||
|
let build = t.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
let old = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| n == param).map(|(_, e, _)| e).ok_or(format!("no parameter `{param}`"))?;
|
||||||
|
let mut out = vec![json!({"step": "build", "ms": build, "features": s.doc.features.len()})];
|
||||||
|
let mut time = |s: &mut Session, step: &str, id: &str, p: Value| -> Result<(), String> {
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
let r = s.execute(id, &p).map_err(|e| format!("{step}: {e}"))?;
|
||||||
|
let n = r.get("recomputed").cloned().unwrap_or_else(|| json!(s.model.last_recomputed));
|
||||||
|
out.push(json!({"step": step, "ms": t.elapsed().as_secs_f64() * 1000.0, "recomputed": n}));
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
let expr = expr.replace("{old}", &old);
|
||||||
|
time(&mut s, "edit", "ChangeParameterCommand", json!({"name": param, "expression": expr}))?;
|
||||||
|
time(&mut s, "set back", "ChangeParameterCommand", json!({"name": param, "expression": old}))?;
|
||||||
|
time(&mut s, "undo", "UndoCommand", json!({}))?;
|
||||||
|
time(&mut s, "redo", "RedoCommand", json!({}))?;
|
||||||
|
println!("{}", pretty(&json!(out)));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+165
-2
@@ -20,11 +20,14 @@ pub struct ModelBody {
|
|||||||
/// Feature that created it.
|
/// Feature that created it.
|
||||||
pub feature: u64,
|
pub feature: u64,
|
||||||
mesh: OnceLock<Arc<Mesh>>,
|
mesh: OnceLock<Arc<Mesh>>,
|
||||||
|
/// Identity of this body's content for the evaluation cache: the key of the evaluation
|
||||||
|
/// that produced it (kept while later features pass it through unchanged).
|
||||||
|
key: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModelBody {
|
impl ModelBody {
|
||||||
pub fn new(name: String, body: Body, feature: u64) -> Self {
|
pub fn new(name: String, body: Body, feature: u64) -> Self {
|
||||||
ModelBody { name, body, feature, mesh: OnceLock::new() }
|
ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0 }
|
||||||
}
|
}
|
||||||
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
|
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
|
||||||
pub fn mesh(&self) -> Arc<Mesh> {
|
pub fn mesh(&self) -> Arc<Mesh> {
|
||||||
@@ -187,6 +190,85 @@ pub struct ModelState {
|
|||||||
pub body_counter: usize,
|
pub body_counter: usize,
|
||||||
/// Sheet metal bodies (by body name) and their flat patterns.
|
/// Sheet metal bodies (by body name) and their flat patterns.
|
||||||
pub sheets: Vec<crate::sheet::SheetBody>,
|
pub sheets: Vec<crate::sheet::SheetBody>,
|
||||||
|
/// Evaluation-cache keys of the sketches (by sketch feature id).
|
||||||
|
sketch_keys: BTreeMap<u64, u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelState {
|
||||||
|
/// A digest of everything later features can read: bodies (by content identity), sketches,
|
||||||
|
/// the body counter, threads and sheet bodies.
|
||||||
|
fn digest(&self) -> u64 {
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
for b in &self.bodies {
|
||||||
|
b.name.hash(&mut h);
|
||||||
|
b.key.hash(&mut h);
|
||||||
|
b.feature.hash(&mut h);
|
||||||
|
}
|
||||||
|
for s in &self.sketches {
|
||||||
|
s.feature.hash(&mut h);
|
||||||
|
self.sketch_keys.get(&s.feature).hash(&mut h);
|
||||||
|
}
|
||||||
|
self.body_counter.hash(&mut h);
|
||||||
|
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
|
||||||
|
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
|
||||||
|
/// made or changed, and sketches it made, take that key; the rest keep theirs.
|
||||||
|
fn stamp(&mut self, before: &ModelState, feature: u64, key: u64) {
|
||||||
|
for b in &mut self.bodies {
|
||||||
|
let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body)).map(|x| x.key);
|
||||||
|
b.key = kept.unwrap_or_else(|| {
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
(key, &b.name).hash(&mut h);
|
||||||
|
h.finish()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.sketches.iter().any(|s| s.feature == feature) {
|
||||||
|
self.sketch_keys.insert(feature, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluation results by cache key, shared by a model and its copies (previews, scratch
|
||||||
|
/// sessions), so a result computed once is reused wherever the same inputs come back.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct EvalCache {
|
||||||
|
map: std::collections::HashMap<u64, CachedEval>,
|
||||||
|
order: std::collections::VecDeque<u64>,
|
||||||
|
pub hits: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct CachedEval {
|
||||||
|
error: Option<String>,
|
||||||
|
warning: Option<String>,
|
||||||
|
ms: f64,
|
||||||
|
state: Arc<ModelState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Results kept (oldest dropped first).
|
||||||
|
const CACHE_ENTRIES: usize = 4096;
|
||||||
|
|
||||||
|
impl EvalCache {
|
||||||
|
fn get(&mut self, key: u64) -> Option<CachedEval> {
|
||||||
|
let r = self.map.get(&key).cloned();
|
||||||
|
if r.is_some() {
|
||||||
|
self.hits += 1;
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
fn put(&mut self, key: u64, e: CachedEval) {
|
||||||
|
if self.map.insert(key, e).is_none() {
|
||||||
|
self.order.push_back(key);
|
||||||
|
}
|
||||||
|
while self.order.len() > CACHE_ENTRIES {
|
||||||
|
if let Some(k) = self.order.pop_front() {
|
||||||
|
self.map.remove(&k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModelState {
|
impl ModelState {
|
||||||
@@ -221,6 +303,10 @@ pub struct Model {
|
|||||||
empty: Arc<ModelState>,
|
empty: Arc<ModelState>,
|
||||||
/// Features recomputed by the last `evaluate`.
|
/// Features recomputed by the last `evaluate`.
|
||||||
pub last_recomputed: usize,
|
pub last_recomputed: usize,
|
||||||
|
/// Results by inputs, shared with this model's copies.
|
||||||
|
pub cache: Arc<std::sync::Mutex<EvalCache>>,
|
||||||
|
/// Evaluate everything from scratch (no reuse; tests compare the two).
|
||||||
|
pub no_cache: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters a feature uses directly (inputs and sketch dimensions), sorted.
|
/// Parameters a feature uses directly (inputs and sketch dimensions), sorted.
|
||||||
@@ -253,6 +339,56 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
|
|||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Document-level inputs any feature may read: construction planes (resolved by name), sheet
|
||||||
|
/// metal and plastic rules (and the parameters they use), components and body placement.
|
||||||
|
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
for f in doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) {
|
||||||
|
fingerprint(0, f, vals, false).hash(&mut h);
|
||||||
|
}
|
||||||
|
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
|
||||||
|
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
|
||||||
|
serde_json::to_string(&doc.components).unwrap_or_default().hash(&mut h);
|
||||||
|
serde_json::to_string(&doc.body_components).unwrap_or_default().hash(&mut h);
|
||||||
|
for e in doc
|
||||||
|
.sheet
|
||||||
|
.rules
|
||||||
|
.iter()
|
||||||
|
.flat_map(|r| [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap])
|
||||||
|
{
|
||||||
|
for n in expr::references(e) {
|
||||||
|
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for r in &doc.plastic.rules {
|
||||||
|
for (_, e, _) in r.fields() {
|
||||||
|
for n in expr::references(e) {
|
||||||
|
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache key of a feature evaluation: its own inputs, the document context and the state it
|
||||||
|
/// starts from. Features that replay other features' definitions (patterns and mirrors of
|
||||||
|
/// features) also take the chain fingerprint, which covers every earlier definition.
|
||||||
|
fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelState) -> u64 {
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
local.hash(&mut h);
|
||||||
|
ctx.hash(&mut h);
|
||||||
|
state.digest().hash(&mut h);
|
||||||
|
f.id.hash(&mut h);
|
||||||
|
let replays = match &f.kind {
|
||||||
|
FeatureKind::Pattern { features, .. } | FeatureKind::Mirror { features, .. } => !features.is_empty(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if replays {
|
||||||
|
chain_fp.hash(&mut h);
|
||||||
|
}
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
fn now() -> web_time::Instant {
|
fn now() -> web_time::Instant {
|
||||||
web_time::Instant::now()
|
web_time::Instant::now()
|
||||||
}
|
}
|
||||||
@@ -284,9 +420,10 @@ impl Model {
|
|||||||
let mut prev_fp = 0u64;
|
let mut prev_fp = 0u64;
|
||||||
let mut state = self.empty.clone();
|
let mut state = self.empty.clone();
|
||||||
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
|
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
|
||||||
let mut reuse = true;
|
let mut reuse = !self.no_cache;
|
||||||
let mut recomputed = 0;
|
let mut recomputed = 0;
|
||||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||||
|
let ctx = context_digest(doc, &vals);
|
||||||
for (i, f) in doc.features.iter().enumerate() {
|
for (i, f) in doc.features.iter().enumerate() {
|
||||||
let rolled_back = i >= marker;
|
let rolled_back = i >= marker;
|
||||||
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back);
|
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back);
|
||||||
@@ -344,6 +481,26 @@ impl Model {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The same feature on the same inputs: its earlier result.
|
||||||
|
let key = cache_key(f, fp, fingerprint(0, f, &vals, false), ctx, &state);
|
||||||
|
if !self.no_cache
|
||||||
|
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
|
||||||
|
{
|
||||||
|
if c.error.is_none() {
|
||||||
|
state = c.state.clone();
|
||||||
|
}
|
||||||
|
out.push(FeatureResult {
|
||||||
|
id: f.id,
|
||||||
|
name: f.name.clone(),
|
||||||
|
error: c.error,
|
||||||
|
warning: c.warning,
|
||||||
|
ms: c.ms,
|
||||||
|
skipped: false,
|
||||||
|
fingerprint: fp,
|
||||||
|
state: state.clone(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
recomputed += 1;
|
recomputed += 1;
|
||||||
let t0 = now();
|
let t0 = now();
|
||||||
let mut next = (*state).clone();
|
let mut next = (*state).clone();
|
||||||
@@ -359,11 +516,17 @@ impl Model {
|
|||||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||||
let error = match r {
|
let error = match r {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
next.stamp(&state, f.id, key);
|
||||||
state = Arc::new(next);
|
state = Arc::new(next);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
|
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
|
||||||
};
|
};
|
||||||
|
if !self.no_cache
|
||||||
|
&& let Ok(mut m) = self.cache.lock()
|
||||||
|
{
|
||||||
|
m.put(key, CachedEval { error: error.clone(), warning: warning.clone(), ms, state: state.clone() });
|
||||||
|
}
|
||||||
out.push(FeatureResult { id: f.id, name: f.name.clone(), error, warning, ms, skipped: false, fingerprint: fp, state: state.clone() });
|
out.push(FeatureResult { id: f.id, name: f.name.clone(), error, warning, ms, skipped: false, fingerprint: fp, state: state.clone() });
|
||||||
}
|
}
|
||||||
self.results = out;
|
self.results = out;
|
||||||
|
|||||||
@@ -83,9 +83,8 @@ pub fn read(s: &str) -> Result<Document> {
|
|||||||
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
|
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if version < 2 {
|
// Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
|
||||||
v1_to_v2(&mut d)?;
|
normalise(&mut d)?;
|
||||||
}
|
|
||||||
d.format = format_string(FORMAT);
|
d.format = format_string(FORMAT);
|
||||||
Ok(d)
|
Ok(d)
|
||||||
}
|
}
|
||||||
@@ -134,8 +133,8 @@ fn rigid(m: &crate::Mat) -> bool {
|
|||||||
&& t[..3].iter().all(|v| v.abs() <= MAX_REAL)
|
&& t[..3].iter().all(|v| v.abs() <= MAX_REAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format 1 files may predate feature input names and occurrences.
|
/// Feature input names and component occurrences, which format 1 files may predate.
|
||||||
fn v1_to_v2(d: &mut Document) -> Result<()> {
|
fn normalise(d: &mut Document) -> Result<()> {
|
||||||
let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect();
|
let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect();
|
||||||
for id in ids {
|
for id in ids {
|
||||||
d.name_feature_inputs(id);
|
d.name_feature_inputs(id);
|
||||||
|
|||||||
@@ -1074,3 +1074,73 @@ fn appearances_by_face_body_and_component() {
|
|||||||
assert!(s.execute("AppearanceCommand", &json!({"color": "#ffffff"})).is_err(), "no target");
|
assert!(s.execute("AppearanceCommand", &json!({"color": "#ffffff"})).is_err(), "no target");
|
||||||
assert!(s.execute("AppearanceCommand", &json!({"faces": [[500, 500, 500]], "color": "#ffffff"})).is_err(), "no face there");
|
assert!(s.execute("AppearanceCommand", &json!({"faces": [[500, 500, 500]], "color": "#ffffff"})).is_err(), "no face there");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The evaluation cache gives exactly what evaluating from scratch gives, through edits,
|
||||||
|
/// suppression, roll-back, undo and redo.
|
||||||
|
#[test]
|
||||||
|
fn cached_evaluation_matches_from_scratch() {
|
||||||
|
let mut s = Session::default();
|
||||||
|
s.run_script(&crate::sample::script()).unwrap();
|
||||||
|
run(
|
||||||
|
&mut s,
|
||||||
|
"PatternRectangular",
|
||||||
|
json!({"bodies": ["Plate"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 100, "dir2": [0, 1, 0], "count2": 2, "spacing2": 70}),
|
||||||
|
);
|
||||||
|
run(&mut s, "PrimitiveBox", json!({"corner": [-50, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Side"}));
|
||||||
|
let same = |s: &Session, what: &str| {
|
||||||
|
let mut fresh = solvecraft_doc::Model::new();
|
||||||
|
fresh.no_cache = true;
|
||||||
|
fresh.evaluate(&s.doc);
|
||||||
|
let errs = |m: &solvecraft_doc::Model| m.results.iter().map(|r| (r.id, r.error.clone(), r.skipped)).collect::<Vec<_>>();
|
||||||
|
assert_eq!(errs(&s.model), errs(&fresh), "{what}: feature results");
|
||||||
|
let shape = |st: &solvecraft_doc::ModelState| {
|
||||||
|
st.bodies
|
||||||
|
.iter()
|
||||||
|
.map(|b| {
|
||||||
|
let m = solvecraft_kernel::measure(&b.body).unwrap();
|
||||||
|
// Volume and area to 1e-9: two evaluations from scratch differ in the last
|
||||||
|
// bits too (the kernel's tessellation is not bit-for-bit repeatable).
|
||||||
|
(b.name.clone(), b.feature, (m.volume * 1e6).round(), (m.area * 1e6).round(), m.faces, m.edges, m.vertices, m.merged.faces)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(shape(&s.model.state()), shape(&fresh.state()), "{what}: bodies");
|
||||||
|
assert_eq!(s.model.state().sketches.len(), fresh.state().sketches.len(), "{what}: sketches");
|
||||||
|
};
|
||||||
|
// Two evaluations from scratch agree to the same precision.
|
||||||
|
let mut a = solvecraft_doc::Model::new();
|
||||||
|
a.no_cache = true;
|
||||||
|
a.evaluate(&s.doc);
|
||||||
|
let mut b = solvecraft_doc::Model::new();
|
||||||
|
b.no_cache = true;
|
||||||
|
b.evaluate(&s.doc);
|
||||||
|
let vols = |m: &solvecraft_doc::Model| {
|
||||||
|
m.state().bodies.iter().map(|x| (solvecraft_kernel::measure(&x.body).unwrap().volume * 1e6).round()).collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(vols(&a), vols(&b));
|
||||||
|
same(&s, "built");
|
||||||
|
let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0);
|
||||||
|
for (step, (id, p)) in [
|
||||||
|
("ChangeParameterCommand", json!({"name": "width", "expression": "90 mm"})),
|
||||||
|
("ChangeParameterCommand", json!({"name": "width", "expression": "80 mm"})),
|
||||||
|
("ChangeParameterCommand", json!({"name": "thickness", "expression": "10 mm"})),
|
||||||
|
("UndoCommand", json!({})),
|
||||||
|
("RedoCommand", json!({})),
|
||||||
|
("timeline.suppress", json!({"feature": "Holes", "suppressed": true})),
|
||||||
|
("timeline.suppress", json!({"feature": "Holes", "suppressed": false})),
|
||||||
|
("timeline.rollTo", json!({"position": 6})),
|
||||||
|
("timeline.rollTo", json!({})),
|
||||||
|
("UndoCommand", json!({})),
|
||||||
|
("UndoCommand", json!({})),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let before = hits(&s);
|
||||||
|
run(&mut s, id, p.clone());
|
||||||
|
same(&s, &format!("step {step}: {id} {p}"));
|
||||||
|
if step == 1 {
|
||||||
|
assert!(hits(&s) > before, "setting the width back reuses the earlier results");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,6 +250,17 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Body {
|
impl Body {
|
||||||
|
/// The very same body (shared data), not just an equal-looking one.
|
||||||
|
pub fn same(&self, other: &Body) -> bool {
|
||||||
|
std::sync::Arc::ptr_eq(&self.solid, &other.solid)
|
||||||
|
&& match (&self.mesh, &other.mesh) {
|
||||||
|
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
&& self.color == other.color
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn new(solid: Solid) -> Result<Body> {
|
pub(crate) fn new(solid: Solid) -> Result<Body> {
|
||||||
if solid.boundaries().is_empty() {
|
if solid.boundaries().is_empty() {
|
||||||
return Err(KernelError::Failed("empty result".into()));
|
return Err(KernelError::Failed("empty result".into()));
|
||||||
|
|||||||
@@ -38,3 +38,46 @@ What changed: rows scrolled out of view only reserve their height. They register
|
|||||||
and paint nothing (no text layout, icons or eye/fold buttons), so the cost follows the rows on
|
and paint nothing (no text layout, icons or eye/fold buttons), so the cost follows the rows on
|
||||||
screen rather than the rows in the tree. Scrolling, Find in Browser and drag and drop still see
|
screen rather than the rows in the tree. Scrolling, Find in Browser and drag and drop still see
|
||||||
every row's place.
|
every row's place.
|
||||||
|
|
||||||
|
# Recompute performance
|
||||||
|
|
||||||
|
`solvecraft-cli bench-edit <script> <param> "(<old>) * 1.02"` builds a design from its script
|
||||||
|
(timing the build), then times a parameter edit, setting the parameter back to its old
|
||||||
|
expression, undo and redo. Each cell is before → after the evaluation cache, with the number of
|
||||||
|
features recomputed in parentheses. Release build, on a machine shared with other build jobs (load average about 45; the
|
||||||
|
times vary by ±50 % run to run, so compare the recompute counts and the zeros). "Early" edits a parameter of the
|
||||||
|
first sketch or feature, "late" one of the last feature.
|
||||||
|
|
||||||
|
| Design | Edit | Build | Edit | Set back | Undo | Redo |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| sample plate, 20 × 20 pattern | early (`width`) | 7587 ms (10) → 1764 ms (10) | 7969 ms (10) → 1691 ms (10) | 7557 ms (10) → 0 ms (0) | 6534 ms (0) → 0 ms (0) | 4570 ms (0) → 0 ms (0) |
|
||||||
|
| sample plate, 20 × 20 pattern | late (`d9`) | 7870 ms (10) → 1699 ms (10) | 3944 ms (1) → 812 ms (1) | 4039 ms (1) → 0 ms (0) | 1464 ms (0) → 0 ms (0) | 1398 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 58 phone stand | early (`d1`) | 163 ms (6) → 140 ms (6) | 228 ms (5) → 140 ms (5) | 171 ms (5) → 0 ms (0) | 158 ms (0) → 0 ms (0) | 130 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 58 phone stand | late (`d4`) | 144 ms (6) → 195 ms (6) | 118 ms (1) → 118 ms (1) | 145 ms (1) → 0 ms (0) | 149 ms (0) → 0 ms (0) | 187 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 59 enclosure base | late (`d8`) | 11582 ms (11) → 11179 ms (11) | 5772 ms (1) → 5742 ms (1) | 5606 ms (1) → 0 ms (0) | 6023 ms (0) → 0 ms (0) | 6119 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 60 pipe flange | early (`d1`) | 8137 ms (7) → 8348 ms (7) | 7569 ms (6) → 10143 ms (6) | 8354 ms (6) → 0 ms (0) | 8607 ms (0) → 0 ms (0) | 10034 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 60 pipe flange | late (`d6`) | 7661 ms (7) → 11990 ms (7) | 67 ms (1) → 361 ms (1) | 69 ms (1) → 0 ms (0) | 84 ms (0) → 0 ms (0) | 66 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 61 shaft keyway | early (`d1`) | 1723 ms (6) → 3016 ms (6) | 1785 ms (5) → 2745 ms (5) | 1760 ms (5) → 2323 ms (5) | 1667 ms (0) → 0 ms (0) | 1719 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 61 shaft keyway | late (`d4`) | 1905 ms (6) → 2247 ms (6) | 1734 ms (1) → 2101 ms (1) | 1682 ms (1) → 0 ms (0) | 1719 ms (0) → 0 ms (0) | 1876 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 62 heat sink | early (`d1`) | 382 ms (6) → 643 ms (6) | 362 ms (5) → 697 ms (5) | 233 ms (5) → 568 ms (5) | 245 ms (0) → 0 ms (0) | 360 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 62 heat sink | late (`d5`) | 479 ms (6) → 688 ms (6) | 270 ms (1) → 657 ms (1) | 321 ms (1) → 0 ms (0) | 413 ms (0) → 0 ms (0) | 283 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 63 hinge knuckle | early (`d1`) | 1305 ms (8) → 1671 ms (8) | 1329 ms (7) → 2397 ms (7) | 1971 ms (7) → 0 ms (0) | 1705 ms (0) → 0 ms (0) | 1321 ms (0) → 0 ms (0) |
|
||||||
|
| oracle 63 hinge knuckle | late (`d4`) | 1430 ms (8) → 2072 ms (8) | 1387 ms (1) → 2073 ms (1) | 1480 ms (1) → 0 ms (0) | 1192 ms (0) → 0 ms (0) | 1035 ms (0) → 0 ms (0) |
|
||||||
|
|
||||||
|
What changed: every feature evaluation is cached by a key made of the feature's own inputs
|
||||||
|
(definition and parameter values), the document context it may read (construction planes,
|
||||||
|
sheet metal and plastic rules, components) and a digest of the model it starts from, in which
|
||||||
|
each body is identified by the evaluation that produced it (bodies a feature passes through
|
||||||
|
unchanged keep their identity). The same feature on the same inputs is never evaluated twice:
|
||||||
|
setting a value back, undo, redo, a preview followed by the same command, and the repeated
|
||||||
|
evaluations while a script builds the design all reuse earlier results. The cache is shared by a
|
||||||
|
model and its copies (previews, scratch sessions) and keeps the last 4096 results.
|
||||||
|
|
||||||
|
An edit still recomputes the edited feature and every later feature whose starting model
|
||||||
|
changed; in these parts every later feature works on the edited body, so early edits recompute
|
||||||
|
almost everything, as they must. `cached_evaluation_matches_from_scratch` checks that cached and
|
||||||
|
from-scratch evaluation give the same models (bodies, volumes and areas to 1e-6, topology,
|
||||||
|
errors) through edits, suppression, roll-back, undo and redo. Setting a sketch dimension back
|
||||||
|
can still recompute (oracle 61, 62): solving the sketch moves its points by rounding noise, so
|
||||||
|
its inputs are not exactly the old ones. Oracle 59's first parameter edit fails (a recipe
|
||||||
|
expression the bench's `* 1.02` can't scale) and is left out.
|
||||||
Reference in new issue
Block a user