[CAM] Reimplemented Mill facing operation (#24367)
This commit is contained in:
99
src/Mod/CAM/CAMTests/TestLinkingGenerator.py
Normal file
99
src/Mod/CAM/CAMTests/TestLinkingGenerator.py
Normal 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
|
||||
1184
src/Mod/CAM/CAMTests/TestPathFacingGenerator.py
Normal file
1184
src/Mod/CAM/CAMTests/TestPathFacingGenerator.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
273
src/Mod/CAM/Gui/Resources/panels/PageOpMillFacingEdit.ui
Normal file
273
src/Mod/CAM/Gui/Resources/panels/PageOpMillFacingEdit.ui
Normal 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>
|
||||
@@ -143,7 +143,7 @@ class CAMWorkbench(Workbench):
|
||||
twodopcmdlist = [
|
||||
"CAM_Profile",
|
||||
"CAM_Pocket_Shape",
|
||||
"CAM_MillFace",
|
||||
"CAM_MillFacing",
|
||||
"CAM_Helix",
|
||||
"CAM_Adaptive",
|
||||
"CAM_Slot",
|
||||
|
||||
174
src/Mod/CAM/Path/Base/Generator/bidirectional_facing.py
Normal file
174
src/Mod/CAM/Path/Base/Generator/bidirectional_facing.py
Normal 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
|
||||
201
src/Mod/CAM/Path/Base/Generator/directional_facing.py
Normal file
201
src/Mod/CAM/Path/Base/Generator/directional_facing.py
Normal 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
|
||||
481
src/Mod/CAM/Path/Base/Generator/facing_common.py
Normal file
481
src/Mod/CAM/Path/Base/Generator/facing_common.py
Normal 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
|
||||
104
src/Mod/CAM/Path/Base/Generator/linking.py
Normal file
104
src/Mod/CAM/Path/Base/Generator/linking.py
Normal 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
|
||||
319
src/Mod/CAM/Path/Base/Generator/spiral_facing.py
Normal file
319
src/Mod/CAM/Path/Base/Generator/spiral_facing.py
Normal 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
|
||||
329
src/Mod/CAM/Path/Base/Generator/zigzag_facing.py
Normal file
329
src/Mod/CAM/Path/Base/Generator/zigzag_facing.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
172
src/Mod/CAM/Path/Op/Gui/MillFacing.py
Normal file
172
src/Mod/CAM/Path/Op/Gui/MillFacing.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
755
src/Mod/CAM/Path/Op/MillFacing.py
Normal file
755
src/Mod/CAM/Path/Op/MillFacing.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user