diff --git a/apple/VcadApp/Sources/VcadApp/CNCMachineBar.swift b/apple/VcadApp/Sources/VcadApp/CNCMachineBar.swift index 14d4fdc94..e59101750 100644 --- a/apple/VcadApp/Sources/VcadApp/CNCMachineBar.swift +++ b/apple/VcadApp/Sources/VcadApp/CNCMachineBar.swift @@ -47,10 +47,14 @@ struct CNCMachineBar: View { if let axes = zeroAxes { Button("Zero \(axes)") { cnc.setupConfirmed = false; machine.zero(axes: axes); zeroAxes = nil } } + Button("Cancel", role: .cancel) {}.keyboardShortcut(.defaultAction) } .sheet(isPresented: $probeShown) { CNCProbeSheet(cnc: cnc) } .confirmationDialog(machine.demo ? "Run the job in the simulator?" : "Start machining this job?", isPresented: $runShown) { Button(machine.demo ? "Run simulated job" : "Start machining") { cnc.startJob() } + // Return answers Cancel: starting the spindle is a deliberate click, + // never the key that also commits a number field. + Button("Cancel", role: .cancel) {}.keyboardShortcut(.defaultAction) } message: { Text(cnc.usesImportedProgram ? "\(cnc.importedName) · G54. Verify the installed tool, program and initial travel." : "\(counted(cnc.operations.count, "operation")) · Ø \(cnc.toolDiameter.formatted()) mm tool · G54. The program starts the spindle and cuts to the configured depths.") } @@ -237,7 +241,10 @@ struct CNCMachineBar: View { .frame(width: 78) }.buttonStyle(.borderedProminent).tint(moving ? .orange : .accentColor) .disabled(held ? !canResume : moving ? !machine.connected : cnc.runBlocker != nil) - .keyboardShortcut(.return, modifiers: [.command, .option]) + // No Return-based key equivalent: AppKit advertises any button + // whose key is Return as the window's default button, modifiers + // or not, so accessibility clients pressed Run Job for "return". + .keyboardShortcut("j", modifiers: [.command, .option]) Button { machine.hold() // Request hold immediately; never leave motion running behind the reset dialog. stopShown = true @@ -315,6 +322,7 @@ struct CNCJogControls: View { } .confirmationDialog("Home the machine?", isPresented: $homeShown) { Button("Run homing cycle") { cnc.setupConfirmed = false; machine.home() } + Button("Cancel", role: .cancel) {}.keyboardShortcut(.defaultAction) } message: { Text("The axes will move toward the configured homing switches.") } .confirmationDialog("Move to \(destination ?? "")?", isPresented: Binding(get: { destination != nil }, set: { if !$0 { destination = nil } })) { Button("Move") { @@ -322,6 +330,7 @@ struct CNCJogControls: View { else { machine.returnToZero(xy: destination == "XY zero", clearance: cnc.setup.clearance) } destination = nil } + Button("Cancel", role: .cancel) {}.keyboardShortcut(.defaultAction) } message: { Text(destination == "Park" ? "Retract to saved machine Z before XY travel, then return to saved Z. Verify the path is clear." : destination == "XY zero" ? "Retract to at least the CAM clearance height before moving to work X0 Y0." : "Move to work Z0 at 100 mm/min.") } diff --git a/apple/VcadApp/Sources/VcadApp/CNCStudioDrawer.swift b/apple/VcadApp/Sources/VcadApp/CNCStudioDrawer.swift index 3a13d91e8..cbdb5cc80 100644 --- a/apple/VcadApp/Sources/VcadApp/CNCStudioDrawer.swift +++ b/apple/VcadApp/Sources/VcadApp/CNCStudioDrawer.swift @@ -90,6 +90,7 @@ struct CNCStudioMacros: View { }.controlSize(.small).padding(.horizontal, 18).padding(.bottom, 12) .confirmationDialog("Run macro \(pending?.name ?? "")?", isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } })) { if let macro = pending { Button("Send command") { cnc.setupConfirmed = false; cnc.machine.sendMDI(macro.command); pending = nil } } + Button("Cancel", role: .cancel) {}.keyboardShortcut(.defaultAction) } message: { Text(pending?.command ?? "") } } } diff --git a/apple/VcadApp/Sources/VcadApp/CNCWorkspace.swift b/apple/VcadApp/Sources/VcadApp/CNCWorkspace.swift index b71d2115f..3e9e5951d 100644 --- a/apple/VcadApp/Sources/VcadApp/CNCWorkspace.swift +++ b/apple/VcadApp/Sources/VcadApp/CNCWorkspace.swift @@ -148,7 +148,18 @@ final class CNCWorkspace { var showClearance = false var showPart = true var origin = CNCVector() { didSet { setupConfirmed = false } } - var stockThickness = 10.0 { didSet { setupConfirmed = false } } + var stockThickness = 10.0 { + didSet { + setupConfirmed = false + // A contour imported as a through cut stays one. Without this, an + // outline imported before the thickness was entered kept cutting + // to the old depth and the job just read as blocked. + for i in operations.indices where operations[i].setup.isContour && operations[i].setup.depth == oldValue { + operations[i].setup.depth = stockThickness + operations[i].setup.tabHeight = min(operations[i].setup.tabHeight, stockThickness / 2) + } + } + } var jogStep = 1.0 var jogFeed = 300.0 var setupConfirmed = false diff --git a/apple/VcadApp/Tests/VcadAppTests/CNCOutlineTests.swift b/apple/VcadApp/Tests/VcadAppTests/CNCOutlineTests.swift index 420742112..a2b43409f 100644 --- a/apple/VcadApp/Tests/VcadAppTests/CNCOutlineTests.swift +++ b/apple/VcadApp/Tests/VcadAppTests/CNCOutlineTests.swift @@ -85,6 +85,21 @@ final class CNCOutlineTests: XCTestCase { } } + /// Entering the stock thickness after importing the outline must not leave + /// the contours cutting to the thickness that was there at import. + func testThroughCutsFollowTheStockThickness() throws { + let cnc = CNCWorkspace() + let square = "0\nLWPOLYLINE\n70\n1\n10\n0\n20\n0\n10\n40\n20\n0\n10\n40\n20\n40\n10\n0\n20\n40\n0\nEOF\n" + try cnc.importOutline(try CNCOutline.parseDXF(square, name: "square.dxf")) + XCTAssertEqual(cnc.operations.map(\.setup.depth), [10]) + cnc.stockThickness = 6 + XCTAssertEqual(cnc.operations.map(\.setup.depth), [6]) + // A depth the user set by hand is theirs. + cnc.setup.depth = 2 + cnc.stockThickness = 8 + XCTAssertEqual(cnc.operations.map(\.setup.depth), [2]) + } + /// The real part: the rana stator outline, when `VCAD_STATOR_DXF` points at it. func testStatorOutlineGeneratesWithTabs() async throws { guard let path = ProcessInfo.processInfo.environment["VCAD_STATOR_DXF"], @@ -104,5 +119,9 @@ final class CNCOutlineTests: XCTestCase { print("STATOR CAM: \(String(format: "%.1f", Date().timeIntervalSince(t0))) s, moves \(cnc.operations.map { $0.program?.moves.count ?? 0 }), est \(CNCWorkspace.durationLabel(cnc.jobDuration))") XCTAssertNil(cnc.error) XCTAssertTrue(cnc.jobCurrent) + // `VCAD_STATOR_GCODE_OUT` keeps the job so it can be checked outside the app. + if let out = ProcessInfo.processInfo.environment["VCAD_STATOR_GCODE_OUT"] { + try XCTUnwrap(cnc.jobCode).write(toFile: out, atomically: true, encoding: .utf8) + } } } diff --git a/changelog/entries/2026-09-17-contour-cam-side-and-tabs.json b/changelog/entries/2026-09-17-contour-cam-side-and-tabs.json new file mode 100644 index 000000000..ded242d97 --- /dev/null +++ b/changelog/entries/2026-09-17-contour-cam-side-and-tabs.json @@ -0,0 +1,9 @@ +{ + "id": "2026-09-17-contour-cam-side-and-tabs", + "version": "0.10.0", + "date": "2026-09-17", + "category": "fix", + "title": "Inside contours cut inside; holding tabs hold", + "summary": "Inside contour toolpaths were offset outward, into the part. Tabs now survive every pass at their stated width, avoid notches and corners, and cut depth follows stock thickness.", + "features": ["cam", "native-app", "manufacture"] +} diff --git a/crates/vcad-kernel-cam/src/error.rs b/crates/vcad-kernel-cam/src/error.rs index 160be7803..4fc8a4617 100644 --- a/crates/vcad-kernel-cam/src/error.rs +++ b/crates/vcad-kernel-cam/src/error.rs @@ -45,6 +45,13 @@ pub enum CamError { #[error("pocket offset resulted in empty geometry")] EmptyPocketOffset, + /// The tool-compensated contour fell into separate pieces: the cutter does + /// not fit through a neck of the contour. + #[error( + "the cutter does not fit through the contour: its path splits into {0} separate regions" + )] + ContourSplit(usize), + /// Tab position is out of range. #[error("tab position {0} is out of contour range")] InvalidTabPosition(f64), diff --git a/crates/vcad-kernel-cam/src/operation/contour.rs b/crates/vcad-kernel-cam/src/operation/contour.rs index 3c0fec47a..c0aa12d66 100644 --- a/crates/vcad-kernel-cam/src/operation/contour.rs +++ b/crates/vcad-kernel-cam/src/operation/contour.rs @@ -9,9 +9,12 @@ use serde::{Deserialize, Serialize}; /// A holding tab to prevent part from moving during cutout. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tab { - /// Position along the contour as a fraction (0.0 to 1.0). + /// Nominal position along the contour as a fraction (0.0 to 1.0). The + /// tab settles on the nearest stretch that runs straight, within half a + /// tab pitch, so it never lands in a notch or wraps a tight corner. pub position: f64, - /// Width of the tab in mm. + /// Width of the material left standing, in mm. The cutter is lifted over + /// this plus one tool diameter, since it cuts a radius into each end. pub width: f64, /// Height of the tab (how much material to leave). pub height: f64, @@ -44,6 +47,11 @@ pub struct Contour2D { pub tabs: Vec, /// Stock to leave for finishing pass. pub stock_to_leave: f64, + /// The cutter runs inside the contour (a hole or opening) instead of + /// around it: tool-radius compensation and stock-to-leave move the path + /// inwards. + #[serde(default)] + pub inside: bool, } impl Contour2D { @@ -55,6 +63,7 @@ impl Contour2D { offset: 0.0, tabs: Vec::new(), stock_to_leave: 0.0, + inside: false, } } @@ -73,7 +82,8 @@ impl Contour2D { /// Add multiple evenly-spaced tabs. pub fn with_tabs(mut self, count: usize, width: f64, height: f64) -> Self { for i in 0..count { - let position = i as f64 / count as f64; + // Half a pitch in, so no tab sits on the seam where each pass plunges. + let position = (i as f64 + 0.5) / count as f64; self.tabs.push(Tab::new(position, width, height)); } self @@ -92,7 +102,10 @@ impl Contour2D { /// Create an inside contour for cutting a hole. pub fn inside(contour: Contour, depth: f64) -> Self { - Self::new(contour, depth) + Self { + inside: true, + ..Self::new(contour, depth) + } } /// Generate the toolpath for this contour operation. @@ -130,76 +143,36 @@ impl Contour2D { // Calculate offset path let tool_radius = tool.radius(); - let total_offset = tool_radius + self.offset + self.stock_to_leave; + let compensation = tool_radius + self.stock_to_leave; + let total_offset = self.offset + + if self.inside { + -compensation + } else { + compensation + }; let offset_contour = self.offset_contour(total_offset)?; // Calculate Z levels let num_z_passes = (self.depth / settings.stepdown).ceil() as usize; let z_step = self.depth / num_z_passes as f64; - // Calculate tab Z height (from bottom) - let tab_z = if !self.tabs.is_empty() { - Some(-self.depth + self.tabs[0].height) - } else { - None - }; - for z_pass in 0..num_z_passes { let z = -((z_pass + 1) as f64) * z_step; - let is_final_pass = z_pass == num_z_passes - 1; toolpath.push(ToolpathSegment::comment(format!("Z level: {:.3}", z))); - // Get contour points let points = &offset_contour; if points.is_empty() { continue; } - - // Move to start + // Every pass that reaches below a tab's top steps over it — not + // only the last one, or the passes before it cut the tab away. + let raised = self.raised_intervals(points, z, tool.diameter()); + self.follow(&mut toolpath, points, z, &raised, settings); + // Straight up out of the cut, after the last pass too: the final + // rapid below travels in XY. let start = &points[0]; toolpath.push(ToolpathSegment::rapid(start.x, start.y, settings.safe_z)); - toolpath.push(ToolpathSegment::linear( - start.x, - start.y, - z, - settings.plunge_rate, - )); - - // Follow contour with tab handling on final pass - if is_final_pass && !self.tabs.is_empty() { - if let Some(tab_height) = tab_z { - self.generate_with_tabs( - &mut toolpath, - points, - z, - tab_height, - settings.feed_rate, - ); - } - } else { - // Regular contour following - for point in points.iter().skip(1) { - toolpath.push(ToolpathSegment::linear( - point.x, - point.y, - z, - settings.feed_rate, - )); - } - // Close the contour - toolpath.push(ToolpathSegment::linear( - start.x, - start.y, - z, - settings.feed_rate, - )); - } - - // Retract between passes - if !is_final_pass { - toolpath.push(ToolpathSegment::rapid(start.x, start.y, settings.safe_z)); - } } // Final retract @@ -234,6 +207,12 @@ impl Contour2D { if result.0.is_empty() { return Err(CamError::EmptyContour); } + // An inward offset that falls apart means the cutter cannot pass a + // neck of the opening. Following only the first piece would leave the + // rest uncut without a word. + if result.0.len() > 1 { + return Err(CamError::ContourSplit(result.0.len())); + } // Extract points from first polygon if let Some(poly) = result.0.first() { @@ -349,83 +328,163 @@ impl Contour2D { points } - /// Generate toolpath with tabs on final pass. - fn generate_with_tabs( + /// Stretches of the closed loop where a pass at `cut_z` must ride over a + /// tab: `(from, to, top_z)` in path length from the loop's first point. + /// A tab across the seam comes back as two stretches. + fn raised_intervals( &self, - toolpath: &mut Toolpath, points: &[Point2D], cut_z: f64, - tab_z: f64, - feed: f64, - ) { - if points.is_empty() { - return; + tool_diameter: f64, + ) -> Vec<(f64, f64, f64)> { + let total = loop_length(points); + let mut out = Vec::new(); + if total <= 0.0 { + return out; } - - // Calculate cumulative distances - let mut cumulative_dist = vec![0.0]; - let mut total_dist = 0.0; - - for i in 1..points.len() { - let dist = points[i - 1].distance_to(&points[i]); - total_dist += dist; - cumulative_dist.push(total_dist); + for tab in &self.tabs { + let top = -self.depth + tab.height; + if top <= cut_z + 1e-9 { + continue; + } + let half = ((tab.width + tool_diameter) / 2.0).min(total / 2.0); + let centre = self.settle_tab(points, total, tab.position, half); + let (from, to) = (centre - half, centre + half); + if from < 0.0 { + out.push((from + total, total, top)); + out.push((0.0, to, top)); + } else if to > total { + out.push((from, total, top)); + out.push((0.0, to - total, top)); + } else { + out.push((from, to, top)); + } } + out + } - // Add distance to close the loop - let close_dist = points.last().unwrap().distance_to(&points[0]); - total_dist += close_dist; - - // Sort tabs by position - let mut sorted_tabs: Vec<_> = self.tabs.iter().collect(); - sorted_tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); - - // Generate path with tabs - let mut in_tab = false; + /// Where a tab nominally at `position` actually goes: the nearest stretch + /// (within half a tab pitch) that runs straight enough, so a tab never + /// lands in a notch or wraps a tight corner, where it would hold little + /// and be hard to clean off. Straightness is the chord across the lifted + /// stretch over its path length. + fn settle_tab(&self, points: &[Point2D], total: f64, position: f64, half: f64) -> f64 { + const STRAIGHT_ENOUGH: f64 = 0.98; + const STEP: f64 = 0.5; + let nominal = position.rem_euclid(1.0) * total; + let straightness = |centre: f64| { + let a = point_at(points, total, centre - half); + let b = point_at(points, total, centre + half); + a.distance_to(&b) / (2.0 * half) + }; + let reach = total / (2.0 * self.tabs.len().max(1) as f64) - half; + let mut best = (straightness(nominal), nominal); + let mut d = STEP; + while best.0 < STRAIGHT_ENOUGH && d <= reach { + for centre in [nominal + d, nominal - d] { + let q = straightness(centre); + if q > best.0 && (q >= STRAIGHT_ENOUGH || best.0 < STRAIGHT_ENOUGH) { + best = (q, centre); + } + } + d += STEP; + } + best.1.rem_euclid(total) + } - for point_idx in 1..=points.len() { - let point = if point_idx == points.len() { - &points[0] - } else { - &points[point_idx] - }; + /// One pass around the closed loop at `cut_z`: plunge at the seam, follow + /// the loop, and step over each raised stretch with a vertical lift at its + /// start and a vertical plunge at its end, so a tab keeps square ends at + /// exactly the stretch it was given. + fn follow( + &self, + toolpath: &mut Toolpath, + points: &[Point2D], + cut_z: f64, + raised: &[(f64, f64, f64)], + settings: &CamSettings, + ) { + let height_at = |s: f64| { + raised + .iter() + .filter(|(from, to, _)| s >= *from && s <= *to) + .map(|(_, _, top)| *top) + .fold(cut_z, f64::max) + }; + let start = &points[0]; + let mut z = height_at(0.0); + toolpath.push(ToolpathSegment::rapid(start.x, start.y, settings.safe_z)); + toolpath.push(ToolpathSegment::linear( + start.x, + start.y, + z, + settings.plunge_rate, + )); - let point_dist = if point_idx == points.len() { - total_dist - } else { - cumulative_dist[point_idx] - }; - let point_fraction = point_dist / total_dist; - - // Check if we're in a tab region - let mut currently_in_tab = false; - for tab in &sorted_tabs { - let tab_half_width = (tab.width / total_dist) / 2.0; - let tab_start = (tab.position - tab_half_width).max(0.0); - let tab_end = (tab.position + tab_half_width).min(1.0); - - if point_fraction >= tab_start && point_fraction <= tab_end { - currently_in_tab = true; - break; + let mut s0 = 0.0; + for k in 0..points.len() { + let (a, b) = (&points[k], &points[(k + 1) % points.len()]); + let len = a.distance_to(b); + if len <= 0.0 { + continue; + } + let s1 = s0 + len; + // Where the height changes inside this segment. + let mut cuts: Vec = raised + .iter() + .flat_map(|(from, to, _)| [*from, *to]) + .filter(|s| *s > s0 + 1e-9 && *s < s1 - 1e-9) + .collect(); + cuts.push(s1); + cuts.sort_by(f64::total_cmp); + let mut from = s0; + for to in cuts { + let want = height_at((from + to) / 2.0); + if (want - z).abs() > 1e-9 { + let t = (from - s0) / len; + let (x, y) = (a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); + let rate = if want < z { + settings.plunge_rate + } else { + settings.feed_rate + }; + toolpath.push(ToolpathSegment::linear(x, y, want, rate)); + z = want; } + let t = (to - s0) / len; + toolpath.push(ToolpathSegment::linear( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t, + z, + settings.feed_rate, + )); + from = to; } + s0 = s1; + } + } +} - // Handle transition in/out of tab - if currently_in_tab && !in_tab { - // Entering tab: raise Z - toolpath.push(ToolpathSegment::linear(point.x, point.y, tab_z, feed)); - in_tab = true; - } else if !currently_in_tab && in_tab { - // Leaving tab: lower Z - toolpath.push(ToolpathSegment::linear(point.x, point.y, cut_z, feed)); - in_tab = false; - } else { - // Normal move at current Z - let z = if in_tab { tab_z } else { cut_z }; - toolpath.push(ToolpathSegment::linear(point.x, point.y, z, feed)); - } +/// The point at path length `s` (any sign; it wraps) along the closed loop. +fn point_at(points: &[Point2D], total: f64, s: f64) -> Point2D { + let mut s = s.rem_euclid(total); + for k in 0..points.len() { + let (a, b) = (&points[k], &points[(k + 1) % points.len()]); + let len = a.distance_to(b); + if s <= len && len > 0.0 { + let t = s / len; + return Point2D::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); } + s -= len; } + Point2D::new(points[0].x, points[0].y) +} + +/// Length of the closed loop through `points`. +fn loop_length(points: &[Point2D]) -> f64 { + (0..points.len()) + .map(|k| points[k].distance_to(&points[(k + 1) % points.len()])) + .sum() } #[cfg(test)] @@ -495,6 +554,261 @@ mod tests { assert!(!toolpath.is_empty()); } + fn polyline(points: &[(f64, f64)]) -> Contour { + let mut c = Contour::new(Point2D::new(points[0].0, points[0].1)); + for p in points.iter().skip(1).chain(std::iter::once(&points[0])) { + c.line_to(Point2D::new(p.0, p.1)); + } + c + } + + /// The side is the whole point of a contour cut: outside keeps the cutter + /// off the part, inside keeps it within the opening. Either winding. + #[test] + fn test_contour2d_cuts_on_the_named_side() { + let tool = Tool::FlatEndMill { + diameter: 6.0, + flute_length: 20.0, + flutes: 2, + }; + let settings = CamSettings::default(); + let ccw = [(0.0, 0.0), (30.0, 0.0), (30.0, 20.0), (0.0, 20.0)]; + let cw = [(0.0, 0.0), (0.0, 20.0), (30.0, 20.0), (30.0, 0.0)]; + for loop_points in [ccw, cw] { + for inside in [false, true] { + let contour = polyline(&loop_points); + let op = if inside { + Contour2D::inside(contour, 2.0) + } else { + Contour2D::outside(contour, 2.0) + }; + let toolpath = op.generate(&tool, &settings).unwrap(); + let cuts: Vec<[f64; 3]> = toolpath + .segments + .iter() + .filter(|s| s.is_cutting()) + .filter_map(|s| s.target()) + .filter(|t| t[2] < 0.0) + .collect(); + assert!(cuts.len() >= 4); + for t in cuts { + // Signed clearance of the tool centre from the rectangle's + // boundary: positive inside the rectangle. + let within = t[0].min(30.0 - t[0]).min(t[1]).min(20.0 - t[1]); + // Distance from the rectangle for a point outside it + // (round joins put the corners on an arc). + let dx = (-t[0]).max(t[0] - 30.0).max(0.0); + let dy = (-t[1]).max(t[1] - 20.0).max(0.0); + if inside { + assert!(within >= 3.0 - 0.02, "inside cut at {t:?} gouges the wall"); + } else { + assert!(within <= 0.0, "outside cut at {t:?} is inside the part"); + assert!( + dx.hypot(dy) >= 3.0 - 0.02, + "outside cut at {t:?} gouges the part" + ); + } + } + } + } + } + + /// A cutter wider than a neck of the opening cannot follow it in one + /// loop; that is an error, not a silently shorter path. + #[test] + fn test_contour2d_inside_refuses_a_neck_the_cutter_cannot_pass() { + // Two 20 mm squares joined by a 4 mm wide neck. + let dumbbell = polyline(&[ + (0.0, 0.0), + (20.0, 0.0), + (20.0, 8.0), + (30.0, 8.0), + (30.0, 0.0), + (50.0, 0.0), + (50.0, 20.0), + (30.0, 20.0), + (30.0, 12.0), + (20.0, 12.0), + (20.0, 20.0), + (0.0, 20.0), + ]); + let tool = Tool::FlatEndMill { + diameter: 6.0, + flute_length: 20.0, + flutes: 2, + }; + let result = Contour2D::inside(dumbbell, 2.0).generate(&tool, &CamSettings::default()); + assert!( + matches!(result, Err(CamError::ContourSplit(2))), + "{result:?}" + ); + } + + /// Metal left under each tab, measured on the toolpath: for every pass + /// that goes below the tab top, the length the cutter spends lifted, less + /// the tool diameter it cuts into the two ends. + fn tab_metal_per_pass(toolpath: &Toolpath, tab_top: f64, tool_diameter: f64) -> Vec> { + let mut passes: Vec> = Vec::new(); + let mut at = [0.0, 0.0, 10.0]; + let mut run: Option = None; + let mut below = false; + for seg in &toolpath.segments { + let Some(to) = seg.target() else { continue }; + if seg.is_rapid() { + if let Some(r) = run.take() { + passes.last_mut().unwrap().push(r - tool_diameter); + } + if below { + below = false; + } else if passes.last().is_some_and(Vec::is_empty) { + passes.pop(); + } + passes.push(Vec::new()); + } else { + let lifted = (at[2] - tab_top).abs() < 1e-9 && (to[2] - tab_top).abs() < 1e-9; + below |= to[2] < tab_top - 1e-9; + let xy = (to[0] - at[0]).hypot(to[1] - at[1]); + // A change of height happens in place, never along a ramp. + assert!( + (to[2] - at[2]).abs() < 1e-9 || xy < 1e-9 || at[2] > 0.0, + "ramp at {to:?}" + ); + if lifted { + *run.get_or_insert(0.0) += xy; + } else if let Some(r) = run.take() { + passes.last_mut().unwrap().push(r - tool_diameter); + } + } + at = to; + } + passes.retain(|p| !p.is_empty()); + passes + } + + #[test] + fn test_contour2d_tabs_survive_every_pass_at_their_stated_width() { + let tool = Tool::FlatEndMill { + diameter: 6.0, + flute_length: 25.0, + flutes: 2, + }; + let settings = CamSettings { + stepdown: 1.0, + ..CamSettings::default() + }; + // Depth 4 in 1 mm passes, tabs 1.5 tall: the passes at -3 and -4 both + // reach below the tab top at -2.5. + let op = Contour2D::outside(Contour::rectangle(0.0, 0.0, 50.0, 40.0), 4.0) + .with_tabs(3, 5.0, 1.5); + let toolpath = op.generate(&tool, &settings).unwrap(); + let passes = tab_metal_per_pass(&toolpath, -2.5, 6.0); + assert_eq!(passes.len(), 2, "{passes:?}"); + for pass in passes { + assert_eq!(pass.len(), 3, "{pass:?}"); + for metal in pass { + assert!( + (metal - 5.0).abs() < 1e-6, + "tab leaves {metal} mm, asked for 5" + ); + } + } + } + + /// A tab on the seam (where each pass starts and ends) is one tab, whole. + #[test] + fn test_contour2d_tab_across_the_seam_is_whole() { + let tool = Tool::FlatEndMill { + diameter: 6.0, + flute_length: 25.0, + flutes: 2, + }; + let settings = CamSettings { + stepdown: 4.0, + ..CamSettings::default() + }; + let op = Contour2D::outside(Contour::rectangle(0.0, 0.0, 50.0, 40.0), 4.0) + .with_tab(Tab::new(0.0, 5.0, 1.5)); + let toolpath = op.generate(&tool, &settings).unwrap(); + // The pass starts lifted (no plunge through the tab) and the two + // halves add up to the tab plus one tool diameter. + let first_cut = toolpath + .segments + .iter() + .find(|s| s.is_cutting()) + .and_then(|s| s.target()) + .unwrap(); + assert!( + (first_cut[2] + 2.5).abs() < 1e-9, + "plunged to {first_cut:?}" + ); + let lifted: f64 = toolpath + .segments + .windows(2) + .filter_map(|w| Some((w[0].target()?, w[1].target()?, w[1].is_cutting()))) + .filter(|(a, b, cutting)| { + *cutting && (a[2] + 2.5).abs() < 1e-9 && (b[2] + 2.5).abs() < 1e-9 + }) + .map(|(a, b, _)| (b[0] - a[0]).hypot(b[1] - a[1])) + .sum(); + assert!((lifted - 11.0).abs() < 1e-6, "lifted over {lifted} mm"); + } + + /// Evenly spaced tabs land wherever the arithmetic puts them — in a notch, + /// around a corner. Each must settle on a stretch that runs straight. + #[test] + fn test_contour2d_tabs_settle_on_straight_stretches() { + // 60 x 40 with an 8 mm wide, 6 mm deep notch in the bottom edge. + let notched = [ + (0.0, 0.0), + (26.0, 0.0), + (26.0, 6.0), + (34.0, 6.0), + (34.0, 0.0), + (60.0, 0.0), + (60.0, 40.0), + (0.0, 40.0), + ]; + let tool = Tool::FlatEndMill { + diameter: 6.0, + flute_length: 25.0, + flutes: 2, + }; + let settings = CamSettings { + stepdown: 4.0, + ..CamSettings::default() + }; + for count in 1..=7 { + let op = Contour2D::outside(polyline(¬ched), 4.0).with_tabs(count, 5.0, 1.5); + let toolpath = op.generate(&tool, &settings).unwrap(); + let mut runs: Vec> = Vec::new(); + let mut lifted = false; + for t in toolpath + .segments + .iter() + .filter(|s| s.is_cutting()) + .filter_map(|s| s.target()) + { + let now = (t[2] + 2.5).abs() < 1e-9; + if now && !lifted { + runs.push(Vec::new()); + } + if now { + runs.last_mut().unwrap().push(t); + } + lifted = now; + } + assert_eq!(runs.len(), count); + for run in runs { + let (a, b) = (run[0], run[run.len() - 1]); + let chord = (b[0] - a[0]).hypot(b[1] - a[1]); + assert!( + chord >= 0.98 * 11.0, + "{count} tabs: one bends (chord {chord:.2} of 11) near {a:?}" + ); + } + } + } + #[test] fn test_tab_creation() { let tab = Tab::new(0.25, 5.0, 2.0); diff --git a/docs/native-app-friction-log.md b/docs/native-app-friction-log.md index 25697569b..7f5fe5a0e 100644 --- a/docs/native-app-friction-log.md +++ b/docs/native-app-friction-log.md @@ -179,3 +179,116 @@ is open. has no accessibility window, no Window-menu entry and no title to query. A `VCAD_STATUS` dump (document, workspace, solve state) on a signal or a debug menu item would have saved an hour here. + +## Getting ready to cut (2026-09-17, second session) + +Items marked **fixed** here are on `claude/unruffled-villani-c08436`. The job +was checked outside the app by a script that replays the G-code against the +outline (gouge, metal left, tabs, plunges); every defect below was found that +way, none by looking at the preview. + +32. **Fixed. Inside contours cut on the wrong side of the line.** + `Contour2D::inside()` was byte-identical to `outside()`: both offset the + path by +tool radius. The stator's bore-and-slots pass ran at r 18.6–25.6 + about the part origin instead of r 15.4–22.4 — 3.2 mm into every post and + the ring. It would have destroyed the part on the first pass. The tests + that covered contours only asserted that moves came back. `inside` is now + a field; the offset is signed; a test checks the side for both windings. +33. **Fixed. Holding tabs held nothing.** Three defects in one function: + tabs were honoured on the final pass only, so with 0.5 mm stepdown the + pass before it cut a 1 mm tab down to 0.5 mm; the width was measured along + the tool-centre path, so a "4 mm" tab left 4 − 3.175 = 0.8 mm of metal; + and a tab at position 0 was clipped at the loop seam (1.7 mm of lift, no + metal at all). Net: two slivers of 0.6 × 0.5 mm. Tabs were also entered + along a ramp from the previous vertex, which on a sparse polyline eats the + tab. Now every pass below the tab top steps over it, vertically at both + ends; width means metal left; the seam is handled; even spacing starts + half a pitch in. +34. **Fixed.** Evenly spaced tabs land wherever the arithmetic puts them: one + of the stator's three sat inside the 4 mm lead notch. A tab now settles on + the nearest stretch that runs straight (chord/path ≥ 0.98, within half a + tab pitch). Still open from item 21: the user cannot see or move them. +35. **Fixed.** Cut depth was copied from the stock thickness at import, so the + natural order — import the outline, then enter 6 mm — left both contours + at 10 mm and the job blocked with no hint why. A through cut now follows + the thickness until its depth is edited by hand. +36. **Fixed (kernel).** An inward offset that falls into several pieces (the + cutter does not fit through a neck) used to follow the first piece and say + nothing; it is now an error. The stator's slot mouths are 3.87 mm: a + Ø3.175 cutter passes with 0.35 mm a side, anything from Ø3.9 up does not. + (Item 22's "5 mm slots" is the post width; the gap is what matters.) +37. There is no way to get the outline out of the solid. The lost DXF was + regenerated by evaluating the loon source in 2D with shapely, outside + vcad (`~/Documents/ChatGPT/home/cnc/stator/loon_outline.py`). A + "section at Z → DXF/contour" in the kernel would close this and item 16. +38. The part was drawn for a Ø2 cutter (R1.05 inside fillets). With Ø3.175, + 24 inside corners and 8 outside ones keep up to 0.29 mm of extra metal + (10.8 mm² in all). Nothing reports corners the cutter cannot reach; the + eight outside ones are where the clocking tabs meet the ring, i.e. on a + mating surface. +39. Inside contours get no tabs and there is no pocketing of the waste, so + the stator's Ø27.6 bore slug comes free on the last pass next to a + Ø3.175 cutter. +40. A through cut stops exactly at the stock's underside; a depth greater + than the thickness is rejected, so there is no break-through allowance + short of lying about the thickness. +41. Work zero is the lower-left of the outline's bounding box, which is + inside the stock, and the stock box is drawn at exactly the outline's + extents. The cutter sweeps −3.2…66.3 mm in X and Y for a 63.15 mm part; + nothing shows the margin the blank needs or where zero sits on it. +42. `M3` is followed straight away by motion (no spin-up dwell; the 3 s + plunge from Z5 is all that covers a relay-switched router), and the + spindle stops and restarts between operations. +43. The inside contour climb-mills and the outside one conventional-mills + (both loops run counter-clockwise); there is no choice of direction. +44. **Fixed. Run Job was the window's default button.** Its shortcut was + ⌥⌘Return, and AppKit advertises any button whose key is Return as the + window's default button, modifiers or not. A physical Return did not + trigger it (checked: the modifiers are honoured, and committing a setup + field un-confirms the setup), but an accessibility client asked to press + "return" pressed Run Job — and the confirmation that follows had "Start + machining" as its Return-default, as did Home, Move to, Set zero and Run + macro. The shortcut is now ⌥⌘J, and every dialog that starts motion has + Cancel as the default. Stop-and-reset keeps Return = stop. +45. Import and Export job exist only inside a pull-down and a popover. Neither + is in the menu bar (File ▸ Export offers STL/USDZ only), and the readiness + popover's contents are not in the window's accessibility tree, so neither + a keyboard user nor an assistive tool can reach "Export job…". Opening the + `.dxf` with `open -a` was the only scriptable import. +46. A blank 900 × 450 window titled "vcad" sits on screen behind the editor + (the SwiftUI host), and it grows a tab for every file opened. +47. The toolpath is drawn in the stock frame (lower-left at the origin) while + the part stays where it was modelled (centred, z 11.1–17.1), so the path + floats beside the part instead of on it (item 18, now visible). +48. "Controller connected" is ticked when the controller is the Simulator; the + readiness list does not say which. +49. Changing the tool diameter does not re-evaluate which holes are + machinable: the three pilots refused at Ø3.175 stayed refused at Ø2 until + the outline was imported again. The four resulting operations are all + named "Inside contour" — the bore and a Ø2.5 pilot are indistinguishable + in the list, and pilots are ordered after the bore (largest first). +50. No "leave a skin" / bottom-allowance option, and nothing knows what is + under the stock. On a bare aluminium bed the only safe through-cut is one + the user shortens by hand. +51. Number fields do not accept accessibility value-setting; typing only + works with the window frontmost, and the sidebar summary ("T1 · Ø …") + is the only confirmation that a value took. +52. Feeds, plunge and stepdown are per operation with no "apply to all". With + five operations the only quick way to change material was to edit the + selected operation and import the outline again, because the import copies + the selected operation's settings into every new one. +53. Nothing helps place the job on the real stock. The blank was clamped about + 10° off the machine axes and 5 mm off centre; that was found by tracing + the bounding square by hand from the sender while watching a camera. A + "trace bounds at safe Z" action, and the sweep square (tool included) + drawn on the stock, would have shown it in the app. +54. First cut (2026-09-17): a 1 mm copper plate (teal-coated; taken for + aluminium from the camera until the owner said otherwise) on a + doubled-MDF riser, Ø2 2-flute, F250 / plunge F40 / 0.17 mm passes, + 0.15 mm onion skin plus three 4 × 0.42 mm tabs, sent from ncSender. + 14 min, cutter survived, profile clean. The first start cut air: the + paper touch-off was 0.81 mm high. Copper-coloured slots and dust were + first read as the skin breaking through to the MDF; with a copper plate + that is just the cut metal, so whether the skin held is unverified. + Nothing came loose. The app has no material setting at all — feeds were + typed by hand for a material that turned out to be a different one.