# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# SPDX-FileCopyrightText: 2025 sliptonic sliptonic@freecad.org
# SPDX-FileNotice: Part of the FreeCAD project.

################################################################################
#                                                                              #
#   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 https://www.gnu.org/licenses       #
#                                                                              #
################################################################################
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",
        )

    # ------------------------------------------------------------------ #
    #  Boundary wire tests — verify facing_common functions handle        #
    #  non-rectangular (e.g. circular) wires from cylindrical stock.      #
    # ------------------------------------------------------------------ #

    def test_project_bounds_rectangular_wire(self):
        """project_bounds returns correct extents for a rectangular wire."""
        origin = self.square_wire.BoundBox.Center
        vec_x = FreeCAD.Vector(1, 0, 0)
        vec_y = FreeCAD.Vector(0, 1, 0)

        min_x, max_x = facing_common.project_bounds(self.square_wire, vec_x, origin)
        min_y, max_y = facing_common.project_bounds(self.square_wire, vec_y, origin)

        self.assertAlmostEqual(min_x, -5.0, places=2)
        self.assertAlmostEqual(max_x, 5.0, places=2)
        self.assertAlmostEqual(min_y, -5.0, places=2)
        self.assertAlmostEqual(max_y, 5.0, places=2)

    def test_project_bounds_circular_wire(self):
        """project_bounds must return the full circular extent, not just seam vertices."""
        # circle_wire is centred at (5,5) with radius 5
        origin = self.circle_wire.BoundBox.Center
        vec_x = FreeCAD.Vector(1, 0, 0)
        vec_y = FreeCAD.Vector(0, 1, 0)

        min_x, max_x = facing_common.project_bounds(self.circle_wire, vec_x, origin)
        min_y, max_y = facing_common.project_bounds(self.circle_wire, vec_y, origin)

        # True extents are ±5 from centre
        self.assertAlmostEqual(min_x, -5.0, places=1)
        self.assertAlmostEqual(max_x, 5.0, places=1)
        self.assertAlmostEqual(min_y, -5.0, places=1)
        self.assertAlmostEqual(max_y, 5.0, places=1)

    def test_generate_t_values_circular_wire(self):
        """generate_t_values must produce multiple step positions spanning the circular wire."""
        origin = self.circle_wire.BoundBox.Center
        step_vec = FreeCAD.Vector(0, 1, 0)

        values = facing_common.generate_t_values(self.circle_wire, step_vec, 2.0, 50, origin)

        # For a 10 mm diameter circle with 2 mm tool at 50% stepover (1 mm steps)
        # we expect roughly 10 passes; definitely more than 2
        self.assertGreater(
            len(values), 2, f"Expected multiple t-values for circular wire, got {len(values)}"
        )

        # Values should span most of the ±5 range from centre
        self.assertLess(min(values), -3.0)
        self.assertGreater(max(values), 3.0)

    def test_zigzag_circular_wire(self):
        """Zigzag generator must produce a valid toolpath for a circular boundary wire."""
        commands = zigzag_facing.zigzag(
            polygon=self.circle_wire,
            tool_diameter=2.0,
            stepover_percent=50,
            angle_degrees=0.0,
        )

        self.assertIsInstance(commands, list)
        self.assertGreater(len(commands), 0, "Zigzag should produce commands for circular wire")

        # Should have cutting moves
        cutting = [c for c in commands if c.Name == "G1"]
        self.assertGreater(len(cutting), 2, "Should have multiple cutting passes")

    def test_bidirectional_circular_wire(self):
        """Bidirectional generator must produce a valid toolpath for a circular boundary wire."""
        commands = bidirectional_facing.bidirectional(
            polygon=self.circle_wire,
            tool_diameter=2.0,
            stepover_percent=50,
            angle_degrees=0.0,
        )

        self.assertIsInstance(commands, list)
        self.assertGreater(
            len(commands), 0, "Bidirectional should produce commands for circular wire"
        )

        cutting = [c for c in commands if c.Name == "G1"]
        self.assertGreater(len(cutting), 2, "Should have multiple cutting passes")

    def test_directional_circular_wire(self):
        """Directional generator must produce a valid toolpath for a circular boundary wire."""
        commands = directional_facing.directional(
            polygon=self.circle_wire,
            tool_diameter=2.0,
            stepover_percent=50,
            angle_degrees=0.0,
        )

        self.assertIsInstance(commands, list)
        self.assertGreater(
            len(commands), 0, "Directional should produce commands for circular wire"
        )

        cutting = [c for c in commands if c.Name == "G1"]
        self.assertGreater(len(cutting), 2, "Should have multiple cutting passes")

    def test_spiral_circular_wire_via_angled_polygon(self):
        """Spiral strategy requires a rectangular boundary.  When the stock has
        curved edges (e.g. cylindrical stock) MillFacing converts the boundary
        to a bounding rectangle via get_angled_polygon before calling any
        strategy.

        Given:  A circular wire (radius 5, centred at 5,5).
        Action: Convert it to a rectangle with get_angled_polygon(wire, 0),
                then pass the rectangle to spiral().
        Expect: - The converted wire has exactly 4 edges.
                - The rectangle fully contains the original circle's bounding box.
                - spiral() returns a non-empty list of commands with cutting moves.
        """
        rect_wire = facing_common.get_angled_polygon(self.circle_wire, 0)

        # Must be a closed 4-edge rectangle
        self.assertTrue(rect_wire.isClosed(), "Converted wire must be closed")
        self.assertEqual(len(rect_wire.Edges), 4, "Converted wire must have exactly 4 edges")

        # Rectangle must encompass the original circle bounding box
        orig_bb = self.circle_wire.BoundBox
        rect_bb = rect_wire.BoundBox
        self.assertLessEqual(rect_bb.XMin, orig_bb.XMin + 1e-6)
        self.assertGreaterEqual(rect_bb.XMax, orig_bb.XMax - 1e-6)
        self.assertLessEqual(rect_bb.YMin, orig_bb.YMin + 1e-6)
        self.assertGreaterEqual(rect_bb.YMax, orig_bb.YMax - 1e-6)

        # Spiral must succeed on the rectangular boundary
        commands = spiral_facing.spiral(
            polygon=rect_wire,
            tool_diameter=2.0,
            stepover_percent=50,
            milling_direction="climb",
        )

        self.assertIsInstance(commands, list)
        self.assertGreater(len(commands), 0, "Spiral should produce commands")
        cutting = [c for c in commands if c.Name == "G1"]
        self.assertGreater(len(cutting), 2, "Should have multiple cutting passes")

    def test_spiral_spline_wire_via_angled_polygon(self):
        """Same boundary-rectification path but with a spline wire.

        Given:  A closed B-spline wire (irregular curved shape).
        Action: Convert via get_angled_polygon(wire, 0), pass to spiral().
        Expect: - 4-edge closed rectangle that contains the spline bounding box.
                - spiral() produces a valid toolpath.
        """
        rect_wire = facing_common.get_angled_polygon(self.spline_wire, 0)

        self.assertTrue(rect_wire.isClosed())
        self.assertEqual(len(rect_wire.Edges), 4)

        orig_bb = self.spline_wire.BoundBox
        rect_bb = rect_wire.BoundBox
        self.assertLessEqual(rect_bb.XMin, orig_bb.XMin + 1e-6)
        self.assertGreaterEqual(rect_bb.XMax, orig_bb.XMax - 1e-6)
        self.assertLessEqual(rect_bb.YMin, orig_bb.YMin + 1e-6)
        self.assertGreaterEqual(rect_bb.YMax, orig_bb.YMax - 1e-6)

        commands = spiral_facing.spiral(
            polygon=rect_wire,
            tool_diameter=2.0,
            stepover_percent=50,
            milling_direction="climb",
        )

        self.assertIsInstance(commands, list)
        self.assertGreater(len(commands), 0, "Spiral should produce commands for spline boundary")
        cutting = [c for c in commands if c.Name == "G1"]
        self.assertGreater(len(cutting), 2, "Should have multiple cutting passes")

    def test_spiral_circular_wire_angled_45(self):
        """Boundary rectification at a non-zero angle still yields a valid
        rectangular boundary for spiral.

        Given:  A circular wire, angle = 45 degrees.
        Action: Convert via get_angled_polygon(wire, 45), pass to spiral().
        Expect: - 4-edge closed rectangle.
                - spiral() at 45 degrees produces a valid toolpath.
        """
        rect_wire = facing_common.get_angled_polygon(self.circle_wire, 45)

        self.assertTrue(rect_wire.isClosed())
        self.assertEqual(len(rect_wire.Edges), 4)

        commands = spiral_facing.spiral(
            polygon=rect_wire,
            tool_diameter=2.0,
            stepover_percent=50,
            milling_direction="climb",
            angle_degrees=45,
        )

        self.assertIsInstance(commands, list)
        self.assertGreater(len(commands), 0, "Spiral should produce commands at 45 degrees")

    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)
