Compare commits

...

3 Commits

Author SHA1 Message Date
Phanoob d0a7c74ce5 Merge remote-tracking branch 'upstream/main' into rename-no-rebuild 2026-10-19 15:20:10 +02:00
Phanoob bd81c4622e Renaming a body or a feature builds nothing again (#3044)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:00:23 +02:00
Phanoob 95731a804a claim: #3044
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:00:32 +02:00
3 changed files with 366 additions and 4 deletions
+150
View File
@@ -1406,6 +1406,156 @@ impl Model {
self.last_recomputed = recomputed;
self.last_restored = restored;
}
/// The model after body `old` was renamed `new`, `before` and `after` being the document
/// either side of the rename, without building anything again (#3044): a rename changes no
/// geometry, but it changes the features that name the body, and so every fingerprint from
/// the body's feature on, and `evaluate` would rebuild all of them. Here each result keeps its
/// geometry and takes the new name (its bodies, threads and sheets, and its sketches' links
/// where the rename changed them), and its fingerprint is worked out on `after`, so the next
/// `evaluate` reuses it.
///
/// `false`, with the model as it was, where a full evaluation might name something else
/// differently: a body whose name was made from `old` at evaluation (`old (2)` or `old'`, a
/// piece split off it) and isn't kept in its feature's names, a body already called `new`
/// at some step, a component's placed body, a message that names `old`, or results that
/// don't match the timeline. The caller then evaluates.
pub fn rename_body(&mut self, before: &Document, after: &Document, old: &str, new: &str) -> bool {
if self.no_cache || old == new || self.results.len() != after.features.len() || before.features.len() != after.features.len() {
return false;
}
let made_from_old = |name: &str| name.strip_prefix(old).is_some_and(|rest| rest.starts_with(" (") || rest.starts_with('\''));
let kept = |b: &ModelBody| after.feature(b.feature).is_some_and(|f| f.body_names.contains(&b.name));
let names_old = |m: &Option<String>| m.as_deref().is_some_and(|m| m.contains(old));
for (r, f) in self.results.iter().zip(&after.features) {
if r.id != f.id || r.stale || names_old(&r.error) || names_old(&r.warning) {
return false;
}
if r.state.bodies.iter().any(|b| b.name == new || b.source_name.is_some() || (made_from_old(&b.name) && !kept(b))) {
return false;
}
}
// The links the rename changed, by sketch feature and link id: their new source and names.
let mut links: std::collections::HashMap<(u64, &str), &solidcraft_sketch::Link> = std::collections::HashMap::new();
for (fb, fa) in before.features.iter().zip(&after.features) {
if let (FeatureKind::Sketch { sketch: sb, .. }, FeatureKind::Sketch { sketch: sa, .. }) = (&fb.kind, &fa.kind) {
for (lb, la) in sb.links.iter().zip(&sa.links) {
if lb.id == la.id && (lb.source != la.source || lb.face_name != la.face_name || lb.edge_name != la.edge_name) {
links.insert((fa.id, la.id.as_str()), la);
}
}
}
}
let rename = |st: &ModelState| -> Option<ModelState> {
let sketch_changed = |sk: &SolvedSketch| sk.sketch.links.iter().any(|l| links.contains_key(&(sk.feature, l.id.as_str())));
let touched = st.bodies.iter().any(|b| b.name == old)
|| st.threads.iter().any(|t| t.body == old)
|| st.sheets.iter().any(|sh| sh.body == old)
|| st.sketches.iter().any(|sk| sketch_changed(sk));
if !touched {
return None;
}
let mut st = st.clone();
for b in st.bodies.iter_mut().filter(|b| b.name == old) {
b.name = new.to_string();
}
for t in st.threads.iter_mut().filter(|t| t.body == old) {
t.body = new.to_string();
}
for sh in st.sheets.iter_mut().filter(|sh| sh.body == old) {
sh.body = new.to_string();
}
for sk in &mut st.sketches {
if sketch_changed(sk) {
let solved = Arc::make_mut(sk);
for l in &mut solved.sketch.links {
if let Some(la) = links.get(&(solved.feature, l.id.as_str())) {
l.source = la.source.clone();
l.face_name.clone_from(&la.face_name);
l.edge_name.clone_from(&la.edge_name);
}
}
}
}
Some(st)
};
self.renamed(after, rename);
true
}
/// The model after feature `id` was renamed `old` to `new` in `after`, without building
/// anything again (#3044), as [`Model::rename_body`]: the feature's result, its solved
/// sketch and its construction geometry take the new name. `false`, with the model as it
/// was, where an evaluation might name something else differently: a body, thread or sheet
/// whose name holds `old`, a message that names it, or results that don't match the timeline.
pub fn rename_feature(&mut self, after: &Document, id: u64, old: &str, new: &str) -> bool {
if self.no_cache || old == new || old.is_empty() || self.results.len() != after.features.len() {
return false;
}
let names_old = |m: &Option<String>| m.as_deref().is_some_and(|m| m.contains(old));
for (r, f) in self.results.iter().zip(&after.features) {
if r.id != f.id || r.stale || names_old(&r.error) || names_old(&r.warning) {
return false;
}
let st = &r.state;
if st.bodies.iter().any(|b| b.name.contains(old) || b.source_name.as_deref().is_some_and(|n| n.contains(old)))
|| st.threads.iter().any(|t| t.body.contains(old))
|| st.sheets.iter().any(|sh| sh.body.contains(old))
{
return false;
}
}
let rename = |st: &ModelState| -> Option<ModelState> {
let touched =
st.sketches.iter().any(|sk| sk.feature == id && sk.name == old) || st.construct.iter().any(|c| c.feature == id && c.name == old);
if !touched {
return None;
}
let mut st = st.clone();
for sk in st.sketches.iter_mut().filter(|sk| sk.feature == id && sk.name == old) {
new.clone_into(&mut Arc::make_mut(sk).name);
}
for c in st.construct.iter_mut().filter(|c| c.feature == id && c.name == old) {
new.clone_into(&mut c.name);
}
Some(st)
};
self.renamed(after, rename);
for r in self.results.iter_mut().filter(|r| r.id == id) {
new.clone_into(&mut r.name);
}
true
}
/// Each result with its state as `rename` makes it (`None`: as it was) and its fingerprint
/// worked out on `after`, so the next `evaluate` reuses it; nothing is built.
fn renamed(&mut self, after: &Document, rename: impl Fn(&ModelState) -> Option<ModelState>) {
let (vals, _) = evaluation_values(after);
let steps = StepDigests::of(after, &self.step_digests);
let marker = after.marker.unwrap_or(usize::MAX);
// Results share their states (a feature that changes nothing passes its input on): each
// state is renamed once.
let mut done: std::collections::HashMap<*const ModelState, Arc<ModelState>> = std::collections::HashMap::new();
let mut prev_fp = 0u64;
let mut out = Vec::with_capacity(self.results.len());
for (i, (r, f)) in self.results.iter().zip(&after.features).enumerate() {
let fp = chain_fingerprint(after, &vals, f, prev_fp, i >= marker, &steps);
prev_fp = fp;
let state = match done.get(&Arc::as_ptr(&r.state)) {
Some(s) => s.clone(),
None => {
let s = rename(&r.state).map_or_else(|| r.state.clone(), Arc::new);
done.insert(Arc::as_ptr(&r.state), s.clone());
s
}
};
out.push(FeatureResult { fingerprint: fp, state, ..r.clone() });
}
self.step_digests = steps;
self.results = out;
self.last_recomputed = 0;
self.last_restored = 0;
}
}
/// Key of the design's length unit (mm per unit) among the values (no parameter can be named
+170
View File
@@ -1182,3 +1182,173 @@ mod tests {
assert_eq!(s.visibility.hidden_folders, vec!["o:4/sketches".to_string()]);
}
}
/// #3044: a rename builds nothing again. The model the rename leaves is the one a full
/// evaluation of the renamed design gives, whether it took the new name in place or, where it
/// can't tell, evaluated.
#[cfg(test)]
mod rename_tests {
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// What a later feature or the view reads from a state: its bodies (name, feature, volume),
/// threads, sheets and sketch link sources.
fn summary(st: &solidcraft_doc::ModelState) -> String {
let bodies: Vec<String> = st
.bodies
.iter()
.map(|b| format!("{} f{} {:.6}", b.name, b.feature, solidcraft_kernel::measure(&b.body).map(|m| m.volume).unwrap_or(f64::NAN)))
.collect();
let threads: Vec<&str> = st.threads.iter().map(|t| t.body.as_str()).collect();
let sheets: Vec<&str> = st.sheets.iter().map(|t| t.body.as_str()).collect();
let links: Vec<String> = st
.sketches
.iter()
.flat_map(|sk| sk.sketch.links.iter().map(move |l| format!("{}:{} {:?} {:?} {:?}", sk.feature, l.id, l.source, l.face_name, l.edge_name)))
.collect();
let sketches: Vec<String> = st.sketches.iter().map(|sk| format!("{} {}", sk.feature, sk.name)).collect();
let construct: Vec<String> = st.construct.iter().map(|c| format!("{} {}", c.feature, c.name)).collect();
format!("{bodies:?} {threads:?} {sheets:?} {links:?} {sketches:?} {construct:?}")
}
/// Rename body `old` to `new`; whether that built nothing again. The model then is the one a
/// full evaluation (no cache) of the renamed design gives, feature by feature.
fn rename(s: &mut Session, old: &str, new: &str) -> bool {
renamed_like_evaluated(s, "body.rename", json!({"body": old, "name": new}))
}
/// Rename feature `old` to `new`, as `rename` does a body.
fn rename_feature(s: &mut Session, old: &str, new: &str) -> bool {
renamed_like_evaluated(s, "timeline.rename", json!({"feature": old, "name": new}))
}
fn renamed_like_evaluated(s: &mut Session, id: &str, p: Value) -> bool {
let evaluations = s.evaluations;
run(s, id, p);
let in_place = s.evaluations == evaluations;
let mut fresh = Session::new((*s.doc).clone());
fresh.model.no_cache = true;
fresh.refresh();
assert_eq!(s.model.results.len(), fresh.model.results.len());
for (r, f) in s.model.results.iter().zip(&fresh.model.results) {
assert_eq!((&r.name, &r.error, &r.warning, r.skipped), (&f.name, &f.error, &f.warning, f.skipped), "feature {}", r.name);
assert_eq!(summary(&r.state), summary(&f.state), "after feature {}", r.name);
}
// And the next evaluation reuses every result.
s.refresh();
assert_eq!(s.model.last_recomputed, 0, "a rename leaves results the next evaluation rebuilds");
in_place
}
#[test]
fn renaming_the_sensor_enclosures_bodies_builds_nothing() {
let mut s = Session::default();
let script: Value = serde_json::from_str(include_str!("../../../../examples/acceptance/sensor_enclosure.json")).unwrap();
s.run_script(&script).unwrap();
assert!(rename(&mut s, "Case", "Shell"));
assert!(rename(&mut s, "Lid", "Cover"));
// Undo puts the old names back, as it puts back any edit.
run(&mut s, "edit.undo", json!({}));
assert!(s.model.state().body("Lid").is_some());
}
/// A body a later feature names, and a sketch on its face whose links name it; the copies a
/// pattern made of another body, renamed one by one.
#[test]
fn a_rename_follows_users_face_sketches_and_copies_without_building() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 30, 0], "operation": "new_body", "body_name": "Y"}));
run(&mut s, "solid.move", json!({"bodies": ["Y"], "translate": [0, 0, 5]}));
run(&mut s, "sketch.create", json!({"plane": {"face": [5, 35, 15]}}));
run(&mut s, "sketch.circle.center", json!({"center": [5, 35], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": -3, "operation": "cut", "profiles": [{"point": [5, 35]}]}));
assert!(rename(&mut s, "Y", "Stand"));
let st = s.model.state();
let named = st.sketches.iter().flat_map(|sk| &sk.sketch.links).filter(|l| format!("{:?}", l.source).contains("\"Stand\"")).count();
assert!(named > 0, "the face sketch's links name the renamed body");
assert!(rename(&mut s, "X (3)", "Last"));
assert!(rename(&mut s, "X (2)", "Middle"));
}
/// The copies a pattern makes are named after their body when the pattern is evaluated:
/// renaming the body renames them, so it evaluates, and gives what an evaluation gives.
#[test]
fn a_patterned_body_is_renamed_by_evaluating() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
assert!(!rename(&mut s, "X", "Base"));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Base", "Base (2)", "Base (3)"]);
}
/// Features renamed: a sketch later features use, and a construction plane a sketch is on (by
/// name). Their results, solved sketches and planes take the new name; nothing is built.
#[test]
fn renaming_features_builds_nothing() {
let mut s = Session::default();
let script: Value = serde_json::from_str(include_str!("../../../../examples/acceptance/sensor_enclosure.json")).unwrap();
s.run_script(&script).unwrap();
let sketch = s.doc.features.iter().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().name.clone();
assert!(rename_feature(&mut s, &sketch, "Outline"));
let mut s = Session::default();
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Shelf"}));
run(&mut s, "sketch.create", json!({"plane": "Shelf"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 4}));
assert!(rename_feature(&mut s, "Shelf", "Ledge"));
}
/// Folders and components renamed: what they name isn't geometry, and the evaluation that
/// follows reuses every result.
#[test]
fn renaming_folders_and_components_builds_nothing() {
let mut s = Session::default();
let part = run(&mut s, "component.create", json!({"name": "Part"}))["component"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "First", "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "name": "Second", "body_name": "B"}));
let folder = run(&mut s, "browser.group", json!({"folder": "bodies", "items": ["A"]}))["group"].as_u64().unwrap();
let features = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
for (id, p) in [
("browser.rename_group", json!({"group": folder, "name": "Fasteners"})),
("timeline.rename_group", json!({"group": features, "name": "Both"})),
("component.rename", json!({"component": part, "name": "Bracket"})),
] {
run(&mut s, id, p);
assert_eq!(s.model.last_recomputed, 0, "{id} built something again");
}
}
/// A body named like a feature: an evaluation may name it after the feature, so renaming the
/// feature evaluates, and gives what an evaluation gives.
#[test]
fn a_feature_a_body_is_named_like_is_renamed_by_evaluating() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Block", "body_name": "Block"}));
assert!(!rename_feature(&mut s, "Block", "Brick"));
}
/// A body split later: the pieces are named after it when the split is evaluated, so the
/// rename evaluates, and gives what an evaluation gives.
#[test]
fn a_body_split_later_is_renamed_by_evaluating() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "operation": "new_body", "body_name": "Block"}));
run(&mut s, "solid.split_body", json!({"body": "Block", "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert!(names.iter().any(|n| n.starts_with("Block (")), "{names:?}");
assert!(!rename(&mut s, "Block", "Beam"));
}
}
+46 -4
View File
@@ -344,6 +344,26 @@ impl Default for Session {
}
}
/// What a rename command renamed (from its result and the document before it), so the model
/// can take the new name without building anything again (#3044).
enum Renamed {
Body { old: String, new: String },
Feature { id: u64, old: String, new: String },
}
impl Renamed {
fn of(command: &str, result: &Value, before: &Document) -> Option<Renamed> {
let new = result.get("name")?.as_str()?.to_string();
match command {
"body.rename" => Some(Renamed::Body { old: result.get("body")?.as_str()?.to_string(), new }),
"timeline.rename" => {
let id = result.get("feature")?.as_u64()?;
Some(Renamed::Feature { id, old: before.feature(id)?.name.clone(), new })
}
_ => None,
}
}
}
impl Session {
pub fn new(mut doc: Document) -> Self {
// The session keeps what is hidden (not an undo step); the design file carries it.
@@ -576,6 +596,26 @@ impl Session {
{
self.evaluations += 1;
}
self.after_evaluation();
}
/// A body or feature renamed (`previous` the document before): the model takes the new
/// name without building anything again, or is evaluated where that might not give what an
/// evaluation gives (see `Model::rename_body` and `Model::rename_feature`, #3044).
fn refresh_renamed(&mut self, previous: &Arc<Document>, renamed: &Renamed) {
let in_place = match renamed {
Renamed::Body { old, new } => self.model.rename_body(previous, &self.doc, old, new),
Renamed::Feature { id, old, new } => self.model.rename_feature(&self.doc, *id, old, new),
};
if in_place {
self.after_evaluation();
} else {
self.refresh();
}
}
/// What follows a new model: a new revision, and what depends on the model brought up to it.
fn after_evaluation(&mut self) {
self.revision += 1;
self.prune_occurrence_state();
// Joints follow the geometry their origins snap to.
@@ -718,6 +758,7 @@ impl Session {
let changed = !Arc::ptr_eq(&self.doc, &before.doc);
match r {
Ok((v, live_edit)) => {
let renamed = Renamed::of(spec.id, &v, &before.doc);
let v = if changed { self.with_created(spec.id, params, &before, v) } else { v };
if changed {
let render_only = self.render_only_change(&before);
@@ -729,10 +770,11 @@ impl Session {
}
self.redo.clear();
}
match live_edit {
Some(edit) => self.refresh_live(edit),
None if render_only => self.refresh_render(&previous_doc),
None => self.refresh(),
match (live_edit, renamed) {
(Some(edit), _) => self.refresh_live(edit),
(None, _) if render_only => self.refresh_render(&previous_doc),
(None, Some(renamed)) => self.refresh_renamed(&previous_doc, &renamed),
(None, None) => self.refresh(),
}
}
Ok(v)