diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 2e364137..f61dae08 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -14,6 +14,8 @@ What did you expect, and what happened instead? Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data. +When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can. + ## Screenshots of the behavior (when observable) For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful. diff --git a/ROADMAP.md b/ROADMAP.md index beb4ae34..1f6ac851 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,13 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. Crash Reports…) with a reviewed issue draft; agents read them through `diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent anywhere. See [crash reports](docs/crash-reports.md). +- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`) + writes a design's history as a script that `eval` and `run` replay into the same design — + user parameters, units, sheet metal and plastic rules and materials as their own commands, + then each feature through `timeline.add` with its ids, names and dimension parameters kept, + so every feature kind replays exactly and a failing feature fails the same way. Trimming the + script after a step gives the design at that step. Appearances, folders, views and + configurations are listed as left out; components and joints are not supported yet. ## 0.2.0 release batch diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index 15aa2883..75b6ffdc 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -3,6 +3,7 @@ //! ```text //! solidcraft-cli run [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors] //! solidcraft-cli eval (alias: inspect) measurements as JSON +//! solidcraft-cli script [--out script.json] the design's history as a command script //! solidcraft-cli bench-open [--parse-only] loading timings as JSON lines //! solidcraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|top|…] //! solidcraft-cli exec [json-params] run one command on an empty design @@ -37,6 +38,8 @@ const USAGE: &str = "usage: solidcraft-cli run --in [--out FILE]... (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway) solidcraft-cli eval (alias: inspect; or --in FILE) + solidcraft-cli script [--out script.json] + (the design's history as a command script that eval and run replay) solidcraft-cli bench-open [--parse-only] solidcraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right] solidcraft-cli exec [json-params] @@ -55,6 +58,7 @@ fn main() -> ExitCode { let r = match args.first().map(String::as_str) { Some("run") => cmd_run(&args[1..]), Some("eval" | "inspect") => cmd_eval(&args[1..]), + Some("script") => cmd_script(&args[1..]), Some("snapshot") => cmd_snapshot(&args[1..]), Some("exec") => cmd_exec(&args[1..]), Some("commands") => { @@ -242,6 +246,23 @@ fn cmd_eval(args: &[String]) -> Result<(), String> { Ok(()) } +/// A design's history as a command script (`timeline.export_script`): printed, or written to +/// `--out`; what the script leaves out goes to stderr. +fn cmd_script(args: &[String]) -> Result<(), String> { + let path = positional(args).ok_or_else(|| USAGE.to_string())?; + let s = &mut load(path)?; + let out = flag(args, "--out"); + let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?; + for what in r["omitted"].as_array().into_iter().flatten().filter_map(Value::as_str) { + eprintln!("note: the script leaves out {what}"); + } + match out { + Some(out) => eprintln!("{out}: {} steps", r["steps"]), + None => println!("{}", pretty(&r["script"])), + } + Ok(()) +} + fn cmd_snapshot(args: &[String]) -> Result<(), String> { let path = args.first().ok_or_else(|| USAGE.to_string())?; let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?; diff --git a/apps/solidcraft-cli/tests/script_roundtrip.rs b/apps/solidcraft-cli/tests/script_roundtrip.rs new file mode 100644 index 00000000..fe1d5a05 --- /dev/null +++ b/apps/solidcraft-cli/tests/script_roundtrip.rs @@ -0,0 +1,86 @@ +//! A design's command script (`timeline.export_script`, #886) replays into the same design: +//! every corpus design that the script supports comes back with the same timeline, parameters, +//! settings and bodies. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; +use solidcraft_engine::Session; + +fn corpus() -> Vec { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus"); + let mut v: Vec = + std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect(); + v.sort(); + assert!(v.len() >= 7, "corpus files in {}", dir.display()); + v +} + +/// Designs with components or joints, which scripts do not support yet. +const ASSEMBLIES: [&str; 2] = ["components-arm", "joints-hinge"]; + +fn bodies(s: &mut Session) -> Vec<(String, f64, f64, Value)> { + let m = s.execute("inspect.measure", &json!({})).unwrap(); + let mut v: Vec<_> = m["bodies"] + .as_array() + .into_iter() + .flatten() + .map(|b| { + ( + b["name"].as_str().unwrap_or_default().to_string(), + b["volume_mm3"].as_f64().unwrap(), + b["area_mm2"].as_f64().unwrap(), + b["faces"].clone(), + ) + }) + .collect(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v +} + +fn errors(s: &Session) -> Vec<(u64, Option)> { + s.model.results.iter().map(|r| (r.id, r.error.clone())).collect() +} + +fn params(s: &Session) -> Vec { + let mut v = s.doc.params.clone(); + v.sort_by(|a, b| a.name.cmp(&b.name)); + v +} + +#[test] +fn corpus_designs_replay_from_their_scripts() { + let mut replayed = 0; + for p in corpus() { + let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string(); + let mut s = Session::default(); + s.execute("doc.open", &json!({"path": p.to_string_lossy()})).unwrap(); + let r = match s.execute("timeline.export_script", &json!({})) { + Ok(r) => r, + Err(e) => { + assert!(ASSEMBLIES.contains(&name.as_str()), "{name}: {e}"); + assert!(e.to_string().contains("not supported yet"), "{name}: {e}"); + continue; + } + }; + assert!(!ASSEMBLIES.contains(&name.as_str()), "{name} has components: the script should refuse it"); + // Through text, as a script file would carry it. + let script: Value = serde_json::from_str(&r["script"].to_string()).unwrap(); + let mut t = Session::default(); + t.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(t.doc.features, s.doc.features, "{name}: timeline"); + assert_eq!(params(&t), params(&s), "{name}: parameters"); + assert_eq!(t.doc.marker, s.doc.marker, "{name}: marker"); + assert_eq!(t.doc.units, s.doc.units, "{name}: units"); + assert_eq!(t.doc.sheet, s.doc.sheet, "{name}: sheet metal rules"); + assert_eq!(t.doc.plastic, s.doc.plastic, "{name}: plastic rules"); + assert_eq!(t.doc.materials, s.doc.materials, "{name}: materials"); + assert_eq!(t.doc.user_materials, s.doc.user_materials, "{name}: user materials"); + assert_eq!(t.doc.favorites, s.doc.favorites, "{name}: favourites"); + assert_eq!(t.doc.param_comments, s.doc.param_comments, "{name}: comments"); + assert_eq!(errors(&t), errors(&s), "{name}: feature results"); + assert_eq!(bodies(&mut t), bodies(&mut s), "{name}: bodies"); + replayed += 1; + } + assert!(replayed >= 7, "replayed {replayed} corpus designs"); +} diff --git a/assets/locales/de.json b/assets/locales/de.json index a53803ec..d5db4939 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -639,9 +639,11 @@ "cmd.surface_unstitch": "Verbund aufheben", "cmd.surface_reverse_normal": "Normale umkehren", "cmd.inspect_continuity": "Kontinuitätsprüfung", + "cmd.timeline_add": "Aufgezeichnetes Merkmal hinzufügen", "cmd.timeline_compute_all": "Alles berechnen", "cmd.timeline_copy": "Merkmale kopieren", "cmd.timeline_dependents": "Merkmalsabhängigkeiten", + "cmd.timeline_export_script": "Befehlsskript exportieren", "cmd.timeline_inspect": "Merkmal prüfen", "cmd.timeline_paste": "Merkmale einfügen", "cmd.timeline_redefine": "Merkmal bearbeiten", diff --git a/assets/locales/en.json b/assets/locales/en.json index 591e8e13..e328e9f9 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -259,9 +259,11 @@ "cmd.surface_unstitch": "Unstitch", "cmd.surface_reverse_normal": "Reverse Normal", "cmd.inspect_continuity": "Continuity Analysis", + "cmd.timeline_add": "Add Recorded Feature", "cmd.timeline_compute_all": "Compute All", "cmd.timeline_copy": "Copy Features", "cmd.timeline_dependents": "Feature Dependents", + "cmd.timeline_export_script": "Export Command Script", "cmd.timeline_inspect": "Inspect Feature", "cmd.timeline_paste": "Paste Features", "cmd.timeline_redefine": "Edit Feature", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index f35b8333..aa3011b0 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -259,9 +259,11 @@ "cmd.surface_unstitch": "縫い合わせ解除", "cmd.surface_reverse_normal": "法線を反転", "cmd.inspect_continuity": "連続性解析", + "cmd.timeline_add": "記録したフィーチャーを追加", "cmd.timeline_compute_all": "すべて再計算", "cmd.timeline_copy": "フィーチャーをコピー", "cmd.timeline_dependents": "従属フィーチャー", + "cmd.timeline_export_script": "コマンド スクリプトをエクスポート", "cmd.timeline_inspect": "フィーチャーを検査", "cmd.timeline_paste": "フィーチャーを貼り付け", "cmd.timeline_redefine": "フィーチャーを編集", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 9224618b..05231f34 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -639,9 +639,11 @@ "cmd.surface_unstitch": "스티치 해제", "cmd.surface_reverse_normal": "법선 반전", "cmd.inspect_continuity": "연속성 분석", + "cmd.timeline_add": "기록된 특성 추가", "cmd.timeline_compute_all": "모두 다시 계산", "cmd.timeline_copy": "특성 복사", "cmd.timeline_dependents": "특성 종속 항목", + "cmd.timeline_export_script": "명령 스크립트 내보내기", "cmd.timeline_inspect": "특성 검사", "cmd.timeline_paste": "특성 붙여넣기", "cmd.timeline_redefine": "특성 편집", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 7be6c5f1..48517771 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -259,9 +259,11 @@ "cmd.surface_unstitch": "取消缝合", "cmd.surface_reverse_normal": "反转法向", "cmd.inspect_continuity": "连续性分析", + "cmd.timeline_add": "添加记录的特征", "cmd.timeline_compute_all": "全部重新計算", "cmd.timeline_copy": "复制特征", "cmd.timeline_dependents": "特征依存项", + "cmd.timeline_export_script": "导出命令脚本", "cmd.timeline_inspect": "检查特征", "cmd.timeline_paste": "粘贴特征", "cmd.timeline_redefine": "编辑特征", diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 4fefc6e1..1e20425b 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -8,6 +8,9 @@ use crate::{DocError, Result}; pub const MAX_FEATURES: usize = 10_000; pub const MAX_PARAMS: usize = 10_000; +/// Largest feature id a replayed feature may keep (`Document::insert_feature`): exact in JSON +/// numbers, and far from overflowing the next id. +pub const MAX_FEATURE_ID: u64 = 1 << 52; /// A named parameter. User parameters are created by the user; model parameters (`d1`, `d2`…) /// are created by dimensions and features. @@ -2169,6 +2172,79 @@ impl Document { shell_piece_order: false, kind, }; + self.place_feature(f); + Ok(id) + } + + /// Insert a feature as given, keeping its id, name, parameter names and stored edge and + /// face names, at the marker (or the end), with the model parameters its sketch dimensions + /// use: a feature replayed from a script (`timeline.add`) is the feature the design had. + /// An id of 0 or an empty name gets a new one. Refused, changing nothing, when the id, the + /// name or a parameter name is already used, or the feature belongs to a component this + /// design does not have. + pub fn insert_feature(&mut self, mut f: Feature, params: Vec) -> Result { + if self.features.len() >= MAX_FEATURES { + return Err(DocError::Invalid("too many features".into())); + } + if f.id == 0 { + f.id = self.next_id.max(1); + } else if f.id > MAX_FEATURE_ID { + return Err(DocError::Invalid(format!("feature id {} is out of range", f.id))); + } else if self.feature(f.id).is_some() { + return Err(DocError::Invalid(format!("feature id {} is already used", f.id))); + } + f.name = f.name.trim().to_string(); + if f.name.is_empty() { + f.name = self.unique_name(f.kind.base_name()); + } else if self.features.iter().any(|x| x.name == f.name) { + return Err(DocError::Invalid(format!("a feature named `{}` already exists", f.name))); + } + if f.component != 0 && !self.components.iter().any(|c| c.id == f.component) { + return Err(DocError::Invalid(format!("feature `{}` belongs to an unknown component", f.name))); + } + if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &f.kind { + crate::validate_body_transfer(self, bodies, *from, *to, transform)?; + if f.component != *to { + return Err(DocError::Invalid("a body transfer belongs to its destination component".into())); + } + } + let mut own = std::collections::BTreeSet::new(); + for n in f.param_names.iter().filter(|n| !n.is_empty()) { + if let Some(why) = Self::param_name_problem(n) { + return Err(DocError::Invalid(why)); + } + if self.name_taken(n) || !own.insert(n) { + return Err(DocError::Invalid(format!("parameter name `{n}` is already used"))); + } + } + if self.params.len().saturating_add(params.len()) > MAX_PARAMS { + return Err(DocError::Invalid("too many parameters".into())); + } + for p in ¶ms { + if let Some(why) = Self::param_name_problem(&p.name) { + return Err(DocError::Invalid(why)); + } + if self.name_taken(&p.name) || !own.insert(&p.name) { + return Err(DocError::Invalid(format!("parameter name `{}` is already used", p.name))); + } + if p.expr.len() > 4096 || p.comment.len() > 4096 { + return Err(DocError::Expr(format!("parameter `{}`: expression or comment too long", p.name))); + } + if !p.unit.is_empty() && expr::unit_info(&p.unit).is_none() { + return Err(DocError::Invalid(format!("parameter `{}`: unknown unit `{}`", p.name, p.unit))); + } + } + let id = f.id; + self.next_id = self.next_id.max(id.saturating_add(1)); + self.params.extend(params.into_iter().map(|p| Param { model: true, ..p })); + self.place_feature(f); + Ok(id) + } + + /// Put a feature at the marker (or the end), in the timeline folder that spans that slot, + /// and name its inputs. + fn place_feature(&mut self, f: Feature) { + let id = f.id; let insertion_group = self.marker.and_then(|marker| self.timeline_group_at_insertion(marker)); match self.marker { Some(m) if m < self.features.len() => { @@ -2186,7 +2262,6 @@ impl Document { } } self.name_feature_inputs(id); - Ok(id) } /// Delete a feature (and, for a sketch, the features that use it). diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index b0f0bf4d..7aea893b 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -459,7 +459,7 @@ impl Document { v } - fn name_taken(&self, n: &str) -> bool { + pub(crate) fn name_taken(&self, n: &str) -> bool { self.param(n).is_some() || self.features.iter().any(|f| f.param_names.iter().any(|x| x == n)) } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index dd39bb56..a01444c5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/script.rs b/crates/engine/src/cmd/script.rs new file mode 100644 index 00000000..9f80bbd7 --- /dev/null +++ b/crates/engine/src/cmd/script.rs @@ -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 { + 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) -> Result { + 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::>>().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> { + 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 { + 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, Vec)> { + 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> = 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 = BTreeSet::new(); + let mut pending: Vec<&Param> = doc.params.iter().filter(|p| !p.model).collect(); + let flush = |pending: &mut Vec<&Param>, known: &mut BTreeSet, out: &mut Vec| { + 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 = 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)) +} diff --git a/crates/engine/src/cmd/script_tests.rs b/crates/engine/src/cmd/script_tests.rs new file mode 100644 index 00000000..0748628d --- /dev/null +++ b/crates/engine/src/cmd/script_tests.rs @@ -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 { + 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)> { + 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 = part.doc.features.iter().map(|f| f.id).collect(); + assert_eq!(ids, s.doc.features.iter().map(|f| f.id).take(ids.len()).collect::>()); + 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}"); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef284aa0..163cdd93 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -729,8 +729,8 @@ fn hostile_params_never_panic() { while let Some(&id) = ids.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) { for v in hostile_values() { for k in keys { - if k == "path" && id.starts_with("parameters.") { - continue; // parameter files: covered by their own tests (no stray files) + if k == "path" && (id.starts_with("parameters.") || id == "timeline.export_script") { + continue; // parameter and script files: covered by their own tests (no stray files) } let mut s = Session::default(); // A model with a sketch, a body and an active sketch, so most commands get far. diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index c9809cac..3de1bb9b 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -121,6 +121,23 @@ rebuilds it; `run` then exits with status 1, lists the failing features and writ its `--out` and `--save` files, because the bodies lack what those features should have made. `--allow-errors` writes them anyway. +A design's history turns into a script that rebuilds it, which makes a bug that only shows +on one design a one-file reproduction: + +```sh +cargo run --release -p solidcraft-cli -- script part.solidcraft --out repro.json +cargo run --release -p solidcraft-cli -- eval repro.json # the same part +``` + +The script starts with the user parameters (`parameters.add`) and the design's units, sheet +metal and plastic rules and materials, then adds each timeline feature with `timeline.add`, +exactly as the design file holds it: same ids, names and parameter names, so later steps +still find the sketches and bodies they use, and a feature that failed in the design fails +the same way. Edit a step's values to try a change, or delete the steps after a feature to +see the design as it was there. Appearances, folders, named views and configurations are +left out (the CLI notes which), and designs with components or joints are not supported +yet. In the app and over MCP the same script comes from `timeline.export_script`. + Units are millimetres and degrees unless an expression says otherwise (`"1 in"`, `"30 deg"`), and Z is up. diff --git a/docs/features.md b/docs/features.md index 4a004542..19672721 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **345 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **347 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (99) @@ -270,7 +270,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Configuration Rule | `config.rule` | – | | Configuration Rules | `config.rules` | – | -## Timeline (19) +## Timeline (21) | Command | Id | Toolbar | |---|---|---| @@ -293,6 +293,8 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Inspect Feature | `timeline.inspect` | – | | Copy Features | `timeline.copy` | – | | Paste Features | `timeline.paste` | – | +| Add Recorded Feature | `timeline.add` | – | +| Export Command Script | `timeline.export_script` | – | ## Inspect (12, 1 planned)