Export a design's timeline as a replayable command script (#1045)
timeline.export_script (and solidcraft-cli script) writes a design's history as a command script that solidcraft-cli eval replays into the same design, so a bug that only shows on one design becomes a one-file repro. timeline.add restores one stored feature with its id, names and references, through the same checks a design file gets when it opens. Closes #886. SychPL's #1078 approached the same issue independently; thanks to both. Co-authored-by: SychPL <SychPL@users.noreply.github.com>
This commit is contained in:
1 parent
08b6a8fe0f
commit
ec8e739aad
17 files changed
+755
-6
No files matched your search
@@ -44,6 +44,7 @@ mod measure_sel;
|
||||
mod mesh;
|
||||
pub(crate) mod motion;
|
||||
mod parts;
|
||||
mod script;
|
||||
mod section;
|
||||
pub(crate) use measure_sel::measure_items;
|
||||
#[cfg(test)]
|
||||
@@ -51,6 +52,8 @@ mod kernel_heal_tests;
|
||||
mod parameters;
|
||||
mod plastic;
|
||||
mod render;
|
||||
#[cfg(test)]
|
||||
mod script_tests;
|
||||
mod sheet;
|
||||
mod sketch;
|
||||
pub(crate) use sketch::author_path_for_rebind;
|
||||
@@ -245,6 +248,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(delete::COMMANDS.iter());
|
||||
v.extend(edit::COMMANDS.iter());
|
||||
v.extend(clipboard::COMMANDS.iter());
|
||||
v.extend(script::COMMANDS.iter());
|
||||
v.extend(parameters::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! A design's history as a command script (#886): `timeline.export_script` writes the steps
|
||||
//! that rebuild the design (`solidcraft-cli eval` / `run` replay them), and `timeline.add`
|
||||
//! appends one recorded feature, as the design file holds it, so every feature kind replays
|
||||
//! exactly: same ids, names, parameter names and stored edge and face names. Trimming the
|
||||
//! script after a step gives the design as it was at that step.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::{Document, Feature, FeatureKind, Param, expr};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.add", "Add Recorded Feature", add).params(
|
||||
"feature: a feature as the design file and timeline.inspect hold it ({type, name?, id?, …}; id and name are kept when free, \
|
||||
so later steps can refer to them); params?: [{name, expression, unit?, comment?}] (the model parameters its sketch \
|
||||
dimensions use); the feature goes at the marker → feature, name, error, warning",
|
||||
),
|
||||
CommandSpec::new("timeline.export_script", "Export Command Script", export_script).noundo().doc_only().params(
|
||||
"path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \
|
||||
steps, omitted: what the script leaves out (appearances, folders, views, configurations); components and assemblies \
|
||||
are not supported yet",
|
||||
),
|
||||
];
|
||||
|
||||
/// Most model parameters one `timeline.add` step may bring (a sketch's dimensions).
|
||||
const MAX_STEP_PARAMS: usize = solidcraft_sketch::MAX_CONSTRAINTS;
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.add";
|
||||
let raw = p
|
||||
.get("feature")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| bad(cmd, "`feature` must be a feature object, as a design file or timeline.inspect has it"))?;
|
||||
let feature = recorded_feature(cmd, raw)?;
|
||||
let params = step_params(cmd, p.get("params"))?;
|
||||
let id = s.doc_mut().insert_feature(feature, params)?;
|
||||
s.refresh();
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
let r = s.model.result(id);
|
||||
Ok(json!({"feature": id, "name": name, "error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone())}))
|
||||
}
|
||||
|
||||
/// The feature, through the checks a design file gets when it opens (numbers, sketch sizes, 3D
|
||||
/// curves, face attachments…), so a script step can hold nothing a design could not.
|
||||
fn recorded_feature(cmd: &str, raw: &Map<String, Value>) -> Result<Feature> {
|
||||
let mut raw = raw.clone();
|
||||
// A hand-written step may leave out the id and the name: they are then given new ones.
|
||||
raw.entry("id").or_insert(json!(0));
|
||||
raw.entry("name").or_insert(json!(""));
|
||||
// Opening fills in missing input names with the next free `dN` of the one-feature design
|
||||
// below; the real design names them instead (`Document::insert_feature`).
|
||||
let inputs = match raw.get("param_names") {
|
||||
None => Vec::new(),
|
||||
Some(Value::Array(a)) if a.len() <= 1000 => {
|
||||
a.iter().map(|n| n.as_str().map(str::to_string)).collect::<Option<Vec<_>>>().ok_or_else(|| bad(cmd, "`param_names` must be names"))?
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, "`param_names` must be a list of names")),
|
||||
};
|
||||
let design = json!({
|
||||
"format": solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
|
||||
"name": "",
|
||||
"params": [],
|
||||
"features": [Value::Object(raw)],
|
||||
});
|
||||
let mut d = Document::from_json(&design.to_string()).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
|
||||
let mut f = d.features.pop().ok_or_else(|| bad(cmd, "invalid feature"))?;
|
||||
f.param_names = inputs;
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
|
||||
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
|
||||
let list = v.as_array().filter(|a| a.len() <= MAX_STEP_PARAMS).ok_or_else(|| bad(cmd, "`params` must be a list of parameters"))?;
|
||||
let text = |p: &Value, k: &str| match p.get(k) {
|
||||
None | Some(Value::Null) => Some(String::new()),
|
||||
Some(Value::String(s)) => Some(s.clone()),
|
||||
Some(Value::Number(n)) if k == "expression" => n.as_f64().filter(|x| x.is_finite()).map(|x| x.to_string()),
|
||||
Some(_) => None,
|
||||
};
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for p in list {
|
||||
let name = str_(p, "name").ok_or_else(|| bad(cmd, "each parameter needs a `name`"))?;
|
||||
let expr =
|
||||
text(p, "expression").filter(|e| !e.trim().is_empty()).ok_or_else(|| bad(cmd, format!("parameter `{name}` needs an `expression`")))?;
|
||||
let unit = text(p, "unit").ok_or_else(|| bad(cmd, format!("parameter `{name}`: `unit` must be text")))?;
|
||||
let comment = text(p, "comment").ok_or_else(|| bad(cmd, format!("parameter `{name}`: `comment` must be text")))?;
|
||||
out.push(Param { name: name.trim().to_string(), expr: expr.trim().to_string(), unit: unit.trim().to_string(), comment, model: true });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.export_script";
|
||||
let (commands, omitted) = script_steps(&s.doc)?;
|
||||
let steps = commands.len();
|
||||
let script = json!({
|
||||
"about": {
|
||||
"design": s.doc.name,
|
||||
"format": solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"replay": "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)",
|
||||
},
|
||||
"commands": commands,
|
||||
});
|
||||
let path = str_(p, "path");
|
||||
if let Some(path) = path {
|
||||
let text = serde_json::to_string_pretty(&script).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
} else if p.get("path").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`path` must be a file path"));
|
||||
}
|
||||
Ok(json!({"script": script, "steps": steps, "omitted": omitted, "path": path}))
|
||||
}
|
||||
|
||||
fn step(command: &str, params: Value) -> Value {
|
||||
json!({"command": command, "params": params})
|
||||
}
|
||||
|
||||
fn not_supported(what: &str) -> EngineError {
|
||||
EngineError::Other(format!("not supported yet: a command script of a design with {what} (#886)"))
|
||||
}
|
||||
|
||||
/// The steps that rebuild `doc` on an empty design, and what they leave out.
|
||||
pub(crate) fn script_steps(doc: &Document) -> Result<(Vec<Value>, Vec<String>)> {
|
||||
if !doc.components.is_empty() || !doc.occurrences.is_empty() || !doc.parts.is_empty() || !doc.links.is_empty() {
|
||||
return Err(not_supported("components"));
|
||||
}
|
||||
if !doc.assembly.is_empty() {
|
||||
return Err(not_supported("joints"));
|
||||
}
|
||||
if !doc.body_components.is_empty()
|
||||
|| !doc.body_offsets.is_empty()
|
||||
|| doc.features.iter().any(|f| f.component != 0 || matches!(f.kind, FeatureKind::BodyTransfer { .. }))
|
||||
{
|
||||
return Err(not_supported("bodies moved between components"));
|
||||
}
|
||||
let mut omitted = Vec::new();
|
||||
for (empty, what) in [
|
||||
(doc.appearances.is_empty(), "appearances (colours of bodies and faces)"),
|
||||
(doc.configs.is_empty(), "configurations"),
|
||||
(doc.canvases.is_empty(), "canvases (reference images)"),
|
||||
(doc.named_views.is_empty(), "named views"),
|
||||
(doc.browser_groups.is_empty() && doc.browser_order.is_empty(), "Browser folders and order"),
|
||||
(doc.timeline_groups.is_empty(), "timeline folders"),
|
||||
(doc.visibility.is_empty(), "hidden items"),
|
||||
] {
|
||||
if !empty {
|
||||
omitted.push(what.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
if doc.units != "mm" {
|
||||
out.push(step("document.units", json!({"units": doc.units})));
|
||||
}
|
||||
for rule in &doc.sheet.rules {
|
||||
let mut p = serde_json::to_value(rule).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if doc.sheet.active.as_deref() == Some(rule.name.as_str())
|
||||
&& let Some(o) = p.as_object_mut()
|
||||
{
|
||||
o.insert("active".into(), json!(true));
|
||||
}
|
||||
out.push(step("sheet.manage_rules", p));
|
||||
}
|
||||
if let Some(active) = doc.sheet.active.as_deref().filter(|a| !doc.sheet.rules.iter().any(|r| r.name == *a)) {
|
||||
out.push(step("sheet.manage_rules", json!({"name": active, "active": true})));
|
||||
}
|
||||
for rule in &doc.plastic.rules {
|
||||
out.push(step("plastic.manage_rules", serde_json::to_value(rule).map_err(|e| EngineError::Other(e.to_string()))?));
|
||||
}
|
||||
if let Some(active) = &doc.plastic.active {
|
||||
out.push(step("plastic.manage_rules", json!({"name": active, "active": true})));
|
||||
}
|
||||
for m in &doc.user_materials {
|
||||
let mut p = json!({"name": m.name, "density_g_cm3": m.density});
|
||||
if let (Some(look), Some(o)) = (&m.appearance, p.as_object_mut()) {
|
||||
// A colour of its own carries the material's name; otherwise it is a library look.
|
||||
if look.name == m.name {
|
||||
o.insert("color".into(), json!(look.color));
|
||||
} else {
|
||||
o.insert("appearance".into(), json!(look.name));
|
||||
}
|
||||
o.insert("opacity".into(), json!(look.opacity));
|
||||
}
|
||||
out.push(step("material.create", p));
|
||||
}
|
||||
|
||||
// Each sketch brings the model parameters of its dimensions.
|
||||
let models: BTreeMap<&str, &Param> = doc.params.iter().filter(|p| p.model).map(|p| (p.name.as_str(), p)).collect();
|
||||
let mut owned: BTreeSet<&str> = BTreeSet::new();
|
||||
let mut brings: BTreeMap<u64, Vec<&Param>> = BTreeMap::new();
|
||||
for f in &doc.features {
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
for name in sketch.constraints.iter().filter_map(|c| c.param.as_deref()) {
|
||||
if let Some(p) = models.get(name)
|
||||
&& owned.insert(name)
|
||||
{
|
||||
brings.entry(f.id).or_default().push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = models.keys().find(|n| !owned.contains(*n)) {
|
||||
return Err(not_supported(&format!("model parameter `{p}` outside a sketch dimension")));
|
||||
}
|
||||
|
||||
// User parameters go in as soon as everything they refer to exists, so each step sees the
|
||||
// values the design has (parameters are global; the order only has to let each one evaluate).
|
||||
let mut known: BTreeSet<String> = BTreeSet::new();
|
||||
let mut pending: Vec<&Param> = doc.params.iter().filter(|p| !p.model).collect();
|
||||
let flush = |pending: &mut Vec<&Param>, known: &mut BTreeSet<String>, out: &mut Vec<Value>| {
|
||||
while let Some(i) = pending.iter().position(|p| expr::references(&p.expr).iter().all(|r| known.contains(r))) {
|
||||
let p = pending.remove(i);
|
||||
let mut params = json!({"name": p.name, "expression": p.expr, "unit": p.unit});
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
if !p.comment.is_empty() {
|
||||
o.insert("comment".into(), json!(p.comment));
|
||||
}
|
||||
if doc.favorites.contains(&p.name) {
|
||||
o.insert("favorite".into(), json!(true));
|
||||
}
|
||||
}
|
||||
out.push(step("parameters.add", params));
|
||||
known.insert(p.name.clone());
|
||||
}
|
||||
};
|
||||
for f in &doc.features {
|
||||
flush(&mut pending, &mut known, &mut out);
|
||||
let feature = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut params = json!({"feature": feature});
|
||||
if let (Some(list), Some(o)) = (brings.get(&f.id), params.as_object_mut()) {
|
||||
let list: Vec<Value> = list
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut v = json!({"name": p.name, "expression": p.expr, "unit": p.unit});
|
||||
if let (false, Some(o)) = (p.comment.is_empty(), v.as_object_mut()) {
|
||||
o.insert("comment".into(), json!(p.comment));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
o.insert("params".into(), Value::Array(list));
|
||||
known.extend(brings.get(&f.id).into_iter().flatten().map(|p| p.name.clone()));
|
||||
}
|
||||
known.extend(f.param_names.iter().filter(|n| !n.is_empty()).cloned());
|
||||
out.push(step("timeline.add", params));
|
||||
}
|
||||
flush(&mut pending, &mut known, &mut out);
|
||||
if let Some(p) = pending.first() {
|
||||
return Err(EngineError::Other(format!(
|
||||
"parameter `{}` refers to a parameter this design does not define (or to itself): fix it before exporting a script",
|
||||
p.name
|
||||
)));
|
||||
}
|
||||
|
||||
// Comments and favourites of feature inputs and dimensions.
|
||||
for (name, comment) in &doc.param_comments {
|
||||
out.push(step("parameters.comment", json!({"name": name, "comment": comment})));
|
||||
}
|
||||
let users: BTreeSet<&str> = doc.params.iter().filter(|p| !p.model).map(|p| p.name.as_str()).collect();
|
||||
for name in doc.favorites.iter().filter(|n| !users.contains(n.as_str())) {
|
||||
out.push(step("parameters.favorite", json!({"name": name, "favorite": true})));
|
||||
}
|
||||
|
||||
// Materials and plastic rules of bodies, once the bodies exist.
|
||||
let mut by_material: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for (body, material) in &doc.materials {
|
||||
by_material.entry(material.as_str()).or_default().push(body.as_str());
|
||||
}
|
||||
for (material, bodies) in by_material {
|
||||
out.push(step("material.assign", json!({"bodies": bodies, "material": material})));
|
||||
}
|
||||
let mut by_rule: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for (body, rule) in &doc.plastic.assigned {
|
||||
by_rule.entry(rule.as_str()).or_default().push(body.as_str());
|
||||
}
|
||||
for (rule, bodies) in by_rule {
|
||||
out.push(step("plastic.assign_rule", json!({"bodies": bodies, "rule": rule})));
|
||||
}
|
||||
|
||||
if let Some(m) = doc.marker.filter(|m| *m < doc.features.len()) {
|
||||
out.push(step("timeline.rollback", json!({"position": m})));
|
||||
}
|
||||
Ok((out, omitted))
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! `timeline.export_script` and `timeline.add` (#886): a design's script replays into the same
|
||||
//! design, and a recorded feature that does not fit the design is refused, changing nothing.
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn script(s: &mut Session) -> Value {
|
||||
let r = run(s, "timeline.export_script", json!({}));
|
||||
// Through text, as a script file carries it.
|
||||
serde_json::from_str(&r["script"].to_string()).unwrap()
|
||||
}
|
||||
|
||||
fn replay(script: &Value) -> Session {
|
||||
let mut t = Session::default();
|
||||
if let Err(e) = t.run_script(script) {
|
||||
panic!("replay: {e}\n{script:#}");
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn params(s: &Session) -> Vec<solidcraft_doc::Param> {
|
||||
let mut v = s.doc.params.clone();
|
||||
v.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
v
|
||||
}
|
||||
|
||||
fn errors(s: &Session) -> Vec<(u64, Option<String>)> {
|
||||
s.model.results.iter().map(|r| (r.id, r.error.clone())).collect()
|
||||
}
|
||||
|
||||
fn same(a: &mut Session, b: &mut Session) {
|
||||
assert_eq!(a.doc.features, b.doc.features, "timeline");
|
||||
assert_eq!(params(a), params(b), "parameters");
|
||||
assert_eq!(a.doc.marker, b.doc.marker, "marker");
|
||||
assert_eq!(a.doc.favorites, b.doc.favorites, "favourites");
|
||||
assert_eq!(a.doc.param_comments, b.doc.param_comments, "comments");
|
||||
assert_eq!(a.doc.materials, b.doc.materials, "materials");
|
||||
assert_eq!(a.doc.user_materials, b.doc.user_materials, "user materials");
|
||||
assert_eq!(errors(a), errors(b), "feature results");
|
||||
assert_eq!(volume(a), volume(b), "volume");
|
||||
}
|
||||
|
||||
fn commands(script: &Value) -> Vec<&str> {
|
||||
script["commands"].as_array().into_iter().flatten().filter_map(|c| c["command"].as_str()).collect()
|
||||
}
|
||||
|
||||
/// A plate from a dimensioned sketch, with a user parameter driving a dimension.
|
||||
fn plate(s: &mut Session) {
|
||||
run(s, "parameters.add", json!({"name": "w", "expression": "40 mm", "comment": "plate width", "favorite": true}));
|
||||
run(s, "parameters.add", json!({"name": "count", "expression": "3", "unit": ""}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "w * 0.75"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Base", "distance": "10 mm"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dimensioned_part_replays_into_the_same_design() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": "2 mm"}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[20, 30, 10]], "distance": "1 mm"}));
|
||||
let sc = script(&mut s);
|
||||
let cmds = commands(&sc);
|
||||
assert_eq!(cmds.iter().filter(|c| **c == "timeline.add").count(), 4, "{cmds:?}");
|
||||
assert_eq!(cmds.first(), Some(&"parameters.add"), "user parameters come first: {cmds:?}");
|
||||
let mut t = replay(&sc);
|
||||
same(&mut s, &mut t);
|
||||
// Replayed parameters still drive the part.
|
||||
run(&mut t, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
|
||||
same(&mut s, &mut t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_names_suppression_marker_and_failures_survive() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0], "name": "Gone"}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0], "name": "Kept box"}));
|
||||
// A gap in the ids, a renamed feature, a suppressed one.
|
||||
run(&mut s, "timeline.delete", json!({"features": ["Gone"]}));
|
||||
plate(&mut s);
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Kept box", "suppressed": true}));
|
||||
// A feature input named by a user parameter that refers back to it.
|
||||
let extrude = s.doc.features.last().unwrap().clone();
|
||||
let depth = extrude.param_names.first().cloned().unwrap();
|
||||
run(&mut s, "parameters.add", json!({"name": "twice_depth", "expression": format!("{depth} * 2")}));
|
||||
run(&mut s, "parameters.comment", json!({"name": depth, "comment": "plate depth"}));
|
||||
run(&mut s, "parameters.favorite", json!({"name": depth}));
|
||||
// A fillet that a parameter change makes too big fails, and the design keeps it.
|
||||
run(&mut s, "parameters.add", json!({"name": "r", "expression": "1 mm"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[5, 0, 10]], "radius": "r"}));
|
||||
let fillet = s.doc.features.last().unwrap().id;
|
||||
run(&mut s, "parameters.change", json!({"name": "r", "expression": "50 mm"}));
|
||||
assert!(s.model.result(fillet).and_then(|r| r.error.clone()).is_some(), "the fillet fails");
|
||||
run(&mut s, "timeline.rollback", json!({"position": 3}));
|
||||
let mut t = replay(&script(&mut s));
|
||||
same(&mut s, &mut t);
|
||||
// The script up to a feature is the design rolled back to just after it.
|
||||
let full = script(&mut s);
|
||||
let cmds = full["commands"].as_array().unwrap();
|
||||
let cut = cmds.iter().position(|c| c["params"]["feature"]["type"] == "extrude").unwrap();
|
||||
let mut part = replay(&json!({"commands": cmds[..=cut].to_vec()}));
|
||||
let ids: Vec<u64> = part.doc.features.iter().map(|f| f.id).collect();
|
||||
assert_eq!(ids, s.doc.features.iter().map(|f| f.id).take(ids.len()).collect::<Vec<_>>());
|
||||
run(&mut s, "timeline.rollback", json!({"position": ids.len()}));
|
||||
assert_eq!(volume(&mut part), volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_materials_and_rules_come_back() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
run(&mut s, "material.create", json!({"name": "Test resin", "density_g_cm3": 1.3, "color": "#336699", "opacity": 0.5}));
|
||||
run(&mut s, "solid.box", json!({"length": "1 in", "width": "1 in", "height": "1 in", "body_name": "Cube"}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Cube"], "material": "Test resin"}));
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Thin", "thickness": "0.8 mm", "active": true}));
|
||||
let mut t = replay(&script(&mut s));
|
||||
same(&mut s, &mut t);
|
||||
assert_eq!(t.doc.units, "in");
|
||||
assert_eq!(t.doc.sheet, s.doc.sheet);
|
||||
let a = run(&mut s, "inspect.measure", json!({}))["total"].clone();
|
||||
let b = run(&mut t, "inspect.measure", json!({}))["total"].clone();
|
||||
assert_eq!(a, b, "mass comes from the replayed material");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_script_leaves_out_is_listed_and_components_are_refused() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies": ["B"], "color": "#ff0000"}));
|
||||
let r = run(&mut s, "timeline.export_script", json!({}));
|
||||
assert!(r["omitted"].to_string().contains("appearances"), "{r}");
|
||||
run(&mut s, "component.create", json!({"name": "Sub"}));
|
||||
let e = s.execute("timeline.export_script", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_feature_that_does_not_fit_is_refused_and_changes_nothing() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
let sc = script(&mut s);
|
||||
let sketch = sc["commands"].as_array().unwrap().iter().find(|c| c["params"]["feature"]["type"] == "sketch").unwrap()["params"].clone();
|
||||
let extrude = sc["commands"].as_array().unwrap().iter().find(|c| c["params"]["feature"]["type"] == "extrude").unwrap()["params"].clone();
|
||||
let before = (*s.doc).clone();
|
||||
let with = |base: &Value, path: &[&str], v: Value| {
|
||||
let mut p = base.clone();
|
||||
let mut at = &mut p;
|
||||
for k in &path[..path.len() - 1] {
|
||||
at = &mut at[*k];
|
||||
}
|
||||
at[path[path.len() - 1]] = v;
|
||||
p
|
||||
};
|
||||
let refused = [
|
||||
("id taken", with(&extrude, &["feature", "name"], json!("Other"))),
|
||||
("name taken", with(&extrude, &["feature", "id"], json!(0))),
|
||||
("id out of range", with(&with(&extrude, &["feature", "id"], json!(1u64 << 60)), &["feature", "name"], json!("X"))),
|
||||
("parameter taken", with(&with(&sketch, &["feature", "id"], json!(0)), &["feature", "name"], json!("S2"))),
|
||||
(
|
||||
"bad parameter name",
|
||||
with(
|
||||
&json!({"feature": {"type": "box", "length": "1", "width": "1", "height": "1"}}),
|
||||
&["params"],
|
||||
json!([{"name": "1x", "expression": "1"}]),
|
||||
),
|
||||
),
|
||||
(
|
||||
"unknown unit",
|
||||
with(
|
||||
&json!({"feature": {"type": "box", "length": "1", "width": "1", "height": "1"}}),
|
||||
&["params"],
|
||||
json!([{"name": "q", "expression": "1", "unit": "parsec"}]),
|
||||
),
|
||||
),
|
||||
("bad input names", with(&extrude, &["feature", "param_names"], json!([1, 2]))),
|
||||
("unknown type", json!({"feature": {"type": "warp_drive"}})),
|
||||
("huge number", json!({"feature": {"type": "box", "length": "1", "width": "1", "height": "1", "corner": [1e300, 0, 0]}})),
|
||||
("unknown component", json!({"feature": {"type": "box", "length": "1", "width": "1", "height": "1", "component": 9}})),
|
||||
("not an object", json!({"feature": [1, 2, 3]})),
|
||||
];
|
||||
for (what, p) in refused {
|
||||
assert!(s.execute("timeline.add", &p).is_err(), "{what}: {p}");
|
||||
assert_eq!(*s.doc, before, "{what}: nothing changed");
|
||||
}
|
||||
// A hand-written step needs only the feature's own fields.
|
||||
let r = run(&mut s, "timeline.add", json!({"feature": {"type": "box", "length": "5", "width": "5", "height": "5", "corner": [100, 0, 0]}}));
|
||||
assert!(r["error"].is_null(), "{r}");
|
||||
assert_eq!(r["name"], "Box1");
|
||||
assert!(s.doc.feature(r["feature"].as_u64().unwrap()).is_some_and(|f| !f.param_names.is_empty()), "its inputs get parameter names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_script_is_written_to_a_file_that_replays() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-script-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("repro.json");
|
||||
let r = run(&mut s, "timeline.export_script", json!({"path": path.to_string_lossy()}));
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let written: Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(written, r["script"]);
|
||||
let mut t = replay(&written);
|
||||
same(&mut s, &mut t);
|
||||
assert!(s.execute("timeline.export_script", &json!({"path": 7})).is_err(), "a path is text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_features_claiming_the_same_body_name_do_not_crash() {
|
||||
let mut s = Session::default();
|
||||
let boxed = |name: &str, x: f64| json!({"feature": {"type": "box", "name": name, "length": "5", "width": "5", "height": "5", "corner": [x, 0, 0], "body_names": ["Twin"]}});
|
||||
run(&mut s, "timeline.add", boxed("A", 0.0));
|
||||
let r = s.execute("timeline.add", &boxed("B", 20.0));
|
||||
assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "{r:?}");
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
assert!(m["body_count"].as_u64().is_some_and(|n| n >= 1), "{m}");
|
||||
}
|
||||
Reference in new issue
Block a user