From 2d4870eb759b5ac20528fb113e3a08500d660b38 Mon Sep 17 00:00:00 2001 From: DarkBiscuits Date: Mon, 9 Nov 2026 17:05:48 -0500 Subject: [PATCH] Preserve tool shapes in IR and verify per-move cutters --- CHANGELOG.md | 5 + README.md | 17 +- crates/cli/src/gouge.rs | 72 +++--- crates/cli/src/gouge/program.rs | 242 +++++++++++++++++++++ crates/cli/src/jobfile.rs | 49 ++++- crates/cli/src/jobfile/tool_shape_tests.rs | 63 ++++++ crates/cli/src/main.rs | 20 +- crates/cli/src/run.rs | 2 +- crates/cli/tests/golden/dome_rough.ir.json | 3 + crates/cli/tests/surface_e2e.rs | 5 + crates/cli/tests/tool_shape_e2e.rs | 106 +++++++++ crates/ir/src/lib.rs | 8 + crates/ir/src/process.rs | 22 +- crates/ir/src/tool_shape.rs | 122 +++++++++++ docs/job-file.md | 8 + docs/schema/campanion-ir-1.schema.json | 90 ++++++++ 16 files changed, 791 insertions(+), 43 deletions(-) create mode 100644 crates/cli/src/gouge/program.rs create mode 100644 crates/cli/src/jobfile/tool_shape_tests.rs create mode 100644 crates/cli/tests/tool_shape_e2e.rs create mode 100644 crates/ir/src/tool_shape.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c2f14..03562db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Optional flat, ball, bull and V-bit cutter geometry in IR tools. Run jobs preserve their shapes and resolve chamfer geometry per tool ID, rejecting conflicting definitions before output (#146). +- `verify --mesh` uses each cutting move's tool geometry when `--tool` is omitted, including tool changes within a path. Missing shapes and unsupported V-bit verification fail explicitly; `--tool` remains a global override (#146). + ## 0.10.0 — 2026-11-09 Router gaps from the processes plan: cutter sense on diameter tools, onion-skin depth passes, a deterministic sheet packer with nesting checks, and a LinuxCNC router dialect with a cited probe cycle and vacuum/dust auxiliaries. This output has not been proven on a machine. diff --git a/README.md b/README.md index 533909a..702c20d 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,24 @@ A `.step` or `.stp` lathe source is sectioned through the spindle by `solidcraft `campanion verify FILE` checks an IR file and prints a report. The status is 1 when the report has errors. -Check cutting moves against an STL mesh. The tool is a radius in millimetres (`ball:3`, `flat:3`, -or `bull:3,1`, where the number after the comma is the bull corner radius): +Check cutting moves against an STL mesh using the IR's tool geometry: ```sh -campanion verify out.json --mesh examples/dome.stl --tool ball:3 +campanion verify out.json --mesh examples/dome.stl ``` +Each path's tool ID establishes its initial cutter; tool-change events select the cutter for +subsequent moves. A path without a tool ID inherits the active tool from the preceding path. +Flat, ball and bull cutters are supported. A cutting move with no tool or missing shape metadata +fails explicitly; older IR still loads but needs `--tool` for mesh verification. V-bit metadata +is preserved, but mesh verification of a V-bit cut is not supported yet. Unused tools and +non-cutting moves do not require a supported cutter shape. + +`--tool ball:3`, `--tool flat:3`, or `--tool bull:3,1` overrides geometry for **all** cutting moves +and is identified on stderr. These numbers are radii in millimetres; the number after the comma +is the bull corner radius. An override checks the supplied geometry, not the actual V-bit or +unknown cutter. Verification without `--mesh` is unchanged. + Tolerance is 0.01 mm, the job file's default chord tolerance. Samples are `min(0.25, tolerance*10)` mm apart. A gouge is printed like any other verify error and names the operation, the position and the depth. `campanion run` on an STL job does the same check with each operation's cutter and the diff --git a/crates/cli/src/gouge.rs b/crates/cli/src/gouge.rs index 071aa1e..b66e0fa 100644 --- a/crates/cli/src/gouge.rs +++ b/crates/cli/src/gouge.rs @@ -12,6 +12,9 @@ //! the mesh turned by that angle, in the machine view. A segment that changes angle is sampled at //! intermediate angles, each on the mesh turned by that angle. +mod program; +pub(crate) use program::check_program_tools; + use std::borrow::Cow; use campanion_ir::{ArcSense, ClBlock, ClMotion, ClPath, MoveRole, Plane, Pose, Program, RotarySetup, Vec3 as IrVec, part_to_machine}; @@ -412,41 +415,52 @@ fn cutting_segments(path: &ClPath, sample_step: f64, setup: Option<&RotarySetup> let ClBlock::Motion(motion) = block else { continue; }; - match motion { - ClMotion::Rapid { to, .. } => cursor = Some(*to), - ClMotion::Linear { to, role, .. } => { - if cutting(*role) { - let start = cursor.unwrap_or(*to); - push_segment(&mut segments, mesh_vec(machine_tip(setup, start)), mesh_vec(machine_tip(setup, *to)))?; - } - cursor = Some(*to); - } - ClMotion::Thread { to, .. } => { + append_cutting_motion(motion, sample_step, setup, &mut cursor, &mut segments)?; + } + Ok(segments) +} + +fn append_cutting_motion( + motion: &ClMotion, + sample_step: f64, + setup: Option<&RotarySetup>, + cursor: &mut Option, + segments: &mut Vec<(MeshVec, MeshVec)>, +) -> Result<(), CliError> { + match motion { + ClMotion::Rapid { to, .. } => *cursor = Some(*to), + ClMotion::Linear { to, role, .. } => { + if cutting(*role) { let start = cursor.unwrap_or(*to); - push_segment(&mut segments, mesh_vec(machine_tip(setup, start)), mesh_vec(machine_tip(setup, *to)))?; - cursor = Some(*to); + push_segment(segments, mesh_vec(machine_tip(setup, start)), mesh_vec(machine_tip(setup, *to)))?; } - ClMotion::Arc { to, plane, center, sense, role, .. } => { - if cutting(*role) { - let Some(start) = cursor else { - return Err(CliError::run("gouge check: arc needs a start position")); - }; - append_curve(&mut segments, setup, start, *to, *center, *plane, *sense, None, sample_step)?; - } - cursor = Some(*to); + *cursor = Some(*to); + } + ClMotion::Thread { to, .. } => { + let start = cursor.unwrap_or(*to); + push_segment(segments, mesh_vec(machine_tip(setup, start)), mesh_vec(machine_tip(setup, *to)))?; + *cursor = Some(*to); + } + ClMotion::Arc { to, plane, center, sense, role, .. } => { + if cutting(*role) { + let Some(start) = *cursor else { + return Err(CliError::run("gouge check: arc needs a start position")); + }; + append_curve(segments, setup, start, *to, *center, *plane, *sense, None, sample_step)?; } - ClMotion::Helix { to, plane, center, sense, sweep, role, .. } => { - if cutting(*role) { - let Some(start) = cursor else { - return Err(CliError::run("gouge check: arc needs a start position")); - }; - append_curve(&mut segments, setup, start, *to, *center, *plane, *sense, Some(*sweep), sample_step)?; - } - cursor = Some(*to); + *cursor = Some(*to); + } + ClMotion::Helix { to, plane, center, sense, sweep, role, .. } => { + if cutting(*role) { + let Some(start) = *cursor else { + return Err(CliError::run("gouge check: arc needs a start position")); + }; + append_curve(segments, setup, start, *to, *center, *plane, *sense, Some(*sweep), sample_step)?; } + *cursor = Some(*to); } } - Ok(segments) + Ok(()) } /// Tip in the frame the drop cutter uses. A rotary pose is turned back into the indexed view. diff --git a/crates/cli/src/gouge/program.rs b/crates/cli/src/gouge/program.rs new file mode 100644 index 0000000..2a2ce90 --- /dev/null +++ b/crates/cli/src/gouge/program.rs @@ -0,0 +1,242 @@ +//! Tool-aware replay for `verify --mesh` without a global cutter override. + +use std::collections::BTreeMap; + +use campanion_ir::{Event, Tool, ToolShape}; + +use super::*; + +struct ToolCuts { + cutter: Cutter, + ranges: Vec>, +} + +pub(crate) fn check_program_tools(program: &Program, mesh: &Mesh, tolerance: f64) -> Result, CliError> { + let sample_step = sample_step_for(tolerance)?; + let tools: BTreeMap = program.tools().iter().map(|tool| (tool.id(), tool)).collect(); + let setup = program.rotary(); + let mut active_tool = None; + let mut notes = Vec::new(); + for (index, path) in program.toolpaths().iter().enumerate() { + let name = path_label(path, index); + // A path declares its initial tool. Explicit changes govern subsequent moves, + // and a path without a declaration inherits the previously active tool. + if let Some(id) = path.tool_id() { + active_tool = Some(id); + } + let mut cursor = None; + let mut groups: BTreeMap = BTreeMap::new(); + // All tools share the original per-path sampling cap. + let mut segments = Vec::new(); + for block in path.blocks() { + if let ClBlock::Event(Event::ToolChange { tool_id }) = block { + active_tool = Some(*tool_id); + } + let ClBlock::Motion(motion) = block else { continue }; + if !is_cut(motion) { + cursor = Some(motion_pose(motion)); + continue; + } + let id = active_tool.ok_or_else(|| CliError::run(format!("toolpath `{name}`: cutting move has no tool; supply --tool")))?; + let group = match groups.entry(id) { + std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::btree_map::Entry::Vacant(entry) => { + let tool = tools.get(&id).ok_or_else(|| CliError::run(format!("toolpath `{name}`: unknown tool {id}")))?; + entry.insert(ToolCuts { cutter: cutter_for_ir(tool)?, ranges: Vec::new() }) + } + }; + let before = segments.len(); + append_cutting_motion(motion, sample_step, setup, &mut cursor, &mut segments)?; + if let Some(last) = group.ranges.last_mut() + && last.end == before + { + last.end = segments.len(); + } else { + group.ranges.push(before..segments.len()); + } + } + if groups.is_empty() { + continue; + } + let view = indexed_mesh(mesh, setup, path_angle(path))?; + for (id, group) in groups { + let dc = DropCutter::new(view.as_ref(), group.cutter).map_err(|err| CliError::run(format!("tool {id}: {err}")))?; + let mut tool_notes = Vec::new(); + for range in group.ranges { + let cuts = segments.get(range).ok_or_else(|| CliError::run("gouge check: invalid segment range"))?; + tool_notes.extend(notes_for(&format!("{name} (tool {id})"), &dc, cuts, sample_step, tolerance)?); + tool_notes.sort_by(|left, right| right.depth.total_cmp(&left.depth)); + tool_notes.truncate(MAX_REPORTS); + } + notes.extend(tool_notes); + } + } + Ok(notes) +} + +fn is_cut(motion: &ClMotion) -> bool { + match motion { + ClMotion::Rapid { .. } => false, + ClMotion::Thread { .. } => true, + ClMotion::Linear { role, .. } | ClMotion::Arc { role, .. } | ClMotion::Helix { role, .. } => cutting(*role), + } +} + +fn cutter_for_ir(tool: &Tool) -> Result { + let id = tool.id(); + let shape = tool.shape().ok_or_else(|| CliError::run(format!("tool {id} has no cutter shape; supply --tool for mesh verification")))?; + let diameter = tool.diameter().ok_or_else(|| CliError::run(format!("tool {id} has no cutter diameter")))?; + let radius = diameter * 0.5; + let cutter = match shape { + ToolShape::Flat {} => Cutter::new_flat(radius), + ToolShape::Ball {} => Cutter::new_ball(radius), + ToolShape::Bull { corner_radius } => Cutter::new_bull(radius, corner_radius), + ToolShape::VBit { .. } => return Err(CliError::run(format!("not supported yet: mesh verification for V-bit tool {id}"))), + }; + cutter.map_err(|err| CliError::run(format!("tool {id}: {err}"))) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use campanion_ir::{CompensationMode, Feeds, RotaryAxisKind, RotaryDirection, Vec3, machine_to_part}; + use campanion_mesh::Triangle; + use proptest::prelude::*; + + fn slope() -> Mesh { + // z = x: the correct tip height differs for flat, ball, and bull cutters. + let a = MeshVec::new(-20.0, -20.0, -20.0); + let b = MeshVec::new(20.0, -20.0, 20.0); + let c = MeshVec::new(20.0, 20.0, 20.0); + let d = MeshVec::new(-20.0, 20.0, -20.0); + Mesh::new(vec![Triangle { a, b, c }, Triangle { a, b: c, c: d }]).expect("slope") + } + + fn pose(x: f64, y: f64, z: f64) -> Pose { + Pose::new(Vec3::new(x, y, z).expect("tip"), Vec3::Z).expect("pose") + } + + fn tool(id: u32, shape: Option) -> Tool { + let tool = Tool::try_new(id, None, Some(2.0), None).expect("tool"); + match shape { + Some(shape) => tool.with_shape(shape).expect("shape"), + None => tool, + } + } + + fn path(id: Option, blocks: Vec) -> ClPath { + ClPath::try_new(None, id, CompensationMode::Off, Feeds::try_new(100.0, None, None, None, None).expect("feeds"), None, Vec::new(), blocks) + .expect("path") + } + + fn cut(to: Pose) -> ClBlock { + ClBlock::Motion(ClMotion::Linear { to, feed: 100.0, role: MoveRole::Cut }) + } + + fn program(tools: Vec, paths: Vec) -> Program { + Program::try_new(None, tools, None, paths, Vec::new(), Vec::new(), None).expect("program") + } + + #[test] + fn per_move_tools_follow_changes_and_preserve_segment_start() { + let mesh = slope(); + let ball_height = 2.0_f64.sqrt() - 1.0; + let blocks = vec![ + ClBlock::Motion(ClMotion::Rapid { to: pose(0.0, -2.0, ball_height), feed: None }), + cut(pose(0.0, -1.0, ball_height)), + ClBlock::Event(Event::ToolChange { tool_id: 2 }), + // Starts at ball height: a flat cutter gouges until this move reaches z=1. + cut(pose(0.0, 1.0, 1.0)), + ClBlock::Event(Event::ToolChange { tool_id: 1 }), + cut(pose(0.0, 2.0, ball_height)), + ClBlock::Event(Event::ToolChange { tool_id: 2 }), + cut(pose(0.0, 3.0, ball_height)), + ]; + let p = program(vec![tool(1, Some(ToolShape::Ball {})), tool(2, Some(ToolShape::Flat {}))], vec![path(Some(1), blocks)]); + let notes = check_program_tools(&p, &mesh, 0.01).map_err(|err| err.to_string()).expect("check"); + assert!(!notes.is_empty()); + assert!(notes.iter().all(|note| note.operation.ends_with("(tool 2)"))); + assert!(notes.iter().any(|note| note.depth > 0.5)); + assert!(notes.iter().any(|note| note.y >= 2.0 && note.depth > 0.5)); + assert!( + check_program(&p, &mesh, Cutter::new_ball(1.0).map_err(|err| err.to_string()).expect("ball"), 0.01) + .map_err(|err| err.to_string()) + .expect("override") + .is_empty() + ); + } + + #[test] + fn missing_shapes_vbits_and_unused_tools_are_explicit() { + let mesh = slope(); + let blocks = vec![ClBlock::Motion(ClMotion::Rapid { to: pose(0.0, 0.0, 2.0), feed: None }), cut(pose(0.0, 1.0, 2.0))]; + for (shape, expected) in [ + (None, "tool 1 has no cutter shape"), + (Some(ToolShape::VBit { angle: 1.0, tip_diameter: 0.0 }), "not supported yet: mesh verification for V-bit tool 1"), + ] { + let p = program(vec![tool(1, shape)], vec![path(Some(1), blocks.clone())]); + assert!(check_program_tools(&p, &mesh, 0.01).expect_err("unsupported").to_string().contains(expected)); + assert!( + check_program(&p, &mesh, Cutter::new_flat(1.0).map_err(|err| err.to_string()).expect("flat"), 0.01) + .map_err(|err| err.to_string()) + .expect("override") + .is_empty() + ); + } + let p = program( + vec![tool(1, Some(ToolShape::Flat {})), tool(2, Some(ToolShape::VBit { angle: 1.0, tip_diameter: 0.0 }))], + vec![path(Some(1), blocks.clone())], + ); + assert!(check_program_tools(&p, &mesh, 0.01).map_err(|err| err.to_string()).expect("unused V-bit").is_empty()); + let p = program(vec![], vec![path(None, blocks)]); + assert!(check_program_tools(&p, &mesh, 0.01).expect_err("missing tool").to_string().contains("cutting move has no tool")); + } + + #[test] + fn path_declarations_and_inherited_tool_changes_select_the_right_cutter() { + let blocks = || vec![ClBlock::Motion(ClMotion::Rapid { to: pose(0.0, -1.0, 0.5), feed: None }), cut(pose(0.0, 1.0, 0.5))]; + let mut changed = vec![ClBlock::Event(Event::ToolChange { tool_id: 2 })]; + changed.extend(blocks()); + let paths = vec![path(Some(1), blocks()), path(None, changed), path(None, blocks()), path(Some(1), blocks())]; + let p = program(vec![tool(1, Some(ToolShape::Ball {})), tool(2, Some(ToolShape::Flat {}))], paths); + let notes = check_program_tools(&p, &slope(), 0.01).map_err(|err| err.to_string()).expect("check"); + assert!(notes.iter().any(|note| note.operation == "toolpath 1 (tool 2)")); + assert!(notes.iter().any(|note| note.operation == "toolpath 2 (tool 2)")); + assert!(notes.iter().all(|note| note.operation.ends_with("(tool 2)"))); + } + + #[test] + fn path_tool_selection_and_indexed_view_are_preserved() { + let mesh = slope(); + let setup = RotarySetup::new(RotaryAxisKind::A, Vec3::new(0.0, 0.0, 0.0).expect("origin"), RotaryDirection::Positive, None).expect("setup"); + let mut paths = Vec::new(); + for (id, z) in [(1, 2.0), (2, 2.0)] { + let to = |y| machine_to_part(&setup, pose(0.0, y, z), 180.0).with_rotary(180.0).expect("angle"); + paths.push(path(Some(id), vec![ClBlock::Motion(ClMotion::Rapid { to: to(0.0), feed: None }), cut(to(1.0))])); + } + let p = Program::try_new( + None, + vec![tool(1, Some(ToolShape::Ball {})), tool(2, Some(ToolShape::Bull { corner_radius: 0.5 }))], + None, + paths, + Vec::new(), + Vec::new(), + Some(setup), + ) + .expect("program"); + assert!(check_program_tools(&p, &mesh, 0.01).map_err(|err| err.to_string()).expect("indexed").is_empty()); + } + + proptest! { + #[test] + fn flat_and_ball_selection_changes_gouge_result(z in 0.45f64..0.95) { + let mesh = slope(); + let blocks = vec![ClBlock::Motion(ClMotion::Rapid { to: pose(0.0, -1.0, z), feed: None }), cut(pose(0.0, 1.0, z))]; + let ball = program(vec![tool(1, Some(ToolShape::Ball {}))], vec![path(Some(1), blocks.clone())]); + let flat = program(vec![tool(1, Some(ToolShape::Flat {}))], vec![path(Some(1), blocks)]); + prop_assert!(check_program_tools(&ball, &mesh, 0.01).map_err(|err| err.to_string()).expect("ball").is_empty()); + prop_assert!(!check_program_tools(&flat, &mesh, 0.01).map_err(|err| err.to_string()).expect("flat").is_empty()); + } + } +} diff --git a/crates/cli/src/jobfile.rs b/crates/cli/src/jobfile.rs index 9e153f6..c7952b9 100644 --- a/crates/cli/src/jobfile.rs +++ b/crates/cli/src/jobfile.rs @@ -1505,7 +1505,7 @@ fn parse_text(text: &str, job_path: &Path) -> Result { if kinematics.is_some() && rotary.is_some() { return Err(JobFileError::invalid("kinematics and rotary cannot be combined")); } - let tools = parse_tools(require(map, "tools").map_err(from_cli)?, scale)?; + let mut tools = parse_tools(require(map, "tools").map_err(from_cli)?, scale)?; let operations = parse_operations(require(map, "operations").map_err(from_cli)?, scale, &tools)?; if rotary.is_none() && let Some(operation) = operations.iter().find(|operation| operation.index_deg().is_some()) @@ -1537,6 +1537,7 @@ fn parse_text(text: &str, job_path: &Path) -> Result { check_reachable(kinematics, policy, operation)?; } } + resolve_ir_shapes(&mut tools, &operations)?; check_mesh_source(&source, &operations, &tools)?; let fixtures = parse_fixtures(optional(map, "fixtures"), scale)?; let collision = parse_collision(optional(map, "collision"), scale)?; @@ -1803,9 +1804,51 @@ fn parse_tool_value(value: &Value, scale: f64) -> Result .clone() .with_cutter_sense(cutter_sense.map(ir_cutter_sense), upcut_length) .map_err(|err| JobFileError::Invalid { message: err.to_string() })?; + let ir = + if optional(map, "shape").is_some() { ir.with_shape(ir_shape(shape)).map_err(|err| JobFileError::invalid(err.to_string()))? } else { ir }; Ok(JobTool { id: parsed.ir().id(), path_tool, ir, shape, speed, assembly }) } +/// Resolve one physical cutter per tool ID before planning or writing any output. +fn resolve_ir_shapes(tools: &mut [JobTool], operations: &[Operation]) -> Result<(), JobFileError> { + for tool in tools { + let mut vbit: Option<(campanion_ir::ToolShape, &str)> = None; + for op in operations.iter().filter(|op| op.tool_id() == tool.id() && matches!(op.kind(), OperationKind::Chamfer { .. })) { + let shape = campanion_ir::ToolShape::VBit { angle: op.v_angle(), tip_diameter: op.tip_diameter() }; + if tool.ir.shape().is_some() { + return Err(JobFileError::invalid(format!( + "tool {} has an explicit shape conflicting with V-bit geometry in operation `{}`", + tool.id(), + op.name() + ))); + } + if let Some((previous, name)) = vbit + && previous != shape + { + return Err(JobFileError::invalid(format!( + "tool {} has conflicting V-bit geometry in operations `{name}` and `{}`; use separate tool IDs", + tool.id(), + op.name() + ))); + } + vbit = Some((shape, op.name())); + } + if tool.diameter().is_some() { + let shape = vbit.map(|(shape, _)| shape).unwrap_or_else(|| ir_shape(tool.shape())); + tool.ir = tool.ir.clone().with_shape(shape).map_err(|err| JobFileError::invalid(err.to_string()))?; + } + } + Ok(()) +} + +fn ir_shape(shape: ToolShape) -> campanion_ir::ToolShape { + match shape { + ToolShape::Flat => campanion_ir::ToolShape::Flat {}, + ToolShape::Ball => campanion_ir::ToolShape::Ball {}, + ToolShape::Bull { corner_radius } => campanion_ir::ToolShape::Bull { corner_radius }, + } +} + fn ir_cutter_sense(sense: CutterSense) -> IrCutterSense { match sense { CutterSense::Upcut => IrCutterSense::Upcut, @@ -7240,3 +7283,7 @@ mod tests { assert!(invalid_detail(&err).contains("nest cannot be combined with kinematics"), "{err}"); } } + +#[cfg(test)] +#[path = "jobfile/tool_shape_tests.rs"] +mod tool_shape_tests; diff --git a/crates/cli/src/jobfile/tool_shape_tests.rs b/crates/cli/src/jobfile/tool_shape_tests.rs new file mode 100644 index 0000000..65a1aca --- /dev/null +++ b/crates/cli/src/jobfile/tool_shape_tests.rs @@ -0,0 +1,63 @@ +use super::*; +use serde_json::{Value, json}; + +fn job() -> Value { + json!({"format":"campanion-job/1", "source":{"path":"part.svg", "hash":"cbf29ce484222325"}, "units":"mm", + "tools":[{"id":1,"diameter":6}], + "operations":[ + {"name":"edge-a","type":"chamfer","layers":["L"],"side":"outside","tool_id":1,"width":0.5,"v_angle_deg":90,"tip_diameter":0.2,"feeds":{"cut":100}}, + {"name":"edge-b","type":"chamfer","layers":["L"],"side":"outside","tool_id":1,"width":1.0,"v_angle_deg":90,"tip_diameter":0.2,"feeds":{"cut":100}} + ]}) +} + +fn parse(value: &Value) -> Result { + parse_text(&value.to_string(), Path::new("tool-shape-test.json")) +} + +#[test] +fn matching_vbit_geometry_is_resolved_and_scaled() { + for (units, scale) in [("mm", 1.0), ("inch", 25.4)] { + let mut value = job(); + value["units"] = json!(units); + value["operations"][1]["tip_clearance"] = json!(0.1); + let body = parse(&value).expect("same physical cutter"); + let shape = body.tools.first().expect("tool").ir().shape(); + assert_eq!(shape, Some(campanion_ir::ToolShape::VBit { angle: std::f64::consts::FRAC_PI_2, tip_diameter: 0.2 * scale })); + } +} + +#[test] +fn conflicting_vbit_parameters_name_tool_and_operations() { + for (key, value) in [("v_angle_deg", 60.0), ("tip_diameter", 0.3)] { + let mut input = job(); + input["operations"][1][key] = json!(value); + let error = parse(&input).err().expect("conflict").to_string(); + for text in ["tool 1", "conflicting V-bit geometry", "edge-a", "edge-b"] { + assert!(error.contains(text), "{error}"); + } + } +} + +#[test] +fn explicit_shapes_cannot_be_overwritten_by_chamfers() { + for shape in [json!("flat"), json!("ball"), json!({"bull": 1})] { + let mut input = job(); + input["tools"][0]["shape"] = shape; + let error = parse(&input).err().expect("conflict").to_string(); + assert!(error.contains("tool 1 has an explicit shape conflicting with V-bit geometry"), "{error}"); + } +} + +#[test] +fn ordinary_shapes_and_kerf_are_resolved_without_inventing_a_cutter() { + for (input, expected) in [ + (json!({"id":1,"diameter":6}), Some(campanion_ir::ToolShape::Flat {})), + (json!({"id":1,"diameter":6,"shape":"ball"}), Some(campanion_ir::ToolShape::Ball {})), + (json!({"id":1,"diameter":6,"shape":{"bull":1}}), Some(campanion_ir::ToolShape::Bull { corner_radius: 1.0 })), + (json!({"id":1,"kerf":0.2}), None), + ] { + let mut tools = vec![parse_tool_value(&input, 1.0).expect("tool")]; + resolve_ir_shapes(&mut tools, &[]).expect("resolve"); + assert_eq!(tools.first().expect("tool").ir().shape(), expected); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 4115b48..71a429e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -54,7 +54,7 @@ Usage: campanion lathe JOB -o OUT [--ir OUT.json] [--post ID|FILE.json] [--up-axis z|y|auto] campanion post IR.json --post ID|FILE.json [-o OUT] campanion posts - campanion verify FILE [--mesh PART.stl --tool ball:3|flat:3|bull:3,1] + campanion verify FILE [--mesh PART.stl [--tool ball:3|flat:3|bull:3,1]] campanion view IR.json campanion schema campanion --help @@ -100,7 +100,7 @@ post Post an IR JSON file. --post is an id or a file ending in .json, same as Read-back uses the default job chord tolerance, 0.01 mm. A mismatch exits 1. posts List built-in post ids with their names and descriptions. verify Verify an IR JSON file and print a report. The status is 1 when the report has errors. - --mesh and --tool also check cutting moves against an STL mesh. Both are required together. + --mesh checks cutting moves using IR tool shapes. --tool overrides all cutters and needs --mesh. The tool is a radius in millimetres: ball:3, flat:3 or bull:3,1 (bull corner after the comma). Tolerance is the job-file default chord tolerance, 0.01 mm. Samples are min(0.25, tolerance*10) mm apart. A gouge names the operation, the position and the depth. XY arcs are sampled; other arcs are rejected. @@ -184,7 +184,7 @@ fn emit(args: &[OsString], ngc: bool) -> Result { struct VerifyArgs { file: PathBuf, - mesh: Option<(PathBuf, String)>, + mesh: Option<(PathBuf, Option)>, } fn verify(args: &[OsString]) -> Result { @@ -192,10 +192,16 @@ fn verify(args: &[OsString]) -> Result { let text = job::read_text(&args.file)?; let doc = campanion_ir::Document::from_json(&text)?; let gouges = if let Some((mesh_path, tool)) = &args.mesh { - let cutter = gouge::parse_tool(tool)?; + let cutter = tool.as_deref().map(gouge::parse_tool).transpose()?; + if let Some(tool) = tool { + writeln!(io::stderr().lock(), "mesh verification: --tool {tool} overrides cutter geometry for all cutting moves")?; + } let bytes = run::read_bytes(mesh_path)?; let mesh = campanion_mesh::parse_stl(&bytes).map_err(|err| CliError::run(format!("STL: {err}")))?; - gouge::check_program(doc.program(), &mesh, cutter, jobfile::DEFAULT_JOB_CHORD_MM)? + match cutter { + Some(cutter) => gouge::check_program(doc.program(), &mesh, cutter, jobfile::DEFAULT_JOB_CHORD_MM)?, + None => gouge::check_program_tools(doc.program(), &mesh, jobfile::DEFAULT_JOB_CHORD_MM)?, + } } else { Vec::new() }; @@ -247,9 +253,9 @@ fn parse_verify(args: &[OsString]) -> Result { let Some(tool) = tool.to_str() else { return Err(CliError::usage("tool must be ball:R, flat:R or bull:R,C")); }; - Some((PathBuf::from(mesh), tool.to_string())) + Some((PathBuf::from(mesh), Some(tool.to_string()))) } - (Some(_), None) => return Err(CliError::usage("verify --mesh needs --tool")), + (Some(mesh), None) => Some((PathBuf::from(mesh), None)), (None, Some(_)) => return Err(CliError::usage("verify --tool needs --mesh")), }; Ok(VerifyArgs { file: PathBuf::from(file), mesh }) diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 30432e1..011d9b0 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -788,7 +788,7 @@ fn finish_paths(job: &JobFile, paths: Vec, rotary: Option) Ok(Document::new(program)?) } -/// Copy each job tool into the IR, including an assembly when the job has one. +/// Copy each job tool and its resolved cutter shape into the IR, including an assembly when present. fn ir_tools(job: &JobFile) -> Result, CliError> { let mut tools = Vec::new(); for tool in job.tools() { diff --git a/crates/cli/tests/golden/dome_rough.ir.json b/crates/cli/tests/golden/dome_rough.ir.json index 49c2712..dde4a21 100644 --- a/crates/cli/tests/golden/dome_rough.ir.json +++ b/crates/cli/tests/golden/dome_rough.ir.json @@ -6,6 +6,9 @@ { "id": 1, "name": null, + "shape": { + "type": "flat" + }, "diameter": 6.0, "kerf": null } diff --git a/crates/cli/tests/surface_e2e.rs b/crates/cli/tests/surface_e2e.rs index 5deed96..44b9611 100644 --- a/crates/cli/tests/surface_e2e.rs +++ b/crates/cli/tests/surface_e2e.rs @@ -284,6 +284,11 @@ fn lowered_dome_cut_is_reported_as_a_gouge() -> Result<(), String> { return Err(format!("clean dome verify --mesh exit {:?} stdout {clean_out} stderr {clean_err}", clean.status.code())); } + let automatic = campanion(&root, &["verify", &ir_arg, "--mesh", "examples/dome.stl"])?; + if automatic.status.code() != Some(0) || automatic.stdout != clean.stdout { + return Err(format!("automatic cutter verification differs: {}", output_text(&automatic.stderr)?)); + } + let ir_text = std::fs::read_to_string(&ir_path).map_err(|err| format!("read ir: {err}"))?; let mut ir: Value = serde_json::from_str(&ir_text).map_err(|err| format!("ir json: {err}"))?; let blocks = ir["program"]["toolpaths"] diff --git a/crates/cli/tests/tool_shape_e2e.rs b/crates/cli/tests/tool_shape_e2e.rs new file mode 100644 index 0000000..50c5b97 --- /dev/null +++ b/crates/cli/tests/tool_shape_e2e.rs @@ -0,0 +1,106 @@ +//! #146: explicit failures and override behavior at the CLI boundary. + +#![allow(clippy::expect_used)] + +use campanion_ir::{ClBlock, ClMotion, ClPath, CompensationMode, Document, Feeds, MoveRole, Pose, Program, Tool, ToolShape, Vec3}; +use serde_json::json; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn directory(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("campanion-tool-shape-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("directory"); + dir +} + +fn run(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_campanion")).current_dir(dir).args(args).output().expect("CLI") +} + +fn write_ir(dir: &Path, shape: Option) { + let tool = Tool::try_new(1, None, Some(2.0), None).expect("tool"); + let tool = match shape { + Some(shape) => tool.with_shape(shape).expect("shape"), + None => tool, + }; + let pose = |x| Pose::new(Vec3::new(x, 0.0, 1.0).expect("point"), Vec3::Z).expect("pose"); + let blocks = vec![ + ClBlock::Motion(ClMotion::Rapid { to: pose(0.0), feed: None }), + ClBlock::Motion(ClMotion::Linear { to: pose(1.0), feed: 100.0, role: MoveRole::Cut }), + ]; + let path = ClPath::try_new( + None, + Some(1), + CompensationMode::Off, + Feeds::try_new(100.0, None, None, None, None).expect("feeds"), + None, + Vec::new(), + blocks, + ) + .expect("path"); + let program = Program::try_new(None, vec![tool], None, vec![path], Vec::new(), Vec::new(), None).expect("program"); + std::fs::write(dir.join("ir.json"), Document::new(program).expect("doc").to_json().expect("json")).expect("IR"); + std::fs::write( + dir.join("part.stl"), + "solid plane\nfacet normal 0 0 1\nouter loop\nvertex -10 -10 0\nvertex 10 -10 0\nvertex 0 10 0\nendloop\nendfacet\nendsolid plane\n", + ) + .expect("mesh"); +} + +#[test] +fn missing_shape_and_vbit_fail_mesh_check_but_allow_explicit_override() { + let dir = directory("verify"); + for (shape, expected) in [ + (None, "no cutter shape"), + (Some(ToolShape::VBit { angle: 1.0, tip_diameter: 0.0 }), "not supported yet: mesh verification for V-bit tool 1"), + ] { + write_ir(&dir, shape); + let ordinary = run(&dir, &["verify", "ir.json"]); + assert!(ordinary.status.success(), "{}", String::from_utf8_lossy(&ordinary.stderr)); + let automatic = run(&dir, &["verify", "ir.json", "--mesh", "part.stl"]); + assert!(!automatic.status.success()); + assert!(automatic.stdout.is_empty()); + assert!(String::from_utf8_lossy(&automatic.stderr).contains(expected)); + let override_result = run(&dir, &["verify", "ir.json", "--mesh", "part.stl", "--tool", "flat:1"]); + assert!(override_result.status.success(), "{}", String::from_utf8_lossy(&override_result.stderr)); + assert!(String::from_utf8_lossy(&override_result.stderr).contains("overrides cutter geometry for all cutting moves")); + } + std::fs::remove_dir_all(dir).expect("cleanup"); +} + +#[test] +fn conflicting_vbit_job_fails_before_any_output_is_written() { + let dir = directory("conflict"); + let job = json!({"format":"campanion-job/1", "source":{"path":"deliberately-missing.svg","hash":"cbf29ce484222325"}, "units":"mm", "tools":[{"id":1,"diameter":6}], + "operations":[ + {"name":"a","type":"chamfer","layers":["L"],"side":"outside","tool_id":1,"width":0.5,"v_angle_deg":90,"feeds":{"cut":100}}, + {"name":"b","type":"chamfer","layers":["L"],"side":"outside","tool_id":1,"width":0.5,"v_angle_deg":60,"feeds":{"cut":100}} + ]}); + std::fs::write(dir.join("job.json"), job.to_string()).expect("job"); + let output = run(&dir, &["run", "job.json", "-o", "out.nc", "--ir", "out.json"]); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("conflicting V-bit geometry")); + assert!(!dir.join("out.nc").exists()); + assert!(!dir.join("out.json").exists()); + std::fs::remove_dir_all(dir).expect("cleanup"); +} + +#[test] +fn chamfer_job_copies_vbit_geometry_into_emitted_ir() { + let dir = directory("chamfer"); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().expect("root"); + let output = Command::new(env!("CARGO_BIN_EXE_campanion")) + .current_dir(&root) + .arg("run") + .arg("examples/chamfer.job.json") + .arg("-o") + .arg(dir.join("out.nc")) + .arg("--ir") + .arg(dir.join("out.json")) + .output() + .expect("CLI"); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let doc = Document::from_json(&std::fs::read_to_string(dir.join("out.json")).expect("IR")).expect("load"); + assert_eq!(doc.program().tools().first().expect("tool").shape(), Some(ToolShape::VBit { angle: std::f64::consts::FRAC_PI_2, tip_diameter: 0.0 })); + std::fs::remove_dir_all(dir).expect("cleanup"); +} diff --git a/crates/ir/src/lib.rs b/crates/ir/src/lib.rs index c2c91e8..4531edb 100644 --- a/crates/ir/src/lib.rs +++ b/crates/ir/src/lib.rs @@ -76,6 +76,12 @@ //! rotary and lets the secondary rotary take the tilt. [`subdivide_path`] inserts joint-space //! samples so each segment stays within a chord tolerance and a tool-axis tolerance. +//! Tools may carry an optional [`ToolShape`], tagged by `type`: `flat`, `ball`, `bull` +//! (`corner_radius` in millimetres), or `v_bit` (`angle` in radians and `tip_diameter` in +//! millimetres). A shape needs a diameter tool without kerf. Absence means unknown, so old +//! IR loads and serializes without adding a shape. Shape metadata does not imply that a +//! mesh verifier supports every profile. + #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod check; @@ -90,6 +96,7 @@ mod program; mod rotary; mod schema; mod spindle; +mod tool_shape; mod turning; mod verify; mod wrap; @@ -111,6 +118,7 @@ pub use rotary::{ }; pub use schema::json_schema; pub use spindle::hold_spindle; +pub use tool_shape::ToolShape; pub use turning::{Hand, Head, HeadAxis, Insert, InsertShape, Spindle, SpindleSpeed, TurnBinding, TurnInsert, TurnSection, TurnStock, TurningSetup}; pub use verify::{Bounds, Issue, RefKind, VerifyOptions, VerifyReport, verify, verify_with}; pub use wrap::{Cylinder, unwrap_pose, wrap_point, wrap_polyline}; diff --git a/crates/ir/src/process.rs b/crates/ir/src/process.rs index 05a449d..aab7bc0 100644 --- a/crates/ir/src/process.rs +++ b/crates/ir/src/process.rs @@ -8,9 +8,9 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::Result; use crate::check::{self, finite, non_negative, optional_name, positive, required_name, text_len}; use crate::geom::Pose; +use crate::{Result, ToolShape}; /// Helix sense of a rotating router cutter. /// @@ -34,6 +34,9 @@ pub struct Tool { id: u32, #[serde(default)] name: Option, + /// Cutter profile. Absent means unknown; older IR remains valid. + #[serde(default, skip_serializing_if = "Option::is_none")] + shape: Option, /// Rotating tool diameter, millimetres. #[serde(default)] diameter: Option, @@ -53,11 +56,23 @@ pub struct Tool { impl Tool { pub fn try_new(id: u32, name: Option, diameter: Option, kerf: Option) -> Result { - let tool = Self { id, name, diameter, kerf, assembly: None, cutter_sense: None, upcut_length: None }; + let tool = Self { id, name, diameter, kerf, shape: None, assembly: None, cutter_sense: None, upcut_length: None }; + tool.check()?; + Ok(tool) + } + + /// Store a cutter profile and validate it against this tool's diameter. + pub fn with_shape(self, shape: ToolShape) -> Result { + let tool = Self { shape: Some(shape), ..self }; tool.check()?; Ok(tool) } + /// Cutter profile. `None` means the file did not specify it. + pub fn shape(&self) -> Option { + self.shape + } + /// Store a tool assembly and re-validate. A kerf tool cannot carry one. pub fn with_assembly(self, assembly: ToolAssembly) -> Result { let tool = Self { assembly: Some(assembly), ..self }; @@ -115,6 +130,9 @@ impl Tool { if let Some(kerf) = self.kerf { check_cutter(kerf, "kerf width")?; } + if let Some(shape) = self.shape { + shape.check(self.diameter, self.kerf)?; + } if let Some(assembly) = &self.assembly { if self.kerf.is_some() || self.diameter.is_none() { return Err(check::invalid("assembly is not valid on a kerf tool")); diff --git a/crates/ir/src/tool_shape.rs b/crates/ir/src/tool_shape.rs new file mode 100644 index 0000000..65e4afe --- /dev/null +++ b/crates/ir/src/tool_shape.rs @@ -0,0 +1,122 @@ +//! Optional cutter geometry carried by the IR, in millimetres and radians. + +use serde::{Deserialize, Serialize}; + +use crate::{Result, check}; + +/// Rotating cutter profile. Absence on a tool means unknown, not flat. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ToolShape { + /// Flat end mill. + Flat {}, + /// Ball nose with radius equal to half the tool diameter. + Ball {}, + /// Bull nose with a positive corner radius smaller than the tool radius. + Bull { + /// Millimetres. + corner_radius: f64, + }, + /// V-bit geometry; carrying this metadata does not imply verifier support. + VBit { + /// Included angle in radians, strictly between zero and pi. + angle: f64, + /// Flat tip diameter in millimetres, from zero up to (but excluding) tool diameter. + tip_diameter: f64, + }, +} + +impl ToolShape { + pub(crate) fn check(self, diameter: Option, kerf: Option) -> Result<()> { + let Some(diameter) = diameter.filter(|_| kerf.is_none()) else { + return Err(check::invalid("tool shape requires a diameter tool without kerf")); + }; + match self { + Self::Flat {} | Self::Ball {} => {} + Self::Bull { corner_radius } => { + check::positive(corner_radius, "bull corner radius")?; + if corner_radius >= diameter * 0.5 { + return Err(check::invalid("bull corner radius must be less than the tool radius")); + } + } + Self::VBit { angle, tip_diameter } => { + check::positive(angle, "V-bit angle")?; + if angle >= std::f64::consts::PI { + return Err(check::invalid("V-bit angle must be less than pi radians")); + } + check::non_negative(tip_diameter, "V-bit tip diameter")?; + if tip_diameter >= diameter { + return Err(check::invalid("V-bit tip diameter must be less than the tool diameter")); + } + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use crate::{Document, Program, Tool}; + use proptest::prelude::*; + + fn document(tool: Tool) -> Document { + Document::new(Program::try_new(None, vec![tool], None, Vec::new(), Vec::new(), Vec::new(), None).expect("program")).expect("document") + } + + #[test] + fn optional_shapes_round_trip_and_old_ir_stays_unchanged() { + let tool = Tool::try_new(1, None, Some(6.0), None).expect("tool"); + let old = document(tool.clone()).to_json().expect("json"); + assert!(!old.contains("shape")); + assert_eq!(Document::from_json(&old).expect("old IR").to_json().expect("json"), old); + for shape in + [ToolShape::Flat {}, ToolShape::Ball {}, ToolShape::Bull { corner_radius: 1.0 }, ToolShape::VBit { angle: 1.2, tip_diameter: 0.2 }] + { + let doc = document(tool.clone().with_shape(shape).expect("shape")); + assert_eq!(Document::from_json(&doc.to_json().expect("json")).expect("load"), doc); + } + } + + #[test] + fn shape_dimensions_are_checked_by_builders_and_document_loading() { + let tool = Tool::try_new(1, None, Some(6.0), None).expect("tool"); + for value in [f64::NAN, f64::INFINITY, -1.0, 0.0, 3.0, 4.0] { + assert!(tool.clone().with_shape(ToolShape::Bull { corner_radius: value }).is_err()); + } + for angle in [f64::NAN, f64::INFINITY, -1.0, 0.0, std::f64::consts::PI] { + assert!(tool.clone().with_shape(ToolShape::VBit { angle, tip_diameter: 0.0 }).is_err()); + } + for tip_diameter in [f64::NAN, f64::INFINITY, -1.0, 6.0] { + assert!(tool.clone().with_shape(ToolShape::VBit { angle: 1.0, tip_diameter }).is_err()); + } + assert!(Tool::try_new(2, None, None, Some(0.2)).expect("kerf").with_shape(ToolShape::Flat {}).is_err()); + let doc = document(tool); + let mut value = serde_json::to_value(&doc).expect("value"); + for shape in [ + serde_json::json!({"type":"bull", "corner_radius":3}), + serde_json::json!({"type":"v_bit", "angle":1, "tip_diameter":6}), + serde_json::json!({"type":"flat", "unknown":true}), + ] { + value["program"]["tools"][0]["shape"] = shape; + assert!(Document::from_json(&value.to_string()).is_err(), "accepted {value}"); + } + } + + proptest! { + #[test] + fn valid_vbit_geometry_round_trips(diameter in 0.1f64..1000.0, ratio in 0.0f64..0.99, angle in 0.001f64..3.0) { + let shape = ToolShape::VBit { angle, tip_diameter: diameter * ratio }; + let tool = Tool::try_new(1, None, Some(diameter), None).expect("tool").with_shape(shape).expect("shape"); + let doc = document(tool); + let loaded = Document::from_json(&doc.to_json().expect("json")).expect("load"); + let loaded_shape = loaded.program().tools().first().expect("tool").shape().expect("shape"); + let ToolShape::VBit { angle: actual_angle, tip_diameter: actual_tip } = loaded_shape else { + return Err(TestCaseError::fail("wrong shape")); + }; + prop_assert!((actual_angle - angle).abs() <= 1e-12); + prop_assert!((actual_tip - diameter * ratio).abs() <= diameter * 1e-12); + } + } +} diff --git a/docs/job-file.md b/docs/job-file.md index fee7d6d..4b7daf7 100644 --- a/docs/job-file.md +++ b/docs/job-file.md @@ -111,6 +111,14 @@ A shape other than flat is rejected when a profile, pocket, drill, engrave, face that tool. Those operations keep the flat diameter offset and do not read `shape`. A surface, a rough, a waterline or a rotary finish reads `shape`. +Generated IR carries the resolved cutter shape on each diameter tool. Chamfers keep +`v_angle_deg` and `tip_diameter` on their operations and also copy that geometry into the IR +tool as a V-bit (angle in radians, tip diameter in millimetres). All chamfers sharing a tool ID +must agree on angle and tip diameter; widths and clearances may differ. An explicit tool +`shape` conflicts with chamfer-derived V-bit geometry, including explicit `"flat"`. Omit it +for a chamfer cutter. Conflicts fail job loading before output is written; use separate tool +IDs for different physical cutters. Job syntax does not add a V-bit `shape` variant in this change. + A diameter tool may set `assembly`: stickout, flute, shank, a holder stack and an optional head. A kerf tool with `assembly` is an error. See [Tool assembly and collisions](#tool-assembly-and-collisions). diff --git a/docs/schema/campanion-ir-1.schema.json b/docs/schema/campanion-ir-1.schema.json index 332eef8..71cce4f 100644 --- a/docs/schema/campanion-ir-1.schema.json +++ b/docs/schema/campanion-ir-1.schema.json @@ -2522,6 +2522,17 @@ "null" ] }, + "shape": { + "anyOf": [ + { + "$ref": "#/$defs/ToolShape" + }, + { + "type": "null" + } + ], + "description": "Cutter profile. Absent means unknown; older IR remains valid." + }, "upcut_length": { "description": "Upcut length at the tip, millimetres. Only with [`CutterSense::Compression`].", "format": "double", @@ -2581,6 +2592,85 @@ ], "type": "object" }, + "ToolShape": { + "description": "Rotating cutter profile. Absence on a tool means unknown, not flat.", + "oneOf": [ + { + "additionalProperties": false, + "description": "Flat end mill.", + "properties": { + "type": { + "const": "flat", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Ball nose with radius equal to half the tool diameter.", + "properties": { + "type": { + "const": "ball", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Bull nose with a positive corner radius smaller than the tool radius.", + "properties": { + "corner_radius": { + "description": "Millimetres.", + "format": "double", + "type": "number" + }, + "type": { + "const": "bull", + "type": "string" + } + }, + "required": [ + "type", + "corner_radius" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "V-bit geometry; carrying this metadata does not imply verifier support.", + "properties": { + "angle": { + "description": "Included angle in radians, strictly between zero and pi.", + "format": "double", + "type": "number" + }, + "tip_diameter": { + "description": "Flat tip diameter in millimetres, from zero up to (but excluding) tool diameter.", + "format": "double", + "type": "number" + }, + "type": { + "const": "v_bit", + "type": "string" + } + }, + "required": [ + "type", + "angle", + "tip_diameter" + ], + "type": "object" + } + ] + }, "TurnBinding": { "additionalProperties": false, "description": "Binds one toolpath to a spindle, a head and a channel.\n\n`toolpath` is an index into [`crate::Program::toolpaths`]. `channel` is a [`crate::Channel`]\nid. An M10 binding uses spindle `main`, head `turret1` and channel 0. `speed` is the spindle\nspeed for that toolpath. Absent means the post uses the process-on speed parameter.",