| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | 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() |
| |
|
| | |
| | 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), |
| | ] |
| | ) |
| |
|
| | |
| | self.circle_wire = Part.Wire( |
| | [Part.Circle(FreeCAD.Vector(5, 5, 0), FreeCAD.Vector(0, 0, 1), 5).toShape()] |
| | ) |
| |
|
| | |
| | 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): |
| | |
| | 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"]) |
| | |
| | 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, |
| | ) |
| |
|
| | |
| | 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))) |
| |
|
| | |
| | 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, |
| | ) |
| |
|
| | |
| | self.assertIsInstance(commands, list) |
| | self.assertGreater(len(commands), 0) |
| |
|
| | |
| | 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) |
| | |
| | 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) |
| | |
| | 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) |
| | |
| | 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.""" |
| | |
| | |
| | |
| | |
| | |
| |
|
| | 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 |
| |
|
| | |
| | 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: |
| | self.assertGreater( |
| | pos[0], |
| | 10, |
| | f"zigzag {milling_dir} reverse={reverse}: expected right (X>10), got X={pos[0]}", |
| | ) |
| |
|
| | |
| | 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: |
| | 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.""" |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | 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" |
| | ) |
| |
|
| | |
| | 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: |
| | self.assertGreater( |
| | pos[0], |
| | 10, |
| | f"directional {milling_dir} reverse={reverse}: expected right (X>10), got X={pos[0]}", |
| | ) |
| |
|
| | |
| | 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: |
| | 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.""" |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | 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" |
| | ) |
| |
|
| | |
| | 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: |
| | self.assertGreater( |
| | pos[0], |
| | 10, |
| | f"bidirectional {milling_dir} reverse={reverse}: expected right (X>10), got X={pos[0]}", |
| | ) |
| |
|
| | |
| | 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: |
| | 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", |
| | ) |
| |
|
| | |
| | self.assertEqual(len(climb_commands), len(conventional_commands)) |
| |
|
| | |
| | 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 |
| | ) |
| |
|
| | |
| | self.assertGreaterEqual(len(commands_with_retract), len(commands_no_retract)) |
| |
|
| | |
| | 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 |
| | ) |
| |
|
| | |
| | self.assertIsInstance(commands, list) |
| | self.assertGreater(len(commands), 0) |
| |
|
| | |
| | self.assertEqual(commands[0].Name, "G0") |
| |
|
| | |
| | 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, |
| | stepover_percent=25, |
| | angle_degrees=0.0, |
| | ) |
| |
|
| | |
| | self.assertGreater(len(commands), 2) |
| |
|
| | |
| | x_coords = [] |
| | for cmd in commands: |
| | if "X" in cmd.Parameters: |
| | x_coords.append(cmd.Parameters["X"]) |
| |
|
| | |
| | self.assertGreater(len(x_coords), 2) |
| |
|
| | def test_zigzag_with_retract_height(self): |
| | """Test zigzag with retract height.""" |
| | |
| | commands_arc_mode = zigzag_facing.zigzag( |
| | polygon=self.square_wire, |
| | tool_diameter=5.0, |
| | stepover_percent=50, |
| | retract_height=15.0, |
| | link_mode="arc", |
| | ) |
| |
|
| | |
| | commands_straight_mode = zigzag_facing.zigzag( |
| | polygon=self.square_wire, |
| | tool_diameter=5.0, |
| | stepover_percent=50, |
| | retract_height=15.0, |
| | link_mode="straight", |
| | ) |
| |
|
| | |
| | self.assertGreater(len(commands_arc_mode), 0) |
| | self.assertGreater(len(commands_straight_mode), 0) |
| |
|
| | |
| | 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" |
| | ) |
| |
|
| | |
| | |
| | |
| |
|
| | 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" |
| | ) |
| |
|
| | |
| | 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") |
| |
|
| | |
| | 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", |
| | ) |
| |
|
| | |
| | self.assertGreater(len(arc_commands), 0) |
| | self.assertGreater(len(straight_commands), 0) |
| |
|
| | |
| | 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") |
| |
|
| | |
| | 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", |
| | ) |
| |
|
| | |
| | self.assertEqual(len(climb_commands), len(conventional_commands)) |
| |
|
| | |
| | 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) |
| |
|
| | |
| | self.assertTrue(result.isClosed()) |
| | |
| | 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()) |
| | |
| | original_bb = self.square_wire.BoundBox |
| | result_bb = result.BoundBox |
| |
|
| | |
| | |
| | |
| | self.assertGreater(result_bb.XLength, original_bb.XLength) |
| | self.assertGreater(result_bb.YLength, original_bb.YLength) |
| |
|
| | |
| | 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) |
| |
|
| | |
| | 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") |
| |
|
| | |
| | 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.""" |
| | |
| | triangle_wire = Part.makePolygon( |
| | [ |
| | FreeCAD.Vector(0, 0, 0), |
| | FreeCAD.Vector(10, 0, 0), |
| | FreeCAD.Vector(5, 10, 0), |
| | FreeCAD.Vector(0, 0, 0), |
| | ] |
| | ) |
| |
|
| | |
| | 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) |
| | |
| | self.assertEqual(commands[0].Name, "G0") |
| |
|
| | |
| | cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"] |
| | self.assertGreaterEqual(len(cutting_moves), 4) |
| |
|
| | 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) |
| | |
| | self.assertEqual(commands[0].Name, "G0") |
| |
|
| | |
| | cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"] |
| | self.assertGreater(len(cutting_moves), 1) |
| |
|
| | |
| | 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) |
| | |
| | self.assertEqual(commands[0].Name, "G0") |
| |
|
| | |
| | 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) |
| | |
| | self.assertEqual(commands[0].Name, "G0") |
| |
|
| | |
| | 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) |
| |
|
| | |
| | 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" |
| | ) |
| |
|
| | |
| | cutting_moves = [ |
| | cmd |
| | for cmd in commands |
| | if cmd.Name == "G1" and "X" in cmd.Parameters and "Y" in cmd.Parameters |
| | ] |
| |
|
| | |
| | self.assertGreaterEqual(len(cutting_moves), 4) |
| |
|
| | |
| | rapid_moves = [cmd for cmd in commands if cmd.Name == "G0"] |
| | self.assertGreater(len(rapid_moves), 0) |
| |
|
| | |
| | start_y_coords = [ |
| | cutting_moves[i].Parameters["Y"] |
| | for i in range(0, len(cutting_moves), 2) |
| | if i < len(cutting_moves) |
| | ] |
| |
|
| | |
| | if len(start_y_coords) >= 4: |
| | |
| | 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) |
| |
|
| | |
| | if len(bottom_passes) >= 2: |
| | self.assertLess( |
| | bottom_passes[0], bottom_passes[1] |
| | ) |
| |
|
| | |
| | if len(top_passes) >= 2: |
| | self.assertGreater(top_passes[0], top_passes[1]) |
| |
|
| | 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) |
| | |
| | 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) |
| | |
| | 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) |
| | |
| | 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.""" |
| | |
| | commands = spiral_facing.spiral( |
| | polygon=self.square_wire, |
| | tool_diameter=2.0, |
| | stepover_percent=25, |
| | ) |
| |
|
| | |
| | self.assertGreater(len(commands), 8) |
| |
|
| | |
| | positions = [] |
| | for cmd in commands: |
| | if "X" in cmd.Parameters and "Y" in cmd.Parameters: |
| | positions.append((cmd.Parameters["X"], cmd.Parameters["Y"])) |
| |
|
| | |
| | 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", |
| | ) |
| |
|
| | |
| | self.assertEqual(len(climb_commands), len(conventional_commands)) |
| |
|
| | |
| | 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 |
| |
|
| | |
| | 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), |
| | ] |
| | ) |
| |
|
| | |
| | commands = spiral_facing.spiral( |
| | polygon=centered_rectangle, |
| | tool_diameter=2.0, |
| | stepover_percent=25, |
| | ) |
| |
|
| | |
| | self.assertGreater(len(commands), 8) |
| |
|
| | |
| | positions = [] |
| | for cmd in commands: |
| | if "X" in cmd.Parameters and "Y" in cmd.Parameters: |
| | positions.append((cmd.Parameters["X"], cmd.Parameters["Y"])) |
| |
|
| | |
| | unique_positions = set(positions) |
| | self.assertGreater(len(unique_positions), 4) |
| |
|
| | |
| | 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 |
| |
|
| | |
| | |
| | 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_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, |
| | ) |
| |
|
| | |
| | self.assertGreater(len(commands), 8) |
| |
|
| | |
| | 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 |
| |
|
| | |
| | |
| | angle = math.radians(30) |
| | cos_a = math.cos(angle) |
| | sin_a = math.sin(angle) |
| |
|
| | |
| | corners = [(-6, -4), (6, -4), (6, 4), (-6, 4)] |
| |
|
| | |
| | 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)) |
| |
|
| | |
| | rotated_corners.append(rotated_corners[0]) |
| |
|
| | angled_rectangle = Part.makePolygon(rotated_corners) |
| |
|
| | |
| | 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", |
| | ) |
| |
|
| | |
| | self.assertGreater(len(commands), 8) |
| |
|
| | |
| | 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 |
| | ) |
| |
|
| | |
| | rapid_moves = [cmd for cmd in commands if cmd.Name == "G0"] |
| |
|
| | |
| | self.assertEqual(len(rapid_moves), 1) |
| | |
| | self.assertEqual(commands[0].Name, "G0") |
| |
|
| | |
| | cutting_moves = [cmd for cmd in commands if cmd.Name == "G1"] |
| | self.assertGreater(len(cutting_moves), 0) |
| | |
| | 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 |
| | ) |
| |
|
| | |
| | self.assertGreater(len(commands), 0) |
| |
|
| | |
| | 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") |
| |
|
| | |
| | for cmd in commands: |
| | if "X" in cmd.Parameters: |
| | x = cmd.Parameters["X"] |
| | |
| | self.assertGreaterEqual(x, -5) |
| | 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 |
| | ) |
| |
|
| | |
| | self.assertGreater(len(commands), 0) |
| |
|
| | |
| | self.assertEqual(commands[0].Name, "G0") |
| |
|
| | |
| | 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.""" |
| | |
| | commands = spiral_facing.spiral( |
| | polygon=self.square_wire, |
| | tool_diameter=2.0, |
| | stepover_percent=50, |
| | milling_direction="climb", |
| | ) |
| |
|
| | |
| | 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") |
| |
|
| | |
| | |
| | |
| | diagonal_moves = [] |
| | for i in range(len(cutting_moves) - 1): |
| | x1, y1 = cutting_moves[i] |
| | x2, y2 = cutting_moves[i + 1] |
| |
|
| | |
| | x_change = abs(x2 - x1) |
| | y_change = abs(y2 - y1) |
| |
|
| | |
| | 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)) |
| |
|
| | |
| | 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]: |
| | 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) |
| |
|
| | |
| | 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) |
| |
|