From eb8b2ddad23ebf4415ae4b767b352347465c93ee Mon Sep 17 00:00:00 2001 From: sliptonic Date: Fri, 28 Nov 2025 17:26:36 -0600 Subject: [PATCH] [CAM] Reimplemented Mill facing operation (#24367) --- src/Mod/CAM/CAMTests/TestLinkingGenerator.py | 99 ++ .../CAM/CAMTests/TestPathFacingGenerator.py | 1184 +++++++++++++++++ src/Mod/CAM/CAMTests/TestPathUtil.py | 246 ++++ src/Mod/CAM/CMakeLists.txt | 11 +- src/Mod/CAM/Gui/Resources/Path.qrc | 1 + .../Resources/panels/PageOpMillFacingEdit.ui | 273 ++++ src/Mod/CAM/InitGui.py | 2 +- .../Base/Generator/bidirectional_facing.py | 174 +++ .../Path/Base/Generator/directional_facing.py | 201 +++ .../CAM/Path/Base/Generator/facing_common.py | 481 +++++++ src/Mod/CAM/Path/Base/Generator/linking.py | 104 ++ .../CAM/Path/Base/Generator/spiral_facing.py | 319 +++++ .../CAM/Path/Base/Generator/zigzag_facing.py | 329 +++++ src/Mod/CAM/Path/GuiInit.py | 1 + src/Mod/CAM/Path/Op/Base.py | 213 +++ src/Mod/CAM/Path/Op/Gui/MillFacing.py | 172 +++ src/Mod/CAM/Path/Op/Gui/Selection.py | 1 + src/Mod/CAM/Path/Op/MillFacing.py | 755 +++++++++++ src/Mod/CAM/TestCAMApp.py | 2 + 19 files changed, 4566 insertions(+), 2 deletions(-) create mode 100644 src/Mod/CAM/CAMTests/TestLinkingGenerator.py create mode 100644 src/Mod/CAM/CAMTests/TestPathFacingGenerator.py create mode 100644 src/Mod/CAM/Gui/Resources/panels/PageOpMillFacingEdit.ui create mode 100644 src/Mod/CAM/Path/Base/Generator/bidirectional_facing.py create mode 100644 src/Mod/CAM/Path/Base/Generator/directional_facing.py create mode 100644 src/Mod/CAM/Path/Base/Generator/facing_common.py create mode 100644 src/Mod/CAM/Path/Base/Generator/linking.py create mode 100644 src/Mod/CAM/Path/Base/Generator/spiral_facing.py create mode 100644 src/Mod/CAM/Path/Base/Generator/zigzag_facing.py create mode 100644 src/Mod/CAM/Path/Op/Gui/MillFacing.py create mode 100644 src/Mod/CAM/Path/Op/MillFacing.py 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 +
Gui/QuantitySpinBox.h
+
+
+ + +
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