Live operation previews: open feature dialogs (new or Edit Feature) evaluate on a scratch session off the UI thread (Session::preview, no document or undo changes) and draw the result in place: new surface tinted, removed material as a translucent ghost; errors show in the dialog and keep the last good preview; a failing join/cut still shows its tool body

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 21:09:16 +00:00
1 parent b8d0fcc8e2
commit d44083d1cc
8 files changed
+548 -23

No files matched your search

+57 -1
View File
@@ -17,7 +17,7 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solvecraft_doc::{Document, Model};
use solvecraft_doc::{Document, Model, ModelState};
use solvecraft_geom::Vec3;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
@@ -122,6 +122,16 @@ pub struct Snapshot {
pub active_sketch: Option<u64>,
}
/// What some commands would make, evaluated on a scratch copy of a session
/// ([`Session::preview`]). The session itself is not changed.
#[derive(Clone, Debug)]
pub struct Preview {
/// The model now.
pub before: Arc<ModelState>,
/// The model after the commands.
pub after: Arc<ModelState>,
}
/// An editing session on one design.
pub struct Session {
pub doc: Arc<Document>,
@@ -240,6 +250,52 @@ impl Session {
}
}
/// A scratch copy of the session (same design and model, no history or log) to try
/// commands on without touching this one.
pub fn scratch(&self) -> Session {
Session {
doc: self.doc.clone(),
model: self.model.clone(),
undo: Vec::new(),
redo: Vec::new(),
active_sketch: self.active_sketch,
selection: Vec::new(),
path: None,
saved: self.saved.clone(),
log: Vec::new(),
revision: self.revision,
}
}
/// Live preview: run `commands` (id, parameters) on a scratch copy and return the model
/// before and after. Nothing in this session changes (document, undo history, selection).
/// A `timeline.redefine` is shown even while the timeline is rolled back to the feature
/// being edited.
pub fn preview(&self, commands: &[(String, Value)]) -> Result<Preview> {
self.scratch().preview_in_place(commands).map(|after| Preview { before: self.model.state(), after })
}
fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result<Arc<ModelState>> {
if commands.is_empty() {
return Err(EngineError::Other("nothing to preview".into()));
}
for (id, params) in commands {
let r = self.execute(id, params)?;
if id == "timeline.redefine"
&& let Some(fid) = r.get("feature").and_then(Value::as_u64)
&& let Some(idx) = self.doc.feature_index(fid)
&& self.doc.marker.is_some_and(|m| m <= idx)
{
self.doc_mut().marker = Some(idx + 1);
self.refresh();
if let Some(e) = self.model.result(fid).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
}
}
Ok(self.model.state())
}
/// Run a script: a JSON array of `{"command": id, "params": {...}}` (or an object with a
/// `commands` array). Stops at the first error. Returns each command's result.
pub fn run_script(&mut self, script: &Value) -> Result<Vec<Value>> {
+52
View File
@@ -473,3 +473,55 @@ fn step_insert_shell_and_errors() {
assert!(d.execute("FusionImportCommandFromToolbar", &json!({"path": p.with_extension("stl").to_string_lossy()})).is_err());
assert_eq!(*d.doc, *before);
}
fn body_volume(st: &solvecraft_doc::ModelState) -> f64 {
st.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum()
}
/// Previews evaluate on a scratch copy: the document, undo history and model are untouched.
#[test]
fn preview_has_no_side_effects() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "SketchStop", json!({}));
let doc = s.doc.clone();
let (undo, rev) = (s.undo.len(), s.revision);
let p = s.preview(&[("Extrude".into(), json!({"distance": 15}))]).unwrap();
assert!(p.before.bodies.is_empty());
assert!(rel(body_volume(&p.after), 40.0 * 30.0 * 15.0) < 1e-9);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!((s.undo.len(), s.revision), (undo, rev));
assert!(s.model.state().bodies.is_empty());
// Errors come back as errors and change nothing either.
assert!(s.preview(&[("Extrude".into(), json!({"distance": "nonsense"}))]).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
// Fillet preview on a real body.
run(&mut s, "Extrude", json!({"distance": 20}));
let (doc, undo) = (s.doc.clone(), s.undo.len());
let p = s.preview(&[("FusionFilletEdgesCommand".into(), json!({"edges": [[0, 0, 10]], "radius": 3}))]).unwrap();
let filleted = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0;
assert!(rel(body_volume(&p.after), filleted) < 2e-4);
assert!(rel(body_volume(&p.before), 24000.0) < 1e-9);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(s.undo.len(), undo);
}
/// Previewing an edit shows the rebuilt feature although the timeline is rolled back to it.
#[test]
fn preview_of_an_edit() {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
run(&mut s, "SketchStop", json!({}));
let r = run(&mut s, "Extrude", json!({"distance": 20}));
let fid = r["feature"].as_u64().unwrap();
let idx = s.doc.feature_index(fid).unwrap();
run(&mut s, "timeline.rollTo", json!({"position": idx}));
assert!(s.model.state().bodies.is_empty());
let doc = s.doc.clone();
let p = s.preview(&[("timeline.redefine".into(), json!({"feature": fid, "command": "Extrude", "params": {"distance": 10}}))]).unwrap();
assert!(rel(body_volume(&p.after), 12000.0) < 1e-9);
assert!(Arc::ptr_eq(&doc, &s.doc));
}