1 parent
1ded8fdd27
commit
2c020dbcbc
85 files changed
+43
-14487
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user