490 lines
23 KiB
Rust
490 lines
23 KiB
Rust
//! SolveCraft headless command line.
|
|
//!
|
|
//! ```text
|
|
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
|
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
|
//! solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
|
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
|
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
|
|
//! solvecraft-cli commands the command registry as JSON
|
|
//! solvecraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
|
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
|
|
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
|
|
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
|
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
|
|
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
|
//! ```
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
#![forbid(unsafe_code)]
|
|
|
|
mod fuzz;
|
|
mod oracle;
|
|
mod recipe;
|
|
mod seams;
|
|
|
|
use std::process::ExitCode;
|
|
|
|
use serde_json::{Value, json};
|
|
use solvecraft_engine::Session;
|
|
use solvecraft_engine::render::{StandardView, render_png};
|
|
|
|
const USAGE: &str = "usage:
|
|
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
|
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
|
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
|
solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
|
|
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
|
solvecraft-cli exec <command> [json-params]
|
|
solvecraft-cli commands
|
|
solvecraft-cli recipe <recipe.json>
|
|
solvecraft-cli oracle <case-dir>... [--json]
|
|
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
|
solvecraft-cli step-corpus <case-dir>... [--json] [--iges]
|
|
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
|
solvecraft-cli licences
|
|
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
|
";
|
|
|
|
fn main() -> ExitCode {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let r = match args.first().map(String::as_str) {
|
|
Some("run") => cmd_run(&args[1..]),
|
|
Some("eval" | "inspect") => cmd_eval(&args[1..]),
|
|
Some("snapshot") => cmd_snapshot(&args[1..]),
|
|
Some("exec") => cmd_exec(&args[1..]),
|
|
Some("commands") => {
|
|
use std::io::Write as _;
|
|
let mut s = Session::default();
|
|
s.execute("engine.commands", &json!({})).map_err(|e| e.to_string()).and_then(|v| {
|
|
// A pipeline consumer may stop early (e.g. `commands | head`).
|
|
// Unlike println!, writing explicitly lets us handle that without a panic.
|
|
match writeln!(std::io::stdout().lock(), "{}", pretty(&v)) {
|
|
Ok(()) => Ok(()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
|
|
Err(e) => Err(format!("stdout: {e}")),
|
|
}
|
|
})
|
|
}
|
|
Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| {
|
|
let v = read_json(p)?;
|
|
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
|
|
}),
|
|
Some("oracle") => oracle::run(&args[1..]),
|
|
Some("step-corpus") => oracle::step_corpus(&args[1..]),
|
|
Some("fuzz") => fuzz::run(&args[1..]),
|
|
Some("fuzz-one") => fuzz::one(&args[1..]),
|
|
Some("mcp") => cmd_mcp(&args[1..]),
|
|
Some("icon") => cmd_icon(&args[1..]),
|
|
Some("licences" | "licenses") => {
|
|
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
|
|
use std::io::Write as _;
|
|
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
|
|
Ok(())
|
|
}
|
|
// Hidden, for the kill-mid-save test: save a large design over and over.
|
|
Some("save-stress") => cmd_save_stress(&args[1..]),
|
|
// Hidden: time recomputes after parameter edits (docs/perf.md).
|
|
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
|
Some("bench-open") => cmd_bench_open(&args[1..]),
|
|
Some("--version" | "-V") => {
|
|
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
|
Ok(())
|
|
}
|
|
_ => Err(USAGE.to_string()),
|
|
};
|
|
match r {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(e) => {
|
|
eprintln!("error: {e}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pretty(v: &Value) -> String {
|
|
serde_json::to_string_pretty(v).unwrap_or_default()
|
|
}
|
|
|
|
pub fn read_json(path: &str) -> Result<Value, String> {
|
|
// Through the engine's file access, which refuses anything that is not a regular file and
|
|
// reads no more than the cap: a character device or a FIFO would otherwise never end.
|
|
let s = solvecraft_engine::io::vfs::read_to_string(path, solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
|
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
|
|
}
|
|
|
|
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
|
|
pub fn load(path: &str) -> Result<Session, String> {
|
|
let mut s = Session::default();
|
|
if solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path) {
|
|
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
|
return Ok(s);
|
|
}
|
|
let v = read_json(path)?;
|
|
if v.get("format").and_then(Value::as_str).is_some_and(|f| f.starts_with("solvecraft")) {
|
|
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
|
} else if v.get("features").is_some() && v.get("commands").is_none() {
|
|
let script = recipe::to_script(&v)?;
|
|
s.run_script(&script).map_err(|e| e.to_string())?;
|
|
} else {
|
|
s.run_script(&v).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(s)
|
|
}
|
|
|
|
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
|
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
|
}
|
|
|
|
fn flags<'a>(args: &'a [String], name: &str) -> Vec<&'a str> {
|
|
args.windows(2).filter(|w| w[0] == name).map(|w| w[1].as_str()).collect()
|
|
}
|
|
|
|
/// The first argument that is not an option or an option's value.
|
|
fn positional(args: &[String]) -> Option<&String> {
|
|
let mut i = 0;
|
|
while let Some(a) = args.get(i) {
|
|
if a.starts_with("--") {
|
|
i += if matches!(a.as_str(), "--quiet" | "--results") { 1 } else { 2 };
|
|
continue;
|
|
}
|
|
return Some(a);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn cmd_run(args: &[String]) -> Result<(), String> {
|
|
let base = flag(args, "--in");
|
|
let path = positional(args);
|
|
if path.is_none() && base.is_none() {
|
|
return Err(USAGE.to_string());
|
|
}
|
|
// `--in` starts from a design or STEP file; the positional script then runs on top of it.
|
|
let mut s = match base {
|
|
Some(b) => load(b)?,
|
|
None => Session::default(),
|
|
};
|
|
let results = if args.iter().any(|a| a == "--results") {
|
|
// Collect every command's result (scripts only), then process requested files too.
|
|
let v = read_json(path.ok_or("--results needs a script")?)?;
|
|
let r = s.run_script(&v).map_err(|e| e.to_string())?;
|
|
Some(Value::Array(r))
|
|
} else {
|
|
match (base, path) {
|
|
(Some(_), Some(p)) => {
|
|
let v = read_json(p)?;
|
|
s.run_script(&v).map_err(|e| e.to_string())?;
|
|
}
|
|
(None, Some(p)) => s = load(p)?,
|
|
_ => {}
|
|
}
|
|
None
|
|
};
|
|
for out in flags(args, "--out") {
|
|
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
|
|
}
|
|
if let Some(p) = flag(args, "--save") {
|
|
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
|
|
}
|
|
if let Some(results) = results {
|
|
println!("{}", pretty(&results));
|
|
} else if !args.iter().any(|a| a == "--quiet") {
|
|
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
|
println!("{}", pretty(&m));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_eval(args: &[String]) -> Result<(), String> {
|
|
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
|
|
let mut s = load(path)?;
|
|
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
|
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
|
|
let out = json!({
|
|
"body_count": m["body_count"],
|
|
"bodies": m["bodies"],
|
|
"total": m["total"],
|
|
"timeline": d["timeline"],
|
|
"params": d["params"],
|
|
"sketches": d["sketches"],
|
|
});
|
|
println!("{}", pretty(&out));
|
|
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")?;
|
|
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
|
|
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
|
|
let s = load(path)?;
|
|
let mut cam = solvecraft_engine::view::home_camera(&s);
|
|
if let Some(v) = flag(args, "--view") {
|
|
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
|
|
}
|
|
let scene = solvecraft_engine::view::scene(&s, &cam);
|
|
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
|
|
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
|
|
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
|
|
Ok(())
|
|
}
|
|
|
|
/// The app icon: the SolidCraft logo, drawn in code on a transparent background
|
|
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
|
|
fn cmd_icon(args: &[String]) -> Result<(), String> {
|
|
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
|
|
let size: usize = flag(args, "--size").and_then(|x| x.parse().ok()).unwrap_or(256).clamp(8, 2048);
|
|
let bytes = if out.to_ascii_lowercase().ends_with(".ico") {
|
|
let pngs: Vec<(usize, Vec<u8>)> =
|
|
[16, 20, 24, 32, 40, 48, 64, 128, 256].into_iter().map(|s| icon_png(s).map(|p| (s, p))).collect::<Result<_, _>>()?;
|
|
ico(&pngs)?
|
|
} else {
|
|
icon_png(size)?
|
|
};
|
|
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
|
println!("{}", pretty(&json!({"path": out, "bytes": bytes.len()})));
|
|
Ok(())
|
|
}
|
|
|
|
/// This block is the icon image data
|
|
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
|
use solvecraft_engine::render::png;
|
|
type P = (f32, f32);
|
|
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
|
|
let ss = if size > 512 { 2 } else { 4 };
|
|
let n = size * ss;
|
|
let mut buf = vec![[0u8; 4]; n * n];
|
|
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
|
|
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
|
|
// Fill a convex polygon given in SVG units.
|
|
let mut fill = |pts: &[P], col: [u8; 3]| {
|
|
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
|
|
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
|
|
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
|
|
let cy = y as f32 + 0.5;
|
|
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
|
for i in 0..v.len() {
|
|
let (a, b) = (v[i], v[(i + 1) % v.len()]);
|
|
if (a.1 <= cy) != (b.1 <= cy) {
|
|
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
|
|
lo = lo.min(x);
|
|
hi = hi.max(x);
|
|
}
|
|
}
|
|
if lo > hi {
|
|
continue;
|
|
}
|
|
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
|
|
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
|
|
}
|
|
}
|
|
};
|
|
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
|
|
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
|
|
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
|
|
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
|
|
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
|
|
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
|
|
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
|
|
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
|
|
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
|
|
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
|
|
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
|
|
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
|
|
let white = [255, 255, 255];
|
|
let mut stroke = |pts: &[P], closed: bool| {
|
|
let m = pts.len();
|
|
for i in 0..(if closed { m } else { m - 1 }) {
|
|
let (a, b) = (pts[i], pts[(i + 1) % m]);
|
|
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
|
|
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
|
|
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
|
|
}
|
|
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
|
|
let disc: Vec<P> = (0..16)
|
|
.map(|k| {
|
|
let t = k as f32 * std::f32::consts::TAU / 16.0;
|
|
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
|
|
})
|
|
.collect();
|
|
fill(&disc, white);
|
|
}
|
|
};
|
|
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
|
|
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
|
|
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
|
|
stroke(
|
|
&[
|
|
(178.5, 5.8),
|
|
(5.0, 105.9),
|
|
(5.0, 206.1),
|
|
(91.7, 256.2),
|
|
(91.7, 356.4),
|
|
(178.5, 406.4),
|
|
(352.0, 306.3),
|
|
(352.0, 206.1),
|
|
(265.2, 156.0),
|
|
(265.2, 55.9),
|
|
],
|
|
true,
|
|
);
|
|
// Average each ss x ss block down to one pixel (straight alpha = covered share).
|
|
let mut rgba = Vec::with_capacity(size * size * 4);
|
|
for y in 0..size {
|
|
for x in 0..size {
|
|
let (mut sum, mut cnt) = ([0u32; 3], 0u32);
|
|
for j in 0..ss {
|
|
for i in 0..ss {
|
|
let p = buf[(y * ss + j) * n + x * ss + i];
|
|
if p[3] != 0 {
|
|
cnt += 1;
|
|
for k in 0..3 {
|
|
sum[k] += u32::from(p[k]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if cnt == 0 {
|
|
rgba.extend_from_slice(&[0, 0, 0, 0]);
|
|
} else {
|
|
let a = 255 * cnt / (ss * ss) as u32;
|
|
rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]);
|
|
}
|
|
}
|
|
}
|
|
png(size, size, rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
|
}
|
|
|
|
/// A Windows icon file holding PNG images (Vista and later read these).
|
|
fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
|
|
let n = u16::try_from(images.len()).map_err(|_| "too many icon sizes")?;
|
|
let mut out = Vec::new();
|
|
out.extend_from_slice(&[0, 0, 1, 0]);
|
|
out.extend_from_slice(&n.to_le_bytes());
|
|
let mut offset = 6 + 16 * images.len();
|
|
for (size, data) in images {
|
|
let dim = if *size >= 256 { 0 } else { *size as u8 };
|
|
out.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
|
|
out.extend_from_slice(&u32::try_from(data.len()).map_err(|_| "icon too large")?.to_le_bytes());
|
|
out.extend_from_slice(&u32::try_from(offset).map_err(|_| "icon too large")?.to_le_bytes());
|
|
offset += data.len();
|
|
}
|
|
for (_, data) in images {
|
|
out.extend_from_slice(data);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn cmd_exec(args: &[String]) -> Result<(), String> {
|
|
let id = args.first().ok_or_else(|| USAGE.to_string())?;
|
|
let p: Value = match args.get(1) {
|
|
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
|
|
None => json!({}),
|
|
};
|
|
let mut s = Session::default();
|
|
let v = s.execute(id, &p).map_err(|e| e.to_string())?;
|
|
println!("{}", pretty(&v));
|
|
Ok(())
|
|
}
|
|
|
|
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
|
|
/// a running app's control port. stdout carries only protocol messages.
|
|
fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
|
let backend: Box<dyn solvecraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
|
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
|
|
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?),
|
|
(None, Some(path)) => Box::new(solvecraft_mcp::Headless::new(load(path)?)),
|
|
(None, None) => Box::new(solvecraft_mcp::Headless::default()),
|
|
};
|
|
let stdin = std::io::stdin();
|
|
let stdout = std::io::stdout();
|
|
solvecraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
|
|
/// parameter `save_round` set to the round number each time.
|
|
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
|
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
|
|
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
|
|
};
|
|
let mut s = Session::default();
|
|
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
|
|
name: format!("p{i}"),
|
|
expr: format!("{i} mm + 0.5 mm"),
|
|
unit: "mm".into(),
|
|
comment: "filler to make the file large".into(),
|
|
model: false,
|
|
});
|
|
s.doc_mut().params.extend(filler);
|
|
for r in 0..rounds {
|
|
s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?;
|
|
s.execute("file.save", &json!({ "path": path })).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `bench-edit <script.json|recipe.json> <param> <expr>`: build the design, then time a
|
|
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
|
|
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
|
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
|
|
return Err("usage: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
|
};
|
|
let v = read_json(path)?;
|
|
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
|
|
let mut s = Session::default();
|
|
let t = std::time::Instant::now();
|
|
s.run_script(&script).map_err(|e| e.to_string())?;
|
|
let build = t.elapsed().as_secs_f64() * 1000.0;
|
|
let old = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| n == param).map(|(_, e, _)| e).ok_or(format!("no parameter `{param}`"))?;
|
|
let mut out = vec![json!({"step": "build", "ms": build, "features": s.doc.features.len()})];
|
|
let mut time = |s: &mut Session, step: &str, id: &str, p: Value| -> Result<(), String> {
|
|
let t = std::time::Instant::now();
|
|
let r = s.execute(id, &p).map_err(|e| format!("{step}: {e}"))?;
|
|
let n = r.get("recomputed").cloned().unwrap_or_else(|| json!(s.model.last_recomputed));
|
|
out.push(json!({"step": step, "ms": t.elapsed().as_secs_f64() * 1000.0, "recomputed": n}));
|
|
Ok(())
|
|
};
|
|
let expr = expr.replace("{old}", &old);
|
|
time(&mut s, "edit", "parameters.change", json!({"name": param, "expression": expr}))?;
|
|
time(&mut s, "set back", "parameters.change", json!({"name": param, "expression": old}))?;
|
|
time(&mut s, "undo", "edit.undo", json!({}))?;
|
|
time(&mut s, "redo", "edit.redo", json!({}))?;
|
|
println!("{}", pretty(&json!(out)));
|
|
Ok(())
|
|
}
|
|
|
|
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
|
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
|
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
|
let t = std::time::Instant::now();
|
|
let step = solvecraft_engine::io::is_step_path(path);
|
|
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
|
|
let bytes = solvecraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
|
|
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
|
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
|
|
let t = std::time::Instant::now();
|
|
let doc = if step {
|
|
let imported = solvecraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
|
let mut doc = solvecraft_engine::doc::Document::new(&imported.name);
|
|
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
|
|
doc
|
|
} else {
|
|
solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
|
|
};
|
|
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
|
|
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
|
|
if args.iter().any(|a| a == "--parse-only") {
|
|
return Ok(());
|
|
}
|
|
let t = std::time::Instant::now();
|
|
let s = Session::new(doc);
|
|
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
|
|
for r in &s.model.results {
|
|
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
|
}
|
|
let t = std::time::Instant::now();
|
|
let st = s.world_state();
|
|
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
|
|
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
|
|
Ok(())
|
|
}
|