[CAM] Reimplemented Mill facing operation (#24367)

This commit is contained in:
sliptonic
2025-11-28 17:26:36 -06:00
committed by GitHub
parent f18842114f
commit eb8b2ddad2
19 changed files with 4566 additions and 2 deletions

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Brad Collette *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# ***************************************************************************
import FreeCAD
import Part
import CAMTests.PathTestUtils as PathTestUtils
import Path.Base.Generator.linking as generator
import unittest
class TestGetLinkingMoves(PathTestUtils.PathTestBase):
def setUp(self):
self.start = FreeCAD.Vector(0, 0, 0)
self.target = FreeCAD.Vector(10, 0, 0)
self.local_clearance = 2.0
self.global_clearance = 5.0
self.tool = Part.makeCylinder(1, 5)
def test_simple_move(self):
cmds = generator.get_linking_moves(
start_position=self.start,
target_position=self.target,
local_clearance=self.local_clearance,
global_clearance=self.global_clearance,
tool_shape=self.tool,
solids=[],
)
self.assertGreater(len(cmds), 0)
self.assertEqual(cmds[0].Name, "G0")
def test_same_position_returns_empty(self):
cmds = generator.get_linking_moves(
start_position=self.start,
target_position=self.start,
local_clearance=self.local_clearance,
global_clearance=self.global_clearance,
tool_shape=self.tool,
solids=[],
)
self.assertEqual(len(cmds), 0)
def test_negative_retract_offset_raises(self):
with self.assertRaises(ValueError):
generator.get_linking_moves(
start_position=self.start,
target_position=self.target,
local_clearance=self.local_clearance,
global_clearance=self.global_clearance,
tool_shape=self.tool,
retract_height_offset=-1,
)
def test_clearance_violation_raises(self):
with self.assertRaises(ValueError):
generator.get_linking_moves(
start_position=self.start,
target_position=self.target,
local_clearance=10.0,
global_clearance=5.0,
tool_shape=self.tool,
)
def test_path_blocked_by_solid(self):
blocking_box = Part.makeBox(20, 20, 10)
blocking_box.translate(FreeCAD.Vector(-5, -5, 0))
with self.assertRaises(RuntimeError):
generator.get_linking_moves(
start_position=self.start,
target_position=self.target,
local_clearance=self.local_clearance,
global_clearance=self.global_clearance,
tool_shape=self.tool,
solids=[blocking_box],
)
@unittest.skip("not yet implemented")
def test_zero_retract_offset_uses_local_clearance(self):
cmds = generator.get_linking_moves(
start_position=self.start,
target_position=FreeCAD.Vector(10, 0, 5),
local_clearance=self.local_clearance,
global_clearance=self.global_clearance,
tool_shape=self.tool,
retract_height_offset=0,
)
self.assertTrue(any(cmd for cmd in cmds if cmd.Parameters.get("Z") == self.local_clearance))
@unittest.skip("not yet implemented")
def test_path_generated_without_local_safe(self):
pass

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@
import FreeCAD
import Path.Base.Util as PathUtil
import Path.Op.Base as PathOpBase
import TestSketcherApp
from CAMTests.PathTestUtils import PathTestBase
@@ -116,3 +117,248 @@ class TestPathUtil(PathTestBase):
# however, the object itself is no longer valid
self.assertFalse(PathUtil.isValidBaseObject(box))
class TestCompass(PathTestBase):
"""Test the Compass helper class for climb/conventional milling."""
def setUp(self):
# Spindle directions for testing
self.spindle_forward = "Forward"
self.spindle_reverse = "Reverse"
self.spindle_none = "None"
super().setUp()
def test_compass_initialization(self):
"""Test Compass class initialization."""
compass = PathOpBase.Compass(self.spindle_forward)
# Check default values
self.assertEqual(compass.spindle_dir, self.spindle_forward)
self.assertEqual(compass.cut_side, "Outside")
self.assertEqual(compass.cut_mode, "Climb")
def test_spindle_direction_property(self):
"""Test spindle direction property handling."""
# Test forward spindle
compass_forward = PathOpBase.Compass(self.spindle_forward)
self.assertEqual(compass_forward.spindle_dir, "Forward")
# Test reverse spindle
compass_reverse = PathOpBase.Compass(self.spindle_reverse)
self.assertEqual(compass_reverse.spindle_dir, "Reverse")
# Test none/unknown spindle
compass_none = PathOpBase.Compass(self.spindle_none)
self.assertEqual(compass_none.spindle_dir, "None")
# Test setting spindle direction
compass = PathOpBase.Compass("Forward")
compass.spindle_dir = "Reverse"
self.assertEqual(compass.spindle_dir, "Reverse")
# Test invalid spindle direction defaults to None
compass.spindle_dir = "Invalid"
self.assertEqual(compass.spindle_dir, "None")
def test_cut_side_property(self):
"""Test cut side property and setter."""
compass = PathOpBase.Compass(self.spindle_forward)
# Test default
self.assertEqual(compass.cut_side, "Outside")
# Test setting inside
compass.cut_side = "inside"
self.assertEqual(compass.cut_side, "Inside")
# Test setting outside
compass.cut_side = "outside"
self.assertEqual(compass.cut_side, "Outside")
def test_cut_mode_property(self):
"""Test cut mode property and setter."""
compass = PathOpBase.Compass(self.spindle_forward)
# Test default
self.assertEqual(compass.cut_mode, "Climb")
# Test setting conventional
compass.cut_mode = "conventional"
self.assertEqual(compass.cut_mode, "Conventional")
# Test setting climb
compass.cut_mode = "climb"
self.assertEqual(compass.cut_mode, "Climb")
def test_path_direction_calculation(self):
"""Test path direction calculation logic."""
# Test Forward spindle, Outside cut, Climb mode -> CW path
compass = PathOpBase.Compass(self.spindle_forward)
compass.cut_side = "Outside"
compass.cut_mode = "Climb"
self.assertEqual(compass.path_dir, "CW")
# Test Forward spindle, Inside cut, Climb mode -> CCW path
compass.cut_side = "Inside"
compass.cut_mode = "Climb"
self.assertEqual(compass.path_dir, "CCW")
# Test Reverse spindle, Outside cut, Climb mode -> CCW path
compass_reverse = PathOpBase.Compass(self.spindle_reverse)
compass_reverse.cut_side = "Outside"
compass_reverse.cut_mode = "Climb"
self.assertEqual(compass_reverse.path_dir, "CCW")
# Test Reverse spindle, Inside cut, Climb mode -> CW path
compass_reverse.cut_side = "Inside"
compass_reverse.cut_mode = "Climb"
self.assertEqual(compass_reverse.path_dir, "CW")
def test_conventional_milling_path_direction(self):
"""Test path direction for conventional milling."""
# Test Forward spindle, Outside cut, Conventional mode -> CCW path
compass = PathOpBase.Compass(self.spindle_forward)
compass.cut_side = "Outside"
compass.cut_mode = "Conventional"
self.assertEqual(compass.path_dir, "CCW")
# Test Forward spindle, Inside cut, Conventional mode -> CW path
compass.cut_side = "Inside"
compass.cut_mode = "Conventional"
self.assertEqual(compass.path_dir, "CW")
def test_unknown_spindle_direction(self):
"""Test behavior with unknown spindle direction."""
compass = PathOpBase.Compass(self.spindle_none)
self.assertEqual(compass.path_dir, "UNKNOWN")
def test_expected_cut_mode_lookup(self):
"""Test the internal _expected_cut_mode lookup table."""
compass = PathOpBase.Compass(self.spindle_forward)
# Test lookup table entries for climb milling
self.assertEqual(compass._expected_cut_mode("Inside", "CW", "CCW"), "Climb")
self.assertEqual(compass._expected_cut_mode("Inside", "CCW", "CW"), "Climb")
self.assertEqual(compass._expected_cut_mode("Outside", "CW", "CW"), "Climb")
self.assertEqual(compass._expected_cut_mode("Outside", "CCW", "CCW"), "Climb")
# Test default to conventional for other combinations
self.assertEqual(compass._expected_cut_mode("Outside", "CW", "CCW"), "Conventional")
self.assertEqual(compass._expected_cut_mode("Inside", "CW", "CW"), "Conventional")
def test_rotation_from_spindle(self):
"""Test spindle direction to rotation mapping."""
compass = PathOpBase.Compass(self.spindle_forward)
self.assertEqual(compass._rotation_from_spindle("Forward"), "CW")
self.assertEqual(compass._rotation_from_spindle("Reverse"), "CCW")
def test_report_functionality(self):
"""Test the report method returns correct data."""
compass = PathOpBase.Compass(self.spindle_forward)
compass.cut_side = "Inside"
compass.cut_mode = "Conventional"
report = compass.report()
expected = {
"spindle_dir": "Forward",
"cut_side": "Inside",
"cut_mode": "Conventional",
"operation_type": "Perimeter",
"path_dir": "CW",
}
self.assertEqual(report, expected)
def test_area_operation_initialization(self):
"""Test Compass class initialization for area operations."""
compass = PathOpBase.Compass(self.spindle_forward, "Area")
# Check values for area operations
self.assertEqual(compass.spindle_dir, "Forward")
self.assertEqual(compass.operation_type, "Area")
self.assertEqual(compass.cut_mode, "Climb")
self.assertEqual(compass.path_dir, "N/A") # Not applicable for area operations
def test_operation_type_property(self):
"""Test operation type property and setter."""
compass = PathOpBase.Compass(self.spindle_forward)
# Test default (perimeter for backward compatibility)
self.assertEqual(compass.operation_type, "Perimeter")
# Test setting area
compass.operation_type = "area"
self.assertEqual(compass.operation_type, "Area")
self.assertEqual(compass.path_dir, "N/A")
# Test setting back to perimeter
compass.operation_type = "perimeter"
self.assertEqual(compass.operation_type, "Perimeter")
self.assertNotEqual(compass.path_dir, "N/A")
def test_step_direction_for_area_operations(self):
"""Test step direction calculation for area operations."""
compass = PathOpBase.Compass(self.spindle_forward, "Area")
compass.cut_mode = "Climb"
# Test X- approach with climb milling
step_dir = compass.get_step_direction("X-")
self.assertTrue(isinstance(step_dir, bool))
# Test conventional milling gives different result
compass.cut_mode = "Conventional"
step_dir_conv = compass.get_step_direction("X-")
self.assertNotEqual(step_dir, step_dir_conv)
# Test with reverse spindle
compass_reverse = PathOpBase.Compass(self.spindle_reverse, "Area")
step_dir_reverse = compass_reverse.get_step_direction("X-")
self.assertTrue(isinstance(step_dir_reverse, bool))
def test_cutting_direction_for_area_operations(self):
"""Test cutting direction calculation for area operations."""
compass = PathOpBase.Compass(self.spindle_forward, "Area")
compass.cut_mode = "Climb"
# Test zigzag pattern alternates direction
cut_dir_0 = compass.get_cutting_direction("X-", 0, "zigzag")
cut_dir_1 = compass.get_cutting_direction("X-", 1, "zigzag")
self.assertNotEqual(cut_dir_0, cut_dir_1)
# Test unidirectional pattern keeps same direction
cut_dir_uni_0 = compass.get_cutting_direction("X-", 0, "unidirectional")
cut_dir_uni_1 = compass.get_cutting_direction("X-", 1, "unidirectional")
self.assertEqual(cut_dir_uni_0, cut_dir_uni_1)
def test_area_operation_error_handling(self):
"""Test error handling for area operation methods on perimeter operations."""
compass = PathOpBase.Compass(self.spindle_forward, "Perimeter")
# Should raise error when calling area methods on perimeter operation
with self.assertRaises(ValueError):
compass.get_step_direction("X-")
with self.assertRaises(ValueError):
compass.get_cutting_direction("X-")
def test_backward_compatibility(self):
"""Test that existing code still works (backward compatibility)."""
# Old style initialization (no operation_type parameter)
compass = PathOpBase.Compass(self.spindle_forward)
# Should default to perimeter operation
self.assertEqual(compass.operation_type, "Perimeter")
# All existing functionality should work
self.assertEqual(compass.spindle_dir, "Forward")
self.assertEqual(compass.cut_side, "Outside")
self.assertEqual(compass.cut_mode, "Climb")
self.assertEqual(compass.path_dir, "CW")
# Report should include operation_type
report = compass.report()
self.assertIn("operation_type", report)
self.assertEqual(report["operation_type"], "Perimeter")

View File

@@ -354,6 +354,7 @@ SET(PathPythonOp_SRCS
Path/Op/Drilling.py
Path/Op/Helix.py
Path/Op/MillFace.py
Path/Op/MillFacing.py
Path/Op/Pocket.py
Path/Op/PocketBase.py
Path/Op/PocketShape.py
@@ -384,6 +385,7 @@ SET(PathPythonOpGui_SRCS
Path/Op/Gui/FeatureExtension.py
Path/Op/Gui/Helix.py
Path/Op/Gui/MillFace.py
Path/Op/Gui/MillFacing.py
Path/Op/Gui/PathShapeTC.py
Path/Op/Gui/Pocket.py
Path/Op/Gui/PocketBase.py
@@ -412,7 +414,13 @@ SET(PathScripts_SRCS
SET(PathPythonBaseGenerator_SRCS
Path/Base/Generator/dogboneII.py
Path/Base/Generator/drill.py
Path/Base/Generator/facing_common.py
Path/Base/Generator/spiral_facing.py
Path/Base/Generator/zigzag_facing.py
Path/Base/Generator/directional_facing.py
Path/Base/Generator/bidirectional_facing.py
Path/Base/Generator/helix.py
Path/Base/Generator/linking.py
Path/Base/Generator/rotation.py
Path/Base/Generator/tapping.py
Path/Base/Generator/threadmilling.py
@@ -497,7 +505,7 @@ SET(Tests_SRCS
CAMTests/TestGrblPost.py
CAMTests/TestGrblLegacyPost.py
CAMTests/TestLinuxCNCPost.py
CAMTests/TestLinuxCNCLegacyPost.py
CAMTests/TestLinkingGenerator.py
CAMTests/TestMach3Mach4Post.py
CAMTests/TestMach3Mach4LegacyPost.py
CAMTests/TestMassoG3Post.py
@@ -511,6 +519,7 @@ SET(Tests_SRCS
CAMTests/TestPathDressupHoldingTags.py
CAMTests/TestPathDrillGenerator.py
CAMTests/TestPathDrillable.py
CAMTests/TestPathFacingGenerator.py
CAMTests/TestPathGeneratorDogboneII.py
CAMTests/TestPathGeom.py
CAMTests/TestPathHelix.py

View File

@@ -111,6 +111,7 @@
<file>panels/PageOpHelixEdit.ui</file>
<file>panels/PageOpPocketExtEdit.ui</file>
<file>panels/PageOpPocketFullEdit.ui</file>
<file>panels/PageOpMillFacingEdit.ui</file>
<file>panels/PageOpProbeEdit.ui</file>
<file>panels/PageOpProfileFullEdit.ui</file>
<file>panels/PageOpSlotEdit.ui</file>

View File

@@ -0,0 +1,273 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>350</width>
<height>400</height>
</rect>
</property>
<property name="windowTitle">
<string notr="true">Form</string>
</property>
<property name="styleSheet">
<string notr="true"/>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QFrame" name="toolOptions">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QGridLayout" name="gridLayout">
<property name="bottomMargin">
<number>3</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="toolController_label">
<property name="text">
<string>Tool controller</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="toolController">
<property name="toolTip">
<string> The tool and its settings to be used for this operation</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="coolantController"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="coolantController_label">
<property name="text">
<string>Coolant mode</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QCheckBox" name="editToolController">
<property name="text">
<string>Edit Tool Controller</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="facingOptions">
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<property name="bottomMargin">
<number>3</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="clearingPattern_label">
<property name="text">
<string>Clearing Pattern</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="clearingPattern">
<property name="toolTip">
<string>Pattern for clearing the face: ZigZag, Bidirectional, Directional, or Spiral</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="cutMode_label">
<property name="text">
<string>Cut Mode</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="cutMode">
<property name="toolTip">
<string>Climb or Conventional milling direction</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="angle_label">
<property name="text">
<string>Angle</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="angle">
<property name="toolTip">
<string>Rotation angle for angled facing operations</string>
</property>
<property name="suffix">
<string>°</string>
</property>
<property name="decimals">
<number>2</number>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>180.000000000000000</double>
</property>
<property name="singleStep">
<double>1.000000000000000</double>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QCheckBox" name="reverse">
<property name="text">
<string>Reverse</string>
</property>
<property name="toolTip">
<string>Reverse the cutting direction for the selected pattern</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="stepOver_label">
<property name="text">
<string>Step Over</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="stepOver">
<property name="toolTip">
<string>Stepover percentage for tool passes</string>
</property>
<property name="suffix">
<string>%</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="minimum">
<double>1.000000000000000</double>
</property>
<property name="maximum">
<double>100.000000000000000</double>
</property>
<property name="singleStep">
<double>5.000000000000000</double>
</property>
<property name="value">
<double>50.000000000000000</double>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="passExtension_label">
<property name="text">
<string>Pass Extension</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="Gui::QuantitySpinBox" name="passExtension">
<property name="toolTip">
<string>Distance to extend cuts beyond polygon boundary for tool disengagement</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="minimum">
<double>-999999999.000000000000000</double>
</property>
<property name="maximum">
<double>999999999.000000000000000</double>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="stockExtension_label">
<property name="text">
<string>Stock Extension</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="Gui::QuantitySpinBox" name="stockExtension">
<property name="toolTip">
<string>Extends the boundary in both direction</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="minimum">
<double>-999999999.000000000000000</double>
</property>
<property name="maximum">
<double>999999999.000000000000000</double>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QLabel" name="axialStockToLeave_label">
<property name="text">
<string>Stock To Leave (axial)</string>
</property>
</widget>
</item>
<item row="6" column="1">
<widget class="Gui::QuantitySpinBox" name="axialStockToLeave">
<property name="toolTip">
<string>Stock to leave for the operation</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="minimum">
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>999999999.000000000000000</double>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>Gui::QuantitySpinBox</class>
<extends>QWidget</extends>
<header>Gui/QuantitySpinBox.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -143,7 +143,7 @@ class CAMWorkbench(Workbench):
twodopcmdlist = [
"CAM_Profile",
"CAM_Pocket_Shape",
"CAM_MillFace",
"CAM_MillFacing",
"CAM_Helix",
"CAM_Adaptive",
"CAM_Slot",

View File

@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""
Bidirectional facing toolpath generator.
This module implements a bidirectional clearing pattern where passes alternate between
starting from the bottom edge and the top edge of the polygon, meeting in the middle.
Bottom passes step inward from min_t toward the center, while top passes step inward
from max_t toward the center. Passes are interleaved (bottom, top, bottom, top, etc.)
to minimize rapid move distances.
Feed moves (cutting) are aligned with the angle_degrees argument direction. Rapid moves
are perpendicular to the feed moves and always travel outside the clearing area along
the polygon edges.
This strategy always maintains either climb or conventional milling direction, but
alternates which side of the polygon is cut to maintain consistent milling direction
throughout.
"""
import FreeCAD
import Path
from . import facing_common
import math
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def bidirectional(
polygon,
tool_diameter,
stepover_percent,
pass_extension=None,
milling_direction="climb",
reverse=False,
angle_degrees=None,
):
if pass_extension is None:
pass_extension = tool_diameter * 0.5
# Establish frame from angle (default 0° = +X primary)
theta = float(angle_degrees) if angle_degrees is not None else 0.0
primary_vec, step_vec = facing_common.unit_vectors_from_angle(theta)
primary_vec = FreeCAD.Vector(primary_vec).normalize()
step_vec = FreeCAD.Vector(step_vec).normalize()
origin = polygon.BoundBox.Center
bb = polygon.BoundBox
z = bb.ZMin
# Compute projection bounds
min_s, max_s = facing_common.project_bounds(polygon, primary_vec, origin)
min_t, max_t = facing_common.project_bounds(polygon, step_vec, origin)
# ------------------------------------------------------------------
# Use the proven generate_t_values (with coverage fix) for full coverage
# ------------------------------------------------------------------
step_positions = facing_common.generate_t_values(
polygon, step_vec, tool_diameter, stepover_percent, origin
)
tool_radius = tool_diameter / 2.0
stepover_distance = tool_diameter * stepover_percent / 100.0
# Coverage guarantee at ≥100% stepover (exact same fix as zigzag/directional)
if stepover_percent >= 99.9 and step_positions:
min_covered = min(step_positions) - tool_radius
max_covered = max(step_positions) + tool_radius
added = False
if max_covered < max_t - 1e-4:
step_positions.append(step_positions[-1] + stepover_distance)
added = True
if min_covered > min_t + 1e-4:
step_positions.insert(0, step_positions[0] - stepover_distance)
added = True
if added:
Path.Log.info(
"Bidirectional facing: Added extra pass(es) for full coverage at ≥100% stepover"
)
center = (min_t + max_t) / 2.0
# Split into bottom (≤ center) and top (> center)
bottom_positions = [t for t in step_positions if t <= center] # ascending = outer → inner
top_positions = [t for t in step_positions if t > center][::-1] # descending = outer → inner
# Interleave, starting with top if reverse=True
all_passes = []
max_passes = max(len(bottom_positions), len(top_positions))
for i in range(max_passes):
if reverse:
if i < len(top_positions):
all_passes.append(("top", top_positions[i]))
if i < len(bottom_positions):
all_passes.append(("bottom", bottom_positions[i]))
else:
if i < len(bottom_positions):
all_passes.append(("bottom", bottom_positions[i]))
if i < len(top_positions):
all_passes.append(("top", top_positions[i]))
Path.Log.debug(
f"Bidirectional: {len(all_passes)} passes ({len(bottom_positions)} bottom, {len(top_positions)} top)"
)
commands = []
tool_radius = tool_diameter / 2.0
engagement_offset = facing_common.calculate_engagement_offset(tool_diameter, stepover_percent)
total_extension = pass_extension + tool_radius + engagement_offset
start_s = min_s - total_extension
end_s = max_s + total_extension
for side, t in all_passes:
# Same direction for all passes on the same side → short outside rapids
if side == "bottom":
if milling_direction == "climb":
p_start, p_end = end_s, start_s # right → left
else:
p_start, p_end = start_s, end_s # left → right
else: # top
if milling_direction == "climb":
p_start, p_end = start_s, end_s # left → right
else:
p_start, p_end = end_s, start_s # right → left
start_point = origin + primary_vec * p_start + step_vec * t
end_point = origin + primary_vec * p_end + step_vec * t
start_point.z = z
end_point.z = z
if not all(
math.isfinite(c) for c in [start_point.x, start_point.y, end_point.x, end_point.y]
):
continue
if commands:
# Short perpendicular rapid at cutting height (outside the material)
commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y}))
else:
# First pass include Z for preamble replacement
commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y, "Z": z}))
commands.append(Path.Command("G1", {"X": end_point.x, "Y": end_point.y, "Z": z}))
return commands

View File

@@ -0,0 +1,201 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""
Directional (unidirectional) facing toolpath generator.
This module implements the unidirectional clearing pattern that cuts in the same
direction for every pass, providing consistent surface finish.
Feed moves (cutting) are aligned with the angle_degrees argument direction.
At the end of each cutting pass, the cutter retracts to safe height and moves laterally to
the start position of the next pass.
This strategy always maintains either climb or conventional milling direction.
"""
import FreeCAD
import Path
from . import facing_common
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def directional(
polygon,
tool_diameter,
stepover_percent,
pass_extension=None,
retract_height=None,
milling_direction="climb",
reverse=False,
angle_degrees=None,
):
import math
import Path
import FreeCAD
from . import facing_common
if pass_extension is None:
pass_extension = tool_diameter * 0.5
theta = float(angle_degrees) if angle_degrees is not None else 0.0
primary_vec, step_vec = facing_common.unit_vectors_from_angle(theta)
primary_vec = FreeCAD.Vector(primary_vec).normalize()
step_vec = FreeCAD.Vector(step_vec).normalize()
origin = polygon.BoundBox.Center
z = polygon.BoundBox.ZMin
min_s, max_s = facing_common.project_bounds(polygon, primary_vec, origin)
min_t, max_t = facing_common.project_bounds(polygon, step_vec, origin)
if not all(math.isfinite(x) for x in [min_s, max_s, min_t, max_t]):
Path.Log.error("Directional: non-finite projection bounds; aborting")
return []
step_positions = facing_common.generate_t_values(
polygon, step_vec, tool_diameter, stepover_percent, origin
)
tool_radius = tool_diameter / 2.0
stepover_distance = tool_diameter * (stepover_percent / 100.0)
if stepover_percent >= 99.9 and step_positions:
min_covered = min(step_positions) - tool_radius
max_covered = max(step_positions) + tool_radius
added = False
if max_covered < max_t - 1e-4:
step_positions.append(step_positions[-1] + stepover_distance)
added = True
if min_covered > min_t + 1e-4:
step_positions.insert(0, step_positions[0] - stepover_distance)
added = True
if added:
Path.Log.info("Directional: Added extra pass(es) for full coverage at high stepover")
# Reverse = mirror positions around center (exactly like bidirectional) to preserve engagement offset on the starting side
if reverse:
center = (min_t + max_t) / 2.0
step_positions = [2 * center - t for t in step_positions]
Path.Log.debug(f"Directional (fixed): {len(step_positions)} passes")
# Use full-length passes exactly like bidirectional (no slice_wire_segments)
total_extension = (
pass_extension
+ tool_radius
+ facing_common.calculate_engagement_offset(tool_diameter, stepover_percent)
)
start_s = min_s - total_extension
end_s = max_s + total_extension
commands = []
kept_segments = 0
for t in step_positions:
# Cutting direction reverse flips it to maintain climb/conventional preference
if milling_direction == "climb":
if reverse:
p_start, p_end = start_s, end_s
else:
p_start, p_end = end_s, start_s
else: # conventional
if reverse:
p_start, p_end = end_s, start_s
else:
p_start, p_end = start_s, end_s
start_point = origin + primary_vec * p_start + step_vec * t
end_point = origin + primary_vec * p_end + step_vec * t
start_point.z = z
end_point.z = z
if commands: # not first pass
if retract_height is not None:
commands.append(Path.Command("G0", {"Z": retract_height}))
commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y}))
commands.append(Path.Command("G0", {"Z": z}))
else:
commands.append(
Path.Command("G0", {"X": start_point.x, "Y": start_point.y, "Z": z})
)
else:
commands.append(Path.Command("G0", {"X": start_point.x, "Y": start_point.y, "Z": z}))
commands.append(Path.Command("G1", {"X": end_point.x, "Y": end_point.y, "Z": z}))
kept_segments += 1
Path.Log.debug(f"Directional: generated {kept_segments} segments")
# Fallback: if nothing kept due to numeric guards, emit a single mid-line pass across bbox
if kept_segments == 0:
t_candidates = []
# mid, min, max t positions
t_candidates.append(0.5 * (min_t + max_t))
t_candidates.append(min_t)
t_candidates.append(max_t)
for t in t_candidates:
intervals = facing_common.slice_wire_segments(polygon, primary_vec, step_vec, t, origin)
if not intervals:
continue
s0, s1 = intervals[0]
start_s = max(s0 - pass_extension, min_s - s_margin)
end_s = min(s1 + pass_extension, max_s + s_margin)
if end_s <= start_s:
continue
if milling_direction == "climb":
p_start, p_end = start_s, end_s
else:
p_start, p_end = end_s, start_s
if reverse:
p_start, p_end = p_end, p_start
sp = (
FreeCAD.Vector(origin)
.add(FreeCAD.Vector(primary_vec).multiply(p_start))
.add(FreeCAD.Vector(step_vec).multiply(t))
)
ep = (
FreeCAD.Vector(origin)
.add(FreeCAD.Vector(primary_vec).multiply(p_end))
.add(FreeCAD.Vector(step_vec).multiply(t))
)
sp.z = z
ep.z = z
# Minimal preamble
if retract_height is not None:
commands.append(Path.Command("G0", {"Z": retract_height}))
commands.append(Path.Command("G0", {"X": sp.x, "Y": sp.y}))
commands.append(Path.Command("G0", {"Z": z}))
else:
commands.append(Path.Command("G1", {"X": sp.x, "Y": sp.y, "Z": z}))
commands.append(Path.Command("G1", {"X": ep.x, "Y": ep.y, "Z": z}))
break
return commands

View File

@@ -0,0 +1,481 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""
Common helper functions for facing toolpath generators.
This module contains shared geometric and utility functions used by
different facing strategies (spiral, zigzag, etc.).
"""
import FreeCAD
import Path
import Part
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def extract_polygon_geometry(polygon):
"""Extract edges and corners from a rectangular polygon."""
edges = []
corners = []
# Validate that polygon has exactly 4 edges (rectangle)
if len(polygon.Edges) != 4:
raise ValueError("Polygon must be rectangular (4 edges)")
for edge in polygon.Edges:
edge_vector = edge.Vertexes[1].Point.sub(edge.Vertexes[0].Point)
edges.append(
{
"start": edge.Vertexes[0].Point,
"end": edge.Vertexes[1].Point,
"vector": edge_vector,
"length": edge.Length,
}
)
corners.append(edge.Vertexes[0].Point)
return {"edges": edges, "corners": corners}
def select_primary_step_edges(edges, axis_preference):
"""Select primary and step edges based on axis preference."""
edge_lengths = [edge["length"] for edge in edges]
unique_lengths = list(set(edge_lengths))
if len(unique_lengths) == 1:
# Square case - all edges are the same length
# For squares, we need to pick two perpendicular edges
primary_edge = edges[0]
step_edge = None
for edge in edges[1:]:
# Check if this edge is perpendicular to the primary edge
dot_product = abs(primary_edge["vector"].normalize().dot(edge["vector"].normalize()))
if dot_product < 0.1: # Nearly perpendicular
step_edge = edge
break
if step_edge is None:
# Fallback - just use adjacent edges (should be perpendicular for rectangles)
step_edge = edges[1]
elif len(unique_lengths) == 2:
# Rectangle case - two different edge lengths
long_length = max(unique_lengths)
short_length = min(unique_lengths)
# Find edges with long and short lengths
long_edges = [edge for edge in edges if abs(edge["length"] - long_length) < 1e-6]
short_edges = [edge for edge in edges if abs(edge["length"] - short_length) < 1e-6]
# Select primary edge based on preference
if axis_preference == "long":
primary_edge = long_edges[0]
step_edge = short_edges[0]
else: # "short"
primary_edge = short_edges[0]
step_edge = long_edges[0]
else:
raise ValueError("Polygon must be rectangular with 1 or 2 unique edge lengths")
# Normalize vectors properly
primary_vec = primary_edge["vector"]
step_vec = step_edge["vector"]
# Manual normalization to ensure it works correctly
primary_length_calc = primary_vec.Length
step_length_calc = step_vec.Length
if primary_length_calc > 0:
primary_vec = primary_vec.multiply(1.0 / primary_length_calc)
if step_length_calc > 0:
step_vec = step_vec.multiply(1.0 / step_length_calc)
return {
"primary_edge": primary_edge,
"step_edge": step_edge,
"primary_vec": primary_vec,
"step_vec": step_vec,
"primary_length": primary_edge["length"],
"step_length": step_edge["length"],
}
def select_starting_corner(corners, primary_vec, step_vec, milling_direction):
"""
Select starting corner based on milling direction and edge orientation.
For climb milling (clockwise spiral), start from the corner with minimum
combined projection (bottom-left in the primary/step coordinate system).
For conventional milling (counter-clockwise spiral), start from the opposite corner.
Args:
corners (list): List of corner points from the polygon
primary_vec (FreeCAD.Vector): Primary direction vector (normalized)
step_vec (FreeCAD.Vector): Step direction vector (normalized)
milling_direction (str): "climb" or "conventional"
Returns:
FreeCAD.Vector: The selected starting corner point
"""
if len(corners) < 4:
return corners[0]
# Find the corner with minimum combined projection in the primary/step coordinate system
# This is the "origin" corner in the rotated coordinate frame
min_projection = float("inf")
selected_corner = corners[0]
for corner in corners:
# Project corner onto the primary and step vectors
primary_proj = corner.dot(primary_vec)
step_proj = corner.dot(step_vec)
# Combined projection gives distance from origin in direction space
combined_proj = primary_proj + step_proj
if combined_proj < min_projection:
min_projection = combined_proj
selected_corner = corner
# For conventional milling, start from the diagonally opposite corner
if milling_direction == "conventional":
# Find the corner that's furthest from the selected corner (diagonal opposite)
max_distance = 0
opposite_corner = selected_corner
for corner in corners:
distance = selected_corner.distanceToPoint(corner)
if distance > max_distance:
max_distance = distance
opposite_corner = corner
selected_corner = opposite_corner
return selected_corner
def get_angled_polygon(wire, angle):
"""
Create a rotated bounding box that fully contains the input wire.
This function generates a rectangular wire representing a bounding box rotated by the
specified angle that completely encompasses the original wire. The algorithm works by:
1. Rotating the original wire in the opposite direction to align it optimally
2. Computing the axis-aligned bounding box of the rotated wire
3. Rotating the bounding box back to the desired angle
Args:
wire (Part.Wire): A closed wire to create the rotated bounding box for
angle (float): Rotation angle in degrees (positive = counterclockwise)
Returns:
Part.Wire: A closed rectangular wire representing the rotated bounding box
Raises:
ValueError: If the input wire is not closed
"""
if not wire.isClosed():
raise ValueError("Wire must be closed")
# Get the center point of the original wire for all rotations
center = wire.BoundBox.Center
rotation_axis = FreeCAD.Vector(0, 0, 1) # Z-axis
Path.Log.debug(f"Original wire center: {center}")
# Step 1: Rotate the wire in the opposite direction to align optimally with axes
temp_wire = wire.copy()
temp_wire.rotate(center, rotation_axis, -angle)
# Step 2: Get the axis-aligned bounding box of the rotated wire
bounding_box = temp_wire.BoundBox
Path.Log.debug(f"Rotated bounding box center: {bounding_box.Center}")
# Create the four corners of the bounding box rectangle
corners = [
FreeCAD.Vector(bounding_box.XMin, bounding_box.YMin, bounding_box.ZMin),
FreeCAD.Vector(bounding_box.XMax, bounding_box.YMin, bounding_box.ZMin),
FreeCAD.Vector(bounding_box.XMax, bounding_box.YMax, bounding_box.ZMin),
FreeCAD.Vector(bounding_box.XMin, bounding_box.YMax, bounding_box.ZMin),
]
# Close the polygon by adding the first corner again
corners.append(corners[0])
bounding_wire = Part.makePolygon(corners)
# Step 3: Rotate the bounding box to the desired angle
bounding_wire.rotate(center, rotation_axis, angle)
return bounding_wire
def calculate_engagement_offset(tool_diameter, stepover_percent):
"""Calculate the engagement offset for proper tool engagement.
For 50% stepover, engagement should be 50% of tool diameter.
engagement_offset is how much of the tool is NOT engaged.
"""
return tool_diameter * (1.0 - stepover_percent / 100.0)
def validate_inputs(
wire,
tool_diameter,
stepover_percent,
start_depth,
final_depth,
start_point,
pattern,
milling_direction,
):
"""Validate all input parameters for facing operations."""
# Validate wire is closed
if not wire.isClosed():
raise ValueError("Wire must be a closed polygon for facing operation")
# Validate wire is co-planar with XY plane
bb = wire.BoundBox
z_tolerance = 0.001 # 1 micron tolerance
if abs(bb.ZMax - bb.ZMin) > z_tolerance:
raise ValueError("Wire must be co-planar with XY plane for facing operation")
# Validate tool diameter
if tool_diameter <= 0:
raise ValueError("Tool diameter must be positive")
if tool_diameter > 100: # Reasonable upper limit
raise ValueError("Tool diameter too large (>100mm)")
# Validate stepover percentage
if stepover_percent <= 0:
raise ValueError("Stepover percentage must be positive")
if stepover_percent > 100:
Path.Log.warning(f"Stepover percentage ({stepover_percent}%) is greater than 100%")
if stepover_percent > 200:
raise ValueError("Stepover percentage too large (>200%)")
if stepover_percent < 1:
Path.Log.warning(
f"Very small stepover percentage ({stepover_percent}%) may result in excessive cutting time"
)
# Validate depths
if start_depth == final_depth:
raise ValueError("Start depth must be different from final depth")
# Validate start point if provided
if start_point is not None:
if not hasattr(start_point, "x") or not hasattr(start_point, "y"):
raise ValueError("Start point must be a FreeCAD.Vector with x and y coordinates")
# Check if start point is too close to polygon (within bounding box + tool radius)
tool_radius = tool_diameter / 2.0
expanded_bb_xmin = bb.XMin - tool_radius
expanded_bb_xmax = bb.XMax + tool_radius
expanded_bb_ymin = bb.YMin - tool_radius
expanded_bb_ymax = bb.YMax + tool_radius
if (
expanded_bb_xmin <= start_point.x <= expanded_bb_xmax
and expanded_bb_ymin <= start_point.y <= expanded_bb_ymax
):
raise ValueError("Start point is too close to the polygon to be cleared")
# Validate pattern
valid_patterns = ["zigzag", "unidirectional", "spiral"]
if pattern not in valid_patterns:
raise ValueError(f"Invalid pattern: {pattern}. Must be one of {valid_patterns}")
# Validate milling direction
valid_directions = ["climb", "conventional"]
if milling_direction not in valid_directions:
raise ValueError(
f"Invalid milling direction: {milling_direction}. Must be one of {valid_directions}"
)
def align_edges_to_angle(primary_vec, step_vec, primary_length, step_length, angle_degrees):
"""Ensure primary_vec aligns with the desired angle direction.
If the provided angle direction is closer to step_vec than primary_vec,
swap the vectors and their associated lengths so primary_vec matches the
intended cut direction. This prevents sudden flips around angles like 46°/90°.
"""
if angle_degrees is None:
return primary_vec, step_vec, primary_length, step_length
# Build a unit vector for the angle in the XY plane (degrees)
import math
rad = math.radians(angle_degrees)
dir_vec = FreeCAD.Vector(math.cos(rad), math.sin(rad), 0)
# Compare absolute alignment; both primary_vec and step_vec should be normalized already by caller
# If not, normalize here safely.
def norm(v):
L = v.Length
return v if L == 0 else v.multiply(1.0 / L)
p = norm(primary_vec)
s = norm(step_vec)
if abs(dir_vec.dot(p)) >= abs(dir_vec.dot(s)):
return p, s, primary_length, step_length
else:
# Swap so primary follows requested angle direction
return s, p, step_length, primary_length
def unit_vectors_from_angle(angle_degrees):
"""Return (primary_vec, step_vec) unit vectors from angle in degrees in XY plane.
primary_vec points in the angle direction. step_vec is +90° rotation (left normal).
"""
import math
rad = math.radians(angle_degrees)
p = FreeCAD.Vector(math.cos(rad), math.sin(rad), 0)
# +90° rotation
s = FreeCAD.Vector(-math.sin(rad), math.cos(rad), 0)
return p, s
def project_bounds(wire, vec, origin):
"""Project all vertices of wire onto vec relative to origin and return (min_t, max_t)."""
ts = []
for v in wire.Vertexes:
ts.append(vec.dot(v.Point.sub(origin)))
return (min(ts), max(ts))
def generate_t_values(wire, step_vec, tool_diameter, stepover_percent, origin):
"""Generate step positions along step_vec with engagement offset and stepover.
The first pass engages (100 - stepover_percent)% of the tool diameter.
For 50% stepover, first pass engages 50% of tool diameter.
Tool center is positioned so the engaged portion touches the polygon edge.
"""
tool_radius = tool_diameter / 2.0
stepover = tool_diameter * (stepover_percent / 100.0)
min_t, max_t = project_bounds(wire, step_vec, origin)
# Calculate how much of the tool should engage on first pass
# For 20% stepover: engage 20% of diameter
# For 50% stepover: engage 50% of diameter
engagement_amount = tool_diameter * (stepover_percent / 100.0)
# Start position: tool center positioned so engagement_amount reaches polygon edge
# Tool center at: min_t - tool_radius + engagement_amount
# This positions the engaged portion at the polygon edge
t = min_t - tool_radius + engagement_amount
t_end = max_t + tool_radius - engagement_amount
values = []
# Guard against zero/negative stepover
if stepover <= 0:
return [t]
while t <= t_end + 1e-9:
values.append(t)
t += stepover
return values
def slice_wire_segments(wire, primary_vec, step_vec, t, origin):
"""Intersect the polygon wire with the infinite line at step coordinate t.
Returns a sorted list of (s_in, s_out) intervals along primary_vec within the polygon.
"""
import math
# For diagnostics
bb = wire.BoundBox
diag = math.hypot(bb.XLength, bb.YLength)
s_debug_threshold = max(1.0, diag * 10.0)
s_vals = []
# Use scale-relative tolerance for near-parallel detection
eps_abs = 1e-12
t_scale = max(abs(t), diag, 1.0)
eps_parallel = max(eps_abs, t_scale * 1e-9)
# Iterate edges
for edge in wire.Edges:
A = FreeCAD.Vector(edge.Vertexes[0].Point)
B = FreeCAD.Vector(edge.Vertexes[1].Point)
a = step_vec.dot(A.sub(origin))
b = step_vec.dot(B.sub(origin))
da = a - t
db = b - t
# Check for crossing; ignore edges parallel to the step line (db == da)
denom = b - a
if not (math.isfinite(a) and math.isfinite(b) and math.isfinite(denom)):
continue
# Reject near-parallel edges using scale-relative tolerance
if abs(denom) < eps_parallel:
continue
# Crossing if signs differ or one is zero
if da == 0.0 and db == 0.0:
# Line coincident with edge: skip to avoid degenerate doubles
continue
if (da <= 0 and db >= 0) or (da >= 0 and db <= 0):
# Linear interpolation factor u from A to B
u = (t - a) / (b - a)
if not math.isfinite(u):
continue
# Strict bounds check: u must be in [0,1] with tight tolerance
# Reject if u is way outside - indicates numerical instability
if u < -0.01 or u > 1.01:
continue
# Clamp u to [0,1] for valid intersections
u = max(0.0, min(1.0, u))
# Interpolate using scalars to avoid any in-place vector mutation
ux = A.x + u * (B.x - A.x)
uy = A.y + u * (B.y - A.y)
uz = A.z + u * (B.z - A.z)
s = (
primary_vec.x * (ux - origin.x)
+ primary_vec.y * (uy - origin.y)
+ primary_vec.z * (uz - origin.z)
)
# Final sanity check: reject if s is absurdly large
if not math.isfinite(s) or abs(s) > diag * 100.0:
continue
s_vals.append(s)
# Sort and pair successive intersections into interior segments
s_vals.sort()
segments = []
for i in range(0, len(s_vals) - 1, 2):
s0 = s_vals[i]
s1 = s_vals[i + 1]
if s1 > s0 + 1e-9 and math.isfinite(s0) and math.isfinite(s1):
segments.append((s0, s1))
return segments

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
import Part
import Path
from FreeCAD import Vector
from typing import List, Optional
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def get_linking_moves(
start_position: Vector,
target_position: Vector,
local_clearance: float,
global_clearance: float,
tool_shape: Part.Shape, # required placeholder
solids: Optional[List[Part.Shape]] = None,
retract_height_offset: Optional[float] = None,
) -> list:
if start_position == target_position:
return []
if local_clearance > global_clearance:
raise ValueError("Local clearance must not exceed global clearance")
if retract_height_offset is not None and retract_height_offset < 0:
raise ValueError("Retract offset must be positive")
# Collision model
collision_model = None
if solids:
solids = [s for s in solids if s]
if len(solids) == 1:
collision_model = solids[0]
elif len(solids) > 1:
collision_model = Part.makeFuse(solids)
# Determine candidate heights
if retract_height_offset is not None:
if retract_height_offset > 0:
retract_height = max(start_position.z, target_position.z) + retract_height_offset
candidate_heights = {retract_height, local_clearance, global_clearance}
else: # explicitly 0
retract_height = max(start_position.z, target_position.z)
candidate_heights = {retract_height, local_clearance, global_clearance}
else:
candidate_heights = {local_clearance, global_clearance}
heights = sorted(candidate_heights, reverse=True)
# Try each height
for height in heights:
wire = make_linking_wire(start_position, target_position, height)
if is_wire_collision_free(wire, collision_model):
cmds = Path.fromShape(wire).Commands
for cmd in cmds:
cmd.Name = "G0"
return cmds
raise RuntimeError("No collision-free path found between start and target positions")
def make_linking_wire(start: Vector, target: Vector, z: float) -> Part.Wire:
p1 = Vector(start.x, start.y, z)
p2 = Vector(target.x, target.y, z)
e1 = Part.makeLine(start, p1)
e2 = Part.makeLine(p1, p2)
e3 = Part.makeLine(p2, target)
return Part.Wire([e1, e2, e3])
def is_wire_collision_free(
wire: Part.Wire, solid: Optional[Part.Shape], tolerance: float = 0.001
) -> bool:
if not solid:
return True
distance = wire.distToShape(solid)[0]
return distance >= tolerance

View File

@@ -0,0 +1,319 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""
Spiral facing toolpath generator.
This module implements the spiral clearing pattern for rectangular polygons,
including support for angled rectangles and proper tool engagement.
"""
import FreeCAD
import Path
from . import facing_common
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def generate_spiral_corners(
start_corner, primary_vec, step_vec, primary_length, step_length, inward_offset
):
"""
Generate the four corners of a spiral layer offset inward from the original polygon.
The start_corner is assumed to be the corner with minimum combined projection
(bottom-left in the primary/step coordinate system). The offset moves inward
by adding positive offsets in both primary and step directions.
"""
# Calculate the four corners of this layer (reduced by inward offset)
adjusted_primary_length = max(0, primary_length - 2 * inward_offset)
adjusted_step_length = max(0, step_length - 2 * inward_offset)
# Move the starting corner inward by the offset amount
# Since start_corner is the minimum projection corner, we move inward by adding offsets
inward_primary = FreeCAD.Vector(primary_vec).multiply(inward_offset)
inward_step = FreeCAD.Vector(step_vec).multiply(inward_offset)
# The actual starting corner for this layer is offset inward
layer_start_corner = FreeCAD.Vector(start_corner).add(inward_primary).add(inward_step)
# Build rectangle from the offset starting corner with reduced dimensions
corner1 = FreeCAD.Vector(layer_start_corner)
corner2 = FreeCAD.Vector(corner1).add(
FreeCAD.Vector(primary_vec).multiply(adjusted_primary_length)
)
corner3 = FreeCAD.Vector(corner2).add(FreeCAD.Vector(step_vec).multiply(adjusted_step_length))
corner4 = FreeCAD.Vector(corner3).add(
FreeCAD.Vector(primary_vec).multiply(-adjusted_primary_length)
)
return [corner1, corner2, corner3, corner4]
def generate_layer_path(
layer_corners,
next_layer_start,
layer_num,
z,
clockwise,
start_corner_index=0,
is_last_layer=False,
):
"""
Generate the toolpath commands for a single spiral layer.
For a true spiral, we do all 4 sides of the rectangle, but the 4th side only goes
partway - it stops at the starting position of the next layer. This creates the
continuous spiral effect.
"""
commands = []
# Set Z coordinate for all corners
for corner in layer_corners:
corner.z = z
# For the first layer, start with a rapid move to the starting corner
if layer_num == 0:
commands.append(
Path.Command(
"G0",
{
"X": layer_corners[start_corner_index].x,
"Y": layer_corners[start_corner_index].y,
"Z": z,
},
)
)
# Generate the path: go around all 4 sides
if clockwise:
# Clockwise: start_corner -> corner 1 -> corner 2 -> corner 3 -> back toward start
for i in range(1, 4):
corner_idx = (start_corner_index + i) % 4
commands.append(
Path.Command(
"G1",
{"X": layer_corners[corner_idx].x, "Y": layer_corners[corner_idx].y, "Z": z},
)
)
# 4th side: go back toward start, but stop at next layer's starting position
if not is_last_layer and next_layer_start:
next_layer_start.z = z
commands.append(
Path.Command("G1", {"X": next_layer_start.x, "Y": next_layer_start.y, "Z": z})
)
else:
# Last layer: complete the rectangle
commands.append(
Path.Command(
"G1",
{
"X": layer_corners[start_corner_index].x,
"Y": layer_corners[start_corner_index].y,
"Z": z,
},
)
)
else:
# Counter-clockwise: start_corner -> corner 3 -> corner 2 -> corner 1 -> back toward start
for i in range(1, 4):
corner_idx = (start_corner_index - i) % 4
commands.append(
Path.Command(
"G1",
{"X": layer_corners[corner_idx].x, "Y": layer_corners[corner_idx].y, "Z": z},
)
)
# 4th side: go back toward start, but stop at next layer's starting position
if not is_last_layer and next_layer_start:
next_layer_start.z = z
commands.append(
Path.Command("G1", {"X": next_layer_start.x, "Y": next_layer_start.y, "Z": z})
)
else:
# Last layer: complete the rectangle
commands.append(
Path.Command(
"G1",
{
"X": layer_corners[start_corner_index].x,
"Y": layer_corners[start_corner_index].y,
"Z": z,
},
)
)
return commands
def spiral(
polygon,
tool_diameter,
stepover_percent,
milling_direction="climb",
reverse=False,
angle_degrees=None,
):
"""
Generate a spiral clearing pattern for rectangular polygons with guaranteed full coverage.
The radial stepover is automatically adjusted (slightly if required to ensure the tool edge reaches exactly the center
in the limiting direction. This eliminates any uncleared areas in the center regardless of stepover% value.
First engagement is preserved exactly at the requested percentage.
"""
import math
import Path
import FreeCAD
from . import facing_common
theta = float(angle_degrees) if angle_degrees is not None else 0.0
primary_vec, step_vec = facing_common.unit_vectors_from_angle(theta)
primary_vec = FreeCAD.Vector(primary_vec).normalize()
step_vec = FreeCAD.Vector(step_vec).normalize()
polygon_info = facing_common.extract_polygon_geometry(polygon)
corners = polygon_info["corners"]
origin = facing_common.select_starting_corner(corners, primary_vec, step_vec, "climb")
min_s, max_s = facing_common.project_bounds(polygon, primary_vec, origin)
min_t, max_t = facing_common.project_bounds(polygon, step_vec, origin)
primary_length = max_s - min_s
step_length = max_t - min_t
tool_radius = tool_diameter / 2.0
stepover_dist = tool_diameter * (stepover_percent / 100.0)
if stepover_dist > tool_diameter * 1.000001:
stepover_dist = tool_diameter
# Calculate adjusted stepover to guarantee center coverage
starting_inset = tool_radius - stepover_dist
limiting_half = min(primary_length, step_length) / 2.0
total_radial_distance = limiting_half - tool_radius - starting_inset
if total_radial_distance <= 0:
actual_stepover = stepover_dist
else:
number_of_intervals = math.ceil(total_radial_distance / stepover_dist)
actual_stepover = total_radial_distance / number_of_intervals
Path.Log.debug(
f"Spiral: adjusted stepover {stepover_dist:.4f}{actual_stepover:.4f} mm, intervals={number_of_intervals if total_radial_distance > 0 else 0}"
)
# Standard initial_offset (preserves first engagement exactly)
initial_offset = -tool_radius + stepover_dist
z = polygon.BoundBox.ZMin
clockwise = milling_direction == "conventional"
start_corner_index = 0 if clockwise else 2
if reverse:
start_corner_index = (start_corner_index + 2) % 4
commands = []
k = 0
first_move_done = False
while True:
current_offset = initial_offset + k * actual_stepover
s0 = min_s + current_offset
s1 = max_s - current_offset
t0 = min_t + current_offset
t1 = max_t - current_offset
if s0 >= s1 or t0 >= t1:
break
corners_st = [(s0, t0), (s1, t0), (s1, t1), (s0, t1)]
if clockwise:
order = [(start_corner_index + i) % 4 for i in range(4)]
else:
order = [(start_corner_index - i) % 4 for i in range(4)]
def st_to_xy(s, t):
return origin + primary_vec * s + step_vec * t
start_idx = order[0]
start_xy = st_to_xy(*corners_st[start_idx])
start_xy.z = z
if not first_move_done:
commands.append(Path.Command("G0", {"X": start_xy.x, "Y": start_xy.y, "Z": z}))
first_move_done = True
# Sides 1-3: full
for i in range(1, 4):
c_xy = st_to_xy(*corners_st[order[i]])
c_xy.z = z
commands.append(Path.Command("G1", {"X": c_xy.x, "Y": c_xy.y, "Z": z}))
# Prepare transition to next layer (partial 4th side)
next_offset = current_offset + actual_stepover
s0n = min_s + next_offset
s1n = max_s - next_offset
t0n = min_t + next_offset
t1n = max_t - next_offset
if s0n < s1n and t0n < t1n:
# Determine which edge we are on for the 4th side and compute intersection
# the transition point on that edge
if clockwise:
if start_corner_index == 0:
transition_xy = st_to_xy(s0, t0n)
elif start_corner_index == 1:
transition_xy = st_to_xy(s1n, t0)
elif start_corner_index == 2:
transition_xy = st_to_xy(s1, t1n)
else:
transition_xy = st_to_xy(s0n, t1)
else: # counter-clockwise
if start_corner_index == 0:
transition_xy = st_to_xy(s0n, t0)
elif start_corner_index == 1:
transition_xy = st_to_xy(s1, t0n)
elif start_corner_index == 2:
transition_xy = st_to_xy(s1n, t1)
else:
transition_xy = st_to_xy(s0, t1n)
transition_xy.z = z
commands.append(
Path.Command("G1", {"X": transition_xy.x, "Y": transition_xy.y, "Z": z})
)
k += 1
else:
# Final layer - close back to start
commands.append(Path.Command("G1", {"X": start_xy.x, "Y": start_xy.y, "Z": z}))
break
return commands

View File

@@ -0,0 +1,329 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""
Zigzag facing toolpath generator.
This module implements the zigzag clearing pattern that cuts back and forth
across the polygon in alternating directions, creating a continuous zigzag pattern.
"""
import FreeCAD
import Path
from . import facing_common
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def _create_link(
prev_seg, next_seg, link_mode, link_radius, stepover_distance, tool_radius, primary_vec, z
):
"""
Create linking moves between two segments.
Args:
prev_seg: Previous segment dict with 'end', 'side', 't' keys
next_seg: Next segment dict with 'start', 'side', 't' keys
link_mode: "arc" or "straight"
link_radius: Radius for arc links (None = auto)
stepover_distance: Distance between passes
tool_radius: Tool radius
primary_vec: Primary direction vector
z: Z height
Returns:
List of Path.Command objects for the link
"""
import math
P = prev_seg["end"]
Q = next_seg["start"]
# Safety checks
if not (
math.isfinite(P.x) and math.isfinite(P.y) and math.isfinite(Q.x) and math.isfinite(Q.y)
):
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Check if we should use arc mode
if link_mode != "arc":
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Calculate chord vector and distance
dx = Q.x - P.x
dy = Q.y - P.y
chord_length = math.sqrt(dx * dx + dy * dy)
# Minimum chord length check
if chord_length < 1e-6:
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Natural semicircle radius for 180° arc
r0 = chord_length / 2.0
# Use specified radius or default to natural radius
if link_radius is not None and link_radius > r0:
r = link_radius
else:
r = r0
# Minimum radius check
if r < 1e-6:
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Calculate arc center
# Midpoint of chord
mx = 0.5 * (P.x + Q.x)
my = 0.5 * (P.y + Q.y)
# Normal to chord (rotate chord by 90°)
# Two options: rotate left (-dy, dx) or rotate right (dy, -dx)
# For zigzag, we need the arc to bulge in the primary direction away from center
# Use cross product to determine which way to rotate the chord
# The arc should bulge in the direction perpendicular to the chord
# and in the same primary direction as the segment side
# For vertical chords (dy != 0, dx ≈ 0), normal is horizontal
# For horizontal chords (dx != 0, dy ≈ 0), normal is vertical
# Calculate both possible normals (90° rotations of chord)
n1x = -dy / chord_length # Rotate left
n1y = dx / chord_length
n2x = dy / chord_length # Rotate right
n2y = -dx / chord_length
# Choose normal based on which side we're on
# The arc should bulge in the primary direction indicated by prev_seg['side']
outward_x = primary_vec.x * prev_seg["side"]
outward_y = primary_vec.y * prev_seg["side"]
dot1 = n1x * outward_x + n1y * outward_y
dot2 = n2x * outward_x + n2y * outward_y
if dot1 > dot2:
nx, ny = n1x, n1y
Path.Log.debug(f" Chose n1: ({nx:.3f}, {ny:.3f}), dot1={dot1:.3f} > dot2={dot2:.3f}")
else:
nx, ny = n2x, n2y
Path.Log.debug(f" Chose n2: ({nx:.3f}, {ny:.3f}), dot2={dot2:.3f} > dot1={dot1:.3f}")
Path.Log.debug(
f" Chord: dx={dx:.3f}, dy={dy:.3f}, side={prev_seg['side']}, outward=({outward_x:.3f},{outward_y:.3f})"
)
# Calculate offset distance for the arc center from the chord midpoint
# Geometry: For an arc with radius r connecting two points separated by chord length 2*r0,
# the center must be perpendicular to the chord at distance offset from the midpoint,
# where: r^2 = r0^2 + offset^2 (Pythagorean theorem)
# Therefore: offset = sqrt(r^2 - r0^2)
#
# For r = r0 (minimum possible radius): offset = 0 (semicircle, 180° arc)
# For r > r0: offset > 0 (less than semicircle)
# For nice smooth arcs, we could use r = 2*r0, giving offset = sqrt(3)*r0
if r >= r0:
offset = math.sqrt(r * r - r0 * r0)
else:
# Radius too small to connect endpoints - shouldn't happen but handle gracefully
offset = 0.0
# Arc center
cx = mx + nx * offset
cy = my + ny * offset
# Verify center is finite
if not (math.isfinite(cx) and math.isfinite(cy)):
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
# Determine arc direction (G2=CW, G3=CCW)
# For semicircles where center is on the chord, cross product is unreliable
# Instead, use the normal direction and chord direction to determine arc sense
# The arc goes from P to Q, bulging in direction (nx, ny)
# Cross product of chord direction with normal gives us the arc direction
# chord × normal = (dx, dy, 0) × (nx, ny, 0) = (0, 0, dx*ny - dy*nx)
z_cross = dx * ny - dy * nx
Path.Log.debug(f" z_cross = {dx:.3f}*{ny:.3f} - {dy:.3f}*{nx:.3f} = {z_cross:.3f}")
# Invert the logic - positive cross product means clockwise for our convention
if z_cross < 0:
arc_cmd = "G3" # Counter-clockwise
else:
arc_cmd = "G2" # Clockwise
# Calculate IJ (relative to start point P)
I = cx - P.x
J = cy - P.y
# Verify IJ are finite
if not (math.isfinite(I) and math.isfinite(J)):
return [Path.Command("G0", {"X": Q.x, "Y": Q.y})]
Path.Log.debug(
f"Arc link: P=({P.x:.3f},{P.y:.3f}) Q=({Q.x:.3f},{Q.y:.3f}) "
f"C=({cx:.3f},{cy:.3f}) r={r:.3f} {arc_cmd} I={I:.3f} J={J:.3f}"
)
# K=0 for XY plane arcs - use string format to ensure I, J, K are preserved
cmd_string = f"{arc_cmd} I{I:.6f} J{J:.6f} K0.0 X{Q.x:.6f} Y{Q.y:.6f} Z{z:.6f}"
return [Path.Command(cmd_string)]
def zigzag(
polygon,
tool_diameter,
stepover_percent,
pass_extension=None,
retract_height=None,
milling_direction="climb",
reverse=False,
angle_degrees=None,
link_mode="arc",
link_radius=None,
):
if pass_extension is None:
pass_extension = tool_diameter * 0.5
import math
theta = float(angle_degrees) if angle_degrees is not None else 0.0
primary_vec, step_vec = facing_common.unit_vectors_from_angle(theta)
primary_vec = FreeCAD.Vector(primary_vec).normalize()
step_vec = FreeCAD.Vector(step_vec).normalize()
origin = polygon.BoundBox.Center
z = polygon.BoundBox.ZMin
min_s, max_s = facing_common.project_bounds(polygon, primary_vec, origin)
min_t, max_t = facing_common.project_bounds(polygon, step_vec, origin)
if not (
math.isfinite(min_s)
and math.isfinite(max_s)
and math.isfinite(min_t)
and math.isfinite(max_t)
):
Path.Log.error("Zigzag: non-finite projection bounds; aborting")
return []
# === Use exactly the same step position generation as bidirectional and directional ===
step_positions = facing_common.generate_t_values(
polygon, step_vec, tool_diameter, stepover_percent, origin
)
tool_radius = tool_diameter / 2.0
stepover_distance = tool_diameter * (stepover_percent / 100.0)
# Guarantee full coverage at high stepover identical to bidirectional/directional
if stepover_percent >= 99.9 and step_positions:
min_covered = min(step_positions) - tool_radius
max_covered = max(step_positions) + tool_radius
added = False
if max_covered < max_t - 1e-4:
step_positions.append(step_positions[-1] + stepover_distance)
added = True
if min_covered > min_t + 1e-4:
step_positions.insert(0, step_positions[0] - stepover_distance)
added = True
if added:
Path.Log.info("Zigzag: Added extra pass(es) for full coverage at ≥100% stepover")
# Reverse only reverses traversal order (same positions set as reverse=False, identical coverage)
if reverse:
step_positions = step_positions[::-1]
Path.Log.debug(
f"Zigzag: {len(step_positions)} passes generated (now identical to bidirectional)"
)
# Determine if first pass should cut negative primary direction to maintain climb/conventional preference
base_negative = (
milling_direction == "climb"
) ^ reverse # True → negative primary for first pass
total_extension = (
pass_extension
+ tool_radius
+ facing_common.calculate_engagement_offset(tool_diameter, stepover_percent)
)
start_s = min_s - total_extension
end_s = max_s + total_extension
s_mid = (min_s + max_s) / 2.0
segments = []
for idx, t in enumerate(step_positions):
current_negative = base_negative if (idx % 2 == 0) else not base_negative
if current_negative:
p_start = end_s
p_end = start_s
else:
p_start = start_s
p_end = end_s
start_point = origin + primary_vec * p_start + step_vec * t
end_point = origin + primary_vec * p_end + step_vec * t
start_point.z = z
end_point.z = z
side = 1 if p_end > s_mid - 1e-6 else -1 # slightly more tolerant comparison
segments.append(
{
"t": t,
"side": side,
"start": start_point,
"end": end_point,
"s_start": p_start,
"s_end": p_end,
}
)
commands = []
for i, seg in enumerate(segments):
if i == 0:
commands.append(Path.Command("G0", {"X": seg["start"].x, "Y": seg["start"].y, "Z": z}))
else:
prev_seg = segments[i - 1]
link_commands = _create_link(
prev_seg,
seg,
link_mode,
link_radius,
stepover_distance,
tool_radius,
primary_vec,
z,
)
commands.extend(link_commands)
commands.append(Path.Command("G1", {"X": seg["end"].x, "Y": seg["end"].y, "Z": z}))
return commands

View File

@@ -68,6 +68,7 @@ def Startup():
from Path.Op.Gui import Engrave
from Path.Op.Gui import Helix
from Path.Op.Gui import MillFace
from Path.Op.Gui import MillFacing
from Path.Op.Gui import PathShapeTC
from Path.Op.Gui import Pocket
from Path.Op.Gui import PocketShape

View File

@@ -841,6 +841,219 @@ class ObjectOp(object):
return True
class Compass:
"""
A compass is a tool to help with direction so the Compass is a helper
class to manage settings that affect tool and spindle direction.
Settings managed:
- Spindle Direction: Forward / Reverse / None
- Cut Side: Inside / Outside (for perimeter operations)
- Cut Mode: Climb / Conventional
- Path Direction: CW / CCW (derived for perimeter operations)
- Operation Type: Perimeter / Area (for facing/pocketing operations)
This class allows the user to set and get any of these properties and the rest will update accordingly.
Supports both perimeter operations (profiling) and area operations (facing, pocketing).
Args:
spindle_direction: "Forward", "Reverse", or "None"
operation_type: "Perimeter" or "Area" (defaults to "Perimeter")
"""
FORWARD = "Forward"
REVERSE = "Reverse"
NONE = "None"
CW = "CW"
CCW = "CCW"
CLIMB = "Climb"
CONVENTIONAL = "Conventional"
INSIDE = "Inside"
OUTSIDE = "Outside"
PERIMETER = "Perimeter"
AREA = "Area"
def __init__(self, spindle_direction, operation_type=None):
self._spindle_dir = (
spindle_direction
if spindle_direction in (self.FORWARD, self.REVERSE, self.NONE)
else self.NONE
)
self._cut_side = self.OUTSIDE
self._cut_mode = self.CLIMB
self._operation_type = (
operation_type or self.PERIMETER
) # Default to perimeter for backward compatibility
self._path_dir = self._calculate_path_dir()
@property
def spindle_dir(self):
return self._spindle_dir
@spindle_dir.setter
def spindle_dir(self, value):
if value in (self.FORWARD, self.REVERSE, self.NONE):
self._spindle_dir = value
self._path_dir = self._calculate_path_dir()
else:
self._spindle_dir = self.NONE
self._path_dir = self._calculate_path_dir()
@property
def cut_side(self):
return self._cut_side
@cut_side.setter
def cut_side(self, value):
self._cut_side = value.capitalize()
self._path_dir = self._calculate_path_dir()
@property
def cut_mode(self):
return self._cut_mode
@cut_mode.setter
def cut_mode(self, value):
self._cut_mode = value.capitalize()
self._path_dir = self._calculate_path_dir()
@property
def operation_type(self):
return self._operation_type
@operation_type.setter
def operation_type(self, value):
self._operation_type = value.capitalize()
self._path_dir = self._calculate_path_dir()
@property
def path_dir(self):
return self._path_dir
def _calculate_path_dir(self):
if self.spindle_dir == self.NONE:
return "UNKNOWN"
# For area operations (facing, pocketing), path direction is not applicable
if self._operation_type == self.AREA:
return "N/A"
spindle_rotation = self._rotation_from_spindle(self.spindle_dir)
for candidate in (self.CW, self.CCW):
mode = self._expected_cut_mode(self._cut_side, spindle_rotation, candidate)
if mode == self._cut_mode:
return candidate
return "UNKNOWN"
def _rotation_from_spindle(self, direction):
return self.CW if direction == self.FORWARD else self.CCW
def _expected_cut_mode(self, cut_side, spindle_rotation, path_dir):
lookup = {
(self.INSIDE, self.CW, self.CCW): self.CLIMB,
(self.INSIDE, self.CCW, self.CW): self.CLIMB,
(self.OUTSIDE, self.CW, self.CW): self.CLIMB,
(self.OUTSIDE, self.CCW, self.CCW): self.CLIMB,
}
return lookup.get((cut_side, spindle_rotation, path_dir), self.CONVENTIONAL)
def get_step_direction(self, approach_direction):
"""
For area operations, determine the step direction for climb/conventional milling.
Args:
approach_direction: "X+", "X-", "Y+", "Y-" - the primary cutting direction
Returns:
True if steps should be in positive direction, False for negative direction
"""
if self._operation_type != self.AREA:
raise ValueError("Step direction is only applicable for area operations")
if self.spindle_dir == self.NONE:
return True # Default to positive direction
spindle_rotation = self._rotation_from_spindle(self.spindle_dir)
# For area operations, climb/conventional depends on relationship between
# spindle rotation, approach direction, and step direction
if approach_direction in ["X-", "X+"]:
# Stepping in Y direction
if self._cut_mode == self.CLIMB:
# Climb: step direction matches spindle for X- approach
return (approach_direction == "X-") == (spindle_rotation == self.CW)
else: # Conventional
# Conventional: step direction opposite to spindle for X- approach
return (approach_direction == "X-") != (spindle_rotation == self.CW)
else: # Y approach
# Stepping in X direction
if self._cut_mode == self.CLIMB:
# Climb: step direction matches spindle for Y- approach
return (approach_direction == "Y-") == (spindle_rotation == self.CW)
else: # Conventional
# Conventional: step direction opposite to spindle for Y- approach
return (approach_direction == "Y-") != (spindle_rotation == self.CW)
def get_cutting_direction(self, approach_direction, pass_index=0, pattern="zigzag"):
"""
For area operations, determine the cutting direction for each pass.
Args:
approach_direction: "X+", "X-", "Y+", "Y-" - the primary cutting direction
pass_index: Index of the current pass (0-based)
pattern: "zigzag", "unidirectional", "spiral"
Returns:
True if cutting should be in forward direction, False for reverse
"""
if self._operation_type != self.AREA:
raise ValueError("Cutting direction is only applicable for area operations")
if self.spindle_dir == self.NONE:
return True # Default to forward direction
spindle_rotation = self._rotation_from_spindle(self.spindle_dir)
# Determine base cutting direction for climb/conventional
if approach_direction in ["X-", "X+"]:
# Cutting along Y axis
if self._cut_mode == self.CLIMB:
base_forward = (approach_direction == "X-") == (spindle_rotation == self.CW)
else: # Conventional
base_forward = (approach_direction == "X-") != (spindle_rotation == self.CW)
else: # Y approach
# Cutting along X axis
if self._cut_mode == self.CLIMB:
base_forward = (approach_direction == "Y-") == (spindle_rotation == self.CW)
else: # Conventional
base_forward = (approach_direction == "Y-") != (spindle_rotation == self.CW)
# Apply pattern modifications
if pattern == "zigzag" and pass_index % 2 == 1:
base_forward = not base_forward
elif pattern == "unidirectional":
# Always same direction
pass
return base_forward
def report(self):
report_data = {
"spindle_dir": self.spindle_dir,
"cut_side": self.cut_side,
"cut_mode": self.cut_mode,
"operation_type": self.operation_type,
"path_dir": self.path_dir,
}
Path.Log.debug("Machining Compass config:")
for k, v in report_data.items():
Path.Log.debug(f" {k:15s}: {v}")
return report_data
def getCycleTimeEstimate(obj):
tc = obj.ToolController

View File

@@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
import Path
import Path.Base.Gui.Util as PathGuiUtil
import Path.Op.Gui.Base as PathOpGui
import Path.Op.MillFacing as PathMillFacing
import FreeCADGui
__title__ = "CAM Mill Facing Operation UI"
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "Mill Facing operation page controller and command implementation."
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
class TaskPanelOpPage(PathOpGui.TaskPanelPage):
"""Page controller class for the mill facing operation."""
def initPage(self, obj):
"""initPage(obj) ... Initialize page with QuantitySpinBox wrappers for expression support"""
self.axialStockToLeaveSpinBox = PathGuiUtil.QuantitySpinBox(
self.form.axialStockToLeave, obj, "AxialStockToLeave"
)
if hasattr(obj, "PassExtension"):
self.passExtensionSpinBox = PathGuiUtil.QuantitySpinBox(
self.form.passExtension, obj, "PassExtension"
)
if hasattr(obj, "StockExtension"):
self.stockExtensionSpinBox = PathGuiUtil.QuantitySpinBox(
self.form.stockExtension, obj, "StockExtension"
)
def getForm(self):
Path.Log.track()
"""getForm() ... return UI"""
form = FreeCADGui.PySideUic.loadUi(":/panels/PageOpMillFacingEdit.ui")
comboToPropertyMap = [
("cutMode", "CutMode"),
("clearingPattern", "ClearingPattern"),
]
enumTups = PathMillFacing.ObjectMillFacing.propertyEnumerations(dataType="raw")
PathGuiUtil.populateCombobox(form, enumTups, comboToPropertyMap)
return form
def getFields(self, obj):
"""getFields(obj) ... transfers values from UI to obj's properties"""
self.updateToolController(obj, self.form.toolController)
self.updateCoolant(obj, self.form.coolantController)
if obj.CutMode != str(self.form.cutMode.currentData()):
obj.CutMode = str(self.form.cutMode.currentData())
if obj.ClearingPattern != str(self.form.clearingPattern.currentData()):
obj.ClearingPattern = str(self.form.clearingPattern.currentData())
# Reverse checkbox
if hasattr(obj, "Reverse") and obj.Reverse != self.form.reverse.isChecked():
obj.Reverse = self.form.reverse.isChecked()
# Angle is a PropertyAngle (quantity). Compare/update by value.
if getattr(obj.Angle, "Value", obj.Angle) != self.form.angle.value():
obj.Angle = self.form.angle.value()
# StepOver is an App::PropertyPercent; assign an int percentage value
step_over_val = int(self.form.stepOver.value())
if obj.StepOver != step_over_val:
obj.StepOver = step_over_val
# AxialStockToLeave and PassExtension are handled by QuantitySpinBox wrappers
self.axialStockToLeaveSpinBox.updateProperty()
if hasattr(obj, "PassExtension"):
self.passExtensionSpinBox.updateProperty()
if hasattr(obj, "StockExtension"):
self.stockExtensionSpinBox.updateProperty()
def setFields(self, obj):
"""setFields(obj) ... transfers obj's property values to UI"""
self.setupToolController(obj, self.form.toolController)
self.setupCoolant(obj, self.form.coolantController)
# Reflect current CutMode and ClearingPattern in UI
self.selectInComboBox(obj.CutMode, self.form.cutMode)
self.selectInComboBox(obj.ClearingPattern, self.form.clearingPattern)
# Handle new properties that may not exist in older operations
if hasattr(obj, "Reverse"):
self.form.reverse.setChecked(bool(obj.Reverse))
else:
self.form.reverse.setChecked(False)
# Angle is a quantity; set spinbox with numeric degrees
self.form.angle.setValue(getattr(obj.Angle, "Value", obj.Angle))
self.form.stepOver.setValue(obj.StepOver)
# Update QuantitySpinBox displays
self.updateQuantitySpinBoxes()
def updateQuantitySpinBoxes(self, index=None):
"""updateQuantitySpinBoxes() ... refresh QuantitySpinBox displays from properties"""
self.axialStockToLeaveSpinBox.updateWidget()
if hasattr(self, "passExtensionSpinBox"):
self.passExtensionSpinBox.updateWidget()
if hasattr(self, "stockExtensionSpinBox"):
self.stockExtensionSpinBox.updateWidget()
def getSignalsForUpdate(self, obj):
"""getSignalsForUpdate(obj) ... return list of signals for updating obj"""
signals = []
signals.append(self.form.toolController.currentIndexChanged)
signals.append(self.form.coolantController.currentIndexChanged)
signals.append(self.form.cutMode.currentIndexChanged)
signals.append(self.form.clearingPattern.currentIndexChanged)
signals.append(self.form.axialStockToLeave.editingFinished)
if hasattr(self.form, "passExtension"):
signals.append(self.form.passExtension.editingFinished)
if hasattr(self.form, "stockExtension"):
signals.append(self.form.stockExtension.editingFinished)
# Qt 6 compatibility for checkbox state change
if hasattr(self.form.reverse, "checkStateChanged"): # Qt >= 6.7.0
signals.append(self.form.reverse.checkStateChanged)
else:
signals.append(self.form.reverse.stateChanged)
signals.append(self.form.angle.editingFinished)
signals.append(self.form.stepOver.editingFinished)
return signals
Command = PathOpGui.SetupOperation(
"MillFacing",
PathMillFacing.Create,
TaskPanelOpPage,
"CAM_Face",
QT_TRANSLATE_NOOP("CAM_MillFacing", "Mill Facing"),
QT_TRANSLATE_NOOP(
"CAM_MillFacing", "Create a Mill Facing Operation to machine the top surface of stock"
),
PathMillFacing.SetupProperties,
)
FreeCAD.Console.PrintLog("Loading PathMillFacingGui... done\n")

View File

@@ -372,6 +372,7 @@ def select(op):
opsel["Engrave"] = engraveselect
opsel["Helix"] = drillselect
opsel["MillFace"] = pocketselect
opsel["MillFacing"] = pocketselect
opsel["Pocket"] = pocketselect
opsel["Pocket 3D"] = pocketselect
opsel["Pocket3D"] = pocketselect # deprecated

View File

@@ -0,0 +1,755 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 sliptonic sliptonic@freecad.org *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
__title__ = "CAM Mill Facing Operation"
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "Class and implementation of Mill Facing operation."
__contributors__ = ""
import FreeCAD
from PySide import QtCore
import Path
import Path.Op.Base as PathOp
import Path.Base.Generator.spiral_facing as spiral_facing
import Path.Base.Generator.zigzag_facing as zigzag_facing
import Path.Base.Generator.directional_facing as directional_facing
import Path.Base.Generator.bidirectional_facing as bidirectional_facing
import Path.Base.Generator.linking as linking
import PathScripts.PathUtils as PathUtils
import Path.Base.FeedRate as FeedRate
# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader
Part = LazyLoader("Part", globals(), "Part")
Arcs = LazyLoader("draftgeoutils.arcs", globals(), "draftgeoutils.arcs")
if FreeCAD.GuiUp:
FreeCADGui = LazyLoader("FreeCADGui", globals(), "FreeCADGui")
translate = FreeCAD.Qt.translate
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
class ObjectMillFacing(PathOp.ObjectOp):
"""Proxy object for Mill Facing operation."""
def opFeatures(self, obj):
"""opFeatures(obj) ... return all standard features"""
return (
PathOp.FeatureTool
| PathOp.FeatureDepths
| PathOp.FeatureHeights
| PathOp.FeatureStepDown
| PathOp.FeatureCoolant
)
def initOperation(self, obj):
"""initOperation(obj) ... Initialize the operation by
managing property creation and property editor status."""
self.propertiesReady = False
self.initOpProperties(obj) # Initialize operation-specific properties
def initOpProperties(self, obj, warn=False):
"""initOpProperties(obj) ... create operation specific properties"""
Path.Log.track()
self.addNewProps = list()
for prtyp, nm, grp, tt in self.opPropertyDefinitions():
if not hasattr(obj, nm):
obj.addProperty(prtyp, nm, grp, tt)
self.addNewProps.append(nm)
# Set enumeration lists for enumeration properties
if len(self.addNewProps) > 0:
ENUMS = self.propertyEnumerations()
for n in ENUMS:
if n[0] in self.addNewProps:
setattr(obj, n[0], n[1])
if warn:
newPropMsg = translate("CAM_MIllFacing", "New property added to")
newPropMsg += ' "{}": {}'.format(obj.Label, self.addNewProps) + ". "
newPropMsg += translate("CAM_MillFacing", "Check default value(s).")
FreeCAD.Console.PrintWarning(newPropMsg + "\n")
self.propertiesReady = True
def onChanged(self, obj, prop):
"""onChanged(obj, prop) ... Called when a property changes"""
if prop == "StepOver" and hasattr(obj, "StepOver"):
# Validate StepOver is between 0 and 100 percent
if obj.StepOver < 0:
obj.StepOver = 0
elif obj.StepOver > 100:
obj.StepOver = 100
def opPropertyDefinitions(self):
"""opPropertyDefinitions(obj) ... Store operation specific properties"""
return [
(
"App::PropertyEnumeration",
"CutMode",
"Facing",
QtCore.QT_TRANSLATE_NOOP(
"App::Property",
"Set the cut mode for the operation.",
),
),
(
"App::PropertyEnumeration",
"ClearingPattern",
"Facing",
QtCore.QT_TRANSLATE_NOOP(
"App::Property",
"Set the clearing pattern for the operation.",
),
),
(
"App::PropertyAngle",
"Angle",
"Facing",
QtCore.QT_TRANSLATE_NOOP(
"App::Property",
"Set the angle for the operation.",
),
),
(
"App::PropertyPercent",
"StepOver",
"Facing",
QtCore.QT_TRANSLATE_NOOP(
"App::Property",
"Set the stepover percentage of tool diameter.",
),
),
(
"App::PropertyDistance",
"AxialStockToLeave",
"Facing",
QtCore.QT_TRANSLATE_NOOP(
"App::Property",
"Set the stock to leave for the operation.",
),
),
(
"App::PropertyDistance",
"PassExtension",
"Facing",
QtCore.QT_TRANSLATE_NOOP(
"App::Property",
"Distance to extend cuts beyond polygon boundary for tool disengagement.",
),
),
(
"App::PropertyDistance",
"StockExtension",
"Facing",
QtCore.QT_TRANSLATE_NOOP(
"App::Property",
"Extends the boundary in both direction.",
),
),
(
"App::PropertyBool",
"Reverse",
"Facing",
QtCore.QT_TRANSLATE_NOOP(
"App::Property",
"Reverse the cutting direction for the selected pattern.",
),
),
]
@classmethod
def propertyEnumerations(self, dataType="data"):
"""propertyEnumerations(dataType="data")... return property enumeration lists of specified dataType.
Args:
dataType = 'data', 'raw', 'translated'
Notes:
'data' is list of internal string literals used in code
'raw' is list of (translated_text, data_string) tuples
'translated' is list of translated string literals
"""
Path.Log.track()
enums = {
"CutMode": [
(translate("CAM_MillFacing", "Climb"), "Climb"),
(translate("CAM_MillFacing", "Conventional"), "Conventional"),
],
"ClearingPattern": [
(translate("CAM_MillFacing", "ZigZag"), "ZigZag"),
(translate("CAM_MillFacing", "Bidirectional"), "Bidirectional"),
(translate("CAM_MillFacing", "Directional"), "Directional"),
(translate("CAM_MillFacing", "Spiral"), "Spiral"),
],
}
if dataType == "raw":
return enums
data = list()
idx = 0 if dataType == "translated" else 1
Path.Log.debug(enums)
for k, v in enumerate(enums):
data.append((v, [tup[idx] for tup in enums[v]]))
Path.Log.debug(data)
return data
def opPropertyDefaults(self, obj, job):
"""opPropertyDefaults(obj, job) ... returns a dictionary of default values
for the operation's properties."""
defaults = {
"CutMode": "Climb",
"ClearingPattern": "ZigZag",
"Angle": 0,
"StepOver": 25,
"AxialStockToLeave": 0.0,
}
return defaults
def opSetDefaultValues(self, obj, job):
"""opSetDefaultValues(obj, job) ... set default values for operation-specific properties"""
Path.Log.track()
# Set default values directly like other operations do
obj.CutMode = "Climb"
obj.ClearingPattern = "ZigZag"
obj.Angle = 0.0
obj.StepOver = 25 # 25% as percentage
obj.AxialStockToLeave = 0.0
obj.PassExtension = (
3.0 # Default to 3mm, will be adjusted based on tool diameter in opExecute
)
obj.Reverse = False
def opExecute(self, obj):
"""opExecute(obj) ... process Mill Facing operation"""
Path.Log.track()
Path.Log.debug("MillFacing.opExecute() starting")
# Get tool information
tool = obj.ToolController.Tool
Path.Log.debug(f"Tool: {tool.Label if tool else 'None'}")
tool_diameter = tool.Diameter.Value
Path.Log.debug(f"Tool diameter: {tool_diameter}")
# Determine the step-downs
finish_step = 0.0 # No finish step for facing
Path.Log.debug(
f"Depth parameters: clearance={obj.ClearanceHeight.Value}, safe={obj.SafeHeight.Value}, start={obj.StartDepth.Value}, step={obj.StepDown.Value}, final={obj.FinalDepth.Value + obj.AxialStockToLeave.Value}"
)
depthparams = PathUtils.depth_params(
clearance_height=obj.ClearanceHeight.Value,
safe_height=obj.SafeHeight.Value,
start_depth=obj.StartDepth.Value,
step_down=obj.StepDown.Value,
z_finish_step=finish_step,
final_depth=obj.FinalDepth.Value + obj.AxialStockToLeave.Value,
user_depths=None,
)
Path.Log.debug(f"Depth params object: {depthparams}")
# Always use the stock object top face for facing operations
job = PathUtils.findParentJob(obj)
Path.Log.debug(f"Job: {job.Label if job else 'None'}")
if job and job.Stock:
Path.Log.debug(f"Stock: {job.Stock.Label}")
stock_faces = job.Stock.Shape.Faces
Path.Log.debug(f"Number of stock faces: {len(stock_faces)}")
# Find faces with normal pointing toward Z+ (upward)
z_up_faces = []
for face in stock_faces:
# Get face normal at center
u_mid = (face.ParameterRange[0] + face.ParameterRange[1]) / 2
v_mid = (face.ParameterRange[2] + face.ParameterRange[3]) / 2
normal = face.normalAt(u_mid, v_mid)
Path.Log.debug(f"Face normal: {normal}, Z component: {normal.z}")
# Check if normal points upward (Z+ direction) with some tolerance
if normal.z > 0.9: # Allow for slight deviation from perfect vertical
z_up_faces.append(face)
Path.Log.debug(f"Found upward-facing face at Z={face.BoundBox.ZMax}")
if not z_up_faces:
Path.Log.error("No upward-facing faces found in stock")
raise ValueError("No upward-facing faces found in stock")
# From the upward-facing faces, select the highest one
top_face = max(z_up_faces, key=lambda f: f.BoundBox.ZMax)
Path.Log.debug(f"Selected top face ZMax: {top_face.BoundBox.ZMax}")
boundary_wire = top_face.OuterWire
Path.Log.debug(f"Wire vertices: {len(boundary_wire.Vertexes)}")
else:
Path.Log.error("No stock found for facing operation")
raise ValueError("No stock found for facing operation")
boundary_wire = boundary_wire.makeOffset2D(
obj.StockExtension.Value, 2
) # offset with interesection joins
# Determine milling direction
milling_direction = "climb" if obj.CutMode == "Climb" else "conventional"
# Get operation parameters
stepover_percent = obj.StepOver
pass_extension = (
obj.PassExtension.Value if hasattr(obj, "PassExtension") else tool_diameter * 0.5
)
retract_height = obj.SafeHeight.Value
# Generate the base toolpath for one depth level based on clearing pattern
try:
if obj.ClearingPattern == "Spiral":
# Spiral has different signature - no pass_extension or retract_height
Path.Log.debug("Generating spiral toolpath")
base_commands = spiral_facing.spiral(
polygon=boundary_wire,
tool_diameter=tool_diameter,
stepover_percent=stepover_percent,
milling_direction=milling_direction,
reverse=bool(getattr(obj, "Reverse", False)),
angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
)
elif obj.ClearingPattern == "ZigZag":
Path.Log.debug("Generating zigzag toolpath")
base_commands = zigzag_facing.zigzag(
polygon=boundary_wire,
tool_diameter=tool_diameter,
stepover_percent=stepover_percent,
pass_extension=pass_extension,
retract_height=retract_height,
milling_direction=milling_direction,
reverse=bool(getattr(obj, "Reverse", False)),
angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
)
elif obj.ClearingPattern == "Bidirectional":
Path.Log.debug("Generating bidirectional toolpath")
base_commands = bidirectional_facing.bidirectional(
polygon=boundary_wire,
tool_diameter=tool_diameter,
stepover_percent=stepover_percent,
pass_extension=pass_extension,
milling_direction=milling_direction,
reverse=bool(getattr(obj, "Reverse", False)),
angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
)
elif obj.ClearingPattern == "Directional":
Path.Log.debug("Generating directional toolpath")
base_commands = directional_facing.directional(
polygon=boundary_wire,
tool_diameter=tool_diameter,
stepover_percent=stepover_percent,
pass_extension=pass_extension,
retract_height=retract_height,
milling_direction=milling_direction,
reverse=bool(getattr(obj, "Reverse", False)),
angle_degrees=getattr(obj.Angle, "Value", obj.Angle),
)
else:
Path.Log.error(f"Unknown clearing pattern: {obj.ClearingPattern}")
raise ValueError(f"Unknown clearing pattern: {obj.ClearingPattern}")
Path.Log.debug(f"Generated {len(base_commands)} base commands")
Path.Log.debug(base_commands)
except Exception as e:
Path.Log.error(f"Error generating toolpath: {e}")
raise
# clear commandlist
self.commandlist = []
# Be safe. Add first G0 to clearance height
targetZ = obj.ClearanceHeight.Value
self.commandlist.append(Path.Command("G0", {"Z": targetZ}))
# Process each step-down using iterator protocol and add to commandlist
depth_count = 0
try:
while True:
depth = depthparams.next()
depth_count += 1
Path.Log.debug(f"Processing depth {depth_count}: {depth}")
if depth_count == 1:
# First stepdown preamble:
# 1) Rapid to ClearanceHeight (already done at line 401)
# 2) Rapid to XY start position at ClearanceHeight
# 3) Rapid down to SafeHeight
# 4) Rapid down to cutting depth
# Find the first XY target from the base commands
first_xy = None
first_move_idx = None
for i, bc in enumerate(base_commands):
if "X" in bc.Parameters and "Y" in bc.Parameters:
first_xy = (bc.Parameters["X"], bc.Parameters["Y"])
first_move_idx = i
break
if first_xy is not None:
# 1) G0 to XY position at current height (ClearanceHeight from line 401)
pre1 = {"X": first_xy[0], "Y": first_xy[1]}
if not self.commandlist or any(
abs(pre1[k] - self.commandlist[-1].Parameters.get(k, pre1[k] + 1))
> 1e-9
for k in ("X", "Y")
):
self.commandlist.append(Path.Command("G0", pre1))
# 2) G0 down to SafeHeight
pre2 = {"Z": obj.SafeHeight.Value}
if (
abs(pre2["Z"] - self.commandlist[-1].Parameters.get("Z", pre2["Z"] + 1))
> 1e-9
):
self.commandlist.append(Path.Command("G0", pre2))
# 3) G0 down to cutting depth
pre3 = {"Z": depth}
if (
abs(pre3["Z"] - self.commandlist[-1].Parameters.get("Z", pre3["Z"] + 1))
> 1e-9
):
self.commandlist.append(Path.Command("G0", pre3))
# Now append the base commands, skipping the generator's initial positioning move
for i, cmd in enumerate(base_commands):
# Skip the first move if it only positions at the start point
if i == first_move_idx:
# If this first move has only XY(Z) to the start point, skip it because we preambled it
pass
else:
new_params = dict(cmd.Parameters)
# Handle Z coordinate based on command type
if "Z" in new_params:
if cmd.Name == "G0":
# For rapids, distinguish between true retracts and plunges
# True retracts are at/near retract_height and should be preserved
# Plunges are at/near polygon ZMin and should be clamped to depth
if (
abs(new_params["Z"] - retract_height) < 1.0
): # Within 1mm of retract height
# Keep as-is (true retract)
pass
else:
# Not a retract - clamp to depth (includes plunges)
new_params["Z"] = depth
else:
# For G1 cutting moves, always use depth
new_params["Z"] = depth
else:
# Missing Z coordinate - set based on command type
if cmd.Name == "G1":
# Cutting moves always at depth
new_params["Z"] = depth
else:
# Rapids without Z - carry forward last Z
if self.commandlist:
new_params["Z"] = self.commandlist[-1].Parameters.get(
"Z", depth
)
# Fill in missing X,Y coordinates from last position
if self.commandlist:
last = self.commandlist[-1].Parameters
if "X" not in cmd.Parameters:
new_params.setdefault("X", last.get("X"))
if "Y" not in cmd.Parameters:
new_params.setdefault("Y", last.get("Y"))
# Skip zero-length moves (but allow Z-only moves for plunges/retracts)
if self.commandlist:
last_params = self.commandlist[-1].Parameters
# Only skip if X and Y are identical (allow Z-only moves)
if all(
abs(new_params[k] - last_params.get(k, new_params[k] + 1))
<= 1e-9
for k in ("X", "Y")
):
# But if Z is different, keep it (it's a plunge or retract)
z_changed = (
abs(
new_params.get("Z", 0)
- last_params.get("Z", new_params.get("Z", 0) + 1)
)
> 1e-9
)
if not z_changed:
continue
self.commandlist.append(Path.Command(cmd.Name, new_params))
Path.Log.debug(
f"First stepdown: Added {len(base_commands)} commands for depth {depth}"
)
else:
# Subsequent stepdowns - handle linking
# Make a copy of base_commands and update Z depths
copy_commands = []
for cmd in base_commands:
new_params = dict(cmd.Parameters)
# Handle Z coordinate based on command type (same logic as first stepdown)
if "Z" in new_params:
if cmd.Name == "G0":
# For rapids, distinguish between true retracts and plunges
if (
abs(new_params["Z"] - retract_height) < 1.0
): # Within 1mm of retract height
# Keep as-is (true retract)
pass
else:
# Not a retract - clamp to depth (includes plunges)
new_params["Z"] = depth
else:
# For G1 cutting moves, always use depth
new_params["Z"] = depth
else:
# Missing Z coordinate - set based on command type
if cmd.Name == "G1":
# Cutting moves always at depth
new_params["Z"] = depth
# For G0 without Z, we'll let it get filled in later from context
copy_commands.append(Path.Command(cmd.Name, new_params))
# Get the last position from self.commandlist
last_cmd = self.commandlist[-1]
last_position = FreeCAD.Vector(
last_cmd.Parameters.get("X", 0),
last_cmd.Parameters.get("Y", 0),
last_cmd.Parameters.get("Z", depth),
)
# Identify the initial retract+position+plunge bundle (G0s) before the next cut
bundle_start = None
bundle_end = None
target_xy = None
for i, cmd in enumerate(copy_commands):
if cmd.Name == "G0":
bundle_start = i
# collect consecutive G0s
j = i
while j < len(copy_commands) and copy_commands[j].Name == "G0":
# capture XY target if present
if (
"X" in copy_commands[j].Parameters
and "Y" in copy_commands[j].Parameters
):
target_xy = (
copy_commands[j].Parameters.get("X"),
copy_commands[j].Parameters.get("Y"),
)
j += 1
bundle_end = j # exclusive
break
if bundle_start is not None and target_xy is not None:
# Build target position at cutting depth
first_position = FreeCAD.Vector(target_xy[0], target_xy[1], depth)
# Generate collision-aware linking moves up to safe/clearance and back down
link_commands = linking.get_linking_moves(
start_position=last_position,
target_position=first_position,
local_clearance=obj.SafeHeight.Value,
global_clearance=obj.ClearanceHeight.Value,
tool_shape=obj.ToolController.Tool.Shape,
)
# Append linking moves, ensuring full XYZ continuity
current = last_position
for lc in link_commands:
params = dict(lc.Parameters)
X = params.get("X", current.x)
Y = params.get("Y", current.y)
Z = params.get("Z", current.z)
# Skip zero-length
if not (
abs(X - current.x) <= 1e-9
and abs(Y - current.y) <= 1e-9
and abs(Z - current.z) <= 1e-9
):
self.commandlist.append(
Path.Command(lc.Name, {"X": X, "Y": Y, "Z": Z})
)
current = FreeCAD.Vector(X, Y, Z)
# Remove the entire initial G0 bundle (up, XY, down) from the copy
del copy_commands[bundle_start:bundle_end]
# Append the copy commands, filling missing coords
for cc in copy_commands:
cp = dict(cc.Parameters)
if self.commandlist:
last = self.commandlist[-1].Parameters
# Only fill in coordinates that are truly missing from the original command
if "X" not in cc.Parameters:
cp.setdefault("X", last.get("X"))
if "Y" not in cc.Parameters:
cp.setdefault("Y", last.get("Y"))
# Don't carry forward Z - it should already be set correctly in copy_commands
if "Z" not in cc.Parameters:
# Only set Z if it wasn't in the original command
if cc.Name == "G1":
cp["Z"] = depth # Cutting moves at depth
else:
cp.setdefault("Z", last.get("Z"))
# Skip zero-length
if self.commandlist:
last = self.commandlist[-1].Parameters
if all(
abs(cp[k] - last.get(k, cp[k] + 1)) <= 1e-9 for k in ("X", "Y", "Z")
):
continue
self.commandlist.append(Path.Command(cc.Name, cp))
Path.Log.debug(
f"Stepdown {depth_count}: Added linking + {len(copy_commands)} commands for depth {depth}"
)
except StopIteration:
Path.Log.debug(f"All depths processed. Total depth levels: {depth_count}")
# Add final G0 to clearance height
targetZ = obj.ClearanceHeight.Value
if self.commandlist:
last = self.commandlist[-1].Parameters
lastZ = last.get("Z")
if lastZ is None or abs(targetZ - lastZ) > 1e-9:
# Prefer Z-only to avoid non-numeric XY issues
self.commandlist.append(Path.Command("G0", {"Z": targetZ}))
# # Sanitize commands: ensure full XYZ continuity and remove zero-length/invalid/absurd moves
# sanitized = []
# curX = curY = curZ = None
# # Compute XY bounds from original wire
# try:
# bb = boundary_wire.BoundBox
# import math
# diag = math.hypot(bb.XLength, bb.YLength)
# xy_limit = max(1.0, diag * 10.0)
# except Exception:
# xy_limit = 1e6
# for idx, cmd in enumerate(self.commandlist):
# params = dict(cmd.Parameters)
# # Carry forward
# if curX is not None:
# params.setdefault("X", curX)
# params.setdefault("Y", curY)
# params.setdefault("Z", curZ)
# # Extract
# X = params.get("X")
# Y = params.get("Y")
# Z = params.get("Z")
# # Skip NaN/inf
# try:
# _ = float(X) + float(Y) + float(Z)
# except Exception:
# Path.Log.warning(
# f"Dropping cmd {idx} non-finite coords: {cmd.Name} {cmd.Parameters}"
# )
# continue
# # Debug: large finite XY - log but keep for analysis (do not drop)
# if abs(X) > xy_limit or abs(Y) > xy_limit:
# Path.Log.warning(f"Large XY detected (limit {xy_limit}): {cmd.Name} {params}")
# # Skip zero-length
# if (
# curX is not None
# and abs(X - curX) <= 1e-12
# and abs(Y - curY) <= 1e-12
# and abs(Z - curZ) <= 1e-12
# ):
# continue
# # Preserve I, J, K parameters for arc commands (G2/G3)
# if cmd.Name in ["G2", "G3"]:
# arc_params = {"X": X, "Y": Y, "Z": Z}
# if "I" in params:
# arc_params["I"] = params["I"]
# if "J" in params:
# arc_params["J"] = params["J"]
# if "K" in params:
# arc_params["K"] = params["K"]
# if "R" in params:
# arc_params["R"] = params["R"]
# sanitized.append(Path.Command(cmd.Name, arc_params))
# else:
# sanitized.append(Path.Command(cmd.Name, {"X": X, "Y": Y, "Z": Z}))
# curX, curY, curZ = X, Y, Z
# self.commandlist = sanitized
# Apply feedrates to the entire commandlist, with debug on failure
try:
FeedRate.setFeedRate(self.commandlist, obj.ToolController)
except Exception as e:
# Dump last 12 commands for diagnostics
n = len(self.commandlist)
start = max(0, n - 12)
Path.Log.error("FeedRate failure. Dumping last commands:")
for i in range(start, n):
c = self.commandlist[i]
Path.Log.error(f" #{i}: {c.Name} {c.Parameters}")
raise
Path.Log.debug(f"Total commands in commandlist: {len(self.commandlist)}")
Path.Log.debug("MillFacing.opExecute() completed successfully")
Path.Log.debug(self.commandlist)
# Eclass
def Create(name, obj=None, parentJob=None):
"""Create(name) ... Creates and returns a Mill Facing operation."""
if obj is None:
obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name)
obj.Proxy = ObjectMillFacing(obj, name, parentJob)
return obj
def SetupProperties():
"""SetupProperties() ... Return list of properties required for the operation."""
setup = []
setup.append("CutMode")
setup.append("ClearingPattern")
setup.append("Angle")
setup.append("StepOver")
setup.append("AxialStockToLeave")
setup.append("PassExtension")
setup.append("Reverse")
return setup

View File

@@ -25,6 +25,7 @@
import TestApp
from CAMTests.TestCAMSanity import TestCAMSanity
from CAMTests.TestLinkingGenerator import TestGetLinkingMoves
from CAMTests.TestPathProfile import TestPathProfile
from CAMTests.TestPathAdaptive import TestPathAdaptive
@@ -36,6 +37,7 @@ from CAMTests.TestPathDressupDogboneII import TestDressupDogboneII
from CAMTests.TestPathDressupHoldingTags import TestHoldingTags
from CAMTests.TestPathDrillable import TestPathDrillable
from CAMTests.TestPathDrillGenerator import TestPathDrillGenerator
from CAMTests.TestPathFacingGenerator import TestPathFacingGenerator
from CAMTests.TestPathGeneratorDogboneII import TestGeneratorDogboneII
from CAMTests.TestPathGeom import TestPathGeom
from CAMTests.TestPathLanguage import TestPathLanguage