Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apple/VcadApp/Sources/VcadApp/CNCMachineBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -315,13 +322,15 @@ 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") {
if destination == "Park" { machine.park() }
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.")
}
Expand Down
1 change: 1 addition & 0 deletions apple/VcadApp/Sources/VcadApp/CNCStudioDrawer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "") }
}
}
13 changes: 12 additions & 1 deletion apple/VcadApp/Sources/VcadApp/CNCWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions apple/VcadApp/Tests/VcadAppTests/CNCOutlineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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)
}
}
}
9 changes: 9 additions & 0 deletions changelog/entries/2026-09-17-contour-cam-side-and-tabs.json
Original file line number Diff line number Diff line change
@@ -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"]
}
7 changes: 7 additions & 0 deletions crates/vcad-kernel-cam/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading