Timeline editing: double-click or Edit Feature reopens the feature's dialog filled in (model rolled back to it, references highlighted) and rebuilds it in place; history marker drag; drag to reorder with dependency checks; context menu (edit, edit profile sketch, rename, suppress, roll marker here, find in browser, delete with dependents); hover links timeline and geometry; fillet edges and hole positions re-resolve after upstream edits (warnings when moved)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
97e73b0935
commit
9dfd7cad60
10 files changed
+876
-47
No files matched your search
@@ -22,6 +22,15 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("features: [id or name] (a sketch takes its dependent features with it)"),
|
||||
CommandSpec::new("FusionRenameTimelineEntryCommand", "Rename", rename).params("feature: id|name, name"),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.rollTo", "Roll History Marker", rollback)
|
||||
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
|
||||
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine)
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents)
|
||||
.noundo()
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
|
||||
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
|
||||
@@ -141,7 +150,13 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.doc.features.len();
|
||||
let pos = num(p, "position").map(|x| x.max(0.0) as usize);
|
||||
let pos = match p.get("feature") {
|
||||
Some(f) => {
|
||||
let id = feature_id(s, Some(f), "timeline.rollTo")?;
|
||||
s.doc.feature_index(id).map(|i| i + 1)
|
||||
}
|
||||
None => num(p, "position").map(|x| x.max(0.0) as usize),
|
||||
};
|
||||
s.doc_mut().marker = match pos {
|
||||
Some(k) if k < n => Some(k),
|
||||
_ => None,
|
||||
@@ -231,3 +246,90 @@ fn select_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.revision += 1;
|
||||
Ok(json!({"selected": 0}))
|
||||
}
|
||||
|
||||
/// Features that worked in `before` and fail in `after`: (name, error).
|
||||
fn newly_broken(before: &solvecraft_doc::Model, after: &solvecraft_doc::Model) -> Vec<(String, String)> {
|
||||
after
|
||||
.results
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let e = r.error.clone()?;
|
||||
let was_ok = before.result(r.id).is_some_and(|b| b.error.is_none() && !b.skipped);
|
||||
was_ok.then(|| (r.name.clone(), e))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.reorder";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let n = s.doc.features.len();
|
||||
let to = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?.min(n.saturating_sub(1));
|
||||
let from = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
if from == to {
|
||||
return Ok(json!({"feature": id, "position": to}));
|
||||
}
|
||||
let mut next = (*s.doc).clone();
|
||||
let f = next.features.remove(from);
|
||||
let name = f.name.clone();
|
||||
next.features.insert(to, f);
|
||||
// Everything that evaluated before must still evaluate.
|
||||
let mut after = solvecraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
let broken = newly_broken(&s.model, &after);
|
||||
if !broken.is_empty() {
|
||||
let list: Vec<String> = broken.iter().map(|(n, e)| format!("{n} ({e})")).collect();
|
||||
return Err(bad(cmd, format!("moving {name} there breaks {}", list.join("; "))));
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"feature": id, "position": to}))
|
||||
}
|
||||
|
||||
fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.redefine";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
|
||||
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
|
||||
if cid.starts_with("timeline.") || cid.starts_with("select.") || cid.starts_with("Sketch") {
|
||||
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
|
||||
}
|
||||
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let marker = s.doc.marker;
|
||||
// Build the new definition where the feature sits, against the model before it.
|
||||
s.doc_mut().marker = Some(idx);
|
||||
s.refresh();
|
||||
let r = (spec.run)(s, ¶ms);
|
||||
let made = r.as_ref().ok().and_then(|v| v.get("feature")).and_then(Value::as_u64);
|
||||
let new = made.and_then(|nid| s.doc.feature(nid).cloned());
|
||||
if let Some(nid) = made {
|
||||
s.doc_mut().features.retain(|f| f.id != nid);
|
||||
}
|
||||
s.doc_mut().marker = marker;
|
||||
r?;
|
||||
let new = new.ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
|
||||
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
|
||||
}
|
||||
if let Some(slot) = s.doc_mut().feature_mut(id) {
|
||||
slot.kind = new.kind;
|
||||
}
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
||||
}
|
||||
|
||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.dependents";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let mut next = (*s.doc).clone();
|
||||
let gone = next.delete_feature(id)?;
|
||||
let mut after = solvecraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
let names = |ids: &[u64]| -> Vec<String> { ids.iter().filter(|x| **x != id).filter_map(|x| s.doc.feature(*x).map(|f| f.name.clone())).collect() };
|
||||
let broken: Vec<String> = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect();
|
||||
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
|
||||
}
|
||||
@@ -336,3 +336,48 @@ fn sample_edges_chain_and_faces() {
|
||||
// Every edge bounds a face, and the top face has the outline and the hole rims.
|
||||
assert!(m.edge_faces.iter().all(|f| !f.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_edits_reresolve_references() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
|
||||
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[20, 0, 20]], "radius": 3, "name": "Round"}));
|
||||
run(&mut s, "FusionHoleCommand", json!({"position": [20, 15, 20], "diameter": 6, "name": "Bore"}));
|
||||
let fillet = |l: f64| (9.0 - 9.0 * PI / 4.0) * l;
|
||||
let expect = |l: f64, h: f64| l * 30.0 * h - fillet(l) - PI * 9.0 * h;
|
||||
assert!(rel(volume(&mut s), expect(40.0, 20.0)) < 1e-3);
|
||||
// Taller box: the fillet's edge point is now 5 mm below the edge (re-found, with a warning)
|
||||
// and the hole moves up with the top face.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "25"}}));
|
||||
assert!(rel(volume(&mut s), expect(40.0, 25.0)) < 1e-3, "{}", volume(&mut s));
|
||||
let round = s.doc.find_feature("Round").map(|f| f.id).unwrap();
|
||||
assert!(s.model.result(round).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("re-found")));
|
||||
// Longer box: the point still lies on the (longer) edge.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60"}}));
|
||||
assert!(rel(volume(&mut s), expect(60.0, 25.0)) < 1e-3, "{}", volume(&mut s));
|
||||
// Reorder: the fillet can't go before the box; the hole can go before the fillet.
|
||||
let e = s.execute("timeline.reorder", &json!({"feature": "Round", "position": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("breaks"), "{e}");
|
||||
run(&mut s, "timeline.reorder", json!({"feature": "Bore", "position": 1}));
|
||||
let names: Vec<&str> = s.doc.features.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["Base", "Bore", "Round"]);
|
||||
assert!(rel(volume(&mut s), expect(60.0, 25.0)) < 1e-3);
|
||||
// Redefine the fillet in place with a new radius; it keeps its name and position.
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Round", "command": "FusionFilletEdgesCommand", "params": {"edges": [[30, 0, 25]], "radius": 4}}),
|
||||
);
|
||||
let f4 = (16.0 - 16.0 * PI / 4.0) * 60.0;
|
||||
assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0 - f4 - PI * 9.0 * 25.0) < 1e-3);
|
||||
assert_eq!(s.doc.features.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Base", "Bore", "Round"]);
|
||||
// Dependents and roll back.
|
||||
let d = run(&mut s, "timeline.dependents", json!({"feature": "Base"}));
|
||||
assert_eq!(d["would_fail"].as_array().map(|a| a.len()), Some(2), "{d}");
|
||||
run(&mut s, "timeline.rollTo", json!({"feature": "Base"}));
|
||||
assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0) < 1e-6);
|
||||
run(&mut s, "timeline.rollTo", json!({}));
|
||||
run(&mut s, "UndoCommand", json!({}));
|
||||
run(&mut s, "UndoCommand", json!({}));
|
||||
assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0 - f4 - PI * 9.0 * 25.0) < 1e-3);
|
||||
}
|
||||
Reference in new issue
Block a user