diff --git a/src/Mod/CAM/CAMTests/TestLinkingGenerator.py b/src/Mod/CAM/CAMTests/TestLinkingGenerator.py
new file mode 100644
index 0000000000..91442e4ebe
--- /dev/null
+++ b/src/Mod/CAM/CAMTests/TestLinkingGenerator.py
@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+# ***************************************************************************
+# * Copyright (c) 2025 Brad Collette *
+# * *
+# * This program is free software; you can redistribute it and/or modify *
+# * it under the terms of the GNU Lesser General Public License (LGPL) *
+# * as published by the Free Software Foundation; either version 2 of *
+# * the License, or (at your option) any later version. *
+# * for detail see the LICENCE text file. *
+# * *
+# ***************************************************************************
+
+import FreeCAD
+import Part
+import CAMTests.PathTestUtils as PathTestUtils
+import Path.Base.Generator.linking as generator
+import unittest
+
+
+class TestGetLinkingMoves(PathTestUtils.PathTestBase):
+ def setUp(self):
+ self.start = FreeCAD.Vector(0, 0, 0)
+ self.target = FreeCAD.Vector(10, 0, 0)
+ self.local_clearance = 2.0
+ self.global_clearance = 5.0
+ self.tool = Part.makeCylinder(1, 5)
+
+ def test_simple_move(self):
+ cmds = generator.get_linking_moves(
+ start_position=self.start,
+ target_position=self.target,
+ local_clearance=self.local_clearance,
+ global_clearance=self.global_clearance,
+ tool_shape=self.tool,
+ solids=[],
+ )
+ self.assertGreater(len(cmds), 0)
+ self.assertEqual(cmds[0].Name, "G0")
+
+ def test_same_position_returns_empty(self):
+ cmds = generator.get_linking_moves(
+ start_position=self.start,
+ target_position=self.start,
+ local_clearance=self.local_clearance,
+ global_clearance=self.global_clearance,
+ tool_shape=self.tool,
+ solids=[],
+ )
+ self.assertEqual(len(cmds), 0)
+
+ def test_negative_retract_offset_raises(self):
+ with self.assertRaises(ValueError):
+ generator.get_linking_moves(
+ start_position=self.start,
+ target_position=self.target,
+ local_clearance=self.local_clearance,
+ global_clearance=self.global_clearance,
+ tool_shape=self.tool,
+ retract_height_offset=-1,
+ )
+
+ def test_clearance_violation_raises(self):
+ with self.assertRaises(ValueError):
+ generator.get_linking_moves(
+ start_position=self.start,
+ target_position=self.target,
+ local_clearance=10.0,
+ global_clearance=5.0,
+ tool_shape=self.tool,
+ )
+
+ def test_path_blocked_by_solid(self):
+ blocking_box = Part.makeBox(20, 20, 10)
+ blocking_box.translate(FreeCAD.Vector(-5, -5, 0))
+ with self.assertRaises(RuntimeError):
+ generator.get_linking_moves(
+ start_position=self.start,
+ target_position=self.target,
+ local_clearance=self.local_clearance,
+ global_clearance=self.global_clearance,
+ tool_shape=self.tool,
+ solids=[blocking_box],
+ )
+
+ @unittest.skip("not yet implemented")
+ def test_zero_retract_offset_uses_local_clearance(self):
+ cmds = generator.get_linking_moves(
+ start_position=self.start,
+ target_position=FreeCAD.Vector(10, 0, 5),
+ local_clearance=self.local_clearance,
+ global_clearance=self.global_clearance,
+ tool_shape=self.tool,
+ retract_height_offset=0,
+ )
+ self.assertTrue(any(cmd for cmd in cmds if cmd.Parameters.get("Z") == self.local_clearance))
+
+ @unittest.skip("not yet implemented")
+ def test_path_generated_without_local_safe(self):
+ pass
diff --git a/src/Mod/CAM/CAMTests/TestPathFacingGenerator.py b/src/Mod/CAM/CAMTests/TestPathFacingGenerator.py
new file mode 100644
index 0000000000..7d7d561f24
--- /dev/null
+++ b/src/Mod/CAM/CAMTests/TestPathFacingGenerator.py
@@ -0,0 +1,1184 @@
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+import FreeCAD
+import math
+import Path.Base.Generator.spiral_facing as spiral_facing
+import Path.Base.Generator.zigzag_facing as zigzag_facing
+import Path.Base.Generator.directional_facing as directional_facing
+import Path.Base.Generator.bidirectional_facing as bidirectional_facing
+import Path.Base.Generator.facing_common as facing_common
+import Part
+import Path
+
+from CAMTests.PathTestUtils import PathTestBase
+
+if False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+
+class TestPathFacingGenerator(PathTestBase):
+ """Test facing generator."""
+
+ def setUp(self):
+ """Set up test fixtures."""
+ super().setUp()
+
+ # Create test polygons
+ self.square_wire = Part.makePolygon(
+ [
+ FreeCAD.Vector(0, 0, 0),
+ FreeCAD.Vector(10, 0, 0),
+ FreeCAD.Vector(10, 10, 0),
+ FreeCAD.Vector(0, 10, 0),
+ FreeCAD.Vector(0, 0, 0),
+ ]
+ )
+
+ self.rectangle_wire = Part.makePolygon(
+ [
+ FreeCAD.Vector(0, 0, 0),
+ FreeCAD.Vector(20, 0, 0),
+ FreeCAD.Vector(20, 10, 0),
+ FreeCAD.Vector(0, 10, 0),
+ FreeCAD.Vector(0, 0, 0),
+ ]
+ )
+
+ # Create a circular wire for testing curves
+ self.circle_wire = Part.Wire(
+ [Part.Circle(FreeCAD.Vector(5, 5, 0), FreeCAD.Vector(0, 0, 1), 5).toShape()]
+ )
+
+ # Create a wire with splines/curves
+ points = [
+ FreeCAD.Vector(0, 0, 0),
+ FreeCAD.Vector(5, 0, 0),
+ FreeCAD.Vector(10, 5, 0),
+ FreeCAD.Vector(5, 10, 0),
+ FreeCAD.Vector(0, 5, 0),
+ FreeCAD.Vector(0, 0, 0),
+ ]
+ self.spline_wire = Part.Wire(
+ [Part.BSplineCurve(points, None, None, False, 3, None, False).toShape()]
+ )
+
+ def _first_xy(self, commands):
+ # Return XY of first G0 rapid move to get actual start position
+ for cmd in commands:
+ if cmd.Name == "G0" and "X" in cmd.Parameters and "Y" in cmd.Parameters:
+ return (cmd.Parameters["X"], cmd.Parameters["Y"])
+ # Fallback to first cutting move
+ for cmd in commands:
+ if cmd.Name == "G1" and "X" in cmd.Parameters and "Y" in cmd.Parameters:
+ return (cmd.Parameters["X"], cmd.Parameters["Y"])
+ return None
+
+ def _bbox_diag(self, wire):
+ bb = wire.BoundBox
+ dx = bb.XMax - bb.XMin
+ dy = bb.YMax - bb.YMin
+ return math.hypot(dx, dy)
+
+ def test_spiral_reverse_toggles_start_corner_climb(self):
+ """Spiral reverse toggles the starting corner while keeping winding (climb)."""
+ cmds_norm = spiral_facing.spiral(
+ polygon=self.rectangle_wire,
+ tool_diameter=10.0,
+ stepover_percent=50,
+ milling_direction="climb",
+ reverse=False,
+ )
+ cmds_rev = spiral_facing.spiral(
+ polygon=self.rectangle_wire,
+ tool_diameter=10.0,
+ stepover_percent=50,
+ milling_direction="climb",
+ reverse=True,
+ )
+
+ # Non-empty and similar length
+ self.assertIsInstance(cmds_norm, list)
+ self.assertIsInstance(cmds_rev, list)
+ self.assertGreater(len(cmds_norm), 0)
+ self.assertGreater(len(cmds_rev), 0)
+ self.assertAlmostEqual(len(cmds_norm), len(cmds_rev), delta=max(1, 0.05 * len(cmds_norm)))
+
+ # First command should be a move to start corner; compare XY distance equals bbox diagonal
+ self.assertIn(cmds_norm[0].Name, ["G0", "G1"])
+ self.assertIn(cmds_rev[0].Name, ["G0", "G1"])
+ p0 = self._first_xy(cmds_norm)
+ p1 = self._first_xy(cmds_rev)
+ self.assertIsNotNone(p0)
+ self.assertIsNotNone(p1)
+ dx = p1[0] - p0[0]
+ dy = p1[1] - p0[1]
+ dist = math.hypot(dx, dy)
+ self.assertAlmostEqual(dist, self._bbox_diag(self.rectangle_wire), places=6)
+
+ def test_spiral_reverse_toggles_start_corner_conventional(self):
+ """Spiral reverse toggles the starting corner while keeping winding (conventional)."""
+ cmds_norm = spiral_facing.spiral(
+ polygon=self.rectangle_wire,
+ tool_diameter=10.0,
+ stepover_percent=50,
+ milling_direction="conventional",
+ reverse=False,
+ )
+ cmds_rev = spiral_facing.spiral(
+ polygon=self.rectangle_wire,
+ tool_diameter=10.0,
+ stepover_percent=50,
+ milling_direction="conventional",
+ reverse=True,
+ )
+
+ self.assertGreater(len(cmds_norm), 0)
+ self.assertGreater(len(cmds_rev), 0)
+ p0 = self._first_xy(cmds_norm)
+ p1 = self._first_xy(cmds_rev)
+ self.assertIsNotNone(p0)
+ self.assertIsNotNone(p1)
+ dx = p1[0] - p0[0]
+ dy = p1[1] - p0[1]
+ dist = math.hypot(dx, dy)
+ self.assertAlmostEqual(dist, self._bbox_diag(self.rectangle_wire), places=6)
+
+ def test_directional_strategy_basic(self):
+ """Test directional strategy basic functionality."""
+ commands = directional_facing.directional(
+ polygon=self.square_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ )
+
+ # Should return a list of Path.Command objects
+ self.assertIsInstance(commands, list)
+ self.assertGreater(len(commands), 0)
+
+ # All commands should be G0 or G1
+ for cmd in commands:
+ self.assertIn(cmd.Name, ["G0", "G1"])
+
+ def test_directional_reverse_changes_start(self):
+ """Directional reverse should start from the opposite side."""
+ cmds_norm = directional_facing.directional(
+ polygon=self.rectangle_wire,
+ tool_diameter=10.0,
+ stepover_percent=50,
+ milling_direction="climb",
+ reverse=False,
+ )
+ cmds_rev = directional_facing.directional(
+ polygon=self.rectangle_wire,
+ tool_diameter=10.0,
+ stepover_percent=50,
+ milling_direction="climb",
+ reverse=True,
+ )
+ self.assertGreater(len(cmds_norm), 0)
+ self.assertGreater(len(cmds_rev), 0)
+ p0 = self._first_xy(cmds_norm)
+ p1 = self._first_xy(cmds_rev)
+ self.assertIsNotNone(p0)
+ self.assertIsNotNone(p1)
+ # Ensure the start points are different
+ self.assertTrue(abs(p0[0] - p1[0]) > 1e-6 or abs(p0[1] - p1[1]) > 1e-6)
+
+ def test_zigzag_reverse_flips_first_pass_direction_climb(self):
+ """Zigzag reverse should flip the first pass direction while preserving alternation (climb)."""
+ cmds_norm = zigzag_facing.zigzag(
+ polygon=self.rectangle_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ angle_degrees=0.0,
+ milling_direction="climb",
+ reverse=False,
+ )
+ cmds_rev = zigzag_facing.zigzag(
+ polygon=self.rectangle_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ angle_degrees=0.0,
+ milling_direction="climb",
+ reverse=True,
+ )
+ self.assertGreater(len(cmds_norm), 0)
+ self.assertGreater(len(cmds_rev), 0)
+ p0 = self._first_xy(cmds_norm)
+ p1 = self._first_xy(cmds_rev)
+ self.assertIsNotNone(p0)
+ self.assertIsNotNone(p1)
+ # Ensure start points differ (first pass toggled)
+ self.assertTrue(abs(p0[0] - p1[0]) > 1e-6 or abs(p0[1] - p1[1]) > 1e-6)
+
+ def test_zigzag_reverse_flips_first_pass_direction_conventional(self):
+ """Zigzag reverse should flip the first pass direction while preserving alternation (conventional)."""
+ cmds_norm = zigzag_facing.zigzag(
+ polygon=self.rectangle_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ angle_degrees=0.0,
+ milling_direction="conventional",
+ reverse=False,
+ )
+ cmds_rev = zigzag_facing.zigzag(
+ polygon=self.rectangle_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ angle_degrees=0.0,
+ milling_direction="conventional",
+ reverse=True,
+ )
+ self.assertGreater(len(cmds_norm), 0)
+ self.assertGreater(len(cmds_rev), 0)
+ p0 = self._first_xy(cmds_norm)
+ p1 = self._first_xy(cmds_rev)
+ self.assertIsNotNone(p0)
+ self.assertIsNotNone(p1)
+ # Ensure start points differ (first pass toggled)
+ self.assertTrue(abs(p0[0] - p1[0]) > 1e-6 or abs(p0[1] - p1[1]) > 1e-6)
+
+ def test_zigzag_reverse_and_milling_combinations(self):
+ """Test all four combinations of reverse and milling_direction for zigzag."""
+ # Expected behavior for zigzag (rectangle 0,0 to 20,10):
+ # reverse=False, climb: start right, bottom (high X, low Y)
+ # reverse=False, conventional: start left, bottom (low X, low Y)
+ # reverse=True, climb: start left, top (low X, high Y)
+ # reverse=True, conventional: start right, top (high X, high Y)
+
+ test_cases = [
+ ("climb", False, "right", "bottom"),
+ ("climb", True, "left", "top"),
+ ("conventional", False, "left", "bottom"),
+ ("conventional", True, "right", "top"),
+ ]
+
+ results = {}
+ for milling_dir, reverse, expected_x_side, expected_y_side in test_cases:
+ commands = zigzag_facing.zigzag(
+ polygon=self.rectangle_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ angle_degrees=0.0,
+ milling_direction=milling_dir,
+ reverse=reverse,
+ )
+ pos = self._first_xy(commands)
+ self.assertIsNotNone(
+ pos, f"zigzag {milling_dir} reverse={reverse} returned no position"
+ )
+ results[(milling_dir, reverse)] = pos
+
+ # Verify X side (left < 10, right > 10 for rectangle 0-20)
+ if expected_x_side == "left":
+ self.assertLess(
+ pos[0],
+ 10,
+ f"zigzag {milling_dir} reverse={reverse}: expected left (X<10), got X={pos[0]}",
+ )
+ else: # right
+ self.assertGreater(
+ pos[0],
+ 10,
+ f"zigzag {milling_dir} reverse={reverse}: expected right (X>10), got X={pos[0]}",
+ )
+
+ # Verify Y side (bottom < 5, top > 5 for rectangle 0-10)
+ if expected_y_side == "bottom":
+ self.assertLess(
+ pos[1],
+ 5,
+ f"zigzag {milling_dir} reverse={reverse}: expected bottom (Y<5), got Y={pos[1]}",
+ )
+ else: # top
+ self.assertGreater(
+ pos[1],
+ 5,
+ f"zigzag {milling_dir} reverse={reverse}: expected top (Y>5), got Y={pos[1]}",
+ )
+
+ def test_directional_reverse_and_milling_combinations(self):
+ """Test all four combinations of reverse and milling_direction for directional."""
+ # Expected behavior for directional (rectangle 0,0 to 20,10):
+ # Bottom-to-top (reverse=False): climb=right-to-left, conventional=left-to-right
+ # Top-to-bottom (reverse=True): climb=left-to-right, conventional=right-to-left
+ # reverse=False, climb: start right, bottom (high X, low Y)
+ # reverse=False, conventional: start left, bottom (low X, low Y)
+ # reverse=True, climb: start left, top (low X, high Y)
+ # reverse=True, conventional: start right, top (high X, high Y)
+
+ test_cases = [
+ ("climb", False, "right", "bottom"),
+ ("climb", True, "left", "top"),
+ ("conventional", False, "left", "bottom"),
+ ("conventional", True, "right", "top"),
+ ]
+
+ for milling_dir, reverse, expected_x_side, expected_y_side in test_cases:
+ commands = directional_facing.directional(
+ polygon=self.rectangle_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ angle_degrees=0.0,
+ milling_direction=milling_dir,
+ reverse=reverse,
+ )
+ pos = self._first_xy(commands)
+ self.assertIsNotNone(
+ pos, f"directional {milling_dir} reverse={reverse} returned no position"
+ )
+
+ # Verify X side
+ if expected_x_side == "left":
+ self.assertLess(
+ pos[0],
+ 10,
+ f"directional {milling_dir} reverse={reverse}: expected left (X<10), got X={pos[0]}",
+ )
+ else: # right
+ self.assertGreater(
+ pos[0],
+ 10,
+ f"directional {milling_dir} reverse={reverse}: expected right (X>10), got X={pos[0]}",
+ )
+
+ # Verify Y side
+ if expected_y_side == "bottom":
+ self.assertLess(
+ pos[1],
+ 5,
+ f"directional {milling_dir} reverse={reverse}: expected bottom (Y<5), got Y={pos[1]}",
+ )
+ else: # top
+ self.assertGreater(
+ pos[1],
+ 5,
+ f"directional {milling_dir} reverse={reverse}: expected top (Y>5), got Y={pos[1]}",
+ )
+
+ def test_bidirectional_reverse_and_milling_combinations(self):
+ """Test all four combinations of reverse and milling_direction for bidirectional."""
+ # Expected behavior for bidirectional (rectangle 0,0 to 20,10):
+ # Bidirectional alternates between bottom and top
+ # Bottom and top cut in OPPOSITE directions to maintain perpendicular rapids
+ # reverse controls which side starts first
+ # reverse=False, climb: start right, bottom (high X, low Y) - bottom cuts right-to-left
+ # reverse=False, conventional: start left, bottom (low X, low Y) - bottom cuts left-to-right
+ # reverse=True, climb: start left, top (low X, high Y) - top cuts left-to-right (opposite of bottom)
+ # reverse=True, conventional: start right, top (high X, high Y) - top cuts right-to-left (opposite of bottom)
+
+ test_cases = [
+ ("climb", False, "right", "bottom"),
+ ("climb", True, "left", "top"),
+ ("conventional", False, "left", "bottom"),
+ ("conventional", True, "right", "top"),
+ ]
+
+ for milling_dir, reverse, expected_x_side, expected_y_side in test_cases:
+ commands = bidirectional_facing.bidirectional(
+ polygon=self.rectangle_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ angle_degrees=0.0,
+ milling_direction=milling_dir,
+ reverse=reverse,
+ )
+ pos = self._first_xy(commands)
+ self.assertIsNotNone(
+ pos, f"bidirectional {milling_dir} reverse={reverse} returned no position"
+ )
+
+ # Verify X side
+ if expected_x_side == "left":
+ self.assertLess(
+ pos[0],
+ 10,
+ f"bidirectional {milling_dir} reverse={reverse}: expected left (X<10), got X={pos[0]}",
+ )
+ else: # right
+ self.assertGreater(
+ pos[0],
+ 10,
+ f"bidirectional {milling_dir} reverse={reverse}: expected right (X>10), got X={pos[0]}",
+ )
+
+ # Verify Y side
+ if expected_y_side == "bottom":
+ self.assertLess(
+ pos[1],
+ 5,
+ f"bidirectional {milling_dir} reverse={reverse}: expected bottom (Y<5), got Y={pos[1]}",
+ )
+ else: # top
+ self.assertGreater(
+ pos[1],
+ 5,
+ f"bidirectional {milling_dir} reverse={reverse}: expected top (Y>5), got Y={pos[1]}",
+ )
+
+ def test_directional_climb_vs_conventional(self):
+ """Test directional with different milling directions."""
+ climb_commands = directional_facing.directional(
+ polygon=self.square_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ milling_direction="climb",
+ )
+
+ conventional_commands = directional_facing.directional(
+ polygon=self.square_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ milling_direction="conventional",
+ )
+
+ # Should have same number of commands but different coordinates
+ self.assertEqual(len(climb_commands), len(conventional_commands))
+
+ # At least some coordinates should be different
+ different_coords = False
+ for i in range(min(len(climb_commands), len(conventional_commands))):
+ if climb_commands[i].Parameters != conventional_commands[i].Parameters:
+ different_coords = True
+ break
+ self.assertTrue(different_coords)
+
+ def test_directional_retract_height(self):
+ """Test retract height functionality in directional."""
+ commands_no_retract = directional_facing.directional(
+ polygon=self.square_wire, tool_diameter=5.0, stepover_percent=50, retract_height=None
+ )
+
+ commands_with_retract = directional_facing.directional(
+ polygon=self.square_wire, tool_diameter=5.0, stepover_percent=50, retract_height=15.0
+ )
+
+ # Commands with retract should have more moves (G0 Z moves)
+ self.assertGreaterEqual(len(commands_with_retract), len(commands_no_retract))
+
+ # Should have some Z-only G0 commands
+ z_retracts = [
+ cmd
+ for cmd in commands_with_retract
+ if cmd.Name == "G0" and "Z" in cmd.Parameters and len(cmd.Parameters) == 1
+ ]
+ self.assertGreater(len(z_retracts), 0)
+
+ def test_zigzag_strategy_basic(self):
+ """Test zigzag strategy basic functionality."""
+ commands = zigzag_facing.zigzag(
+ polygon=self.square_wire, tool_diameter=10.0, stepover_percent=50, angle_degrees=0.0
+ )
+
+ # Should return a list of Path.Command objects
+ self.assertIsInstance(commands, list)
+ self.assertGreater(len(commands), 0)
+
+ # First command should be G0 (for op preamble replacement)
+ self.assertEqual(commands[0].Name, "G0")
+
+ # Should have cutting moves (G1)
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreater(len(cutting_moves), 0)
+
+ def test_zigzag_alternating_direction(self):
+ """Test that zigzag alternates cutting direction."""
+ commands = zigzag_facing.zigzag(
+ polygon=self.rectangle_wire,
+ tool_diameter=2.0, # Small tool for more passes
+ stepover_percent=25, # Small stepover for more passes
+ angle_degrees=0.0,
+ )
+
+ # Should have multiple passes
+ self.assertGreater(len(commands), 2)
+
+ # Extract X coordinates from cutting moves
+ x_coords = []
+ for cmd in commands:
+ if "X" in cmd.Parameters:
+ x_coords.append(cmd.Parameters["X"])
+
+ # Should have alternating pattern in X coordinates
+ self.assertGreater(len(x_coords), 2)
+
+ def test_zigzag_with_retract_height(self):
+ """Test zigzag with retract height."""
+ # Arc mode (default) - no retracts, uses arcs
+ commands_arc_mode = zigzag_facing.zigzag(
+ polygon=self.square_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ retract_height=15.0,
+ link_mode="arc",
+ )
+
+ # Straight mode - should use retracts
+ commands_straight_mode = zigzag_facing.zigzag(
+ polygon=self.square_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ retract_height=15.0,
+ link_mode="straight",
+ )
+
+ # Both should generate valid toolpaths
+ self.assertGreater(len(commands_arc_mode), 0)
+ self.assertGreater(len(commands_straight_mode), 0)
+
+ # Arc mode should have G2/G3 arcs and no Z retracts
+ arcs = [cmd for cmd in commands_arc_mode if cmd.Name in ["G2", "G3"]]
+ z_retracts_arc = [
+ cmd
+ for cmd in commands_arc_mode
+ if cmd.Name == "G0" and "Z" in cmd.Parameters and cmd.Parameters["Z"] == 15.0
+ ]
+ self.assertGreater(len(arcs), 0, "Arc mode should have G2/G3 commands")
+ self.assertEqual(
+ len(z_retracts_arc), 0, "Arc mode should not have Z retracts between passes"
+ )
+
+ # Straight mode should have Z retracts (retract_height is ignored in current implementation)
+ # Note: zigzag doesn't currently support retract_height in straight mode, only uses G0 at cutting height
+ # So this test documents current behavior
+
+ def test_zigzag_arc_links(self):
+ """Test zigzag arc linking generates proper G2/G3 commands."""
+ commands = zigzag_facing.zigzag(
+ polygon=self.square_wire, tool_diameter=5.0, stepover_percent=50, link_mode="arc"
+ )
+
+ # Should have both cutting moves and arcs
+ g1_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ arcs = [cmd for cmd in commands if cmd.Name in ["G2", "G3"]]
+
+ self.assertGreater(len(g1_moves), 0, "Should have G1 cutting moves")
+ self.assertGreater(len(arcs), 0, "Should have G2/G3 arc moves")
+
+ # Arcs should have I, J, K parameters
+ for arc in arcs:
+ self.assertIn("I", arc.Parameters, f"{arc.Name} should have I parameter")
+ self.assertIn("J", arc.Parameters, f"{arc.Name} should have J parameter")
+ self.assertIn("K", arc.Parameters, f"{arc.Name} should have K parameter")
+ self.assertIn("X", arc.Parameters, f"{arc.Name} should have X parameter")
+ self.assertIn("Y", arc.Parameters, f"{arc.Name} should have Y parameter")
+
+ def test_zigzag_arc_vs_straight_link_modes(self):
+ """Test that arc and straight link modes produce different but valid toolpaths."""
+ arc_commands = zigzag_facing.zigzag(
+ polygon=self.rectangle_wire, tool_diameter=5.0, stepover_percent=50, link_mode="arc"
+ )
+
+ straight_commands = zigzag_facing.zigzag(
+ polygon=self.rectangle_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ link_mode="straight",
+ )
+
+ # Both should generate valid paths
+ self.assertGreater(len(arc_commands), 0)
+ self.assertGreater(len(straight_commands), 0)
+
+ # Arc mode should have arcs, straight mode should not
+ arc_mode_arcs = [cmd for cmd in arc_commands if cmd.Name in ["G2", "G3"]]
+ straight_mode_arcs = [cmd for cmd in straight_commands if cmd.Name in ["G2", "G3"]]
+
+ self.assertGreater(len(arc_mode_arcs), 0, "Arc mode should have G2/G3 commands")
+ self.assertEqual(len(straight_mode_arcs), 0, "Straight mode should have no G2/G3 commands")
+
+ # Straight mode should have more G0 rapids (one per link)
+ arc_mode_g0 = [cmd for cmd in arc_commands if cmd.Name == "G0"]
+ straight_mode_g0 = [cmd for cmd in straight_commands if cmd.Name == "G0"]
+ self.assertGreater(
+ len(straight_mode_g0),
+ len(arc_mode_g0),
+ "Straight mode should have more G0 rapids than arc mode",
+ )
+
+ def test_zigzag_milling_direction(self):
+ """Test zigzag with different milling directions."""
+ climb_commands = zigzag_facing.zigzag(
+ polygon=self.square_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ milling_direction="climb",
+ )
+
+ conventional_commands = zigzag_facing.zigzag(
+ polygon=self.square_wire,
+ tool_diameter=5.0,
+ stepover_percent=50,
+ milling_direction="conventional",
+ )
+
+ # Should have same number of commands
+ self.assertEqual(len(climb_commands), len(conventional_commands))
+
+ # But coordinates should be different
+ different_coords = False
+ for i in range(min(len(climb_commands), len(conventional_commands))):
+ if climb_commands[i].Parameters != conventional_commands[i].Parameters:
+ different_coords = True
+ break
+ self.assertTrue(different_coords)
+
+ def test_get_angled_polygon_zero_degrees(self):
+ """Test get_angled_polygon with 0 degree rotation."""
+ result = facing_common.get_angled_polygon(self.square_wire, 0)
+
+ # Should get back a valid wire
+ self.assertTrue(result.isClosed())
+ # Bounding box should be similar to original
+ original_bb = self.square_wire.BoundBox
+ result_bb = result.BoundBox
+ self.assertAlmostEqual(original_bb.XLength, result_bb.XLength, places=1)
+ self.assertAlmostEqual(original_bb.YLength, result_bb.YLength, places=1)
+
+ def test_get_angled_polygon_45_degrees(self):
+ """Test get_angled_polygon with 45 degree rotation."""
+ result = facing_common.get_angled_polygon(self.square_wire, 45)
+
+ self.assertTrue(result.isClosed())
+ # The rotated bounding box should be larger than the original
+ original_bb = self.square_wire.BoundBox
+ result_bb = result.BoundBox
+
+ # The function creates a bounding box that fully contains the rotated wire
+ # For a 45-degree rotation, this will be larger than just the diagonal
+ # The result should be larger than the original in both dimensions
+ self.assertGreater(result_bb.XLength, original_bb.XLength)
+ self.assertGreater(result_bb.YLength, original_bb.YLength)
+
+ # Should have 4 edges (rectangular)
+ self.assertEqual(len(result.Edges), 4)
+
+ def test_analyze_rectangle_axis_aligned(self):
+ """Test polygon geometry extraction with axis-aligned rectangle."""
+ result = facing_common.extract_polygon_geometry(self.rectangle_wire)
+
+ # Should have edges and corners
+ self.assertIsNotNone(result["edges"])
+ self.assertIsNotNone(result["corners"])
+ self.assertEqual(len(result["edges"]), 4)
+ self.assertEqual(len(result["corners"]), 4)
+
+ def test_analyze_rectangle_short_preference(self):
+ """Test edge selection with short axis preference."""
+ polygon_info = facing_common.extract_polygon_geometry(self.rectangle_wire)
+ result = facing_common.select_primary_step_edges(polygon_info["edges"], "short")
+
+ # Primary should be shorter axis (Y for this rectangle)
+ self.assertAlmostEqual(result["primary_length"], 10, places=1)
+ self.assertAlmostEqual(result["step_length"], 20, places=1)
+
+ def test_analyze_rectangle_invalid_polygon(self):
+ """Test edge selection with invalid polygon."""
+ # Create a triangle (3 edges instead of 4)
+ triangle_wire = Part.makePolygon(
+ [
+ FreeCAD.Vector(0, 0, 0),
+ FreeCAD.Vector(10, 0, 0),
+ FreeCAD.Vector(5, 10, 0),
+ FreeCAD.Vector(0, 0, 0),
+ ]
+ )
+
+ # Should raise ValueError for non-rectangular polygon
+ with self.assertRaises(ValueError):
+ facing_common.extract_polygon_geometry(triangle_wire)
+
+ def test_spiral_conventional_milling(self):
+ """Test spiral strategy with conventional milling direction."""
+ commands = spiral_facing.spiral(
+ self.square_wire, 10.0, 50.0, milling_direction="conventional"
+ )
+
+ self.assertGreater(len(commands), 0)
+ # First move should be G0 rapid positioning
+ self.assertEqual(commands[0].Name, "G0")
+
+ # Check that we have cutting moves (G1 commands)
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreaterEqual(len(cutting_moves), 4) # At least one complete rectangle
+
+ def test_bidirectional_basic(self):
+ """Test basic bidirectional strategy functionality."""
+ commands = bidirectional_facing.bidirectional(self.square_wire, 10.0, 50.0)
+
+ self.assertGreater(len(commands), 0)
+ # First move should be G0 to start position (op will replace with preamble)
+ self.assertEqual(commands[0].Name, "G0")
+
+ # Check that we have cutting moves (G1 commands)
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreater(len(cutting_moves), 1) # At least one pass
+
+ # Check that we have rapid moves (G0 commands) between passes
+ rapid_moves = [cmd for cmd in commands if cmd.Name == "G0"]
+ self.assertGreater(len(rapid_moves), 0)
+
+ def test_bidirectional_climb_milling(self):
+ """Test bidirectional strategy with climb milling direction."""
+ commands = bidirectional_facing.bidirectional(
+ self.square_wire, 10.0, 50.0, milling_direction="climb"
+ )
+
+ self.assertGreater(len(commands), 0)
+ # First move should be G0 to start position (op will replace with preamble)
+ self.assertEqual(commands[0].Name, "G0")
+
+ # Check that we have cutting moves (G1 commands)
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreater(len(cutting_moves), 1)
+
+ def test_bidirectional_conventional_milling(self):
+ """Test bidirectional strategy with conventional milling direction."""
+ commands = bidirectional_facing.bidirectional(
+ self.square_wire, 10.0, 50.0, milling_direction="conventional"
+ )
+
+ self.assertGreater(len(commands), 0)
+ # First move should be G0 to start position (op will replace with preamble)
+ self.assertEqual(commands[0].Name, "G0")
+
+ # Check that we have cutting moves (G1 commands)
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreater(len(cutting_moves), 1)
+
+ def test_bidirectional_with_retract_height(self):
+ """Test bidirectional strategy - rapids stay at cutting height."""
+ commands = bidirectional_facing.bidirectional(self.square_wire, 10.0, 50.0)
+
+ self.assertGreater(len(commands), 0)
+
+ # Bidirectional should have rapid moves at cutting height (no Z retracts between passes)
+ rapid_moves = [
+ cmd
+ for cmd in commands
+ if cmd.Name == "G0" and "X" in cmd.Parameters and "Y" in cmd.Parameters
+ ]
+ self.assertGreater(len(rapid_moves), 0)
+
+ def test_bidirectional_alternating_positions(self):
+ """Test that bidirectional strategy alternates between bottom and top positions."""
+ commands = bidirectional_facing.bidirectional(
+ self.rectangle_wire, 2.0, 25.0, milling_direction="climb"
+ )
+
+ # Get all G1 cutting moves
+ cutting_moves = [
+ cmd
+ for cmd in commands
+ if cmd.Name == "G1" and "X" in cmd.Parameters and "Y" in cmd.Parameters
+ ]
+
+ # Should have multiple cutting moves
+ self.assertGreaterEqual(len(cutting_moves), 4)
+
+ # For bidirectional, we should have rapid moves (G0) between passes
+ rapid_moves = [cmd for cmd in commands if cmd.Name == "G0"]
+ self.assertGreater(len(rapid_moves), 0)
+
+ # Extract Y coordinates of start positions for each cutting move
+ start_y_coords = [
+ cutting_moves[i].Parameters["Y"]
+ for i in range(0, len(cutting_moves), 2)
+ if i < len(cutting_moves)
+ ]
+
+ # Should have alternating Y positions (bottom and top)
+ if len(start_y_coords) >= 4:
+ # Separate into bottom and top passes based on Y coordinate
+ sorted_coords = sorted(start_y_coords)
+ mid_y = (sorted_coords[0] + sorted_coords[-1]) / 2.0
+
+ bottom_passes = sorted([y for y in start_y_coords if y < mid_y])
+ top_passes = sorted([y for y in start_y_coords if y > mid_y], reverse=True)
+
+ # Bottom passes should be increasing (stepping inward from bottom)
+ if len(bottom_passes) >= 2:
+ self.assertLess(
+ bottom_passes[0], bottom_passes[1]
+ ) # Second bottom pass higher than first
+
+ # Top passes should be decreasing (stepping inward from top)
+ if len(top_passes) >= 2:
+ self.assertGreater(top_passes[0], top_passes[1]) # Second top pass lower than first
+
+ def test_bidirectional_axis_preference_long(self):
+ """Test bidirectional strategy with angle for long axis."""
+ commands = bidirectional_facing.bidirectional(
+ self.rectangle_wire, 5.0, 50.0, angle_degrees=0.0
+ )
+
+ self.assertGreater(len(commands), 0)
+ # Should generate valid toolpath commands
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreater(len(cutting_moves), 1)
+
+ def test_bidirectional_axis_preference_short(self):
+ """Test bidirectional strategy with angle for short axis."""
+ commands = bidirectional_facing.bidirectional(
+ self.rectangle_wire, 5.0, 50.0, angle_degrees=90.0
+ )
+
+ self.assertGreater(len(commands), 0)
+ # Should generate valid toolpath commands
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreater(len(cutting_moves), 1)
+
+ def test_bidirectional_with_pass_extension(self):
+ """Test bidirectional strategy with pass extension parameter."""
+ pass_extension = 2.0
+ commands = bidirectional_facing.bidirectional(
+ self.square_wire, 10.0, 50.0, pass_extension=pass_extension
+ )
+
+ self.assertGreater(len(commands), 0)
+ # Should generate valid toolpath commands
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreater(len(cutting_moves), 1)
+
+ def test_spiral_layer_calculation(self):
+ """Test that spiral generates appropriate number of layers."""
+ # Use small stepover to get multiple layers
+ commands = spiral_facing.spiral(
+ polygon=self.square_wire, # 10x10 square
+ tool_diameter=2.0,
+ stepover_percent=25, # 0.5mm stepover
+ )
+
+ # Should have multiple layers
+ self.assertGreater(len(commands), 8) # At least 2-3 layers with multiple moves each
+
+ # Extract unique positions to verify spiral pattern
+ positions = []
+ for cmd in commands:
+ if "X" in cmd.Parameters and "Y" in cmd.Parameters:
+ positions.append((cmd.Parameters["X"], cmd.Parameters["Y"]))
+
+ # Should have multiple unique positions
+ unique_positions = set(positions)
+ self.assertGreater(len(unique_positions), 4)
+ import Path
+
+ p = Path.Path(commands)
+ print(p.toGCode())
+
+ def test_spiral_milling_direction(self):
+ """Test spiral with different milling directions."""
+ climb_commands = spiral_facing.spiral(
+ polygon=self.square_wire,
+ tool_diameter=4.0,
+ stepover_percent=50,
+ milling_direction="climb",
+ )
+
+ conventional_commands = spiral_facing.spiral(
+ polygon=self.square_wire,
+ tool_diameter=4.0,
+ stepover_percent=50,
+ milling_direction="conventional",
+ )
+
+ # Should have same number of commands
+ self.assertEqual(len(climb_commands), len(conventional_commands))
+
+ # But coordinates should be different due to different spiral direction
+ different_coords = False
+ for i in range(min(len(climb_commands), len(conventional_commands))):
+ if climb_commands[i].Parameters != conventional_commands[i].Parameters:
+ different_coords = True
+ break
+ self.assertTrue(different_coords)
+
+ def test_spiral_centered_on_origin(self):
+ """Test spiral with rectangle centered on origin to debug overlapping passes."""
+ import Part
+
+ # Create a 10x6 rectangle centered on origin (-5,-3) to (5,3)
+ centered_rectangle = Part.makePolygon(
+ [
+ FreeCAD.Vector(-5, -3, 0),
+ FreeCAD.Vector(5, -3, 0),
+ FreeCAD.Vector(5, 3, 0),
+ FreeCAD.Vector(-5, 3, 0),
+ FreeCAD.Vector(-5, -3, 0),
+ ]
+ )
+
+ # Use small stepover to get multiple layers
+ commands = spiral_facing.spiral(
+ polygon=centered_rectangle, # 10x6 rectangle centered on origin
+ tool_diameter=2.0,
+ stepover_percent=25, # 0.5mm stepover
+ )
+
+ # Should have multiple layers
+ self.assertGreater(len(commands), 8) # At least 2-3 layers with multiple moves each
+
+ # Extract unique positions to verify spiral pattern
+ positions = []
+ for cmd in commands:
+ if "X" in cmd.Parameters and "Y" in cmd.Parameters:
+ positions.append((cmd.Parameters["X"], cmd.Parameters["Y"]))
+
+ # Should have multiple unique positions
+ unique_positions = set(positions)
+ self.assertGreater(len(unique_positions), 4)
+
+ # Print G-code for debugging
+ import Path
+
+ p = Path.Path(commands)
+ print("Centered on origin G-code:")
+ print(p.toGCode())
+
+ def test_spiral_axis_preference_variations(self):
+ """Test spiral with different axis preferences and milling directions."""
+ import Part
+
+ # Create a 12x8 rectangle centered on origin (-6,-4) to (6,4)
+ # Long axis = 12mm (X), Short axis = 8mm (Y)
+ test_rectangle = Part.makePolygon(
+ [
+ FreeCAD.Vector(-6, -4, 0),
+ FreeCAD.Vector(6, -4, 0),
+ FreeCAD.Vector(6, 4, 0),
+ FreeCAD.Vector(-6, 4, 0),
+ FreeCAD.Vector(-6, -4, 0),
+ ]
+ )
+
+ # Test different combinations
+ test_cases = [
+ ("long", "climb"),
+ ("long", "conventional"),
+ ("short", "climb"),
+ ("short", "conventional"),
+ ]
+
+ for axis_pref, milling_dir in test_cases:
+ with self.subTest(axis_preference=axis_pref, milling_direction=milling_dir):
+ commands = spiral_facing.spiral(
+ polygon=test_rectangle,
+ tool_diameter=2.0,
+ stepover_percent=25,
+ milling_direction=milling_dir,
+ )
+
+ # Should have multiple layers
+ self.assertGreater(len(commands), 8)
+
+ # Print G-code for debugging
+ import Path
+
+ p = Path.Path(commands)
+ print(f"\n{axis_pref} axis, {milling_dir} milling G-code:")
+ print(p.toGCode())
+
+ def test_spiral_angled_rectangle(self):
+ """Test spiral with angled rectangle to verify it follows polygon shape, not bounding box."""
+ import Part
+ import math
+
+ # Create a 12x8 rectangle rotated 30 degrees
+ # This will test if spiral follows the actual polygon or just the axis-aligned bounding box
+ angle = math.radians(30)
+ cos_a = math.cos(angle)
+ sin_a = math.sin(angle)
+
+ # Original rectangle corners (before rotation)
+ corners = [(-6, -4), (6, -4), (6, 4), (-6, 4)]
+
+ # Rotate corners
+ rotated_corners = []
+ for x, y in corners:
+ new_x = x * cos_a - y * sin_a
+ new_y = x * sin_a + y * cos_a
+ rotated_corners.append(FreeCAD.Vector(new_x, new_y, 0))
+
+ # Close the polygon
+ rotated_corners.append(rotated_corners[0])
+
+ angled_rectangle = Part.makePolygon(rotated_corners)
+
+ # Test both axis preferences with the angled rectangle
+ for axis_pref in ["long", "short"]:
+ with self.subTest(axis_preference=axis_pref):
+ commands = spiral_facing.spiral(
+ polygon=angled_rectangle,
+ tool_diameter=2.0,
+ stepover_percent=25,
+ milling_direction="climb",
+ )
+
+ # Should have multiple layers
+ self.assertGreater(len(commands), 8)
+
+ # Print G-code for debugging
+ import Path
+
+ p = Path.Path(commands)
+ print(f"\nAngled rectangle {axis_pref} axis G-code:")
+ print(p.toGCode())
+
+ def test_spiral_continuous_cutting(self):
+ """Test that spiral maintains continuous cutting motion throughout."""
+ commands = spiral_facing.spiral(
+ polygon=self.square_wire, tool_diameter=4.0, stepover_percent=50
+ )
+
+ # Spiral should have only one rapid move (G0) for initial positioning
+ rapid_moves = [cmd for cmd in commands if cmd.Name == "G0"]
+
+ # Should have exactly one rapid move for initial positioning
+ self.assertEqual(len(rapid_moves), 1)
+ # First command should be the rapid positioning move
+ self.assertEqual(commands[0].Name, "G0")
+
+ # Should have multiple cutting moves after the initial rapid move
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreater(len(cutting_moves), 0)
+ # Total commands should be rapid moves + cutting moves
+ self.assertEqual(len(commands), len(rapid_moves) + len(cutting_moves))
+
+ def test_spiral_rectangular_polygon(self):
+ """Test spiral with rectangular (non-square) polygon."""
+ commands = spiral_facing.spiral(
+ polygon=self.rectangle_wire, tool_diameter=3.0, stepover_percent=40 # 20x10 rectangle
+ )
+
+ # Should generate valid spiral
+ self.assertGreater(len(commands), 0)
+
+ # First command should be rapid positioning (G0), rest should be cutting moves (G1)
+ self.assertEqual(commands[0].Name, "G0")
+ for cmd in commands[1:]:
+ if "X" in cmd.Parameters and "Y" in cmd.Parameters:
+ self.assertEqual(cmd.Name, "G1")
+
+ # Should stay within reasonable bounds
+ for cmd in commands:
+ if "X" in cmd.Parameters:
+ x = cmd.Parameters["X"]
+ # Should be within extended rectangle bounds
+ self.assertGreaterEqual(x, -5) # Some margin for tool
+ self.assertLessEqual(x, 25)
+ if "Y" in cmd.Parameters:
+ y = cmd.Parameters["Y"]
+ self.assertGreaterEqual(y, -5)
+ self.assertLessEqual(y, 15)
+
+ def test_spiral_basic(self):
+ """Test spiral basic functionality."""
+ commands = spiral_facing.spiral(
+ polygon=self.rectangle_wire, tool_diameter=4.0, stepover_percent=50
+ )
+
+ # Should generate valid path
+ self.assertGreater(len(commands), 0)
+
+ # First command should be G0 rapid positioning
+ self.assertEqual(commands[0].Name, "G0")
+
+ # Should have cutting moves
+ cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"]
+ self.assertGreater(len(cutting_moves), 0)
+
+ def test_spiral_continuous_pattern(self):
+ """Test that spiral generates a continuous inward pattern without diagonal jumps."""
+ # Use a simple 10x10 square with 2mm tool and 50% stepover for predictable results
+ commands = spiral_facing.spiral(
+ polygon=self.square_wire, # 10x10 square
+ tool_diameter=2.0,
+ stepover_percent=50, # 1mm stepover
+ milling_direction="climb",
+ )
+
+ # Extract all G1 cutting moves
+ cutting_moves = [
+ (cmd.Parameters.get("X"), cmd.Parameters.get("Y"))
+ for cmd in commands
+ if cmd.Name == "G1" and "X" in cmd.Parameters and "Y" in cmd.Parameters
+ ]
+
+ self.assertGreater(len(cutting_moves), 0, "Should have cutting moves")
+
+ # Verify the spiral pattern:
+ # ALL moves should be axis-aligned (straight along edges, X or Y constant)
+ # A proper rectangular spiral has no diagonal moves at all
+ diagonal_moves = []
+ for i in range(len(cutting_moves) - 1):
+ x1, y1 = cutting_moves[i]
+ x2, y2 = cutting_moves[i + 1]
+
+ # Check if move is axis-aligned (either X or Y stays constant)
+ x_change = abs(x2 - x1)
+ y_change = abs(y2 - y1)
+
+ # Allow small tolerance for floating point errors
+ is_axis_aligned = x_change < 0.01 or y_change < 0.01
+
+ if not is_axis_aligned:
+ diagonal_moves.append((i, x1, y1, x2, y2, x_change, y_change))
+
+ # Should have NO diagonal moves
+ if diagonal_moves:
+ msg = "Found diagonal moves instead of axis-aligned edges:\n"
+ for i, x1, y1, x2, y2, dx, dy in diagonal_moves[:5]: # Show first 5
+ msg += f" Move {i} to {i+1}: ({x1:.2f},{y1:.2f}) -> ({x2:.2f},{y2:.2f}) dx={dx:.2f} dy={dy:.2f}\n"
+ self.fail(msg)
+
+ # Verify the pattern spirals inward by checking that later moves are closer to center
+ center_x, center_y = 5.0, 5.0
+ first_quarter = cutting_moves[: len(cutting_moves) // 4]
+ last_quarter = cutting_moves[3 * len(cutting_moves) // 4 :]
+
+ avg_dist_first = sum(
+ ((x - center_x) ** 2 + (y - center_y) ** 2) ** 0.5 for x, y in first_quarter
+ ) / len(first_quarter)
+ avg_dist_last = sum(
+ ((x - center_x) ** 2 + (y - center_y) ** 2) ** 0.5 for x, y in last_quarter
+ ) / len(last_quarter)
+
+ self.assertLess(
+ avg_dist_last,
+ avg_dist_first,
+ "Spiral should move inward - later moves should be closer to center",
+ )
+
+ def _create_mock_tool_controller(self, spindle_dir):
+ """Create a mock tool controller for testing."""
+
+ class MockToolController:
+ def __init__(self, spindle_direction):
+ self.SpindleDir = spindle_direction
+
+ return MockToolController(spindle_dir)
diff --git a/src/Mod/CAM/CAMTests/TestPathUtil.py b/src/Mod/CAM/CAMTests/TestPathUtil.py
index e76c744ab4..17457b8623 100644
--- a/src/Mod/CAM/CAMTests/TestPathUtil.py
+++ b/src/Mod/CAM/CAMTests/TestPathUtil.py
@@ -23,6 +23,7 @@
import FreeCAD
import Path.Base.Util as PathUtil
+import Path.Op.Base as PathOpBase
import TestSketcherApp
from CAMTests.PathTestUtils import PathTestBase
@@ -116,3 +117,248 @@ class TestPathUtil(PathTestBase):
# however, the object itself is no longer valid
self.assertFalse(PathUtil.isValidBaseObject(box))
+
+
+class TestCompass(PathTestBase):
+ """Test the Compass helper class for climb/conventional milling."""
+
+ def setUp(self):
+ # Spindle directions for testing
+ self.spindle_forward = "Forward"
+ self.spindle_reverse = "Reverse"
+ self.spindle_none = "None"
+ super().setUp()
+
+ def test_compass_initialization(self):
+ """Test Compass class initialization."""
+ compass = PathOpBase.Compass(self.spindle_forward)
+
+ # Check default values
+ self.assertEqual(compass.spindle_dir, self.spindle_forward)
+ self.assertEqual(compass.cut_side, "Outside")
+ self.assertEqual(compass.cut_mode, "Climb")
+
+ def test_spindle_direction_property(self):
+ """Test spindle direction property handling."""
+ # Test forward spindle
+ compass_forward = PathOpBase.Compass(self.spindle_forward)
+ self.assertEqual(compass_forward.spindle_dir, "Forward")
+
+ # Test reverse spindle
+ compass_reverse = PathOpBase.Compass(self.spindle_reverse)
+ self.assertEqual(compass_reverse.spindle_dir, "Reverse")
+
+ # Test none/unknown spindle
+ compass_none = PathOpBase.Compass(self.spindle_none)
+ self.assertEqual(compass_none.spindle_dir, "None")
+
+ # Test setting spindle direction
+ compass = PathOpBase.Compass("Forward")
+ compass.spindle_dir = "Reverse"
+ self.assertEqual(compass.spindle_dir, "Reverse")
+
+ # Test invalid spindle direction defaults to None
+ compass.spindle_dir = "Invalid"
+ self.assertEqual(compass.spindle_dir, "None")
+
+ def test_cut_side_property(self):
+ """Test cut side property and setter."""
+ compass = PathOpBase.Compass(self.spindle_forward)
+
+ # Test default
+ self.assertEqual(compass.cut_side, "Outside")
+
+ # Test setting inside
+ compass.cut_side = "inside"
+ self.assertEqual(compass.cut_side, "Inside")
+
+ # Test setting outside
+ compass.cut_side = "outside"
+ self.assertEqual(compass.cut_side, "Outside")
+
+ def test_cut_mode_property(self):
+ """Test cut mode property and setter."""
+ compass = PathOpBase.Compass(self.spindle_forward)
+
+ # Test default
+ self.assertEqual(compass.cut_mode, "Climb")
+
+ # Test setting conventional
+ compass.cut_mode = "conventional"
+ self.assertEqual(compass.cut_mode, "Conventional")
+
+ # Test setting climb
+ compass.cut_mode = "climb"
+ self.assertEqual(compass.cut_mode, "Climb")
+
+ def test_path_direction_calculation(self):
+ """Test path direction calculation logic."""
+ # Test Forward spindle, Outside cut, Climb mode -> CW path
+ compass = PathOpBase.Compass(self.spindle_forward)
+ compass.cut_side = "Outside"
+ compass.cut_mode = "Climb"
+ self.assertEqual(compass.path_dir, "CW")
+
+ # Test Forward spindle, Inside cut, Climb mode -> CCW path
+ compass.cut_side = "Inside"
+ compass.cut_mode = "Climb"
+ self.assertEqual(compass.path_dir, "CCW")
+
+ # Test Reverse spindle, Outside cut, Climb mode -> CCW path
+ compass_reverse = PathOpBase.Compass(self.spindle_reverse)
+ compass_reverse.cut_side = "Outside"
+ compass_reverse.cut_mode = "Climb"
+ self.assertEqual(compass_reverse.path_dir, "CCW")
+
+ # Test Reverse spindle, Inside cut, Climb mode -> CW path
+ compass_reverse.cut_side = "Inside"
+ compass_reverse.cut_mode = "Climb"
+ self.assertEqual(compass_reverse.path_dir, "CW")
+
+ def test_conventional_milling_path_direction(self):
+ """Test path direction for conventional milling."""
+ # Test Forward spindle, Outside cut, Conventional mode -> CCW path
+ compass = PathOpBase.Compass(self.spindle_forward)
+ compass.cut_side = "Outside"
+ compass.cut_mode = "Conventional"
+ self.assertEqual(compass.path_dir, "CCW")
+
+ # Test Forward spindle, Inside cut, Conventional mode -> CW path
+ compass.cut_side = "Inside"
+ compass.cut_mode = "Conventional"
+ self.assertEqual(compass.path_dir, "CW")
+
+ def test_unknown_spindle_direction(self):
+ """Test behavior with unknown spindle direction."""
+ compass = PathOpBase.Compass(self.spindle_none)
+ self.assertEqual(compass.path_dir, "UNKNOWN")
+
+ def test_expected_cut_mode_lookup(self):
+ """Test the internal _expected_cut_mode lookup table."""
+ compass = PathOpBase.Compass(self.spindle_forward)
+
+ # Test lookup table entries for climb milling
+ self.assertEqual(compass._expected_cut_mode("Inside", "CW", "CCW"), "Climb")
+ self.assertEqual(compass._expected_cut_mode("Inside", "CCW", "CW"), "Climb")
+ self.assertEqual(compass._expected_cut_mode("Outside", "CW", "CW"), "Climb")
+ self.assertEqual(compass._expected_cut_mode("Outside", "CCW", "CCW"), "Climb")
+
+ # Test default to conventional for other combinations
+ self.assertEqual(compass._expected_cut_mode("Outside", "CW", "CCW"), "Conventional")
+ self.assertEqual(compass._expected_cut_mode("Inside", "CW", "CW"), "Conventional")
+
+ def test_rotation_from_spindle(self):
+ """Test spindle direction to rotation mapping."""
+ compass = PathOpBase.Compass(self.spindle_forward)
+
+ self.assertEqual(compass._rotation_from_spindle("Forward"), "CW")
+ self.assertEqual(compass._rotation_from_spindle("Reverse"), "CCW")
+
+ def test_report_functionality(self):
+ """Test the report method returns correct data."""
+ compass = PathOpBase.Compass(self.spindle_forward)
+ compass.cut_side = "Inside"
+ compass.cut_mode = "Conventional"
+
+ report = compass.report()
+
+ expected = {
+ "spindle_dir": "Forward",
+ "cut_side": "Inside",
+ "cut_mode": "Conventional",
+ "operation_type": "Perimeter",
+ "path_dir": "CW",
+ }
+
+ self.assertEqual(report, expected)
+
+ def test_area_operation_initialization(self):
+ """Test Compass class initialization for area operations."""
+ compass = PathOpBase.Compass(self.spindle_forward, "Area")
+
+ # Check values for area operations
+ self.assertEqual(compass.spindle_dir, "Forward")
+ self.assertEqual(compass.operation_type, "Area")
+ self.assertEqual(compass.cut_mode, "Climb")
+ self.assertEqual(compass.path_dir, "N/A") # Not applicable for area operations
+
+ def test_operation_type_property(self):
+ """Test operation type property and setter."""
+ compass = PathOpBase.Compass(self.spindle_forward)
+
+ # Test default (perimeter for backward compatibility)
+ self.assertEqual(compass.operation_type, "Perimeter")
+
+ # Test setting area
+ compass.operation_type = "area"
+ self.assertEqual(compass.operation_type, "Area")
+ self.assertEqual(compass.path_dir, "N/A")
+
+ # Test setting back to perimeter
+ compass.operation_type = "perimeter"
+ self.assertEqual(compass.operation_type, "Perimeter")
+ self.assertNotEqual(compass.path_dir, "N/A")
+
+ def test_step_direction_for_area_operations(self):
+ """Test step direction calculation for area operations."""
+ compass = PathOpBase.Compass(self.spindle_forward, "Area")
+ compass.cut_mode = "Climb"
+
+ # Test X- approach with climb milling
+ step_dir = compass.get_step_direction("X-")
+ self.assertTrue(isinstance(step_dir, bool))
+
+ # Test conventional milling gives different result
+ compass.cut_mode = "Conventional"
+ step_dir_conv = compass.get_step_direction("X-")
+ self.assertNotEqual(step_dir, step_dir_conv)
+
+ # Test with reverse spindle
+ compass_reverse = PathOpBase.Compass(self.spindle_reverse, "Area")
+ step_dir_reverse = compass_reverse.get_step_direction("X-")
+ self.assertTrue(isinstance(step_dir_reverse, bool))
+
+ def test_cutting_direction_for_area_operations(self):
+ """Test cutting direction calculation for area operations."""
+ compass = PathOpBase.Compass(self.spindle_forward, "Area")
+ compass.cut_mode = "Climb"
+
+ # Test zigzag pattern alternates direction
+ cut_dir_0 = compass.get_cutting_direction("X-", 0, "zigzag")
+ cut_dir_1 = compass.get_cutting_direction("X-", 1, "zigzag")
+ self.assertNotEqual(cut_dir_0, cut_dir_1)
+
+ # Test unidirectional pattern keeps same direction
+ cut_dir_uni_0 = compass.get_cutting_direction("X-", 0, "unidirectional")
+ cut_dir_uni_1 = compass.get_cutting_direction("X-", 1, "unidirectional")
+ self.assertEqual(cut_dir_uni_0, cut_dir_uni_1)
+
+ def test_area_operation_error_handling(self):
+ """Test error handling for area operation methods on perimeter operations."""
+ compass = PathOpBase.Compass(self.spindle_forward, "Perimeter")
+
+ # Should raise error when calling area methods on perimeter operation
+ with self.assertRaises(ValueError):
+ compass.get_step_direction("X-")
+
+ with self.assertRaises(ValueError):
+ compass.get_cutting_direction("X-")
+
+ def test_backward_compatibility(self):
+ """Test that existing code still works (backward compatibility)."""
+ # Old style initialization (no operation_type parameter)
+ compass = PathOpBase.Compass(self.spindle_forward)
+
+ # Should default to perimeter operation
+ self.assertEqual(compass.operation_type, "Perimeter")
+
+ # All existing functionality should work
+ self.assertEqual(compass.spindle_dir, "Forward")
+ self.assertEqual(compass.cut_side, "Outside")
+ self.assertEqual(compass.cut_mode, "Climb")
+ self.assertEqual(compass.path_dir, "CW")
+
+ # Report should include operation_type
+ report = compass.report()
+ self.assertIn("operation_type", report)
+ self.assertEqual(report["operation_type"], "Perimeter")
diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt
index ef3b78a2b1..4950a8d880 100644
--- a/src/Mod/CAM/CMakeLists.txt
+++ b/src/Mod/CAM/CMakeLists.txt
@@ -354,6 +354,7 @@ SET(PathPythonOp_SRCS
Path/Op/Drilling.py
Path/Op/Helix.py
Path/Op/MillFace.py
+ Path/Op/MillFacing.py
Path/Op/Pocket.py
Path/Op/PocketBase.py
Path/Op/PocketShape.py
@@ -384,6 +385,7 @@ SET(PathPythonOpGui_SRCS
Path/Op/Gui/FeatureExtension.py
Path/Op/Gui/Helix.py
Path/Op/Gui/MillFace.py
+ Path/Op/Gui/MillFacing.py
Path/Op/Gui/PathShapeTC.py
Path/Op/Gui/Pocket.py
Path/Op/Gui/PocketBase.py
@@ -412,7 +414,13 @@ SET(PathScripts_SRCS
SET(PathPythonBaseGenerator_SRCS
Path/Base/Generator/dogboneII.py
Path/Base/Generator/drill.py
+ Path/Base/Generator/facing_common.py
+ Path/Base/Generator/spiral_facing.py
+ Path/Base/Generator/zigzag_facing.py
+ Path/Base/Generator/directional_facing.py
+ Path/Base/Generator/bidirectional_facing.py
Path/Base/Generator/helix.py
+ Path/Base/Generator/linking.py
Path/Base/Generator/rotation.py
Path/Base/Generator/tapping.py
Path/Base/Generator/threadmilling.py
@@ -497,7 +505,7 @@ SET(Tests_SRCS
CAMTests/TestGrblPost.py
CAMTests/TestGrblLegacyPost.py
CAMTests/TestLinuxCNCPost.py
- CAMTests/TestLinuxCNCLegacyPost.py
+ CAMTests/TestLinkingGenerator.py
CAMTests/TestMach3Mach4Post.py
CAMTests/TestMach3Mach4LegacyPost.py
CAMTests/TestMassoG3Post.py
@@ -511,6 +519,7 @@ SET(Tests_SRCS
CAMTests/TestPathDressupHoldingTags.py
CAMTests/TestPathDrillGenerator.py
CAMTests/TestPathDrillable.py
+ CAMTests/TestPathFacingGenerator.py
CAMTests/TestPathGeneratorDogboneII.py
CAMTests/TestPathGeom.py
CAMTests/TestPathHelix.py
diff --git a/src/Mod/CAM/Gui/Resources/Path.qrc b/src/Mod/CAM/Gui/Resources/Path.qrc
index e665bd5e6c..9b57eb1069 100644
--- a/src/Mod/CAM/Gui/Resources/Path.qrc
+++ b/src/Mod/CAM/Gui/Resources/Path.qrc
@@ -111,6 +111,7 @@
panels/PageOpHelixEdit.ui
panels/PageOpPocketExtEdit.ui
panels/PageOpPocketFullEdit.ui
+ panels/PageOpMillFacingEdit.ui
panels/PageOpProbeEdit.ui
panels/PageOpProfileFullEdit.ui
panels/PageOpSlotEdit.ui
diff --git a/src/Mod/CAM/Gui/Resources/panels/PageOpMillFacingEdit.ui b/src/Mod/CAM/Gui/Resources/panels/PageOpMillFacingEdit.ui
new file mode 100644
index 0000000000..b3db8e4341
--- /dev/null
+++ b/src/Mod/CAM/Gui/Resources/panels/PageOpMillFacingEdit.ui
@@ -0,0 +1,273 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 350
+ 400
+
+
+
+ Form
+
+
+
+
+
+ -
+
+
+ QFrame::StyledPanel
+
+
+ QFrame::Raised
+
+
+
+ 3
+
+
-
+
+
+ Tool controller
+
+
+
+ -
+
+
+ The tool and its settings to be used for this operation
+
+
+
+ -
+
+
+ -
+
+
+ Coolant mode
+
+
+
+ -
+
+
+ Edit Tool Controller
+
+
+
+
+
+
+ -
+
+
+ QFrame::StyledPanel
+
+
+ QFrame::Raised
+
+
+
+ 3
+
+
-
+
+
+ Clearing Pattern
+
+
+
+ -
+
+
+ Pattern for clearing the face: ZigZag, Bidirectional, Directional, or Spiral
+
+
+
+ -
+
+
+ Cut Mode
+
+
+
+ -
+
+
+ Climb or Conventional milling direction
+
+
+
+ -
+
+
+ Angle
+
+
+
+ -
+
+
+ Rotation angle for angled facing operations
+
+
+ °
+
+
+ 2
+
+
+ 0.000000000000000
+
+
+ 180.000000000000000
+
+
+ 1.000000000000000
+
+
+
+ -
+
+
+ Reverse
+
+
+ Reverse the cutting direction for the selected pattern
+
+
+
+ -
+
+
+ Step Over
+
+
+
+ -
+
+
+ Stepover percentage for tool passes
+
+
+ %
+
+
+ 1
+
+
+ 1.000000000000000
+
+
+ 100.000000000000000
+
+
+ 5.000000000000000
+
+
+ 50.000000000000000
+
+
+
+ -
+
+
+ Pass Extension
+
+
+
+ -
+
+
+ Distance to extend cuts beyond polygon boundary for tool disengagement
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ -999999999.000000000000000
+
+
+ 999999999.000000000000000
+
+
+
+ -
+
+
+ Stock Extension
+
+
+
+ -
+
+
+ Extends the boundary in both direction
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ -999999999.000000000000000
+
+
+ 999999999.000000000000000
+
+
+
+ -
+
+
+ Stock To Leave (axial)
+
+
+
+ -
+
+
+ Stock to leave for the operation
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ 0.000000000000000
+
+
+ 999999999.000000000000000
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+ Gui::QuantitySpinBox
+ QWidget
+
+
+
+
+
+
diff --git a/src/Mod/CAM/InitGui.py b/src/Mod/CAM/InitGui.py
index 10dc9848f6..ac122527eb 100644
--- a/src/Mod/CAM/InitGui.py
+++ b/src/Mod/CAM/InitGui.py
@@ -143,7 +143,7 @@ class CAMWorkbench(Workbench):
twodopcmdlist = [
"CAM_Profile",
"CAM_Pocket_Shape",
- "CAM_MillFace",
+ "CAM_MillFacing",
"CAM_Helix",
"CAM_Adaptive",
"CAM_Slot",
diff --git a/src/Mod/CAM/Path/Base/Generator/bidirectional_facing.py b/src/Mod/CAM/Path/Base/Generator/bidirectional_facing.py
new file mode 100644
index 0000000000..67411afd90
--- /dev/null
+++ b/src/Mod/CAM/Path/Base/Generator/bidirectional_facing.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+
+"""
+Bidirectional facing toolpath generator.
+
+This module implements a bidirectional clearing pattern where passes alternate between
+starting from the bottom edge and the top edge of the polygon, meeting in the middle.
+Bottom passes step inward from min_t toward the center, while top passes step inward
+from max_t toward the center. Passes are interleaved (bottom, top, bottom, top, etc.)
+to minimize rapid move distances.
+
+Feed moves (cutting) are aligned with the angle_degrees argument direction. Rapid moves
+are perpendicular to the feed moves and always travel outside the clearing area along
+the polygon edges.
+
+This strategy always maintains either climb or conventional milling direction, but
+alternates which side of the polygon is cut to maintain consistent milling direction
+throughout.
+"""
+
+import FreeCAD
+import Path
+from . import facing_common
+import math
+
+if False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+
+def bidirectional(
+ polygon,
+ tool_diameter,
+ stepover_percent,
+ pass_extension=None,
+ milling_direction="climb",
+ reverse=False,
+ angle_degrees=None,
+):
+ if pass_extension is None:
+ pass_extension = tool_diameter * 0.5
+
+ # Establish frame from angle (default 0° = +X primary)
+ theta = float(angle_degrees) if angle_degrees is not None else 0.0
+ primary_vec, step_vec = facing_common.unit_vectors_from_angle(theta)
+ primary_vec = FreeCAD.Vector(primary_vec).normalize()
+ step_vec = FreeCAD.Vector(step_vec).normalize()
+
+ origin = polygon.BoundBox.Center
+ bb = polygon.BoundBox
+ z = bb.ZMin
+
+ # Compute projection bounds
+ min_s, max_s = facing_common.project_bounds(polygon, primary_vec, origin)
+ min_t, max_t = facing_common.project_bounds(polygon, step_vec, origin)
+
+ # ------------------------------------------------------------------
+ # Use the proven generate_t_values (with coverage fix) for full coverage
+ # ------------------------------------------------------------------
+ step_positions = facing_common.generate_t_values(
+ polygon, step_vec, tool_diameter, stepover_percent, origin
+ )
+
+ tool_radius = tool_diameter / 2.0
+ stepover_distance = tool_diameter * stepover_percent / 100.0
+
+ # Coverage guarantee at ≥100% stepover (exact same fix as zigzag/directional)
+ if stepover_percent >= 99.9 and step_positions:
+ min_covered = min(step_positions) - tool_radius
+ max_covered = max(step_positions) + tool_radius
+
+ added = False
+ if max_covered < max_t - 1e-4:
+ step_positions.append(step_positions[-1] + stepover_distance)
+ added = True
+ if min_covered > min_t + 1e-4:
+ step_positions.insert(0, step_positions[0] - stepover_distance)
+ added = True
+ if added:
+ Path.Log.info(
+ "Bidirectional facing: Added extra pass(es) for full coverage at ≥100% stepover"
+ )
+
+ center = (min_t + max_t) / 2.0
+
+ # Split into bottom (≤ center) and top (> center)
+ bottom_positions = [t for t in step_positions if t <= center] # ascending = outer → inner
+ top_positions = [t for t in step_positions if t > center][::-1] # descending = outer → inner
+
+ # Interleave, starting with top if reverse=True
+ all_passes = []
+ max_passes = max(len(bottom_positions), len(top_positions))
+ for i in range(max_passes):
+ if reverse:
+ if i < len(top_positions):
+ all_passes.append(("top", top_positions[i]))
+ if i < len(bottom_positions):
+ all_passes.append(("bottom", bottom_positions[i]))
+ else:
+ if i < len(bottom_positions):
+ all_passes.append(("bottom", bottom_positions[i]))
+ if i < len(top_positions):
+ all_passes.append(("top", top_positions[i]))
+
+ Path.Log.debug(
+ f"Bidirectional: {len(all_passes)} passes ({len(bottom_positions)} bottom, {len(top_positions)} top)"
+ )
+
+ commands = []
+ tool_radius = tool_diameter / 2.0
+ engagement_offset = facing_common.calculate_engagement_offset(tool_diameter, stepover_percent)
+ total_extension = pass_extension + tool_radius + engagement_offset
+
+ start_s = min_s - total_extension
+ end_s = max_s + total_extension
+
+ for side, t in all_passes:
+ # Same direction for all passes on the same side → short outside rapids
+ if side == "bottom":
+ if milling_direction == "climb":
+ p_start, p_end = end_s, start_s # right → left
+ else:
+ p_start, p_end = start_s, end_s # left → right
+ else: # top
+ if milling_direction == "climb":
+ p_start, p_end = start_s, end_s # left → right
+ else:
+ p_start, p_end = end_s, start_s # right → left
+
+ start_point = origin + primary_vec * p_start + step_vec * t
+ end_point = origin + primary_vec * p_end + step_vec * t
+ start_point.z = z
+ end_point.z = z
+
+ if not all(
+ math.isfinite(c) for c in [start_point.x, start_point.y, end_point.x, end_point.y]
+ ):
+ continue
+
+ if commands:
+ # Short perpendicular rapid at cutting height (outside the material)
+ commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y}))
+ else:
+ # First pass – include Z for preamble replacement
+ commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y, "Z": z}))
+
+ commands.append(Path.Command("G1", {"X": end_point.x, "Y": end_point.y, "Z": z}))
+
+ return commands
diff --git a/src/Mod/CAM/Path/Base/Generator/directional_facing.py b/src/Mod/CAM/Path/Base/Generator/directional_facing.py
new file mode 100644
index 0000000000..04257126fc
--- /dev/null
+++ b/src/Mod/CAM/Path/Base/Generator/directional_facing.py
@@ -0,0 +1,201 @@
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+"""
+Directional (unidirectional) facing toolpath generator.
+
+This module implements the unidirectional clearing pattern that cuts in the same
+direction for every pass, providing consistent surface finish.
+
+Feed moves (cutting) are aligned with the angle_degrees argument direction.
+
+At the end of each cutting pass, the cutter retracts to safe height and moves laterally to
+the start position of the next pass.
+
+This strategy always maintains either climb or conventional milling direction.
+"""
+
+import FreeCAD
+import Path
+from . import facing_common
+
+if False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+
+def directional(
+ polygon,
+ tool_diameter,
+ stepover_percent,
+ pass_extension=None,
+ retract_height=None,
+ milling_direction="climb",
+ reverse=False,
+ angle_degrees=None,
+):
+
+ import math
+ import Path
+ import FreeCAD
+ from . import facing_common
+
+ if pass_extension is None:
+ pass_extension = tool_diameter * 0.5
+
+ theta = float(angle_degrees) if angle_degrees is not None else 0.0
+ primary_vec, step_vec = facing_common.unit_vectors_from_angle(theta)
+ primary_vec = FreeCAD.Vector(primary_vec).normalize()
+ step_vec = FreeCAD.Vector(step_vec).normalize()
+
+ origin = polygon.BoundBox.Center
+ z = polygon.BoundBox.ZMin
+
+ min_s, max_s = facing_common.project_bounds(polygon, primary_vec, origin)
+ min_t, max_t = facing_common.project_bounds(polygon, step_vec, origin)
+
+ if not all(math.isfinite(x) for x in [min_s, max_s, min_t, max_t]):
+ Path.Log.error("Directional: non-finite projection bounds; aborting")
+ return []
+
+ step_positions = facing_common.generate_t_values(
+ polygon, step_vec, tool_diameter, stepover_percent, origin
+ )
+
+ tool_radius = tool_diameter / 2.0
+ stepover_distance = tool_diameter * (stepover_percent / 100.0)
+
+ if stepover_percent >= 99.9 and step_positions:
+ min_covered = min(step_positions) - tool_radius
+ max_covered = max(step_positions) + tool_radius
+
+ added = False
+ if max_covered < max_t - 1e-4:
+ step_positions.append(step_positions[-1] + stepover_distance)
+ added = True
+ if min_covered > min_t + 1e-4:
+ step_positions.insert(0, step_positions[0] - stepover_distance)
+ added = True
+ if added:
+ Path.Log.info("Directional: Added extra pass(es) for full coverage at high stepover")
+
+ # Reverse = mirror positions around center (exactly like bidirectional) to preserve engagement offset on the starting side
+ if reverse:
+ center = (min_t + max_t) / 2.0
+ step_positions = [2 * center - t for t in step_positions]
+
+ Path.Log.debug(f"Directional (fixed): {len(step_positions)} passes")
+
+ # Use full-length passes exactly like bidirectional (no slice_wire_segments)
+ total_extension = (
+ pass_extension
+ + tool_radius
+ + facing_common.calculate_engagement_offset(tool_diameter, stepover_percent)
+ )
+
+ start_s = min_s - total_extension
+ end_s = max_s + total_extension
+
+ commands = []
+ kept_segments = 0
+
+ for t in step_positions:
+ # Cutting direction – reverse flips it to maintain climb/conventional preference
+ if milling_direction == "climb":
+ if reverse:
+ p_start, p_end = start_s, end_s
+ else:
+ p_start, p_end = end_s, start_s
+ else: # conventional
+ if reverse:
+ p_start, p_end = end_s, start_s
+ else:
+ p_start, p_end = start_s, end_s
+
+ start_point = origin + primary_vec * p_start + step_vec * t
+ end_point = origin + primary_vec * p_end + step_vec * t
+ start_point.z = z
+ end_point.z = z
+
+ if commands: # not first pass
+ if retract_height is not None:
+ commands.append(Path.Command("G0", {"Z": retract_height}))
+ commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y}))
+ commands.append(Path.Command("G0", {"Z": z}))
+ else:
+ commands.append(
+ Path.Command("G0", {"X": start_point.x, "Y": start_point.y, "Z": z})
+ )
+ else:
+ commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y, "Z": z}))
+
+ commands.append(Path.Command("G1", {"X": end_point.x, "Y": end_point.y, "Z": z}))
+ kept_segments += 1
+
+ Path.Log.debug(f"Directional: generated {kept_segments} segments")
+ # Fallback: if nothing kept due to numeric guards, emit a single mid-line pass across bbox
+ if kept_segments == 0:
+ t_candidates = []
+ # mid, min, max t positions
+ t_candidates.append(0.5 * (min_t + max_t))
+ t_candidates.append(min_t)
+ t_candidates.append(max_t)
+ for t in t_candidates:
+ intervals = facing_common.slice_wire_segments(polygon, primary_vec, step_vec, t, origin)
+ if not intervals:
+ continue
+ s0, s1 = intervals[0]
+ start_s = max(s0 - pass_extension, min_s - s_margin)
+ end_s = min(s1 + pass_extension, max_s + s_margin)
+ if end_s <= start_s:
+ continue
+ if milling_direction == "climb":
+ p_start, p_end = start_s, end_s
+ else:
+ p_start, p_end = end_s, start_s
+ if reverse:
+ p_start, p_end = p_end, p_start
+ sp = (
+ FreeCAD.Vector(origin)
+ .add(FreeCAD.Vector(primary_vec).multiply(p_start))
+ .add(FreeCAD.Vector(step_vec).multiply(t))
+ )
+ ep = (
+ FreeCAD.Vector(origin)
+ .add(FreeCAD.Vector(primary_vec).multiply(p_end))
+ .add(FreeCAD.Vector(step_vec).multiply(t))
+ )
+ sp.z = z
+ ep.z = z
+ # Minimal preamble
+ if retract_height is not None:
+ commands.append(Path.Command("G0", {"Z": retract_height}))
+ commands.append(Path.Command("G0", {"X": sp.x, "Y": sp.y}))
+ commands.append(Path.Command("G0", {"Z": z}))
+ else:
+ commands.append(Path.Command("G1", {"X": sp.x, "Y": sp.y, "Z": z}))
+ commands.append(Path.Command("G1", {"X": ep.x, "Y": ep.y, "Z": z}))
+ break
+ return commands
diff --git a/src/Mod/CAM/Path/Base/Generator/facing_common.py b/src/Mod/CAM/Path/Base/Generator/facing_common.py
new file mode 100644
index 0000000000..0f3b402dad
--- /dev/null
+++ b/src/Mod/CAM/Path/Base/Generator/facing_common.py
@@ -0,0 +1,481 @@
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+"""
+Common helper functions for facing toolpath generators.
+
+This module contains shared geometric and utility functions used by
+different facing strategies (spiral, zigzag, etc.).
+"""
+
+import FreeCAD
+import Path
+import Part
+
+if False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+
+def extract_polygon_geometry(polygon):
+ """Extract edges and corners from a rectangular polygon."""
+ edges = []
+ corners = []
+
+ # Validate that polygon has exactly 4 edges (rectangle)
+ if len(polygon.Edges) != 4:
+ raise ValueError("Polygon must be rectangular (4 edges)")
+
+ for edge in polygon.Edges:
+ edge_vector = edge.Vertexes[1].Point.sub(edge.Vertexes[0].Point)
+ edges.append(
+ {
+ "start": edge.Vertexes[0].Point,
+ "end": edge.Vertexes[1].Point,
+ "vector": edge_vector,
+ "length": edge.Length,
+ }
+ )
+ corners.append(edge.Vertexes[0].Point)
+
+ return {"edges": edges, "corners": corners}
+
+
+def select_primary_step_edges(edges, axis_preference):
+ """Select primary and step edges based on axis preference."""
+ edge_lengths = [edge["length"] for edge in edges]
+ unique_lengths = list(set(edge_lengths))
+
+ if len(unique_lengths) == 1:
+ # Square case - all edges are the same length
+ # For squares, we need to pick two perpendicular edges
+ primary_edge = edges[0]
+ step_edge = None
+
+ for edge in edges[1:]:
+ # Check if this edge is perpendicular to the primary edge
+ dot_product = abs(primary_edge["vector"].normalize().dot(edge["vector"].normalize()))
+ if dot_product < 0.1: # Nearly perpendicular
+ step_edge = edge
+ break
+
+ if step_edge is None:
+ # Fallback - just use adjacent edges (should be perpendicular for rectangles)
+ step_edge = edges[1]
+ elif len(unique_lengths) == 2:
+ # Rectangle case - two different edge lengths
+ long_length = max(unique_lengths)
+ short_length = min(unique_lengths)
+
+ # Find edges with long and short lengths
+ long_edges = [edge for edge in edges if abs(edge["length"] - long_length) < 1e-6]
+ short_edges = [edge for edge in edges if abs(edge["length"] - short_length) < 1e-6]
+
+ # Select primary edge based on preference
+ if axis_preference == "long":
+ primary_edge = long_edges[0]
+ step_edge = short_edges[0]
+ else: # "short"
+ primary_edge = short_edges[0]
+ step_edge = long_edges[0]
+ else:
+ raise ValueError("Polygon must be rectangular with 1 or 2 unique edge lengths")
+
+ # Normalize vectors properly
+ primary_vec = primary_edge["vector"]
+ step_vec = step_edge["vector"]
+
+ # Manual normalization to ensure it works correctly
+ primary_length_calc = primary_vec.Length
+ step_length_calc = step_vec.Length
+
+ if primary_length_calc > 0:
+ primary_vec = primary_vec.multiply(1.0 / primary_length_calc)
+ if step_length_calc > 0:
+ step_vec = step_vec.multiply(1.0 / step_length_calc)
+
+ return {
+ "primary_edge": primary_edge,
+ "step_edge": step_edge,
+ "primary_vec": primary_vec,
+ "step_vec": step_vec,
+ "primary_length": primary_edge["length"],
+ "step_length": step_edge["length"],
+ }
+
+
+def select_starting_corner(corners, primary_vec, step_vec, milling_direction):
+ """
+ Select starting corner based on milling direction and edge orientation.
+
+ For climb milling (clockwise spiral), start from the corner with minimum
+ combined projection (bottom-left in the primary/step coordinate system).
+ For conventional milling (counter-clockwise spiral), start from the opposite corner.
+
+ Args:
+ corners (list): List of corner points from the polygon
+ primary_vec (FreeCAD.Vector): Primary direction vector (normalized)
+ step_vec (FreeCAD.Vector): Step direction vector (normalized)
+ milling_direction (str): "climb" or "conventional"
+
+ Returns:
+ FreeCAD.Vector: The selected starting corner point
+ """
+ if len(corners) < 4:
+ return corners[0]
+
+ # Find the corner with minimum combined projection in the primary/step coordinate system
+ # This is the "origin" corner in the rotated coordinate frame
+ min_projection = float("inf")
+ selected_corner = corners[0]
+
+ for corner in corners:
+ # Project corner onto the primary and step vectors
+ primary_proj = corner.dot(primary_vec)
+ step_proj = corner.dot(step_vec)
+
+ # Combined projection gives distance from origin in direction space
+ combined_proj = primary_proj + step_proj
+
+ if combined_proj < min_projection:
+ min_projection = combined_proj
+ selected_corner = corner
+
+ # For conventional milling, start from the diagonally opposite corner
+ if milling_direction == "conventional":
+ # Find the corner that's furthest from the selected corner (diagonal opposite)
+ max_distance = 0
+ opposite_corner = selected_corner
+
+ for corner in corners:
+ distance = selected_corner.distanceToPoint(corner)
+ if distance > max_distance:
+ max_distance = distance
+ opposite_corner = corner
+
+ selected_corner = opposite_corner
+
+ return selected_corner
+
+
+def get_angled_polygon(wire, angle):
+ """
+ Create a rotated bounding box that fully contains the input wire.
+
+ This function generates a rectangular wire representing a bounding box rotated by the
+ specified angle that completely encompasses the original wire. The algorithm works by:
+ 1. Rotating the original wire in the opposite direction to align it optimally
+ 2. Computing the axis-aligned bounding box of the rotated wire
+ 3. Rotating the bounding box back to the desired angle
+
+ Args:
+ wire (Part.Wire): A closed wire to create the rotated bounding box for
+ angle (float): Rotation angle in degrees (positive = counterclockwise)
+
+ Returns:
+ Part.Wire: A closed rectangular wire representing the rotated bounding box
+
+ Raises:
+ ValueError: If the input wire is not closed
+ """
+ if not wire.isClosed():
+ raise ValueError("Wire must be closed")
+
+ # Get the center point of the original wire for all rotations
+ center = wire.BoundBox.Center
+ rotation_axis = FreeCAD.Vector(0, 0, 1) # Z-axis
+
+ Path.Log.debug(f"Original wire center: {center}")
+
+ # Step 1: Rotate the wire in the opposite direction to align optimally with axes
+ temp_wire = wire.copy()
+ temp_wire.rotate(center, rotation_axis, -angle)
+
+ # Step 2: Get the axis-aligned bounding box of the rotated wire
+ bounding_box = temp_wire.BoundBox
+ Path.Log.debug(f"Rotated bounding box center: {bounding_box.Center}")
+
+ # Create the four corners of the bounding box rectangle
+ corners = [
+ FreeCAD.Vector(bounding_box.XMin, bounding_box.YMin, bounding_box.ZMin),
+ FreeCAD.Vector(bounding_box.XMax, bounding_box.YMin, bounding_box.ZMin),
+ FreeCAD.Vector(bounding_box.XMax, bounding_box.YMax, bounding_box.ZMin),
+ FreeCAD.Vector(bounding_box.XMin, bounding_box.YMax, bounding_box.ZMin),
+ ]
+
+ # Close the polygon by adding the first corner again
+ corners.append(corners[0])
+ bounding_wire = Part.makePolygon(corners)
+
+ # Step 3: Rotate the bounding box to the desired angle
+ bounding_wire.rotate(center, rotation_axis, angle)
+
+ return bounding_wire
+
+
+def calculate_engagement_offset(tool_diameter, stepover_percent):
+ """Calculate the engagement offset for proper tool engagement.
+
+ For 50% stepover, engagement should be 50% of tool diameter.
+ engagement_offset is how much of the tool is NOT engaged.
+ """
+ return tool_diameter * (1.0 - stepover_percent / 100.0)
+
+
+def validate_inputs(
+ wire,
+ tool_diameter,
+ stepover_percent,
+ start_depth,
+ final_depth,
+ start_point,
+ pattern,
+ milling_direction,
+):
+ """Validate all input parameters for facing operations."""
+
+ # Validate wire is closed
+ if not wire.isClosed():
+ raise ValueError("Wire must be a closed polygon for facing operation")
+
+ # Validate wire is co-planar with XY plane
+ bb = wire.BoundBox
+ z_tolerance = 0.001 # 1 micron tolerance
+ if abs(bb.ZMax - bb.ZMin) > z_tolerance:
+ raise ValueError("Wire must be co-planar with XY plane for facing operation")
+
+ # Validate tool diameter
+ if tool_diameter <= 0:
+ raise ValueError("Tool diameter must be positive")
+ if tool_diameter > 100: # Reasonable upper limit
+ raise ValueError("Tool diameter too large (>100mm)")
+
+ # Validate stepover percentage
+ if stepover_percent <= 0:
+ raise ValueError("Stepover percentage must be positive")
+ if stepover_percent > 100:
+ Path.Log.warning(f"Stepover percentage ({stepover_percent}%) is greater than 100%")
+ if stepover_percent > 200:
+ raise ValueError("Stepover percentage too large (>200%)")
+ if stepover_percent < 1:
+ Path.Log.warning(
+ f"Very small stepover percentage ({stepover_percent}%) may result in excessive cutting time"
+ )
+
+ # Validate depths
+ if start_depth == final_depth:
+ raise ValueError("Start depth must be different from final depth")
+
+ # Validate start point if provided
+ if start_point is not None:
+ if not hasattr(start_point, "x") or not hasattr(start_point, "y"):
+ raise ValueError("Start point must be a FreeCAD.Vector with x and y coordinates")
+
+ # Check if start point is too close to polygon (within bounding box + tool radius)
+ tool_radius = tool_diameter / 2.0
+ expanded_bb_xmin = bb.XMin - tool_radius
+ expanded_bb_xmax = bb.XMax + tool_radius
+ expanded_bb_ymin = bb.YMin - tool_radius
+ expanded_bb_ymax = bb.YMax + tool_radius
+
+ if (
+ expanded_bb_xmin <= start_point.x <= expanded_bb_xmax
+ and expanded_bb_ymin <= start_point.y <= expanded_bb_ymax
+ ):
+ raise ValueError("Start point is too close to the polygon to be cleared")
+
+ # Validate pattern
+ valid_patterns = ["zigzag", "unidirectional", "spiral"]
+ if pattern not in valid_patterns:
+ raise ValueError(f"Invalid pattern: {pattern}. Must be one of {valid_patterns}")
+
+ # Validate milling direction
+ valid_directions = ["climb", "conventional"]
+ if milling_direction not in valid_directions:
+ raise ValueError(
+ f"Invalid milling direction: {milling_direction}. Must be one of {valid_directions}"
+ )
+
+
+def align_edges_to_angle(primary_vec, step_vec, primary_length, step_length, angle_degrees):
+ """Ensure primary_vec aligns with the desired angle direction.
+
+ If the provided angle direction is closer to step_vec than primary_vec,
+ swap the vectors and their associated lengths so primary_vec matches the
+ intended cut direction. This prevents sudden flips around angles like 46°/90°.
+ """
+ if angle_degrees is None:
+ return primary_vec, step_vec, primary_length, step_length
+
+ # Build a unit vector for the angle in the XY plane (degrees)
+ import math
+
+ rad = math.radians(angle_degrees)
+ dir_vec = FreeCAD.Vector(math.cos(rad), math.sin(rad), 0)
+
+ # Compare absolute alignment; both primary_vec and step_vec should be normalized already by caller
+ # If not, normalize here safely.
+ def norm(v):
+ L = v.Length
+ return v if L == 0 else v.multiply(1.0 / L)
+
+ p = norm(primary_vec)
+ s = norm(step_vec)
+
+ if abs(dir_vec.dot(p)) >= abs(dir_vec.dot(s)):
+ return p, s, primary_length, step_length
+ else:
+ # Swap so primary follows requested angle direction
+ return s, p, step_length, primary_length
+
+
+def unit_vectors_from_angle(angle_degrees):
+ """Return (primary_vec, step_vec) unit vectors from angle in degrees in XY plane.
+
+ primary_vec points in the angle direction. step_vec is +90° rotation (left normal).
+ """
+ import math
+
+ rad = math.radians(angle_degrees)
+ p = FreeCAD.Vector(math.cos(rad), math.sin(rad), 0)
+ # +90° rotation
+ s = FreeCAD.Vector(-math.sin(rad), math.cos(rad), 0)
+ return p, s
+
+
+def project_bounds(wire, vec, origin):
+ """Project all vertices of wire onto vec relative to origin and return (min_t, max_t)."""
+ ts = []
+ for v in wire.Vertexes:
+ ts.append(vec.dot(v.Point.sub(origin)))
+ return (min(ts), max(ts))
+
+
+def generate_t_values(wire, step_vec, tool_diameter, stepover_percent, origin):
+ """Generate step positions along step_vec with engagement offset and stepover.
+
+ The first pass engages (100 - stepover_percent)% of the tool diameter.
+ For 50% stepover, first pass engages 50% of tool diameter.
+ Tool center is positioned so the engaged portion touches the polygon edge.
+ """
+ tool_radius = tool_diameter / 2.0
+ stepover = tool_diameter * (stepover_percent / 100.0)
+ min_t, max_t = project_bounds(wire, step_vec, origin)
+
+ # Calculate how much of the tool should engage on first pass
+ # For 20% stepover: engage 20% of diameter
+ # For 50% stepover: engage 50% of diameter
+ engagement_amount = tool_diameter * (stepover_percent / 100.0)
+
+ # Start position: tool center positioned so engagement_amount reaches polygon edge
+ # Tool center at: min_t - tool_radius + engagement_amount
+ # This positions the engaged portion at the polygon edge
+ t = min_t - tool_radius + engagement_amount
+ t_end = max_t + tool_radius - engagement_amount
+
+ values = []
+ # Guard against zero/negative stepover
+ if stepover <= 0:
+ return [t]
+ while t <= t_end + 1e-9:
+ values.append(t)
+ t += stepover
+ return values
+
+
+def slice_wire_segments(wire, primary_vec, step_vec, t, origin):
+ """Intersect the polygon wire with the infinite line at step coordinate t.
+
+ Returns a sorted list of (s_in, s_out) intervals along primary_vec within the polygon.
+ """
+ import math
+
+ # For diagnostics
+ bb = wire.BoundBox
+ diag = math.hypot(bb.XLength, bb.YLength)
+ s_debug_threshold = max(1.0, diag * 10.0)
+
+ s_vals = []
+ # Use scale-relative tolerance for near-parallel detection
+ eps_abs = 1e-12
+ t_scale = max(abs(t), diag, 1.0)
+ eps_parallel = max(eps_abs, t_scale * 1e-9)
+
+ # Iterate edges
+ for edge in wire.Edges:
+ A = FreeCAD.Vector(edge.Vertexes[0].Point)
+ B = FreeCAD.Vector(edge.Vertexes[1].Point)
+ a = step_vec.dot(A.sub(origin))
+ b = step_vec.dot(B.sub(origin))
+ da = a - t
+ db = b - t
+ # Check for crossing; ignore edges parallel to the step line (db == da)
+ denom = b - a
+ if not (math.isfinite(a) and math.isfinite(b) and math.isfinite(denom)):
+ continue
+ # Reject near-parallel edges using scale-relative tolerance
+ if abs(denom) < eps_parallel:
+ continue
+ # Crossing if signs differ or one is zero
+ if da == 0.0 and db == 0.0:
+ # Line coincident with edge: skip to avoid degenerate doubles
+ continue
+ if (da <= 0 and db >= 0) or (da >= 0 and db <= 0):
+ # Linear interpolation factor u from A to B
+ u = (t - a) / (b - a)
+ if not math.isfinite(u):
+ continue
+ # Strict bounds check: u must be in [0,1] with tight tolerance
+ # Reject if u is way outside - indicates numerical instability
+ if u < -0.01 or u > 1.01:
+ continue
+ # Clamp u to [0,1] for valid intersections
+ u = max(0.0, min(1.0, u))
+
+ # Interpolate using scalars to avoid any in-place vector mutation
+ ux = A.x + u * (B.x - A.x)
+ uy = A.y + u * (B.y - A.y)
+ uz = A.z + u * (B.z - A.z)
+ s = (
+ primary_vec.x * (ux - origin.x)
+ + primary_vec.y * (uy - origin.y)
+ + primary_vec.z * (uz - origin.z)
+ )
+
+ # Final sanity check: reject if s is absurdly large
+ if not math.isfinite(s) or abs(s) > diag * 100.0:
+ continue
+ s_vals.append(s)
+
+ # Sort and pair successive intersections into interior segments
+ s_vals.sort()
+ segments = []
+ for i in range(0, len(s_vals) - 1, 2):
+ s0 = s_vals[i]
+ s1 = s_vals[i + 1]
+ if s1 > s0 + 1e-9 and math.isfinite(s0) and math.isfinite(s1):
+ segments.append((s0, s1))
+ return segments
diff --git a/src/Mod/CAM/Path/Base/Generator/linking.py b/src/Mod/CAM/Path/Base/Generator/linking.py
new file mode 100644
index 0000000000..103904e08e
--- /dev/null
+++ b/src/Mod/CAM/Path/Base/Generator/linking.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+import Part
+import Path
+from FreeCAD import Vector
+from typing import List, Optional
+
+if False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+
+def get_linking_moves(
+ start_position: Vector,
+ target_position: Vector,
+ local_clearance: float,
+ global_clearance: float,
+ tool_shape: Part.Shape, # required placeholder
+ solids: Optional[List[Part.Shape]] = None,
+ retract_height_offset: Optional[float] = None,
+) -> list:
+ if start_position == target_position:
+ return []
+
+ if local_clearance > global_clearance:
+ raise ValueError("Local clearance must not exceed global clearance")
+
+ if retract_height_offset is not None and retract_height_offset < 0:
+ raise ValueError("Retract offset must be positive")
+
+ # Collision model
+ collision_model = None
+ if solids:
+ solids = [s for s in solids if s]
+ if len(solids) == 1:
+ collision_model = solids[0]
+ elif len(solids) > 1:
+ collision_model = Part.makeFuse(solids)
+
+ # Determine candidate heights
+ if retract_height_offset is not None:
+ if retract_height_offset > 0:
+ retract_height = max(start_position.z, target_position.z) + retract_height_offset
+ candidate_heights = {retract_height, local_clearance, global_clearance}
+ else: # explicitly 0
+ retract_height = max(start_position.z, target_position.z)
+ candidate_heights = {retract_height, local_clearance, global_clearance}
+ else:
+ candidate_heights = {local_clearance, global_clearance}
+
+ heights = sorted(candidate_heights, reverse=True)
+
+ # Try each height
+ for height in heights:
+ 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
+
+ raise RuntimeError("No collision-free path found between start and target positions")
+
+
+def make_linking_wire(start: Vector, target: Vector, z: float) -> Part.Wire:
+ p1 = Vector(start.x, start.y, z)
+ p2 = Vector(target.x, target.y, z)
+ e1 = Part.makeLine(start, p1)
+ e2 = Part.makeLine(p1, p2)
+ e3 = Part.makeLine(p2, target)
+ return Part.Wire([e1, e2, e3])
+
+
+def is_wire_collision_free(
+ wire: Part.Wire, solid: Optional[Part.Shape], tolerance: float = 0.001
+) -> bool:
+ if not solid:
+ return True
+ distance = wire.distToShape(solid)[0]
+ return distance >= tolerance
diff --git a/src/Mod/CAM/Path/Base/Generator/spiral_facing.py b/src/Mod/CAM/Path/Base/Generator/spiral_facing.py
new file mode 100644
index 0000000000..7944933f32
--- /dev/null
+++ b/src/Mod/CAM/Path/Base/Generator/spiral_facing.py
@@ -0,0 +1,319 @@
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+"""
+Spiral facing toolpath generator.
+
+This module implements the spiral clearing pattern for rectangular polygons,
+including support for angled rectangles and proper tool engagement.
+"""
+
+import FreeCAD
+import Path
+from . import facing_common
+
+if False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+
+def generate_spiral_corners(
+ start_corner, primary_vec, step_vec, primary_length, step_length, inward_offset
+):
+ """
+ Generate the four corners of a spiral layer offset inward from the original polygon.
+
+ The start_corner is assumed to be the corner with minimum combined projection
+ (bottom-left in the primary/step coordinate system). The offset moves inward
+ by adding positive offsets in both primary and step directions.
+ """
+ # Calculate the four corners of this layer (reduced by inward offset)
+ adjusted_primary_length = max(0, primary_length - 2 * inward_offset)
+ adjusted_step_length = max(0, step_length - 2 * inward_offset)
+
+ # Move the starting corner inward by the offset amount
+ # Since start_corner is the minimum projection corner, we move inward by adding offsets
+ inward_primary = FreeCAD.Vector(primary_vec).multiply(inward_offset)
+ inward_step = FreeCAD.Vector(step_vec).multiply(inward_offset)
+
+ # The actual starting corner for this layer is offset inward
+ layer_start_corner = FreeCAD.Vector(start_corner).add(inward_primary).add(inward_step)
+
+ # Build rectangle from the offset starting corner with reduced dimensions
+ corner1 = FreeCAD.Vector(layer_start_corner)
+ corner2 = FreeCAD.Vector(corner1).add(
+ FreeCAD.Vector(primary_vec).multiply(adjusted_primary_length)
+ )
+ corner3 = FreeCAD.Vector(corner2).add(FreeCAD.Vector(step_vec).multiply(adjusted_step_length))
+ corner4 = FreeCAD.Vector(corner3).add(
+ FreeCAD.Vector(primary_vec).multiply(-adjusted_primary_length)
+ )
+
+ return [corner1, corner2, corner3, corner4]
+
+
+def generate_layer_path(
+ layer_corners,
+ next_layer_start,
+ layer_num,
+ z,
+ clockwise,
+ start_corner_index=0,
+ is_last_layer=False,
+):
+ """
+ Generate the toolpath commands for a single spiral layer.
+
+ For a true spiral, we do all 4 sides of the rectangle, but the 4th side only goes
+ partway - it stops at the starting position of the next layer. This creates the
+ continuous spiral effect.
+ """
+ commands = []
+
+ # Set Z coordinate for all corners
+ for corner in layer_corners:
+ corner.z = z
+
+ # For the first layer, start with a rapid move to the starting corner
+ if layer_num == 0:
+ commands.append(
+ Path.Command(
+ "G0",
+ {
+ "X": layer_corners[start_corner_index].x,
+ "Y": layer_corners[start_corner_index].y,
+ "Z": z,
+ },
+ )
+ )
+
+ # Generate the path: go around all 4 sides
+ if clockwise:
+ # Clockwise: start_corner -> corner 1 -> corner 2 -> corner 3 -> back toward start
+ for i in range(1, 4):
+ corner_idx = (start_corner_index + i) % 4
+ commands.append(
+ Path.Command(
+ "G1",
+ {"X": layer_corners[corner_idx].x, "Y": layer_corners[corner_idx].y, "Z": z},
+ )
+ )
+
+ # 4th side: go back toward start, but stop at next layer's starting position
+ if not is_last_layer and next_layer_start:
+ next_layer_start.z = z
+ commands.append(
+ Path.Command("G1", {"X": next_layer_start.x, "Y": next_layer_start.y, "Z": z})
+ )
+ else:
+ # Last layer: complete the rectangle
+ commands.append(
+ Path.Command(
+ "G1",
+ {
+ "X": layer_corners[start_corner_index].x,
+ "Y": layer_corners[start_corner_index].y,
+ "Z": z,
+ },
+ )
+ )
+ else:
+ # Counter-clockwise: start_corner -> corner 3 -> corner 2 -> corner 1 -> back toward start
+ for i in range(1, 4):
+ corner_idx = (start_corner_index - i) % 4
+ commands.append(
+ Path.Command(
+ "G1",
+ {"X": layer_corners[corner_idx].x, "Y": layer_corners[corner_idx].y, "Z": z},
+ )
+ )
+
+ # 4th side: go back toward start, but stop at next layer's starting position
+ if not is_last_layer and next_layer_start:
+ next_layer_start.z = z
+ commands.append(
+ Path.Command("G1", {"X": next_layer_start.x, "Y": next_layer_start.y, "Z": z})
+ )
+ else:
+ # Last layer: complete the rectangle
+ commands.append(
+ Path.Command(
+ "G1",
+ {
+ "X": layer_corners[start_corner_index].x,
+ "Y": layer_corners[start_corner_index].y,
+ "Z": z,
+ },
+ )
+ )
+
+ return commands
+
+
+def spiral(
+ polygon,
+ tool_diameter,
+ stepover_percent,
+ milling_direction="climb",
+ reverse=False,
+ angle_degrees=None,
+):
+ """
+ Generate a spiral clearing pattern for rectangular polygons with guaranteed full coverage.
+
+ The radial stepover is automatically adjusted (slightly if required to ensure the tool edge reaches exactly the center
+ in the limiting direction. This eliminates any uncleared areas in the center regardless of stepover% value.
+ First engagement is preserved exactly at the requested percentage.
+ """
+ import math
+ import Path
+ import FreeCAD
+ from . import facing_common
+
+ theta = float(angle_degrees) if angle_degrees is not None else 0.0
+ primary_vec, step_vec = facing_common.unit_vectors_from_angle(theta)
+ primary_vec = FreeCAD.Vector(primary_vec).normalize()
+ step_vec = FreeCAD.Vector(step_vec).normalize()
+
+ polygon_info = facing_common.extract_polygon_geometry(polygon)
+ corners = polygon_info["corners"]
+
+ origin = facing_common.select_starting_corner(corners, primary_vec, step_vec, "climb")
+ min_s, max_s = facing_common.project_bounds(polygon, primary_vec, origin)
+ min_t, max_t = facing_common.project_bounds(polygon, step_vec, origin)
+
+ primary_length = max_s - min_s
+ step_length = max_t - min_t
+
+ tool_radius = tool_diameter / 2.0
+ stepover_dist = tool_diameter * (stepover_percent / 100.0)
+ if stepover_dist > tool_diameter * 1.000001:
+ stepover_dist = tool_diameter
+
+ # Calculate adjusted stepover to guarantee center coverage
+ starting_inset = tool_radius - stepover_dist
+ limiting_half = min(primary_length, step_length) / 2.0
+ total_radial_distance = limiting_half - tool_radius - starting_inset
+
+ if total_radial_distance <= 0:
+ actual_stepover = stepover_dist
+ else:
+ number_of_intervals = math.ceil(total_radial_distance / stepover_dist)
+ actual_stepover = total_radial_distance / number_of_intervals
+
+ Path.Log.debug(
+ f"Spiral: adjusted stepover {stepover_dist:.4f} → {actual_stepover:.4f} mm, intervals={number_of_intervals if total_radial_distance > 0 else 0}"
+ )
+
+ # Standard initial_offset (preserves first engagement exactly)
+ initial_offset = -tool_radius + stepover_dist
+
+ z = polygon.BoundBox.ZMin
+
+ clockwise = milling_direction == "conventional"
+
+ start_corner_index = 0 if clockwise else 2
+ if reverse:
+ start_corner_index = (start_corner_index + 2) % 4
+
+ commands = []
+ k = 0
+ first_move_done = False
+
+ while True:
+ current_offset = initial_offset + k * actual_stepover
+
+ s0 = min_s + current_offset
+ s1 = max_s - current_offset
+ t0 = min_t + current_offset
+ t1 = max_t - current_offset
+
+ if s0 >= s1 or t0 >= t1:
+ break
+
+ corners_st = [(s0, t0), (s1, t0), (s1, t1), (s0, t1)]
+
+ if clockwise:
+ order = [(start_corner_index + i) % 4 for i in range(4)]
+ else:
+ order = [(start_corner_index - i) % 4 for i in range(4)]
+
+ def st_to_xy(s, t):
+ return origin + primary_vec * s + step_vec * t
+
+ start_idx = order[0]
+ start_xy = st_to_xy(*corners_st[start_idx])
+ start_xy.z = z
+
+ if not first_move_done:
+ commands.append(Path.Command("G0", {"X": start_xy.x, "Y": start_xy.y, "Z": z}))
+ first_move_done = True
+
+ # Sides 1-3: full
+ for i in range(1, 4):
+ c_xy = st_to_xy(*corners_st[order[i]])
+ c_xy.z = z
+ commands.append(Path.Command("G1", {"X": c_xy.x, "Y": c_xy.y, "Z": z}))
+
+ # Prepare transition to next layer (partial 4th side)
+ next_offset = current_offset + actual_stepover
+ s0n = min_s + next_offset
+ s1n = max_s - next_offset
+ t0n = min_t + next_offset
+ t1n = max_t - next_offset
+
+ if s0n < s1n and t0n < t1n:
+ # Determine which edge we are on for the 4th side and compute intersection
+ # the transition point on that edge
+ if clockwise:
+ if start_corner_index == 0:
+ transition_xy = st_to_xy(s0, t0n)
+ elif start_corner_index == 1:
+ transition_xy = st_to_xy(s1n, t0)
+ elif start_corner_index == 2:
+ transition_xy = st_to_xy(s1, t1n)
+ else:
+ transition_xy = st_to_xy(s0n, t1)
+ else: # counter-clockwise
+ if start_corner_index == 0:
+ transition_xy = st_to_xy(s0n, t0)
+ elif start_corner_index == 1:
+ transition_xy = st_to_xy(s1, t0n)
+ elif start_corner_index == 2:
+ transition_xy = st_to_xy(s1n, t1)
+ else:
+ transition_xy = st_to_xy(s0, t1n)
+
+ transition_xy.z = z
+ commands.append(
+ Path.Command("G1", {"X": transition_xy.x, "Y": transition_xy.y, "Z": z})
+ )
+ k += 1
+ else:
+ # Final layer - close back to start
+ commands.append(Path.Command("G1", {"X": start_xy.x, "Y": start_xy.y, "Z": z}))
+ break
+
+ return commands
diff --git a/src/Mod/CAM/Path/Base/Generator/zigzag_facing.py b/src/Mod/CAM/Path/Base/Generator/zigzag_facing.py
new file mode 100644
index 0000000000..d718bc4d14
--- /dev/null
+++ b/src/Mod/CAM/Path/Base/Generator/zigzag_facing.py
@@ -0,0 +1,329 @@
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+"""
+Zigzag facing toolpath generator.
+
+This module implements the zigzag clearing pattern that cuts back and forth
+across the polygon in alternating directions, creating a continuous zigzag pattern.
+"""
+
+import FreeCAD
+import Path
+from . import facing_common
+
+if False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+
+def _create_link(
+ prev_seg, next_seg, link_mode, link_radius, stepover_distance, tool_radius, primary_vec, z
+):
+ """
+ Create linking moves between two segments.
+
+ Args:
+ prev_seg: Previous segment dict with 'end', 'side', 't' keys
+ next_seg: Next segment dict with 'start', 'side', 't' keys
+ link_mode: "arc" or "straight"
+ link_radius: Radius for arc links (None = auto)
+ stepover_distance: Distance between passes
+ tool_radius: Tool radius
+ primary_vec: Primary direction vector
+ z: Z height
+
+ Returns:
+ List of Path.Command objects for the link
+ """
+ import math
+
+ P = prev_seg["end"]
+ Q = next_seg["start"]
+
+ # Safety checks
+ if not (
+ math.isfinite(P.x) and math.isfinite(P.y) and math.isfinite(Q.x) and math.isfinite(Q.y)
+ ):
+ return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
+
+ # Check if we should use arc mode
+ if link_mode != "arc":
+ return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
+
+ # Calculate chord vector and distance
+ dx = Q.x - P.x
+ dy = Q.y - P.y
+ chord_length = math.sqrt(dx * dx + dy * dy)
+
+ # Minimum chord length check
+ if chord_length < 1e-6:
+ return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
+
+ # Natural semicircle radius for 180° arc
+ r0 = chord_length / 2.0
+
+ # Use specified radius or default to natural radius
+ if link_radius is not None and link_radius > r0:
+ r = link_radius
+ else:
+ r = r0
+
+ # Minimum radius check
+ if r < 1e-6:
+ return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
+
+ # Calculate arc center
+ # Midpoint of chord
+ mx = 0.5 * (P.x + Q.x)
+ my = 0.5 * (P.y + Q.y)
+
+ # Normal to chord (rotate chord by 90°)
+ # Two options: rotate left (-dy, dx) or rotate right (dy, -dx)
+ # For zigzag, we need the arc to bulge in the primary direction away from center
+ # Use cross product to determine which way to rotate the chord
+
+ # The arc should bulge in the direction perpendicular to the chord
+ # and in the same primary direction as the segment side
+ # For vertical chords (dy != 0, dx ≈ 0), normal is horizontal
+ # For horizontal chords (dx != 0, dy ≈ 0), normal is vertical
+
+ # Calculate both possible normals (90° rotations of chord)
+ n1x = -dy / chord_length # Rotate left
+ n1y = dx / chord_length
+ n2x = dy / chord_length # Rotate right
+ n2y = -dx / chord_length
+
+ # Choose normal based on which side we're on
+ # The arc should bulge in the primary direction indicated by prev_seg['side']
+ outward_x = primary_vec.x * prev_seg["side"]
+ outward_y = primary_vec.y * prev_seg["side"]
+
+ dot1 = n1x * outward_x + n1y * outward_y
+ dot2 = n2x * outward_x + n2y * outward_y
+
+ if dot1 > dot2:
+ nx, ny = n1x, n1y
+ Path.Log.debug(f" Chose n1: ({nx:.3f}, {ny:.3f}), dot1={dot1:.3f} > dot2={dot2:.3f}")
+ else:
+ nx, ny = n2x, n2y
+ Path.Log.debug(f" Chose n2: ({nx:.3f}, {ny:.3f}), dot2={dot2:.3f} > dot1={dot1:.3f}")
+
+ Path.Log.debug(
+ f" Chord: dx={dx:.3f}, dy={dy:.3f}, side={prev_seg['side']}, outward=({outward_x:.3f},{outward_y:.3f})"
+ )
+
+ # Calculate offset distance for the arc center from the chord midpoint
+ # Geometry: For an arc with radius r connecting two points separated by chord length 2*r0,
+ # the center must be perpendicular to the chord at distance offset from the midpoint,
+ # where: r^2 = r0^2 + offset^2 (Pythagorean theorem)
+ # Therefore: offset = sqrt(r^2 - r0^2)
+ #
+ # For r = r0 (minimum possible radius): offset = 0 (semicircle, 180° arc)
+ # For r > r0: offset > 0 (less than semicircle)
+ # For nice smooth arcs, we could use r = 2*r0, giving offset = sqrt(3)*r0
+
+ if r >= r0:
+ offset = math.sqrt(r * r - r0 * r0)
+ else:
+ # Radius too small to connect endpoints - shouldn't happen but handle gracefully
+ offset = 0.0
+
+ # Arc center
+ cx = mx + nx * offset
+ cy = my + ny * offset
+
+ # Verify center is finite
+ if not (math.isfinite(cx) and math.isfinite(cy)):
+ return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
+
+ # Determine arc direction (G2=CW, G3=CCW)
+ # For semicircles where center is on the chord, cross product is unreliable
+ # Instead, use the normal direction and chord direction to determine arc sense
+ # The arc goes from P to Q, bulging in direction (nx, ny)
+ # Cross product of chord direction with normal gives us the arc direction
+ # chord × normal = (dx, dy, 0) × (nx, ny, 0) = (0, 0, dx*ny - dy*nx)
+ z_cross = dx * ny - dy * nx
+
+ Path.Log.debug(f" z_cross = {dx:.3f}*{ny:.3f} - {dy:.3f}*{nx:.3f} = {z_cross:.3f}")
+
+ # Invert the logic - positive cross product means clockwise for our convention
+ if z_cross < 0:
+ arc_cmd = "G3" # Counter-clockwise
+ else:
+ arc_cmd = "G2" # Clockwise
+
+ # Calculate IJ (relative to start point P)
+ I = cx - P.x
+ J = cy - P.y
+
+ # Verify IJ are finite
+ if not (math.isfinite(I) and math.isfinite(J)):
+ return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
+
+ Path.Log.debug(
+ f"Arc link: P=({P.x:.3f},{P.y:.3f}) Q=({Q.x:.3f},{Q.y:.3f}) "
+ f"C=({cx:.3f},{cy:.3f}) r={r:.3f} {arc_cmd} I={I:.3f} J={J:.3f}"
+ )
+
+ # K=0 for XY plane arcs - use string format to ensure I, J, K are preserved
+ cmd_string = f"{arc_cmd} I{I:.6f} J{J:.6f} K0.0 X{Q.x:.6f} Y{Q.y:.6f} Z{z:.6f}"
+ return [Path.Command(cmd_string)]
+
+
+def zigzag(
+ polygon,
+ tool_diameter,
+ stepover_percent,
+ pass_extension=None,
+ retract_height=None,
+ milling_direction="climb",
+ reverse=False,
+ angle_degrees=None,
+ link_mode="arc",
+ link_radius=None,
+):
+
+ if pass_extension is None:
+ pass_extension = tool_diameter * 0.5
+
+ import math
+
+ theta = float(angle_degrees) if angle_degrees is not None else 0.0
+ primary_vec, step_vec = facing_common.unit_vectors_from_angle(theta)
+ primary_vec = FreeCAD.Vector(primary_vec).normalize()
+ step_vec = FreeCAD.Vector(step_vec).normalize()
+
+ origin = polygon.BoundBox.Center
+ z = polygon.BoundBox.ZMin
+
+ min_s, max_s = facing_common.project_bounds(polygon, primary_vec, origin)
+ min_t, max_t = facing_common.project_bounds(polygon, step_vec, origin)
+
+ if not (
+ math.isfinite(min_s)
+ and math.isfinite(max_s)
+ and math.isfinite(min_t)
+ and math.isfinite(max_t)
+ ):
+ Path.Log.error("Zigzag: non-finite projection bounds; aborting")
+ return []
+
+ # === Use exactly the same step position generation as bidirectional and directional ===
+ step_positions = facing_common.generate_t_values(
+ polygon, step_vec, tool_diameter, stepover_percent, origin
+ )
+
+ tool_radius = tool_diameter / 2.0
+ stepover_distance = tool_diameter * (stepover_percent / 100.0)
+
+ # Guarantee full coverage at high stepover – identical to bidirectional/directional
+ if stepover_percent >= 99.9 and step_positions:
+ min_covered = min(step_positions) - tool_radius
+ max_covered = max(step_positions) + tool_radius
+
+ added = False
+ if max_covered < max_t - 1e-4:
+ step_positions.append(step_positions[-1] + stepover_distance)
+ added = True
+ if min_covered > min_t + 1e-4:
+ step_positions.insert(0, step_positions[0] - stepover_distance)
+ added = True
+ if added:
+ Path.Log.info("Zigzag: Added extra pass(es) for full coverage at ≥100% stepover")
+
+ # Reverse only reverses traversal order (same positions set as reverse=False, identical coverage)
+ if reverse:
+ step_positions = step_positions[::-1]
+
+ Path.Log.debug(
+ f"Zigzag: {len(step_positions)} passes generated (now identical to bidirectional)"
+ )
+
+ # Determine if first pass should cut negative primary direction to maintain climb/conventional preference
+ base_negative = (
+ milling_direction == "climb"
+ ) ^ reverse # True → negative primary for first pass
+
+ total_extension = (
+ pass_extension
+ + tool_radius
+ + facing_common.calculate_engagement_offset(tool_diameter, stepover_percent)
+ )
+ start_s = min_s - total_extension
+ end_s = max_s + total_extension
+ s_mid = (min_s + max_s) / 2.0
+
+ segments = []
+
+ for idx, t in enumerate(step_positions):
+ current_negative = base_negative if (idx % 2 == 0) else not base_negative
+
+ if current_negative:
+ p_start = end_s
+ p_end = start_s
+ else:
+ p_start = start_s
+ p_end = end_s
+
+ start_point = origin + primary_vec * p_start + step_vec * t
+ end_point = origin + primary_vec * p_end + step_vec * t
+ start_point.z = z
+ end_point.z = z
+
+ side = 1 if p_end > s_mid - 1e-6 else -1 # slightly more tolerant comparison
+
+ segments.append(
+ {
+ "t": t,
+ "side": side,
+ "start": start_point,
+ "end": end_point,
+ "s_start": p_start,
+ "s_end": p_end,
+ }
+ )
+
+ commands = []
+ for i, seg in enumerate(segments):
+ if i == 0:
+ commands.append(Path.Command("G0", {"X": seg["start"].x, "Y": seg["start"].y, "Z": z}))
+ else:
+ prev_seg = segments[i - 1]
+ link_commands = _create_link(
+ prev_seg,
+ seg,
+ link_mode,
+ link_radius,
+ stepover_distance,
+ tool_radius,
+ primary_vec,
+ z,
+ )
+ commands.extend(link_commands)
+
+ commands.append(Path.Command("G1", {"X": seg["end"].x, "Y": seg["end"].y, "Z": z}))
+
+ return commands
diff --git a/src/Mod/CAM/Path/GuiInit.py b/src/Mod/CAM/Path/GuiInit.py
index 51fdb65abf..8dd4e3e1d5 100644
--- a/src/Mod/CAM/Path/GuiInit.py
+++ b/src/Mod/CAM/Path/GuiInit.py
@@ -68,6 +68,7 @@ def Startup():
from Path.Op.Gui import Engrave
from Path.Op.Gui import Helix
from Path.Op.Gui import MillFace
+ from Path.Op.Gui import MillFacing
from Path.Op.Gui import PathShapeTC
from Path.Op.Gui import Pocket
from Path.Op.Gui import PocketShape
diff --git a/src/Mod/CAM/Path/Op/Base.py b/src/Mod/CAM/Path/Op/Base.py
index edbee54f90..9ad9509f38 100644
--- a/src/Mod/CAM/Path/Op/Base.py
+++ b/src/Mod/CAM/Path/Op/Base.py
@@ -841,6 +841,219 @@ class ObjectOp(object):
return True
+class Compass:
+ """
+ A compass is a tool to help with direction so the Compass is a helper
+ class to manage settings that affect tool and spindle direction.
+
+ Settings managed:
+ - Spindle Direction: Forward / Reverse / None
+ - Cut Side: Inside / Outside (for perimeter operations)
+ - Cut Mode: Climb / Conventional
+ - Path Direction: CW / CCW (derived for perimeter operations)
+ - Operation Type: Perimeter / Area (for facing/pocketing operations)
+
+ This class allows the user to set and get any of these properties and the rest will update accordingly.
+ Supports both perimeter operations (profiling) and area operations (facing, pocketing).
+
+ Args:
+ spindle_direction: "Forward", "Reverse", or "None"
+ operation_type: "Perimeter" or "Area" (defaults to "Perimeter")
+ """
+
+ FORWARD = "Forward"
+ REVERSE = "Reverse"
+ NONE = "None"
+ CW = "CW"
+ CCW = "CCW"
+ CLIMB = "Climb"
+ CONVENTIONAL = "Conventional"
+ INSIDE = "Inside"
+ OUTSIDE = "Outside"
+ PERIMETER = "Perimeter"
+ AREA = "Area"
+
+ def __init__(self, spindle_direction, operation_type=None):
+ self._spindle_dir = (
+ spindle_direction
+ if spindle_direction in (self.FORWARD, self.REVERSE, self.NONE)
+ else self.NONE
+ )
+ self._cut_side = self.OUTSIDE
+ self._cut_mode = self.CLIMB
+ self._operation_type = (
+ operation_type or self.PERIMETER
+ ) # Default to perimeter for backward compatibility
+ self._path_dir = self._calculate_path_dir()
+
+ @property
+ def spindle_dir(self):
+ return self._spindle_dir
+
+ @spindle_dir.setter
+ def spindle_dir(self, value):
+ if value in (self.FORWARD, self.REVERSE, self.NONE):
+ self._spindle_dir = value
+ self._path_dir = self._calculate_path_dir()
+ else:
+ self._spindle_dir = self.NONE
+ self._path_dir = self._calculate_path_dir()
+
+ @property
+ def cut_side(self):
+ return self._cut_side
+
+ @cut_side.setter
+ def cut_side(self, value):
+ self._cut_side = value.capitalize()
+ self._path_dir = self._calculate_path_dir()
+
+ @property
+ def cut_mode(self):
+ return self._cut_mode
+
+ @cut_mode.setter
+ def cut_mode(self, value):
+ self._cut_mode = value.capitalize()
+ self._path_dir = self._calculate_path_dir()
+
+ @property
+ def operation_type(self):
+ return self._operation_type
+
+ @operation_type.setter
+ def operation_type(self, value):
+ self._operation_type = value.capitalize()
+ self._path_dir = self._calculate_path_dir()
+
+ @property
+ def path_dir(self):
+ return self._path_dir
+
+ def _calculate_path_dir(self):
+ if self.spindle_dir == self.NONE:
+ return "UNKNOWN"
+
+ # For area operations (facing, pocketing), path direction is not applicable
+ if self._operation_type == self.AREA:
+ return "N/A"
+
+ spindle_rotation = self._rotation_from_spindle(self.spindle_dir)
+
+ for candidate in (self.CW, self.CCW):
+ mode = self._expected_cut_mode(self._cut_side, spindle_rotation, candidate)
+ if mode == self._cut_mode:
+ return candidate
+
+ return "UNKNOWN"
+
+ def _rotation_from_spindle(self, direction):
+ return self.CW if direction == self.FORWARD else self.CCW
+
+ def _expected_cut_mode(self, cut_side, spindle_rotation, path_dir):
+ lookup = {
+ (self.INSIDE, self.CW, self.CCW): self.CLIMB,
+ (self.INSIDE, self.CCW, self.CW): self.CLIMB,
+ (self.OUTSIDE, self.CW, self.CW): self.CLIMB,
+ (self.OUTSIDE, self.CCW, self.CCW): self.CLIMB,
+ }
+ return lookup.get((cut_side, spindle_rotation, path_dir), self.CONVENTIONAL)
+
+ def get_step_direction(self, approach_direction):
+ """
+ For area operations, determine the step direction for climb/conventional milling.
+
+ Args:
+ approach_direction: "X+", "X-", "Y+", "Y-" - the primary cutting direction
+
+ Returns:
+ True if steps should be in positive direction, False for negative direction
+ """
+ if self._operation_type != self.AREA:
+ raise ValueError("Step direction is only applicable for area operations")
+
+ if self.spindle_dir == self.NONE:
+ return True # Default to positive direction
+
+ spindle_rotation = self._rotation_from_spindle(self.spindle_dir)
+
+ # For area operations, climb/conventional depends on relationship between
+ # spindle rotation, approach direction, and step direction
+ if approach_direction in ["X-", "X+"]:
+ # Stepping in Y direction
+ if self._cut_mode == self.CLIMB:
+ # Climb: step direction matches spindle for X- approach
+ return (approach_direction == "X-") == (spindle_rotation == self.CW)
+ else: # Conventional
+ # Conventional: step direction opposite to spindle for X- approach
+ return (approach_direction == "X-") != (spindle_rotation == self.CW)
+ else: # Y approach
+ # Stepping in X direction
+ if self._cut_mode == self.CLIMB:
+ # Climb: step direction matches spindle for Y- approach
+ return (approach_direction == "Y-") == (spindle_rotation == self.CW)
+ else: # Conventional
+ # Conventional: step direction opposite to spindle for Y- approach
+ return (approach_direction == "Y-") != (spindle_rotation == self.CW)
+
+ def get_cutting_direction(self, approach_direction, pass_index=0, pattern="zigzag"):
+ """
+ For area operations, determine the cutting direction for each pass.
+
+ Args:
+ approach_direction: "X+", "X-", "Y+", "Y-" - the primary cutting direction
+ pass_index: Index of the current pass (0-based)
+ pattern: "zigzag", "unidirectional", "spiral"
+
+ Returns:
+ True if cutting should be in forward direction, False for reverse
+ """
+ if self._operation_type != self.AREA:
+ raise ValueError("Cutting direction is only applicable for area operations")
+
+ if self.spindle_dir == self.NONE:
+ return True # Default to forward direction
+
+ spindle_rotation = self._rotation_from_spindle(self.spindle_dir)
+
+ # Determine base cutting direction for climb/conventional
+ if approach_direction in ["X-", "X+"]:
+ # Cutting along Y axis
+ if self._cut_mode == self.CLIMB:
+ base_forward = (approach_direction == "X-") == (spindle_rotation == self.CW)
+ else: # Conventional
+ base_forward = (approach_direction == "X-") != (spindle_rotation == self.CW)
+ else: # Y approach
+ # Cutting along X axis
+ if self._cut_mode == self.CLIMB:
+ base_forward = (approach_direction == "Y-") == (spindle_rotation == self.CW)
+ else: # Conventional
+ base_forward = (approach_direction == "Y-") != (spindle_rotation == self.CW)
+
+ # Apply pattern modifications
+ if pattern == "zigzag" and pass_index % 2 == 1:
+ base_forward = not base_forward
+ elif pattern == "unidirectional":
+ # Always same direction
+ pass
+
+ return base_forward
+
+ def report(self):
+ report_data = {
+ "spindle_dir": self.spindle_dir,
+ "cut_side": self.cut_side,
+ "cut_mode": self.cut_mode,
+ "operation_type": self.operation_type,
+ "path_dir": self.path_dir,
+ }
+
+ Path.Log.debug("Machining Compass config:")
+ for k, v in report_data.items():
+ Path.Log.debug(f" {k:15s}: {v}")
+ return report_data
+
+
def getCycleTimeEstimate(obj):
tc = obj.ToolController
diff --git a/src/Mod/CAM/Path/Op/Gui/MillFacing.py b/src/Mod/CAM/Path/Op/Gui/MillFacing.py
new file mode 100644
index 0000000000..fa6ef44bf3
--- /dev/null
+++ b/src/Mod/CAM/Path/Op/Gui/MillFacing.py
@@ -0,0 +1,172 @@
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+from PySide.QtCore import QT_TRANSLATE_NOOP
+import FreeCAD
+import Path
+import Path.Base.Gui.Util as PathGuiUtil
+import Path.Op.Gui.Base as PathOpGui
+import Path.Op.MillFacing as PathMillFacing
+import FreeCADGui
+
+__title__ = "CAM Mill Facing Operation UI"
+__author__ = "sliptonic (Brad Collette)"
+__url__ = "https://www.freecad.org"
+__doc__ = "Mill Facing operation page controller and command implementation."
+
+if False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+
+class TaskPanelOpPage(PathOpGui.TaskPanelPage):
+ """Page controller class for the mill facing operation."""
+
+ def initPage(self, obj):
+ """initPage(obj) ... Initialize page with QuantitySpinBox wrappers for expression support"""
+ self.axialStockToLeaveSpinBox = PathGuiUtil.QuantitySpinBox(
+ self.form.axialStockToLeave, obj, "AxialStockToLeave"
+ )
+ if hasattr(obj, "PassExtension"):
+ self.passExtensionSpinBox = PathGuiUtil.QuantitySpinBox(
+ self.form.passExtension, obj, "PassExtension"
+ )
+ if hasattr(obj, "StockExtension"):
+ self.stockExtensionSpinBox = PathGuiUtil.QuantitySpinBox(
+ self.form.stockExtension, obj, "StockExtension"
+ )
+
+ def getForm(self):
+ Path.Log.track()
+ """getForm() ... return UI"""
+
+ form = FreeCADGui.PySideUic.loadUi(":/panels/PageOpMillFacingEdit.ui")
+ comboToPropertyMap = [
+ ("cutMode", "CutMode"),
+ ("clearingPattern", "ClearingPattern"),
+ ]
+
+ enumTups = PathMillFacing.ObjectMillFacing.propertyEnumerations(dataType="raw")
+ PathGuiUtil.populateCombobox(form, enumTups, comboToPropertyMap)
+ return form
+
+ def getFields(self, obj):
+ """getFields(obj) ... transfers values from UI to obj's properties"""
+ self.updateToolController(obj, self.form.toolController)
+ self.updateCoolant(obj, self.form.coolantController)
+
+ if obj.CutMode != str(self.form.cutMode.currentData()):
+ obj.CutMode = str(self.form.cutMode.currentData())
+
+ if obj.ClearingPattern != str(self.form.clearingPattern.currentData()):
+ obj.ClearingPattern = str(self.form.clearingPattern.currentData())
+
+ # Reverse checkbox
+ if hasattr(obj, "Reverse") and obj.Reverse != self.form.reverse.isChecked():
+ obj.Reverse = self.form.reverse.isChecked()
+
+ # Angle is a PropertyAngle (quantity). Compare/update by value.
+ if getattr(obj.Angle, "Value", obj.Angle) != self.form.angle.value():
+ obj.Angle = self.form.angle.value()
+
+ # StepOver is an App::PropertyPercent; assign an int percentage value
+ step_over_val = int(self.form.stepOver.value())
+ if obj.StepOver != step_over_val:
+ obj.StepOver = step_over_val
+
+ # AxialStockToLeave and PassExtension are handled by QuantitySpinBox wrappers
+ self.axialStockToLeaveSpinBox.updateProperty()
+ if hasattr(obj, "PassExtension"):
+ self.passExtensionSpinBox.updateProperty()
+ if hasattr(obj, "StockExtension"):
+ self.stockExtensionSpinBox.updateProperty()
+
+ def setFields(self, obj):
+ """setFields(obj) ... transfers obj's property values to UI"""
+ self.setupToolController(obj, self.form.toolController)
+ self.setupCoolant(obj, self.form.coolantController)
+
+ # Reflect current CutMode and ClearingPattern in UI
+ self.selectInComboBox(obj.CutMode, self.form.cutMode)
+ self.selectInComboBox(obj.ClearingPattern, self.form.clearingPattern)
+
+ # Handle new properties that may not exist in older operations
+ if hasattr(obj, "Reverse"):
+ self.form.reverse.setChecked(bool(obj.Reverse))
+ else:
+ self.form.reverse.setChecked(False)
+
+ # Angle is a quantity; set spinbox with numeric degrees
+ self.form.angle.setValue(getattr(obj.Angle, "Value", obj.Angle))
+ self.form.stepOver.setValue(obj.StepOver)
+
+ # Update QuantitySpinBox displays
+ self.updateQuantitySpinBoxes()
+
+ def updateQuantitySpinBoxes(self, index=None):
+ """updateQuantitySpinBoxes() ... refresh QuantitySpinBox displays from properties"""
+ self.axialStockToLeaveSpinBox.updateWidget()
+ if hasattr(self, "passExtensionSpinBox"):
+ self.passExtensionSpinBox.updateWidget()
+ if hasattr(self, "stockExtensionSpinBox"):
+ self.stockExtensionSpinBox.updateWidget()
+
+ def getSignalsForUpdate(self, obj):
+ """getSignalsForUpdate(obj) ... return list of signals for updating obj"""
+ signals = []
+ signals.append(self.form.toolController.currentIndexChanged)
+ signals.append(self.form.coolantController.currentIndexChanged)
+ signals.append(self.form.cutMode.currentIndexChanged)
+ signals.append(self.form.clearingPattern.currentIndexChanged)
+ signals.append(self.form.axialStockToLeave.editingFinished)
+ if hasattr(self.form, "passExtension"):
+ signals.append(self.form.passExtension.editingFinished)
+ if hasattr(self.form, "stockExtension"):
+ signals.append(self.form.stockExtension.editingFinished)
+ # Qt 6 compatibility for checkbox state change
+ if hasattr(self.form.reverse, "checkStateChanged"): # Qt >= 6.7.0
+ signals.append(self.form.reverse.checkStateChanged)
+ else:
+ signals.append(self.form.reverse.stateChanged)
+ signals.append(self.form.angle.editingFinished)
+ signals.append(self.form.stepOver.editingFinished)
+
+ return signals
+
+
+Command = PathOpGui.SetupOperation(
+ "MillFacing",
+ PathMillFacing.Create,
+ TaskPanelOpPage,
+ "CAM_Face",
+ QT_TRANSLATE_NOOP("CAM_MillFacing", "Mill Facing"),
+ QT_TRANSLATE_NOOP(
+ "CAM_MillFacing", "Create a Mill Facing Operation to machine the top surface of stock"
+ ),
+ PathMillFacing.SetupProperties,
+)
+
+FreeCAD.Console.PrintLog("Loading PathMillFacingGui... done\n")
diff --git a/src/Mod/CAM/Path/Op/Gui/Selection.py b/src/Mod/CAM/Path/Op/Gui/Selection.py
index 60c38fa663..0163c13e81 100644
--- a/src/Mod/CAM/Path/Op/Gui/Selection.py
+++ b/src/Mod/CAM/Path/Op/Gui/Selection.py
@@ -372,6 +372,7 @@ def select(op):
opsel["Engrave"] = engraveselect
opsel["Helix"] = drillselect
opsel["MillFace"] = pocketselect
+ opsel["MillFacing"] = pocketselect
opsel["Pocket"] = pocketselect
opsel["Pocket 3D"] = pocketselect
opsel["Pocket3D"] = pocketselect # deprecated
diff --git a/src/Mod/CAM/Path/Op/MillFacing.py b/src/Mod/CAM/Path/Op/MillFacing.py
new file mode 100644
index 0000000000..7c2c395831
--- /dev/null
+++ b/src/Mod/CAM/Path/Op/MillFacing.py
@@ -0,0 +1,755 @@
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+
+__title__ = "CAM Mill Facing Operation"
+__author__ = "sliptonic (Brad Collette)"
+__url__ = "https://www.freecad.org"
+__doc__ = "Class and implementation of Mill Facing operation."
+__contributors__ = ""
+
+import FreeCAD
+from PySide import QtCore
+import Path
+import Path.Op.Base as PathOp
+
+import Path.Base.Generator.spiral_facing as spiral_facing
+import Path.Base.Generator.zigzag_facing as zigzag_facing
+import Path.Base.Generator.directional_facing as directional_facing
+import Path.Base.Generator.bidirectional_facing as bidirectional_facing
+import Path.Base.Generator.linking as linking
+import PathScripts.PathUtils as PathUtils
+import Path.Base.FeedRate as FeedRate
+
+# lazily loaded modules
+from lazy_loader.lazy_loader import LazyLoader
+
+Part = LazyLoader("Part", globals(), "Part")
+Arcs = LazyLoader("draftgeoutils.arcs", globals(), "draftgeoutils.arcs")
+if FreeCAD.GuiUp:
+ FreeCADGui = LazyLoader("FreeCADGui", globals(), "FreeCADGui")
+
+
+translate = FreeCAD.Qt.translate
+
+
+if False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+
+class ObjectMillFacing(PathOp.ObjectOp):
+ """Proxy object for Mill Facing operation."""
+
+ def opFeatures(self, obj):
+ """opFeatures(obj) ... return all standard features"""
+ return (
+ PathOp.FeatureTool
+ | PathOp.FeatureDepths
+ | PathOp.FeatureHeights
+ | PathOp.FeatureStepDown
+ | PathOp.FeatureCoolant
+ )
+
+ def initOperation(self, obj):
+ """initOperation(obj) ... Initialize the operation by
+ managing property creation and property editor status."""
+ self.propertiesReady = False
+
+ self.initOpProperties(obj) # Initialize operation-specific properties
+
+ def initOpProperties(self, obj, warn=False):
+ """initOpProperties(obj) ... create operation specific properties"""
+ Path.Log.track()
+ self.addNewProps = list()
+
+ for prtyp, nm, grp, tt in self.opPropertyDefinitions():
+ if not hasattr(obj, nm):
+ obj.addProperty(prtyp, nm, grp, tt)
+ self.addNewProps.append(nm)
+
+ # Set enumeration lists for enumeration properties
+ if len(self.addNewProps) > 0:
+ ENUMS = self.propertyEnumerations()
+ for n in ENUMS:
+ if n[0] in self.addNewProps:
+ setattr(obj, n[0], n[1])
+ if warn:
+ newPropMsg = translate("CAM_MIllFacing", "New property added to")
+ newPropMsg += ' "{}": {}'.format(obj.Label, self.addNewProps) + ". "
+ newPropMsg += translate("CAM_MillFacing", "Check default value(s).")
+ FreeCAD.Console.PrintWarning(newPropMsg + "\n")
+
+ self.propertiesReady = True
+
+ def onChanged(self, obj, prop):
+ """onChanged(obj, prop) ... Called when a property changes"""
+ if prop == "StepOver" and hasattr(obj, "StepOver"):
+ # Validate StepOver is between 0 and 100 percent
+ if obj.StepOver < 0:
+ obj.StepOver = 0
+ elif obj.StepOver > 100:
+ obj.StepOver = 100
+
+ def opPropertyDefinitions(self):
+ """opPropertyDefinitions(obj) ... Store operation specific properties"""
+
+ return [
+ (
+ "App::PropertyEnumeration",
+ "CutMode",
+ "Facing",
+ QtCore.QT_TRANSLATE_NOOP(
+ "App::Property",
+ "Set the cut mode for the operation.",
+ ),
+ ),
+ (
+ "App::PropertyEnumeration",
+ "ClearingPattern",
+ "Facing",
+ QtCore.QT_TRANSLATE_NOOP(
+ "App::Property",
+ "Set the clearing pattern for the operation.",
+ ),
+ ),
+ (
+ "App::PropertyAngle",
+ "Angle",
+ "Facing",
+ QtCore.QT_TRANSLATE_NOOP(
+ "App::Property",
+ "Set the angle for the operation.",
+ ),
+ ),
+ (
+ "App::PropertyPercent",
+ "StepOver",
+ "Facing",
+ QtCore.QT_TRANSLATE_NOOP(
+ "App::Property",
+ "Set the stepover percentage of tool diameter.",
+ ),
+ ),
+ (
+ "App::PropertyDistance",
+ "AxialStockToLeave",
+ "Facing",
+ QtCore.QT_TRANSLATE_NOOP(
+ "App::Property",
+ "Set the stock to leave for the operation.",
+ ),
+ ),
+ (
+ "App::PropertyDistance",
+ "PassExtension",
+ "Facing",
+ QtCore.QT_TRANSLATE_NOOP(
+ "App::Property",
+ "Distance to extend cuts beyond polygon boundary for tool disengagement.",
+ ),
+ ),
+ (
+ "App::PropertyDistance",
+ "StockExtension",
+ "Facing",
+ QtCore.QT_TRANSLATE_NOOP(
+ "App::Property",
+ "Extends the boundary in both direction.",
+ ),
+ ),
+ (
+ "App::PropertyBool",
+ "Reverse",
+ "Facing",
+ QtCore.QT_TRANSLATE_NOOP(
+ "App::Property",
+ "Reverse the cutting direction for the selected pattern.",
+ ),
+ ),
+ ]
+
+ @classmethod
+ def propertyEnumerations(self, dataType="data"):
+ """propertyEnumerations(dataType="data")... return property enumeration lists of specified dataType.
+ Args:
+ dataType = 'data', 'raw', 'translated'
+ Notes:
+ 'data' is list of internal string literals used in code
+ 'raw' is list of (translated_text, data_string) tuples
+ 'translated' is list of translated string literals
+ """
+ Path.Log.track()
+
+ enums = {
+ "CutMode": [
+ (translate("CAM_MillFacing", "Climb"), "Climb"),
+ (translate("CAM_MillFacing", "Conventional"), "Conventional"),
+ ],
+ "ClearingPattern": [
+ (translate("CAM_MillFacing", "ZigZag"), "ZigZag"),
+ (translate("CAM_MillFacing", "Bidirectional"), "Bidirectional"),
+ (translate("CAM_MillFacing", "Directional"), "Directional"),
+ (translate("CAM_MillFacing", "Spiral"), "Spiral"),
+ ],
+ }
+
+ if dataType == "raw":
+ return enums
+
+ data = list()
+ idx = 0 if dataType == "translated" else 1
+
+ Path.Log.debug(enums)
+
+ for k, v in enumerate(enums):
+ data.append((v, [tup[idx] for tup in enums[v]]))
+ Path.Log.debug(data)
+
+ return data
+
+ def opPropertyDefaults(self, obj, job):
+ """opPropertyDefaults(obj, job) ... returns a dictionary of default values
+ for the operation's properties."""
+ defaults = {
+ "CutMode": "Climb",
+ "ClearingPattern": "ZigZag",
+ "Angle": 0,
+ "StepOver": 25,
+ "AxialStockToLeave": 0.0,
+ }
+
+ return defaults
+
+ def opSetDefaultValues(self, obj, job):
+ """opSetDefaultValues(obj, job) ... set default values for operation-specific properties"""
+ Path.Log.track()
+
+ # Set default values directly like other operations do
+ obj.CutMode = "Climb"
+ obj.ClearingPattern = "ZigZag"
+ obj.Angle = 0.0
+ obj.StepOver = 25 # 25% as percentage
+ obj.AxialStockToLeave = 0.0
+ obj.PassExtension = (
+ 3.0 # Default to 3mm, will be adjusted based on tool diameter in opExecute
+ )
+ obj.Reverse = False
+
+ def opExecute(self, obj):
+ """opExecute(obj) ... process Mill Facing operation"""
+ Path.Log.track()
+ Path.Log.debug("MillFacing.opExecute() starting")
+
+ # Get tool information
+ tool = obj.ToolController.Tool
+ Path.Log.debug(f"Tool: {tool.Label if tool else 'None'}")
+ tool_diameter = tool.Diameter.Value
+ Path.Log.debug(f"Tool diameter: {tool_diameter}")
+
+ # Determine the step-downs
+ finish_step = 0.0 # No finish step for facing
+ Path.Log.debug(
+ f"Depth parameters: clearance={obj.ClearanceHeight.Value}, safe={obj.SafeHeight.Value}, start={obj.StartDepth.Value}, step={obj.StepDown.Value}, final={obj.FinalDepth.Value + obj.AxialStockToLeave.Value}"
+ )
+ depthparams = PathUtils.depth_params(
+ clearance_height=obj.ClearanceHeight.Value,
+ safe_height=obj.SafeHeight.Value,
+ start_depth=obj.StartDepth.Value,
+ step_down=obj.StepDown.Value,
+ z_finish_step=finish_step,
+ final_depth=obj.FinalDepth.Value + obj.AxialStockToLeave.Value,
+ user_depths=None,
+ )
+ Path.Log.debug(f"Depth params object: {depthparams}")
+
+ # Always use the stock object top face for facing operations
+ job = PathUtils.findParentJob(obj)
+ Path.Log.debug(f"Job: {job.Label if job else 'None'}")
+ if job and job.Stock:
+ Path.Log.debug(f"Stock: {job.Stock.Label}")
+ stock_faces = job.Stock.Shape.Faces
+ Path.Log.debug(f"Number of stock faces: {len(stock_faces)}")
+
+ # Find faces with normal pointing toward Z+ (upward)
+ z_up_faces = []
+ for face in stock_faces:
+ # Get face normal at center
+ u_mid = (face.ParameterRange[0] + face.ParameterRange[1]) / 2
+ v_mid = (face.ParameterRange[2] + face.ParameterRange[3]) / 2
+ normal = face.normalAt(u_mid, v_mid)
+ Path.Log.debug(f"Face normal: {normal}, Z component: {normal.z}")
+
+ # Check if normal points upward (Z+ direction) with some tolerance
+ if normal.z > 0.9: # Allow for slight deviation from perfect vertical
+ z_up_faces.append(face)
+ Path.Log.debug(f"Found upward-facing face at Z={face.BoundBox.ZMax}")
+
+ if not z_up_faces:
+ Path.Log.error("No upward-facing faces found in stock")
+ raise ValueError("No upward-facing faces found in stock")
+
+ # From the upward-facing faces, select the highest one
+ top_face = max(z_up_faces, key=lambda f: f.BoundBox.ZMax)
+ Path.Log.debug(f"Selected top face ZMax: {top_face.BoundBox.ZMax}")
+ boundary_wire = top_face.OuterWire
+ Path.Log.debug(f"Wire vertices: {len(boundary_wire.Vertexes)}")
+ else:
+ Path.Log.error("No stock found for facing operation")
+ raise ValueError("No stock found for facing operation")
+
+ boundary_wire = boundary_wire.makeOffset2D(
+ obj.StockExtension.Value, 2
+ ) # offset with interesection joins
+
+ # Determine milling direction
+ milling_direction = "climb" if obj.CutMode == "Climb" else "conventional"
+
+ # Get operation parameters
+ stepover_percent = obj.StepOver
+ pass_extension = (
+ obj.PassExtension.Value if hasattr(obj, "PassExtension") else tool_diameter * 0.5
+ )
+ retract_height = obj.SafeHeight.Value
+
+ # Generate the base toolpath for one depth level based on clearing pattern
+ try:
+ if obj.ClearingPattern == "Spiral":
+ # Spiral has different signature - no pass_extension or retract_height
+ Path.Log.debug("Generating spiral toolpath")
+ base_commands = spiral_facing.spiral(
+ polygon=boundary_wire,
+ tool_diameter=tool_diameter,
+ stepover_percent=stepover_percent,
+ milling_direction=milling_direction,
+ reverse=bool(getattr(obj, "Reverse", False)),
+ angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
+ )
+ elif obj.ClearingPattern == "ZigZag":
+ Path.Log.debug("Generating zigzag toolpath")
+ base_commands = zigzag_facing.zigzag(
+ polygon=boundary_wire,
+ tool_diameter=tool_diameter,
+ stepover_percent=stepover_percent,
+ pass_extension=pass_extension,
+ retract_height=retract_height,
+ milling_direction=milling_direction,
+ reverse=bool(getattr(obj, "Reverse", False)),
+ angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
+ )
+ elif obj.ClearingPattern == "Bidirectional":
+ Path.Log.debug("Generating bidirectional toolpath")
+ base_commands = bidirectional_facing.bidirectional(
+ polygon=boundary_wire,
+ tool_diameter=tool_diameter,
+ stepover_percent=stepover_percent,
+ pass_extension=pass_extension,
+ milling_direction=milling_direction,
+ reverse=bool(getattr(obj, "Reverse", False)),
+ angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
+ )
+ elif obj.ClearingPattern == "Directional":
+ Path.Log.debug("Generating directional toolpath")
+ base_commands = directional_facing.directional(
+ polygon=boundary_wire,
+ tool_diameter=tool_diameter,
+ stepover_percent=stepover_percent,
+ pass_extension=pass_extension,
+ retract_height=retract_height,
+ milling_direction=milling_direction,
+ reverse=bool(getattr(obj, "Reverse", False)),
+ angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
+ )
+ else:
+ Path.Log.error(f"Unknown clearing pattern: {obj.ClearingPattern}")
+ raise ValueError(f"Unknown clearing pattern: {obj.ClearingPattern}")
+
+ Path.Log.debug(f"Generated {len(base_commands)} base commands")
+ Path.Log.debug(base_commands)
+
+ except Exception as e:
+ Path.Log.error(f"Error generating toolpath: {e}")
+ raise
+
+ # clear commandlist
+ self.commandlist = []
+
+ # Be safe. Add first G0 to clearance height
+ targetZ = obj.ClearanceHeight.Value
+ self.commandlist.append(Path.Command("G0", {"Z": targetZ}))
+
+ # Process each step-down using iterator protocol and add to commandlist
+ depth_count = 0
+ try:
+ while True:
+ depth = depthparams.next()
+ depth_count += 1
+ Path.Log.debug(f"Processing depth {depth_count}: {depth}")
+
+ if depth_count == 1:
+ # First stepdown preamble:
+ # 1) Rapid to ClearanceHeight (already done at line 401)
+ # 2) Rapid to XY start position at ClearanceHeight
+ # 3) Rapid down to SafeHeight
+ # 4) Rapid down to cutting depth
+
+ # Find the first XY target from the base commands
+ first_xy = None
+ first_move_idx = None
+ for i, bc in enumerate(base_commands):
+ if "X" in bc.Parameters and "Y" in bc.Parameters:
+ first_xy = (bc.Parameters["X"], bc.Parameters["Y"])
+ first_move_idx = i
+ break
+
+ if first_xy is not None:
+ # 1) G0 to XY position at current height (ClearanceHeight from line 401)
+ pre1 = {"X": first_xy[0], "Y": first_xy[1]}
+ if not self.commandlist or any(
+ abs(pre1[k] - self.commandlist[-1].Parameters.get(k, pre1[k] + 1))
+ > 1e-9
+ for k in ("X", "Y")
+ ):
+ self.commandlist.append(Path.Command("G0", pre1))
+
+ # 2) G0 down to SafeHeight
+ pre2 = {"Z": obj.SafeHeight.Value}
+ if (
+ abs(pre2["Z"] - self.commandlist[-1].Parameters.get("Z", pre2["Z"] + 1))
+ > 1e-9
+ ):
+ self.commandlist.append(Path.Command("G0", pre2))
+
+ # 3) G0 down to cutting depth
+ pre3 = {"Z": depth}
+ if (
+ abs(pre3["Z"] - self.commandlist[-1].Parameters.get("Z", pre3["Z"] + 1))
+ > 1e-9
+ ):
+ self.commandlist.append(Path.Command("G0", pre3))
+
+ # Now append the base commands, skipping the generator's initial positioning move
+ for i, cmd in enumerate(base_commands):
+ # Skip the first move if it only positions at the start point
+ if i == first_move_idx:
+ # If this first move has only XY(Z) to the start point, skip it because we preambled it
+ pass
+ else:
+ new_params = dict(cmd.Parameters)
+ # Handle Z coordinate based on command type
+ if "Z" in new_params:
+ if cmd.Name == "G0":
+ # For rapids, distinguish between true retracts and plunges
+ # True retracts are at/near retract_height and should be preserved
+ # Plunges are at/near polygon ZMin and should be clamped to depth
+ if (
+ abs(new_params["Z"] - retract_height) < 1.0
+ ): # Within 1mm of retract height
+ # Keep as-is (true retract)
+ pass
+ else:
+ # Not a retract - clamp to depth (includes plunges)
+ new_params["Z"] = depth
+ else:
+ # For G1 cutting moves, always use depth
+ new_params["Z"] = depth
+ else:
+ # Missing Z coordinate - set based on command type
+ if cmd.Name == "G1":
+ # Cutting moves always at depth
+ new_params["Z"] = depth
+ else:
+ # Rapids without Z - carry forward last Z
+ if self.commandlist:
+ new_params["Z"] = self.commandlist[-1].Parameters.get(
+ "Z", depth
+ )
+
+ # Fill in missing X,Y coordinates from last position
+ if self.commandlist:
+ last = self.commandlist[-1].Parameters
+ if "X" not in cmd.Parameters:
+ new_params.setdefault("X", last.get("X"))
+ if "Y" not in cmd.Parameters:
+ new_params.setdefault("Y", last.get("Y"))
+ # Skip zero-length moves (but allow Z-only moves for plunges/retracts)
+ if self.commandlist:
+ last_params = self.commandlist[-1].Parameters
+ # Only skip if X and Y are identical (allow Z-only moves)
+ if all(
+ abs(new_params[k] - last_params.get(k, new_params[k] + 1))
+ <= 1e-9
+ 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
+ )
+ if not z_changed:
+ continue
+ self.commandlist.append(Path.Command(cmd.Name, new_params))
+ Path.Log.debug(
+ f"First stepdown: Added {len(base_commands)} commands for depth {depth}"
+ )
+ else:
+ # Subsequent stepdowns - handle linking
+ # Make a copy of base_commands and update Z depths
+ copy_commands = []
+ for cmd in base_commands:
+ new_params = dict(cmd.Parameters)
+ # Handle Z coordinate based on command type (same logic as first stepdown)
+ if "Z" in new_params:
+ if cmd.Name == "G0":
+ # For rapids, distinguish between true retracts and plunges
+ if (
+ abs(new_params["Z"] - retract_height) < 1.0
+ ): # Within 1mm of retract height
+ # Keep as-is (true retract)
+ pass
+ else:
+ # Not a retract - clamp to depth (includes plunges)
+ new_params["Z"] = depth
+ else:
+ # For G1 cutting moves, always use depth
+ new_params["Z"] = depth
+ else:
+ # Missing Z coordinate - set based on command type
+ if cmd.Name == "G1":
+ # Cutting moves always at depth
+ new_params["Z"] = depth
+ # For G0 without Z, we'll let it get filled in later from context
+ copy_commands.append(Path.Command(cmd.Name, new_params))
+
+ # Get the last position from self.commandlist
+ last_cmd = self.commandlist[-1]
+ last_position = FreeCAD.Vector(
+ last_cmd.Parameters.get("X", 0),
+ last_cmd.Parameters.get("Y", 0),
+ last_cmd.Parameters.get("Z", depth),
+ )
+
+ # Identify the initial retract+position+plunge bundle (G0s) before the next cut
+ bundle_start = None
+ bundle_end = None
+ target_xy = None
+ for i, cmd in enumerate(copy_commands):
+ if cmd.Name == "G0":
+ bundle_start = i
+ # collect consecutive G0s
+ j = i
+ while j < len(copy_commands) and copy_commands[j].Name == "G0":
+ # capture XY target if present
+ if (
+ "X" in copy_commands[j].Parameters
+ and "Y" in copy_commands[j].Parameters
+ ):
+ target_xy = (
+ copy_commands[j].Parameters.get("X"),
+ copy_commands[j].Parameters.get("Y"),
+ )
+ j += 1
+ bundle_end = j # exclusive
+ break
+
+ if bundle_start is not None and target_xy is not None:
+ # Build target position at cutting depth
+ first_position = FreeCAD.Vector(target_xy[0], target_xy[1], depth)
+
+ # Generate collision-aware linking moves up to safe/clearance and back down
+ link_commands = linking.get_linking_moves(
+ start_position=last_position,
+ target_position=first_position,
+ local_clearance=obj.SafeHeight.Value,
+ global_clearance=obj.ClearanceHeight.Value,
+ tool_shape=obj.ToolController.Tool.Shape,
+ )
+ # 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
+ if not (
+ abs(X - current.x) <= 1e-9
+ and abs(Y - current.y) <= 1e-9
+ and abs(Z - current.z) <= 1e-9
+ ):
+ self.commandlist.append(
+ Path.Command(lc.Name, {"X": X, "Y": Y, "Z": 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]
+
+ # Append the copy commands, filling missing coords
+ for cc in copy_commands:
+ cp = dict(cc.Parameters)
+ if self.commandlist:
+ last = self.commandlist[-1].Parameters
+ # Only fill in coordinates that are truly missing from the original command
+ if "X" not in cc.Parameters:
+ cp.setdefault("X", last.get("X"))
+ if "Y" not in cc.Parameters:
+ cp.setdefault("Y", last.get("Y"))
+ # Don't carry forward Z - it should already be set correctly in copy_commands
+ if "Z" not in cc.Parameters:
+ # Only set Z if it wasn't in the original command
+ if cc.Name == "G1":
+ cp["Z"] = depth # Cutting moves at depth
+ else:
+ cp.setdefault("Z", last.get("Z"))
+ # Skip zero-length
+ if self.commandlist:
+ last = self.commandlist[-1].Parameters
+ if all(
+ abs(cp[k] - last.get(k, cp[k] + 1)) <= 1e-9 for k in ("X", "Y", "Z")
+ ):
+ continue
+ self.commandlist.append(Path.Command(cc.Name, cp))
+ Path.Log.debug(
+ f"Stepdown {depth_count}: Added linking + {len(copy_commands)} commands for depth {depth}"
+ )
+
+ except StopIteration:
+ Path.Log.debug(f"All depths processed. Total depth levels: {depth_count}")
+
+ # Add final G0 to clearance height
+ targetZ = obj.ClearanceHeight.Value
+ if self.commandlist:
+ last = self.commandlist[-1].Parameters
+ lastZ = last.get("Z")
+ if lastZ is None or abs(targetZ - lastZ) > 1e-9:
+ # Prefer Z-only to avoid non-numeric XY issues
+ self.commandlist.append(Path.Command("G0", {"Z": targetZ}))
+
+ # # Sanitize commands: ensure full XYZ continuity and remove zero-length/invalid/absurd moves
+ # sanitized = []
+ # curX = curY = curZ = None
+ # # Compute XY bounds from original wire
+ # try:
+ # bb = boundary_wire.BoundBox
+ # import math
+
+ # diag = math.hypot(bb.XLength, bb.YLength)
+ # xy_limit = max(1.0, diag * 10.0)
+ # except Exception:
+ # xy_limit = 1e6
+ # for idx, cmd in enumerate(self.commandlist):
+ # params = dict(cmd.Parameters)
+ # # Carry forward
+ # if curX is not None:
+ # params.setdefault("X", curX)
+ # params.setdefault("Y", curY)
+ # params.setdefault("Z", curZ)
+ # # Extract
+ # X = params.get("X")
+ # Y = params.get("Y")
+ # Z = params.get("Z")
+ # # Skip NaN/inf
+ # try:
+ # _ = float(X) + float(Y) + float(Z)
+ # except Exception:
+ # Path.Log.warning(
+ # f"Dropping cmd {idx} non-finite coords: {cmd.Name} {cmd.Parameters}"
+ # )
+ # continue
+ # # Debug: large finite XY - log but keep for analysis (do not drop)
+ # if abs(X) > xy_limit or abs(Y) > xy_limit:
+ # Path.Log.warning(f"Large XY detected (limit {xy_limit}): {cmd.Name} {params}")
+ # # Skip zero-length
+ # if (
+ # curX is not None
+ # and abs(X - curX) <= 1e-12
+ # and abs(Y - curY) <= 1e-12
+ # and abs(Z - curZ) <= 1e-12
+ # ):
+ # continue
+
+ # # Preserve I, J, K parameters for arc commands (G2/G3)
+ # if cmd.Name in ["G2", "G3"]:
+ # arc_params = {"X": X, "Y": Y, "Z": Z}
+ # if "I" in params:
+ # arc_params["I"] = params["I"]
+ # if "J" in params:
+ # arc_params["J"] = params["J"]
+ # if "K" in params:
+ # arc_params["K"] = params["K"]
+ # if "R" in params:
+ # arc_params["R"] = params["R"]
+ # sanitized.append(Path.Command(cmd.Name, arc_params))
+ # else:
+ # sanitized.append(Path.Command(cmd.Name, {"X": X, "Y": Y, "Z": Z}))
+ # curX, curY, curZ = X, Y, Z
+
+ # self.commandlist = sanitized
+
+ # Apply feedrates to the entire commandlist, with debug on failure
+ try:
+ FeedRate.setFeedRate(self.commandlist, obj.ToolController)
+ except Exception as e:
+ # Dump last 12 commands for diagnostics
+ n = len(self.commandlist)
+ start = max(0, n - 12)
+ Path.Log.error("FeedRate failure. Dumping last commands:")
+ for i in range(start, n):
+ c = self.commandlist[i]
+ Path.Log.error(f" #{i}: {c.Name} {c.Parameters}")
+ raise
+
+ Path.Log.debug(f"Total commands in commandlist: {len(self.commandlist)}")
+ Path.Log.debug("MillFacing.opExecute() completed successfully")
+ Path.Log.debug(self.commandlist)
+
+
+# Eclass
+
+
+def Create(name, obj=None, parentJob=None):
+ """Create(name) ... Creates and returns a Mill Facing operation."""
+ if obj is None:
+ obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name)
+ obj.Proxy = ObjectMillFacing(obj, name, parentJob)
+ return obj
+
+
+def SetupProperties():
+ """SetupProperties() ... Return list of properties required for the operation."""
+ setup = []
+ setup.append("CutMode")
+ setup.append("ClearingPattern")
+ setup.append("Angle")
+ setup.append("StepOver")
+ setup.append("AxialStockToLeave")
+ setup.append("PassExtension")
+ setup.append("Reverse")
+ return setup
diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py
index 80a0c6f744..1b6bd06b32 100644
--- a/src/Mod/CAM/TestCAMApp.py
+++ b/src/Mod/CAM/TestCAMApp.py
@@ -25,6 +25,7 @@
import TestApp
from CAMTests.TestCAMSanity import TestCAMSanity
+from CAMTests.TestLinkingGenerator import TestGetLinkingMoves
from CAMTests.TestPathProfile import TestPathProfile
from CAMTests.TestPathAdaptive import TestPathAdaptive
@@ -36,6 +37,7 @@ from CAMTests.TestPathDressupDogboneII import TestDressupDogboneII
from CAMTests.TestPathDressupHoldingTags import TestHoldingTags
from CAMTests.TestPathDrillable import TestPathDrillable
from CAMTests.TestPathDrillGenerator import TestPathDrillGenerator
+from CAMTests.TestPathFacingGenerator import TestPathFacingGenerator
from CAMTests.TestPathGeneratorDogboneII import TestGeneratorDogboneII
from CAMTests.TestPathGeom import TestPathGeom
from CAMTests.TestPathLanguage import TestPathLanguage