Rename SolveCraft to SolidCraft everywhere, with compatibility shims (#389)

* WIP deep rename (not for push)

* WIP2

* WIP3

* WIP4 wxs fix

* rustfmt

* Rename the remaining identifiers in merged crash-report code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-12 17:17:01 -04:00
1 parent 27415e4dd7
commit 296683229d
464 files changed
+3408 -2956

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
[package]
name = "solidcraft-cli"
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "solidcraft-cli"
[lints]
workspace = true
[[bin]]
name = "solidcraft-cli"
path = "src/main.rs"
# Compatibility alias for the CLI's former name: forwards to solidcraft-cli with a deprecation
# note on stderr. Shipped in every package for at least one release.
[[bin]]
name = "solvecraft-cli"
path = "src/alias.rs"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-mcp = { workspace = true }
serde_json = { workspace = true }
+30
View File
@@ -0,0 +1,30 @@
//! `solvecraft-cli`: the CLI's former name, kept for at least one release so existing scripts
//! and MCP client setups (`claude mcp add solvecraft -- solvecraft-cli mcp`) keep working.
//! Compatibility shim: it runs `solidcraft-cli` (next to this executable, else on PATH) with the
//! same arguments and standard streams, after a one-line deprecation note on stderr (stdout
//! stays clean for MCP's JSON-RPC), and exits with its status.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::process::{Command, ExitCode};
const TARGET: &str = "solidcraft-cli";
fn main() -> ExitCode {
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
let sibling = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
.filter(|p| p.is_file());
let program = sibling.map(|p| p.into_os_string()).unwrap_or_else(|| TARGET.into());
match Command::new(&program).args(std::env::args_os().skip(1)).status() {
Ok(status) => match status.code() {
Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
None => ExitCode::FAILURE,
},
Err(e) => {
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
ExitCode::FAILURE
}
}
}
+103
View File
@@ -0,0 +1,103 @@
//! `solidcraft-cli crashes`: the crash reports the desktop app wrote on this computer
//! (docs/crash-reports.md): list them, show one, replay one, delete them.
use std::sync::Mutex;
use serde_json::{Value, json};
use solidcraft_engine::crash;
const USAGE: &str = "usage: solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
pub fn run(args: &[String]) -> Result<(), String> {
let dir = crash::default_dir().ok_or("no crash folder on this system")?;
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
match args.first().map(String::as_str) {
None | Some("list") => {
let reports = crash::list(&dir);
if args.iter().any(|a| a == "--json") {
println!("{}", super::pretty(&json!({ "dir": dir.to_string_lossy(), "reports": reports })));
return Ok(());
}
println!("crash reports in {}", dir.display());
if reports.is_empty() {
println!(" (none)");
}
for r in reports {
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default();
let replay = if r.replayable { " [replayable]" } else { "" };
println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message);
}
Ok(())
}
Some("show") => {
println!("{}", super::pretty(&crash::read(&dir, id()?)?));
Ok(())
}
Some("replay") => replay(&crash::read(&dir, id()?)?),
Some("dir") => {
println!("{}", dir.display());
Ok(())
}
Some("clear") => {
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
Ok(())
}
_ => Err(USAGE.to_string()),
}
}
/// Panics seen while replaying: (location, message).
static PANICS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
/// Run a report's commands on its design and say whether the crash happens again.
fn replay(report: &Value) -> Result<(), String> {
let id = report["id"].as_str().unwrap_or_default();
let replay_path = report["replay_path"].as_str().ok_or_else(|| format!("report {id} cannot be replayed (see its notes)"))?;
let script = super::read_json(replay_path)?;
let commands = script["commands"].as_array().cloned().unwrap_or_default();
let mut s = match report["design_path"].as_str() {
Some(d) => super::load(d)?,
None => solidcraft_engine::Session::default(),
};
std::panic::set_hook(Box::new(|info| {
let loc = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
let msg = info.payload_as_str().unwrap_or("(not a text message)").to_string();
if let Ok(mut p) = PANICS.lock() {
p.push((loc, msg));
}
}));
if report["kind"] == "unclean_exit" {
println!("this crash ended the app without a report: if this process ends abruptly, it reproduced");
}
println!(
"replaying {} command(s) on {}",
commands.len(),
report["design_path"].as_str().map_or_else(|| "a new design".to_string(), |d| d.to_string())
);
for (i, c) in commands.iter().enumerate() {
let cmd = c["command"].as_str().unwrap_or_default();
let r = s.execute(cmd, c.get("params").unwrap_or(&Value::Null));
match r {
Ok(_) => println!(" {:>3}. {cmd}: ok", i + 1),
Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1),
}
}
let _ = std::panic::take_hook();
let seen = PANICS.lock().map(|p| p.clone()).unwrap_or_default();
let want = report["signature"].as_str().unwrap_or_default();
let same = seen.iter().any(|(l, m)| format!("{:016x}", crash::signature(l, m)) == want);
if same {
println!(
"reproduced: the same internal error ({}) at {}",
report["message"].as_str().unwrap_or_default(),
report["location"].as_str().unwrap_or_default()
);
} else if let Some((l, m)) = seen.first() {
println!("an internal error happened, but not the reported one: {m} at {l}");
println!("(the report says: {} at {})", report["message"].as_str().unwrap_or_default(), report["location"].as_str().unwrap_or_default());
} else {
println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)");
}
Ok(())
}
+263
View File
@@ -0,0 +1,263 @@
//! `solidcraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//!
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
//! is recorded like any other failure.
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use solidcraft_engine::fuzz;
use solidcraft_engine::fuzz::boolean_case;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
}
/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr).
pub fn one(args: &[String]) -> Result<(), String> {
let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one <seed> [--steps N] [--minimise]")?;
let steps = flag(args, "--steps", 8usize);
let minimise = args.iter().any(|a| a == "--minimise");
// Panics caught inside the kernel: harmless natively, fatal in the web build.
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
std::panic::set_hook(Box::new(|_| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}));
eprintln!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
let mut design = json!({
"outcome": run.outcome,
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
"unsupported": run.unsupported,
"rejected": run.rejected,
"boolean_fallbacks": run.boolean_fallbacks,
"boolean_reports_truncated": run.boolean_reports_truncated,
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
});
if run.outcome.is_bug() {
design["script"] = run.script();
if minimise {
eprintln!("minimise");
let m = fuzz::minimise(&run);
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
}
}
let (a, b) = solidcraft_engine::fuzz::boolean_shapes(seed);
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
let b = boolean_case(seed);
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
Ok(())
}
struct Job {
seed: u64,
child: std::process::Child,
started: Instant,
err: std::sync::mpsc::Receiver<String>,
last: String,
}
/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each;
/// write failing scripts to `--out` and a summary to `--report`.
pub fn run(args: &[String]) -> Result<(), String> {
let from = flag(args, "--from", 0u64);
let count = flag(args, "--count", 100u64);
let last = from.checked_add(count.saturating_sub(1)).ok_or("seed range exceeds u64::MAX: reduce --from or --count")?;
let mut seeds = (count > 0).then_some(from..=last).into_iter().flatten().peekable();
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
let steps = flag(args, "--steps", 8usize);
let out_dir = args.iter().position(|a| a == "--out").and_then(|i| args.get(i + 1)).cloned();
let report = args.iter().position(|a| a == "--report").and_then(|i| args.get(i + 1)).cloned();
let minimise = args.iter().any(|a| a == "--minimise");
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
if let Some(d) = &out_dir {
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
}
let mut running: Vec<Job> = Vec::new();
let mut results: Vec<Value> = Vec::new();
let t0 = Instant::now();
while seeds.peek().is_some() || !running.is_empty() {
while running.len() < jobs {
let Some(seed) = seeds.next() else { break };
let mut cmd = Command::new(&exe);
cmd.args(["fuzz-one", &seed.to_string(), "--steps", &steps.to_string()]);
if minimise {
cmd.arg("--minimise");
}
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
let (tx, rx) = std::sync::mpsc::channel();
if let Some(err) = child.stderr.take() {
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
}
running.push(Job { seed, child, started: Instant::now(), err: rx, last: String::new() });
}
std::thread::sleep(Duration::from_millis(50));
let mut i = 0;
while i < running.len() {
let Some(j) = running.get_mut(i) else { break };
while let Ok(l) = j.err.try_recv() {
if l.starts_with("step") || l.starts_with("boolean") || l.starts_with("minimise") {
j.last = l;
}
}
let done = match j.child.try_wait() {
Ok(Some(status)) => {
let mut s = String::new();
if let Some(mut o) = j.child.stdout.take() {
let _ = o.read_to_string(&mut s);
}
Some(match serde_json::from_str::<Value>(s.trim()) {
Ok(v) => v,
Err(_) => json!({"seed": j.seed, "crash": format!("exit {status}"), "at": j.last}),
})
}
Ok(None) if j.started.elapsed() > timeout => {
let _ = j.child.kill();
let _ = j.child.wait();
Some(json!({"seed": j.seed, "timeout": timeout.as_secs(), "at": j.last}))
}
Ok(None) => None,
Err(e) => Some(json!({"seed": j.seed, "crash": e.to_string(), "at": j.last})),
};
match done {
Some(v) => {
record(&v, out_dir.as_deref());
results.push(v);
running.swap_remove(i);
}
None => i += 1,
}
}
}
let summary = summarise(&results, t0.elapsed());
println!("{summary}");
if let Some(r) = report {
std::fs::write(&r, &summary).map_err(|e| format!("{r}: {e}"))?;
}
Ok(())
}
/// Print a failure as it comes in and keep its script.
fn record(v: &Value, out_dir: Option<&str>) {
let seed = v["seed"].as_u64().unwrap_or(0);
let d = &v["design"];
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
}
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
if bk != "ok" {
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
}
if let Some(dir) = out_dir {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(format!("{dir}/results.jsonl")) {
let _ = writeln!(f, "{v}");
}
if bug {
let script = if d.get("minimal").is_some() { &d["minimal"]["script"] } else { &d["script"] };
let _ = std::fs::write(format!("{dir}/design-{seed}.json"), serde_json::to_string_pretty(script).unwrap_or_default());
}
if bk != "ok" {
let _ = std::fs::write(format!("{dir}/boolean-{seed}.json"), serde_json::to_string_pretty(&v["boolean"]).unwrap_or_default());
}
}
}
fn summarise(results: &[Value], took: Duration) -> String {
let n = results.len().max(1);
let mut design: BTreeMap<String, usize> = BTreeMap::new();
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
let mut fallbacks: BTreeMap<String, [u64; 2]> = BTreeMap::new();
let mut truncated = [0usize; 2];
let mut steps = 0;
let (mut dp, mut bp) = (0, 0);
for v in results {
for (i, group) in ["design", "boolean"].into_iter().enumerate() {
if v[group]["boolean_reports_truncated"].as_bool().unwrap_or(false) {
truncated[i] += 1;
}
if let Some(counts) = v[group]["boolean_fallbacks"].as_object() {
for (strategy, count) in counts {
if let Some(total) = fallbacks.entry(strategy.clone()).or_default().get_mut(i) {
*total = total.saturating_add(count.as_u64().unwrap_or(0));
}
}
}
}
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
let k = if v.get("timeout").is_some() {
"timeout".to_string()
} else if v.get("crash").is_some() {
"crash".to_string()
} else {
v["design"]["outcome"]["kind"].as_str().unwrap_or("?").to_string()
};
if k != "ok" {
let last = v["design"]["steps"].as_array().and_then(|a| a.last()).and_then(Value::as_str).unwrap_or("?");
*at_step.entry(format!("{k} at {last}")).or_default() += 1;
}
*design.entry(k).or_default() += 1;
*boolean.entry(v["boolean"]["outcome"].as_str().unwrap_or("not run").to_string()).or_default() += 1;
steps += v["design"]["steps"].as_array().map(Vec::len).unwrap_or(0);
for u in v["design"]["unsupported"].as_array().into_iter().flatten() {
*unsupported.entry(u[0].as_str().unwrap_or("?").to_string()).or_default() += 1;
}
}
let pct = |c: usize| format!("{c} ({:.1} %)", 100.0 * c as f64 / n as f64);
let mut s = format!("{} seeds, {steps} design steps, {:.0} s\n\nDesigns:\n", results.len(), took.as_secs_f64());
for (k, c) in &design {
s += &format!("- {k}: {}\n", pct(*c));
}
s += "\nFailures by step:\n";
for (k, c) in &at_step {
s += &format!("- {k}: {c}\n");
}
s += "\nSteps refused as not supported (undone, the design went on):\n";
for (k, c) in &unsupported {
s += &format!("- {k}: {c}\n");
}
s += &format!("\nCaught panics (fatal in the web build): designs {}, boolean checks {}\n", pct(dp), pct(bp));
s += "\nBoolean identity (union, intersection, cut of two primitives):\n";
for (k, c) in &boolean {
s += &format!("- {k}: {}\n", pct(*c));
}
s += "\nSuccessful Boolean retries (events, including nested operations):\n";
for (strategy, [designs, checks]) in &fallbacks {
s += &format!("- {strategy}: designs {designs}, boolean checks {checks}\n");
}
s += &format!("- Capped reports: designs {}, boolean checks {}\n", truncated[0], truncated[1]);
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_counts_retry_events_separately_and_marks_incomplete_logs() {
let results = [
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":2}},"boolean":{"outcome":"ok","boolean_fallbacks":{"shifted_operands":3},"boolean_reports_truncated":true}}),
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":1}},"boolean":{"outcome":"ok"}}),
json!({"timeout":90}),
];
let text = summarise(&results, Duration::ZERO);
assert!(text.contains("shifted_operands: designs 3, boolean checks 3"), "{text}");
assert!(text.contains("Capped reports: designs 0, boolean checks 1"), "{text}");
assert!(text.contains("timeout: 1 (33.3 %)"), "{text}");
}
}
+393
View File
@@ -0,0 +1,393 @@
//! SolidCraft headless command line.
//!
//! ```text
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] loading timings as JSON lines
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
//! solidcraft-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 crashes;
mod fuzz;
mod oracle;
mod recipe;
mod spatial;
use std::process::ExitCode;
use serde_json::{Value, json};
#[path = "../../app_icon.rs"]
mod app_icon;
use solidcraft_engine::Session;
use solidcraft_engine::render::{StandardView, render_png};
const USAGE: &str = "usage:
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect; or --in FILE)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
solidcraft-cli recipe <recipe.json>
solidcraft-cli oracle <case-dir>... [--json]
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT]
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
solidcraft-cli licences
solidcraft-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("fuzz") => fuzz::run(&args[1..]),
Some("fuzz-one") => fuzz::one(&args[1..]),
Some("mcp") => cmd_mcp(&args[1..]),
Some("crashes") => crashes::run(&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(solidcraft_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!("solidcraft-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 = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_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 solidcraft_engine::io::is_step_path(path) || solidcraft_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| solidcraft_engine::doc::format::version_of(f).is_some()) {
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 = solidcraft_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 = solidcraft_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(())
}
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
solidcraft_engine::render::png(size, size, app_icon::rgba(size)?).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 solidcraft_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(solidcraft_mcp::Remote::connect(addr).map_err(|e| {
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
format!(
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
`solidcraft --control {port}` (or SOLIDCRAFT_CONTROL_PORT={port}) and retry"
)
})?),
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
solidcraft_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: solidcraft-cli save-stress <path> <rounds>".into());
};
let mut s = Session::default();
let filler = (0..3000).map(|i| solidcraft_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: solidcraft-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: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
let t = std::time::Instant::now();
let step = solidcraft_engine::io::is_step_path(path);
let limit = if step { solidcraft_engine::io::MAX_STEP_BYTES } else { solidcraft_engine::io::MAX_DESIGN_BYTES };
let bytes = solidcraft_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 = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
doc
} else {
solidcraft_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(())
}
+263
View File
@@ -0,0 +1,263 @@
//! Reference tests: replay `tests/oracle/<case>/recipe.json` through the command engine and
//! compare the part with `expected.json` (closed-form values, or values from an independent
//! kernel; see tests/oracle/README.md), and with `reference.stl` where one exists.
#![allow(clippy::needless_range_loop)]
use serde_json::{Value, json};
use solidcraft_engine::geom::Vec3;
use crate::spatial::{self, Tri};
use crate::{read_json, recipe};
/// Default relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
pub const REL_TOL: f64 = 1e-3;
/// Default tolerance for centre of mass and bounding box, as a fraction of the bbox diagonal.
const ABS_FRAC: f64 = 1e-3;
/// Chord tolerance used to triangulate our part for the surface-distance check (mm).
const MESH_TOL: f64 = 0.01;
/// Relative tolerance on the principal second moments of the two triangulations.
const INERTIA_REL: f64 = 2e-3;
/// What SolidCraft built: totals over every body.
struct Got {
body_count: usize,
volume: f64,
area: f64,
com: [f64; 3],
bbox: ([f64; 3], [f64; 3]),
faces: u64,
}
fn v3(v: &Value) -> Option<[f64; 3]> {
let a = v.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
}
fn totals(m: &Value) -> Got {
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
let (mut volume, mut area, mut faces) = (0.0, 0.0, 0);
let mut moment = [0.0; 3];
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for b in &bodies {
let v = b["volume_mm3"].as_f64().unwrap_or(f64::NAN);
volume += v;
area += b["area_mm2"].as_f64().unwrap_or(f64::NAN);
faces += b["faces"].as_u64().unwrap_or(0);
let c = v3(&b["center_of_mass"]).unwrap_or([f64::NAN; 3]);
let (bl, bh) = (v3(&b["bbox"]["min"]).unwrap_or([f64::NAN; 3]), v3(&b["bbox"]["max"]).unwrap_or([f64::NAN; 3]));
for k in 0..3 {
moment[k] += v * c[k];
lo[k] = lo[k].min(bl[k]);
hi[k] = hi[k].max(bh[k]);
}
}
let com = moment.map(|x| x / volume);
Got { body_count: bodies.len(), volume, area, com, bbox: (lo, hi), faces }
}
struct Checks {
list: Vec<Value>,
pass: bool,
}
impl Checks {
fn push(&mut self, what: &str, got: Value, want: Value, ok: bool) {
self.pass &= ok;
self.list.push(json!({"check": what, "got": got, "want": want, "ok": ok}));
}
fn rel(&mut self, what: &str, g: f64, w: Option<f64>, rel: f64) {
let ok = w.is_some_and(|w| g.is_finite() && (g - w).abs() <= rel * w.abs().max(1.0));
self.push(what, json!(g), json!(w), ok);
}
fn vec(&mut self, what: &str, g: [f64; 3], w: Option<[f64; 3]>, tol: f64) {
let ok = w.is_some_and(|w| g.iter().zip(w).all(|(g, w)| g.is_finite() && (g - w).abs() <= tol));
self.push(what, json!(g), json!(w), ok);
}
}
fn check_case(dir: &str) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let recipe = match read_json(&format!("{dir}/recipe.json")) {
Ok(r) => r,
Err(e) => return json!({"case": name, "pass": false, "error": e}),
};
let want = read_json(&format!("{dir}/expected.json")).ok();
let script = match recipe::to_script(&recipe) {
Ok(s) => s,
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
};
let mut s = solidcraft_engine::Session::default();
if let Err(e) = s.run_script(&script) {
return json!({"case": name, "pass": false, "error": e.to_string(), "source": want.as_ref().map(|w| w["source"].clone())});
}
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => totals(&m),
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let got_json = json!({
"body_count": got.body_count, "volume_mm3": got.volume, "area_mm2": got.area,
"center_of_mass": got.com, "bbox": {"min": got.bbox.0, "max": got.bbox.1}, "faces": got.faces,
});
let Some(want) = want else {
return json!({"case": name, "pass": false, "skipped": true, "error": "no expected.json yet", "got": got_json});
};
let only: Option<Vec<String>> = want["compare"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());
let wanted = |k: &str| want.get(k).is_some_and(|v| !v.is_null()) && only.as_ref().is_none_or(|o| o.iter().any(|x| x == k));
let rel = want["tolerance"]["rel"].as_f64().unwrap_or(REL_TOL);
let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt();
let want_bbox = v3(&want["bbox"]["min"]).zip(v3(&want["bbox"]["max"]));
let abs = want["tolerance"]["abs_mm"].as_f64().unwrap_or_else(|| ABS_FRAC * want_bbox.map(diag).unwrap_or_else(|| diag(got.bbox)).max(1.0));
let mut c = Checks { list: Vec::new(), pass: true };
if wanted("body_count") {
let ok = want["body_count"].as_u64() == Some(got.body_count as u64);
c.push("body_count", json!(got.body_count), want["body_count"].clone(), ok);
}
if wanted("volume_mm3") {
c.rel("volume_mm3", got.volume, want["volume_mm3"].as_f64(), rel);
}
if wanted("area_mm2") {
c.rel("area_mm2", got.area, want["area_mm2"].as_f64(), rel);
}
if wanted("center_of_mass") {
c.vec("center_of_mass", got.com, v3(&want["center_of_mass"]), abs);
}
if wanted("bbox") {
c.vec("bbox.min", got.bbox.0, want_bbox.map(|b| b.0), abs);
c.vec("bbox.max", got.bbox.1, want_bbox.map(|b| b.1), abs);
}
if wanted("faces") {
let ok = want["faces"].as_u64() == Some(got.faces);
c.push("faces", json!(got.faces), want["faces"].clone(), ok);
}
let stl = format!("{dir}/reference.stl");
if std::path::Path::new(&stl).is_file() && only.as_ref().is_none_or(|o| o.iter().any(|x| x == "surface")) {
let tol = want["tolerance"]["surface_mm"].as_f64().unwrap_or(0.05);
reference_checks(&s, &stl, tol, &mut c);
}
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
// same volume.
let (ok, note) = step_export_check(&s, got.volume);
c.push("step_export", json!(note), json!("valid, re-imports cleanly"), ok);
let mut out = json!({"case": name, "pass": c.pass, "source": want["source"], "checks": c.list, "got": got_json});
if let Some(n) = want["note"].as_str() {
out["note"] = json!(n);
}
out
}
/// Export the session's bodies as STEP, validate the file and import it again.
fn step_export_check(s: &solidcraft_engine::Session, volume: f64) -> (bool, String) {
let st = s.model.state();
let bytes = match solidcraft_engine::io::export(&st, &[], solidcraft_engine::io::Format::Step, "oracle") {
Ok(b) => b,
Err(e) => return (false, format!("export: {e}")),
};
let text = String::from_utf8_lossy(&bytes);
if let Err(e) = solidcraft_engine::kernel::step_validate(&text) {
return (false, format!("invalid: {e}"));
}
match solidcraft_engine::kernel::step_orientation_errors(&text) {
Ok(errs) if !errs.is_empty() => return (false, format!("faces inside out: {}", errs.join("; "))),
Err(e) => return (false, format!("orientation check: {e}")),
Ok(_) => {}
}
match solidcraft_engine::kernel::step_import(&text) {
Ok(imp) if !imp.warnings.is_empty() => (false, format!("warnings: {}", imp.warnings.join("; "))),
Ok(imp) if imp.bodies.len() != st.bodies.len() => (false, format!("{} bodies back of {}", imp.bodies.len(), st.bodies.len())),
Ok(imp) => {
let m: Vec<_> = imp.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
let m0: Vec<_> = st.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
let v: f64 = m.iter().map(|x| x.volume).sum();
let (a, a0): (f64, f64) = (m.iter().map(|x| x.area).sum(), m0.iter().map(|x| x.area).sum());
let faces: usize = imp.bodies.iter().map(|b| b.file_faces).sum();
let faces0: usize = m0.iter().map(|x| x.merged.faces).sum();
let close = |x: f64, y: f64| (x - y).abs() <= REL_TOL * y.abs().max(1.0);
let faces_raw: usize = st.bodies.iter().map(|b| b.body.face_count()).sum();
// (Fewer is fine: pieces of one plane, cylinder, cone, sphere or torus are written as one face.)
let ok = close(v, volume) && close(a, a0) && faces <= faces_raw;
(ok, format!("re-imported volume {v:.3} (was {volume:.3}), area {a:.3} (was {a0:.3}), faces {faces} (was {faces0})"))
}
Err(e) => (false, format!("import: {e}")),
}
}
/// Our part as a triangle soup (curved faces refined onto the surface).
fn our_mesh(s: &solidcraft_engine::Session) -> Result<Vec<Tri>, String> {
let mut ours: Vec<Tri> = Vec::new();
for b in &s.world_state().bodies {
let m = b.body.tessellate_with(MESH_TOL, true).map_err(|e| e.to_string())?;
let p = |i: u32| m.positions.get(i as usize).map(|v: &Vec3| [v.x, v.y, v.z]);
for t in &m.triangles {
if let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) {
ours.push([a, b, c]);
}
}
}
if ours.is_empty() { Err("empty mesh".into()) } else { Ok(ours) }
}
/// Compare our part with the reference triangulation: the symmetric surface distance, and the
/// principal second moments of volume of both meshes (the shape's inertia, independent of how it
/// sits in space).
fn reference_checks(s: &solidcraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) {
let (ours, reference) = match (our_mesh(s), spatial::read_stl(stl)) {
(Ok(o), Ok(r)) if !r.is_empty() => (o, r),
(Err(e), _) | (_, Err(e)) => return c.push("surface_distance_mm", json!(e), json!(format!("<= {tol}")), false),
_ => return c.push("surface_distance_mm", json!("empty reference.stl"), json!(format!("<= {tol}")), false),
};
let (lo, hi) = spatial::bounds(&reference);
let size = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
let h = spatial::hausdorff(&ours, &reference, size.max(1.0));
let worst = h.a_to_b.max(h.b_to_a);
c.push(
"surface_distance_mm",
json!({"max": worst, "mean": h.mean, "ours_to_reference": h.a_to_b, "reference_to_ours": h.b_to_a, "worst_at": h.worst_at}),
json!(format!("<= {tol}")),
worst <= tol,
);
let ((_, _, pa), (_, _, pb)) = (spatial::mass_properties(&ours), spatial::mass_properties(&reference));
let ok = pa.iter().zip(pb).all(|(a, b)| (a - b).abs() <= INERTIA_REL * b.abs().max(1.0));
c.push("principal_moments_mm5", json!(pa), json!(pb), ok);
}
fn report(args: &[String], results: &[Value]) {
if args.iter().any(|a| a == "--json") {
println!("{}", serde_json::to_string_pretty(&Value::Array(results.to_vec())).unwrap_or_default());
return;
}
for r in results {
let status = if r["pass"].as_bool().unwrap_or(false) {
"PASS"
} else if r["skipped"].as_bool().unwrap_or(false) {
"SKIP"
} else {
"FAIL"
};
let detail: Vec<String> = r["checks"]
.as_array()
.into_iter()
.flatten()
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
.collect();
let err = r["error"].as_str().map(|e| format!(" ({e})")).unwrap_or_default();
println!(
"{status} {}{err}{}",
r["case"].as_str().unwrap_or(""),
if detail.is_empty() { String::new() } else { format!(": {}", detail.join("; ")) }
);
}
}
pub fn run(args: &[String]) -> Result<(), String> {
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if dirs.is_empty() {
return Err("oracle: give one or more case directories (each with recipe.json and expected.json)".into());
}
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
report(args, &results);
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false) && !r["skipped"].as_bool().unwrap_or(false)).count();
if failed > 0 { Err(format!("{failed} oracle case(s) failed")) } else { Ok(()) }
}
+560
View File
@@ -0,0 +1,560 @@
//! Reference-test recipes (`tests/oracle/*/recipe.json`) → SolidCraft command scripts.
//!
//! A recipe lists features with sketch entities in sketch-local coordinates; point references
//! (`l1.end`) share points. Each feature becomes one or more commands.
use serde_json::{Value, json};
fn plane_param(pl: &Value) -> Value {
let r = pl.get("ref").and_then(Value::as_str).unwrap_or("");
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
let std = match r.to_ascii_uppercase().as_str() {
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, -1.0])),
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
_ => None,
};
let axes_match = |k: &str, want: [f64; 3]| {
pl.get(k).and_then(Value::as_array).is_some_and(|a| a.iter().zip(want).all(|(x, w)| (x.as_f64().unwrap_or(f64::NAN) - w).abs() < 1e-9))
};
match std {
Some((x, y)) if origin_zero && axes_match("x_dir", x) && axes_match("y_dir", y) => json!(r.to_ascii_uppercase()),
_ => json!({"origin": pl.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "x_dir": pl.get("x_dir"), "y_dir": pl.get("y_dir")}),
}
}
/// A point argument: the shared reference if any, else the coordinates.
fn parg(p: Option<&Value>) -> Value {
match p {
Some(v) => match (v.get("ref"), v.get("at")) {
(Some(Value::String(r)), _) => json!(r),
(_, Some(at)) => at.clone(),
_ => v.clone(),
},
None => Value::Null,
}
}
fn op(f: &Value) -> Value {
f.get("operation").cloned().unwrap_or(json!("new"))
}
fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
let mut p = json!({"plane": plane_param(f.get("plane").unwrap_or(&Value::Null))});
if let Some(n) = f.get("name") {
p["name"] = n.clone();
}
out.push(json!({"command": "sketch.create", "params": p}));
for e in f.get("entities").and_then(Value::as_array).into_iter().flatten() {
let id = e.get("id").cloned().unwrap_or(Value::Null);
let construction = e.get("construction").cloned().unwrap_or(json!(false));
match e.get("type").and_then(Value::as_str) {
Some("line") => out.push(json!({"command": "sketch.line", "params": {"points": [parg(e.get("start")), parg(e.get("end"))], "ids": [id], "construction": construction}})),
Some("circle") => out.push(json!({"command": "sketch.circle.center", "params": {"center": parg(e.get("center")), "radius": e.get("radius"), "id": id, "construction": construction}})),
Some("arc_three_point") => out.push(json!({"command": "sketch.arc.three_point", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": e.get("mid").or(e.get("through")), "id": id}})),
Some("arc_center_start_sweep") => out.push(json!({"command": "sketch.arc.center_point", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "sweep": e.get("sweep_deg").or(e.get("sweep")), "id": id}})),
Some("arc") => {
if e.get("center").is_some() {
out.push(json!({"command": "sketch.arc.center_point", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "end": parg(e.get("end")), "id": id}}))
} else {
out.push(json!({"command": "sketch.arc.three_point", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": parg(e.get("through").or(e.get("mid"))), "id": id}}))
}
}
Some("point") => out.push(json!({"command": "sketch.point", "params": {"point": parg(e.get("at").map(|a| json!({"at": a})).as_ref().or(Some(e))), "id": id}})),
// A 3D helix fit: the sweep along it becomes a coil (see `helix_path`).
Some("fitted_spline") if e.get("fit_points").and_then(Value::as_array).is_some_and(|a| a.iter().any(|p| p.get(2).is_some())) => {}
Some(other) => return Err(format!("recipe: sketch entity type `{other}` is not supported yet")),
None => return Err("recipe: sketch entity without type".into()),
}
}
for c in f.get("constraints").and_then(Value::as_array).into_iter().flatten() {
out.push(constraint_cmd(c)?);
}
for d in f.get("dimensions").and_then(Value::as_array).into_iter().flatten() {
let mut p = json!({"entities": d.get("refs").or(d.get("entities")), "type": d.get("type"), "value": d.get("value")});
for k in ["orientation", "text_at"] {
if let Some(v) = d.get(k) {
p[k] = v.clone();
}
}
out.push(json!({"command": "sketch.dimension", "params": p}));
}
out.push(json!({"command": "sketch.finish", "params": {}}));
Ok(())
}
/// Extent parameters for the Extrude command.
fn extent(f: &Value, p: &mut Value) {
let e = f.get("extent").cloned().unwrap_or(Value::Null);
let ty = e.get("type").and_then(Value::as_str).unwrap_or("distance");
let dist = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null);
match ty {
"symmetric" => {
let full = e.get("is_full_length").and_then(Value::as_bool).unwrap_or(false);
p["direction"] = json!("symmetric");
p["distance"] = if full { json!(format!("({}) / 2", expr_text(&dist))) } else { dist };
}
"two_sides" => {
p["distance"] = e.get("side1").cloned().unwrap_or(Value::Null);
p["distance2"] = e.get("side2_opposite").or(e.get("side2")).cloned().unwrap_or(Value::Null);
}
"through_all" => {
p["through_all"] = json!(true);
p["direction"] = e.get("direction").cloned().unwrap_or(json!("positive"));
}
_ => {
p["distance"] = dist;
p["direction"] = e.get("direction").cloned().unwrap_or(json!("positive"));
}
}
if let Some(t) = e.get("taper_deg") {
p["taper"] = t.clone();
}
}
fn expr_text(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn refs(c: &Value) -> Vec<Value> {
c.get("refs").and_then(Value::as_array).cloned().unwrap_or_default()
}
/// A recipe constraint `{type, refs}` → a constraint command.
fn constraint_cmd(c: &Value) -> Result<Value, String> {
let ty = c.get("type").and_then(Value::as_str).unwrap_or("").to_ascii_lowercase();
let r = refs(c);
let at = |i: usize| r.get(i).cloned().unwrap_or(Value::Null);
let is_point = |v: &Value| v.as_str().is_some_and(|s| s.contains('.') || s == "origin" || s.starts_with('p'));
let (cmd, p) = match ty.as_str() {
"horizontal" | "vertical" if r.len() >= 2 => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": ty})),
"horizontal" | "vertical" => ("sketch.constraint.horizontal_vertical", json!({"line": at(0), "mode": ty})),
"horizontal_points" => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": "horizontal"})),
"vertical_points" => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": "vertical"})),
"coincident" | "point_on_curve" => {
// The point goes first.
if is_point(&at(0)) {
("sketch.constraint.coincident", json!({"a": at(0), "b": at(1)}))
} else {
("sketch.constraint.coincident", json!({"a": at(1), "b": at(0)}))
}
}
"midpoint" => {
if is_point(&at(0)) {
("sketch.constraint.midpoint", json!({"point": at(0), "line": at(1)}))
} else {
("sketch.constraint.midpoint", json!({"point": at(1), "line": at(0)}))
}
}
"symmetric" | "symmetry" => ("sketch.constraint.symmetry", json!({"a": at(0), "b": at(1), "line": at(2)})),
"fix" => ("sketch.constraint.fix", json!({"entity": at(0), "fixed": true})),
"tangent" => ("sketch.constraint.tangent", json!({"a": at(0), "b": at(1)})),
"equal" => ("sketch.constraint.equal", json!({"a": at(0), "b": at(1)})),
"parallel" => ("sketch.constraint.parallel", json!({"a": at(0), "b": at(1)})),
"perpendicular" => ("sketch.constraint.perpendicular", json!({"a": at(0), "b": at(1)})),
"concentric" => ("sketch.constraint.concentric", json!({"a": at(0), "b": at(1)})),
"collinear" => ("sketch.constraint.collinear", json!({"a": at(0), "b": at(1)})),
other => return Err(format!("recipe: constraint `{other}` is not supported yet")),
};
Ok(json!({"command": cmd, "params": p}))
}
/// The profiles a feature uses (`profiles` list or a single `profile`).
fn profiles_of(f: &Value) -> Value {
match (f.get("profiles"), f.get("profile")) {
(Some(p), _) => p.clone(),
(None, Some(p)) => json!([p]),
_ => json!("all"),
}
}
/// The sketch a feature uses (name), from its profiles.
fn profile_sketch(f: &Value) -> Value {
profiles_of(f)
.as_array()
.and_then(|a| a.first())
.and_then(|p| p.get("sketch"))
.cloned()
.or_else(|| f.get("sketch").cloned())
.unwrap_or(Value::Null)
}
fn sketch_entity<'a>(features: &'a [Value], sketch: &Value, id: &Value) -> Option<&'a Value> {
features
.iter()
.find(|x| x.get("op").and_then(Value::as_str) == Some("sketch") && x.get("name") == Some(sketch))?
.get("entities")?
.as_array()?
.iter()
.find(|e| e.get("id") == Some(id))
}
/// A sweep of a circle along a fitted 3D spline that is a helix about a Z-parallel axis:
/// (base, diameter, pitch, turns, start angle, clockwise, section size).
#[allow(clippy::type_complexity)]
fn helix_path(features: &[Value], f: &Value) -> Option<([f64; 3], f64, f64, f64, f64, bool, f64)> {
let path = f.get("path")?;
let curve = sketch_entity(features, path.get("sketch")?, path.get("curves")?.as_array()?.first()?)?;
if curve.get("type")?.as_str()? != "fitted_spline" {
return None;
}
let pts: Vec<[f64; 3]> = curve
.get("fit_points")?
.as_array()?
.iter()
.filter_map(|p| {
let a = p.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
})
.collect();
if pts.len() < 8 {
return None;
}
let n = pts.len() as f64;
let (cx, cy) = circle_fit(&pts)?;
let r: Vec<f64> = pts.iter().map(|p| (p[0] - cx).hypot(p[1] - cy)).collect();
let rm = r.iter().sum::<f64>() / n;
if rm <= 0.0 || r.iter().any(|x| (x - rm).abs() > 1e-3 * rm) {
return None;
}
// Unwrapped angle and height: both linear along a helix.
let mut total = 0.0;
let mut prev = (pts[0][1] - cy).atan2(pts[0][0] - cx);
let start = prev;
for p in pts.iter().skip(1) {
let a = (p[1] - cy).atan2(p[0] - cx);
let mut d = a - prev;
while d > std::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d < -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
total += d;
prev = a;
}
let turns = total.abs() / std::f64::consts::TAU;
let rise = pts.last()?[2] - pts[0][2];
if turns < 1e-6 || rise.abs() < 1e-9 {
return None;
}
// The section: a circle in the profile sketch.
let prof = f.get("profile").or_else(|| f.get("profiles").and_then(|x| x.get(0)))?;
let loop0 = prof.get("loops")?.get(0)?.get("curves")?.get(0)?;
let circle = sketch_entity(features, prof.get("sketch")?, loop0)?;
let size = 2.0 * circle.get("radius")?.as_f64()?;
Some(([cx, cy, pts[0][2]], 2.0 * rm, rise.abs() / turns, turns, start, (total < 0.0) != (rise < 0.0), size))
}
/// Least-squares circle through the points' (x, y): x² + y² + D x + E y + F = 0 (Kåsa).
#[allow(clippy::needless_range_loop)]
fn circle_fit(pts: &[[f64; 3]]) -> Option<(f64, f64)> {
let mut m = [[0.0f64; 4]; 3];
for p in pts {
let row = [p[0], p[1], 1.0];
let rhs = -(p[0] * p[0] + p[1] * p[1]);
for i in 0..3 {
for j in 0..3 {
m[i][j] += row[i] * row[j];
}
m[i][3] += row[i] * rhs;
}
}
// Gauss-Jordan on the 3x4 normal equations.
for c in 0..3 {
let piv = (c..3).max_by(|a, b| m[*a][c].abs().total_cmp(&m[*b][c].abs()))?;
m.swap(c, piv);
if m[c][c].abs() < 1e-12 {
return None;
}
for r in 0..3 {
if r != c {
let f = m[r][c] / m[c][c];
for k in c..4 {
m[r][k] -= f * m[c][k];
}
}
}
}
let (d, e) = (m[0][3] / m[0][0], m[1][3] / m[1][1]);
Some((-d / 2.0, -e / 2.0))
}
pub fn to_script(recipe: &Value) -> Result<Value, String> {
let mut out: Vec<Value> = Vec::new();
for p in recipe.get("parameters").and_then(Value::as_array).into_iter().flatten() {
out.push(json!({"command": "parameters.change", "params": {"name": p.get("name"), "expression": p.get("expression").or(p.get("value")), "unit": p.get("unit").cloned().unwrap_or(json!("mm"))}}));
}
let features = recipe.get("features").and_then(Value::as_array).ok_or("recipe: missing `features`")?;
for f in features {
let name = f.get("name").cloned().unwrap_or(Value::Null);
let bodies = f.get("result_bodies").cloned().unwrap_or(json!([]));
match f.get("op").and_then(Value::as_str) {
Some("sketch") => sketch_cmds(f, &mut out)?,
Some("extrude") => {
let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies});
extent(f, &mut p);
out.push(json!({"command": "solid.extrude", "params": p}));
}
Some("combine") => out.push(json!({"command": "solid.combine", "params": {
"target": f.get("target_body").or(f.get("target")), "tools": f.get("tool_bodies").or(f.get("tools")),
"operation": op(f), "keep_tools": f.get("keep_tools").cloned().unwrap_or(json!(false)), "name": name}})),
Some("revolve") => {
let axis = match f.get("axis") {
Some(a) if a.get("dir").is_some() => json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")}),
Some(a) => a.get("line").or(a.get("ref")).cloned().unwrap_or_else(|| a.clone()),
None => Value::Null,
};
out.push(json!({"command": "solid.revolve", "params": {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "axis": axis,
"angle": f.get("angle").cloned().unwrap_or(json!("360 deg")), "operation": op(f), "name": name, "body_names": bodies}}));
}
Some("rectangular_pattern") => {
let d1 = f.get("direction1").cloned().unwrap_or(Value::Null);
let d2 = f.get("direction2").cloned().unwrap_or(Value::Null);
let mut p = json!({"features": feature_refs(f.get("features")), "dir1": d1.get("axis"), "count1": d1.get("count"), "spacing1": d1.get("spacing"), "name": name});
if !d2.is_null() {
p["dir2"] = d2.get("axis").cloned().unwrap_or(Value::Null);
p["count2"] = d2.get("count").cloned().unwrap_or(Value::Null);
p["spacing2"] = d2.get("spacing").cloned().unwrap_or(Value::Null);
}
out.push(json!({"command": "solid.pattern.rectangular", "params": p}));
}
Some("circular_pattern") => {
let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null);
out.push(json!({"command": "solid.pattern.circular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
}
Some("sm_flange") => match f.get("type").and_then(Value::as_str) {
Some("edge") => {
let mut p = json!({"type": "edge", "edges": edge_points(f), "height": f.get("height"), "name": name});
if let Some(a) = f.get("angle") {
p["angle"] = a.clone();
}
if f.get("flip").and_then(Value::as_bool) == Some(true) {
p["flip"] = json!(true);
}
out.push(json!({"command": "sheet.flange", "params": p}));
}
Some("contour") => out.push(json!({"command": "sheet.flange", "params": {
"type": "contour", "sketch": f.get("profile_sketch"), "curves": f.get("profile_curves"), "distance": f.get("distance"), "name": name}})),
_ => out.push(json!({"command": "sheet.flange", "params": {
"type": "base", "sketch": profile_sketch(f), "profiles": profiles_of(f), "name": name, "body_names": bodies}})),
},
Some("sheet_metal_rule_edit") => {
let rule = f.get("rule").cloned().unwrap_or(json!("Steel (mm)"));
let mut p = json!({"name": rule});
if let Some(set) = f.get("set") {
for (from, to) in [("thickness", "thickness"), ("kFactor", "k_factor"), ("bendRadius", "bend_radius")] {
if let Some(v) = set.get(from) {
p[to] = v.clone();
}
}
}
// "thickness 3 mm (final state…)": the last step of a sequence.
if let Some(last) = f.get("sequence").and_then(Value::as_array).and_then(|a| a.last()).and_then(Value::as_str) {
let w: Vec<&str> = last.split_whitespace().collect();
if let (Some(&"thickness"), Some(v), Some(u)) = (w.first(), w.get(1), w.get(2)) {
p["thickness"] = json!(format!("{v} {u}"));
}
}
out.push(json!({"command": "sheet.manage_rules", "params": p}));
}
Some("sm_hem") => {
let e = f.get("edge").and_then(|e| e.get("point")).cloned().unwrap_or(Value::Null);
let mut p = json!({"edges": [e], "length": f.get("length"), "name": name});
if f.get("flipped").and_then(Value::as_bool) == Some(true) {
p["flip"] = json!(true);
}
out.push(json!({"command": "sheet.hem", "params": p}));
}
Some("sm_unfold") => out.push(json!({"command": "sheet.unfold", "params": {"name": name}})),
Some("sm_refold") => out.push(json!({"command": "sheet.refold", "params": {"name": name}})),
Some("path_pattern") => {
let path = f.get("path").cloned().unwrap_or(Value::Null);
let mut p = json!({"path_sketch": path.get("sketch"), "path": path.get("curves"), "count": f.get("count"), "name": name});
match f.get("spacing") {
Some(sp) => p["spacing"] = sp.clone(),
None => p["distance"] = f.get("distance").cloned().unwrap_or(Value::Null),
}
if f.get("pattern_of").and_then(Value::as_str) == Some("bodies") {
p["bodies"] = f.get("bodies").cloned().unwrap_or(Value::Null);
} else {
p["features"] = feature_refs(f.get("features"));
}
out.push(json!({"command": "solid.pattern.path", "params": p}));
}
Some("loft") => {
let sections: Vec<Value> = f
.get("sections")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|sec| match sec.get("point") {
Some(pt) => json!({"sketch": sec.get("sketch"), "point": pt}),
None => json!({"sketch": sec.get("sketch"), "profiles": [sec]}),
})
.collect();
out.push(json!({"command": "solid.loft", "params": {"sections": sections, "operation": op(f), "name": name, "body_names": bodies}}));
}
Some("sweep") if helix_path(features, f).is_some() => {
// A swept circle along a fitted 3D helix: a coil (the true helix).
let Some((base, diameter, pitch, turns, start, cw, size)) = helix_path(features, f) else { continue };
out.push(json!({"command": "solid.coil", "params": {
"base": base, "diameter": diameter, "pitch": pitch, "revolutions": turns, "section_size": size,
"start_angle": format!("{start} rad"), "clockwise": cw, "operation": op(f), "name": name, "body_names": bodies}}));
}
Some("sweep") => {
let path = f.get("path").cloned().unwrap_or(Value::Null);
out.push(json!({"command": "solid.sweep", "params": {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
"operation": op(f), "name": name, "body_names": bodies}}));
}
Some("shell") => {
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
}
Some("draft") => {
let faces: Vec<Value> = f.get("faces").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
let np = f.get("neutral_plane").cloned().unwrap_or(Value::Null);
let n = np.get("plane_normal_outward").or(np.get("normal_at_point_on_face")).cloned().unwrap_or(json!([0, 0, 1]));
let neutral = json!({"origin": np.get("point").or(np.get("centroid")).cloned().unwrap_or(json!([0, 0, 0])), "normal": n.clone()});
let mut angle = f.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
if f.get("flipped").and_then(Value::as_bool).unwrap_or(false) {
angle = -angle;
}
out.push(json!({"command": "solid.draft", "params": {"faces": faces, "angle": angle, "neutral": neutral, "pull": n, "name": name}}));
}
Some("thread") => {
let face = f.get("face").and_then(|x| x.get("point")).cloned().unwrap_or(Value::Null);
let mut p = json!({"face": face, "designation": f.get("designation"), "name": name});
if f.get("full_length").and_then(Value::as_bool) == Some(false)
&& let Some(l) = f.get("length")
{
p["length"] = l.clone();
}
out.push(json!({"command": "solid.thread", "params": p}));
}
Some("hole") => {
let n = f.get("face").and_then(|fc| fc.get("plane_normal_outward").or(fc.get("normal_at_point_on_face"))).cloned();
let mut dir = n.and_then(|n| n.as_array().map(|a| a.iter().map(|x| -x.as_f64().unwrap_or(0.0)).collect::<Vec<f64>>()));
// On a cylinder the normal given is at the face's sample point: drill toward the axis.
let v3 = |v: Option<&Value>| -> Option<[f64; 3]> {
let a = v?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
};
if let Some(fc) = f.get("face").filter(|fc| fc.get("type").and_then(Value::as_str) == Some("cylinder"))
&& let (Some(o), Some(ax), Some(p)) = (v3(fc.get("axis_origin")), v3(fc.get("axis")), v3(f.get("position")))
{
let d = [p[0] - o[0], p[1] - o[1], p[2] - o[2]];
let t = d[0] * ax[0] + d[1] * ax[1] + d[2] * ax[2];
let r = [d[0] - t * ax[0], d[1] - t * ax[1], d[2] - t * ax[2]];
let l = (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt();
if l > 1e-9 {
dir = Some(vec![-r[0] / l, -r[1] / l, -r[2] / l]);
}
}
let ty = f.get("hole_type").and_then(Value::as_str).unwrap_or("simple");
let mut p = json!({"position": f.get("position"), "diameter": f.get("diameter"), "type": ty, "name": name});
// "sketch HolePoints points p1..p6"
if let Some(src) = f.get("positions_from").and_then(Value::as_str) {
let w: Vec<&str> = src.split_whitespace().collect();
if let (Some(sk), Some(range)) = (w.iter().position(|x| *x == "sketch").and_then(|i| w.get(i + 1)), w.iter().position(|x| *x == "points").and_then(|i| w.get(i + 1))) {
let ids: Vec<String> = match range.split_once("..") {
Some((a, b)) => {
let pre: String = a.chars().take_while(|c| !c.is_ascii_digit()).collect();
let (lo, hi) = (a[pre.len()..].parse::<u32>().unwrap_or(1), b.trim_start_matches(pre.as_str()).parse::<u32>().unwrap_or(0));
(lo..=hi).map(|k| format!("{pre}{k}")).collect()
}
None => range.split(',').map(str::to_string).collect(),
};
p = json!({"sketch": sk, "points": ids, "diameter": f.get("diameter"), "type": ty, "name": name});
}
}
if let Some(d) = dir {
p["direction"] = json!(d);
}
if f.get("extent").and_then(Value::as_str) != Some("through_all") {
p["depth"] = f.get("depth").cloned().unwrap_or(Value::Null);
if let Some(t) = f.get("tip_angle") {
p["tip_angle"] = t.clone();
}
}
for (k, src) in [("cb_diameter", "counterbore_diameter"), ("cb_depth", "counterbore_depth"), ("cs_diameter", "countersink_diameter"), ("cs_angle", "countersink_angle")] {
if let Some(v) = f.get(src) {
p[k] = v.clone();
}
}
out.push(json!({"command": "solid.hole", "params": p}));
}
Some("construction_plane") => match f.get("method").and_then(Value::as_str) {
Some("offset") => out.push(json!({"command": "construct.plane.offset", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})),
_ => {
// Use the resulting plane as recorded.
let (o, n) = (f.get("result_origin").cloned().unwrap_or(json!([0, 0, 0])), f.get("result_normal").cloned().unwrap_or(Value::Null));
if n.is_null() {
return Err(format!("recipe: construction plane method {:?} without result_normal", f.get("method")));
}
out.push(json!({"command": "construct.plane.offset", "params": {"base": {"origin": o, "normal": n}, "offset": 0, "name": name}}));
}
},
Some("split_body") => out.push(json!({"command": "solid.split_body", "params": {"body": f.get("body"), "plane": f.get("tool"), "name": name}})),
Some("mirror") => {
let pl = f.get("plane").cloned().unwrap_or(Value::Null);
let plane = match pl.get("ref").and_then(Value::as_str) {
Some(r @ ("XY" | "XZ" | "YZ")) => json!(r),
_ => json!({"origin": pl.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "normal": pl.get("normal")}),
};
let mut p = json!({"features": feature_refs(f.get("features")), "plane": plane, "name": name});
if f.get("mirror_of").and_then(Value::as_str) == Some("bodies") {
p = json!({"bodies": f.get("bodies"), "combine": f.get("combine").cloned().unwrap_or(json!(false)), "plane": plane, "name": name});
}
out.push(json!({"command": "solid.mirror", "params": p}));
}
Some("fillet") => {
out.push(json!({"command": "solid.fillet", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
}
Some("chamfer") => {
out.push(json!({"command": "solid.chamfer", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
}
Some(other) => return Err(format!("recipe: feature op `{other}` is not supported yet")),
None => return Err("recipe: feature without `op`".into()),
}
}
Ok(json!({"commands": out}))
}
fn edge_points(f: &Value) -> Value {
Value::Array(
f.get("edges")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|e| e.get("point").or(e.get("mid")).cloned().unwrap_or_else(|| e.clone()))
.collect(),
)
}
/// A pattern's source features may carry an occurrence suffix ("Hole1 (1)"); the features are
/// the recipe's own names.
fn feature_refs(v: Option<&Value>) -> Value {
let list: Vec<Value> = v
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|x| match x.as_str() {
Some(s) => {
let t = s.trim();
let base = match t.rfind(" (") {
Some(i) if t.ends_with(')') && t[i + 2..t.len() - 1].chars().all(|c| c.is_ascii_digit()) => &t[..i],
_ => t,
};
json!(base)
}
None => x.clone(),
})
.collect();
json!(list)
}
+333
View File
@@ -0,0 +1,333 @@
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
//! triangle meshes.
use std::collections::HashMap;
/// A triangle soup in mm.
pub type Tri = [[f64; 3]; 3];
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// Read a binary (or ASCII) STL file.
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
if b.len() >= 84 {
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
if b.len() == 84 + 50 * n {
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
return Ok((0..n)
.map(|i| {
let o = 84 + 50 * i + 12;
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
[v(0), v(1), v(2)]
})
.collect());
}
}
let text = String::from_utf8_lossy(&b);
let mut verts = Vec::new();
for line in text.lines() {
let mut it = line.split_whitespace();
if it.next() == Some("vertex") {
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
if c.len() == 3 {
verts.push([c[0], c[1], c[2]]);
}
}
}
if verts.is_empty() || verts.len() % 3 != 0 {
return Err(format!("{path}: not an STL file"));
}
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
}
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
let (mut v6, mut m1) = (0.0, [0.0; 3]);
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
for t in tris {
let d = dot(t[0], cross(t[1], t[2]));
v6 += d;
let s = add(add(t[0], t[1]), t[2]);
m1 = add(m1, scale(s, d / 24.0));
for (i, row) in c.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
*cell += d / 120.0 * vv;
}
}
}
let vol = v6 / 6.0;
if vol.abs() < 1e-12 {
return (0.0, [0.0; 3], [0.0; 3]);
}
let com = scale(m1, 1.0 / vol);
// Central covariance and inertia tensor I = tr(C)·1 − C.
let mut cc = c;
for (i, row) in cc.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell -= vol * com[i] * com[j];
}
}
let tr = cc[0][0] + cc[1][1] + cc[2][2];
let mut inertia = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
}
}
let mut ev = eigen_sym3(inertia);
ev.sort_by(f64::total_cmp);
(vol, com, ev)
}
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
for _ in 0..50 {
let (mut p, mut q, mut big) = (0, 1, 0.0);
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
if a[i][j].abs() > big {
(p, q, big) = (i, j, a[i][j].abs());
}
}
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
break;
}
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let t = if theta == 0.0 { 1.0 } else { t };
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
let mut b = a;
for k in 0..3 {
b[k][p] = cs * a[k][p] - sn * a[k][q];
b[k][q] = sn * a[k][p] + cs * a[k][q];
}
let mut c2 = b;
for k in 0..3 {
c2[p][k] = cs * b[p][k] - sn * b[q][k];
c2[q][k] = sn * b[p][k] + cs * b[q][k];
}
a = c2;
}
[a[0][0], a[1][1], a[2][2]]
}
/// Axis-aligned bounds of a triangle soup.
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in tris.iter().flatten() {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
let (a, b, c) = (t[0], t[1], t[2]);
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
let q = if d1 <= 0.0 && d2 <= 0.0 {
a
} else {
let bp = sub(p, b);
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
if d3 >= 0.0 && d4 <= d3 {
b
} else {
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
add(a, scale(ab, d1 / (d1 - d3)))
} else {
let cp = sub(p, c);
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
if d6 >= 0.0 && d5 <= d6 {
c
} else {
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
add(a, scale(ac, d2 / (d2 - d6)))
} else {
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
} else {
let den = va + vb + vc;
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
}
}
}
}
}
};
let d = sub(p, q);
dot(d, d).sqrt()
}
/// Uniform grid of triangles for distance queries up to `cap`.
struct Grid<'a> {
tris: &'a [Tri],
cell: f64,
cells: HashMap<(i64, i64, i64), Vec<u32>>,
}
impl<'a> Grid<'a> {
fn new(tris: &'a [Tri], cell: f64) -> Self {
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let key = |x: f64| (x / cell).floor() as i64;
for (i, t) in tris.iter().enumerate() {
let (lo, hi) = bounds(std::slice::from_ref(t));
for x in key(lo[0])..=key(hi[0]) {
for y in key(lo[1])..=key(hi[1]) {
for z in key(lo[2])..=key(hi[2]) {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Grid { tris, cell, cells }
}
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
let rmax = (cap / self.cell).ceil() as i64;
let k = |x: f64| (x / self.cell).floor() as i64;
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
let mut best = cap;
for r in 0..=rmax {
for x in cx - r..=cx + r {
for y in cy - r..=cy + r {
for z in cz - r..=cz + r {
// only the shell at Chebyshev distance r
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
continue;
}
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
for &i in list {
let Some(slot) = seen.get_mut(i as usize) else { continue };
if *slot == stamp {
continue;
}
*slot = stamp;
if let Some(t) = self.tris.get(i as usize) {
best = best.min(point_tri(p, t));
}
}
}
}
}
// Everything not yet searched is at least r·cell away from p.
if best <= r as f64 * self.cell {
break;
}
}
best
}
}
/// At most this many sample points per side (larger meshes are sampled with an even stride).
const MAX_SAMPLES: usize = 250_000;
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
/// very large meshes).
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
for t in tris.iter().step_by(stride) {
out.extend_from_slice(t);
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
}
out
}
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
/// capped at `cap`.
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
let (lo, hi) = bounds(to);
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
// finer than diag/400 (so big flat triangles don't fill too many cells).
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
let typical = (area / to.len().max(1) as f64).sqrt();
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
let mut seen = vec![0u32; to.len()];
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
for (i, p) in samples(from).into_iter().enumerate() {
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
sum += d;
n += 1;
if d > max {
max = d;
worst = p;
}
}
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
}
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
pub struct Hausdorff {
pub a_to_b: f64,
pub b_to_a: f64,
pub mean: f64,
/// Where the larger one-sided distance was found (mm, world).
pub worst_at: [f64; 3],
}
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
}
#[cfg(test)]
mod tests {
use super::*;
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
let mut t = Vec::new();
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
t
}
#[test]
fn box_moments_and_distance() {
let c = cube(10.0, [5.0, -3.0, 2.0]);
let (v, com, pm) = mass_properties(&c);
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
for m in pm {
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
}
let moved = cube(10.0, [5.0, -3.0, 2.3]);
let h = hausdorff(&c, &moved, 1.0);
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
let same = hausdorff(&c, &c, 1.0);
assert!(same.a_to_b < 1e-12);
}
}
@@ -0,0 +1,26 @@
//! The registry remains valid JSON and a closed pipeline reader must not cause a panic.
use std::process::{Command, Stdio};
#[test]
fn registry_output_is_complete_json() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let commands = registry.as_array().unwrap();
assert!(commands.iter().any(|command| command["id"] == "solid.box"));
assert!(commands.iter().any(|command| command["id"] == "engine.commands"));
assert!(output.stdout.ends_with(b"\n"));
assert!(output.stderr.is_empty());
}
#[test]
fn closed_registry_pipe_exits_without_panicking() {
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
// Close our read end before waiting. No sleeps or timing threshold are needed:
// the registry is larger than the pipe buffer, so it cannot all be written first.
drop(child.stdout.take());
let output = child.wait_with_output().unwrap();
assert!(output.status.success(), "status: {}; stderr: {}", output.status, String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
}
@@ -0,0 +1,22 @@
# Design file corpus
Designs saved by older SolidCraft builds. `tests/design_corpus.rs` opens each one with the
current build and checks that it evaluates as it did when saved (the `.expected.json` next to
it is `solidcraft-cli eval` of that file by the build that saved it: bodies, volumes, areas,
world bounding boxes), that it is upgraded to the current file format, and that damaged and
hostile variants of it never panic.
| File | Saved by | Era |
|---|---|---|
| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut |
| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit |
| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground |
| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter |
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
point is that it was written by the old one. Add new files for new eras instead.
@@ -0,0 +1,134 @@
{
"bodies": [
{
"area_mm2": 700.5299915783324,
"bbox": {
"max": [
10.0,
10.0,
10.000000000000007
],
"min": [
0.0,
0.0,
-6.080836915950756e-16
]
},
"center_of_mass": [
4.999999998722992,
4.999999999366269,
4.999999996470206
],
"density_g_cm3": 1.29,
"edges": 14,
"face_types": {
"cylinder": 1,
"plane": 6
},
"faces": 7,
"kernel": {
"edges": 18,
"faces": 8,
"shells": 1,
"vertices": 12
},
"mass_g": 1.1279207478827844,
"material": "Default",
"name": "Base",
"vertices": 10,
"volume_mm3": 874.3571689013833
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 1,
"dof": 3,
"id": 11,
"name": "S1",
"profiles": 1,
"status": "solved"
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 13.568090999999999,
"name": "Box1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 1.004123,
"name": "Box2",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 6.818065,
"name": "Fillet1",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 9,
"ms": 0.223829,
"name": "Box2 (copy)",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"error": null,
"id": 10,
"ms": 6.139636,
"name": "Fillet1 (copy)",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 11,
"ms": 0.045728,
"name": "S1",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 12,
"ms": 1537.323948,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
}
],
"total": {
"area_mm2": 700.5299915783324,
"edges": 14,
"faces": 7,
"mass_g": 1.1279207478827844,
"vertices": 10,
"volume_mm3": 874.3571689013833
}
}
@@ -0,0 +1,263 @@
{
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Box1",
"suppressed": false,
"body_names": [
"Base"
],
"type": "box",
"corner": [
0.0,
0.0,
0.0
],
"length": "10",
"width": "10",
"height": "10",
"operation": "new_body"
},
{
"id": 4,
"name": "Box2",
"suppressed": false,
"body_names": [
"ArmBody"
],
"component": 2,
"type": "box",
"corner": [
20.0,
0.0,
0.0
],
"length": "10",
"width": "4",
"height": "4",
"operation": "new_body"
},
{
"id": 5,
"name": "Fillet1",
"suppressed": false,
"component": 2,
"type": "fillet",
"edges": [
[
25.0,
4.0,
4.0
]
],
"radius": "1"
},
{
"id": 9,
"name": "Box2 (copy)",
"suppressed": false,
"component": 7,
"type": "box",
"corner": [
20.0,
0.0,
0.0
],
"length": "10",
"width": "4",
"height": "4",
"operation": "new_body"
},
{
"id": 10,
"name": "Fillet1 (copy)",
"suppressed": false,
"component": 7,
"type": "fillet",
"edges": [
[
25.0,
4.0,
4.0
]
],
"radius": "1"
},
{
"id": 11,
"name": "S1",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "c1.center",
"pos": [
5.0,
5.0
],
"fixed": false
}
],
"curves": [
{
"id": "c1",
"type": "circle",
"c": 1,
"r": 2.0,
"construction": false
}
],
"constraints": [],
"counters": {
"c": 1
}
}
},
{
"id": 12,
"name": "Extrude1",
"suppressed": false,
"type": "extrude",
"sketch": 11,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "cut"
}
],
"next_id": 13,
"components": [
{
"id": 2,
"name": "Arm",
"parent": 0
},
{
"id": 7,
"name": "Arm (copy)",
"parent": 0
}
],
"occurrences": [
{
"id": 3,
"component": 2,
"parent": 0,
"name": "Arm:1",
"transform": [
[
1.0,
0.0,
0.0,
0.0
],
[
0.0,
1.0,
0.0,
0.0
],
[
0.0,
0.0,
1.0,
0.0
],
[
0.0,
50.0,
0.0,
1.0
]
],
"grounded": true
},
{
"id": 6,
"component": 2,
"parent": 0,
"name": "Arm:2",
"transform": [
[
1.0,
0.0,
0.0,
0.0
],
[
0.0,
1.0,
0.0,
0.0
],
[
0.0,
0.0,
1.0,
0.0
],
[
0.0,
70.0,
0.0,
1.0
]
],
"grounded": false
},
{
"id": 8,
"component": 7,
"parent": 0,
"name": "Arm (copy):1",
"transform": [
[
1.0,
0.0,
0.0,
0.0
],
[
0.0,
1.0,
0.0,
0.0
],
[
0.0,
0.0,
1.0,
0.0
],
[
0.0,
20.0,
0.0,
1.0
]
],
"grounded": false
}
]
}
@@ -0,0 +1,217 @@
{
"bodies": [
{
"area_mm2": 2820.0,
"bbox": {
"max": [
40.0,
30.0,
3.0
],
"min": [
0.0,
0.0,
0.0
]
},
"center_of_mass": [
20.0,
15.0,
1.5
],
"density_g_cm3": 1.29,
"edges": 12,
"face_types": {
"plane": 6
},
"faces": 6,
"kernel": {
"edges": 12,
"faces": 6,
"shells": 1,
"vertices": 8
},
"mass_g": 4.644,
"material": "Default",
"name": "Body1",
"vertices": 8,
"volume_mm3": 3600.0
},
{
"area_mm2": 1105.7821673177034,
"bbox": {
"max": [
40.00000000000002,
37.0,
8.000000000000002
],
"min": [
0.0,
29.0,
-6.661338147750943e-16
]
},
"center_of_mass": [
20.00000000000423,
32.99999999999611,
3.9999999999995404
],
"density_g_cm3": 1.29,
"edges": 2,
"face_types": {
"cylinder": 1,
"plane": 2
},
"faces": 3,
"kernel": {
"edges": 6,
"faces": 4,
"shells": 1,
"vertices": 4
},
"mass_g": 2.593268071855663,
"material": "Default",
"name": "Body2",
"vertices": 2,
"volume_mm3": 2010.2853270198937
},
{
"area_mm2": 2820.0,
"bbox": {
"max": [
40.0,
32.00000000000008,
37.000000000000064
],
"min": [
0.0,
29.000000000000075,
7.000000000000064
]
},
"center_of_mass": [
20.0,
30.500000000000075,
22.00000000000007
],
"density_g_cm3": 1.29,
"edges": 12,
"face_types": {
"plane": 6
},
"faces": 6,
"kernel": {
"edges": 12,
"faces": 6,
"shells": 1,
"vertices": 8
},
"mass_g": 4.643999999999996,
"material": "Default",
"name": "Body3",
"vertices": 8,
"volume_mm3": 3599.9999999999964
},
{
"area_mm2": 1105.782167317711,
"bbox": {
"max": [
40.00000000000002,
37.000000000000085,
8.000000000000064
],
"min": [
0.0,
29.000000000000075,
6.394884621840902e-14
]
},
"center_of_mass": [
20.000000000004935,
32.99999999999709,
3.999999999999749
],
"density_g_cm3": 1.29,
"edges": 2,
"face_types": {
"cylinder": 1,
"plane": 2
},
"faces": 3,
"kernel": {
"edges": 6,
"faces": 4,
"shells": 1,
"vertices": 4
},
"mass_g": 2.59326807185558,
"material": "Default",
"name": "Body4",
"vertices": 2,
"volume_mm3": 2010.2853270198293
}
],
"body_count": 4,
"params": [
{
"comment": "",
"error": null,
"expression": "90 deg",
"model": false,
"name": "open_angle",
"unit": "deg",
"value": 1.5707963267948966
}
],
"sketches": [],
"timeline": [
{
"error": null,
"id": 3,
"ms": 1.1335799999999998,
"name": "Box1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 0.580462,
"name": "Cylinder1",
"rolled_back": false,
"suppressed": false,
"type": "CylinderFeature",
"warning": null
},
{
"error": null,
"id": 7,
"ms": 0.257673,
"name": "Box2",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"error": null,
"id": 8,
"ms": 0.5234019999999999,
"name": "Cylinder2",
"rolled_back": false,
"suppressed": false,
"type": "CylinderFeature",
"warning": null
}
],
"total": {
"area_mm2": 7851.564334635414,
"edges": 28,
"faces": 18,
"mass_g": 14.474536143711237,
"vertices": 20,
"volume_mm3": 11220.570654039719
}
}
@@ -0,0 +1,225 @@
{
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [
{
"name": "open_angle",
"expr": "90 deg",
"unit": "deg",
"model": false
}
],
"features": [
{
"id": 3,
"name": "Box1",
"suppressed": false,
"param_names": [
"d1",
"d2",
"d3"
],
"component": 1,
"type": "box",
"corner": [
0.0,
0.0,
0.0
],
"length": "40",
"width": "30",
"height": "3",
"operation": "new_body"
},
{
"id": 4,
"name": "Cylinder1",
"suppressed": false,
"param_names": [
"d4",
"d5"
],
"component": 1,
"type": "cylinder",
"base": [
0.0,
33.0,
4.0
],
"axis": [
1.0,
0.0,
0.0
],
"radius": "4",
"height": "40",
"operation": "new_body"
},
{
"id": 7,
"name": "Box2",
"suppressed": false,
"param_names": [
"d6",
"d7",
"d8"
],
"component": 5,
"type": "box",
"corner": [
0.0,
0.0,
0.0
],
"length": "40",
"width": "30",
"height": "3",
"operation": "new_body"
},
{
"id": 8,
"name": "Cylinder2",
"suppressed": false,
"param_names": [
"d9",
"d10"
],
"component": 5,
"type": "cylinder",
"base": [
0.0,
33.0,
4.0
],
"axis": [
1.0,
0.0,
0.0
],
"radius": "4",
"height": "40",
"operation": "new_body"
}
],
"next_id": 9,
"components": [
{
"id": 1,
"name": "LeafA",
"parent": 0
},
{
"id": 5,
"name": "LeafB",
"parent": 0
}
],
"occurrences": [
{
"id": 2,
"component": 1,
"parent": 0,
"name": "LeafA:1",
"transform": [
[
1.0,
0.0,
0.0,
0.0
],
[
0.0,
1.0,
0.0,
0.0
],
[
0.0,
0.0,
1.0,
0.0
],
[
0.0,
0.0,
0.0,
1.0
]
],
"grounded": true
},
{
"id": 6,
"component": 5,
"parent": 0,
"name": "LeafB:1",
"transform": [
[
1.0,
0.0,
0.0,
0.0
],
[
0.0,
6.123233995736766e-17,
-1.0,
0.0
],
[
0.0,
1.0,
6.123233995736766e-17,
0.0
],
[
0.0,
29.000000000000075,
37.000000000000064,
1.0
]
],
"grounded": false
}
],
"assembly": {
"joints": [
{
"id": 1,
"name": "Hinge",
"kind": "revolute",
"a": {
"occurrence": 2,
"snap": {
"type": "circle_center",
"pick": [
1.0,
37.0,
4.0
]
}
},
"b": {
"occurrence": 6,
"snap": {
"type": "circle_center",
"pick": [
1.0,
37.0,
4.0
]
}
},
"values": [
1.5707963267948966
],
"limits": [
null
],
"offset": 0.0,
"angle": 0.0
}
]
}
}
@@ -0,0 +1,128 @@
{
"bodies": [
{
"area_mm2": 5641.5699696719,
"bbox": {
"max": [
40.0,
30.0,
20.00000000000001
],
"min": [
0.0,
0.0,
-2.331468351865795e-15
]
},
"center_of_mass": [
20.0333953680467,
15.02475683626249,
9.999999936169297
],
"edges": 17,
"face_types": {
"cylinder": 2,
"plane": 6
},
"faces": 8,
"kernel": {
"edges": 21,
"faces": 9,
"shells": 1,
"vertices": 14
},
"name": "Body1",
"vertices": 12,
"volume_mm3": 22391.21832759239
}
],
"body_count": 1,
"params": [
{
"comment": "",
"error": null,
"expression": "40 mm",
"model": false,
"name": "width",
"unit": "mm",
"value": 40.0
}
],
"sketches": [
{
"curves": 4,
"dof": 4,
"id": 1,
"name": "Sketch1",
"profiles": 1,
"status": "solved"
},
{
"curves": 1,
"dof": 3,
"id": 4,
"name": "Sketch2",
"profiles": 1,
"status": "solved"
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.043782999999999996,
"name": "Sketch1",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 1.609402,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 11.196359,
"name": "Fillet1",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 0.031249,
"name": "Sketch2",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 1378.231746,
"name": "Extrude2",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
}
],
"total": {
"area_mm2": 5641.5699696719,
"edges": 17,
"faces": 8,
"vertices": 12,
"volume_mm3": 22391.21832759239
}
}
@@ -0,0 +1,208 @@
{
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [
{
"name": "width",
"expr": "40 mm",
"unit": "mm",
"model": false
}
],
"features": [
{
"id": 1,
"name": "Sketch1",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l5",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l6",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l7",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l8",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k9",
"type": "horizontal",
"l": 0
},
{
"id": "k10",
"type": "horizontal",
"l": 2
},
{
"id": "k11",
"type": "vertical",
"l": 1
},
{
"id": "k12",
"type": "vertical",
"l": 3
}
],
"next_id": 13
}
},
{
"id": 2,
"name": "Extrude1",
"suppressed": false,
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "width / 2",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "Fillet1",
"suppressed": false,
"type": "fillet",
"edges": [
[
0.0,
0.0,
10.0
]
],
"radius": "3"
},
{
"id": 4,
"name": "Sketch2",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "c1.center",
"pos": [
20.0,
15.0
],
"fixed": false
}
],
"curves": [
{
"id": "c1",
"type": "circle",
"c": 1,
"r": 5.0,
"construction": false
}
],
"constraints": [],
"next_id": 2
}
},
{
"id": 5,
"name": "Extrude2",
"suppressed": false,
"type": "extrude",
"sketch": 4,
"profiles": {
"type": "all"
},
"extent": {
"distance": "20",
"direction": "positive"
},
"operation": "cut"
}
],
"next_id": 6
}
@@ -0,0 +1,155 @@
{
"bodies": [
{
"area_mm2": 835.4130709318358,
"bbox": {
"max": [
15.0,
15.0,
5.0
],
"min": [
-15.0,
0.0,
0.0
]
},
"center_of_mass": [
-5.1971813422033816e-8,
8.063488509334698,
2.500000003353729
],
"edges": 12,
"face_types": {
"cylinder": 2,
"plane": 4
},
"faces": 6,
"kernel": {
"edges": 28,
"faces": 14,
"shells": 1,
"vertices": 16
},
"name": "Body1",
"vertices": 8,
"volume_mm3": 981.8090960455798
},
{
"area_mm2": 693.6702600465544,
"bbox": {
"max": [
40.0,
10.0,
10.000000000000007
],
"min": [
30.0,
0.0,
-2.3775115272352145e-15
]
},
"center_of_mass": [
35.02683928802334,
4.999999999290639,
4.973160725428021
],
"edges": 17,
"face_types": {
"cylinder": 1,
"plane": 7
},
"faces": 8,
"kernel": {
"edges": 21,
"faces": 9,
"shells": 1,
"vertices": 14
},
"name": "Body2",
"vertices": 12,
"volume_mm3": 869.3724925404262
}
],
"body_count": 2,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 8,
"id": 1,
"name": "Sketch1",
"profiles": 1,
"status": "solved"
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.034476,
"name": "Sketch1",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 7.732595999999999,
"name": "Revolve1",
"rolled_back": false,
"suppressed": false,
"type": "RevolveFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 2.5985959999999997,
"name": "Box1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 1.6926910000000002,
"name": "Cylinder1",
"rolled_back": false,
"suppressed": false,
"type": "CylinderFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 384.534019,
"name": "Combine1",
"rolled_back": false,
"suppressed": false,
"type": "CombineFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 150.763588,
"name": "Chamfer1",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
}
],
"total": {
"area_mm2": 1529.0833309783902,
"edges": 29,
"faces": 14,
"vertices": 20,
"volume_mm3": 1851.181588586006
}
}
@@ -0,0 +1,171 @@
{
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Sketch1",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XZ"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "l1.start",
"pos": [
10.0,
0.0
],
"fixed": false
},
{
"id": "l1.end",
"pos": [
15.0,
0.0
],
"fixed": false
},
{
"id": "l2.end",
"pos": [
15.0,
5.0
],
"fixed": false
},
{
"id": "l3.end",
"pos": [
10.0,
5.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [],
"next_id": 5
}
},
{
"id": 2,
"name": "Revolve1",
"suppressed": false,
"type": "revolve",
"sketch": 1,
"profiles": {
"type": "all"
},
"axis": {
"type": "sketch_axis",
"axis": "y"
},
"angle": "180 deg",
"operation": "new_body"
},
{
"id": 3,
"name": "Box1",
"suppressed": false,
"type": "box",
"corner": [
30.0,
0.0,
0.0
],
"length": "10",
"width": "10",
"height": "10",
"operation": "new_body"
},
{
"id": 4,
"name": "Cylinder1",
"suppressed": false,
"type": "cylinder",
"base": [
35.0,
5.0,
-5.0
],
"axis": [
0.0,
0.0,
1.0
],
"radius": "(4) / 2",
"height": "20",
"operation": "new_body"
},
{
"id": 5,
"name": "Combine1",
"suppressed": false,
"type": "combine",
"target": "Body2",
"tools": [
"Body3"
],
"operation": "cut",
"keep_tools": false
},
{
"id": 6,
"name": "Chamfer1",
"suppressed": false,
"type": "chamfer",
"edges": [
[
30.0,
5.0,
10.0
]
],
"distance": "1"
}
],
"next_id": 7
}
@@ -0,0 +1,122 @@
{
"bodies": [
{
"area_mm2": 16463.932136252846,
"bbox": {
"max": [
120.0,
60.0,
5.0000000000000036
],
"min": [
0.0,
0.0,
0.0
]
},
"center_of_mass": [
60.000000010005735,
29.965234604788655,
2.4999999999999867
],
"density_g_cm3": 1.29,
"edges": 26,
"face_types": {
"cylinder": 7,
"plane": 6
},
"faces": 13,
"kernel": {
"edges": 54,
"faces": 20,
"shells": 1,
"vertices": 36
},
"mass_g": 45.163625870882676,
"material": "Default",
"name": "Plate",
"vertices": 22,
"volume_mm3": 35010.56269060673
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 8,
"id": 1,
"name": "Plate_sketch",
"profiles": 1,
"status": "solved"
},
{
"curves": 2,
"dof": 6,
"id": 3,
"name": "PathSketch",
"profiles": 1,
"status": "solved"
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.093199,
"name": "Plate_sketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 9.166392,
"name": "Plate",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.057569999999999996,
"name": "PathSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 84.237259,
"name": "Hole1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 1093.817516,
"name": "PathPattern1",
"rolled_back": false,
"suppressed": false,
"type": "PathPatternFeature",
"warning": null
}
],
"total": {
"area_mm2": 16463.932136252846,
"edges": 26,
"faces": 13,
"mass_g": 45.163625870882676,
"vertices": 22,
"volume_mm3": 35010.56269060673
}
}
@@ -0,0 +1,238 @@
{
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Plate_sketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "l1.start",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "l1.end",
"pos": [
120.0,
0.0
],
"fixed": false
},
{
"id": "l2.end",
"pos": [
120.0,
60.0
],
"fixed": false
},
{
"id": "l3.end",
"pos": [
0.0,
60.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [],
"counters": {}
}
},
{
"id": 2,
"name": "Plate",
"suppressed": false,
"body_names": [
"Plate"
],
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "curves",
"loops": [
[
"l1",
"l2",
"l3",
"l4"
]
]
},
"extent": {
"distance": "5",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "PathSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "a1.center",
"pos": [
60.0,
3.3333333333333335
],
"fixed": false
},
{
"id": "a1.start",
"pos": [
100.0,
15.0
],
"fixed": false
},
{
"id": "a1.end",
"pos": [
20.0,
15.000000000000002
],
"fixed": false
}
],
"curves": [
{
"id": "a1",
"type": "arc",
"c": 1,
"a": 2,
"b": 3,
"construction": false
},
{
"id": "c1",
"type": "circle",
"c": 2,
"r": 3.0,
"construction": false
}
],
"constraints": [],
"counters": {}
}
},
{
"id": 4,
"name": "Hole1",
"suppressed": false,
"body_names": [
"Plate"
],
"param_names": [
"d2"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "curves",
"loops": [
[
"c1"
]
]
},
"extent": {
"distance": "0",
"direction": "positive",
"through_all": true
},
"operation": "cut"
},
{
"id": 5,
"name": "PathPattern1",
"suppressed": false,
"param_names": [
"d3",
"d4"
],
"type": "pattern",
"features": [
"Hole1"
],
"pattern": {
"type": "path",
"path_sketch": 3,
"path": [
"a1"
],
"count": "7",
"spacing": "17.875031"
}
}
],
"next_id": 6
}
@@ -0,0 +1,126 @@
{
"bodies": [
{
"area_mm2": 24280.380636378435,
"bbox": {
"max": [
80.0,
50.0,
32.0
],
"min": [
0.0,
0.0,
0.0
]
},
"center_of_mass": [
39.89510389356416,
24.906013036240918,
10.936558895792075
],
"density_g_cm3": 1.29,
"edges": 82,
"face_types": {
"cone": 1,
"cylinder": 1,
"plane": 35
},
"faces": 37,
"kernel": {
"edges": 90,
"faces": 39,
"shells": 1,
"vertices": 60
},
"mass_g": 29.716687268283682,
"material": "Default",
"name": "Body1",
"vertices": 56,
"volume_mm3": 23036.19168084006
}
],
"body_count": 1,
"params": [
{
"comment": "",
"error": null,
"expression": "2 mm",
"model": false,
"name": "wall",
"unit": "mm",
"value": 2.0
}
],
"sketches": [],
"timeline": [
{
"error": null,
"id": 1,
"ms": 13.671479,
"name": "Box1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 15.729548999999999,
"name": "Shell1",
"rolled_back": false,
"suppressed": false,
"type": "ShellFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 136.407014,
"name": "Lip1",
"rolled_back": false,
"suppressed": false,
"type": "LipFeature",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 530.869306,
"name": "Boss1",
"rolled_back": false,
"suppressed": false,
"type": "BossFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 176.568115,
"name": "Rest1",
"rolled_back": false,
"suppressed": false,
"type": "RestFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 260.395779,
"name": "SnapFit1",
"rolled_back": false,
"suppressed": false,
"type": "SnapFitFeature",
"warning": null
}
],
"total": {
"area_mm2": 24280.380636378435,
"edges": 82,
"faces": 37,
"mass_g": 29.716687268283682,
"vertices": 56,
"volume_mm3": 23036.19168084006
}
}
@@ -0,0 +1,158 @@
{
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [
{
"name": "wall",
"expr": "2 mm",
"unit": "mm",
"model": false
}
],
"features": [
{
"id": 1,
"name": "Box1",
"suppressed": false,
"param_names": [
"d1",
"d2",
"d3"
],
"type": "box",
"corner": [
0.0,
0.0,
0.0
],
"length": "80",
"width": "50",
"height": "30",
"operation": "new_body"
},
{
"id": 2,
"name": "Shell1",
"suppressed": false,
"param_names": [
"d4"
],
"type": "shell",
"faces": [
[
40.0,
25.0,
30.0
]
],
"thickness": "wall"
},
{
"id": 3,
"name": "Lip1",
"suppressed": false,
"param_names": [
"d5",
"d6"
],
"type": "lip",
"face": [
1.0,
25.0,
30.0
],
"width": "1",
"height": "2"
},
{
"id": 4,
"name": "Boss1",
"suppressed": false,
"param_names": [
"d7",
"d8",
"d9",
"d10"
],
"type": "boss",
"position": [
20.0,
20.0,
2.0
],
"direction": [
0.0,
0.0,
1.0
],
"diameter": "6",
"height": "10",
"hole_diameter": "2.5",
"draft": "1 deg"
},
{
"id": 5,
"name": "Rest1",
"suppressed": false,
"param_names": [
"d11",
"d12",
"d13",
"d14"
],
"type": "rest",
"position": [
60.0,
30.0,
2.0
],
"direction": [
0.0,
0.0,
1.0
],
"width": "8",
"length": "12",
"height": "3",
"thickness": "1"
},
{
"id": 6,
"name": "SnapFit1",
"suppressed": false,
"param_names": [
"d15",
"d16",
"d17",
"d18",
"d19"
],
"type": "snap_fit",
"position": [
40.0,
12.0,
2.0
],
"direction": [
0.0,
0.0,
1.0
],
"hook": [
0.0,
1.0,
0.0
],
"length": "12",
"thickness": "1.5",
"width": "6",
"catch_depth": "1",
"catch_length": "2"
}
],
"next_id": 7,
"plastic": {
"active": "ABS (1.5mm)"
}
}
@@ -0,0 +1,85 @@
{
"bodies": [
{
"area_mm2": 10411.967792188118,
"bbox": {
"max": [
70.0,
50.00000000000002,
32.500000000000014
],
"min": [
0.0,
-4.440892098500626e-14,
-5.329070518200751e-15
]
},
"center_of_mass": [
36.03765771659564,
24.999999999999982,
15.08756759282672
],
"density_g_cm3": 1.29,
"edges": 44,
"face_types": {
"cylinder": 4,
"plane": 10
},
"faces": 22,
"kernel": {
"edges": 44,
"faces": 22,
"shells": 1,
"vertices": 24
},
"mass_g": 15.605746095907175,
"material": "Default",
"name": "Body1",
"sheet_metal": true,
"vertices": 24,
"volume_mm3": 12097.477593726493
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 3,
"dof": 8,
"id": 1,
"name": "Contour",
"profiles": 0,
"status": "solved"
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.074984,
"name": "Contour",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 37.187197,
"name": "ContourFlange",
"rolled_back": false,
"suppressed": false,
"type": "ContourFlangeFeature",
"warning": null
}
],
"total": {
"area_mm2": 10411.967792188118,
"edges": 44,
"faces": 22,
"mass_g": 15.605746095907175,
"vertices": 24,
"volume_mm3": 12097.477593726493
}
}
@@ -0,0 +1,120 @@
{
"format": "solidcraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Contour",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "custom",
"plane": {
"origin": [
0.0,
0.0,
0.0
],
"x_dir": [
1.0,
0.0,
0.0
],
"y_dir": [
0.0,
0.0,
-1.0
]
}
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "l1.start",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "l1.end",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "l2.end",
"pos": [
40.0,
-30.0
],
"fixed": false
},
{
"id": "l3.end",
"pos": [
70.0,
-30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
}
],
"constraints": [],
"counters": {}
}
},
{
"id": 2,
"name": "ContourFlange",
"suppressed": false,
"param_names": [
"d1"
],
"type": "sheet_contour",
"sketch": 1,
"curves": [
"l1",
"l2",
"l3"
],
"distance": "50"
}
],
"next_id": 3
}
@@ -0,0 +1,144 @@
{
"bodies": [
{
"area_mm2": 4078.7680390666596,
"bbox": {
"max": [
40.0,
30.000000000000014,
12.000000000000002
],
"min": [
0.0,
0.0,
0.0
]
},
"center_of_mass": [
19.25890231218174,
15.000000010054242,
5.04117761026949
],
"density_g_cm3": 1.29,
"edges": 30,
"face_types": {
"cylinder": 1,
"plane": 11
},
"faces": 12,
"kernel": {
"edges": 30,
"faces": 12,
"shells": 1,
"vertices": 20
},
"mass_g": 14.435720327850033,
"material": "Default",
"name": "Body1",
"vertices": 20,
"volume_mm3": 11190.480874302351
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "SlotSketch",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.09895799999999999,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 20.768292000000002,
"name": "Block",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.057,
"name": "SlotSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 136.28875,
"name": "Slot",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 153.58625,
"name": "Round",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 169.174375,
"name": "Bevel",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
},
{
"error": null,
"id": 7,
"ms": 47.772166,
"name": "Raise",
"rolled_back": false,
"suppressed": false,
"type": "OffsetFacesFeature",
"warning": null
}
],
"total": {
"area_mm2": 4078.7680390666596,
"edges": 30,
"faces": 12,
"mass_g": 14.435720327850033,
"vertices": 20,
"volume_mm3": 11190.480874302351
}
}
@@ -0,0 +1,373 @@
{
"format": "solidcraft/13",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Base",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
},
{
"id": "k5",
"type": "coincident",
"p": 0,
"q": 1
}
],
"counters": {
"k": 5,
"l": 4,
"p": 4
}
}
},
{
"id": 2,
"name": "Block",
"suppressed": false,
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "SlotSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
15.0,
-5.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
25.0,
-5.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
25.0,
35.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
15.0,
35.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
}
],
"counters": {
"k": 4,
"l": 4,
"p": 4
}
}
},
{
"id": 4,
"name": "Slot",
"suppressed": false,
"param_names": [
"d2",
"d3"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive",
"start_offset": "5"
},
"operation": "cut"
},
{
"id": 5,
"name": "Round",
"suppressed": false,
"param_names": [
"d4"
],
"edge_refs": [
{
"dir": [
0.0,
-1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.0,
10.0
]
}
],
"edge_names": [
"F2:side:l2|F2:top#2"
],
"type": "fillet",
"edges": [
[
40.0,
15.0,
10.0
]
],
"radius": "2"
},
{
"id": 6,
"name": "Bevel",
"suppressed": false,
"param_names": [
"d5"
],
"edge_refs": [
{
"dir": [
0.0,
1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.000000000000007,
10.0
]
}
],
"edge_names": [
"F2:side:l4|F2:top#1"
],
"type": "chamfer",
"edges": [
[
0.0,
15.0,
10.0
]
],
"distance": "1.5"
},
{
"id": 7,
"name": "Raise",
"suppressed": false,
"param_names": [
"d6"
],
"face_names": [
"F2:top#1"
],
"type": "offset_face",
"faces": [
[
7.0,
15.0,
10.0
]
],
"distance": "2"
}
],
"next_id": 8
}
@@ -0,0 +1,15 @@
{"commands": [
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
{"command": "component.create", "params": {"name": "Arm"}},
{"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}},
{"command": "solid.fillet", "params": {"edges": [[25, 4, 4]], "radius": 1}},
{"command": "component.activate", "params": {"component": 0}},
{"command": "occurrence.move", "params": {"occurrence": 3, "translate": [0, 50, 0]}},
{"command": "occurrence.copy", "params": {"component": "Arm", "translate": [0, 20, 0]}},
{"command": "component.paste_new", "params": {"component": "Arm", "translate": [0, -30, 0]}},
{"command": "occurrence.ground", "params": {"occurrence": 3}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "S1"}},
{"command": "sketch.circle.center", "params": {"center": [5, 5], "radius": 2}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10, "operation": "cut"}}
]}
@@ -0,0 +1,14 @@
{"commands": [
{"command": "parameters.change", "params": {"name": "open_angle", "expression": "90 deg"}},
{"command": "component.create", "params": {"name": "LeafA"}},
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "component.activate", "params": {"component": "root"}},
{"command": "component.create", "params": {"name": "LeafB"}},
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "component.activate", "params": {"component": "root"}},
{"command": "occurrence.ground", "params": {"occurrence": 2, "grounded": true}},
{"command": "joint.create", "params": {"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}},
{"command": "joint.drive", "params": {"joint": "Hinge", "value": "open_angle"}}
]}
@@ -0,0 +1,12 @@
{"commands": [
{"command": "parameters.change", "params": {"name": "width", "expression": "40 mm"}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "width / 2"}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.circle.center", "params": {"center": [20, 15], "radius": 5}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 20, "operation": "cut"}}
]}
@@ -0,0 +1,11 @@
{"commands": [
{"command": "sketch.create", "params": {"plane": "XZ"}},
{"command": "sketch.line", "params": {"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.revolve", "params": {"axis": "y"}},
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}},
{"command": "solid.cylinder", "params": {"diameter": 4, "height": 20, "base": [35, 5, -5]}},
{"command": "solid.combine", "params": {"target": "Body2", "tools": ["Body3"], "operation": "cut"}},
{"command": "solid.chamfer", "params": {"edges": [[30, 5, 10]], "distance": 1}},
{"command": "timeline.edit", "params": {"feature": "Revolve1", "set": {"angle": "180 deg"}}}
]}
@@ -0,0 +1,197 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"name": "Plate_sketch",
"plane": "XY"
}
},
{
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
"l1"
],
"points": [
[
0,
0
],
[
120,
0
]
]
}
},
{
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
"l2"
],
"points": [
"l1.end",
[
120,
60
]
]
}
},
{
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
"l3"
],
"points": [
"l2.end",
[
0,
60
]
]
}
},
{
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
"l4"
],
"points": [
"l3.end",
"l1.start"
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"body_names": [
"Plate"
],
"direction": "positive",
"distance": 5,
"name": "Plate",
"operation": "new",
"profiles": [
{
"area_mm2": 7200.0,
"centroid_sketch": [
60.0,
30.0
],
"loops": [
{
"curves": [
"l1",
"l2",
"l3",
"l4"
],
"outer": true
}
],
"sketch": "Plate_sketch"
}
],
"sketch": "Plate_sketch"
}
},
{
"command": "sketch.create",
"params": {
"name": "PathSketch",
"plane": "XY"
}
},
{
"command": "sketch.arc.three_point",
"params": {
"end": [
20,
15
],
"id": "a1",
"start": [
100,
15
],
"through": [
60,
45
]
}
},
{
"command": "sketch.circle.center",
"params": {
"center": "a1.start",
"construction": false,
"id": "c1",
"radius": 3
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"body_names": [
"Plate"
],
"direction": "positive",
"name": "Hole1",
"operation": "cut",
"profiles": [
{
"area_mm2": 28.274334,
"centroid_sketch": [
100.0,
15.0
],
"loops": [
{
"curves": [
"c1"
],
"outer": true
}
],
"sketch": "PathSketch"
}
],
"sketch": "PathSketch",
"through_all": true
}
},
{
"command": "solid.pattern.path",
"params": {
"count": 7,
"features": [
"Hole1"
],
"name": "PathPattern1",
"path": [
"a1"
],
"path_sketch": "PathSketch",
"spacing": 17.875031
}
}
]
}
@@ -0,0 +1,10 @@
{"commands": [
{"command": "parameters.change", "params": {"name": "wall", "expression": "2 mm"}},
{"command": "solid.box", "params": {"length": 80, "width": 50, "height": 30}},
{"command": "solid.shell", "params": {"faces": [[40, 25, 30]], "thickness": "wall"}},
{"command": "plastic.lip", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
{"command": "plastic.boss", "params": {"position": [20, 20, 2], "diameter": 6, "height": 10, "hole_diameter": 2.5, "draft": "1 deg"}},
{"command": "plastic.rest", "params": {"position": [60, 30, 2], "width": 8, "length": 12, "height": 3, "thickness": 1}},
{"command": "plastic.snap_fit", "params": {"position": [40, 12, 2], "direction": [0, 0, 1], "hook": [0, 1, 0], "length": 12, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
{"command": "plastic.manage_rules", "params": {"name": "ABS (1.5mm)", "active": true}}
]}
@@ -0,0 +1,96 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"name": "Contour",
"plane": {
"origin": [
0.0,
0.0,
0.0
],
"x_dir": [
1.0,
0.0,
0.0
],
"y_dir": [
0.0,
0.0,
-1.0
]
}
}
},
{
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
"l1"
],
"points": [
[
0.0,
0.0
],
[
40.0,
0.0
]
]
}
},
{
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
"l2"
],
"points": [
"l1.end",
[
40.0,
-30.0
]
]
}
},
{
"command": "sketch.line",
"params": {
"construction": false,
"ids": [
"l3"
],
"points": [
"l2.end",
[
70.0,
-30.0
]
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "sheet.flange",
"params": {
"curves": [
"l1",
"l2",
"l3"
],
"distance": 50,
"name": "ContourFlange",
"sketch": "Contour",
"type": "contour"
}
}
]
}
@@ -0,0 +1,111 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "Base"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
40,
30
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"distance": 10,
"name": "Block"
}
},
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "SlotSketch"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
15,
-5
],
"p1": [
25,
35
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"sketch": "SlotSketch",
"distance": 10,
"start_offset": 5,
"operation": "cut",
"name": "Slot"
}
},
{
"command": "solid.fillet",
"params": {
"edges": [
[
40,
15,
10
]
],
"radius": 2,
"name": "Round"
}
},
{
"command": "solid.chamfer",
"params": {
"edges": [
[
0,
15,
10
]
],
"distance": 1.5,
"name": "Bevel"
}
},
{
"command": "solid.offset_face",
"params": {
"faces": [
[
7,
15,
10
]
],
"distance": 2,
"name": "Raise"
}
}
]
}
@@ -0,0 +1,107 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "Base"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
40,
30
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"distance": 10,
"name": "Block"
}
},
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "SlotSketch"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
-5,
12
],
"p1": [
45,
18
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"sketch": "SlotSketch",
"distance": 10,
"operation": "cut",
"name": "Slot"
}
},
{
"command": "solid.fillet",
"params": {
"edges": [
[
20,
0,
10
]
],
"radius": 2,
"body": "Body1 (1)",
"name": "NearRound"
}
},
{
"command": "solid.chamfer",
"params": {
"edges": [
[
20,
30,
10
]
],
"distance": 1.5,
"body": "Body1",
"name": "FarBevel"
}
},
{
"command": "appearance.assign",
"params": {
"bodies": [
"Body1 (1)"
],
"color": "#cc3333"
}
}
]
}
@@ -0,0 +1,171 @@
{
"bodies": [
{
"area_mm2": 1962.6028137423857,
"bbox": {
"max": [
40.0,
30.0,
10.0
],
"min": [
0.0,
18.0,
0.0
]
},
"center_of_mass": [
20.0,
23.94794952681388,
4.957413249211356
],
"density_g_cm3": 1.29,
"edges": 15,
"face_types": {
"plane": 7
},
"faces": 7,
"kernel": {
"edges": 15,
"faces": 7,
"shells": 1,
"vertices": 10
},
"mass_g": 6.13395,
"material": "Default",
"name": "Body1",
"vertices": 10,
"volume_mm3": 4755.0
},
{
"area_mm2": 1963.9401350108699,
"bbox": {
"max": [
40.00000000000002,
12.000000000000002,
10.0
],
"min": [
-8.881784197001252e-15,
0.0,
0.0
]
},
"center_of_mass": [
20.00000000000079,
6.040035701025577,
4.967173993940324
],
"density_g_cm3": 1.29,
"edges": 15,
"face_types": {
"cylinder": 1,
"plane": 6
},
"faces": 7,
"kernel": {
"edges": 15,
"faces": 7,
"shells": 1,
"vertices": 10
},
"mass_g": 6.147677129601969,
"material": "Default",
"name": "Body1 (1)",
"vertices": 10,
"volume_mm3": 4765.64118573796
}
],
"body_count": 2,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "SlotSketch",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.07066700000000001,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 0.7462909999999999,
"name": "Block",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.035125,
"name": "SlotSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 9.647208000000001,
"name": "Slot",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 3.443708,
"name": "NearRound",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 3.947834,
"name": "FarBevel",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
}
],
"total": {
"area_mm2": 3926.5429487532556,
"edges": 30,
"faces": 14,
"mass_g": 12.281627129601969,
"vertices": 20,
"volume_mm3": 9520.64118573796
}
}
@@ -0,0 +1,366 @@
{
"format": "solidcraft/13",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Base",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
},
{
"id": "k5",
"type": "coincident",
"p": 0,
"q": 1
}
],
"counters": {
"k": 5,
"l": 4,
"p": 4
}
}
},
{
"id": 2,
"name": "Block",
"suppressed": false,
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "SlotSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
-5.0,
12.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
45.0,
12.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
45.0,
18.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
-5.0,
18.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
}
],
"counters": {
"k": 4,
"l": 4,
"p": 4
}
}
},
{
"id": 4,
"name": "Slot",
"suppressed": false,
"param_names": [
"d2"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "cut"
},
{
"id": 5,
"name": "NearRound",
"suppressed": false,
"param_names": [
"d3"
],
"edge_refs": [
{
"dir": [
-1.0,
0.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
12.000000000000002,
10.0
]
}
],
"edge_names": [
"F2:side:l1|F2:top"
],
"type": "fillet",
"edges": [
[
20.0,
0.0,
10.0
]
],
"radius": "2",
"body": "Body1 (1)"
},
{
"id": 6,
"name": "FarBevel",
"suppressed": false,
"param_names": [
"d4"
],
"edge_refs": [
{
"dir": [
1.0,
0.0,
0.0
],
"min": [
0.0,
18.0,
0.0
],
"max": [
40.0,
30.0,
10.0
]
}
],
"edge_names": [
"F2:side:l3|F2:top"
],
"type": "chamfer",
"edges": [
[
20.0,
30.0,
10.0
]
],
"distance": "1.5",
"body": "Body1"
}
],
"next_id": 7,
"appearances": {
"bodies": {
"Body1 (1)": {
"name": "Custom",
"color": [
204,
51,
51
],
"opacity": 1.0
}
}
}
}
+289
View File
@@ -0,0 +1,289 @@
//! Designs saved by older SolidCraft builds (tests/corpus, see its README) open and evaluate as
//! they did when they were saved; damaged and hostile design files never panic.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_engine::Session;
fn corpus() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
let mut v: Vec<PathBuf> =
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
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
/// Differences that are deliberate behaviour changes made before the file format had versions
/// (no migration can tell those files apart): (file, check, why).
const KNOWN: [(&str, &str, &str); 2] = [
(
"m0-revolve-primitives",
"Body1 bbox",
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
),
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
];
fn known(file: &str, check: &str) -> bool {
KNOWN.iter().any(|(f, c, _)| *f == file && *c == check)
}
/// Volumes and areas then came from coarser tessellation (and curved booleans and fillets have
/// become exact since): the same part within this relative tolerance (the oracle's).
const REL: f64 = 1e-3;
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * b.abs().max(1.0)
}
#[test]
fn old_designs_open_and_evaluate_as_saved() {
let mut problems = Vec::new();
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap();
let mut s = open(&p);
for r in &s.model.results {
if let Some(e) = &r.error {
problems.push(format!("{name}: feature {}: {e}", r.id));
}
}
assert_eq!(s.doc.format, solidcraft_engine::doc::format::format_string(solidcraft_engine::doc::format::FORMAT), "{name}: upgraded");
let got = s.execute("inspect.measure", &json!({})).unwrap();
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
}
for w in want["bodies"].as_array().into_iter().flatten() {
let bname = w["name"].as_str().unwrap_or_default();
let Some(g) = got["bodies"].as_array().into_iter().flatten().find(|g| g["name"] == w["name"]) else {
problems.push(format!("{name}: body {bname} missing"));
continue;
};
for k in ["volume_mm3", "area_mm2"] {
let (a, b) = (g[k].as_f64().unwrap_or(f64::NAN), w[k].as_f64().unwrap_or(f64::NAN));
if !close(a, b, REL) {
problems.push(format!("{name}: {bname} {k} {a} (saved {b})"));
}
}
if known(&name, &format!("{bname} bbox")) {
continue;
}
for (end, i) in [("min", 0), ("min", 1), ("min", 2), ("max", 0), ("max", 1), ("max", 2)] {
let (a, b) = (g["bbox"][end][i].as_f64().unwrap_or(f64::NAN), w["bbox"][end][i].as_f64().unwrap_or(f64::NAN));
if (a - b).abs() > 1e-3 * b.abs().max(1.0) {
problems.push(format!("{name}: {bname} bbox {end}[{i}] {a} (saved {b})"));
}
}
}
// Saved again, it reads back the same.
let again = solidcraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(again, *s.doc, "{name}: round trip");
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// Names saved before format 14 were worked out from geometry, pieces of a split face numbered by
/// where their centres lay (`F2:top#2`). They keep naming the faces they named: marked as such,
/// they are found as the build that saved them found them, without new notes.
#[test]
fn names_saved_before_exact_history_find_the_faces_they_named() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solidcraft");
let s = open(&path);
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
assert_eq!(feature("Bevel").edge_names, vec!["?F2:side:l4|F2:top#1".to_string()]);
assert_eq!(feature("Raise").face_names, vec!["?F2:top#1".to_string()]);
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
// The raised piece is the left one, as saved (the stored point is on it).
let state = s.model.state();
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
}
/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order.
/// The features after the split that refer to a piece by its body name still find that piece:
/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured).
#[test]
fn body_names_saved_before_exact_history_keep_their_pieces() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solidcraft");
let s = open(&path);
assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order");
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
let state = s.model.state();
let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}"));
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0]));
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
const HOSTILE: [&str; 14] =
["0", "-1", "1e308", "-1e308", "1e9", "4294967297", "\"\"", "\"1/0\"", "\"((((1\"", "null", "true", "[]", "{}", "\"nope\""];
/// Every JSON value in a document, as a path of keys and indices.
fn paths(v: &Value, at: &mut Vec<Value>, out: &mut Vec<Vec<Value>>) {
out.push(at.clone());
match v {
Value::Object(m) => {
for (k, x) in m {
at.push(json!(k));
paths(x, at, out);
at.pop();
}
}
Value::Array(a) => {
for (i, x) in a.iter().enumerate() {
at.push(json!(i));
paths(x, at, out);
at.pop();
}
}
_ => {}
}
}
fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
let mut cur = v;
for k in path {
cur = match k {
Value::String(s) => cur.get_mut(s.as_str())?,
Value::Number(n) => cur.get_mut(n.as_u64()? as usize)?,
_ => return None,
};
}
Some(cur)
}
/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure.
fn try_bytes(bytes: &[u8]) {
if let Ok(doc) = solidcraft_engine::io::read_design(bytes) {
let mut s = Session::new(doc);
let _ = s.execute("inspect.measure", &json!({}));
let _ = s.execute("document.inspect", &json!({}));
}
}
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
/// cycle, which resolve through the model and back into the document.
fn generated() -> Vec<(String, Vec<u8>)> {
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
let plane = |id: u64, name: &str, side: Value| {
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
};
let design = |name: &str, features: Value| {
json!({"format": "solidcraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
};
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
// by the point count and abort on the allocation.
let point = json!({"id": "p1", "pos": [0.0, 0.0], "fixed": false});
let oversized = json!([{"id": 1, "name": "Sketch1", "suppressed": false, "type": "sketch",
"plane": {"type": "origin", "name": "XY"},
"sketch": {"points": vec![point; 200_000], "curves": [], "constraints": []}}]);
vec![
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
("sketch with 200,000 points".into(), design("bigsketch", oversized)),
]
}
#[test]
fn damaged_and_hostile_design_files_never_panic() {
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
// The cases come from one seeded sequence; trying them is independent, so threads share it.
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
for p in corpus() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let bytes = std::fs::read(&p).unwrap();
let doc: Value = serde_json::from_slice(&bytes).unwrap();
let mut all = Vec::new();
paths(&doc, &mut Vec::new(), &mut all);
let mut cases: Vec<(String, Vec<u8>)> = Vec::new();
// Truncated and bit-flipped files.
for _ in 0..8 {
let cut = rng.below(bytes.len());
cases.push((format!("truncated at {cut}"), bytes[..cut].to_vec()));
let mut b = bytes.clone();
let i = rng.below(b.len());
b[i] ^= 1 << rng.below(8);
cases.push((format!("bit flip at {i}"), b));
}
// Values replaced by hostile ones, and keys removed.
for _ in 0..25 {
let path = &all[rng.below(all.len())];
let mut v = doc.clone();
let h = HOSTILE[rng.below(HOSTILE.len())];
if let Some(slot) = at_mut(&mut v, path) {
*slot = serde_json::from_str(h).unwrap();
}
cases.push((format!("{path:?} = {h}"), v.to_string().into_bytes()));
let mut v = doc.clone();
if let Some((last, parent)) = path.split_last()
&& let (Some(Value::Object(m)), Value::String(k)) = (at_mut(&mut v, parent), last)
{
m.remove(k);
}
cases.push((format!("{path:?} removed"), v.to_string().into_bytes()));
}
// Features repeated many times; deep nesting; not JSON at all.
let mut v = doc.clone();
if let Some(Value::Array(fs)) = v.get_mut("features") {
let copy = fs.clone();
for _ in 0..2 {
fs.extend(copy.iter().cloned());
}
}
cases.push(("features repeated".into(), v.to_string().into_bytes()));
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
}
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let failures: Vec<String> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut failures = Vec::new();
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
failures
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
});
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
}
+31
View File
@@ -0,0 +1,31 @@
//! Seed ranges must not crash or wrap, including at the last representable seed.
use std::process::Command;
#[test]
fn overflowing_seed_range_is_an_error_not_a_panic() {
let output =
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
}
#[test]
fn empty_seed_range_at_maximum_is_valid() {
let output =
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
}
#[test]
fn maximum_seed_runs_once_without_wrapping() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).starts_with("1 seeds,"));
}
@@ -0,0 +1,55 @@
//! Kill the process while it saves: the design file is always the old version or the new one,
//! whole, and opens.
use std::process::Command;
use std::time::Duration;
fn round_of(path: &std::path::Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
let doc = solidcraft_engine::io::read_design(&bytes).ok()?;
doc.params.iter().find(|p| p.name == "save_round").map(|p| p.expr.clone())
}
#[test]
fn killed_mid_save_the_old_file_survives() {
let exe = env!("CARGO_BIN_EXE_solidcraft-cli");
let dir = std::env::temp_dir().join(format!("solidcraft-kill-mid-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("design.solidcraft");
let ok = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "1"]).status().unwrap();
assert!(ok.success());
assert_eq!(round_of(&path).as_deref(), Some("0"));
let mut kills = 0;
let mut seen = std::collections::BTreeSet::new();
let modified = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
for i in 0..25u64 {
let before = modified();
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
// Wait until it is in its save loop (its first save replaced the file) and has reached a
// round that varies from kill to kill, however busy the machine is; then kill it a
// varying while later.
let target = i % 10;
let reached = || modified() != before && round_of(&path).and_then(|r| r.parse::<u64>().ok()).is_some_and(|r| r >= target);
let t0 = std::time::Instant::now();
while !reached() && t0.elapsed() < Duration::from_secs(20) && child.try_wait().unwrap().is_none() {
std::thread::sleep(Duration::from_millis(2));
}
std::thread::sleep(Duration::from_millis((i * 7) % 30));
if child.try_wait().unwrap().is_none() {
child.kill().unwrap();
kills += 1;
}
let _ = child.wait();
let round = round_of(&path);
assert!(round.is_some(), "after kill {i} the design file is torn or missing");
seen.insert(round);
let bak = solidcraft_engine::io::backup_path(&path);
if bak.exists() {
assert!(round_of(&bak).is_some(), "after kill {i} the backup is torn");
}
}
assert!(kills > 20, "the saver was killed while running ({kills})");
assert!(seen.len() > 5, "the saver got to saving before it was killed ({seen:?})");
let _ = std::fs::remove_dir_all(&dir);
}
+20
View File
@@ -0,0 +1,20 @@
//! The `solvecraft-cli` compatibility alias forwards to `solidcraft-cli`.
use std::process::Command;
#[test]
fn legacy_alias_forwards_with_a_deprecation_note() {
let out = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--version").output().unwrap();
assert!(out.status.success(), "{out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.trim(), format!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("deprecated") && stderr.contains("solidcraft-cli"), "{stderr}");
}
#[test]
fn legacy_alias_passes_the_exit_status_through() {
let alias = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("no-such-command").output().unwrap();
let real = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("no-such-command").output().unwrap();
assert_eq!(alias.status.code(), real.status.code());
assert_eq!(alias.stdout, real.stdout);
}
+18
View File
@@ -0,0 +1,18 @@
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
use std::net::TcpListener;
use std::process::{Command, Stdio};
#[test]
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
// A port that was free a moment ago: nothing listens on it.
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
.stdin(Stdio::null())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{stderr}");
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
}
+115
View File
@@ -0,0 +1,115 @@
//! Result reporting must not skip requested files or execute a script twice.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
fn script(&self) -> PathBuf {
let path = self.0.join("box.json");
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
}
fn json_output(output: Output) -> Value {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
serde_json::from_slice(&output.stdout).unwrap()
}
fn body_count(path: &Path) -> Value {
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
}
#[test]
fn results_still_save_and_export_the_script_once() {
let dir = Scratch::new();
let saved = dir.0.join("box.solidcraft");
let stl = dir.0.join("box.stl");
let obj = dir.0.join("box.obj");
let result = json_output(
cli().arg("run").arg(dir.script()).arg("--results").arg("--save").arg(&saved).arg("--out").arg(&stl).arg("--out").arg(&obj).output().unwrap(),
);
assert_eq!(result.as_array().unwrap().len(), 1);
assert_eq!(body_count(&saved), 1);
for exported in [&stl, &obj] {
assert!(std::fs::metadata(exported).unwrap().len() > 0);
assert_eq!(body_count(exported), 1);
}
}
#[test]
fn results_with_base_design_and_quiet_still_report_and_save() {
let dir = Scratch::new();
let script = dir.script();
let base = dir.0.join("base.solidcraft");
let saved = dir.0.join("combined.solidcraft");
json_output(cli().arg("run").arg(&script).arg("--save").arg(&base).output().unwrap());
let result = json_output(cli().args(["run", "--results", "--quiet", "--in"]).arg(&base).arg(&script).arg("--save").arg(&saved).output().unwrap());
assert_eq!(result.as_array().unwrap().len(), 1);
assert_eq!(body_count(&saved), 2);
assert_eq!(body_count(&base), 1);
}
#[test]
fn results_propagate_output_errors() {
let dir = Scratch::new();
for flag in ["--save", "--out"] {
let path = dir.0.join("missing").join(if flag == "--save" { "box.solidcraft" } else { "box.stl" });
let output = cli().arg("run").arg(dir.script()).arg("--results").arg(flag).arg(&path).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(!output.stderr.is_empty());
assert!(output.stdout.is_empty());
assert!(!path.exists());
}
}
#[test]
fn results_without_script_still_fail() {
let dir = Scratch::new();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let output = cli().args(["run", "--results", "--in"]).arg(&base).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("--results needs a script"));
}
/// Compatibility shim (docs/compatibility.md): a design saved before the rename, with the
/// legacy extension and format tag, still opens; re-saving writes the current tag.
#[test]
fn legacy_extension_and_format_tag_still_open() {
let dir = Scratch::new();
let saved = dir.0.join("box.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
let text = std::fs::read_to_string(&saved).unwrap();
assert!(text.contains("\"solidcraft/"), "new designs carry the current tag");
let legacy = dir.0.join("old.solvecraft");
std::fs::write(&legacy, text.replace("\"solidcraft/", "\"solvecraft/")).unwrap();
assert_eq!(body_count(&legacy), 1);
let resaved = dir.0.join("resaved.solidcraft");
json_output(cli().args(["run", "--in"]).arg(&legacy).arg(dir.script()).arg("--save").arg(&resaved).output().unwrap());
assert_eq!(body_count(&resaved), 2);
assert!(std::fs::read_to_string(&resaved).unwrap().contains("\"solidcraft/"));
}