CAM: Waterline operation with empty Base silently produces no cutting moves once (tool diameter - BoundaryAdjustment) exceeds ~4mm
- Dominant language
- C++
- Stars
- 33.6k
- Forks
- 6k
- Avg merge
- 3d 17h
- Merged PRs (30d)
- 196
Description
*This report was written up by Claude (Anthropic's Claude Code), based on testing done at the direction of the repo owner while automating the CAM workbench for a hobby CNC router. Filed under the owner's account; flagging the authorship for transparency.*
**Edit:** the original version of this report claimed tool *shape* (flat/V-bit vs. ball nose) was the deciding factor. Further testing shows that was wrong — it was a coincidence of the specific diameters tested. The real deciding factor is **tool diameter relative to `BoundaryAdjustment`**, independent of shape. Corrected below; see the first comment for what changed.
### Area / Workbench affected
CAM (Path Waterline operation)
### Problem description
A `Path.Op.Waterline` operation with an empty `Base` (i.e. "process the whole Job model" mode, selected via `BoundBox = "Stock"` or `"BaseBoundBox"`) produces **zero cutting moves** (`G1` commands) — only clearance/rapid (`G0`) moves — whenever `tool_diameter - BoundaryAdjustment` exceeds approximately **4.0mm**. Below that threshold it produces a normal, complete toolpath. No error or warning is raised in either case.
This holds **regardless of tool shape** — confirmed for flat endmill (`CylCutter`), ball-nose (`BallCutter`), and V-bit (`ConeCutter`), all showing the same cutoff. It's also independent of part size (identical cutoff on a 40×30mm and a 200×150mm test box) and of `StepDown`.
Measured cutoffs at different `BoundaryAdjustment` values (binary-searched to ~0.01mm, ball-nose tool, 40×30×10mm box):
| `BoundaryAdjustment` | Cutoff diameter | diameter − adjustment |
|---|---|---|
| 0.0mm | ~3.99mm | 3.99 |
| 0.3mm | ~4.40mm | 4.10 |
| 1.0mm | ~4.99mm | 3.99 |
| 2.0mm | ~5.98mm | 3.98 |
Confirmed the same relationship holds for flat and V-bit tools at spot-checked `BoundaryAdjustment` values (flat, adj=1.0: works at 4.8–4.9mm, breaks at 5.0mm+; V-bit, adj=0.3: works at 4.2–4.35mm, breaks at 4.5mm+ — both consistent with the ~4.0mm-plus-adjustment pattern above).
This reproduces both under `freecadcmd` (headless) and with the GUI subsystem actually initialized (tested via `QT_QPA_PLATFORM=offscreen`, confirming `FreeCAD.GuiUp == True`), with identical results in both modes.
### Steps to reproduce
Run the script below via `freecadcmd repro.py`, varying `TOOL_DIA` and `BOUNDARY_ADJ` to see the cutoff. It creates a plain 40×30×10mm box (no internal features), a Job/Stock, one ToolController, and one Waterline operation with `Base` left empty.
```python
import sys
try:
sys.stdout.reconfigure(line_buffering=True)
except Exception:
pass
import FreeCAD, Part
import Path.Main.Job as PathJob
import Path.Main.Stock as PathStock
from Path.Tool.toolbit import ToolBit
import Path.Tool.Controller as PathToolController
import Path.Op.Waterline as PathWaterline
# Unrelated headless-only issue workaround: PathScripts.PathUtils.
# findToolController() raises UnboundLocalError whenever a job ends up with
# >1 ToolController and no GUI is present to disambiguate (its module-level
# `UserInput` hook is only ever set by the GUI). Job.Create() auto-adds a
# default controller, so this fires even before adding an explicit one.
import PathScripts.PathUtils as PathUtils
class _HeadlessToolControllerPicker:
def selectedToolController(self):
return None
def chooseToolController(self, controllers):
return controllers[-1] if controllers else None
def createJob(self):
return None
def chooseJob(self, jobs):
return jobs[0] if jobs else None
PathUtils.UserInput = _HeadlessToolControllerPicker()
TOOL_SHAPE = "ballend.fcstd" # or "endmill.fcstd" / "v-bit.fcstd" -- same cutoff either way
TOOL_DIA = 4.5 # try 3.5 (works) vs 4.5 (breaks) with BOUNDARY_ADJ=0.3
BOUNDARY_ADJ = 0.3
doc = FreeCAD.newDocument("repro")
shape = Part.makeBox(40, 30, 10)
part = doc.addObject("Part::Feature", "TestPart")
part.Shape = shape
bb0 = part.Shape.BoundBox
part.Placement.move(FreeCAD.Vector(-bb0.XMin, -bb0.YMin, -bb0.ZMax))
doc.recompute()
bb = part.Shape.BoundBox
job = PathJob.Create("Job", [part])
job.PostProcessor = "grbl"
stock_extent = FreeCAD.Vector(bb.XLength + 6, bb.YLength + 6, bb.ZLength + 2)
stock_placement = FreeCAD.Placement(FreeCAD.Vector(bb.XMin - 3, bb.YMin - 3, bb.ZMin), FreeCAD.Rotation())
job.Stock = PathStock.CreateBox(job, stock_extent, stock_placement)
doc.recompute()
toolbit = ToolBit.from_shape_id(TOOL_SHAPE)
tool_obj = toolbit.attach_to_doc(doc=doc)
tool_obj.Diameter = TOOL_DIA
tc = PathToolController.Create(name="TC", tool=tool_obj, toolNumber=1)
tc.SpindleSpeed = 10000
tc.HorizFeed = 400
tc.VertFeed = 140
job.Proxy.addToolController(tc)
op = PathWaterline.Create("Op")
op.ToolController = tc
op.setExpression("StepDown", None)
op.StepDown = 1.0
op.setExpression("StartDepth", None)
op.StartDepth = 0.0
op.setExpression("FinalDepth", None)
op.FinalDepth = bb.ZMin
op.setExpression("BoundaryAdjustment", None)
op.BoundaryAdjustment = BOUNDARY_ADJ
op.BoundBox = "Stock"
job.Proxy.addOperation(op)
doc.recompute()
g1 = sum(1 for c in op.Path.Commands if c.Name == "G1")
print(f"RESULT: Tool={TOOL_SHAPE} dia={TOOL_DIA} adj={BOUNDARY_ADJ} -> total_cmds={len(op.Path.Commands)} G1(cutting)_cmds={g1}")
```
### Expected behavior
Any tool diameter should produce a toolpath with real cutting (`G1`) moves tracing the part's contours at each Z step, for a plain rectangular box with no features that could plausibly be too small to reach.
### Actual behavior
```
$ freecadcmd repro.py # TOOL_DIA = 3.5, BOUNDARY_ADJ = 0.3
RESULT: Tool=ballend.fcstd dia=3.5 adj=0.3 -> total_cmds=230 G1(cutting)_cmds=214
$ freecadcmd repro.py # TOOL_DIA = 4.5, BOUNDARY_ADJ = 0.3
RESULT: Tool=ballend.fcstd dia=4.5 adj=0.3 -> total_cmds=10 G1(cutting)_cmds=0
```
No error or warning is raised in the broken case — `doc.recompute()` completes normally and `Path.Log.info` reports "Begin Waterline operation..." / an operation time as if it succeeded.
### Additional notes
- Since the model here is a plain, featureless box, this can't be explained by the tool simply being too large to physically reach some feature (which would legitimately produce no toolpath in *that region* but should still leave the rest of the boundary cuttable) — the *entire* toolpath goes to zero.
- Possibly related context: #27751 (OCL-based Surface/Waterline operations flagged for a from-scratch reimplementation due to being hard to test/troubleshoot) — I didn't find an existing issue describing this specific diameter-cutoff symptom, so filing separately, but linking in case it's a known symptom of the same underlying fragility.
- This was found while testing an external automation script that drives the CAM workbench end-to-end for a hobby CNC router; its default roughing tool is 6.35mm, well above the ~4mm-ish cutoff, so this silently produces a no-op roughing pass by default.
### FreeCAD version
```
1.1.3, Libs: 1.1.3R20260725 (Git shallow), 2026/07/25 04:52:02
```
Installed via the official conda-based `FreeCAD.app` build (Homebrew cask `freecad`) on macOS.
### OS
macOS (Darwin 25.5.0)
Contributor guide
Research direction
Run the supplied repro.py with freecadcmd, comparing the 3.5mm and 4.5mm tool cases and their G1 command counts. Trace Path.Op.Waterline.Create and the empty Base, BoundBox="Stock", and BoundaryAdjustment handling to locate the diameter cutoff. Done means a plain box produces cutting moves above the current threshold without changing valid smaller-tool behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100