Add three-axis milling with independent CAM and Fusion validation (#1682)
Thanks @CypherNaught-0x.
This commit is contained in:
1 parent
dd5278a402
commit
737c9358d0
85 files changed
+14487
-43
No files matched your search
@@ -6,5 +6,6 @@ license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
solidcraft-cam = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -0,0 +1,352 @@
|
||||
//! CAM analytic validation and local-only Fusion oracle reports.
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_cam::{self as cam, JobInput, nc, verify};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod reference;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
const COMPARISON_VERSION: &str = "cam-oracle/2";
|
||||
|
||||
fn write(path: &Path, content: impl AsRef<[u8]>) -> Result<(), String> {
|
||||
std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
fn json_file(path: &Path) -> Result<Value, String> {
|
||||
let bytes = read(path)?;
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
fn read(path: &Path) -> Result<Vec<u8>, String> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if meta.len() > 128 * 1024 * 1024 {
|
||||
return Err("oracle artifact exceeds 128 MiB".into());
|
||||
}
|
||||
std::fs::read(path).map_err(|e| e.to_string())
|
||||
}
|
||||
fn pretty(value: &Value) -> Result<String, String> {
|
||||
serde_json::to_string_pretty(value).map_err(|e| e.to_string())
|
||||
}
|
||||
fn directory(path: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path).map_err(|e| e.to_string())
|
||||
}
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
pub fn validate(root: &Path) -> Result<(), String> {
|
||||
let out = root.join("target/cam-validation");
|
||||
directory(&out)?;
|
||||
let mut cases = Vec::new();
|
||||
for &name in cam::corpus::CASES {
|
||||
let result = (|| -> Result<Value, String> {
|
||||
let input = cam::corpus::fixture(name).map_err(|e| e.to_string())?;
|
||||
let path = cam::generate::generate(input.clone()).map_err(|e| e.to_string())?;
|
||||
let mut stages = Vec::new();
|
||||
for completed in 1..=input.operations.len() {
|
||||
let operation = input.operations.get(completed - 1).ok_or("missing operation")?;
|
||||
let end = path.blocks.iter().rposition(|b| b.operation == operation.id).ok_or("missing operation motions")? + 1;
|
||||
// Validate prefixes of the actual complete program, not independently
|
||||
// generated replacements. Final protected geometry remains intact.
|
||||
let mut stage_input = input.clone();
|
||||
stage_input.operations.truncate(completed);
|
||||
stage_input.fingerprint.clear();
|
||||
stage_input.fingerprint = cam::fingerprint(&stage_input).map_err(|e| e.to_string())?;
|
||||
let stage_path = cam::Toolpath { fingerprint: stage_input.fingerprint.clone(), blocks: path.blocks[..end].to_vec() };
|
||||
let mut dir = out.join(name);
|
||||
if completed != input.operations.len() {
|
||||
dir = dir.join(format!("after-{completed}"));
|
||||
}
|
||||
let mut stage = validate_stage(&dir, name, completed, &stage_input, &stage_path)?;
|
||||
stage["operation"] = json!(operation.id);
|
||||
stage["artifacts"] = json!(if completed == input.operations.len() { ".".to_string() } else { format!("after-{completed}") });
|
||||
stages.push(stage);
|
||||
}
|
||||
let mut summary = stages.last().cloned().ok_or("empty fixture")?;
|
||||
summary["case"] = json!(name);
|
||||
summary["pass"] = json!(stages.iter().all(|s| s["pass"] == true));
|
||||
summary["stages"] = json!(stages);
|
||||
Ok(summary)
|
||||
})();
|
||||
cases.push(result.unwrap_or_else(|e| json!({"case":name,"pass":false,"error":e})));
|
||||
}
|
||||
report(&out, &cases, "Analytic CAM validation")?;
|
||||
println!("CAM validation: {}/{} passed → {}", cases.iter().filter(|v| v["pass"] == true).count(), cases.len(), out.display());
|
||||
if cases.iter().all(|v| v["pass"] == true) { Ok(()) } else { Err("CAM analytic validation failed; see report.json".into()) }
|
||||
}
|
||||
fn validate_stage(dir: &Path, name: &str, completed: usize, input: &JobInput, path: &cam::Toolpath) -> Result<Value, String> {
|
||||
let parsed = nc::round_trip(path, &input.setup, &input.tools).map_err(|e| e.to_string())?;
|
||||
let report = verify::simulate(input, &parsed, Default::default()).map_err(|e| e.to_string())?;
|
||||
let mut probes = Vec::new();
|
||||
for (xy, expected) in cam::corpus::probes(name, completed).map_err(|e| e.to_string())? {
|
||||
let actual = verify::probe_height(input, &parsed, xy).map_err(|e| e.to_string())?;
|
||||
probes.push(json!({"xy":xy,"expected":expected,"actual":actual,"pass":(actual-expected).abs()<=cam::TOLERANCE}));
|
||||
}
|
||||
directory(dir)?;
|
||||
write(&dir.join("input.json"), pretty(&json!(input))?)?;
|
||||
write(&dir.join("toolpath.json"), pretty(&json!(path))?)?;
|
||||
let code = nc::post(path, &input.setup).map_err(|e| e.to_string())?;
|
||||
write(&dir.join("program.nc"), &code)?;
|
||||
write(&dir.join("solve.html"), program_html(&code, &report))?;
|
||||
write(&dir.join("stock-difference.svg"), stock_overlay(input, &report))?;
|
||||
write(&dir.join("validation.json"), pretty(&json!(report))?)?;
|
||||
let stock = verify::stock_stl(&report, input.setup.stock.min.z).map_err(|e| e.to_string())?;
|
||||
write(&dir.join("stock.stl"), &stock)?;
|
||||
let mesh = cam::compare::read_stl(stock.as_bytes()).map_err(|e| e.to_string())?;
|
||||
let exported = cam::compare::stock_mesh(&report, &mesh, input.setup.stock.min.z, cam::TOLERANCE).map_err(|e| e.to_string())?;
|
||||
let mut export_values = json!(exported);
|
||||
export_values["maximum_added_height_budget"] = json!(cam::STOCK_EXPORT_ERROR);
|
||||
write(&dir.join("stock-export-validation.json"), pretty(&export_values)?)?;
|
||||
write(&dir.join("paths.svg"), overlay(input, &parsed, None)?)?;
|
||||
let values = json!({"setup":input.setup,"tools":input.tools,"operations":input.operations});
|
||||
write(&dir.join("setup-sheet-values.json"), pretty(&values)?)?;
|
||||
write(
|
||||
&dir.join("setup-sheet.html"),
|
||||
format!(
|
||||
"<!doctype html><meta charset=utf-8><title>Analytic CAM setup</title><h1>Analytic CAM setup</h1><p>Lengths: mm; feeds: mm/min; angles: radians. Generator: {}. Input: {}</p><pre>{}</pre>",
|
||||
cam::VERSION,
|
||||
esc(&input.fingerprint),
|
||||
esc(&pretty(&values)?)
|
||||
),
|
||||
)?;
|
||||
let pass = report.status == verify::Status::Pass
|
||||
&& report.finished_dimensions == verify::Status::Pass
|
||||
&& report.stock_complete
|
||||
&& exported.status == verify::Status::Pass
|
||||
&& probes.iter().all(|p| p["pass"] == true);
|
||||
Ok(json!({"pass":pass,"status":report.status,"finished_dimensions":report.finished_dimensions,"probes":probes,
|
||||
"stock_export":export_values,"cells":report.cells.len(),"work":report.work,"metrics":metrics(input,&parsed,&report)?,
|
||||
"findings":report.findings.iter().take(5).collect::<Vec<_>>()}))
|
||||
}
|
||||
fn metrics(input: &JobInput, parsed: &nc::Parsed, report: &verify::Report) -> Result<Value, String> {
|
||||
let segments = nc::segments(parsed, &input.setup).map_err(|e| e.to_string())?;
|
||||
let tools: Vec<_> =
|
||||
parsed.records.iter().filter_map(|r| if let cam::Motion::Tool { number, .. } = r.motion { Some(number) } else { None }).collect();
|
||||
let retracts = segments.iter().filter(|s| s.rapid && s.to.z > s.from.z + 1e-9).count();
|
||||
Ok(json!({"tool_order":tools,"retract_count":retracts,"cutting_distance":report.cutting_distance,
|
||||
"rapid_distance":report.rapid_distance,"cutting_minutes":report.cutting_minutes,
|
||||
"remaining_volume":[report.remaining_volume_low,report.remaining_volume_high]}))
|
||||
}
|
||||
pub fn oracle(root: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let dir = args.windows(2).find(|w| w[0] == "--dir").map(|w| PathBuf::from(w[1])).ok_or("cam-oracle requires --dir LOCAL_ORACLE_DIRECTORY")?;
|
||||
let canonical = std::fs::canonicalize(&dir).map_err(|e| e.to_string())?;
|
||||
if canonical.starts_with(root) {
|
||||
let ignored = std::process::Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(["check-ignore", "--quiet", "--"])
|
||||
.arg(&canonical)
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !ignored.success() {
|
||||
return Err("Fusion storage inside this repository must be git-ignored (use plan/fusion/cam)".into());
|
||||
}
|
||||
}
|
||||
let out = dir.join("comparison");
|
||||
directory(&out)?;
|
||||
let mut cases = Vec::new();
|
||||
for name in ["face", "contour", "pocket", "drill"] {
|
||||
let case = dir.join(name);
|
||||
let value = compare_case(&case, &out.join(name)).unwrap_or_else(|e| json!({"pass":false,"status":"incomplete","error":e}));
|
||||
let mut value = value;
|
||||
value["case"] = json!(name);
|
||||
cases.push(value);
|
||||
}
|
||||
report(&out, &cases, "Local Fusion CAM comparison")?;
|
||||
let _ = root;
|
||||
println!("Fusion CAM oracle: {}/{} passed → {}", cases.iter().filter(|v| v["pass"] == true).count(), cases.len(), out.display());
|
||||
if cases.iter().all(|v| v["pass"] == true) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Fusion parity unverified: missing, failed or inconclusive reference cases".into())
|
||||
}
|
||||
}
|
||||
fn compare_case(dir: &Path, out: &Path) -> Result<Value, String> {
|
||||
let manifest = json_file(&dir.join("manifest.json"))?;
|
||||
for field in ["fusion_version", "post_sha256", "captured_at", "input_fingerprint"] {
|
||||
if manifest[field].as_str().is_none_or(str::is_empty) {
|
||||
return Err(format!("missing reference provenance: {field}"));
|
||||
}
|
||||
}
|
||||
let digest = format!("{:x}", Sha256::digest(read(&dir.join("post.cps"))?));
|
||||
if manifest["post_sha256"] != digest {
|
||||
return Err("reference post hash mismatch".into());
|
||||
}
|
||||
if manifest["capture_complete"] != true {
|
||||
return Err("reference capture is incomplete".into());
|
||||
}
|
||||
let input_value = json_file(&dir.join("input.json"))?;
|
||||
let reference_value = json_file(&dir.join("reference-input.json"))?;
|
||||
if input_value != reference_value {
|
||||
return Err("SolidCraft and reference inputs differ; comparison invalid".into());
|
||||
}
|
||||
let input: JobInput = serde_json::from_value(input_value).map_err(|e| e.to_string())?;
|
||||
if manifest["input_fingerprint"] != input.fingerprint {
|
||||
return Err("manifest input fingerprint mismatch".into());
|
||||
}
|
||||
let mut unhashed = input.clone();
|
||||
unhashed.fingerprint.clear();
|
||||
if cam::fingerprint(&unhashed).map_err(|e| e.to_string())? != input.fingerprint {
|
||||
return Err("input fingerprint does not describe supplied geometry and settings".into());
|
||||
}
|
||||
let capture = reference::validate(dir, &manifest, &input)?;
|
||||
let reference = String::from_utf8(read(&dir.join("reference.nc"))?).map_err(|e| e.to_string())?;
|
||||
let reference_code = reference;
|
||||
let reference = nc::parse(&reference_code, &input.tools).map_err(|e| e.to_string())?;
|
||||
let path = cam::generate::generate(input.clone()).map_err(|e| e.to_string())?;
|
||||
let own = nc::round_trip(&path, &input.setup, &input.tools).map_err(|e| e.to_string())?;
|
||||
let ours = verify::simulate(&input, &own, Default::default()).map_err(|e| e.to_string())?;
|
||||
let mut theirs = verify::simulate(&input, &reference, Default::default()).map_err(|e| e.to_string())?;
|
||||
// The verifier interprets the reference dialect, but did not create this post.
|
||||
// Attribute the imported program to its captured post rather than our own.
|
||||
theirs.post_version = format!("{}; sha256={digest}", manifest["post_identity"].as_str().unwrap_or_default());
|
||||
directory(out)?;
|
||||
write(&out.join("solve.html"), program_html(&nc::post(&path, &input.setup).map_err(|e| e.to_string())?, &ours))?;
|
||||
write(&out.join("fusion.html"), program_html(&reference_code, &theirs))?;
|
||||
write(&out.join("stock-difference.svg"), stock_overlay(&input, &ours))?;
|
||||
write(&out.join("solve-validation.json"), pretty(&json!(ours))?)?;
|
||||
write(&out.join("fusion-nc-validation.json"), pretty(&json!(theirs))?)?;
|
||||
write(&out.join("paths.svg"), overlay(&input, &own, Some(&reference))?)?;
|
||||
let mesh = cam::compare::read_stl(&read(&dir.join("stock.stl"))?).map_err(|e| e.to_string())?;
|
||||
let stock = cam::compare::stock_reports(&ours, &theirs, cam::TOLERANCE).map_err(|e| e.to_string())?;
|
||||
let mesh_check = cam::compare::stock_mesh(&ours, &mesh, input.setup.stock.min.z, cam::TOLERANCE).map_err(|e| e.to_string())?;
|
||||
let reference_mesh_check = cam::compare::stock_mesh(&theirs, &mesh, input.setup.stock.min.z, cam::TOLERANCE).map_err(|e| e.to_string())?;
|
||||
let metadata = json_file(&dir.join("setup-sheet-values.json"))?;
|
||||
let setup_values_match =
|
||||
metadata["setup"] == json!(input.setup) && metadata["tools"] == json!(input.tools) && metadata["operations"] == json!(input.operations);
|
||||
let status = combined_status([
|
||||
ours.status,
|
||||
theirs.status,
|
||||
ours.finished_dimensions,
|
||||
theirs.finished_dimensions,
|
||||
stock.status,
|
||||
mesh_check.status,
|
||||
reference_mesh_check.status,
|
||||
capture.simulation_status,
|
||||
if setup_values_match { verify::Status::Pass } else { verify::Status::Fail },
|
||||
]);
|
||||
let pass = status == verify::Status::Pass;
|
||||
let solve_metrics = metrics(&input, &own, &ours)?;
|
||||
let reference_metrics = metrics(&input, &reference, &theirs)?;
|
||||
Ok(json!({"pass":pass,"status":status,"comparison_version":COMPARISON_VERSION,"provenance":capture.provenance,
|
||||
"solve":ours.status,"reference":theirs.status,"finished_dimensions":[ours.finished_dimensions,theirs.finished_dimensions],
|
||||
"stock":stock,"reference_stl":mesh_check,"reference_nc_vs_reference_stl":reference_mesh_check,
|
||||
"setup_values_match":setup_values_match,"distance_delta":ours.cutting_distance-theirs.cutting_distance,
|
||||
"minutes_delta":ours.cutting_minutes-theirs.cutting_minutes,
|
||||
"volume_intervals":[[ours.remaining_volume_low,ours.remaining_volume_high],[theirs.remaining_volume_low,theirs.remaining_volume_high]],
|
||||
"metrics":{"solve":solve_metrics,"reference":reference_metrics}}))
|
||||
}
|
||||
fn combined_status(statuses: impl IntoIterator<Item = verify::Status>) -> verify::Status {
|
||||
statuses.into_iter().fold(verify::Status::Pass, |result, next| match (result, next) {
|
||||
(verify::Status::Fail, _) | (_, verify::Status::Fail) => verify::Status::Fail,
|
||||
(verify::Status::Inconclusive, _) | (_, verify::Status::Inconclusive) => verify::Status::Inconclusive,
|
||||
_ => verify::Status::Pass,
|
||||
})
|
||||
}
|
||||
fn overlay(input: &JobInput, a: &nc::Parsed, b: Option<&nc::Parsed>) -> Result<String, String> {
|
||||
let min = input.setup.stock.min;
|
||||
let max = input.setup.stock.max;
|
||||
let w = (max.x - min.x).max(1.0);
|
||||
let h = (max.y - min.y).max(1.0);
|
||||
let mut svg = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{} {} {} {}\"><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"#101820\"/>",
|
||||
min.x - 5.0,
|
||||
min.y - 5.0,
|
||||
w + 10.0,
|
||||
h + 10.0,
|
||||
min.x - 5.0,
|
||||
min.y - 5.0,
|
||||
w + 10.0,
|
||||
h + 10.0
|
||||
);
|
||||
for (program, color, name) in std::iter::once((a, "#42c4ff", "solve")).chain(b.map(|b| (b, "#ffb347", "fusion"))) {
|
||||
for s in nc::segments(program, &input.setup).map_err(|e| e.to_string())? {
|
||||
svg.push_str(&format!(
|
||||
"<a href=\"{name}.html#L{line}\"><path d=\"M {} {} L {} {}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\" opacity=\"{}\"><title>NC line {}</title></path></a>",
|
||||
s.from.x,
|
||||
s.from.y,
|
||||
s.to.x,
|
||||
s.to.y,
|
||||
color,
|
||||
w / 400.0,
|
||||
if s.rapid { 0.25 } else { 0.8 },
|
||||
s.line,
|
||||
line = s.line
|
||||
));
|
||||
}
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
Ok(svg)
|
||||
}
|
||||
fn report(out: &Path, cases: &[Value], title: &str) -> Result<(), String> {
|
||||
write(&out.join("report.json"), pretty(&json!({"version":cam::VERSION,"comparison_version":COMPARISON_VERSION,"cases":cases}))?)?;
|
||||
let mut html = format!(
|
||||
"<!doctype html><meta charset=utf-8><title>{title}</title><style>body{{font:16px system-ui;margin:40px;max-width:1100px}}pre{{white-space:pre-wrap;background:#eee;padding:16px}}img{{width:480px}}a{{color:#145aa8}}</style><h1>{title}</h1><p>Blue: SolidCraft; orange: Fusion. Path and time differences are diagnostic, not equivalence gates.</p>"
|
||||
);
|
||||
for c in cases {
|
||||
let name = c["case"].as_str().unwrap_or("unknown");
|
||||
html.push_str(&format!(
|
||||
"<h2>{}</h2><pre>{}</pre><a href=\"{}/paths.svg\">Toolpath overlay</a><br><img src=\"{}/paths.svg\" alt=\"Toolpath overlay\">",
|
||||
esc(name),
|
||||
esc(&pretty(c)?),
|
||||
esc(name),
|
||||
esc(name)
|
||||
));
|
||||
html.push_str(&format!("<p><a href=\"{name}/solve.html\">SolidCraft line-linked diagnostics</a></p><img src=\"{name}/stock-difference.svg\" alt=\"Remaining material relative to protected geometry\">"));
|
||||
if let Some(stages) = c["stages"].as_array() {
|
||||
html.push_str("<h3>After each operation</h3><ul>");
|
||||
for stage in stages {
|
||||
let directory = esc(stage["artifacts"].as_str().unwrap_or("."));
|
||||
html.push_str(&format!("<li>Operation {}: {} — <a href=\"{name}/{directory}/solve.html\">NC and diagnostics</a>, <a href=\"{name}/{directory}/setup-sheet.html\">setup sheet</a>, <a href=\"{name}/{directory}/stock.stl\">stock STL</a>, <a href=\"{name}/{directory}/stock-export-validation.json\">stock export comparison</a></li>",stage["operation"],if stage["pass"] == true { "pass" } else { "failed or inconclusive" }));
|
||||
}
|
||||
html.push_str("</ul>");
|
||||
}
|
||||
}
|
||||
write(&out.join("report.html"), html)
|
||||
}
|
||||
|
||||
fn program_html(code: &str, validation: &verify::Report) -> String {
|
||||
let mut html = String::from(
|
||||
"<!doctype html><meta charset=utf-8><title>NC diagnostics</title><style>body{font:16px system-ui;margin:30px}pre{line-height:1.6}pre a{display:block;color:inherit;text-decoration:none}a:target{background:#ffc}code{white-space:pre}</style><h1>NC diagnostics</h1><ul>",
|
||||
);
|
||||
for f in &validation.findings {
|
||||
let location = f.source.map(|source| format!("Operation {}, motion {} · ", source.operation, source.block)).unwrap_or_default();
|
||||
let label = esc(&format!("{location}{}: {}", f.kind, f.detail));
|
||||
if let Some(line) = f.line {
|
||||
html.push_str(&format!("<li><a href=\"#L{line}\">Line {line}: {label}</a></li>"));
|
||||
} else {
|
||||
html.push_str(&format!("<li>{label}</li>"));
|
||||
}
|
||||
}
|
||||
html.push_str("</ul><pre>");
|
||||
for (i, line) in code.lines().enumerate() {
|
||||
let n = i + 1;
|
||||
html.push_str(&format!("<a id=\"L{n}\" href=\"#L{n}\">{n:6} <code>{}</code></a>", esc(line)));
|
||||
}
|
||||
html.push_str("</pre>");
|
||||
html
|
||||
}
|
||||
fn stock_overlay(input: &JobInput, validation: &verify::Report) -> String {
|
||||
let min = input.setup.stock.min;
|
||||
let max = input.setup.stock.max;
|
||||
let mut svg = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{} {} {} {}\"><title>Sampled stock height above protected geometry; diagnostic only. Orange: remaining stock; red: definite gouge; blue: within tolerance.</title>",
|
||||
min.x,
|
||||
min.y,
|
||||
max.x - min.x,
|
||||
max.y - min.y
|
||||
);
|
||||
let step = validation.cells.len().div_ceil(20_000).max(1);
|
||||
for c in validation.cells.iter().step_by(step) {
|
||||
let excess = c.low - c.target_high;
|
||||
let gouge = c.target_low - c.high;
|
||||
let color = if gouge > validation.tolerance {
|
||||
"#e33"
|
||||
} else if excess > validation.tolerance {
|
||||
"#fa3"
|
||||
} else {
|
||||
"#29a6d2"
|
||||
};
|
||||
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{color}\"><title>Height [{:.6}, {:.6}] mm; excess at least {:.6} mm</title></rect>",c.min[0],c.min[1],c.max[0]-c.min[0],c.max[1]-c.min[1],c.low,c.high,excess));
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Completeness and provenance of local reference captures; no vendor API dependency.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Capture {
|
||||
pub simulation_status: verify::Status,
|
||||
pub provenance: Value,
|
||||
}
|
||||
|
||||
pub(super) fn validate(dir: &Path, manifest: &Value, input: &JobInput) -> Result<Capture, String> {
|
||||
for field in ["post_identity", "source_geometry"] {
|
||||
if manifest[field].as_str().is_none_or(|s| s.trim().is_empty()) {
|
||||
return Err(format!("missing reference provenance: {field}"));
|
||||
}
|
||||
}
|
||||
if manifest["capabilities"]["license"].as_str().is_none_or(|s| s.trim().is_empty())
|
||||
|| manifest["capabilities"]["strategies"]
|
||||
.as_array()
|
||||
.is_none_or(|v| v.is_empty() || v.iter().any(|s| s.as_str().is_none_or(|s| s.trim().is_empty())))
|
||||
{
|
||||
return Err("capture must record Fusion license/capabilities".into());
|
||||
}
|
||||
let source = manifest["source_geometry"].as_str().ok_or("missing source geometry")?;
|
||||
if Path::new(source).components().count() != 1 || !matches!(Path::new(source).components().next(), Some(std::path::Component::Normal(_))) {
|
||||
return Err("source geometry must name a file in the reference package".into());
|
||||
}
|
||||
let extension = Path::new(source).extension().and_then(|s| s.to_str()).unwrap_or_default().to_ascii_lowercase();
|
||||
if !matches!(extension.as_str(), "step" | "stp" | "stl" | "f3d" | "iges" | "igs" | "obj") {
|
||||
return Err("source geometry must be a captured CAD or mesh artifact".into());
|
||||
}
|
||||
let artifacts = manifest["artifact_sha256"].as_object().ok_or("missing captured artifact hashes")?;
|
||||
for name in [
|
||||
source,
|
||||
"input.json",
|
||||
"reference-input.json",
|
||||
"reference.nc",
|
||||
"setup-sheet.html",
|
||||
"setup-sheet-values.json",
|
||||
"stock.stl",
|
||||
"simulation.json",
|
||||
"post-properties.json",
|
||||
] {
|
||||
let bytes = read(&dir.join(name))?;
|
||||
if bytes.is_empty() {
|
||||
return Err(format!("empty artifact {name}"));
|
||||
}
|
||||
let actual = format!("{:x}", Sha256::digest(bytes));
|
||||
if artifacts.get(name).and_then(Value::as_str) != Some(actual.as_str()) {
|
||||
return Err(format!("missing or mismatched captured artifact hash: {name}"));
|
||||
}
|
||||
}
|
||||
let properties = json_file(&dir.join("post-properties.json"))?;
|
||||
if !properties.is_object() && !properties.is_array() {
|
||||
return Err("post properties must be a captured JSON object or array".into());
|
||||
}
|
||||
let simulation = json_file(&dir.join("simulation.json"))?;
|
||||
if simulation["complete"] != true || simulation["at_program_end"] != true {
|
||||
return Err("reference simulation/end-position capture is incomplete".into());
|
||||
}
|
||||
let findings = simulation["findings"].as_array().ok_or("reference simulation findings were not captured")?;
|
||||
let order: Vec<_> = input.operations.iter().map(|op| op.id).collect();
|
||||
if simulation["completed_operations"] != json!(order) {
|
||||
return Err("reference simulation operation order or completeness differs".into());
|
||||
}
|
||||
if simulation["stock_after_operation"] != json!(order.last()) {
|
||||
return Err("reference stock is not captured after the final operation".into());
|
||||
}
|
||||
if simulation["accuracy"]["setting"].as_str().is_none_or(|s| s.trim().is_empty()) {
|
||||
return Err("reference simulation accuracy setting was not captured".into());
|
||||
}
|
||||
let resolution = &simulation["accuracy"]["resolution_mm"];
|
||||
if !resolution.as_f64().is_some_and(|v| v.is_finite() && v > 0.)
|
||||
&& !(resolution.is_null() && simulation["accuracy"]["resolution_status"] == "not_reported")
|
||||
{
|
||||
return Err("reference simulation resolution must be positive or explicitly not_reported".into());
|
||||
}
|
||||
let mut simulation_status =
|
||||
serde_json::from_value(simulation["status"].clone()).map_err(|e| format!("invalid reference simulation status: {e}"))?;
|
||||
for finding in findings {
|
||||
if finding["detail"].as_str().is_none_or(|s| s.trim().is_empty()) {
|
||||
return Err("reference simulation finding lacks detail".into());
|
||||
}
|
||||
let status = match finding["severity"].as_str() {
|
||||
Some("error") => verify::Status::Fail,
|
||||
Some("unresolved") => verify::Status::Inconclusive,
|
||||
Some("information") => verify::Status::Pass,
|
||||
_ => return Err("reference simulation finding has unknown severity".into()),
|
||||
};
|
||||
simulation_status = combined_status([simulation_status, status]);
|
||||
}
|
||||
Ok(Capture {
|
||||
simulation_status,
|
||||
provenance: json!({
|
||||
"fusion_version":manifest["fusion_version"], "captured_at":manifest["captured_at"],
|
||||
"input_fingerprint":input.fingerprint, "generator_version":cam::VERSION,
|
||||
"solve_post":nc::DIALECT, "reference_post":manifest["post_identity"],
|
||||
"reference_post_sha256":manifest["post_sha256"], "post_properties":properties,
|
||||
"capabilities":manifest["capabilities"], "source_geometry":source,
|
||||
"artifact_sha256":artifacts, "reference_simulation":simulation,
|
||||
"verifier_uncertainty_mm":cam::VERIFY_ERROR, "post_error_budget_mm":cam::POST_ERROR,
|
||||
"comparison_tolerance_mm":cam::TOLERANCE,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Original analytic capture packages. These are not Fusion observations or parity evidence.
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
struct Package(PathBuf);
|
||||
impl Drop for Package {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn box_stl(top: f64) -> String {
|
||||
let points = [[0., 0., 0.], [20., 0., 0.], [20., 20., 0.], [0., 20., 0.], [0., 0., top], [20., 0., top], [20., 20., top], [0., 20., top]];
|
||||
let mut text = String::from("solid original_analytic_box\n");
|
||||
for face in [[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]] {
|
||||
text.push_str("facet normal 0 0 0\nouter loop\n");
|
||||
for index in face {
|
||||
let [x, y, z] = points[index];
|
||||
text.push_str(&format!("vertex {x} {y} {z}\n"));
|
||||
}
|
||||
text.push_str("endloop\nendfacet\n");
|
||||
}
|
||||
text.push_str("endsolid original_analytic_box\n");
|
||||
text
|
||||
}
|
||||
fn package(nc_height: f64, stock_height: f64) -> (Package, JobInput, Value) {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-original-cam-oracle-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
let package = Package(path);
|
||||
let input = cam::corpus::fixture("face").unwrap();
|
||||
let toolpath = cam::generate::generate(input.clone()).unwrap();
|
||||
let nc = nc::post(&toolpath, &input.setup).unwrap().replace("Z5.000000", &format!("Z{nc_height:.6}"));
|
||||
write(&package.0.join("reference.nc"), nc).unwrap();
|
||||
write(&package.0.join("input.json"), pretty(&json!(input)).unwrap()).unwrap();
|
||||
write(&package.0.join("reference-input.json"), pretty(&json!(input)).unwrap()).unwrap();
|
||||
write(&package.0.join("post.cps"), "original analytic test post marker; not executable vendor code").unwrap();
|
||||
write(&package.0.join("post-properties.json"), "{}").unwrap();
|
||||
write(&package.0.join("source.stl"), box_stl(5.)).unwrap();
|
||||
write(&package.0.join("stock.stl"), box_stl(stock_height)).unwrap();
|
||||
write(&package.0.join("setup-sheet.html"), "<!doctype html><title>Original analytic test setup</title>").unwrap();
|
||||
write(
|
||||
&package.0.join("setup-sheet-values.json"),
|
||||
pretty(&json!({"setup":input.setup,"tools":input.tools,"operations":input.operations})).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
write(
|
||||
&package.0.join("simulation.json"),
|
||||
pretty(&json!({
|
||||
"status":"pass", "complete":true, "at_program_end":true, "findings":[],
|
||||
"completed_operations":[1], "stock_after_operation":1,
|
||||
"accuracy":{"setting":"exact analytic box", "resolution_mm":null,"resolution_status":"not_reported"},
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut manifest = json!({
|
||||
"fusion_version":"original analytic test; not a Fusion capture", "captured_at":"2026-10-16T00:00:00Z",
|
||||
"input_fingerprint":input.fingerprint, "post_identity":"independently authored test post",
|
||||
"post_sha256":format!("{:x}",Sha256::digest(read(&package.0.join("post.cps")).unwrap())),
|
||||
"capture_complete":true, "capabilities":{"license":"analytic test", "strategies":["face"]},
|
||||
"source_geometry":"source.stl", "artifact_sha256":{},
|
||||
});
|
||||
for name in [
|
||||
"source.stl",
|
||||
"input.json",
|
||||
"reference-input.json",
|
||||
"reference.nc",
|
||||
"setup-sheet.html",
|
||||
"setup-sheet-values.json",
|
||||
"stock.stl",
|
||||
"simulation.json",
|
||||
"post-properties.json",
|
||||
] {
|
||||
hash(&package, &mut manifest, name);
|
||||
}
|
||||
write(&package.0.join("manifest.json"), pretty(&manifest).unwrap()).unwrap();
|
||||
(package, input, manifest)
|
||||
}
|
||||
fn hash(package: &Package, manifest: &mut Value, name: &str) {
|
||||
manifest["artifact_sha256"][name] = json!(format!("{:x}", Sha256::digest(read(&package.0.join(name)).unwrap())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_requires_direct_reference_nc_to_exported_stock_agreement() {
|
||||
let (package, _, _) = package(5.006, 4.994);
|
||||
let result = compare_case(&package.0, &package.0.join("comparison")).unwrap();
|
||||
// Both used to pass through a common midpoint at Z5, although the two
|
||||
// reference results differ by 0.012 mm, beyond the 0.01 mm budget.
|
||||
assert_eq!(result["stock"]["status"], "pass", "{result}");
|
||||
assert_eq!(result["reference_stl"]["status"], "pass", "{result}");
|
||||
assert_eq!(result["finished_dimensions"], json!(["pass", "pass"]), "{result}");
|
||||
assert_eq!(result["reference_nc_vs_reference_stl"]["status"], "fail", "{result}");
|
||||
assert_eq!(result["status"], "fail");
|
||||
assert_eq!(result["pass"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_complete_analytic_capture_passes_and_retains_post_provenance() {
|
||||
let (package, _, manifest) = package(5., 5.);
|
||||
let out = package.0.join("comparison");
|
||||
let result = compare_case(&package.0, &out).unwrap();
|
||||
assert_eq!(result["pass"], true, "{result}");
|
||||
assert_eq!(result["reference_nc_vs_reference_stl"]["status"], "pass");
|
||||
assert_eq!(result["comparison_version"], COMPARISON_VERSION);
|
||||
assert_eq!(result["provenance"]["reference_post_sha256"], manifest["post_sha256"]);
|
||||
assert_eq!(result["provenance"]["reference_simulation"]["accuracy"]["resolution_status"], "not_reported");
|
||||
let validation = json_file(&out.join("fusion-nc-validation.json")).unwrap();
|
||||
assert!(validation["post_version"].as_str().unwrap().starts_with("independently authored test post; sha256="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_rejects_incomplete_stale_and_unlocated_capture_evidence() {
|
||||
let (package, input, manifest) = package(5., 5.);
|
||||
for field in ["post_identity", "source_geometry", "capabilities", "artifact_sha256"] {
|
||||
let mut bad = manifest.clone();
|
||||
bad.as_object_mut().unwrap().remove(field);
|
||||
assert!(reference::validate(&package.0, &bad, &input).is_err(), "{field}");
|
||||
}
|
||||
let mut bad = manifest.clone();
|
||||
bad["source_geometry"] = json!("../source.stl");
|
||||
assert!(reference::validate(&package.0, &bad, &input).unwrap_err().contains("file in the reference package"));
|
||||
bad["source_geometry"] = json!("reference.nc");
|
||||
assert!(reference::validate(&package.0, &bad, &input).unwrap_err().contains("CAD or mesh"));
|
||||
let original = json_file(&package.0.join("simulation.json")).unwrap();
|
||||
for (field, value) in [
|
||||
("complete", json!(false)),
|
||||
("at_program_end", json!(false)),
|
||||
("completed_operations", json!([])),
|
||||
("stock_after_operation", json!(2)),
|
||||
("accuracy", json!({})),
|
||||
("accuracy", json!({"setting":"captured", "resolution_mm":0})),
|
||||
("status", json!("unknown")),
|
||||
("findings", json!(null)),
|
||||
("findings", json!([{"severity":"unknown","detail":"test"}])),
|
||||
] {
|
||||
let mut simulation = original.clone();
|
||||
simulation[field] = value;
|
||||
write(&package.0.join("simulation.json"), pretty(&simulation).unwrap()).unwrap();
|
||||
let mut updated = manifest.clone();
|
||||
hash(&package, &mut updated, "simulation.json");
|
||||
assert!(reference::validate(&package.0, &updated, &input).is_err(), "{field}");
|
||||
}
|
||||
write(&package.0.join("simulation.json"), pretty(&original).unwrap()).unwrap();
|
||||
write(&package.0.join("reference.nc"), "G21 G0 X99").unwrap();
|
||||
assert!(reference::validate(&package.0, &manifest, &input).unwrap_err().contains("artifact hash: reference.nc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_definite_failures_survive_inconclusive_checks() {
|
||||
use verify::Status::{Fail, Inconclusive, Pass};
|
||||
assert_eq!(combined_status([Pass, Inconclusive, Fail]), Fail);
|
||||
assert_eq!(combined_status([Fail, Inconclusive, Pass]), Fail);
|
||||
assert_eq!(combined_status([Pass, Inconclusive]), Inconclusive);
|
||||
let (package, _, mut manifest) = package(5., 5.);
|
||||
let mut simulation = json_file(&package.0.join("simulation.json")).unwrap();
|
||||
simulation["status"] = json!("fail");
|
||||
write(&package.0.join("simulation.json"), pretty(&simulation).unwrap()).unwrap();
|
||||
hash(&package, &mut manifest, "simulation.json");
|
||||
write(&package.0.join("manifest.json"), pretty(&manifest).unwrap()).unwrap();
|
||||
let result = compare_case(&package.0, &package.0.join("comparison")).unwrap();
|
||||
assert_eq!(result["status"], "fail");
|
||||
assert_eq!(result["pass"], false);
|
||||
for (severity, expected) in [("error", "fail"), ("unresolved", "inconclusive")] {
|
||||
simulation["status"] = json!("pass");
|
||||
simulation["findings"] = json!([{"severity":severity,"detail":"Original injected simulation finding"}]);
|
||||
write(&package.0.join("simulation.json"), pretty(&simulation).unwrap()).unwrap();
|
||||
hash(&package, &mut manifest, "simulation.json");
|
||||
write(&package.0.join("manifest.json"), pretty(&manifest).unwrap()).unwrap();
|
||||
let result = compare_case(&package.0, &package.0.join("comparison")).unwrap();
|
||||
assert_eq!(result["status"], expected);
|
||||
assert_eq!(result["pass"], false);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
// the derive macros (proc-macro, no workspace dependencies)
|
||||
("derive", Class::Standalone),
|
||||
("sketch", Class::Layer(1)),
|
||||
("cam", Class::Layer(1)),
|
||||
("kernel", Class::Layer(1)),
|
||||
("render", Class::Layer(1)),
|
||||
("doc", Class::Layer(2)),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
mod app_icons;
|
||||
mod assets;
|
||||
mod book;
|
||||
mod cam;
|
||||
mod features;
|
||||
mod i18n;
|
||||
mod layers;
|
||||
@@ -61,6 +62,9 @@ commands:
|
||||
web-offline-test [--site DIR]
|
||||
check in headless Chrome that the web app works offline and updates to a new
|
||||
build (a stand-in app, or the web build in DIR such as dist/web)
|
||||
cam-validate validate analytic machining fixtures and write JSON/HTML reports
|
||||
cam-oracle --dir DIR
|
||||
compare ignored local Fusion reference packages (missing evidence fails)
|
||||
ci fmt --check, assets, licences --check, lists, layers, clippy -D warnings, then the tests
|
||||
with the wasm check alongside, then web-offline-test when Chrome is installed
|
||||
(stops at the first failing step)
|
||||
@@ -70,6 +74,8 @@ fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("cam-validate") => cam::validate(&root()),
|
||||
Some("cam-oracle") => cam::oracle(&root(), &rest),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("app-icons") => app_icons::run(&root()),
|
||||
@@ -371,6 +377,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
let (wasm, n) = wasm_check()?;
|
||||
test::run(&[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm), test::doc_tests(&[], &[])])?;
|
||||
web_offline_test::run(&root(), None, false)?;
|
||||
cam::validate(&root())?;
|
||||
eprintln!("ci: all gates passed");
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user