FreeCAD / FreeCAD/FreeCAD

CAM: Waterline operation with empty Base silently produces no cutting moves once (tool diameter - BoundaryAdjustment) exceeds ~4mm

Open
#32,032 3 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.