Add three-axis milling with independent CAM and Fusion validation (#1682)

Thanks @CypherNaught-0x.
This commit is contained in:
CypherNaugh_0x authored and GitHub committed 2026-10-16 08:26:28 -04:00
1 parent dd5278a402
commit 737c9358d0
85 files changed
+14487 -43

No files matched your search

+1
View File
@@ -6,5 +6,6 @@ license.workspace = true
publish = false
[dependencies]
solidcraft-cam = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
+352
View File
@@ -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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
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
}
+104
View File
@@ -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,
}),
})
}
+173
View File
@@ -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);
}
}
+1
View File
@@ -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)),
+7
View File
@@ -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(())
}