Revert #1682 (three-axis milling) (#1715)

Thanks @bherbruck.
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-16 09:02:19 -04:00
1 parent 1ded8fdd27
commit 2c020dbcbc
85 files changed
+43 -14487

No files matched your search

-603
View File
@@ -1,603 +0,0 @@
//! Manufacturing commands shared by UI, CLI and MCP.
use super::CommandSpec;
use crate::{EngineError, Result, Session, cam, manufacturing as jobs};
use serde_json::{Value, json};
use std::sync::atomic::Ordering;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("cam.holes.from_selection", "Use Selected Holes", from_holes)
.noundo()
.params("setup: ID, selections: [{kind:face,body,index,occurrence?:[IDs]}|{kind:profile,sketch,index,occurrence?:[IDs]}]; returns ordered holes, diameters and durable sources; face body is a world label unless occurrence is explicit"),
CommandSpec::new("cam.boundary.from_profile", "Use Sketch Profile", from_profile)
.noundo()
.params("setup: ID, sketch: feature ID, profile: index, occurrence?: [IDs]; returns boundary and durable source reference"),
CommandSpec::new("cam.setup.put", "Milling Setup", setup)
.at("MILLING", "SETUP")
.icon("box")
.params("setup: complete Setup object (create or replace by ID)"),
CommandSpec::new("cam.tool.put", "Tool Library", tool).at("MILLING", "SETUP").icon("hole").params("tool: complete Tool object"),
CommandSpec::new("cam.operation.put", "Milling Operation", operation)
.at("MILLING", "TOOLPATHS")
.icon("cam_path")
.params("operation: complete Operation object; strategy face, contour, pocket or drill"),
CommandSpec::new("cam.program.put", "NC Program", program)
.at("MILLING", "SETUP")
.icon("save")
.params("program: {id,name,setup,operations:[ordered IDs]}"),
CommandSpec::new("cam.operation.order", "Reorder Operations", order).params("program: ID, operations: permutation of existing operation IDs"),
CommandSpec::new("cam.delete", "Delete Manufacturing Item", delete)
.params("kind: setup|tool|operation|program, id; referenced items cannot be deleted"),
CommandSpec::new("cam.inspect", "Inspect Manufacturing", inspect)
.noundo()
.params("job?: ID; include_path?: bool; include_stock?: bool (world-space display mesh); motion?: completed block count; finding?: zero-based verification finding index"),
CommandSpec::new("cam.generate", "Generate Toolpaths", generate)
.at("MILLING", "TOOLPATHS")
.icon("cam_generate")
.noundo()
.params("program: ID, background?: bool (default false); returns job ID"),
CommandSpec::new("cam.job.status", "CAM Job Status", status).noundo().params("job: ID"),
CommandSpec::new("cam.job.cancel", "Cancel CAM Job", cancel).noundo().params("job: ID"),
CommandSpec::new("cam.simulate", "Simulate Toolpaths", simulate)
.at("MILLING", "VERIFY")
.icon("cam_verify")
.noundo()
.params("job: ID, background?: bool"),
CommandSpec::new("cam.post", "Post Reference G-code", post)
.at("MILLING", "EXPORT")
.icon("save")
.noundo()
.params("job: ID, path?: string, background?: bool; requires current passing verification; background output is published by cam.export.result"),
CommandSpec::new("cam.export", "Export CAM Artifact", export)
.noundo()
.params("job: ID, format: toolpath|setup_sheet|stock|validation, path?: string, background?: bool; background output is published by cam.export.result"),
CommandSpec::new("cam.export.result", "Collect CAM Export", export_result).noundo().params("job: ID; consumes completed output after checking freshness; writes the requested path or returns content"),
];
fn from_holes(s: &mut Session, p: &Value) -> Result<Value> {
let setup = id(p, "setup")?;
let setup = s.doc.manufacturing.setups.iter().find(|x| x.id == setup).ok_or_else(|| EngineError::Other("setup not found".into()))?;
let (sources, selected) = jobs::holes::select(s, setup, decode(p, "selections")?)?;
Ok(
json!({"sources":sources,"holes":selected.iter().map(|h|h.center).collect::<Vec<_>>(),"diameters":selected.iter().map(|h|h.diameter).collect::<Vec<_>>()}),
)
}
fn from_profile(s: &mut Session, p: &Value) -> Result<Value> {
let setup = id(p, "setup")?;
let sketch = id(p, "sketch")?;
let profile = id(p, "profile")? as usize;
let state = s.model.state();
let profile = state
.sketches
.iter()
.find(|x| x.feature == sketch)
.and_then(|s| s.profiles.get(profile))
.ok_or_else(|| EngineError::Other("selected profile not found".into()))?;
let source = cam::ProfileRef {
sketch,
curves: profile.curve_ids(),
occurrence: p.get("occurrence").map(|_| decode(p, "occurrence")).transpose()?.unwrap_or_default(),
};
let setup = s.doc.manufacturing.setups.iter().find(|x| x.id == setup).ok_or_else(|| EngineError::Other("setup not found".into()))?;
let boundary = jobs::profile_boundary(s, setup, &source, cam::TOLERANCE)?;
Ok(json!({"boundary":boundary,"source":source}))
}
fn decode<T: serde::de::DeserializeOwned>(p: &Value, key: &str) -> Result<T> {
serde_json::from_value(p.get(key).cloned().ok_or_else(|| EngineError::Other(format!("missing {key}")))?)
.map_err(|e| EngineError::Other(e.to_string()))
}
fn id(p: &Value, key: &str) -> Result<u64> {
p.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::Other(format!("{key} must be an ID")))
}
macro_rules! put {($fn:ident,$key:literal,$ty:ty,$field:ident)=>{fn $fn(s:&mut Session,p:&Value)->Result<Value>{
let item:$ty=decode(p,$key)?;let id=item.id;let mut m=s.doc.manufacturing.clone();
if let Some(old)=m.$field.iter_mut().find(|x|x.id==id){*old=item;}else{m.$field.push(item);}
m.validate().map_err(jobs::error)?;s.doc_mut().manufacturing=m;Ok(json!({"id":id}))
}};}
put!(setup, "setup", cam::Setup, setups);
put!(tool, "tool", cam::Tool, tools);
put!(operation, "operation", cam::Operation, operations);
put!(program, "program", cam::Program, programs);
fn order(s: &mut Session, p: &Value) -> Result<Value> {
let program = id(p, "program")?;
let ids: Vec<u64> = decode(p, "operations")?;
let mut m = s.doc.manufacturing.clone();
let target = m.programs.iter_mut().find(|x| x.id == program).ok_or_else(|| EngineError::Other("program not found".into()))?;
let mut a = target.operations.clone();
let mut b = ids.clone();
a.sort();
b.sort();
if a != b {
return Err(EngineError::Other("order must be a permutation of the program operations".into()));
}
target.operations = ids;
m.validate().map_err(jobs::error)?;
s.doc_mut().manufacturing = m;
Ok(json!({"program":program}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let id = id(p, "id")?;
let mut m = s.doc.manufacturing.clone();
let removed = match p.get("kind").and_then(Value::as_str) {
Some("setup") => {
let n = m.setups.len();
m.setups.retain(|x| x.id != id);
n != m.setups.len()
}
Some("tool") => {
let n = m.tools.len();
m.tools.retain(|x| x.id != id);
n != m.tools.len()
}
Some("operation") => {
let n = m.operations.len();
m.operations.retain(|x| x.id != id);
n != m.operations.len()
}
Some("program") => {
let n = m.programs.len();
m.programs.retain(|x| x.id != id);
n != m.programs.len()
}
_ => return Err(EngineError::Other("unknown CAM item kind".into())),
};
if !removed {
return Err(EngineError::Other("CAM item not found".into()));
}
m.validate().map_err(jobs::error)?;
s.doc_mut().manufacturing = m;
Ok(json!({"deleted":id}))
}
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("job").is_none() {
if p.get("finding").is_some()
|| p.get("motion").is_some()
|| ["include_path", "include_stock"].iter().any(|key| p.get(key).and_then(Value::as_bool) == Some(true))
{
return Err(EngineError::Other("job is required for result inspection".into()));
}
return Ok(json!({"manufacturing":s.doc.manufacturing,"jobs":s.cam_jobs.jobs.keys().collect::<Vec<_>>()}));
}
let job = jobs::job(s, id(p, "job")?)?;
let mut result = status(s, p)?;
match job.state()? {
jobs::JobState::Generated(path) | jobs::JobState::Verified(path, _) if p.get("include_path").and_then(Value::as_bool) == Some(true) => {
result["toolpath"] = json!(path);
}
_ => {}
}
if let Some(motion) = p.get("motion") {
jobs::current(s, &job)?;
let motion = motion
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| EngineError::Other("motion must be a nonnegative block count".into()))?;
let path = match job.state()? {
jobs::JobState::Generated(path) | jobs::JobState::Verified(path, _) => path,
_ => return Err(EngineError::Other("generate before inspecting playback".into())),
};
let state = cam::playback::at(&path, &job.input()?.setup, motion).map_err(jobs::error)?;
result["playback"] = json!(state);
let operation = job.input()?.operations.iter().find(|o| Some(o.id) == state.operation);
result["source"] = json!(operation.and_then(|o| o.source.as_ref()));
result["hole_sources"] = json!(operation.map(|o| &o.hole_sources));
}
if let Some(index) = p.get("finding") {
jobs::current(s, &job)?;
let index =
index.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| EngineError::Other("finding must be a nonnegative index".into()))?;
let jobs::JobState::Verified(path, report) = job.state()? else {
return Err(EngineError::Other("simulate before inspecting findings".into()));
};
let finding = report.findings.get(index).ok_or_else(|| EngineError::Other("finding index out of range".into()))?;
let input = job.input()?;
let operation = finding.source.and_then(|source| input.operations.iter().find(|o| o.id == source.operation));
let code = cam::nc::post(&path, &input.setup).map_err(jobs::error)?;
let line = finding.line.and_then(|line| line.checked_sub(1)).and_then(|index| code.lines().nth(index));
result["diagnostic"] = json!({"index":index,"finding":finding,"world":input.setup.frame.world(finding.at),"operation":operation,
"source":operation.and_then(|o|o.source.as_ref()),"hole_sources":operation.map(|o|&o.hole_sources),"gcode_line":line,
"playback":finding.source.map(|source|cam::playback::at(&path,&input.setup,source.block)).transpose().map_err(jobs::error)?});
}
if p.get("include_stock").and_then(Value::as_bool) == Some(true) {
jobs::current(s, &job)?;
result["stock_mesh"] = serde_json::to_value(job.stock_mesh()?.as_deref()).map_err(|e| EngineError::Other(e.to_string()))?;
}
Ok(result)
}
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
let id = jobs::start(s, id(p, "program")?, p.get("background").and_then(Value::as_bool).unwrap_or(false))?;
status(s, &json!({"job":id}))
}
fn status(s: &mut Session, p: &Value) -> Result<Value> {
let id = id(p, "job")?;
let job = jobs::job(s, id)?;
#[cfg(target_arch = "wasm32")]
jobs::advance(&job);
let stale = jobs::current(s, &job).is_err();
let mut v = json!({"job":id,"stale":stale,"progress":job.progress.load(Ordering::Relaxed),"fingerprint":job.input().ok().map(|input| &input.fingerprint)});
match job.state()? {
jobs::JobState::Verifying(_) => v["state"] = json!("simulating"),
jobs::JobState::Preparing => v["state"] = json!("preparing"),
jobs::JobState::Running => v["state"] = json!("running"),
jobs::JobState::Cancelled => v["state"] = json!("cancelled"),
jobs::JobState::Failed(e) => {
v["state"] = json!("failed");
v["error"] = json!(e);
}
jobs::JobState::Generated(path) => {
v["state"] = json!("generated");
v["blocks"] = json!(path.blocks.len());
}
jobs::JobState::Verified(path, r) => {
v["state"] = json!("verified");
v["blocks"] = json!(path.blocks.len());
v["stock_preview"] = match job.stock_mesh() {
Ok(Some(mesh)) => json!({"available":true,"triangles":mesh.triangles.len()}),
Ok(None) => json!({"available":false}),
Err(e) => json!({"available":false,"error":e.to_string()}),
};
v["verification"] = json!({"status":r.status,"finished_dimensions":r.finished_dimensions,"stock_complete":r.stock_complete,
"findings":r.findings,"cells":r.cells.len(),"work":r.work});
}
}
v["export"] = jobs::exports::status(&job)?;
Ok(v)
}
fn cancel(s: &mut Session, p: &Value) -> Result<Value> {
let job = jobs::job(s, id(p, "job")?)?;
job.cancelled.store(true, Ordering::Relaxed);
job.set(jobs::JobState::Cancelled);
status(s, p)
}
fn simulate(s: &mut Session, p: &Value) -> Result<Value> {
let job = jobs::job(s, id(p, "job")?)?;
jobs::current(s, &job)?;
if jobs::exports::busy(&job)? {
return Err(EngineError::Other("finish or cancel the current export first".into()));
}
let path = match job.state()? {
jobs::JobState::Generated(p) | jobs::JobState::Verified(p, _) => p,
_ => return Err(EngineError::Other("generate a path first; another job may still be running".into())),
};
job.set(jobs::JobState::Verifying(path));
jobs::dispatch(job, p.get("background").and_then(Value::as_bool).unwrap_or(false), true)?;
status(s, p)
}
fn output(p: &Value, content: String) -> Result<Value> {
if let Some(path) = p.get("path").and_then(Value::as_str) {
crate::io::write_atomic(std::path::Path::new(path), content.as_bytes(), false).map_err(|e| EngineError::Other(e.to_string()))?;
Ok(json!({"path":path,"bytes":content.len()}))
} else {
Ok(json!({"content":content}))
}
}
fn post(s: &mut Session, p: &Value) -> Result<Value> {
let job = jobs::job(s, id(p, "job")?)?;
jobs::current(s, &job)?;
let jobs::JobState::Verified(path, report) = job.state()? else {
return Err(EngineError::Other("simulate before posting".into()));
};
if report.status != cam::verify::Status::Pass || report.finished_dimensions != cam::verify::Status::Pass || !report.stock_complete {
return Err(EngineError::Other("posting blocked by failed or inconclusive verification".into()));
}
if p.get("background").and_then(Value::as_bool).unwrap_or(false) {
jobs::exports::start(job, "gcode".into(), p.get("path").and_then(Value::as_str).map(str::to_owned))?;
return status(s, p);
}
cam::nc::round_trip(&path, &job.input()?.setup, &job.input()?.tools).map_err(jobs::error)?;
output(p, cam::nc::post(&path, &job.input()?.setup).map_err(jobs::error)?)
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let job = jobs::job(s, id(p, "job")?)?;
jobs::current(s, &job)?;
let (path, report) = match job.state()? {
jobs::JobState::Generated(p) => (p, None),
jobs::JobState::Verified(p, r) => (p, Some(r)),
_ => return Err(EngineError::Other("no generated result".into())),
};
let format = p.get("format").and_then(Value::as_str).unwrap_or("validation");
if p.get("background").and_then(Value::as_bool).unwrap_or(false) {
jobs::exports::start(job, format.into(), p.get("path").and_then(Value::as_str).map(str::to_owned))?;
return status(s, p);
}
output(p, crate::io::cam::artifact(job.input()?, &path, report.as_deref(), format)?)
}
fn export_result(s: &mut Session, p: &Value) -> Result<Value> {
let job = jobs::job(s, id(p, "job")?)?;
jobs::current(s, &job)?;
let (content, destination) = jobs::exports::take(&job)?;
output(&destination.map_or_else(|| json!({}), |path| json!({"path":path})), content)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn wait_export(s: &mut Session, job: u64) -> Value {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let status = s.execute("cam.job.status", &json!({"job":job})).unwrap();
if status["export"]["state"] != "running" {
return status;
}
assert!(std::time::Instant::now() < deadline, "export worker timeout");
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
#[test]
fn background_exports_only_publish_complete_current_results_once() {
let mut s = configured();
let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap();
s.execute("cam.simulate", &json!({"job":job})).unwrap();
for format in ["toolpath", "validation", "setup_sheet", "stock", "gcode"] {
let cmd = if format == "gcode" { "cam.post" } else { "cam.export" };
let expected = s.execute(cmd, &json!({"job":job,"format":format})).unwrap()["content"].as_str().unwrap().to_owned();
s.execute(cmd, &json!({"job":job,"format":format,"background":true})).unwrap();
assert!(s.execute(cmd, &json!({"job":job,"format":format,"background":true})).is_err());
let status = wait_export(&mut s, job);
assert_eq!(status["export"]["state"], "ready", "{status}");
let actual = s.execute("cam.export.result", &json!({"job":job})).unwrap()["content"].as_str().unwrap().to_owned();
match format {
"toolpath" | "validation" => {
assert_eq!(serde_json::from_str::<Value>(&actual).unwrap(), serde_json::from_str::<Value>(&expected).unwrap())
}
"setup_sheet" => assert!(actual.contains("<h2>Operation settings and selected geometry</h2>")),
_ => assert_eq!(actual, expected),
}
assert!(s.execute("cam.export.result", &json!({"job":job})).is_err());
}
let path = std::env::temp_dir().join(format!("solidcraft-cam-export-{}.nc", std::process::id()));
s.execute("cam.post", &json!({"job":job,"background":true,"path":path})).unwrap();
assert_eq!(wait_export(&mut s, job)["export"]["state"], "ready");
assert!(!path.exists());
s.execute("cam.export.result", &json!({"job":job})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().contains("G21"));
std::fs::remove_file(&path).unwrap();
s.execute("cam.post", &json!({"job":job,"background":true,"path":path})).unwrap();
wait_export(&mut s, job);
let mut tool = s.doc.manufacturing.tools[0].clone();
tool.diameter += 0.1;
s.execute("cam.tool.put", &json!({"tool":tool})).unwrap();
assert!(s.execute("cam.export.result", &json!({"job":job})).unwrap_err().to_string().contains("stale"));
assert!(!path.exists());
}
#[test]
fn cancelling_a_background_export_cannot_publish_or_restart_it() {
let mut s = configured();
let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap();
s.execute("cam.export", &json!({"job":job,"format":"toolpath","background":true})).unwrap();
s.execute("cam.job.cancel", &json!({"job":job})).unwrap();
assert_eq!(wait_export(&mut s, job)["export"]["state"], "cancelled");
assert!(s.execute("cam.export.result", &json!({"job":job})).is_err());
assert!(s.execute("cam.export", &json!({"job":job,"format":"toolpath","background":true})).is_err());
}
fn configured() -> Session {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":20,"width":20,"height":4})).unwrap();
let mut i = cam::corpus::fixture("face").unwrap();
i.setup.models[0].body = s.model.state().bodies[0].name.clone();
let bounds = s.model.state().bodies[0].mesh().bounds();
i.setup.stock.min = bounds.min;
i.setup.stock.max = bounds.max + crate::geom::Vec3::Z;
i.operations[0].top = bounds.max.z + 1.0;
i.operations[0].bottom = bounds.max.z;
s.execute("cam.setup.put", &json!({"setup":i.setup})).unwrap();
s.execute("cam.tool.put", &json!({"tool":i.tools[0]})).unwrap();
s.execute("cam.operation.put", &json!({"operation":i.operations[0]})).unwrap();
s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Plate","setup":1,"operations":[1]}})).unwrap();
s
}
#[test]
fn save_undo_and_stale_generation() {
let mut s = configured();
let saved = crate::io::write_design(&s.doc);
assert_eq!(crate::io::read_design(&saved).unwrap().manufacturing, s.doc.manufacturing);
let r = s.execute("cam.generate", &json!({"program":1})).unwrap();
assert_eq!(r["state"], "generated");
let id = r["job"].clone();
s.execute("cam.operation.order", &json!({"program":1,"operations":[1]})).unwrap();
s.execute("solid.box", &json!({"length":2,"width":2,"height":2})).unwrap();
assert!(s.execute("cam.post", &json!({"job":id})).is_err());
assert_eq!(s.execute("cam.job.status", &json!({"job":id})).unwrap()["stale"], true);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("cam.job.status", &json!({"job":id})).unwrap()["stale"], false);
}
#[test]
fn operation_order_survives_reopen_undo_redo_and_controls_generated_sequence() {
let mut s = configured();
let mut second = s.doc.manufacturing.operations[0].clone();
second.id = 2;
second.name = "Second facing pass".into();
second.feed += 25.;
s.execute("cam.operation.put", &json!({"operation":second})).unwrap();
s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Ordered milling","setup":1,"operations":[1,2]}})).unwrap();
let job = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].clone();
for invalid in [json!([1, 1]), json!([2]), json!([1, 3])] {
let before = s.doc.clone();
assert!(s.execute("cam.operation.order", &json!({"program":1,"operations":invalid})).is_err());
assert_eq!(s.doc, before);
}
s.execute("cam.operation.order", &json!({"program":1,"operations":[2,1]})).unwrap();
assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], true);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.manufacturing.programs[0].operations, vec![1, 2]);
assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], false);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc.manufacturing.programs[0].operations, vec![2, 1]);
assert_eq!(s.execute("cam.job.status", &json!({"job":job})).unwrap()["stale"], true);
let doc = crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap();
let mut reopened = Session::new(doc);
let id = reopened.execute("cam.generate", &json!({"program":1})).unwrap()["job"].as_u64().unwrap();
let state = jobs::job(&reopened, id).unwrap().state().unwrap();
let path = match state {
jobs::JobState::Generated(path) => Some(path),
_ => None,
}
.unwrap();
let mut sequence = path.blocks.iter().map(|b| b.operation).collect::<Vec<_>>();
sequence.dedup();
assert_eq!(sequence, vec![2, 1]);
assert_eq!(reopened.doc.manufacturing.programs[0].operations, vec![2, 1]);
}
#[test]
fn referenced_tool_delete_rolls_back() {
let mut s = configured();
let d = s.doc.clone();
assert!(s.execute("cam.delete", &json!({"kind":"tool","id":1})).is_err());
assert_eq!(s.doc, d);
}
#[test]
fn missing_body_is_not_guessed() {
let mut s = configured();
s.doc_mut().manufacturing.setups[0].models[0].body = "missing".into();
assert!(s.execute("cam.generate", &json!({"program":1})).is_err());
}
#[test]
fn generate_simulate_post_and_reopen() {
let mut s = configured();
let r = s.execute("cam.generate", &json!({"program":1})).unwrap();
let id = r["job"].clone();
let v = s.execute("cam.simulate", &json!({"job":id})).unwrap();
assert_eq!(v["verification"]["status"], "pass", "{v}");
let posted = s.execute("cam.post", &json!({"job":id})).unwrap();
assert!(posted["content"].as_str().unwrap().contains("G21 G90 G17 G91.1 G94 G54 G40 G49 G80\nM5\nM9\n"));
for format in ["toolpath", "setup_sheet", "validation", "stock"] {
assert!(!s.execute("cam.export", &json!({"job":id,"format":format})).unwrap()["content"].as_str().unwrap().is_empty());
}
let doc = crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap();
let mut reopened = Session::new(doc);
assert!(reopened.execute("cam.post", &json!({"job":id})).is_err());
assert_eq!(reopened.execute("cam.generate", &json!({"program":1})).unwrap()["state"], "generated");
s.execute("cam.job.cancel", &json!({"job":id})).unwrap();
assert!(s.execute("cam.post", &json!({"job":id})).is_err());
}
#[test]
fn legacy_document_has_no_manufacturing() {
let mut value: Value = serde_json::from_slice(&crate::io::write_design(&solidcraft_doc::Document::default())).unwrap();
value["format"] = json!("solidcraft/11");
value.as_object_mut().unwrap().remove("manufacturing");
let migrated = crate::io::read_design(value.to_string().as_bytes()).unwrap();
assert!(migrated.manufacturing.is_empty());
}
#[test]
fn inconclusive_dimensions_cannot_be_posted() {
let mut s = configured();
let r = s.execute("cam.generate", &json!({"program":1})).unwrap();
let id = r["job"].as_u64().unwrap();
s.execute("cam.simulate", &json!({"job":id})).unwrap();
let job = jobs::job(&s, id).unwrap();
if let jobs::JobState::Verified(path, report) = job.state().unwrap() {
let mut report = (*report).clone();
report.finished_dimensions = cam::verify::Status::Inconclusive;
job.set(jobs::JobState::Verified(path, std::sync::Arc::new(report)));
}
assert!(s.execute("cam.post", &json!({"job":id})).is_err());
}
#[test]
fn drilled_cad_body_generates_verifies_posts_and_reopens() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":20,"width":20,"height":6})).unwrap();
s.execute("solid.hole", &json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":1,"depth":1,"type":"drilled","tip_angle":118})).unwrap();
let mut input = cam::corpus::fixture("drill").unwrap();
input.setup.models[0].body = s.model.state().bodies[0].name.clone();
input.operations[0].geometry = cam::Strategy::Drill { holes: vec![[10., 10.]], peck: Some(0.3), full_diameter: true };
s.execute("cam.setup.put", &json!({"setup":input.setup})).unwrap();
let body = s.model.state().bodies[0].clone();
let face = (0..body.body.face_count()).find(|i| crate::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap();
let selected =
s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":face}]})).unwrap();
input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap();
assert!((selected["diameters"][0].as_f64().unwrap() - 1.).abs() < 1e-8);
let center: Vec<[f64; 2]> = serde_json::from_value(selected["holes"].clone()).unwrap();
assert!((center[0][0] - 10.).abs() < 1e-8 && (center[0][1] - 10.).abs() < 1e-8);
s.execute("cam.tool.put", &json!({"tool":input.tools[0]})).unwrap();
s.execute("cam.operation.put", &json!({"operation":input.operations[0]})).unwrap();
s.execute("cam.program.put", &json!({"program":{"id":1,"name":"Drilled plate","setup":1,"operations":[1]}})).unwrap();
let generated = s.execute("cam.generate", &json!({"program":1})).unwrap();
let id = generated["job"].clone();
let verified = s.execute("cam.simulate", &json!({"job":id})).unwrap();
assert_eq!(verified["verification"]["status"], "pass", "{verified}");
assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{verified}");
assert!(s.execute("cam.post", &json!({"job":id})).unwrap()["content"].as_str().unwrap().contains("M30"));
let saved = crate::io::write_design(&s.doc);
let mut reopened = Session::new(crate::io::read_design(&saved).unwrap());
let generated = reopened.execute("cam.generate", &json!({"program":1})).unwrap();
let verified = reopened.execute("cam.simulate", &json!({"job":generated["job"]})).unwrap();
assert_eq!(verified["verification"]["finished_dimensions"], "pass");
}
#[test]
fn milled_cad_pocket_and_contour_generate_verify_post_and_reopen() {
let mut model = Session::default();
model.execute("solid.box", &json!({"length":20,"width":20,"height":6})).unwrap();
model.execute("solid.hole", &json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":16,"depth":1,"type":"simple"})).unwrap();
model.execute("sketch.create", &json!({"plane":"XY","name":"Pocket boundary"})).unwrap();
model.execute("sketch.circle.center", &json!({"center":[10,10],"radius":8,"id":"wall"})).unwrap();
model.execute("sketch.finish", &json!({})).unwrap();
let sketch = model.model.state().sketches.last().unwrap().feature;
for name in ["contour", "pocket"] {
let mut s = Session::new((*model.doc).clone());
let mut input = cam::corpus::fixture(name).unwrap();
input.setup.models[0].body = s.model.state().bodies[0].name.clone();
input.operations[0].ramp_angle = Some(5_f64.to_radians());
s.execute("cam.setup.put", &json!({"setup":input.setup})).unwrap();
let selected = s.execute("cam.boundary.from_profile", &json!({"setup":1,"sketch":sketch,"profile":0})).unwrap();
input.operations[0].source = Some(serde_json::from_value(selected["source"].clone()).unwrap());
match &mut input.operations[0].geometry {
cam::Strategy::Contour { boundary, .. } | cam::Strategy::Pocket { boundary } => {
*boundary = serde_json::from_value(selected["boundary"].clone()).unwrap()
}
_ => {}
}
s.execute("cam.tool.put", &json!({"tool":input.tools[0]})).unwrap();
s.execute("cam.operation.put", &json!({"operation":input.operations[0]})).unwrap();
s.execute("cam.program.put", &json!({"program":{"id":1,"name":name,"setup":1,"operations":[1]}})).unwrap();
let generated = s.execute("cam.generate", &json!({"program":1})).unwrap();
let id = generated["job"].clone();
let verified = s.execute("cam.simulate", &json!({"job":id})).unwrap();
assert_eq!(verified["verification"]["status"], "pass", "{name}: {verified}");
assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{name}: {verified}");
assert!(s.execute("cam.post", &json!({"job":id})).unwrap()["content"].as_str().unwrap().contains("M30"));
let mut reopened = Session::new(crate::io::read_design(&crate::io::write_design(&s.doc)).unwrap());
let generated = reopened.execute("cam.generate", &json!({"program":1})).unwrap();
let verified = reopened.execute("cam.simulate", &json!({"job":generated["job"]})).unwrap();
assert_eq!(verified["verification"]["finished_dimensions"], "pass", "{name}: {verified}");
}
}
#[test]
fn diagnostic_inspection_identifies_posted_operation_and_rejects_stale_results() {
let mut s = configured();
assert!(s.execute("cam.inspect", &json!({"finding":0})).is_err());
let origin = crate::geom::Vec3::new(100., 200., 0.);
let mut setup = s.doc.manufacturing.setups[0].clone();
setup.frame.origin = origin;
setup.stock.min -= origin;
setup.stock.max -= origin;
s.execute("cam.setup.put", &json!({"setup":setup})).unwrap();
let mut operation = s.doc.manufacturing.operations[0].clone();
operation.bottom = 3.;
s.execute("cam.operation.put", &json!({"operation":operation})).unwrap();
let id = s.execute("cam.generate", &json!({"program":1})).unwrap()["job"].clone();
assert!(s.execute("cam.inspect", &json!({"job":id,"finding":0})).is_err());
let report = s.execute("cam.simulate", &json!({"job":id})).unwrap();
assert_eq!(report["verification"]["status"], "fail");
let index = report["verification"]["findings"].as_array().unwrap().iter().position(|f| f["source"].is_object()).unwrap();
let inspected = s.execute("cam.inspect", &json!({"job":id,"finding":index})).unwrap();
let diagnostic = &inspected["diagnostic"];
assert_eq!(diagnostic["operation"]["id"], 1);
assert_eq!(diagnostic["playback"]["completed"], diagnostic["finding"]["source"]["block"]);
assert!(diagnostic["gcode_line"].as_str().unwrap().starts_with("G1"));
let at: crate::geom::Vec3 = serde_json::from_value(diagnostic["finding"]["at"].clone()).unwrap();
assert_eq!(diagnostic["world"], json!(at + origin));
assert!(s.execute("cam.inspect", &json!({"job":id,"finding":-1})).is_err());
assert!(s.execute("cam.inspect", &json!({"job":id,"finding":999999})).is_err());
assert!(s.execute("cam.post", &json!({"job":id})).is_err());
let exported = s.execute("cam.export", &json!({"job":id,"format":"validation"})).unwrap();
assert!(exported["content"].as_str().unwrap().contains("source"));
s.execute("solid.box", &json!({"length":1,"width":1,"height":1})).unwrap();
assert!(s.execute("cam.inspect", &json!({"job":id,"finding":index})).is_err());
}
}
#[cfg(test)]
#[path = "cam_holes_tests.rs"]
mod holes_tests;
-201
View File
@@ -1,201 +0,0 @@
#![allow(clippy::unwrap_used)]
use super::*;
use crate::geom::Vec3;
fn run(s: &mut Session, cmd: &str, value: Value) -> Value {
s.execute(cmd, &value).unwrap()
}
fn linked_profiles() -> (Session, u64) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.circle.center", json!({"center":[4,5],"radius":0.5,"id":"a"}));
run(&mut s, "sketch.circle.center", json!({"center":[12,15],"radius":0.5,"id":"b"}));
run(&mut s, "sketch.finish", json!({}));
let state = s.model.state();
let sketch = state.sketches.last().unwrap();
let id = sketch.feature;
let a = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"a".to_string())).unwrap();
let b = sketch.profiles.iter().position(|p| p.curve_ids().contains(&"b".to_string())).unwrap();
let mut input = cam::corpus::fixture("drill").unwrap();
input.setup.models[0].body = state.bodies[0].name.clone();
input.setup.frame.origin = Vec3::new(100., -50., 0.);
input.setup.stock.min -= input.setup.frame.origin;
input.setup.stock.max -= input.setup.frame.origin;
run(&mut s, "cam.setup.put", json!({"setup":input.setup}));
let selected = run(
&mut s,
"cam.holes.from_selection",
json!({"setup":1,"selections":[
{"kind":"profile","sketch":id,"index":b},{"kind":"profile","sketch":id,"index":a}
]}),
);
let holes: Vec<[f64; 2]> = serde_json::from_value(selected["holes"].clone()).unwrap();
assert_eq!(holes, vec![[-88., 65.], [-96., 55.]]);
input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap();
input.operations[0].geometry = cam::Strategy::Drill { holes, peck: Some(0.3), full_diameter: false };
run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]}));
run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]}));
run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Selected holes","setup":1,"operations":[1]}}));
(s, id)
}
fn centers(s: &mut Session) -> (u64, Vec<[f64; 2]>) {
let generated = run(s, "cam.generate", json!({"program":1}));
assert_eq!(generated["state"], "generated", "{generated}");
let id = generated["job"].as_u64().unwrap();
let job = jobs::job(s, id).unwrap();
let op = &job.input().unwrap().operations[0];
let holes = match &op.geometry {
cam::Strategy::Drill { holes, .. } => holes.clone(),
_ => vec![],
};
(id, holes)
}
#[test]
fn hole_selection_follows_profile_edits_frames_undo_and_reopen() {
let (mut s, sketch) = linked_profiles();
let (old, before) = centers(&mut s);
let saved = crate::io::write_design(&s.doc);
let mut reopened = Session::new(crate::io::read_design(&saved).unwrap());
assert_eq!(centers(&mut reopened).1, before);
run(&mut s, "sketch.edit", json!({"sketch":sketch}));
run(&mut s, "sketch.move", json!({"entities":["a"],"translate":[2,3]}));
assert_eq!(run(&mut s, "cam.job.status", json!({"job":old}))["stale"], true);
assert!(s.execute("cam.post", &json!({"job":old})).is_err());
assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(centers(&mut s).1, before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(centers(&mut s).1, vec![[-88., 65.], [-94., 58.]]);
// Unassociated drilling preserves manually authored coordinates on reopen.
let mut legacy: Value = serde_json::from_slice(&crate::io::write_design(&s.doc)).unwrap();
legacy["manufacturing"]["operations"][0].as_object_mut().unwrap().remove("hole_sources");
let doc = crate::io::read_design(legacy.to_string().as_bytes()).unwrap();
assert!(doc.manufacturing.operations[0].hole_sources.is_empty());
assert_eq!(doc.manufacturing.operations[0].geometry, s.doc.manufacturing.operations[0].geometry);
}
#[test]
fn hole_selection_rejects_missing_duplicate_tilted_and_wrong_size_sources() {
let (mut s, sketch) = linked_profiles();
let pick = json!({"kind":"profile","sketch":sketch,"index":0});
for picks in [json!([]), json!([pick.clone(), pick.clone()]), json!([{"kind":"profile","sketch":sketch,"index":999}])] {
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":picks})).is_err());
}
let excessive = vec![pick.clone(); cam::MAX_ITEMS + 1];
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":excessive})).is_err());
let mut invalid = s.doc.manufacturing.operations[0].clone();
invalid.hole_sources.pop();
assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err());
let mut invalid = s.doc.manufacturing.operations[0].clone();
invalid.hole_sources[1] = invalid.hole_sources[0].clone();
assert!(s.execute("cam.operation.put", &json!({"operation":invalid})).is_err());
let mut setup = s.doc.manufacturing.setups[0].clone();
setup.frame = cam::Frame { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z, z: -Vec3::Y };
run(&mut s, "cam.setup.put", json!({"setup":setup}));
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[pick]})).is_err());
run(&mut s, "edit.undo", json!({}));
let mut tool = s.doc.manufacturing.tools[0].clone();
tool.diameter = 0.8;
run(&mut s, "cam.tool.put", json!({"tool":tool}));
let failed = run(&mut s, "cam.generate", json!({"program":1}));
assert_eq!(failed["state"], "failed");
assert!(failed["error"].as_str().unwrap().contains("diameter"));
assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err());
run(&mut s, "edit.undo", json!({}));
let mut op = s.doc.manufacturing.operations[0].clone();
if let cam::HoleSource::Profile { profile } = &mut op.hole_sources[0] {
profile.curves = vec!["missing".into()];
}
run(&mut s, "cam.operation.put", json!({"operation":op}));
assert!(s.execute("cam.generate", &json!({"program":1})).unwrap_err().to_string().contains("reselect"));
}
#[test]
fn hole_selection_keeps_exact_occurrence_and_rejects_lost_faces() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Drilled part"}));
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":6}));
run(&mut s, "solid.hole", json!({"position":[10,10,6],"direction":[0,0,-1],"diameter":1,"depth":1,"type":"drilled","tip_angle":118}));
run(&mut s, "component.activate", json!({"component":0}));
let second = run(&mut s, "occurrence.copy", json!({"component":component["component"],"translate":[30,0,0]}))["occurrence"].as_u64().unwrap();
let body = s.model.state().bodies[0].clone();
let face = (0..body.body.face_count()).find(|i| crate::kernel::hole_axis(&body.body, *i, 0.0005).is_ok()).unwrap();
let mut input = cam::corpus::fixture("drill").unwrap();
input.setup.models = vec![cam::BodyRef { body: body.name.clone(), occurrence: vec![second] }];
input.setup.frame.origin = Vec3::new(20., 0., 0.);
input.setup.stock.min.x += 10.;
input.setup.stock.max.x += 20.;
run(&mut s, "cam.setup.put", json!({"setup":input.setup}));
let selected = run(
&mut s,
"cam.holes.from_selection",
json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":face,"occurrence":[second]}]}),
);
// Viewport labels resolve through the same setup occurrence as explicit command picks.
let state = s.model.state();
let placed = s.placed_document();
let label = placed.world_body_name(&state, &body.name, placed.model_body_component(&body), &[second]);
let from_viewport = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":[{"kind":"face","body":label,"index":face}]}));
assert_eq!(from_viewport, selected);
assert!(crate::kernel::hole_axis(&body.body, face, f64::NAN).is_err());
assert!(crate::kernel::hole_axis(&body.body, usize::MAX, 0.0005).is_err());
let plane = (0..body.body.face_count())
.find(|i| matches!(crate::kernel::face_surface(&body.body, *i), Some(crate::kernel::FaceSurface::Plane { .. })))
.unwrap();
assert!(
s.execute(
"cam.holes.from_selection",
&json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":plane,"occurrence":[second]}]})
)
.is_err()
);
input.operations[0].hole_sources = serde_json::from_value(selected["sources"].clone()).unwrap();
run(&mut s, "cam.tool.put", json!({"tool":input.tools[0]}));
run(&mut s, "cam.operation.put", json!({"operation":input.operations[0]}));
run(&mut s, "cam.program.put", json!({"program":{"id":1,"name":"Placed holes","setup":1,"operations":[1]}}));
let (job, before) = centers(&mut s);
assert!((before[0][0] - 20.).abs() < 1e-8 && (before[0][1] - 10.).abs() < 1e-8);
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
assert_eq!(run(&mut s, "cam.job.status", json!({"job":job}))["stale"], true);
let moved = centers(&mut s).1;
assert!((moved[0][0] - 25.).abs() < 1e-8);
let mut op = s.doc.manufacturing.operations[0].clone();
if let cam::HoleSource::Face { name, .. } = &mut op.hole_sources[0] {
*name = "missing:face".into();
}
run(&mut s, "cam.operation.put", json!({"operation":op}));
let failed = run(&mut s, "cam.generate", json!({"program":1}));
assert_eq!(failed["state"], "failed");
assert!(failed["error"].as_str().unwrap().contains("reselect"));
assert!(s.execute("cam.post", &json!({"job":failed["job"]})).is_err());
}
#[test]
fn hole_selection_combines_seam_faces_but_rejects_exterior_cylinders() {
let mut s = Session::default();
s.run_script(&crate::sample::script()).unwrap();
let body = s.model.state().bodies[0].clone();
let mut setup = cam::corpus::fixture("drill").unwrap().setup;
setup.models[0].body = body.name.clone();
run(&mut s, "cam.setup.put", json!({"setup":setup}));
let mut picks = vec![];
for index in 0..body.body.face_count() {
if let Ok((origin, _, _)) = crate::kernel::hole_axis(&body.body, index, 0.0005) {
if (origin.x - 68.).abs() < 1e-8 {
picks.push(json!({"kind":"face","body":body.name,"index":index}));
}
} else if matches!(crate::kernel::face_surface(&body.body, index), Some(crate::kernel::FaceSurface::Cylinder { .. })) {
assert!(
s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":[{"kind":"face","body":body.name,"index":index}]})).is_err()
);
}
}
assert!(picks.len() >= 2, "the sample must exercise multiple seam faces on one drilled wall");
let mut duplicate = picks.clone();
duplicate.push(picks[1].clone());
assert!(s.execute("cam.holes.from_selection", &json!({"setup":1,"selections":duplicate})).is_err());
let result = run(&mut s, "cam.holes.from_selection", json!({"setup":1,"selections":picks}));
let centers: Vec<[f64; 2]> = serde_json::from_value(result["holes"].clone()).unwrap();
assert_eq!(centers.len(), 1);
assert!((centers[0][0] - 68.).abs() < 1e-8 && (centers[0][1] - 25.).abs() < 1e-8);
assert_eq!(result["sources"].as_array().unwrap().len(), 1);
}
-2
View File
@@ -21,7 +21,6 @@ macro_rules! planned {
}
mod browser;
mod cam;
mod clipboard;
mod compare;
mod component;
@@ -253,7 +252,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(cam::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
-6
View File
@@ -14,8 +14,6 @@ pub mod crash;
pub mod frames;
pub mod fuzz;
pub mod licences;
pub mod manufacturing;
pub use solidcraft_cam as cam;
pub mod occurrences;
pub mod params;
pub mod product_scene;
@@ -254,8 +252,6 @@ pub struct Session {
/// Host-owned viewport metadata, never saved or included in history.
render_viewport: Option<Arc<render::ExecutorDescriptor>>,
render_jobs: render_jobs::Jobs,
/// Derived, immutable CAM jobs; not persisted or copied into undo snapshots.
pub cam_jobs: manufacturing::Jobs,
/// The visibility the design file holds (what `is_dirty` compares with).
saved_visibility: Visibility,
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
@@ -381,7 +377,6 @@ impl Session {
camera: None,
render_viewport: None,
render_jobs: Default::default(),
cam_jobs: Default::default(),
#[cfg(test)]
evaluations: 0,
crash_journal: false,
@@ -849,7 +844,6 @@ impl Session {
camera: self.camera,
render_viewport: self.render_viewport.clone(),
render_jobs: self.render_jobs.scratch(),
cam_jobs: Default::default(),
saved_visibility: self.saved_visibility.clone(),
#[cfg(test)]
evaluations: 0,
-461
View File
@@ -1,461 +0,0 @@
//! Immutable CAM jobs, independent of the desktop UI and document undo state.
use crate::{EngineError, Result, Session, cam};
pub mod exports;
pub(crate) mod holes;
mod snapshot;
use std::collections::BTreeMap;
use std::sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
#[derive(Default)]
pub struct Jobs {
pub jobs: BTreeMap<u64, Arc<Job>>,
pub next: u64,
}
pub struct Job {
pub source_doc: Arc<solidcraft_doc::Document>,
pub source_moves: BTreeMap<u64, solidcraft_doc::Mat>,
input: Arc<OnceLock<cam::JobInput>>,
snapshot: Mutex<Option<snapshot::Snapshot>>,
stock: Arc<Mutex<Option<std::result::Result<Arc<crate::geom::Mesh>, String>>>>,
pub(crate) export: Arc<Mutex<exports::State>>,
pub cancelled: Arc<AtomicBool>,
#[cfg(target_arch = "wasm32")]
pending: Mutex<Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<JobState>>>>>>,
pub progress: Arc<AtomicUsize>,
pub result: Arc<Mutex<JobState>>,
}
#[derive(Clone)]
pub enum JobState {
Preparing,
Running,
Generated(Arc<cam::Toolpath>),
Verifying(Arc<cam::Toolpath>),
Verified(Arc<cam::Toolpath>, Arc<cam::verify::Report>),
Failed(String),
Cancelled,
}
impl Job {
/// Display geometry is a derived cache. It never substitutes for a verification report.
pub fn stock_mesh(&self) -> Result<Option<Arc<crate::geom::Mesh>>> {
if !matches!(self.state()?, JobState::Verified(..)) {
return Ok(None);
}
match self.stock.lock().map_err(|_| EngineError::Other("CAM stock display unavailable".into()))?.as_ref() {
Some(Ok(mesh)) => Ok(Some(mesh.clone())),
Some(Err(e)) => Err(EngineError::Other(e.clone())),
None => Ok(None),
}
}
pub fn input(&self) -> Result<&cam::JobInput> {
self.input.get().ok_or_else(|| EngineError::Other("CAM input is still being prepared".into()))
}
pub fn state(&self) -> Result<JobState> {
self.result.lock().map(|s| s.clone()).map_err(|_| EngineError::Other("CAM worker state unavailable".into()))
}
pub fn set(&self, state: JobState) {
if let Ok(mut s) = self.result.lock() {
*s = if self.cancelled.load(Ordering::Relaxed) { JobState::Cancelled } else { state };
}
}
}
pub fn error(e: cam::Error) -> EngineError {
EngineError::Other(e.to_string())
}
pub fn input(s: &Session, program: u64) -> Result<cam::JobInput> {
snapshot::ready(snapshot::Snapshot::capture(s, program)?.build(&|| false, &|_| {}, &mut || std::future::ready(())))
}
pub fn profile_boundary(s: &Session, setup: &cam::Setup, source: &cam::ProfileRef, tolerance: f64) -> Result<cam::Boundary> {
snapshot::ready(snapshot::Profile::capture(s, setup, source, tolerance)?.sample(&|| false, &mut || std::future::ready(())))
}
pub fn start(s: &mut Session, program: u64, background: bool) -> Result<u64> {
if s.cam_jobs.jobs.len() >= 16 {
let removable = s.cam_jobs.jobs.iter().find_map(|(id, j)| match j.state() {
Ok(JobState::Preparing | JobState::Running | JobState::Verifying(_)) => None,
Ok(JobState::Generated(_) | JobState::Verified(..)) if exports::busy(j).unwrap_or(true) => None,
_ => Some(*id),
});
if let Some(id) = removable {
s.cam_jobs.jobs.remove(&id);
} else {
return Err(EngineError::Other("too many running CAM jobs".into()));
}
}
let snapshot = snapshot::Snapshot::capture(s, program)?;
let job = Arc::new(Job {
source_doc: s.doc.clone(),
source_moves: s.pending_moves.clone(),
input: Arc::new(OnceLock::new()),
snapshot: Mutex::new(Some(snapshot)),
stock: Arc::new(Mutex::new(None)),
export: Arc::new(Mutex::new(exports::State::Idle)),
cancelled: Arc::new(AtomicBool::new(false)),
#[cfg(target_arch = "wasm32")]
pending: Mutex::new(None),
progress: Arc::new(AtomicUsize::new(0)),
result: Arc::new(Mutex::new(JobState::Preparing)),
});
s.cam_jobs.next = s.cam_jobs.next.checked_add(1).ok_or_else(|| EngineError::Other("CAM job ID exhausted".into()))?;
let id = s.cam_jobs.next;
s.cam_jobs.jobs.insert(id, job.clone());
dispatch(job, background, false)?;
Ok(id)
}
pub fn dispatch(job: Arc<Job>, background: bool, verify: bool) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
if background {
let failed = job.clone();
if let Err(e) = std::thread::Builder::new().name("cam".into()).spawn(move || run(job, verify)) {
failed.set(JobState::Failed(e.to_string()));
return Err(EngineError::Other(e.to_string()));
}
return Ok(());
}
#[cfg(target_arch = "wasm32")]
if background {
let future = task(&job, verify)?;
*job.pending.lock().map_err(|_| EngineError::Other("CAM scheduler unavailable".into()))? = Some(Box::pin(future));
return Ok(());
}
run(job, verify);
Ok(())
}
// Capture owned task components instead of Job itself: a browser's pending
// future must not keep its owning Job alive through an Arc cycle.
fn task(job: &Job, verify: bool) -> Result<impl std::future::Future<Output = Result<JobState>> + use<>> {
let input = job.input.clone();
let cancelled = job.cancelled.clone();
let progress = job.progress.clone();
let state = job.result.clone();
let stock = job.stock.clone();
let path = if verify {
match job.state()? {
JobState::Verifying(path) => Some(path),
_ => return Err(EngineError::Other("generate a path before simulation".into())),
}
} else {
None
};
let snapshot = if verify { None } else { job.snapshot.lock().map_err(|_| EngineError::Other("CAM snapshot unavailable".into()))?.take() };
Ok(async move {
YieldOnce::default().await;
if cancelled.load(Ordering::Relaxed) {
return Ok(JobState::Cancelled);
}
if let Some(path) = path {
let input = input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?;
let parsed = cam::nc::round_trip_cooperative(&path, &input.setup, &input.tools, || cancelled.load(Ordering::Relaxed), YieldOnce::default)
.await
.map_err(error)?;
let report =
cam::verify::simulate_cooperative(input, &parsed, Default::default(), || cancelled.load(Ordering::Relaxed), YieldOnce::default)
.await
.map_err(error)?;
YieldOnce::default().await;
if cancelled.load(Ordering::Relaxed) {
return Ok(JobState::Cancelled);
}
let display = stock_display(&report, input.setup.frame, input.setup.stock.min.z, &cancelled).await;
if cancelled.load(Ordering::Relaxed) {
return Ok(JobState::Cancelled);
}
if let Ok(mut cache) = stock.lock() {
*cache = Some(display.map(Arc::new).map_err(|e| e.to_string()));
}
Ok(JobState::Verified(path, Arc::new(report)))
} else {
let snapshot = snapshot.ok_or_else(|| EngineError::Other("CAM snapshot already consumed".into()))?;
let prepared = snapshot
.build(
&|| cancelled.load(Ordering::Relaxed),
&|n| {
progress.store(n, Ordering::Relaxed);
},
&mut YieldOnce::default,
)
.await?;
if cancelled.load(Ordering::Relaxed) {
return Ok(JobState::Cancelled);
}
input.set(prepared).map_err(|_| EngineError::Other("CAM snapshot already prepared".into()))?;
if let Ok(mut state) = state.lock() {
if cancelled.load(Ordering::Relaxed) {
return Ok(JobState::Cancelled);
}
*state = JobState::Running;
}
let mut generator =
cam::generate::Generator::new(input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?.clone())
.map_err(error)?;
loop {
YieldOnce::default().await;
if cancelled.load(Ordering::Relaxed) {
return Ok(JobState::Cancelled);
}
let done = generator.step().map_err(error)?;
progress.store(20 + (generator.progress() * 80.) as usize, Ordering::Relaxed);
if done {
break;
}
}
Ok(JobState::Generated(Arc::new(generator.finish().map_err(error)?)))
}
})
}
async fn stock_display(report: &cam::verify::Report, frame: cam::Frame, bottom: f64, cancelled: &AtomicBool) -> Result<crate::geom::Mesh> {
let triangles =
cam::verify::stock_triangles_cooperative(report, bottom, || cancelled.load(Ordering::Relaxed), YieldOnce::default).await.map_err(error)?;
let mut mesh = crate::geom::Mesh::default();
for (i, triangle) in triangles.into_iter().enumerate() {
if i.is_multiple_of(256) {
YieldOnce::default().await;
if cancelled.load(Ordering::Relaxed) {
return Err(EngineError::Other("CAM stock display cancelled".into()));
}
}
let triangle = triangle.map(|p| frame.world(p));
let normal = (triangle[1] - triangle[0])
.cross(triangle[2] - triangle[0])
.normalized()
.ok_or_else(|| EngineError::Other("invalid stock display triangle".into()))?;
let base = mesh.positions.len() as u32;
mesh.positions.extend(triangle);
mesh.normals.extend([normal; 3]);
mesh.triangles.push([base, base + 1, base + 2]);
}
Ok(mesh)
}
fn run(job: Arc<Job>, verify: bool) {
match task(&job, verify) {
Ok(future) => run_future(job, future, true),
Err(e) => job.set(JobState::Failed(e.to_string())),
}
}
fn run_future(job: Arc<Job>, future: impl std::future::Future<Output = Result<JobState>>, publish_state: bool) {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<JobState> {
let mut future = std::pin::pin!(future);
loop {
if job.cancelled.load(Ordering::Relaxed) {
return Ok(JobState::Cancelled);
}
if let std::task::Poll::Ready(result) =
std::future::Future::poll(future.as_mut(), &mut std::task::Context::from_waker(std::task::Waker::noop()))
{
return result;
}
}
}));
let state = match outcome {
Ok(Ok(state)) => state,
Ok(Err(e)) => JobState::Failed(e.to_string()),
Err(_) => JobState::Failed("CAM worker failed; no result published".into()),
};
if publish_state {
job.set(state);
} else if let JobState::Failed(error) = state
&& let Ok(mut export) = job.export.lock()
{
*export = exports::State::Failed(error);
}
}
pub fn job(s: &Session, id: u64) -> Result<Arc<Job>> {
s.cam_jobs.jobs.get(&id).cloned().ok_or_else(|| EngineError::Other("CAM job missing; regenerate after reopening".into()))
}
pub fn current(s: &Session, job: &Job) -> Result<()> {
if (!Arc::ptr_eq(&s.doc, &job.source_doc) && *s.doc != *job.source_doc) || s.pending_moves != job.source_moves {
Err(EngineError::Other("CAM result is stale; regenerate after editing the design, tools or setup".into()))
} else {
Ok(())
}
}
/// Resume one browser job checkpoint. Returns true while more work is pending.
/// The host controls its frame budget; status queries can still advance one step.
#[cfg(target_arch = "wasm32")]
pub fn advance(job: &Job) -> bool {
let Ok(mut pending) = job.pending.lock() else { return false };
if job.cancelled.load(Ordering::Relaxed) {
*pending = None;
job.set(JobState::Cancelled);
return false;
}
let Some(future) = pending.as_mut() else { return false };
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
future.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop()))
}));
let state = match result {
Ok(std::task::Poll::Pending) => return true,
Ok(std::task::Poll::Ready(Ok(state))) => state,
Ok(std::task::Poll::Ready(Err(error))) => JobState::Failed(error.to_string()),
Err(_) => JobState::Failed("CAM worker failed; no result published".into()),
};
*pending = None;
job.progress.store(100, Ordering::Relaxed);
job.set(state);
false
}
#[derive(Default)]
struct YieldOnce(bool);
impl std::future::Future for YieldOnce {
type Output = ();
fn poll(mut self: std::pin::Pin<&mut Self>, _: &mut std::task::Context<'_>) -> std::task::Poll<()> {
if self.0 {
std::task::Poll::Ready(())
} else {
self.0 = true;
std::task::Poll::Pending
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
use std::{
future::Future,
task::{Context, Poll, Waker},
};
fn captured() -> (Session, Arc<Job>) {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":20,"width":20,"height":5})).unwrap();
let mut input = cam::corpus::fixture("face").unwrap();
input.setup.models[0].body = s.model.state().bodies[0].name.clone();
let m = &mut s.doc_mut().manufacturing;
m.setups.push(input.setup);
m.tools = input.tools;
m.operations = input.operations;
m.programs.push(cam::Program { id: 1, name: "Test".into(), setup: 1, operations: vec![1] });
let job = Arc::new(Job {
source_doc: s.doc.clone(),
source_moves: s.pending_moves.clone(),
input: Arc::new(OnceLock::new()),
snapshot: Mutex::new(Some(snapshot::Snapshot::capture(&s, 1).unwrap())),
stock: Arc::new(Mutex::new(None)),
export: Arc::new(Mutex::new(exports::State::Idle)),
cancelled: Arc::new(AtomicBool::new(false)),
#[cfg(target_arch = "wasm32")]
pending: Mutex::new(None),
progress: Arc::new(AtomicUsize::new(0)),
result: Arc::new(Mutex::new(JobState::Preparing)),
});
(s, job)
}
#[test]
fn completed_export_worker_cannot_restore_state_after_result_collection() {
let (_, job) = captured();
let ready = Arc::new(std::sync::Barrier::new(2));
let collected = Arc::new(std::sync::Barrier::new(2));
let worker_job = job.clone();
let worker_ready = ready.clone();
let worker_collected = collected.clone();
let worker = std::thread::spawn(move || {
let result = worker_job.export.clone();
run_future(
worker_job,
async move {
*result.lock().unwrap() = exports::State::Ready { content: "complete".into(), destination: None };
worker_ready.wait();
worker_collected.wait();
Ok(JobState::Preparing)
},
false,
);
});
ready.wait();
assert_eq!(exports::take(&job).unwrap().0, "complete");
job.set(JobState::Running);
collected.wait();
worker.join().unwrap();
assert!(matches!(job.state().unwrap(), JobState::Running));
}
#[test]
fn snapshot_is_immutable_and_preparation_is_deferred() {
let (mut s, job) = captured();
let expected = input(&s, 1).unwrap();
let mut future = std::pin::pin!(task(&job, false).unwrap());
let mut cx = Context::from_waker(Waker::noop());
assert!(future.as_mut().poll(&mut cx).is_pending());
assert!(job.input().is_err());
s.execute("solid.box", &json!({"length":2,"width":2,"height":2})).unwrap();
s.doc_mut().manufacturing.operations[0].feed = 900.;
let mut polls = 0;
let mut previous = 0;
let state = loop {
match future.as_mut().poll(&mut cx) {
Poll::Pending => {
polls += 1;
let progress = job.progress.load(Ordering::Relaxed);
assert!(progress >= previous);
previous = progress;
}
Poll::Ready(result) => break result.unwrap(),
}
};
assert!(polls > 2);
assert!(matches!(state, JobState::Generated(_)));
assert_eq!(serde_json::to_value(job.input().unwrap()).unwrap(), serde_json::to_value(&expected).unwrap());
assert!(current(&s, &job).is_err());
}
#[test]
fn preparation_cancellation_never_publishes_or_revives_job() {
let (_, job) = captured();
let mut future = std::pin::pin!(task(&job, false).unwrap());
let mut cx = Context::from_waker(Waker::noop());
for _ in 0..2 {
assert!(future.as_mut().poll(&mut cx).is_pending());
}
job.cancelled.store(true, Ordering::Relaxed);
job.set(JobState::Cancelled);
let result = loop {
if let Poll::Ready(result) = future.as_mut().poll(&mut cx) {
break result;
}
};
assert!(result.is_err() || matches!(result, Ok(JobState::Cancelled)));
assert!(job.input().is_err());
job.set(JobState::Running);
assert!(matches!(job.state().unwrap(), JobState::Cancelled));
}
#[test]
fn native_worker_prepares_generates_and_releases_its_snapshot() {
let (_, job) = captured();
let worker = job.clone();
std::thread::spawn(move || run(worker, false)).join().unwrap();
assert!(matches!(job.state().unwrap(), JobState::Generated(_)));
assert!(job.input().is_ok());
assert!(job.snapshot.lock().unwrap().is_none());
assert_eq!(job.progress.load(Ordering::Relaxed), 100);
}
#[test]
fn cam_snapshot_uses_fixed_tolerance_for_large_curved_geometry() {
let mut session = Session::default();
session.execute("solid.cylinder", &json!({"radius":5000,"height":6})).unwrap();
let mut recipe = cam::corpus::fixture("face").unwrap();
recipe.setup.models[0].body = session.model.state().bodies[0].name.clone();
recipe.setup.stock.min = crate::geom::Vec3::new(-5000., -5000., 0.);
recipe.setup.stock.max = crate::geom::Vec3::new(5000., 5000., 7.);
let manufacturing = &mut session.doc_mut().manufacturing;
manufacturing.setups.push(recipe.setup);
manufacturing.tools = recipe.tools;
manufacturing.operations = recipe.operations;
manufacturing.programs.push(cam::Program { id: 1, name: "Large cylinder".into(), setup: 1, operations: vec![1] });
let prepared = input(&session, 1).unwrap();
let mut checked = 0;
for triangle in &prepared.geometry.target {
if triangle.iter().any(|p| p.z < 1.) && triangle.iter().any(|p| p.z > 5.) {
for i in 0..3 {
let midpoint = (triangle[i] + triangle[(i + 1) % 3]) * 0.5;
assert!((5000. - midpoint.x.hypot(midpoint.y)).abs() <= cam::VERIFY_ERROR / 2.);
checked += 1;
}
}
}
assert!(checked > 1000);
}
}
-119
View File
@@ -1,119 +0,0 @@
//! Artifact jobs retain immutable source data; only a fresh result command may publish output.
use super::*;
#[derive(Default)]
pub(crate) enum State {
#[default]
Idle,
Running,
Ready {
content: String,
destination: Option<String>,
},
Failed(String),
}
pub fn status(job: &Job) -> Result<serde_json::Value> {
if job.cancelled.load(Ordering::Relaxed) {
return Ok(serde_json::json!({"state":"cancelled"}));
}
let state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?;
Ok(match &*state {
State::Idle => serde_json::json!({"state":"idle"}),
State::Running => serde_json::json!({"state":"running"}),
State::Ready { content, .. } => serde_json::json!({"state":"ready","bytes":content.len()}),
State::Failed(error) => serde_json::json!({"state":"failed","error":error}),
})
}
pub fn busy(job: &Job) -> Result<bool> {
Ok(matches!(*job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?, State::Running | State::Ready { .. }))
}
pub fn take(job: &Job) -> Result<(String, Option<String>)> {
if job.cancelled.load(Ordering::Relaxed) {
return Err(EngineError::Other("CAM export cancelled".into()));
}
let mut state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?;
if !matches!(*state, State::Ready { .. }) {
return Err(EngineError::Other("CAM export is not ready".into()));
}
match std::mem::take(&mut *state) {
State::Ready { content, destination } => Ok((content, destination)),
_ => Err(EngineError::Other("CAM export is not ready".into())),
}
}
pub fn start(job: Arc<Job>, format: String, destination: Option<String>) -> Result<()> {
if !matches!(format.as_str(), "gcode" | "toolpath" | "validation" | "stock" | "setup_sheet") {
return Err(EngineError::Other("unknown CAM artifact format".into()));
}
let original = job.state()?;
if !matches!(original, JobState::Generated(_) | JobState::Verified(..)) {
return Err(EngineError::Other("no generated result".into()));
}
{
let mut state = job.export.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?;
if matches!(*state, State::Running | State::Ready { .. }) {
return Err(EngineError::Other("finish or cancel the current export first".into()));
}
*state = State::Running;
}
#[cfg(not(target_arch = "wasm32"))]
{
let failed = job.clone();
if let Err(e) = std::thread::Builder::new().name("cam-export".into()).spawn(move || {
let future = task(&job, original, format, destination);
// A ready result may already have been collected and another job started.
// Completing an export must never restore its old generation/verification state.
run_future(job, future, false);
}) {
if let Ok(mut state) = failed.export.lock() {
*state = State::Failed(e.to_string());
}
return Err(EngineError::Other(e.to_string()));
}
}
#[cfg(target_arch = "wasm32")]
{
let future = task(&job, original, format, destination);
*job.pending.lock().map_err(|_| EngineError::Other("CAM scheduler unavailable".into()))? = Some(Box::pin(future));
}
Ok(())
}
fn task(job: &Job, original: JobState, format: String, destination: Option<String>) -> impl std::future::Future<Output = Result<JobState>> + use<> {
let input = job.input.clone();
let cancelled = job.cancelled.clone();
let result = job.export.clone();
async move {
YieldOnce::default().await;
let produce = async {
let input = input.get().ok_or_else(|| EngineError::Other("CAM prepared input missing".into()))?;
let (path, report) = match &original {
JobState::Generated(path) => (path, None),
JobState::Verified(path, report) => (path, Some(report.as_ref())),
_ => return Err(EngineError::Other("no generated result".into())),
};
if format == "gcode" {
let report = report.ok_or_else(|| EngineError::Other("simulate before posting".into()))?;
if report.status != cam::verify::Status::Pass || report.finished_dimensions != cam::verify::Status::Pass || !report.stock_complete {
return Err(EngineError::Other("posting blocked by failed or inconclusive verification".into()));
}
cam::nc::round_trip_cooperative(path, &input.setup, &input.tools, || cancelled.load(Ordering::Relaxed), YieldOnce::default)
.await
.map_err(error)?;
cam::nc::post_cooperative(path, &input.setup, || cancelled.load(Ordering::Relaxed), YieldOnce::default).await.map_err(error)
} else {
crate::io::cam::artifact_cooperative(input, path, report, &format, || cancelled.load(Ordering::Relaxed), YieldOnce::default)
.await
.map_err(|e| EngineError::Other(e.to_string()))
}
}
.await;
if cancelled.load(Ordering::Relaxed) {
return Ok(JobState::Cancelled);
}
let mut state = result.lock().map_err(|_| EngineError::Other("CAM export unavailable".into()))?;
*state = match produce {
Ok(content) => State::Ready { content, destination },
Err(error) => State::Failed(error.to_string()),
};
Ok(original)
}
}
-179
View File
@@ -1,179 +0,0 @@
//! Explicit hole picks and immutable resolution of their persistent identities.
use super::snapshot::{PlacedBody, Profile};
use crate::{
EngineError, Result, Session, cam,
geom::{Seg2, Vec3},
};
use serde::{Deserialize, Serialize};
use solidcraft_doc::apply_point;
fn bad(message: &str) -> EngineError {
EngineError::Other(message.into())
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub(crate) enum Pick {
Face {
body: String,
index: usize,
#[serde(default)]
occurrence: Option<Vec<u64>>,
},
Profile {
sketch: u64,
index: usize,
#[serde(default)]
occurrence: Vec<u64>,
},
}
#[derive(Serialize)]
pub(crate) struct Selected {
pub center: [f64; 2],
pub diameter: f64,
}
pub(super) enum Hole {
Face { body: Box<PlacedBody>, name: String },
Profile(Profile),
}
impl Hole {
pub(super) fn capture(s: &Session, setup: &cam::Setup, source: &cam::HoleSource, tolerance: f64) -> Result<Self> {
source.validate().map_err(super::error)?;
match source {
cam::HoleSource::Face { target, name } => {
if !setup.models.contains(target) {
return Err(bad("select a hole on one of this setup's model occurrences"));
}
Ok(Self::Face { body: Box::new(PlacedBody::capture(s, target)?), name: name.clone() })
}
cam::HoleSource::Profile { profile } => Ok(Self::Profile(Profile::capture(s, setup, profile, tolerance)?)),
}
}
pub(super) fn resolve(self, frame: cam::Frame, tolerance: f64) -> Result<Selected> {
let (origin, axis, diameter) = match self {
Self::Face { body, name } => {
let names = solidcraft_doc::naming::face_names(&body.body);
let mut indices = names.iter().enumerate().filter_map(|(i, n)| (*n == name).then_some(i));
let index = indices.next().ok_or_else(|| bad("CAM hole face missing; reselect it"))?;
if indices.next().is_some() {
return Err(bad("CAM hole face is ambiguous; reselect it"));
}
let (origin, axis, radius) = crate::kernel::hole_axis(&body.body.body, index, cam::profile_tolerance(tolerance))?;
let point = |p| frame.local(apply_point(&body.placement, p));
let center = point(origin);
let x = axis.any_perp();
let y = axis.cross(x);
let dx = point(origin + x) - center;
let dy = point(origin + y) - center;
if (dx.len() - dy.len()).abs() > 1e-9 || dx.dot(dy).abs() > 1e-9 {
return Err(bad("non-circular placed hole; select a rigid occurrence"));
}
(center, point(origin + axis) - center, 2. * radius * dx.len())
}
Self::Profile(profile) => {
if !profile.region.holes.is_empty() {
return Err(bad("select one circular profile per drill center"));
}
let Some(Seg2::Arc { center, radius, .. }) = profile.region.outer.segs.first() else {
return Err(bad("drilling profile must be circular"));
};
let (center, radius) = (*center, *radius);
let mut sweep = 0.;
let segments = &profile.region.outer.segs;
for (a, next) in segments.iter().zip(segments.iter().cycle().skip(1)).take(segments.len()) {
let Seg2::Arc { center: c, radius: r, sweep: angle, .. } = a else {
return Err(bad("drilling profile must be circular"));
};
if c.dist(center) > 1e-9 || (r - radius).abs() > 1e-9 || a.end().dist(next.start()) > 1e-8 {
return Err(bad("drilling profile must be a closed circle"));
}
sweep += angle;
}
if !sweep.is_finite() || (sweep.abs() - std::f64::consts::TAU).abs() > 1e-8 || radius <= 0. {
return Err(bad("drilling profile must be one complete circle"));
}
let (x, y) = (profile.plane.x, profile.plane.y);
if (x.len() - y.len()).abs() > 1e-9 || x.dot(y).abs() > 1e-9 {
return Err(bad("non-circular placed profile; select a rigid occurrence"));
}
(profile.plane.to_world(center), x.cross(y), 2. * radius * x.len())
}
};
let axis = axis.normalized().ok_or_else(|| bad("invalid hole axis"))?;
if !origin.is_finite() || !axis.is_finite() || !diameter.is_finite() || diameter <= 0. || axis.cross(Vec3::Z).len() > 1e-9 {
return Err(bad("drilling needs a finite circular hole parallel to setup Z"));
}
Ok(Selected { center: [origin.x, origin.y], diameter })
}
}
pub(crate) fn select(s: &Session, setup: &cam::Setup, picks: Vec<Pick>) -> Result<(Vec<cam::HoleSource>, Vec<Selected>)> {
if picks.is_empty() || picks.len() > cam::MAX_ITEMS {
return Err(bad("select between one and 1000 holes"));
}
let state = s.model.state();
let doc = s.placed_document();
let mut sources = Vec::new();
let mut seen = Vec::new();
let mut selected = Vec::new();
for pick in picks {
let source = match pick {
Pick::Face { body, index, occurrence } => {
let target = if let Some(occurrence) = occurrence {
cam::BodyRef { body, occurrence }
} else {
if setup.models.len().saturating_mul(doc.occurrences.len() + state.bodies.len() + 64) > 2_000_000 {
return Err(bad("hole selection work limit; provide a definition body and occurrence path"));
}
// Only these explicit model placements may be selected. Do not expand
// an entire shared assembly merely to interpret a viewport label.
let mut matching = setup.models.iter().filter(|target| {
state.body(&target.body).is_some_and(|model| {
doc.world_body_name(&state, &model.name, doc.model_body_component(model), &target.occurrence) == body
})
});
let target = matching.next().ok_or_else(|| bad("selected hole body is not in this setup; reselect its model occurrence"))?;
if matching.next().is_some() {
return Err(bad("selected hole body ambiguous; provide its occurrence path"));
}
target.clone()
};
let placed = PlacedBody::capture(s, &target)?;
let name = solidcraft_doc::naming::face_names(&placed.body).get(index).cloned().ok_or_else(|| bad("selected hole face missing"))?;
cam::HoleSource::Face { target, name }
}
Pick::Profile { sketch, index, occurrence } => {
let profile = state
.sketches
.iter()
.find(|s| s.feature == sketch)
.and_then(|s| s.profiles.get(index))
.ok_or_else(|| bad("selected circular profile missing"))?;
cam::HoleSource::Profile { profile: cam::ProfileRef { sketch, curves: profile.curve_ids(), occurrence } }
}
};
if seen.contains(&source) {
return Err(bad("duplicate hole selection"));
}
seen.push(source.clone());
let hole = Hole::capture(s, setup, &source, cam::TOLERANCE)?.resolve(setup.frame, cam::TOLERANCE)?;
if let Some((old, prior)) = sources
.iter()
.zip(&selected)
.find(|(_, p): &(_, &Selected)| (p.center[0] - hole.center[0]).hypot(p.center[1] - hole.center[1]) < cam::TOLERANCE)
{
// A viewport pick expands a cylinder across its topological seam. Its
// faces still describe one hole on one explicitly placed body.
if matches!((old, &source), (cam::HoleSource::Face { target: a, .. }, cam::HoleSource::Face { target: b, .. }) if a == b)
&& (prior.center[0] - hole.center[0]).hypot(prior.center[1] - hole.center[1]) < 1e-8
&& (prior.diameter - hole.diameter).abs() < 1e-8
{
continue;
}
return Err(bad("selected hole axes coincide; remove duplicate selections"));
}
sources.push(source);
selected.push(hole);
}
Ok((sources, selected))
}
-422
View File
@@ -1,422 +0,0 @@
//! Cheap immutable capture on the session thread; curve sampling, tessellation,
//! transforms and hashing happen during preparation on the job's worker.
use crate::{
EngineError, Result, Session, cam,
geom::{Plane, Region2, Seg2, Vec2, Vec3},
};
use solidcraft_doc::{Mat, apply_point};
pub(super) struct Snapshot {
setup: cam::Setup,
tools: Vec<cam::Tool>,
operations: Vec<cam::Operation>,
profiles: Vec<(usize, Profile)>,
holes: Vec<(usize, Vec<super::holes::Hole>)>,
models: Vec<PlacedBody>,
fixtures: Vec<PlacedBody>,
}
pub(super) struct PlacedBody {
pub(super) body: solidcraft_doc::ModelBody,
pub(super) placement: Mat,
}
pub(super) struct Profile {
pub(super) region: Region2,
pub(super) plane: Plane,
tolerance: f64,
}
fn error(message: &str) -> EngineError {
EngineError::Other(message.into())
}
fn cancelled(check: &impl Fn() -> bool) -> Result<()> {
if check() { Err(error("CAM preparation cancelled")) } else { Ok(()) }
}
impl Snapshot {
pub(super) fn capture(s: &Session, program: u64) -> Result<Self> {
s.doc.manufacturing.validate().map_err(super::error)?;
let m = &s.doc.manufacturing;
let p = m.programs.iter().find(|p| p.id == program).ok_or_else(|| error("CAM program not found"))?;
let setup = m.setups.iter().find(|x| x.id == p.setup).cloned().ok_or_else(|| error("CAM setup missing"))?;
let operations = p
.operations
.iter()
.map(|id| m.operations.iter().find(|o| o.id == *id).cloned().ok_or_else(|| error("CAM operation missing")))
.collect::<Result<Vec<_>>>()?;
let mut profiles = Vec::new();
for (i, o) in operations.iter().enumerate() {
if let Some(source) = &o.source {
profiles.push((i, Profile::capture(s, &setup, source, o.tolerance)?));
}
}
let mut holes = Vec::new();
for (index, operation) in operations.iter().enumerate() {
if !operation.hole_sources.is_empty() {
holes.push((
index,
operation
.hole_sources
.iter()
.map(|source| super::holes::Hole::capture(s, &setup, source, operation.tolerance))
.collect::<Result<_>>()?,
));
}
}
let tools = m.tools.iter().filter(|t| operations.iter().any(|o| o.tool == t.id)).cloned().collect::<Vec<_>>();
if tools.iter().map(|t| t.number).collect::<std::collections::BTreeSet<_>>().len() != tools.len() {
return Err(error("CAM tools must have unique tool numbers within a program"));
}
let models = setup.models.iter().map(|r| PlacedBody::capture(s, r)).collect::<Result<_>>()?;
let fixtures = setup.fixtures.iter().map(|r| PlacedBody::capture(s, r)).collect::<Result<_>>()?;
Ok(Self { setup, tools, operations, profiles, holes, models, fixtures })
}
pub(super) async fn build<F: std::future::Future<Output = ()>>(
mut self,
check: &impl Fn() -> bool,
progress: &impl Fn(usize),
yield_control: &mut impl FnMut() -> F,
) -> Result<cam::JobInput> {
let total =
self.profiles.len() + self.holes.iter().map(|(_, holes)| holes.len()).sum::<usize>() + self.models.len() + self.fixtures.len() + 1;
let mut done = 0;
for (index, p) in self.profiles {
cancelled(check)?;
yield_control().await;
let boundary = p.sample(check, yield_control).await?;
let op = self.operations.get_mut(index).ok_or_else(|| error("CAM operation missing from snapshot"))?;
match &mut op.geometry {
cam::Strategy::Contour { boundary: b, .. } | cam::Strategy::Pocket { boundary: b } => *b = boundary,
_ => return Err(error("profile bound to unsupported operation")),
}
done += 1;
progress(done * 20 / total);
}
for (index, holes) in self.holes {
let operation = self.operations.get_mut(index).ok_or_else(|| error("CAM hole operation missing"))?;
let tool = self.tools.iter().find(|t| t.id == operation.tool).ok_or_else(|| error("CAM drill missing"))?;
let mut centers = Vec::new();
for hole in holes {
cancelled(check)?;
yield_control().await;
let selected = hole.resolve(self.setup.frame, operation.tolerance)?;
if (selected.diameter - tool.diameter).abs() > operation.tolerance {
return Err(error("selected hole diameter differs from the drill; choose a matching tool or unlink the hole"));
}
if centers.iter().any(|p: &[f64; 2]| (p[0] - selected.center[0]).hypot(p[1] - selected.center[1]) <= operation.tolerance) {
return Err(error("selected hole axes coincide; remove duplicate selections"));
}
centers.push(selected.center);
done += 1;
progress(done * 20 / total);
}
let cam::Strategy::Drill { holes, .. } = &mut operation.geometry else {
return Err(error("hole references require drilling"));
};
*holes = centers;
}
let mut target = Vec::new();
let mut fixtures = Vec::new();
let mut fixture_groups = Vec::new();
for body in self.models {
body.append(self.setup.frame, &mut target, check, yield_control).await?;
done += 1;
progress(done * 20 / total);
}
for body in self.fixtures {
let before = fixtures.len();
body.append(self.setup.frame, &mut fixtures, check, yield_control).await?;
fixture_groups.push(fixtures.len() - before);
done += 1;
progress(done * 20 / total);
}
if target.len() + fixtures.len() > 200_000 {
return Err(error("CAM mesh exceeds verification budget"));
}
cancelled(check)?;
yield_control().await;
let mut input = cam::JobInput {
setup: self.setup,
tools: self.tools,
operations: self.operations,
geometry: cam::Geometry { target, fixtures, fixture_groups },
fingerprint: String::new(),
};
input.validate().map_err(super::error)?;
input.fingerprint = cam::fingerprint_job_cooperative(&input, check, yield_control).await.map_err(super::error)?;
cancelled(check)?;
progress(20);
Ok(input)
}
}
impl PlacedBody {
pub(super) fn capture(s: &Session, r: &cam::BodyRef) -> Result<Self> {
let state = s.model.state();
let candidates: Vec<_> = state.bodies.iter().filter(|b| b.name == r.body).collect();
if candidates.len() != 1 {
return Err(error(&format!("CAM body `{}` missing or ambiguous; reselect it", r.body)));
}
let b = candidates[0];
let component = s.doc.model_body_component(b);
let placement = if r.occurrence.is_empty() {
if component != 0 {
return Err(error("CAM component body requires an occurrence path"));
}
solidcraft_doc::IDENTITY
} else {
let p = s.occurrence_placement(&r.occurrence)?;
if p.component != component {
return Err(error("CAM body no longer belongs to selected occurrence"));
}
p.world
};
let placement = s.doc.model_body_offset(b).map(|offset| solidcraft_doc::mat_mul(&placement, offset)).unwrap_or(placement);
Ok(Self { body: b.clone(), placement })
}
async fn append<F: std::future::Future<Output = ()>>(
self,
frame: cam::Frame,
out: &mut Vec<[Vec3; 3]>,
check: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<()> {
cancelled(check)?;
yield_control().await;
cancelled(check)?;
let mesh = self.body.body.tessellate_for_verification(cam::VERIFY_ERROR / 2., 200_000usize.saturating_sub(out.len()))?;
if out.len() + mesh.triangles.len() > 200_000 {
return Err(error("CAM mesh exceeds verification budget"));
}
for (i, indices) in mesh.triangles.iter().enumerate() {
if i.is_multiple_of(256) {
cancelled(check)?;
yield_control().await;
}
let triangle = mesh.tri(indices).ok_or_else(|| error("CAM mesh contains invalid indices"))?;
out.push(triangle.map(|v| frame.local(apply_point(&self.placement, v))));
}
Ok(())
}
}
impl Profile {
pub(super) fn capture(s: &Session, setup: &cam::Setup, source: &cam::ProfileRef, tolerance: f64) -> Result<Self> {
let state = s.model.state();
let sketch = state.sketches.iter().find(|x| x.feature == source.sketch).ok_or_else(|| error("CAM sketch missing; reselect the profile"))?;
let mut wanted = source.curves.clone();
wanted.sort();
let candidates: Vec<_> = sketch
.profiles
.iter()
.filter(|p| {
let mut ids = p.curve_ids();
ids.sort();
ids == wanted
})
.collect();
if candidates.len() != 1 {
return Err(error("CAM profile missing or ambiguous; reselect it"));
}
let component = s.doc.feature(source.sketch).map(|f| f.component).ok_or_else(|| error("CAM sketch feature missing"))?;
let transform = if source.occurrence.is_empty() {
if component != 0 {
return Err(error("CAM sketch needs an explicit occurrence path"));
}
solidcraft_doc::IDENTITY
} else {
let p = s.occurrence_placement(&source.occurrence)?;
if p.component != component {
return Err(error("CAM sketch occurrence mismatch"));
}
p.world
};
let point = |p| setup.frame.local(apply_point(&transform, p));
let origin = point(sketch.plane.origin);
let plane =
Plane { origin, x: point(sketch.plane.origin + sketch.plane.x) - origin, y: point(sketch.plane.origin + sketch.plane.y) - origin };
Ok(Self { region: candidates[0].region.clone(), plane, tolerance: cam::profile_tolerance(tolerance) })
}
fn segments(&self, s: &Seg2) -> Result<usize> {
let v = |p: Vec2| self.plane.x * p.x + self.plane.y * p.y;
let m = match s {
Seg2::Line { .. } => 0.,
Seg2::Arc { radius, sweep, .. } => radius.abs() * sweep.powi(2) * (self.plane.x.len2() + self.plane.y.len2()).sqrt(),
Seg2::Cubic { p0, p1, p2, p3 } => 6. * v(*p0 - *p1 * 2. + *p2).len().max(v(*p1 - *p2 * 2. + *p3).len()),
Seg2::Bezier { n, p } => {
let points = p.get(..=*n as usize).filter(|p| p.len() >= 2).ok_or_else(|| error("invalid CAM Bezier degree"))?;
*n as f64 * (*n as f64 - 1.) * points.windows(3).map(|p| v(p[0] - p[1] * 2. + p[2]).len()).fold(0., f64::max)
}
Seg2::Conic { a, apex, b, w } => {
if !w.is_finite() || *w <= -1. {
return Err(error("singular CAM conic profile"));
}
let u = v((*apex - *a) * *w);
let b = v(*b - *a);
let d = 1_f64.min((1. + w) / 2.);
let n = u.len().max(b.len());
let n1 = 2. * u.len().max((b - u).len());
let n2 = 2. * (b - u * 2.).len();
let d1 = 2. * (w - 1.).abs();
let d2 = 4. * (w - 1.).abs();
n2 / d + 2. * n1 * d1 / d.powi(2) + n * d2 / d.powi(2) + 2. * n * d1.powi(2) / d.powi(3)
}
};
// ||curve''||/(8 n²) bounds the chord error on every parameter interval.
// The reserve covers evaluation/placement rounding, rather than silently
// clamping the segment count or increasing the requested tolerance.
let size = |p: Vec2| self.plane.x.len() * p.x.abs() + self.plane.y.len() * p.y.abs();
let magnitude = match s {
Seg2::Line { a, b } => size(*a) + size(*b),
Seg2::Arc { center, radius, start, sweep } => {
size(*center) + radius.abs() * (1. + start.abs() + sweep.abs()) * (self.plane.x.len() + self.plane.y.len())
}
Seg2::Cubic { p0, p1, p2, p3 } => size(*p0) + size(*p1) + size(*p2) + size(*p3),
Seg2::Bezier { n, p } => p.get(..=*n as usize).ok_or_else(|| error("invalid CAM Bezier degree"))?.iter().map(|p| size(*p)).sum(),
Seg2::Conic { a, apex, b, w } => (size(*a) + w.abs() * size(*apex) + size(*b)) / 1_f64.min((1. + w) / 2.),
};
let guard = 256. * f64::EPSILON * (1. + self.plane.origin.len() + magnitude + m.abs());
let budget = self.tolerance * 15. / 16. - guard;
if !m.is_finite() || !budget.is_finite() || budget <= 0. {
return Err(error("CAM profile precision cannot meet the requested tolerance"));
}
let count = ((m * (1. + 256. * f64::EPSILON) / (8. * budget)).sqrt().ceil()).max(1.);
if !count.is_finite() || count > 10_000. {
return Err(error("CAM profile exceeds 10000 vertices at the required tolerance"));
}
Ok(count as usize)
}
pub(super) async fn sample<F: std::future::Future<Output = ()>>(
&self,
check: &impl Fn() -> bool,
yield_control: &mut impl FnMut() -> F,
) -> Result<cam::Boundary> {
if !self.plane.origin.is_finite() || !self.plane.x.is_finite() || !self.plane.y.is_finite() || self.plane.normal().len() < 1e-12 {
return Err(error("degenerate CAM profile placement"));
}
if self.plane.x.z.abs() > 1e-10 * self.plane.x.len() || self.plane.y.z.abs() > 1e-10 * self.plane.y.len() {
return Err(error("CAM profile must be perpendicular to setup Z"));
}
let mut rings = Vec::new();
let mut total = 0;
for ring in std::iter::once(&self.region.outer).chain(&self.region.holes) {
let mut points = Vec::new();
for (a, b) in ring.segs.iter().zip(ring.segs.iter().cycle().skip(1)).take(ring.segs.len()) {
let gap = self.plane.dir_to_world(a.end() - b.start()).len();
if !gap.is_finite() || gap > self.tolerance / 16. {
return Err(error("disconnected CAM profile segments"));
}
}
for curve in &ring.segs {
cancelled(check)?;
yield_control().await;
let count = self.segments(curve)?;
total += count;
if total > 10_000 {
return Err(error("CAM profile exceeds 10000 vertices at the required tolerance"));
}
for i in 0..count {
if i.is_multiple_of(256) {
cancelled(check)?;
yield_control().await;
}
let p = self.plane.to_world(curve.point_at(i as f64 / count as f64));
if !p.is_finite() {
return Err(error("non-finite CAM profile point"));
}
points.push([p.x, p.y]);
}
}
if points.first() == points.last() {
points.pop();
}
rings.push(points);
}
let mut rings = rings.into_iter();
let boundary = cam::Boundary { outer: rings.next().ok_or_else(|| error("empty CAM profile"))?, islands: rings.collect() };
boundary.validate().map_err(super::error)?;
Ok(boundary)
}
}
pub(super) fn ready<T>(future: impl std::future::Future<Output = Result<T>>) -> Result<T> {
use std::task::{Context, Poll, Waker};
let mut future = std::pin::pin!(future);
match future.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
Poll::Ready(result) => result,
Poll::Pending => Err(error("synchronous CAM preparation unexpectedly yielded")),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::geom::Loop2;
fn profile() -> Profile {
Profile { region: Region2 { outer: Loop2::circle(Vec2::ZERO, 8.), holes: vec![] }, plane: Plane::XY, tolerance: cam::profile_tolerance(0.01) }
}
#[test]
fn bounded_sampler_meets_transformed_curve_chord_budget() {
let mut p = profile();
p.plane.x = Vec3::new(3., 1., 0.);
p.plane.y = Vec3::new(-1., 0.5, 0.);
p.plane.origin = Vec3::new(100., -120., 2.);
let a = Vec2::new(2., -1.);
let b = Vec2::new(-2., 3.);
let c = Vec2::new(4., 8.);
let mut curves = vec![
Seg2::Line { a, b },
Seg2::Arc { center: a, radius: 8., start: 0.2, sweep: -5. },
Seg2::Cubic { p0: a, p1: c, p2: b, p3: Vec2::ZERO },
Seg2::Bezier { n: 7, p: [a, b, c, a, b, c, a, b, c, a] },
Seg2::Bezier { n: 9, p: [a, b, c, a, b, c, a, b, c, a] },
];
for w in [-0.5, 0., 0.7, 1., 5.] {
curves.push(Seg2::Conic { a, apex: c, b, w });
}
for curve in curves {
let n = p.segments(&curve).unwrap();
for i in 0..n {
let a = p.plane.to_world(curve.point_at(i as f64 / n as f64));
let b = p.plane.to_world(curve.point_at((i + 1) as f64 / n as f64));
for k in 1..8 {
let fraction = k as f64 / 8.;
let exact = p.plane.to_world(curve.point_at((i as f64 + fraction) / n as f64));
assert!(exact.dist(a + (b - a) * fraction) <= p.tolerance, "{curve:?}");
}
}
}
}
#[test]
fn precision_and_vertex_limits_fail_without_relaxing_tolerance() {
let mut p = profile();
let circle = Seg2::Arc { center: Vec2::ZERO, radius: 1e6, start: 0., sweep: std::f64::consts::TAU };
assert!(p.segments(&circle).is_err());
p.plane.origin = Vec3::new(1e15, 0., 0.);
assert!(p.segments(&Seg2::Line { a: Vec2::ZERO, b: Vec2::X }).is_err());
let p = profile();
assert!(p.segments(&Seg2::Bezier { n: 255, p: Default::default() }).is_err());
assert!(p.segments(&Seg2::Conic { a: Vec2::ZERO, apex: Vec2::X, b: Vec2::Y, w: -1. }).is_err());
assert!(p.segments(&Seg2::Line { a: Vec2::new(f64::NAN, 0.), b: Vec2::ZERO }).is_err());
}
#[test]
fn profile_sampling_yields_and_honours_cancellation_and_planarity() {
use std::{
future::Future,
sync::atomic::{AtomicBool, Ordering},
task::{Context, Poll, Waker},
};
let p = profile();
let cancel = AtomicBool::new(false);
let check = || cancel.load(Ordering::Relaxed);
let mut yields = super::super::YieldOnce::default;
let mut future = std::pin::pin!(p.sample(&check, &mut yields));
let mut cx = Context::from_waker(Waker::noop());
assert!(future.as_mut().poll(&mut cx).is_pending());
cancel.store(true, Ordering::Relaxed);
assert!(matches!(future.as_mut().poll(&mut cx), Poll::Ready(Err(_))));
let mut p = profile();
p.plane = Plane::XZ;
assert!(ready(p.sample(&|| false, &mut || std::future::ready(()))).is_err());
p.plane = Plane::XY;
p.region.outer.segs.pop();
assert!(ready(p.sample(&|| false, &mut || std::future::ready(()))).is_err());
}
}