diff --git a/src/Mod/CAM/CAMTests/TestLinkingGenerator.py b/src/Mod/CAM/CAMTests/TestLinkingGenerator.py index 91442e4ebe..072741e4f6 100644 --- a/src/Mod/CAM/CAMTests/TestLinkingGenerator.py +++ b/src/Mod/CAM/CAMTests/TestLinkingGenerator.py @@ -82,6 +82,63 @@ class TestGetLinkingMoves(PathTestUtils.PathTestBase): solids=[blocking_box], ) + def test_plunge_to_zero_depth(self): + """Test that plunge moves correctly go to Z=0 (regression test for depth==0 bug)""" + start = FreeCAD.Vector(0, 0, 1) # Start below clearance + target = FreeCAD.Vector(10, 10, 0) # Target depth is 0 + + cmds = generator.get_linking_moves( + start_position=start, + target_position=target, + local_clearance=self.local_clearance, + global_clearance=self.global_clearance, + tool_shape=self.tool, + solids=[], + ) + + # Verify we got commands + self.assertGreater(len(cmds), 0) + + # All commands should have complete XYZ coordinates + for cmd in cmds: + self.assertIn("X", cmd.Parameters, "Command missing X coordinate") + self.assertIn("Y", cmd.Parameters, "Command missing Y coordinate") + self.assertIn("Z", cmd.Parameters, "Command missing Z coordinate") + + # The last command should be the plunge to target depth (Z=0) + last_cmd = cmds[-1] + self.assertAlmostEqual(last_cmd.Parameters["X"], target.x, places=5) + self.assertAlmostEqual(last_cmd.Parameters["Y"], target.y, places=5) + self.assertAlmostEqual( + last_cmd.Parameters["Z"], + target.z, + places=5, + msg="Final plunge should go to target Z=0, not clearance height", + ) + + def test_plunge_to_negative_depth(self): + """Test that plunge moves correctly go to negative Z depths""" + start = FreeCAD.Vector(0, 0, 1) # Start below clearance + target = FreeCAD.Vector(10, 10, -2) # Target depth is negative + + cmds = generator.get_linking_moves( + start_position=start, + target_position=target, + local_clearance=self.local_clearance, + global_clearance=self.global_clearance, + tool_shape=self.tool, + solids=[], + ) + + # The last command should be the plunge to target depth (Z=-2) + last_cmd = cmds[-1] + self.assertAlmostEqual( + last_cmd.Parameters["Z"], + target.z, + places=5, + msg="Final plunge should go to target Z=-2", + ) + @unittest.skip("not yet implemented") def test_zero_retract_offset_uses_local_clearance(self): cmds = generator.get_linking_moves( diff --git a/src/Mod/CAM/Path/Base/Generator/linking.py b/src/Mod/CAM/Path/Base/Generator/linking.py index 103904e08e..3a3434856f 100644 --- a/src/Mod/CAM/Path/Base/Generator/linking.py +++ b/src/Mod/CAM/Path/Base/Generator/linking.py @@ -79,9 +79,23 @@ def get_linking_moves( wire = make_linking_wire(start_position, target_position, height) if is_wire_collision_free(wire, collision_model): cmds = Path.fromShape(wire).Commands - for cmd in cmds: - cmd.Name = "G0" - return cmds + # Ensure all commands have complete XYZ coordinates + # Path.fromShape() may omit coordinates that don't change + current_pos = start_position + complete_cmds = [] + for i, cmd in enumerate(cmds): + params = dict(cmd.Parameters) + # Fill in missing coordinates from current position + x = params.get("X", current_pos.x) + y = params.get("Y", current_pos.y) + # For the last command (plunge to target), use target.z if Z is missing + if "Z" not in params and i == len(cmds) - 1: + z = target_position.z + else: + z = params.get("Z", current_pos.z) + complete_cmds.append(Path.Command("G0", {"X": x, "Y": y, "Z": z})) + current_pos = Vector(x, y, z) + return complete_cmds raise RuntimeError("No collision-free path found between start and target positions") diff --git a/src/Mod/CAM/Path/Op/MillFacing.py b/src/Mod/CAM/Path/Op/MillFacing.py index 298aba1dac..7c230512f1 100644 --- a/src/Mod/CAM/Path/Op/MillFacing.py +++ b/src/Mod/CAM/Path/Op/MillFacing.py @@ -508,13 +508,10 @@ class ObjectMillFacing(PathOp.ObjectOp): for k in ("X", "Y") ): # But if Z is different, keep it (it's a plunge or retract) - z_changed = ( - abs( - new_params.get("Z", 0) - - last_params.get("Z", new_params.get("Z", 0) + 1) - ) - > 1e-9 - ) + # Use sentinel values that won't conflict with depth == 0 + z_new = new_params.get("Z", float("inf")) + z_last = last_params.get("Z", float("-inf")) + z_changed = abs(z_new - z_last) > 1e-9 if not z_changed: continue self.commandlist.append(Path.Command(cmd.Name, new_params)) @@ -596,11 +593,11 @@ class ObjectMillFacing(PathOp.ObjectOp): # Append linking moves, ensuring full XYZ continuity current = last_position for lc in link_commands: - params = dict(lc.Parameters) - X = params.get("X", current.x) - Y = params.get("Y", current.y) - Z = params.get("Z", current.z) - # Skip zero-length + params = lc.Parameters + X = params["X"] + Y = params["Y"] + Z = params["Z"] + # Skip zero-length moves if not ( abs(X - current.x) <= 1e-9 and abs(Y - current.y) <= 1e-9 @@ -609,7 +606,7 @@ class ObjectMillFacing(PathOp.ObjectOp): self.commandlist.append( Path.Command(lc.Name, {"X": X, "Y": Y, "Z": Z}) ) - current = FreeCAD.Vector(X, Y, Z) + current = FreeCAD.Vector(X, Y, Z) # Remove the entire initial G0 bundle (up, XY, down) from the copy del copy_commands[bundle_start:bundle_end] @@ -631,11 +628,13 @@ class ObjectMillFacing(PathOp.ObjectOp): cp["Z"] = depth # Cutting moves at depth else: cp.setdefault("Z", last.get("Z")) - # Skip zero-length + # Skip zero-length moves if self.commandlist: last = self.commandlist[-1].Parameters + # Use sentinel values that won't conflict with depth == 0 if all( - abs(cp[k] - last.get(k, cp[k] + 1)) <= 1e-9 for k in ("X", "Y", "Z") + abs(cp.get(k, float("inf")) - last.get(k, float("-inf"))) <= 1e-9 + for k in ("X", "Y", "Z") ): continue self.commandlist.append(Path.Command(cc.Name, cp))