From d749098dcb2093d3363c7325c2c4d2004c5dc3a4 Mon Sep 17 00:00:00 2001 From: Samuel Abels Date: Mon, 19 May 2025 20:25:00 +0200 Subject: [PATCH] CAM: Replace complete tool management (PR 21425) --- src/Mod/CAM/CAMTests/PathTestUtils.py | 50 + src/Mod/CAM/CAMTests/TestPathHelpers.py | 11 +- src/Mod/CAM/CAMTests/TestPathOpDeburr.py | 1 - src/Mod/CAM/CAMTests/TestPathPreferences.py | 20 +- src/Mod/CAM/CAMTests/TestPathToolAsset.py | 41 + .../CAM/CAMTests/TestPathToolAssetCache.py | 342 ++++++ .../CAM/CAMTests/TestPathToolAssetManager.py | 468 ++++++++ .../CAM/CAMTests/TestPathToolAssetStore.py | 389 ++++++ src/Mod/CAM/CAMTests/TestPathToolAssetUri.py | 101 ++ src/Mod/CAM/CAMTests/TestPathToolBit.py | 166 +-- .../CAMTests/TestPathToolBitBrowserWidget.py | 151 +++ .../CAMTests/TestPathToolBitEditorWidget.py | 124 ++ .../CAM/CAMTests/TestPathToolBitListWidget.py | 143 +++ .../TestPathToolBitPropertyEditorWidget.py | 241 ++++ .../CAM/CAMTests/TestPathToolBitSerializer.py | 134 +++ .../CAM/CAMTests/TestPathToolController.py | 16 +- .../TestPathToolDocumentObjectEditorWidget.py | 299 +++++ src/Mod/CAM/CAMTests/TestPathToolLibrary.py | 381 ++++++ .../CAMTests/TestPathToolLibrarySerializer.py | 128 ++ src/Mod/CAM/CAMTests/TestPathToolMachine.py | 206 ++++ .../CAM/CAMTests/TestPathToolShapeClasses.py | 391 ++++++ src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py | 220 ++++ src/Mod/CAM/CAMTests/TestPathToolShapeIcon.py | 261 ++++ src/Mod/CAM/CAMTests/TestPathVcarve.py | 12 +- src/Mod/CAM/CMakeLists.txt | 336 +++++- src/Mod/CAM/Gui/Resources/Path.qrc | 2 +- .../CAM/Gui/Resources/panels/ShapeSelector.ui | 65 + .../CAM/Gui/Resources/panels/ToolBitEditor.ui | 401 ++++--- .../Resources/panels/ToolBitLibraryEdit.ui | 29 +- .../Gui/Resources/panels/ToolBitSelector.ui | 132 --- src/Mod/CAM/InitGui.py | 16 +- src/Mod/CAM/Path/Base/Gui/SetupSheet.py | 8 +- src/Mod/CAM/Path/Base/Gui/Util.py | 223 +++- src/Mod/CAM/Path/Base/Util.py | 2 +- src/Mod/CAM/Path/Main/Gui/Job.py | 34 +- src/Mod/CAM/Path/Main/Gui/PreferencesJob.py | 8 - src/Mod/CAM/Path/Main/Sanity/Sanity.py | 12 +- src/Mod/CAM/Path/Preferences.py | 179 ++- src/Mod/CAM/Path/Tool/Bit.py | 500 -------- src/Mod/CAM/Path/Tool/Controller.py | 41 +- src/Mod/CAM/Path/Tool/Gui/Bit.py | 270 ----- src/Mod/CAM/Path/Tool/Gui/BitEdit.py | 275 ----- src/Mod/CAM/Path/Tool/Gui/BitLibrary.py | 1045 ----------------- src/Mod/CAM/Path/Tool/Gui/Controller.py | 45 +- src/Mod/CAM/Path/Tool/__init__.py | 36 + src/Mod/CAM/Path/Tool/assets/README.md | 323 +++++ src/Mod/CAM/Path/Tool/assets/__init__.py | 19 + src/Mod/CAM/Path/Tool/assets/asset.py | 91 ++ src/Mod/CAM/Path/Tool/assets/cache.py | 172 +++ .../Path/Tool/assets/docs/blender-assets.jpg | Bin 0 -> 183010 bytes src/Mod/CAM/Path/Tool/assets/manager.py | 768 ++++++++++++ src/Mod/CAM/Path/Tool/assets/serializer.py | 104 ++ .../CAM/Path/Tool/assets/store/__init__.py | 0 src/Mod/CAM/Path/Tool/assets/store/base.py | 166 +++ .../CAM/Path/Tool/assets/store/filestore.py | 501 ++++++++ src/Mod/CAM/Path/Tool/assets/store/memory.py | 212 ++++ src/Mod/CAM/Path/Tool/assets/ui/__init__.py | 6 + src/Mod/CAM/Path/Tool/assets/ui/filedialog.py | 158 +++ .../CAM/Path/Tool/assets/ui/preferences.py | 128 ++ src/Mod/CAM/Path/Tool/assets/ui/util.py | 109 ++ src/Mod/CAM/Path/Tool/assets/uri.py | 119 ++ src/Mod/CAM/Path/Tool/camassets.py | 111 ++ src/Mod/CAM/Path/Tool/library/__init__.py | 6 + .../CAM/Path/Tool/library/models/__init__.py | 0 .../CAM/Path/Tool/library/models/library.py | 182 +++ .../Path/Tool/library/serializers/__init__.py | 13 + .../Path/Tool/library/serializers/camotics.py | 171 +++ .../CAM/Path/Tool/library/serializers/fctl.py | 107 ++ .../Path/Tool/library/serializers/linuxcnc.py | 78 ++ src/Mod/CAM/Path/Tool/library/ui/__init__.py | 0 src/Mod/CAM/Path/Tool/library/ui/browser.py | 116 ++ .../BitLibraryCmd.py => library/ui/cmd.py} | 26 +- src/Mod/CAM/Path/Tool/library/ui/dock.py | 190 +++ src/Mod/CAM/Path/Tool/library/ui/editor.py | 642 ++++++++++ src/Mod/CAM/Path/Tool/library/util.py | 21 + src/Mod/CAM/Path/Tool/machine/__init__.py | 6 + .../CAM/Path/Tool/machine/models/__init__.py | 0 .../CAM/Path/Tool/machine/models/machine.py | 434 +++++++ src/Mod/CAM/Path/Tool/shape/__init__.py | 48 + src/Mod/CAM/Path/Tool/shape/doc.py | 189 +++ .../CAM/Path/Tool/shape/models/__init__.py | 1 + src/Mod/CAM/Path/Tool/shape/models/ballend.py | 59 + src/Mod/CAM/Path/Tool/shape/models/base.py | 630 ++++++++++ .../CAM/Path/Tool/shape/models/bullnose.py | 63 + src/Mod/CAM/Path/Tool/shape/models/chamfer.py | 63 + .../CAM/Path/Tool/shape/models/dovetail.py | 75 ++ src/Mod/CAM/Path/Tool/shape/models/drill.py | 55 + src/Mod/CAM/Path/Tool/shape/models/endmill.py | 59 + src/Mod/CAM/Path/Tool/shape/models/fillet.py | 63 + src/Mod/CAM/Path/Tool/shape/models/icon.py | 296 +++++ src/Mod/CAM/Path/Tool/shape/models/probe.py | 51 + src/Mod/CAM/Path/Tool/shape/models/reamer.py | 55 + .../CAM/Path/Tool/shape/models/slittingsaw.py | 67 ++ src/Mod/CAM/Path/Tool/shape/models/tap.py | 63 + .../CAM/Path/Tool/shape/models/threadmill.py | 71 ++ src/Mod/CAM/Path/Tool/shape/models/vbit.py | 67 ++ src/Mod/CAM/Path/Tool/shape/ui/__init__.py | 0 src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py | 216 ++++ src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py | 47 + .../CAM/Path/Tool/shape/ui/shapeselector.py | 80 ++ src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py | 43 + src/Mod/CAM/Path/Tool/shape/util.py | 114 ++ src/Mod/CAM/Path/Tool/toolbit/__init__.py | 36 + src/Mod/CAM/Path/Tool/toolbit/docobject.py | 184 +++ .../CAM/Path/Tool/toolbit/mixins/__init__.py | 9 + .../CAM/Path/Tool/toolbit/mixins/cutting.py | 40 + .../CAM/Path/Tool/toolbit/mixins/rotary.py | 60 + .../CAM/Path/Tool/toolbit/models/__init__.py | 1 + .../CAM/Path/Tool/toolbit/models/ballend.py | 45 + src/Mod/CAM/Path/Tool/toolbit/models/base.py | 810 +++++++++++++ .../CAM/Path/Tool/toolbit/models/bullnose.py | 47 + .../CAM/Path/Tool/toolbit/models/chamfer.py | 45 + .../CAM/Path/Tool/toolbit/models/dovetail.py | 45 + src/Mod/CAM/Path/Tool/toolbit/models/drill.py | 43 + .../CAM/Path/Tool/toolbit/models/endmill.py | 45 + .../CAM/Path/Tool/toolbit/models/fillet.py | 45 + src/Mod/CAM/Path/Tool/toolbit/models/probe.py | 48 + .../CAM/Path/Tool/toolbit/models/reamer.py | 42 + .../Path/Tool/toolbit/models/slittingsaw.py | 45 + src/Mod/CAM/Path/Tool/toolbit/models/tap.py | 45 + .../Path/Tool/toolbit/models/threadmill.py | 45 + src/Mod/CAM/Path/Tool/toolbit/models/vbit.py | 43 + .../Path/Tool/toolbit/serializers/__init__.py | 12 + .../Path/Tool/toolbit/serializers/camotics.py | 104 ++ .../CAM/Path/Tool/toolbit/serializers/fctb.py | 103 ++ src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py | 6 + src/Mod/CAM/Path/Tool/toolbit/ui/browser.py | 263 +++++ .../Tool/{Gui/BitCmd.py => toolbit/ui/cmd.py} | 56 +- src/Mod/CAM/Path/Tool/toolbit/ui/editor.py | 282 +++++ src/Mod/CAM/Path/Tool/toolbit/ui/file.py | 81 ++ src/Mod/CAM/Path/Tool/toolbit/ui/panel.py | 69 ++ src/Mod/CAM/Path/Tool/toolbit/ui/selector.py | 89 ++ src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py | 179 +++ src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py | 181 +++ src/Mod/CAM/Path/Tool/toolbit/ui/view.py | 109 ++ src/Mod/CAM/Path/Tool/toolbit/util.py | 37 + src/Mod/CAM/Path/Tool/ui/__init__.py | 0 src/Mod/CAM/Path/Tool/ui/docobject.py | 134 +++ src/Mod/CAM/Path/Tool/ui/property.py | 260 ++++ src/Mod/CAM/TestCAMApp.py | 84 +- src/Mod/CAM/TestCAMGui.py | 34 + src/Mod/CAM/Tools/Shape/ballend.fcstd | Bin 14720 -> 15745 bytes src/Mod/CAM/Tools/Shape/ballend.svg | 345 ++++++ src/Mod/CAM/Tools/Shape/bullnose.fcstd | Bin 14736 -> 15503 bytes src/Mod/CAM/Tools/Shape/bullnose.svg | 374 ++++++ src/Mod/CAM/Tools/Shape/chamfer.fcstd | Bin 15003 -> 15911 bytes src/Mod/CAM/Tools/Shape/chamfer.svg | 448 +++++++ src/Mod/CAM/Tools/Shape/dovetail.fcstd | Bin 16876 -> 16950 bytes src/Mod/CAM/Tools/Shape/dovetail.svg | 660 +++++++++++ src/Mod/CAM/Tools/Shape/drill.fcstd | Bin 12957 -> 13346 bytes src/Mod/CAM/Tools/Shape/drill.svg | 258 ++++ src/Mod/CAM/Tools/Shape/endmill.fcstd | Bin 13770 -> 14497 bytes src/Mod/CAM/Tools/Shape/endmill.svg | 433 +++++++ src/Mod/CAM/Tools/Shape/fillet.fcstd | Bin 0 -> 17383 bytes src/Mod/CAM/Tools/Shape/fillet.svg | 423 +++++++ src/Mod/CAM/Tools/Shape/probe.fcstd | Bin 14128 -> 15163 bytes src/Mod/CAM/Tools/Shape/probe.svg | 468 ++++++++ src/Mod/CAM/Tools/Shape/reamer.fcstd | Bin 0 -> 14313 bytes src/Mod/CAM/Tools/Shape/reamer.svg | 586 +++++++++ src/Mod/CAM/Tools/Shape/slittingsaw.fcstd | Bin 14719 -> 15516 bytes src/Mod/CAM/Tools/Shape/slittingsaw.svg | 504 ++++++++ src/Mod/CAM/Tools/Shape/tap.fcstd | Bin 13271 -> 15404 bytes src/Mod/CAM/Tools/Shape/tap.svg | 427 +++++++ src/Mod/CAM/Tools/Shape/thread-mill.fcstd | Bin 15335 -> 0 bytes src/Mod/CAM/Tools/Shape/threadmill.fcstd | Bin 0 -> 16823 bytes src/Mod/CAM/Tools/Shape/threadmill.svg | 602 ++++++++++ src/Mod/CAM/Tools/Shape/v-bit.fcstd | Bin 16858 -> 0 bytes src/Mod/CAM/Tools/Shape/vbit.fcstd | Bin 0 -> 18822 bytes src/Mod/CAM/Tools/Shape/vbit.svg | 436 +++++++ 169 files changed, 22274 insertions(+), 2905 deletions(-) create mode 100644 src/Mod/CAM/CAMTests/TestPathToolAsset.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolAssetCache.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolAssetManager.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolAssetStore.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolAssetUri.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolBitEditorWidget.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolBitPropertyEditorWidget.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolDocumentObjectEditorWidget.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolLibrary.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolMachine.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py create mode 100644 src/Mod/CAM/CAMTests/TestPathToolShapeIcon.py create mode 100644 src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui delete mode 100644 src/Mod/CAM/Gui/Resources/panels/ToolBitSelector.ui delete mode 100644 src/Mod/CAM/Path/Tool/Bit.py delete mode 100644 src/Mod/CAM/Path/Tool/Gui/Bit.py delete mode 100644 src/Mod/CAM/Path/Tool/Gui/BitEdit.py delete mode 100644 src/Mod/CAM/Path/Tool/Gui/BitLibrary.py create mode 100644 src/Mod/CAM/Path/Tool/assets/README.md create mode 100644 src/Mod/CAM/Path/Tool/assets/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/assets/asset.py create mode 100644 src/Mod/CAM/Path/Tool/assets/cache.py create mode 100644 src/Mod/CAM/Path/Tool/assets/docs/blender-assets.jpg create mode 100644 src/Mod/CAM/Path/Tool/assets/manager.py create mode 100644 src/Mod/CAM/Path/Tool/assets/serializer.py create mode 100644 src/Mod/CAM/Path/Tool/assets/store/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/assets/store/base.py create mode 100644 src/Mod/CAM/Path/Tool/assets/store/filestore.py create mode 100644 src/Mod/CAM/Path/Tool/assets/store/memory.py create mode 100644 src/Mod/CAM/Path/Tool/assets/ui/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/assets/ui/filedialog.py create mode 100644 src/Mod/CAM/Path/Tool/assets/ui/preferences.py create mode 100644 src/Mod/CAM/Path/Tool/assets/ui/util.py create mode 100644 src/Mod/CAM/Path/Tool/assets/uri.py create mode 100644 src/Mod/CAM/Path/Tool/camassets.py create mode 100644 src/Mod/CAM/Path/Tool/library/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/library/models/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/library/models/library.py create mode 100644 src/Mod/CAM/Path/Tool/library/serializers/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/library/serializers/camotics.py create mode 100644 src/Mod/CAM/Path/Tool/library/serializers/fctl.py create mode 100644 src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py create mode 100644 src/Mod/CAM/Path/Tool/library/ui/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/library/ui/browser.py rename src/Mod/CAM/Path/Tool/{Gui/BitLibraryCmd.py => library/ui/cmd.py} (83%) create mode 100644 src/Mod/CAM/Path/Tool/library/ui/dock.py create mode 100644 src/Mod/CAM/Path/Tool/library/ui/editor.py create mode 100644 src/Mod/CAM/Path/Tool/library/util.py create mode 100644 src/Mod/CAM/Path/Tool/machine/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/machine/models/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/machine/models/machine.py create mode 100644 src/Mod/CAM/Path/Tool/shape/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/shape/doc.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/ballend.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/base.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/bullnose.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/chamfer.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/dovetail.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/drill.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/endmill.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/fillet.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/icon.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/probe.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/reamer.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/tap.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/threadmill.py create mode 100644 src/Mod/CAM/Path/Tool/shape/models/vbit.py create mode 100644 src/Mod/CAM/Path/Tool/shape/ui/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py create mode 100644 src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py create mode 100644 src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py create mode 100644 src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py create mode 100644 src/Mod/CAM/Path/Tool/shape/util.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/docobject.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/mixins/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/mixins/rotary.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/ballend.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/base.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/drill.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/endmill.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/fillet.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/probe.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/reamer.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/tap.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/models/vbit.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/browser.py rename src/Mod/CAM/Path/Tool/{Gui/BitCmd.py => toolbit/ui/cmd.py} (80%) create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/editor.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/file.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/panel.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/selector.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/view.py create mode 100644 src/Mod/CAM/Path/Tool/toolbit/util.py create mode 100644 src/Mod/CAM/Path/Tool/ui/__init__.py create mode 100644 src/Mod/CAM/Path/Tool/ui/docobject.py create mode 100644 src/Mod/CAM/Path/Tool/ui/property.py create mode 100644 src/Mod/CAM/TestCAMGui.py create mode 100644 src/Mod/CAM/Tools/Shape/ballend.svg create mode 100644 src/Mod/CAM/Tools/Shape/bullnose.svg create mode 100644 src/Mod/CAM/Tools/Shape/chamfer.svg create mode 100644 src/Mod/CAM/Tools/Shape/dovetail.svg create mode 100644 src/Mod/CAM/Tools/Shape/drill.svg create mode 100644 src/Mod/CAM/Tools/Shape/endmill.svg create mode 100644 src/Mod/CAM/Tools/Shape/fillet.fcstd create mode 100644 src/Mod/CAM/Tools/Shape/fillet.svg create mode 100644 src/Mod/CAM/Tools/Shape/probe.svg create mode 100644 src/Mod/CAM/Tools/Shape/reamer.fcstd create mode 100644 src/Mod/CAM/Tools/Shape/reamer.svg create mode 100644 src/Mod/CAM/Tools/Shape/slittingsaw.svg create mode 100644 src/Mod/CAM/Tools/Shape/tap.svg delete mode 100644 src/Mod/CAM/Tools/Shape/thread-mill.fcstd create mode 100644 src/Mod/CAM/Tools/Shape/threadmill.fcstd create mode 100644 src/Mod/CAM/Tools/Shape/threadmill.svg delete mode 100644 src/Mod/CAM/Tools/Shape/v-bit.fcstd create mode 100644 src/Mod/CAM/Tools/Shape/vbit.fcstd create mode 100644 src/Mod/CAM/Tools/Shape/vbit.svg diff --git a/src/Mod/CAM/CAMTests/PathTestUtils.py b/src/Mod/CAM/CAMTests/PathTestUtils.py index ee0ce931d8..34e073c017 100644 --- a/src/Mod/CAM/CAMTests/PathTestUtils.py +++ b/src/Mod/CAM/CAMTests/PathTestUtils.py @@ -21,10 +21,20 @@ # *************************************************************************** import FreeCAD +import os import Part import Path import math +import pathlib import unittest +from Path.Tool.assets import AssetManager, MemoryStore, DummyAssetSerializer +from Path.Tool.library.serializers import FCTLSerializer +from Path.Tool.toolbit.serializers import FCTBSerializer +from Path.Tool.camassets import ensure_assets_initialized +from Path.Tool.library import Library +from Path.Tool.toolbit import ToolBit +from Path.Tool.shape import ToolBitShape +from Path.Tool.shape.models.icon import ToolBitShapeSvgIcon, ToolBitShapePngIcon from FreeCAD import Vector @@ -196,3 +206,43 @@ class PathTestBase(unittest.TestCase): failed_objects = [o.Name for o in objs if "Invalid" in o.State] if len(failed_objects) > 0: self.fail(msg or f"Recompute failed for {failed_objects}") + + +class PathTestWithAssets(PathTestBase): + """ + A base class that creates an AssetManager, so tests can easily fetch + test data. Examples: + + toolbit = self.assets.get("toolbit://ballend") + toolbit = self.assets.get("toolbitshape://chamfer") + """ + + __tool_dir = pathlib.Path(os.path.realpath(__file__)).parent.parent / "Tools" + + def setUp(self): + # Set up the manager with an in-memory store. + self.assets: AssetManager = AssetManager() + self.asset_store: MemoryStore = MemoryStore("local") + self.assets.register_store(self.asset_store) + + # Register some asset classes. + self.assets.register_asset(Library, FCTLSerializer) + self.assets.register_asset(ToolBit, FCTBSerializer) + self.assets.register_asset(ToolBitShape, DummyAssetSerializer) + self.assets.register_asset(ToolBitShapeSvgIcon, DummyAssetSerializer) + self.assets.register_asset(ToolBitShapePngIcon, DummyAssetSerializer) + + # Include the built-in assets from src/Mod/CAM/Tools. + # These functions only copy if there are no assets, so this + # must be done BEFORE adding the additional test assets below. + ensure_assets_initialized(self.assets, self.asset_store.name) + + # Additional test assets. + for path in pathlib.Path(self.__tool_dir / "Bit").glob("*.fctb"): + self.assets.add_file("toolbit", path) + for path in pathlib.Path(self.__tool_dir / "Shape").glob("*.fcstd"): + self.assets.add_file("toolbitshape", path) + + def tearDown(self): + del self.assets + del self.asset_store diff --git a/src/Mod/CAM/CAMTests/TestPathHelpers.py b/src/Mod/CAM/CAMTests/TestPathHelpers.py index ce556e2ecc..f299fedd3b 100644 --- a/src/Mod/CAM/CAMTests/TestPathHelpers.py +++ b/src/Mod/CAM/CAMTests/TestPathHelpers.py @@ -25,7 +25,7 @@ import Part import Path import Path.Base.FeedRate as PathFeedRate import Path.Base.MachineState as PathMachineState -import Path.Tool.Bit as PathToolBit +from Path.Tool.toolbit import ToolBit import Path.Tool.Controller as PathToolController import PathScripts.PathUtils as PathUtils @@ -34,12 +34,13 @@ from CAMTests.PathTestUtils import PathTestBase def createTool(name="t1", diameter=1.75): attrs = { - "shape": None, - "name": name, + "name": name or "t1", + "shape": "endmill.fcstd", "parameter": {"Diameter": diameter}, - "attribute": [], + "attribute": {}, } - return PathToolBit.Factory.CreateFromAttrs(attrs, name) + toolbit = ToolBit.from_dict(attrs) + return toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument) class TestPathHelpers(PathTestBase): diff --git a/src/Mod/CAM/CAMTests/TestPathOpDeburr.py b/src/Mod/CAM/CAMTests/TestPathOpDeburr.py index dc911b0b86..d850adcad7 100644 --- a/src/Mod/CAM/CAMTests/TestPathOpDeburr.py +++ b/src/Mod/CAM/CAMTests/TestPathOpDeburr.py @@ -22,7 +22,6 @@ import Path import Path.Op.Deburr as PathDeburr -import Path.Tool.Bit as PathToolBit import CAMTests.PathTestUtils as PathTestUtils Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) diff --git a/src/Mod/CAM/CAMTests/TestPathPreferences.py b/src/Mod/CAM/CAMTests/TestPathPreferences.py index af4a490784..93b1028b0a 100644 --- a/src/Mod/CAM/CAMTests/TestPathPreferences.py +++ b/src/Mod/CAM/CAMTests/TestPathPreferences.py @@ -51,11 +51,19 @@ class TestPathPreferences(PathTestUtils.PathTestBase): def test10(self): """Default paths for tools are resolved correctly""" - self.assertTrue(Path.Preferences.pathDefaultToolsPath().endswith("/CAM/Tools/")) - self.assertTrue(Path.Preferences.pathDefaultToolsPath("Bit").endswith("/CAM/Tools/Bit")) - self.assertTrue( - Path.Preferences.pathDefaultToolsPath("Library").endswith("/CAM/Tools/Library") + self.assertEqual( + Path.Preferences.getDefaultAssetPath().parts[-2:], + ("CAM", "Tools"), + str(Path.Preferences.getDefaultAssetPath()), ) - self.assertTrue( - Path.Preferences.pathDefaultToolsPath("Template").endswith("/CAM/Tools/Template") + self.assertEqual( + Path.Preferences.getBuiltinToolPath().parts[-2:], + ("CAM", "Tools"), + str(Path.Preferences.getBuiltinToolPath()), ) + self.assertEqual( + Path.Preferences.getBuiltinShapePath().parts[-3:], + ("CAM", "Tools", "Shape"), + str(Path.Preferences.getBuiltinShapePath()), + ) + self.assertEqual(Path.Preferences.getToolBitPath().name, "Bit") diff --git a/src/Mod/CAM/CAMTests/TestPathToolAsset.py b/src/Mod/CAM/CAMTests/TestPathToolAsset.py new file mode 100644 index 0000000000..7969b6397c --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAsset.py @@ -0,0 +1,41 @@ +import unittest +from typing import Any, List, Mapping +from Path.Tool.assets import Asset, AssetUri + + +class TestAsset(Asset): + asset_type: str = "test_asset" + + @classmethod + def dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def from_bytes(cls, data: bytes, id: str, dependencies: Mapping[AssetUri, Any]) -> Any: + return "dummy_object" + + def to_bytes(self) -> bytes: + return b"dummy_serialized_data" + + def get_id(self) -> str: + # Dummy implementation for testing purposes + return "dummy_id" + + +class TestPathToolAsset(unittest.TestCase): + def test_asset_cannot_be_instantiated(self): + with self.assertRaises(TypeError): + Asset() # type: ignore + + def test_asset_can_be_instantiated_and_has_members(self): + asset = TestAsset() + self.assertIsInstance(asset, Asset) + self.assertEqual(asset.asset_type, "test_asset") + self.assertEqual(asset.to_bytes(), b"dummy_serialized_data") + self.assertEqual(TestAsset.dependencies(b"some_data"), []) + self.assertEqual(TestAsset.from_bytes(b"some_data", "some_id", {}), "dummy_object") + self.assertEqual(asset.get_id(), "dummy_id") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetCache.py b/src/Mod/CAM/CAMTests/TestPathToolAssetCache.py new file mode 100644 index 0000000000..42d632f776 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetCache.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +import unittest +import asyncio +import hashlib +from typing import Any, Type, Optional, List, Mapping +from Path.Tool.assets.cache import AssetCache, CacheKey +from Path.Tool.assets import ( + AssetManager, + Asset, + AssetUri, + AssetSerializer, + DummyAssetSerializer, + MemoryStore, +) + + +class MockAsset(Asset): + asset_type: str = "mock_asset" + _build_counter = 0 + + def __init__( + self, + asset_id: str, + raw_data: bytes, + dependencies: Optional[Mapping[AssetUri, Any]] = None, + ): + super().__init__() # Initialize Asset ABC + self._asset_id = asset_id # Store id internally + self.raw_data_content = raw_data + self.resolved_dependencies = dependencies or {} + MockAsset._build_counter += 1 + self.build_id = MockAsset._build_counter + + def get_id(self) -> str: # Implement abstract method + return self._asset_id + + # get_uri() is inherited from Asset and uses self.asset_type and self.get_id() + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + # This mock implementation handles the simple "dep:" format + data_str = data.decode() + if data_str.startswith("dep:"): + try: + # Get content after the first "dep:" + dep_content = data_str.split(":", 1)[1] + except IndexError: + # This case should ideally not be reached if startswith("dep:") is true + # and there's content after "dep:", but good for robustness. + return [] + + dep_uri_strings = dep_content.split(",") + uris = [] + for uri_string in dep_uri_strings: + uri_string = uri_string.strip() # Remove leading/trailing whitespace + if not uri_string: + continue + try: + uris.append(AssetUri(uri_string)) + except ValueError: + # This print will now show the full problematic uri_string + print(f"Warning: Could not parse mock dependency URI: '{uri_string}'") + return uris + return [] + + @classmethod + def from_bytes( + cls: Type["MockAsset"], + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Any]], + serializer: AssetSerializer, + ) -> "MockAsset": + return cls(asset_id=id, raw_data=data, dependencies=dependencies) + + def to_bytes(self, serializer: AssetSerializer) -> bytes: # Implement abstract method + return self.raw_data_content + + +class MockAssetB(Asset): # New mock asset class for type 'mock_asset_b' + asset_type: str = "mock_asset_b" + _build_counter = 0 # Separate counter if needed, or share MockAsset's + + def __init__( + self, + asset_id: str, + raw_data: bytes, + dependencies: Optional[Mapping[AssetUri, Any]] = None, + ): + super().__init__() + self._asset_id = asset_id + self.raw_data_content = raw_data + self.resolved_dependencies = dependencies or {} + MockAssetB._build_counter += 1 + self.build_id = MockAssetB._build_counter + + def get_id(self) -> str: + return self._asset_id + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]: + # Keep simple, or adapt if MockAssetB has different dep logic + return [] + + @classmethod + def from_bytes( + cls: Type["MockAssetB"], + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Any]], + serializer: AssetSerializer, + ) -> "MockAssetB": + return cls(asset_id=id, raw_data=data, dependencies=dependencies) + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + return self.raw_data_content + + +def _get_raw_data_hash(raw_data: bytes) -> int: + return int(hashlib.sha256(raw_data).hexdigest(), 16) + + +class TestPathToolAssetCache(unittest.TestCase): + def setUp(self): + self.cache = AssetCache(max_size_bytes=1000) + + def test_put_and_get_simple(self): + key = CacheKey("store1", "mock_asset://id1", _get_raw_data_hash(b"data1"), ("dep1",)) + asset_obj = MockAsset(asset_id="id1", raw_data=b"data1") + self.cache.put(key, asset_obj, len(b"data1"), {"dep_uri_str"}) + + retrieved = self.cache.get(key) + self.assertIsNotNone(retrieved) + # Assuming retrieved is MockAsset, it will have get_id() + self.assertEqual(retrieved.get_id(), "id1") + + def test_get_miss(self): + key = CacheKey("store1", "mock_asset://id1", _get_raw_data_hash(b"data1"), tuple()) + self.assertIsNone(self.cache.get(key)) + + def test_lru_eviction(self): + asset_data_size = 300 + asset1_data = b"a" * asset_data_size + asset2_data = b"b" * asset_data_size + asset3_data = b"c" * asset_data_size + asset4_data = b"d" * asset_data_size + + key1 = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(asset1_data), tuple()) + key2 = CacheKey("s", "mock_asset://id2", _get_raw_data_hash(asset2_data), tuple()) + key3 = CacheKey("s", "mock_asset://id3", _get_raw_data_hash(asset3_data), tuple()) + key4 = CacheKey("s", "mock_asset://id4", _get_raw_data_hash(asset4_data), tuple()) + + self.cache.put(key1, MockAsset("id1", asset1_data), asset_data_size, set()) + self.cache.put(key2, MockAsset("id2", asset2_data), asset_data_size, set()) + self.cache.put(key3, MockAsset("id3", asset3_data), asset_data_size, set()) + + self.assertEqual(self.cache.current_size_bytes, 3 * asset_data_size) + self.assertIsNotNone(self.cache.get(key1)) # Access key1 to make it MRU + + # Adding key4 should evict key2 (oldest after key1 accessed) + self.cache.put(key4, MockAsset("id4", asset4_data), asset_data_size, set()) + self.assertEqual(self.cache.current_size_bytes, 3 * asset_data_size) + self.assertIsNotNone(self.cache.get(key1)) + self.assertIsNone(self.cache.get(key2)) # Evicted + self.assertIsNotNone(self.cache.get(key3)) + self.assertIsNotNone(self.cache.get(key4)) + + def test_invalidate_direct(self): + key = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(b"data"), tuple()) + self.cache.put(key, MockAsset("id1", b"data"), 4, set()) + retrieved = self.cache.get(key) # Ensure it's there + self.assertIsNotNone(retrieved) + + self.cache.invalidate_for_uri("mock_asset://id1") + self.assertIsNone(self.cache.get(key)) + self.assertEqual(self.cache.current_size_bytes, 0) + + def test_invalidate_recursive(self): + data_a = b"data_a_dep:mock_asset_b://idB" # A depends on B + data_b = b"data_b" + data_c = b"data_c_dep:mock_asset_a://idA" # C depends on A + + uri_a_str = "mock_asset_a://idA" + uri_b_str = "mock_asset_b://idB" + uri_c_str = "mock_asset_c://idC" + + key_b = CacheKey("s", uri_b_str, _get_raw_data_hash(data_b), tuple()) + key_a = CacheKey("s", uri_a_str, _get_raw_data_hash(data_a), (uri_b_str,)) + key_c = CacheKey("s", uri_c_str, _get_raw_data_hash(data_c), (uri_a_str,)) + + self.cache.put(key_b, MockAsset("idB", data_b), len(data_b), set()) + self.cache.put(key_a, MockAsset("idA", data_a), len(data_a), {uri_b_str}) + self.cache.put(key_c, MockAsset("idC", data_c), len(data_c), {uri_a_str}) + + self.assertIsNotNone(self.cache.get(key_a)) + self.assertIsNotNone(self.cache.get(key_b)) + self.assertIsNotNone(self.cache.get(key_c)) + + self.cache.invalidate_for_uri(uri_b_str) # Invalidate B + + self.assertIsNone(self.cache.get(key_a), "Asset A should be invalidated") + self.assertIsNone(self.cache.get(key_b), "Asset B should be invalidated") + self.assertIsNone(self.cache.get(key_c), "Asset C should be invalidated") + self.assertEqual(self.cache.current_size_bytes, 0) + + def test_clear_cache(self): + key = CacheKey("s", "mock_asset://id1", _get_raw_data_hash(b"data"), tuple()) + self.cache.put(key, MockAsset("id1", b"data"), 4, set()) + self.assertNotEqual(self.cache.current_size_bytes, 0) + + self.cache.clear() + self.assertIsNone(self.cache.get(key)) + self.assertEqual(self.cache.current_size_bytes, 0) + self.assertEqual(len(self.cache._cache_dependencies_map), 0) + self.assertEqual(len(self.cache._cache_dependents_map), 0) + + +class TestPathToolAssetCacheIntegration(unittest.TestCase): + def setUp(self): + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + self.manager = AssetManager(cache_max_size_bytes=10 * 1024) # 10KB cache + self.store_name = "test_store" + self.store = MemoryStore(name=self.store_name) + self.manager.register_store(self.store, cacheable=True) + self.manager.register_asset(MockAsset, DummyAssetSerializer) + self.manager.register_asset(MockAssetB, DummyAssetSerializer) # Register the new mock type + MockAsset._build_counter = 0 + MockAssetB._build_counter = 0 + + def tearDown(self): + self.loop.close() + + def _run_async(self, coro): + return self.loop.run_until_complete(coro) + + def test_get_caches_asset(self): + uri_str = "mock_asset://asset1" + raw_data = b"asset1_data" + self._run_async(self.store.create("mock_asset", "asset1", raw_data)) + + # First get - should build and cache + asset1 = self.manager.get(uri_str, store=self.store_name) + self.assertIsInstance(asset1, MockAsset) + self.assertEqual(asset1.get_id(), "asset1") + self.assertEqual(MockAsset._build_counter, 1) # Built once + + # Second get - should hit cache + asset2 = self.manager.get(uri_str, store=self.store_name) + self.assertIsInstance(asset2, MockAsset) + self.assertEqual(asset2.get_id(), "asset1") + self.assertEqual(MockAsset._build_counter, 1) # Still 1, not rebuilt + self.assertIs(asset1, asset2) # Should be the same instance from cache + + def test_get_respects_depth_in_cache_key(self): + uri_str = "mock_asset://asset_depth" + # A depends on B (mock_asset_b://dep_b) + raw_data_a = b"dep:mock_asset_b://dep_b" + raw_data_b = b"dep_b_data" + + self._run_async(self.store.create("mock_asset", "asset_depth", raw_data_a)) + self._run_async(self.store.create("mock_asset_b", "dep_b", raw_data_b)) + + # Get with depth=0 (shallow) + asset_shallow = self.manager.get(uri_str, store=self.store_name, depth=0) + self.assertIsInstance(asset_shallow, MockAsset) + self.assertEqual(len(asset_shallow.resolved_dependencies), 0) + self.assertEqual(MockAsset._build_counter, 1) # asset_depth built + + # Get with depth=None (full) + asset_full = self.manager.get(uri_str, store=self.store_name, depth=None) + self.assertIsInstance(asset_full, MockAsset) + self.assertEqual(len(asset_full.resolved_dependencies), 1) + # asset_depth (MockAsset) built twice (once shallow, once full) + self.assertEqual(MockAsset._build_counter, 2) + # dep_b (MockAssetB) built once as a dependency of the full asset_depth + self.assertEqual(MockAssetB._build_counter, 1) + + # Get shallow again - should hit shallow cache + asset_shallow_2 = self.manager.get(uri_str, store=self.store_name, depth=0) + self.assertIs(asset_shallow, asset_shallow_2) + self.assertEqual(MockAsset._build_counter, 2) # No new MockAsset builds + self.assertEqual(MockAssetB._build_counter, 1) # No new MockAssetB builds + + # Get full again - should hit full cache + asset_full_2 = self.manager.get(uri_str, store=self.store_name, depth=None) + self.assertIs(asset_full, asset_full_2) + self.assertEqual(MockAsset._build_counter, 2) # No new MockAsset builds + self.assertEqual(MockAssetB._build_counter, 1) # No new MockAssetB builds + + def test_update_invalidates_cache(self): + uri_str = "mock_asset://asset_upd" + raw_data_v1 = b"version1" + raw_data_v2 = b"version2" + asset_uri = AssetUri(uri_str) # Use real AssetUri + + self._run_async(self.store.create(asset_uri.asset_type, asset_uri.asset_id, raw_data_v1)) + + asset_v1 = self.manager.get(asset_uri, store=self.store_name) + self.assertEqual(asset_v1.raw_data_content, raw_data_v1) + self.assertEqual(MockAsset._build_counter, 1) + + # Update the asset in the store (MemoryStore creates new version) + # For this test, let's simulate an update by re-adding with add_raw + # which should trigger invalidation. + # Note: MemoryStore's update creates a new version, so get() would get latest. + # To test invalidation of the *exact* cached object, we'd need to ensure + # the cache key changes (e.g. raw_data_hash). + # Let's use add_raw which calls invalidate. + self.manager.add_raw( + asset_uri.asset_type, asset_uri.asset_id, raw_data_v2, store=self.store_name + ) + + # Get again - should rebuild because v1 was invalidated + # And MemoryStore's get() for a URI without version gets latest. + # The add_raw invalidates based on URI (type+id), so all versions of it. + asset_v2 = self.manager.get(asset_uri, store=self.store_name) + self.assertEqual(asset_v2.raw_data_content, raw_data_v2) + self.assertEqual(MockAsset._build_counter, 2) # Rebuilt + + def test_delete_invalidates_cache(self): + uri_str = "mock_asset://asset_del" + raw_data = b"delete_me" + asset_uri = AssetUri(uri_str) + self._run_async(self.store.create(asset_uri.asset_type, asset_uri.asset_id, raw_data)) + + asset1 = self.manager.get(asset_uri, store=self.store_name) + self.assertIsNotNone(asset1) + self.assertEqual(MockAsset._build_counter, 1) + + self.manager.delete(asset_uri, store=self.store_name) + + with self.assertRaises(FileNotFoundError): + self.manager.get(asset_uri, store=self.store_name) + # Check build counter didn't increase due to trying to get deleted asset + self.assertEqual(MockAsset._build_counter, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py new file mode 100644 index 0000000000..d014475bc1 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetManager.py @@ -0,0 +1,468 @@ +import unittest +import asyncio +from unittest.mock import Mock +import pathlib +import tempfile +from typing import Any, Mapping, List +from Path.Tool.assets import ( + AssetManager, + FileStore, + Asset, + AssetUri, + MemoryStore, + AssetSerializer, + DummyAssetSerializer, +) + + +# Mock Asset class for testing +class MockAsset(Asset): + asset_type: str = "mock_asset" + + def __init__(self, data: Any = None, id: str = "mock_id"): + self._data = data + self._id = id + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: AssetSerializer) -> List[AssetUri]: + # Mock implementation doesn't use data or format for dependencies + return [] + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Mapping[AssetUri, Asset] | None, + serializer: AssetSerializer, + ) -> "MockAsset": + # Create instance with provided id + return cls(data, id) + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + return self._data + + def get_id(self) -> str: + return self._id + + +class TestPathToolAssetManager(unittest.TestCase): + def test_register_store(self): + manager = AssetManager() + mock_store_local = Mock() + mock_store_local.name = "local" + mock_store_remote = Mock() + mock_store_remote.name = "remote" + + manager.register_store(mock_store_local) + self.assertEqual(manager.stores["local"], mock_store_local) + + manager.register_store(mock_store_remote) + self.assertEqual(manager.stores["remote"], mock_store_remote) + + # Test overwriting + mock_store_local_new = Mock() + mock_store_local_new.name = "local" + manager.register_store(mock_store_local_new) + self.assertEqual(manager.stores["local"], mock_store_local_new) + + def test_register_asset(self): + manager = AssetManager() + # Register the actual MockAsset class + manager.register_asset(MockAsset, DummyAssetSerializer) + self.assertEqual(manager._asset_classes[MockAsset.asset_type], MockAsset) + + # Test registering a different actual Asset class + class AnotherMockAsset(Asset): + asset_type: str = "another_mock_asset" + + @classmethod + def dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Mapping[AssetUri, Asset] | None, + serializer: AssetSerializer, + ) -> "AnotherMockAsset": + return cls() + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + return b"" + + def get_id(self) -> str: + return "another_mock_id" + + manager.register_asset(AnotherMockAsset, DummyAssetSerializer) + self.assertEqual(manager._asset_classes[AnotherMockAsset.asset_type], AnotherMockAsset) + + # Test overwriting + manager.register_asset( + MockAsset, DummyAssetSerializer + ) # Registering again should overwrite + self.assertEqual(manager._asset_classes[MockAsset.asset_type], MockAsset) + + # Test registering non-Asset class + with self.assertRaises(TypeError): + + class NotAnAsset(Asset): # Inherit from Asset + pass + + manager.register_asset(NotAnAsset, DummyAssetSerializer) + + def test_get(self): + # Setup AssetManager with a real LocalStore and the MockAsset class + with tempfile.TemporaryDirectory() as tmpdir: + base_dir = pathlib.Path(tmpdir) + local_store = FileStore("local", base_dir) + manager = AssetManager() + manager.register_store(local_store) + + # Register the MockAsset class + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create a test asset file via AssetManager + test_data = b"test asset data" + test_uri = manager.add_raw( + asset_type=MockAsset.asset_type, + asset_id="dummy_id_get", + data=test_data, + store="local", + ) + + # Call AssetManager.get + retrieved_object = manager.get(test_uri) + + # Assert the retrieved object is an instance of MockAsset + self.assertIsInstance(retrieved_object, MockAsset) + # Assert the data was passed to from_bytes + self.assertEqual(retrieved_object._data, test_data) + + # Test error handling for non-existent URI + non_existent_uri = AssetUri.build(MockAsset.asset_type, "non_existent", "1") + with self.assertRaises(FileNotFoundError): + manager.get(non_existent_uri) + + # Test error handling for no asset class registered + non_registered_uri = AssetUri.build("non_existent_type", "dummy_id", "1") + # Need to create a dummy file for the store to find + dummy_data = b"dummy" + manager.add_raw( + asset_type="non_existent_type", asset_id="dummy_id", data=dummy_data, store="local" + ) + + with self.assertRaises(ValueError) as cm: + manager.get(non_registered_uri) + self.assertIn("No asset class registered for asset type:", str(cm.exception)) + + def test_delete(self): + # Setup AssetManager with a real LocalStore + with tempfile.TemporaryDirectory() as tmpdir: + base_dir = pathlib.Path(tmpdir) + local_store = FileStore("local", base_dir) + manager = AssetManager() + manager.register_store(local_store) + + # Create a test asset file + test_data = b"test asset data to delete" + test_uri = manager.add_raw( + asset_type="temp_asset", asset_id="dummy_id_delete", data=test_data, store="local" + ) + test_path = base_dir / "temp_asset" / str(test_uri.asset_id) / str(test_uri.version) + self.assertTrue(test_path.exists()) + + # Call AssetManager.delete + manager.delete(test_uri) + + # Verify file deletion + self.assertFalse(test_path.exists()) + + # Test error handling for non-existent URI (should not raise error + # as LocalStore.delete handles this) + non_existent_uri = AssetUri.build( + "temp_asset", "non_existent", "1" # Keep original for logging + ) + manager.delete(non_existent_uri) # Should not raise + + def test_create(self): + # Setup AssetManager with LocalStore and MockAsset class + with tempfile.TemporaryDirectory() as tmpdir: + base_dir = pathlib.Path(tmpdir) + local_store = FileStore("local", base_dir) + manager = AssetManager() + manager.register_store(local_store) + + # Register the MockAsset class + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create a MockAsset instance with a specific id + test_obj = MockAsset(b"object data", id="mocked_asset_id") + + # Call manager.add to create + created_uri = manager.add(test_obj, store="local") + + # Assert returned URI is as expected + expected_uri = AssetUri.build(MockAsset.asset_type, "mocked_asset_id", "1") + self.assertEqual(created_uri, expected_uri) + + # Verify the asset was created + retrieved_data = asyncio.run(local_store.get(created_uri)) + self.assertEqual(retrieved_data, test_obj.to_bytes(DummyAssetSerializer)) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.add(test_obj, store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + with tempfile.TemporaryDirectory() as tmpdir: + local_store = MemoryStore("local") + manager = AssetManager() + manager.register_store(local_store) + + # Register the MockAsset class + manager.register_asset(MockAsset, DummyAssetSerializer) + + # First, create an asset + initial_data = b"initial data" + asset_id = "some_asset_id" + test_uri = manager.add_raw(MockAsset.asset_type, asset_id, initial_data, "local") + self.assertEqual(test_uri.version, "1") + + # Create a MockAsset instance with the same id for update + updated_data = b"updated object data" + test_obj = MockAsset(updated_data, id=asset_id) + + # Call manager.add to update + updated_uri = manager.add(test_obj, store="local") + + # Assert returned URI matches the original except for version + self.assertEqual(updated_uri.asset_type, test_uri.asset_type) + self.assertEqual(updated_uri.asset_id, test_uri.asset_id) + self.assertEqual(updated_uri.version, "2") + + # Verify the asset was updated + obj = manager.get(updated_uri, store="local") + self.assertEqual(updated_data, test_obj.to_bytes(DummyAssetSerializer)) + self.assertEqual(updated_data, obj.to_bytes(DummyAssetSerializer)) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.add(test_obj, store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_create_raw(self): + # Setup AssetManager with a real MemoryStore + memory_store = MemoryStore("memory_raw") + manager = AssetManager() + manager.register_store(memory_store) + + asset_type = "raw_test_type" + asset_id = "raw_test_id" + data = b"raw test data" + + # Expected URI with version 1 (assuming MemoryStore uses integer versions) + expected_uri = AssetUri.build(asset_type, asset_id, "1") + + # Call manager.add_raw + created_uri = manager.add_raw( + asset_type=asset_type, asset_id=asset_id, data=data, store="memory_raw" + ) + + # Assert returned URI is correct (check asset_type and asset_id) + self.assertEqual(created_uri.asset_type, asset_type) + self.assertEqual(created_uri.asset_id, asset_id) + self.assertEqual(created_uri, expected_uri) + + # Verify data was stored using the actual created_uri + # Await the async get method using asyncio.run + retrieved_data = asyncio.run(memory_store.get(created_uri)) + self.assertEqual(retrieved_data, data) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.add_raw( + asset_type=asset_type, asset_id=asset_id, data=data, store="non_existent_store" + ) + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_get_raw(self): + # Setup AssetManager with a real MemoryStore + memory_store = MemoryStore("memory_raw_get") + manager = AssetManager() + manager.register_store(memory_store) + + test_uri_str = "test_type://test_id/1" + test_uri = AssetUri(test_uri_str) + expected_data = b"retrieved raw data" + + # Manually put data into the memory store + manager.add_raw("test_type", "test_id", expected_data, "memory_raw_get") + + # Call manager.get_raw using the URI returned by add_raw + retrieved_data = manager.get_raw(test_uri, store="memory_raw_get") + + # Assert returned data matches store's result + self.assertEqual(retrieved_data, expected_data) + + # Test error handling (store not found) + non_existent_uri = AssetUri("type://id/1") + with self.assertRaises(ValueError) as cm: + manager.get_raw(non_existent_uri, store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_is_empty(self): + # Setup AssetManager with a real MemoryStore + memory_store = MemoryStore("memory_empty") + manager = AssetManager() + manager.register_store(memory_store) + + # Test when store is empty + self.assertTrue(manager.is_empty(store="memory_empty")) + + # Add an asset and test again + manager.add_raw("test_type", "test_id", b"data", "memory_empty") + self.assertFalse(manager.is_empty(store="memory_empty")) + + # Test with asset type + self.assertTrue(manager.is_empty(store="memory_empty", asset_type="another_type")) + self.assertFalse(manager.is_empty(store="memory_empty", asset_type="test_type")) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.is_empty(store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_count_assets(self): + # Setup AssetManager with a real MemoryStore + memory_store = MemoryStore("memory_count") + manager = AssetManager() + manager.register_store(memory_store) + + # Test when store is empty + self.assertEqual(manager.count_assets(store="memory_count"), 0) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 0) + + # Add assets and test counts + manager.add_raw("type1", "asset1", b"data1", "memory_count") + self.assertEqual(manager.count_assets(store="memory_count"), 1) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 1) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type2"), 0) + + manager.add_raw("type2", "asset2", b"data2", "memory_count") + manager.add_raw("type1", "asset3", b"data3", "memory_count") + self.assertEqual(manager.count_assets(store="memory_count"), 3) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type1"), 2) + self.assertEqual(manager.count_assets(store="memory_count", asset_type="type2"), 1) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.count_assets(store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_get_bulk(self): + # Setup AssetManager with a real MemoryStore and MockAsset class + memory_store = MemoryStore("memory_bulk") + manager = AssetManager() + manager.register_store(memory_store) + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create some assets in the memory store + data1 = b"data for id1" + data2 = b"data for id2" + uri1 = manager.add_raw(MockAsset.asset_type, "id1", data1, "memory_bulk") + uri2 = manager.add_raw(MockAsset.asset_type, "id2", data2, "memory_bulk") + uri3 = AssetUri.build(MockAsset.asset_type, "non_existent", "1") + uris = [uri1, uri2, uri3] + + # Call manager.get_bulk + retrieved_assets = manager.get_bulk(uris, store="memory_bulk") + + # Assert the correct number of assets were returned + self.assertEqual(len(retrieved_assets), 3) + + # Assert the retrieved assets are MockAsset instances with correct data + self.assertIsInstance(retrieved_assets[0], MockAsset) + self.assertEqual( + retrieved_assets[0].to_bytes(DummyAssetSerializer), + data1, + ) + + self.assertIsInstance(retrieved_assets[1], MockAsset) + self.assertEqual( + retrieved_assets[1].to_bytes(DummyAssetSerializer), + data2, + ) + + # Assert the non-existent asset is None + self.assertIsNone(retrieved_assets[2]) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.get_bulk(uris, store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + def test_fetch(self): + # Setup AssetManager with a real MemoryStore and MockAsset class + memory_store = MemoryStore("memory_fetch") + manager = AssetManager() + manager.register_store(memory_store) + manager.register_asset(MockAsset, DummyAssetSerializer) + + # Create some assets in the memory store + data1 = b"data for id1" + data2 = b"data for id2" + manager.add_raw(MockAsset.asset_type, "id1", data1, "memory_fetch") + manager.add_raw(MockAsset.asset_type, "id2", data2, "memory_fetch") + # Create an asset of a different type + manager.add_raw("another_type", "id3", b"data for id3", "memory_fetch") + AssetUri.build(MockAsset.asset_type, "non_existent", "1") + + # Call manager.fetch without filters + # This should raise ValueError because uri3 has an unregistered type + with self.assertRaises(ValueError) as cm: + manager.fetch(store="memory_fetch") + self.assertIn("No asset class registered for asset type:", str(cm.exception)) + + # Now test fetching with a registered asset type filter + # Setup a new manager and store to avoid state from previous test + memory_store_filtered = MemoryStore("memory_fetch_filtered") + manager_filtered = AssetManager() + manager_filtered.register_store(memory_store_filtered) + manager_filtered.register_asset(MockAsset, DummyAssetSerializer) + + # Create assets again + manager_filtered.add_raw(MockAsset.asset_type, "id1", data1, "memory_fetch_filtered") + manager_filtered.add_raw(MockAsset.asset_type, "id2", data2, "memory_fetch_filtered") + manager_filtered.add_raw("another_type", "id3", b"data for id3", "memory_fetch_filtered") + + retrieved_assets_filtered = manager_filtered.fetch( + asset_type=MockAsset.asset_type, store="memory_fetch_filtered" + ) + + # Assert the correct number of assets were returned + self.assertEqual(len(retrieved_assets_filtered), 2) + + # Assert the retrieved assets are MockAsset instances with correct data + self.assertIsInstance(retrieved_assets_filtered[0], MockAsset) + self.assertEqual( + retrieved_assets_filtered[0].to_bytes(DummyAssetSerializer).decode("utf-8"), + data1.decode("utf-8"), + ) + + self.assertIsInstance(retrieved_assets_filtered[1], MockAsset) + self.assertEqual( + retrieved_assets_filtered[1].to_bytes(DummyAssetSerializer).decode("utf-8"), + data2.decode("utf-8"), + ) + + # Test error handling (store not found) + with self.assertRaises(ValueError) as cm: + manager.fetch(store="non_existent_store") + self.assertIn("No store registered for name:", str(cm.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetStore.py b/src/Mod/CAM/CAMTests/TestPathToolAssetStore.py new file mode 100644 index 0000000000..a7b400a683 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetStore.py @@ -0,0 +1,389 @@ +import unittest +import pathlib +import asyncio +import tempfile +from uuid import uuid4 +from Path.Tool.assets import ( + AssetUri, + AssetStore, + MemoryStore, + FileStore, +) + + +class BaseTestPathToolAssetStore(unittest.TestCase): + """ + Base test suite for Path Tool Asset Stores assuming full versioning support. + Store-agnostic tests without direct file system access. + """ + + store: AssetStore + + def setUp(self): + self.tmp_dir = tempfile.TemporaryDirectory() + self.tmp_path = pathlib.Path(self.tmp_dir.name) + + def tearDown(self): + self.tmp_dir.cleanup() + + def test_name(self): + self.assertIsNotNone(self.store) + self.assertIsInstance(self.store.name, str) + self.assertTrue(len(self.store.name) > 0) + + def test_create_and_get(self): + async def async_test(): + data = b"test data" + asset_type = f"type_{uuid4()}" + asset_id = f"asset_{uuid4()}" + uri = await self.store.create(asset_type, asset_id, data) + + self.assertIsInstance(uri, AssetUri) + self.assertEqual(uri.asset_type, asset_type) + self.assertEqual(uri.asset_id, asset_id) + self.assertIsNotNone(uri.version) + + retrieved_data = await self.store.get(uri) + self.assertEqual(retrieved_data, data) + + # Test non-existent URI + non_existent_uri = AssetUri.build( + asset_type="non_existent", asset_id="missing", version="1" + ) + with self.assertRaises(FileNotFoundError): + await self.store.get(non_existent_uri) + + asyncio.run(async_test()) + + def test_delete(self): + async def async_test(): + data = b"data to delete" + asset_type = "delete_type" + asset_id = f"asset_{uuid4()}" + uri = await self.store.create(asset_type, asset_id, data) + + await self.store.delete(uri) + + with self.assertRaises(FileNotFoundError): + await self.store.get(uri) + + # Deleting non-existent URI should not raise + non_existent_uri = AssetUri.build( + asset_type="non_existent", asset_id="missing", version="1" + ) + await self.store.delete(non_existent_uri) + + asyncio.run(async_test()) + + def test_is_empty(self): + async def async_test(): + self.assertTrue(await self.store.is_empty()) + self.assertTrue(await self.store.is_empty("type1")) + + uri1 = await self.store.create("type1", f"asset_{uuid4()}", b"data") + self.assertFalse(await self.store.is_empty()) + self.assertFalse(await self.store.is_empty("type1")) + self.assertTrue(await self.store.is_empty("type2")) + + uri2 = await self.store.create("type2", f"asset_{uuid4()}", b"data") + await self.store.delete(uri1) + self.assertFalse(await self.store.is_empty()) + self.assertTrue(await self.store.is_empty("type1")) + self.assertFalse(await self.store.is_empty("type2")) + + await self.store.delete(uri2) + self.assertTrue(await self.store.is_empty()) + + asyncio.run(async_test()) + + def test_count_assets(self): + async def async_test(): + self.assertEqual(await self.store.count_assets(), 0) + self.assertEqual(await self.store.count_assets("type1"), 0) + + uri1 = await self.store.create("type1", f"asset1_{uuid4()}", b"data1") + self.assertEqual(await self.store.count_assets(), 1) + self.assertEqual(await self.store.count_assets("type1"), 1) + self.assertEqual(await self.store.count_assets("type2"), 0) + + uri2 = await self.store.create("type2", f"asset2_{uuid4()}", b"data2") + uri3 = await self.store.create("type1", f"asset3_{uuid4()}", b"data3") + self.assertEqual(await self.store.count_assets(), 3) + self.assertEqual(await self.store.count_assets("type1"), 2) + self.assertEqual(await self.store.count_assets("type2"), 1) + + await self.store.delete(uri1) + self.assertEqual(await self.store.count_assets(), 2) + self.assertEqual(await self.store.count_assets("type1"), 1) + self.assertEqual(await self.store.count_assets("type2"), 1) + + await self.store.delete(uri2) + await self.store.delete(uri3) + self.assertEqual(await self.store.count_assets(), 0) + self.assertEqual(await self.store.count_assets("type1"), 0) + self.assertEqual(await self.store.count_assets("type2"), 0) + + asyncio.run(async_test()) + + def test_list_assets(self): + async def async_test(): + asset_typedata = [ + ("type1", f"asset1_{uuid4()}", b"data1"), + ("type1", f"asset2_{uuid4()}", b"data2"), + ("type2", f"asset3_{uuid4()}", b"data3"), + ] + + uris = [] + for asset_type, asset_id, data in asset_typedata: + uri = await self.store.create(asset_type, asset_id, data) + uris.append(uri) + + all_assets = await self.store.list_assets() + self.assertEqual(len(all_assets), 3) + for uri in uris: + self.assertTrue(any(u.asset_id == uri.asset_id for u in all_assets)) + + type1_assets = await self.store.list_assets(asset_type="type1") + self.assertEqual(len(type1_assets), 2) + self.assertTrue(any(u.asset_id == uris[0].asset_id for u in type1_assets)) + self.assertTrue(any(u.asset_id == uris[1].asset_id for u in type1_assets)) + + paginated = await self.store.list_assets(limit=2) + self.assertEqual(len(paginated), 2) + + asyncio.run(async_test()) + + def test_update_versioning(self): + async def async_test(): + initial_data = b"initial data" + updated_data = b"updated data" + asset_type = f"update_{uuid4()}" + asset_id = f"asset_{uuid4()}" + + uri1 = await self.store.create(asset_type, asset_id, initial_data) + uri2 = await self.store.update(uri1, updated_data) + + self.assertEqual(uri1.asset_type, uri2.asset_type) + self.assertEqual(uri1.asset_id, uri2.asset_id) + self.assertEqual(uri1.version, "1") + self.assertEqual(uri2.version, "2") + self.assertNotEqual(uri1.version, uri2.version) + + self.assertEqual(await self.store.get(uri1), initial_data) + self.assertEqual(await self.store.get(uri2), updated_data) + + with self.assertRaises(FileNotFoundError): + non_existent_uri = AssetUri.build( + asset_type="non_existent", asset_id="missing", version="1" + ) + await self.store.update(non_existent_uri, b"data") + + asyncio.run(async_test()) + + def test_list_versions(self): + async def async_test(): + asset_type = f"version_{uuid4()}" + asset_id = f"asset_{uuid4()}" + data1 = b"version1" + data2 = b"version2" + + uri1 = await self.store.create(asset_type, asset_id, data1) + uri2 = await self.store.update(uri1, data2) + + versions = await self.store.list_versions(uri1) + self.assertEqual(len(versions), 2) + version_ids = {v.version for v in versions if v.version} + self.assertEqual(version_ids, {uri1.version, uri2.version}) + + non_existent_uri = AssetUri.build( + asset_type="non_existent", asset_id="missing", version="1" + ) + self.assertEqual(await self.store.list_versions(non_existent_uri), []) + + asyncio.run(async_test()) + + def test_create_with_empty_data(self): + async def async_test(): + data = b"" + asset_type = "shape" + asset_id = f"asset_{uuid4()}" + uri = await self.store.create(asset_type, asset_id, data) + + self.assertIsInstance(uri, AssetUri) + retrieved_data = await self.store.get(uri) + self.assertEqual(retrieved_data, data) + + asyncio.run(async_test()) + + def test_list_assets_non_existent_type(self): + async def async_test(): + assets = await self.store.list_assets(asset_type=f"non_existent_type_{uuid4()}") + self.assertEqual(len(assets), 0) + + asyncio.run(async_test()) + + def test_list_assets_pagination_offset_too_high(self): + async def async_test(): + await self.store.create("shape", f"asset1_{uuid4()}", b"data") + assets = await self.store.list_assets(offset=100) # Assuming less than 100 assets + self.assertEqual(len(assets), 0) + + asyncio.run(async_test()) + + def test_list_assets_pagination_limit_zero(self): + async def async_test(): + await self.store.create("shape", f"asset1_{uuid4()}", b"data") + assets = await self.store.list_assets(limit=0) + self.assertEqual(len(assets), 0) + + asyncio.run(async_test()) + + def test_create_delete_recreate(self): + async def async_test(): + asset_type = "shape" + asset_id = f"asset_{uuid4()}" + data1 = b"first data" + data2 = b"second data" + + uri1 = await self.store.create(asset_type, asset_id, data1) + self.assertEqual(await self.store.get(uri1), data1) + # For versioned stores, this would be version "1" + # For stores that don't deeply track versions, it's important what happens on recreate + + await self.store.delete(uri1) + with self.assertRaises(FileNotFoundError): + await self.store.get(uri1) + + uri2 = await self.store.create(asset_type, asset_id, data2) + self.assertEqual(await self.store.get(uri2), data2) + + # Behavior of uri1.version vs uri2.version depends on store implementation + # For a fully versioned store that starts fresh: + self.assertEqual( + uri2.version, "1", "Recreating should yield version 1 for a fresh start" + ) + + # Ensure only the new asset exists if the store fully removes old versions + versions = await self.store.list_versions( + AssetUri.build(asset_type=asset_type, asset_id=asset_id) + ) + self.assertEqual(len(versions), 1) + self.assertEqual(versions[0].version, "1") + + asyncio.run(async_test()) + + def test_get_non_existent_specific_version(self): + async def async_test(): + asset_type = "shape" + asset_id = f"asset_{uuid4()}" + await self.store.create(asset_type, asset_id, b"data_v1") + + non_existent_version_uri = AssetUri.build( + asset_type=asset_type, + asset_id=asset_id, + version="99", # Assuming version 99 won't exist + ) + with self.assertRaises(FileNotFoundError): + await self.store.get(non_existent_version_uri) + + asyncio.run(async_test()) + + def test_delete_last_version(self): + async def async_test(): + asset_type = "shape" + asset_id = f"asset_{uuid4()}" + + uri_v1 = await self.store.create(asset_type, asset_id, b"v1_data") + uri_v2 = await self.store.update(uri_v1, b"v2_data") + + await self.store.delete(uri_v2) # Delete latest version + with self.assertRaises(FileNotFoundError): + await self.store.get(uri_v2) + + # v1 should still exist + self.assertEqual(await self.store.get(uri_v1), b"v1_data") + versions_after_v2_delete = await self.store.list_versions(uri_v1) + self.assertEqual(len(versions_after_v2_delete), 1) + self.assertEqual(versions_after_v2_delete[0].version, "1") + + await self.store.delete(uri_v1) # Delete the now last version (v1) + with self.assertRaises(FileNotFoundError): + await self.store.get(uri_v1) + + versions_after_all_delete = await self.store.list_versions(uri_v1) + self.assertEqual(len(versions_after_all_delete), 0) + + # Asset should not appear in list_assets + listed_assets = await self.store.list_assets(asset_type=asset_type) + self.assertFalse(any(a.asset_id == asset_id for a in listed_assets)) + self.assertTrue(await self.store.is_empty(asset_type)) + + asyncio.run(async_test()) + + def test_get_latest_on_non_existent_asset(self): + async def async_test(): + latest_uri = AssetUri.build( + asset_type="shape", + asset_id=f"non_existent_id_for_latest_{uuid4()}", + version="latest", + ) + with self.assertRaises( + FileNotFoundError + ): # Or custom NoVersionsFoundError if that's how store behaves + await self.store.get(latest_uri) + + asyncio.run(async_test()) + + +class TestPathToolFileStore(BaseTestPathToolAssetStore): + """Test suite for FileStore with full versioning support.""" + + def setUp(self): + super().setUp() + asset_type_map = { + "*": "{asset_type}/{asset_id}/{version}", + "special1": "Especial/{asset_id}/{version}", + "special2": "my/super/{asset_id}.spcl", + } + self.store = FileStore("versioned", self.tmp_path, asset_type_map) + + def test_get_latest_version(self): + async def async_test(): + asset_type = f"latest_{uuid4()}" + asset_id = f"asset_{uuid4()}" + + uri1 = await self.store.create(asset_type, asset_id, b"v1") + await self.store.update(uri1, b"v2") + + latest_uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id, version="latest") + self.assertEqual(await self.store.get(latest_uri), b"v2") + + asyncio.run(async_test()) + + def test_delete_all_versions(self): + async def async_test(): + asset_type = f"delete_{uuid4()}" + asset_id = f"asset_{uuid4()}" + + uri1 = await self.store.create(asset_type, asset_id, b"v1") + await self.store.update(uri1, b"v2") + + uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id) + await self.store.delete(uri) + + with self.assertRaises(FileNotFoundError): + await self.store.get(uri1) + + asyncio.run(async_test()) + + +class TestPathToolMemoryStore(BaseTestPathToolAssetStore): + """Test suite for MemoryStore.""" + + def setUp(self): + super().setUp() + self.store = MemoryStore("memory_test") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolAssetUri.py b/src/Mod/CAM/CAMTests/TestPathToolAssetUri.py new file mode 100644 index 0000000000..c22aa8c8a9 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolAssetUri.py @@ -0,0 +1,101 @@ +import unittest +from Path.Tool.assets.uri import AssetUri + + +class TestPathToolAssetUri(unittest.TestCase): + """ + Test suite for the AssetUri utility class. + """ + + def test_uri_parsing_full(self): + uri_string = "remote://asset_id/version?" "param1=value1¶m2=value2" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "remote") + self.assertEqual(uri.asset_id, "asset_id") + self.assertEqual(uri.version, "version") + self.assertEqual(uri.params, {"param1": ["value1"], "param2": ["value2"]}) + self.assertEqual(str(uri), uri_string) + self.assertEqual(repr(uri), f"AssetUri('{uri_string}')") + + def test_uri_parsing_local(self): + uri_string = "local://id/2?param=value" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "local") + self.assertEqual(uri.asset_id, "id") + self.assertEqual(uri.version, "2") + self.assertEqual(uri.params, {"param": ["value"]}) + self.assertEqual(str(uri), uri_string) + self.assertEqual(repr(uri), f"AssetUri('{uri_string}')") + + def test_uri_parsing_no_params(self): + uri_string = "file://asset_id/1" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "file") + self.assertEqual(uri.asset_id, "asset_id") + self.assertEqual(uri.version, "1") + self.assertEqual(uri.params, {}) + self.assertEqual(str(uri), uri_string) + self.assertEqual(repr(uri), f"AssetUri('{uri_string}')") + + def test_uri_version_missing(self): + uri_string = "foo://asset" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "foo") + self.assertEqual(uri.asset_id, "asset") + self.assertIsNone(uri.version) + self.assertEqual(uri.params, {}) + self.assertEqual(str(uri), uri_string) + + def test_uri_parsing_with_version(self): + """ + Test parsing a URI string with asset_type, asset_id, and version. + """ + uri_string = "test_type://test_id/1" + uri = AssetUri(uri_string) + self.assertEqual(uri.asset_type, "test_type") + self.assertEqual(uri.asset_id, "test_id") + self.assertEqual(uri.version, "1") + self.assertEqual(uri.params, {}) + self.assertEqual(str(uri), uri_string) + self.assertEqual(repr(uri), f"AssetUri('{uri_string}')") + + def test_uri_build_full(self): + expected_uri_string = "local://asset_id/version?param1=value1" + uri = AssetUri.build( + asset_type="local", asset_id="asset_id", version="version", params={"param1": "value1"} + ) + self.assertEqual(str(uri), expected_uri_string) + self.assertEqual(uri.asset_type, "local") + self.assertEqual(uri.asset_id, "asset_id") + self.assertEqual(uri.version, "version") + self.assertEqual(uri.params, {"param1": ["value1"]}) # parse_qs always returns list + + def test_uri_build_latest_version_no_params(self): + expected_uri_string = "remote://id/latest" + uri = AssetUri.build(asset_type="remote", asset_id="id", version="latest") + self.assertEqual(str(uri), expected_uri_string) + self.assertEqual(uri.asset_type, "remote") + self.assertEqual(uri.asset_id, "id") + self.assertEqual(uri.version, "latest") + self.assertEqual(uri.params, {}) + + def test_uri_equality(self): + uri1 = AssetUri("local://asset/version") + uri2 = AssetUri("local://asset/version") + uri3 = AssetUri("local://asset/another_version") + self.assertEqual(uri1, uri2) + self.assertNotEqual(uri1, uri3) + self.assertNotEqual(uri1, "not a uri") + + def test_uri_parsing_invalid_path_structure(self): + """ + Test that parsing a URI string with an invalid path structure + (more than one component) raises a ValueError. + """ + uri_string = "local://foo/bar/1" + with self.assertRaisesRegex(ValueError, "Invalid URI path structure:"): + AssetUri(uri_string) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBit.py b/src/Mod/CAM/CAMTests/TestPathToolBit.py index 90f3400fbb..d5b9fbe758 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolBit.py +++ b/src/Mod/CAM/CAMTests/TestPathToolBit.py @@ -20,134 +20,62 @@ # * * # *************************************************************************** -import Path.Tool.Bit as PathToolBit -import CAMTests.PathTestUtils as PathTestUtils -import glob +from typing import cast import os - -TestToolDir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "Tools") -TestInvalidDir = os.path.join( - TestToolDir, "some", "silly", "path", "that", "should", "not", "exist" -) - -TestToolBitName = "test-path-tool-bit-bit-00.fctb" -TestToolShapeName = "test-path-tool-bit-shape-00.fcstd" -TestToolLibraryName = "test-path-tool-bit-library-00.fctl" +import uuid +import pathlib +import FreeCAD +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.library import Library +from Path.Tool.shape import ToolBitShapeBullnose +from Path.Tool.toolbit import ToolBitEndmill, ToolBitBullnose -def testToolShape(path=TestToolDir, name=TestToolShapeName): - return os.path.join(path, "Shape", name) +TOOL_DIR = pathlib.Path(os.path.realpath(__file__)).parent.parent / "Tools" +SHAPE_DIR = TOOL_DIR / "Shape" +BIT_DIR = TOOL_DIR / "Bit" -def testToolBit(path=TestToolDir, name=TestToolBitName): - return os.path.join(path, "Bit", name) - - -def testToolLibrary(path=TestToolDir, name=TestToolLibraryName): - return os.path.join(path, "Library", name) - - -def printTree(path, indent): - print("{} {}".format(indent, os.path.basename(path))) - if os.path.isdir(path): - if os.path.basename(path).startswith("__"): - print("{} ...".format(indent)) - else: - for foo in sorted(glob.glob(os.path.join(path, "*"))): - printTree(foo, "{} ".format(indent)) - - -class TestPathToolBit(PathTestUtils.PathTestBase): - def test(self): - """Log test setup directory structure""" - # Enable this test if there are errors showing up in the build system with the - # paths that work OK locally. It'll print out the directory tree, and if it - # doesn't look right you know where to look for it - print() - print("realpath : {}".format(os.path.realpath(__file__))) - print(" Tools : {}".format(TestToolDir)) - print(" dir : {}".format(os.path.dirname(os.path.realpath(__file__)))) - printTree(os.path.dirname(os.path.realpath(__file__)), " :") - - def test00(self): - """Find a tool shape from file name""" - path = PathToolBit.findToolShape("endmill.fcstd") - self.assertIsNot(path, None) - self.assertNotEqual(path, "endmill.fcstd") - - def test01(self): - """Not find a relative path shape if not stored in default location""" - path = PathToolBit.findToolShape(TestToolShapeName) - self.assertIsNone(path) - - def test02(self): - """Find a relative path shape if it's local to a bit path""" - path = PathToolBit.findToolShape(TestToolShapeName, testToolBit()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolShape()) - - def test03(self): - """Not find a tool shape from an invalid absolute path.""" - path = PathToolBit.findToolShape(testToolShape(TestInvalidDir)) - self.assertIsNone(path) - - def test04(self): - """Find a tool shape from a valid absolute path.""" - path = PathToolBit.findToolShape(testToolShape()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolShape()) - - def test10(self): +class TestPathToolBit(PathTestWithAssets): + def testGetToolBit(self): """Find a tool bit from file name""" - path = PathToolBit.findToolBit("5mm_Endmill.fctb") - self.assertIsNot(path, None) - self.assertNotEqual(path, "5mm_Endmill.fctb") + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.assertIsInstance(toolbit, ToolBitEndmill) + self.assertEqual(toolbit.id, "5mm_Endmill") - def test11(self): - """Not find a relative path bit if not stored in default location""" - path = PathToolBit.findToolBit(TestToolBitName) - self.assertIsNone(path) - - def test12(self): - """Find a relative path bit if it's local to a library path""" - path = PathToolBit.findToolBit(TestToolBitName, testToolLibrary()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolBit()) - - def test13(self): - """Not find a tool bit from an invalid absolute path.""" - path = PathToolBit.findToolBit(testToolBit(TestInvalidDir)) - self.assertIsNone(path) - - def test14(self): - """Find a tool bit from a valid absolute path.""" - path = PathToolBit.findToolBit(testToolBit()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolBit()) - - def test20(self): + def testGetLibrary(self): """Find a tool library from file name""" - path = PathToolBit.findToolLibrary("Default.fctl") - self.assertIsNot(path, None) - self.assertNotEqual(path, "Default.fctl") + library = self.assets.get("toolbitlibrary://Default") + self.assertIsInstance(library, Library) + self.assertEqual(library.id, "Default") - def test21(self): - """Not find a relative path library if not stored in default location""" - path = PathToolBit.findToolLibrary(TestToolLibraryName) - self.assertIsNone(path) + def testBullnose(self): + """Test ToolBitBullnose basic parameters""" + shape = self.assets.get("toolbitshape://bullnose") + shape = cast(ToolBitShapeBullnose, shape) - def test22(self): - """[skipped] Find a relative path library if it's local to """ - # this is not a valid test for libraries because t - self.assertTrue(True) + bullnose_bit = ToolBitBullnose(shape, id="mybullnose") + self.assertEqual(bullnose_bit.get_id(), "mybullnose") - def test23(self): - """Not find a tool library from an invalid absolute path.""" - path = PathToolBit.findToolLibrary(testToolLibrary(TestInvalidDir)) - self.assertIsNone(path) + bullnose_bit = ToolBitBullnose(shape) + uuid.UUID(bullnose_bit.get_id()) # will raise if not valid UUID - def test24(self): - """Find a tool library from a valid absolute path.""" - path = PathToolBit.findToolBit(testToolBit()) - self.assertIsNot(path, None) - self.assertEqual(path, testToolBit()) + # Parameters should be loaded from the shape file and set on the tool bit's object + self.assertEqual(bullnose_bit.obj.Diameter, FreeCAD.Units.Quantity("5.0 mm")) + self.assertEqual(bullnose_bit.obj.FlatRadius, FreeCAD.Units.Quantity("1.5 mm")) + + def testToolBitPickle(self): + """Test if ToolBit is picklable""" + import pickle + + shape = self.assets.get("toolbitshape://bullnose") + shape = cast(ToolBitShapeBullnose, shape) + bullnose_bit = ToolBitBullnose(shape, id="mybullnose") + try: + pickled_bit = pickle.dumps(bullnose_bit) + unpickled_bit = pickle.loads(pickled_bit) + self.assertIsInstance(unpickled_bit, ToolBitBullnose) + self.assertEqual(unpickled_bit.get_id(), "mybullnose") + # Add more assertions here to check if other attributes are preserved + except Exception as e: + self.fail(f"ToolBit is not picklable: {e}") diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py new file mode 100644 index 0000000000..a0ee20711b --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitBrowserWidget.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE text file. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the FreeCAD * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Unit tests for the ToolBitBrowserWidget.""" + +import unittest +from unittest.mock import MagicMock +from typing import cast +from Path.Tool.toolbit.ui.browser import ToolBitBrowserWidget, ToolBitUriRole +from Path.Tool.toolbit.ui.tablecell import TwoLineTableCell +from Path.Tool.toolbit.models.base import ToolBit +from .PathTestUtils import PathTestWithAssets + + +class TestToolBitBrowserWidget(PathTestWithAssets): + """Tests for ToolBitBrowserWidget using real assets and widgets.""" + + def setUp(self): + super().setUp() # Call the base class setUp to initialize assets + # The browser widget uses the global cam_assets, which is set up + # by PathTestWithAssets. + self.widget = ToolBitBrowserWidget(self.assets) + + def test_initial_fetch(self): + # Verify that the list widget is populated after initialization + # The default test assets include some toolbits. + self.assertGreater(self.widget._tool_list_widget.count(), 0) + + # Verify apply_filter was called on the list widget with empty string + # We can check the search_highlight property on the cell widgets + # as apply_filter sets this. + for i in range(self.widget._tool_list_widget.count()): + item = self.widget._tool_list_widget.item(i) + cell = self.widget._tool_list_widget.itemWidget(item) + self.assertIsInstance(cell, TwoLineTableCell) + self.assertEqual(cell.search_highlight, "") + + def test_search_filtering(self): + # Simulate typing in the search box + search_term = "Endmill" + self.widget._search_edit.setText(search_term) + + # Directly trigger the fetch and filtering logic + self.widget._trigger_fetch() + + # Verify that the filter was applied to the list widget + # We can check if items are hidden/shown based on the filter term + # This requires knowing the content of the test assets. + # Assuming '5mm_Endmill' and '10mm_Endmill' contain "Endmill" + # and 'BallEndmill_3mm' does not. + + # Re-fetch assets to know their labels/names for verification + all_assets = self.assets.fetch(asset_type="toolbit", depth=0) + expected_visible_uris = set() + for asset in all_assets: + tb = cast(ToolBit, asset) + is_expected = ( + search_term.lower() in tb.label.lower() or search_term.lower() in tb.summary.lower() + ) + if is_expected: + expected_visible_uris.add(str(tb.get_uri())) + + actual_visible_uris = set() + for i in range(self.widget._tool_list_widget.count()): + item = self.widget._tool_list_widget.item(i) + cell = self.widget._tool_list_widget.itemWidget(item) + self.assertIsInstance(cell, TwoLineTableCell) + item_uri = item.data(ToolBitUriRole) + + # Verify highlight was called on all cells + self.assertEqual(cell.search_highlight, search_term) + + if not item.isHidden(): + actual_visible_uris.add(item_uri) + + self.assertEqual(actual_visible_uris, expected_visible_uris) + + def test_lazy_loading_on_scroll(self): + # This test requires more than self._batch_size toolbits to be effective. + # The default test assets might not have enough. + # We'll assume there are enough for the test structure. + + initial_count = self.widget._tool_list_widget.count() + if initial_count < self.widget._batch_size: + self.skipTest("Not enough toolbits for lazy loading test.") + + # Simulate scrolling to the bottom by emitting the signal + scrollbar = self.widget._tool_list_widget.verticalScrollBar() + # Set the scrollbar value to its maximum to simulate reaching the end + scrollbar.valueChanged.emit(scrollbar.maximum()) + + # Verify that more items were loaded + new_count = self.widget._tool_list_widget.count() + self.assertGreater(new_count, initial_count) + # Verify that the number of new items is approximately the batch size + self.assertAlmostEqual( + new_count - initial_count, self.widget._batch_size, delta=5 + ) # Allow small delta + + def test_tool_selected_signal(self): + mock_slot = MagicMock() + self.widget.toolSelected.connect(mock_slot) + + # Select the first item in the list widget + if self.widget._tool_list_widget.count() == 0: + self.skipTest("Not enough toolbits for selection test.") + first_item = self.widget._tool_list_widget.item(0) + self.widget._tool_list_widget.setCurrentItem(first_item) + + # Verify signal was emitted with the correct URI + expected_uri = first_item.data(ToolBitUriRole) + mock_slot.assert_called_once_with(expected_uri) + + def test_tool_edit_requested_signal(self): + mock_slot = MagicMock() + self.widget.itemDoubleClicked.connect(mock_slot) + + # Double-click the first item in the list widget + if self.widget._tool_list_widget.count() == 0: + self.skipTest("Not enough toolbits for double-click test.") + + first_item = self.widget._tool_list_widget.item(0) + # Simulate double-click signal emission from the list widget + self.widget._tool_list_widget.itemDoubleClicked.emit(first_item) + + # Verify signal was emitted with the correct URI + expected_uri = first_item.data(ToolBitUriRole) + mock_slot.assert_called_once_with(expected_uri) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitEditorWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitEditorWidget.py new file mode 100644 index 0000000000..f3102bf6c1 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitEditorWidget.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE text file. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the FreeCAD * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Unit tests for the ToolBitEditorWidget.""" + +import unittest +from unittest.mock import MagicMock +from Path.Tool.toolbit.ui.editor import ToolBitPropertiesWidget +from Path.Tool.toolbit.models.base import ToolBit +from Path.Tool.shape.ui.shapewidget import ShapeWidget +from Path.Tool.ui.property import BasePropertyEditorWidget +from .PathTestUtils import PathTestWithAssets + + +class TestToolBitPropertiesWidget(PathTestWithAssets): + """Tests for ToolBitEditorWidget using real assets and widgets.""" + + def setUp(self): + super().setUp() # Call the base class setUp to initialize assets + self.widget = ToolBitPropertiesWidget() + + def test_load_toolbit(self): + # Get a real ToolBit asset + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.assertIsInstance(toolbit, ToolBit) + + self.widget.load_toolbit(toolbit) + + # Verify label and ID are populated + self.assertEqual(self.widget._label_edit.text(), toolbit.obj.Label) + self.assertEqual(self.widget._id_label.text(), toolbit.get_id()) + + # Verify DocumentObjectEditorWidget is populated + self.assertEqual(self.widget._property_editor._obj, toolbit.obj) + # Check if properties were passed to the property editor + self.assertGreater(len(self.widget._property_editor._properties_to_show), 0) + + # Verify ShapeWidget is created and populated + self.assertIsNotNone(self.widget._shape_widget) + self.assertIsInstance(self.widget._shape_widget, ShapeWidget) + # We can't easily check the internal shape of ShapeWidget without mocks, + # but we can verify it was created. + + def test_label_changed_updates_object(self): + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.widget.load_toolbit(toolbit) + + new_label = "Updated Endmill" + self.widget._label_edit.setText(new_label) + # Simulate editing finished signal + self.widget._label_edit.editingFinished.emit() + + # Verify the toolbit object's label is updated + self.assertEqual(toolbit.obj.Label, new_label) + + def test_property_changed_signal_emitted(self): + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.widget.load_toolbit(toolbit) + + mock_slot = MagicMock() + self.widget.toolBitChanged.connect(mock_slot) + + # Simulate a property change in the DocumentObjectEditorWidget + # We need to trigger the signal from the real property editor. + # This requires accessing a child editor and emitting its signal. + # Find a child editor (e.g., the first one) + if self.widget._property_editor._property_editors: + first_prop_name = list(self.widget._property_editor._property_editors.keys())[0] + child_editor = self.widget._property_editor._property_editors[first_prop_name] + self.assertIsInstance(child_editor, BasePropertyEditorWidget) + + # Emit the propertyChanged signal from the child editor + child_editor.propertyChanged.emit() + + # Verify the ToolBitEditorWidget's signal was emitted + mock_slot.assert_called_once() + else: + self.skipTest("DocumentObjectEditorWidget has no property editors.") + + def test_save_toolbit(self): + # The save_toolbit method primarily ensures the label is updated + # and potentially calls updateObject on the property editor. + # Since property changes are signal-driven, this method is more + # for explicit save actions. + + toolbit = self.assets.get("toolbit://5mm_Endmill") + self.widget.load_toolbit(toolbit) + + initial_label = toolbit.obj.Label + new_label = "Another Label" + self.widget._label_edit.setText(new_label) + + # Call save_toolbit + self.widget.save_toolbit() + + # Verify the label was updated + self.assertEqual(toolbit.obj.Label, new_label) + + # We can't easily verify if updateObject was called on the property + # editor without mocks, but we trust the implementation based on + # the DocumentObjectEditorWidget's own tests. + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py new file mode 100644 index 0000000000..4b358e9d13 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitListWidget.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE text file. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Unit tests for the ToolBitListWidget.""" + +import unittest +from Path.Tool.toolbit.ui.toollist import ToolBitListWidget, ToolBitUriRole +from Path.Tool.toolbit.ui.tablecell import TwoLineTableCell +from .PathTestUtils import PathTestWithAssets # Import the base test class + + +class TestToolBitListWidget(PathTestWithAssets): + """Tests for ToolBitListWidget using real assets.""" + + def setUp(self): + super().setUp() # Call the base class setUp to initialize assets + self.widget = ToolBitListWidget() + + def test_add_toolbit(self): + # Get a real ToolBit asset + toolbit = self.assets.get("toolbit://5mm_Endmill") + tool_no = 1 + + self.widget.add_toolbit(toolbit, str(tool_no)) + + self.assertEqual(self.widget.count(), 1) + item = self.widget.item(0) + self.assertIsNotNone(item) + + cell_widget = self.widget.itemWidget(item) + self.assertIsInstance(cell_widget, TwoLineTableCell) # Check against real class + + # Verify cell widget properties are set correctly + self.assertEqual(cell_widget.tool_no, str(tool_no)) + self.assertEqual(cell_widget.upper_text, toolbit.label) + # Assuming the 5mm_Endmill asset has a shape named 'Endmill' + self.assertEqual(cell_widget.lower_text, "5.00 mm 4-flute endmill, 30.00 mm cutting edge") + + # Verify URI is stored in item data + stored_uri = item.data(ToolBitUriRole) + self.assertEqual(stored_uri, str(toolbit.get_uri())) + + def test_clear_list(self): + # Add some real items first + toolbit1 = self.assets.get("toolbit://5mm_Endmill") + toolbit2 = self.assets.get("toolbit://slittingsaw") + self.widget.add_toolbit(toolbit1, 1) + self.widget.add_toolbit(toolbit2, 2) + self.assertEqual(self.widget.count(), 2) + + self.widget.clear_list() + self.assertEqual(self.widget.count(), 0) + + def test_apply_filter(self): + # Add items with distinct text for filtering + toolbit1 = self.assets.get("toolbit://5mm_Endmill") + toolbit2 = self.assets.get("toolbit://slittingsaw") + toolbit3 = self.assets.get("toolbit://probe") + + self.widget.add_toolbit(toolbit1, 1) + self.widget.add_toolbit(toolbit2, 2) + self.widget.add_toolbit(toolbit3, 3) + + items = [self.widget.item(i) for i in range(self.widget.count())] + cells = [self.widget.itemWidget(item) for item in items] + + # Test filter "Endmill" + self.widget.apply_filter("Endmill") + + self.assertFalse(items[0].isHidden()) # 5mm Endmill + self.assertTrue(items[1].isHidden()) # slittingsaw + self.assertTrue(items[2].isHidden()) # probe + + # Verify highlight was called on all cells + for cell in cells: + self.assertEqual(cell.search_highlight, "Endmill") + + # Test filter "Ballnose" + self.widget.apply_filter("Ballnose") + + self.assertTrue(items[0].isHidden()) # 5mm Endmill + self.assertTrue(items[1].isHidden()) # slittingsaw + self.assertTrue(items[2].isHidden()) # probe + + # Verify highlight was called again + for cell in cells: + self.assertEqual(cell.search_highlight, "Ballnose") + + # Test filter "3mm" + self.widget.apply_filter("3mm") + + self.assertTrue(items[0].isHidden()) # 5mm Endmill + self.assertTrue(items[1].isHidden()) # slittingsaw + self.assertTrue(items[2].isHidden()) # probe + + # Verify highlight was called again + for cell in cells: + self.assertEqual(cell.search_highlight, "3mm") + + def test_get_selected_toolbit_uri(self): + toolbit1 = self.assets.get("toolbit://5mm_Endmill") + toolbit2 = self.assets.get("toolbit://slittingsaw") + + self.widget.add_toolbit(toolbit1, 1) + self.widget.add_toolbit(toolbit2, 2) + + # No selection initially + self.assertIsNone(self.widget.get_selected_toolbit_uri()) + + # Select the first item + self.widget.setCurrentItem(self.widget.item(0)) + self.assertEqual(self.widget.get_selected_toolbit_uri(), str(toolbit1.get_uri())) + + # Select the second item + self.widget.setCurrentItem(self.widget.item(1)) + self.assertEqual(self.widget.get_selected_toolbit_uri(), str(toolbit2.get_uri())) + + # Clear selection (simulate by setting current item to None) + self.widget.setCurrentItem(None) + self.assertIsNone(self.widget.get_selected_toolbit_uri()) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitPropertyEditorWidget.py b/src/Mod/CAM/CAMTests/TestPathToolBitPropertyEditorWidget.py new file mode 100644 index 0000000000..1361115a6d --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitPropertyEditorWidget.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE text file. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Unit tests for the Property Editor Widgets.""" + +import unittest +import FreeCAD +from Path.Tool.ui.property import ( + BasePropertyEditorWidget, + QuantityPropertyEditorWidget, + BoolPropertyEditorWidget, + IntPropertyEditorWidget, + EnumPropertyEditorWidget, + LabelPropertyEditorWidget, +) +from Path.Tool.toolbit.docobject import DetachedDocumentObject + + +class TestPropertyEditorFactory(unittest.TestCase): + """Tests the BasePropertyEditorWidget.for_property factory method.""" + + def setUp(self): + # Use the real DetachedDocumentObject + self.obj = DetachedDocumentObject() + # Add properties using the DetachedDocumentObject API with correct signature + self.obj.addProperty("App::PropertyLength", "Length", "Base", "Length property") + self.obj.Length = FreeCAD.Units.Quantity(10.0) # Set value separately + + self.obj.addProperty("App::PropertyBool", "IsEnabled", "Base", "Boolean property") + self.obj.IsEnabled = True # Set value separately + + self.obj.addProperty("App::PropertyInt", "Count", "Base", "Integer property") + self.obj.Count = 5 # Set value separately + + self.obj.addProperty("App::PropertyEnumeration", "Mode", "Base", "Enumeration property") + # Set enums and initial value separately + self.obj.Mode = ["Auto", "Manual"] + + self.obj.addProperty("App::PropertyString", "Comment", "Base", "String property") + self.obj.Comment = "Test" # Set value separately + + def test_quantity_creation(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "Length") + self.assertIsInstance(widget, QuantityPropertyEditorWidget) + + def test_bool_creation(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "IsEnabled") + self.assertIsInstance(widget, BoolPropertyEditorWidget) + + def test_int_creation(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "Count") + self.assertIsInstance(widget, IntPropertyEditorWidget) + + def test_enum_creation(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "Mode") + self.assertIsInstance(widget, EnumPropertyEditorWidget) + + def test_label_creation_for_string(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "Comment") + self.assertIsInstance(widget, LabelPropertyEditorWidget) + + def test_label_creation_for_invalid_prop(self): + widget = BasePropertyEditorWidget.for_property(self.obj, "NonExistent") + self.assertIsInstance(widget, LabelPropertyEditorWidget) + + +class TestQuantityPropertyEditorWidget(unittest.TestCase): + """Tests for QuantityPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyLength", "Length", "Base", "Length property") + self.obj.Length = FreeCAD.Units.Quantity("10.0 mm") + self.widget = QuantityPropertyEditorWidget(self.obj, "Length") + # Access the real editor widget + self.editor = self.widget._editor_widget + self.widget.updateWidget() + + def test_update_property(self): + # Check if the real widget's value is updated + self.assertEqual(self.editor.property("rawValue"), 10.0) + # Check if the real widget's value and unit are updated + self.assertEqual(self.editor.property("value").UserString, "10.00 mm") + + # Simulate changing the raw value and check if the object's value updates + self.editor.lineEdit().setText("12.0") + self.widget.updateProperty() + self.assertEqual(self.obj.Length.Value, 12.0) + self.assertEqual(self.obj.Length.UserString, "12.00 mm") + + # Try assignment with unit. + self.editor.lineEdit().setText("15.5 in") + self.widget.updateProperty() + updated_value = self.obj.getPropertyByName("Length") + self.assertIsInstance(updated_value, FreeCAD.Units.Quantity) + self.assertEqual(updated_value, FreeCAD.Units.Quantity("15.5 in")) + + +class TestBoolPropertyEditorWidget(unittest.TestCase): + """Tests for BoolPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyBool", "IsEnabled", "Base", "Boolean property") + self.obj.IsEnabled = True + self.widget = BoolPropertyEditorWidget(self.obj, "IsEnabled") + # Access the real editor widget + self.editor = self.widget._editor_widget + + def test_update_widget(self): + self.widget.updateWidget() + # Check if the real widget's value is updated + self.assertEqual(self.editor.currentIndex(), 1) # True is index 1 + + self.obj.setPropertyByName("IsEnabled", False) + self.widget.updateWidget() + self.assertEqual(self.editor.currentIndex(), 0) # False is index 0 + + def test_update_property(self): + # Simulate user changing value in the combobox + self.editor.setCurrentIndex(0) # Select False + self.widget._on_index_changed(0) + self.assertEqual(self.obj.getPropertyByName("IsEnabled"), False) + + self.editor.setCurrentIndex(1) # Select True + self.widget._on_index_changed(1) + self.assertEqual(self.obj.getPropertyByName("IsEnabled"), True) + + +class TestIntPropertyEditorWidget(unittest.TestCase): + """Tests for IntPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyInt", "Count", "Base", "Integer property") + self.obj.Count = 5 + self.widget = IntPropertyEditorWidget(self.obj, "Count") + # Access the real editor widget + self.editor = self.widget._editor_widget + + def test_update_widget(self): + self.widget.updateWidget() + # Check if the real widget's value is updated + self.assertEqual(self.editor.value(), 5) + + self.obj.setPropertyByName("Count", 100) + self.widget.updateWidget() + self.assertEqual(self.editor.value(), 100) + + def test_update_property(self): + # Simulate user changing value in the spinbox + self.editor.setValue(42) + self.widget.updateProperty() + self.assertEqual(self.obj.getPropertyByName("Count"), 42) + + +class TestEnumPropertyEditorWidget(unittest.TestCase): + """Tests for EnumPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyEnumeration", "Mode", "Base", "Enumeration property") + self.obj.Mode = ["Auto", "Manual", "Semi"] # Set enums and initial value + self.widget = EnumPropertyEditorWidget(self.obj, "Mode") + # Access the real editor widget + self.editor = self.widget._editor_widget + + def test_populate_enum(self): + # Check if the real widget is populated + self.assertEqual(self.editor.count(), 3) + self.assertEqual(self.editor.itemText(0), "Auto") + self.assertEqual(self.editor.itemText(1), "Manual") + self.assertEqual(self.editor.itemText(2), "Semi") + + def test_update_widget(self): + self.widget.updateWidget() + # Check if the real widget's value is updated + self.assertEqual(self.editor.currentText(), "Auto") + + self.obj.setPropertyByName("Mode", "Manual") + self.widget.updateWidget() + self.assertEqual(self.editor.currentText(), "Manual") + + def test_update_property(self): + # Simulate user changing value in the combobox + self.editor.setCurrentIndex(1) # Select Manual + self.widget._on_index_changed(1) + self.assertEqual(self.obj.getPropertyByName("Mode"), "Manual") + + self.editor.setCurrentIndex(2) # Select Semi + self.widget._on_index_changed(2) + self.assertEqual(self.obj.getPropertyByName("Mode"), "Semi") + + +class TestLabelPropertyEditorWidget(unittest.TestCase): + """Tests for LabelPropertyEditorWidget.""" + + def setUp(self): + self.obj = DetachedDocumentObject() + self.obj.addProperty("App::PropertyString", "Comment", "Base", "String property") + self.obj.Comment = "Test Comment" + self.widget = LabelPropertyEditorWidget(self.obj, "Comment") + # Access the real editor widget + self.editor = self.widget._editor_widget + + def test_update_widget(self): + self.widget.updateWidget() + # Check if the real widget's value is updated + self.assertEqual(self.editor.text(), "Test Comment") + + self.obj.setPropertyByName("Comment", "New Comment") + self.widget.updateWidget() + self.assertEqual(self.editor.text(), "New Comment") + + def test_update_property_is_noop(self): + # updateProperty should do nothing for a read-only label + self.widget.updateProperty() + # No assertions needed, just ensure it doesn't raise errors + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py new file mode 100644 index 0000000000..773641c33e --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolBitSerializer.py @@ -0,0 +1,134 @@ +import json +from typing import Type, cast +import FreeCAD +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.toolbit import ToolBit, ToolBitEndmill +from Path.Tool.toolbit.serializers import ( + FCTBSerializer, + CamoticsToolBitSerializer, +) +from Path.Tool.assets.asset import Asset +from Path.Tool.assets.serializer import AssetSerializer +from Path.Tool.assets.uri import AssetUri +from Path.Tool.shape import ToolBitShapeEndmill +from typing import Mapping + + +class _BaseToolBitSerializerTestCase(PathTestWithAssets): + """Base test case for ToolBit Serializers.""" + + __test__ = False + + serializer_class: Type[AssetSerializer] + test_tool_bit: ToolBit + + def setUp(self): + """Create a tool bit for each test.""" + super().setUp() + if self.serializer_class is None or not issubclass(self.serializer_class, AssetSerializer): + raise NotImplementedError("Subclasses must define a valid serializer_class") + + self.test_tool_bit = cast(ToolBitEndmill, self.assets.get("toolbit://5mm_Endmill")) + self.test_tool_bit.label = "Test Tool" + self.test_tool_bit.set_diameter(FreeCAD.Units.Quantity("4.12 mm")) + self.test_tool_bit.set_length(FreeCAD.Units.Quantity("15.0 mm")) + + def test_serialize(self): + """Test serialization of a toolbit.""" + if self.test_tool_bit is None: + raise NotImplementedError("Subclasses must define a test_tool_bit") + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + self.assertIsInstance(serialized_data, bytes) + + def test_extract_dependencies(self): + """Test dependency extraction.""" + # This test assumes that the serializers don't have dependencies + # and can be overridden in subclasses if needed. + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + dependencies = self.serializer_class.extract_dependencies(serialized_data) + self.assertIsInstance(dependencies, list) + self.assertEqual(len(dependencies), 0) + + +class TestCamoticsToolBitSerializer(_BaseToolBitSerializerTestCase): + serializer_class = CamoticsToolBitSerializer + + def test_serialize(self): + super().test_serialize() + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + # Camotics specific assertions + expected_substrings = [ + b'"units": "metric"', + b'"shape": "Cylindrical"', + b'"length": 15', + b'"diameter": 4.12', + b'"description": "Test Tool"', + ] + for substring in expected_substrings: + self.assertIn(substring, serialized_data) + + def test_deserialize(self): + # Create a known serialized data string based on the Camotics format + camotics_data = ( + b'{"units": "metric", "shape": "Cylindrical", "length": 15, ' + b'"diameter": 4.12, "description": "Test Tool"}' + ) + deserialized_bit = cast( + ToolBitEndmill, + self.serializer_class.deserialize(camotics_data, id="test_id", dependencies=None), + ) + + self.assertIsInstance(deserialized_bit, ToolBit) + self.assertEqual(deserialized_bit.label, "Test Tool") + self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm") + self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm") + self.assertEqual(deserialized_bit.get_shape_name(), "Endmill") + + +class TestFCTBSerializer(_BaseToolBitSerializerTestCase): + serializer_class = FCTBSerializer + + def test_serialize(self): + super().test_serialize() + serialized_data = self.serializer_class.serialize(self.test_tool_bit) + # FCTB specific assertions (JSON format) + data = json.loads(serialized_data.decode("utf-8")) + self.assertEqual(data.get("name"), "Test Tool") + self.assertEqual(data.get("shape"), "endmill.fcstd") + self.assertEqual(data.get("parameter", {}).get("Diameter"), "4.12 mm") + self.assertEqual(data.get("parameter", {}).get("Length"), "15.0 mm", data) + + def test_extract_dependencies(self): + """Test dependency extraction for FCTB.""" + fctb_data = ( + b'{"name": "Test Tool", "pocket": null, "shape": "endmill", ' + b'"parameter": {"Diameter": "4.12 mm", "Length": "15.0 mm"}, "attribute": {}}' + ) + dependencies = self.serializer_class.extract_dependencies(fctb_data) + self.assertIsInstance(dependencies, list) + self.assertEqual(len(dependencies), 1) + self.assertEqual(dependencies[0], AssetUri.build("toolbitshape", "endmill")) + + def test_deserialize(self): + # Create a known serialized data string based on the FCTB format + fctb_data = ( + b'{"name": "Test Tool", "pocket": null, "shape": "endmill", ' + b'"parameter": {"Diameter": "4.12 mm", "Length": "15.0 mm"}, "attribute": {}}' + ) + # Create a ToolBitShapeEndmill instance for 'endmill' + shape = ToolBitShapeEndmill("endmill") + + # Create the dependencies dictionary with the shape instance + dependencies: Mapping[AssetUri, Asset] = {AssetUri.build("toolbitshape", "endmill"): shape} + + # Provide dummy id and dependencies for deserialization test + deserialized_bit = cast( + ToolBitEndmill, + self.serializer_class.deserialize(fctb_data, id="test_id", dependencies=dependencies), + ) + + self.assertIsInstance(deserialized_bit, ToolBit) + self.assertEqual(deserialized_bit.label, "Test Tool") + self.assertEqual(deserialized_bit.get_shape_name(), "Endmill") + self.assertEqual(str(deserialized_bit.get_diameter()), "4.12 mm") + self.assertEqual(str(deserialized_bit.get_length()), "15.0 mm") diff --git a/src/Mod/CAM/CAMTests/TestPathToolController.py b/src/Mod/CAM/CAMTests/TestPathToolController.py index 2214b60734..6d8c966a0f 100644 --- a/src/Mod/CAM/CAMTests/TestPathToolController.py +++ b/src/Mod/CAM/CAMTests/TestPathToolController.py @@ -21,10 +21,8 @@ # *************************************************************************** import FreeCAD -import Path -import Path.Tool.Bit as PathToolBit +from Path.Tool.toolbit import ToolBit import Path.Tool.Controller as PathToolController - from CAMTests.PathTestUtils import PathTestBase @@ -39,12 +37,14 @@ class TestPathToolController(PathTestBase): def createTool(self, name="t1", diameter=1.75): attrs = { - "shape": None, - "name": name, + "name": name or "t1", + "shape": "endmill.fcstd", "parameter": {"Diameter": diameter}, - "attribute": [], + "attribute": {}, } - return PathToolBit.Factory.CreateFromAttrs(attrs, name) + print(f"Debug: attrs['attribute'] is {attrs['attribute']}") + toolbit = ToolBit.from_dict(attrs) + return toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument) def test00(self): """Verify ToolController templateAttrs""" @@ -72,7 +72,7 @@ class TestPathToolController(PathTestBase): self.assertEqual(attrs["hrapid"], "28.0 mm/s") self.assertEqual(attrs["dir"], "Reverse") self.assertEqual(attrs["speed"], 12000) - self.assertEqual(attrs["tool"], t.Proxy.templateAttrs(t)) + self.assertEqual(attrs["tool"], t.Proxy.to_dict()) return tc diff --git a/src/Mod/CAM/CAMTests/TestPathToolDocumentObjectEditorWidget.py b/src/Mod/CAM/CAMTests/TestPathToolDocumentObjectEditorWidget.py new file mode 100644 index 0000000000..22c14625fb --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolDocumentObjectEditorWidget.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE text file. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Unit tests for the DocumentObjectEditorWidget.""" + +import unittest +from unittest.mock import MagicMock +import FreeCAD +from PySide import QtGui +from Path.Tool.ui.property import ( + BasePropertyEditorWidget, + QuantityPropertyEditorWidget, + BoolPropertyEditorWidget, + IntPropertyEditorWidget, + EnumPropertyEditorWidget, + LabelPropertyEditorWidget, +) +from Path.Tool.ui.docobject import DocumentObjectEditorWidget, _get_label_text +from Path.Tool.toolbit.docobject import DetachedDocumentObject + + +class TestDocumentObjectEditorWidget(unittest.TestCase): + """Tests for DocumentObjectEditorWidget.""" + + def test_populate_form(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2") + obj.Prop2 = 123 + obj.addProperty("App::PropertyLength", "Prop3", "Group1", "Doc3") + obj.Prop3 = FreeCAD.Units.Quantity(5.0, "mm") + obj.addProperty("App::PropertyBool", "Prop4", "Group1", "Doc4") + obj.Prop4 = False + obj.addProperty("App::PropertyEnumeration", "Prop5", "Group1", "Doc5") + obj.Prop5 = ["OptionA", "OptionB"] + + properties_to_show = ["Prop1", "Prop2", "Prop3", "Prop4", "Prop5", "NonExistent"] + property_suffixes = {"Prop1": "Suffix1", "Prop3": "Len"} + + widget = DocumentObjectEditorWidget( + obj=obj, properties_to_show=properties_to_show, property_suffixes=property_suffixes + ) + + # Verify the layout contains the correct number of rows (excluding non-existent) + expected_row_count = len([p for p in properties_to_show if hasattr(obj, p)]) + self.assertEqual(widget._layout.rowCount(), expected_row_count) + + # Verify labels and widgets are added correctly and are of the expected types + prop_names_in_layout = [] + for i in range(widget._layout.rowCount()): + label_item = widget._layout.itemAt(i, QtGui.QFormLayout.LabelRole) + field_item = widget._layout.itemAt(i, QtGui.QFormLayout.FieldRole) + + self.assertIsNotNone(label_item) + self.assertIsNotNone(field_item) + + label_widget = label_item.widget() + field_widget = field_item.widget() + + self.assertIsInstance(label_widget, QtGui.QLabel) + self.assertIsInstance( + field_widget, BasePropertyEditorWidget + ) # Check against base class + + # Determine the property name from the label text (reverse of _get_label_text) + # This is a bit fragile, but necessary without storing prop_name in the label widget + label_text = label_widget.text() + prop_name = None + for original_prop_name in properties_to_show: + expected_label = _get_label_text(original_prop_name) + suffix = property_suffixes.get(original_prop_name) + if suffix: + expected_label = f"{expected_label} ({suffix}):" + else: + expected_label = f"{expected_label}:" + if label_text == expected_label: + prop_name = original_prop_name + break + + self.assertIsNotNone( + prop_name, f"Could not determine property name for label: {label_text}" + ) + prop_names_in_layout.append(prop_name) + + # Verify widget type based on property type + if prop_name == "Prop1": + self.assertIsInstance(field_widget, LabelPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop1 (Suffix1):") + elif prop_name == "Prop2": + self.assertIsInstance(field_widget, IntPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop2:") + elif prop_name == "Prop3": + self.assertIsInstance(field_widget, QuantityPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop3 (Len):") + elif prop_name == "Prop4": + self.assertIsInstance(field_widget, BoolPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop4:") + elif prop_name == "Prop5": + self.assertIsInstance(field_widget, EnumPropertyEditorWidget) + self.assertEqual(label_widget.text(), "Prop5:") + + # Verify property editors are stored + self.assertEqual(len(widget._property_editors), expected_row_count) + for prop_name in prop_names_in_layout: + self.assertIn(prop_name, widget._property_editors) + self.assertIsInstance(widget._property_editors[prop_name], BasePropertyEditorWidget) + + def test_set_object(self): + obj1 = DetachedDocumentObject() + obj1.addProperty("App::PropertyString", "PropA", "GroupA", "DocA") + obj1.PropA = "ValueA" + + obj2 = DetachedDocumentObject() + obj2.addProperty("App::PropertyString", "PropA", "GroupA", "DocA") + obj2.PropA = "ValueB" + + properties_to_show = ["PropA"] + widget = DocumentObjectEditorWidget(obj=obj1, properties_to_show=properties_to_show) + + # Get the initial editor widget instance + initial_editor = widget._property_editors["PropA"] + self.assertIsInstance(initial_editor, BasePropertyEditorWidget) + + # Set a new object + widget.setObject(obj2) + + # Verify that the editor widget instance is the same + self.assertEqual(widget._property_editors["PropA"], initial_editor) + # Verify that attachTo was called on the existing editor widget + # This requires the real attachTo method to be implemented correctly + # and the editor widget to update its internal object reference. + # We can't easily assert the internal state change without mocks, + # but we can trust the implementation of attachTo in PropertyEditorWidget. + # We can verify updateWidget was called. + # Note: This test relies on the side effect of attachTo calling updateWidget + # in the real implementation. + # We can't directly assert method calls without mocks, so we'll rely on + # the fact that setting the object and then updating the UI should + # reflect the new object's values if attachTo worked. + widget.updateUI() + # Check if the child widget's display reflects obj2's value + # This requires accessing the child widget's internal editor widget + # which might be fragile. A better approach is to trust the unit tests + # for the individual PropertyEditorWidgets and focus on the + # DocumentObjectEditorWidget's logic of calling attachTo and updateUI. + + def test_set_properties_to_show(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2") + obj.Prop2 = 123 + + widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=["Prop1"]) + + # Store the initial editor instance for Prop1 + initial_prop1_editor = widget._property_editors["Prop1"] + + # Set new properties to show + new_properties_to_show = ["Prop1", "Prop2"] + new_suffixes = {"Prop2": "Suffix2"} + widget.setPropertiesToShow(new_properties_to_show, new_suffixes) + + # Verify that the form was repopulated with the new properties + self.assertEqual(widget._layout.rowCount(), len(new_properties_to_show)) + + # Verify property editors are updated and new ones created + self.assertEqual(len(widget._property_editors), len(new_properties_to_show)) + self.assertIn("Prop1", widget._property_editors) + self.assertIn("Prop2", widget._property_editors) + + # Verify that the editor for Prop1 is a *new* instance after repopulation + self.assertIsNot(widget._property_editors["Prop1"], initial_prop1_editor) + self.assertIsInstance(widget._property_editors["Prop2"], IntPropertyEditorWidget) + + # Verify labels including suffixes + prop_names_in_layout = [] + for i in range(widget._layout.rowCount()): + label_item = widget._layout.itemAt(i, QtGui.QFormLayout.LabelRole) + label_widget = label_item.widget() + label_text = label_widget.text() + + prop_name = None + for original_prop_name in new_properties_to_show: + expected_label = _get_label_text(original_prop_name) + suffix = new_suffixes.get(original_prop_name) + if suffix: + expected_label = f"{expected_label} ({suffix}):" + else: + expected_label = f"{expected_label}:" + if label_text == expected_label: + prop_name = original_prop_name + break + prop_names_in_layout.append(prop_name) + + self.assertIn("Prop1", prop_names_in_layout) + self.assertIn("Prop2", prop_names_in_layout) + self.assertEqual( + widget._layout.itemAt(0, QtGui.QFormLayout.LabelRole).widget().text(), "Prop1:" + ) + self.assertEqual( + widget._layout.itemAt(1, QtGui.QFormLayout.LabelRole).widget().text(), + "Prop2 (Suffix2):", + ) + + def test_property_changed_signal(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + + widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=["Prop1"]) + + # Connect to the widget's propertyChanged signal + mock_slot = MagicMock() + widget.propertyChanged.connect(mock_slot) + + # Get the real child editor widget + child_editor = widget._property_editors["Prop1"] + self.assertIsInstance(child_editor, BasePropertyEditorWidget) + + # Emit the signal from the real child editor + child_editor.propertyChanged.emit() + + # Verify that the widget's signal was emitted + mock_slot.assert_called_once() + + def test_update_ui(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2") + obj.Prop2 = 123 + + properties_to_show = ["Prop1", "Prop2"] + widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=properties_to_show) + + # Get the real child editor widgets + editor1 = widget._property_editors["Prop1"] + editor2 = widget._property_editors["Prop2"] + + # Mock their updateWidget methods to check if they are called + editor1.updateWidget = MagicMock() + editor2.updateWidget = MagicMock() + + # Call updateUI + widget.updateUI() + + # Verify that updateWidget was called on all child editors + editor1.updateWidget.assert_called_once() + editor2.updateWidget.assert_called_once() + + def test_update_object(self): + obj = DetachedDocumentObject() + obj.addProperty("App::PropertyString", "Prop1", "Group1", "Doc1") + obj.Prop1 = "Value1" + obj.addProperty("App::PropertyInt", "Prop2", "Group1", "Doc2") + obj.Prop2 = 123 + + properties_to_show = ["Prop1", "Prop2"] + widget = DocumentObjectEditorWidget(obj=obj, properties_to_show=properties_to_show) + + # Get the real child editor widgets + editor1 = widget._property_editors["Prop1"] + editor2 = widget._property_editors["Prop2"] + + # Mock their updateProperty methods to check if they are called + editor1.updateProperty = MagicMock() + editor2.updateProperty = MagicMock() + + # Call updateObject + widget.updateObject() + + # Verify that updateProperty was called on all child editors + editor1.updateProperty.assert_called_once() + editor2.updateProperty.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolLibrary.py b/src/Mod/CAM/CAMTests/TestPathToolLibrary.py new file mode 100644 index 0000000000..01291c5f0d --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolLibrary.py @@ -0,0 +1,381 @@ +import unittest +from Path.Tool.library import Library +from Path.Tool.toolbit import ToolBitEndmill, ToolBitDrill, ToolBitVBit +from Path.Tool.shape import ( + ToolBitShapeEndmill, + ToolBitShapeDrill, + ToolBitShapeVBit, +) + + +class TestPathToolLibrary(unittest.TestCase): + def test_init(self): + library = Library("Test Library") + self.assertEqual(library.label, "Test Library") + self.assertIsNotNone(library.id) + self.assertEqual(len(library._bits), 0) + self.assertEqual(len(library._bit_nos), 0) + + def test_str(self): + library = Library("My Library", "123-abc") + self.assertEqual(str(library), '123-abc "My Library"') + + def test_eq(self): + library1 = Library("Lib1", "same-id") + library2 = Library("Lib2", "same-id") + library3 = Library("Lib3", "different-id") + self.assertEqual(library1, library2) + self.assertNotEqual(library1, library3) + + def test_iter(self): + library = Library("Test Library") + # Create ToolBitShape instances with required parameters + shape1 = ToolBitShapeEndmill( + id="dummy_endmill", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + library.add_bit(bit1) + library.add_bit(bit2) + _bits_list = list(library) + self.assertEqual(len(_bits_list), 2) + self.assertIn(bit1, _bits_list) + self.assertIn(bit2, _bits_list) + + def test_get_next_bit_no(self): + library = Library("Test Library") + self.assertEqual(library.get_next_bit_no(), 1) + # Using ToolBit instances in _bit_nos with ToolBitShape + shape_a = ToolBitShapeEndmill( + id="dummy_a", + CuttingEdgeHeight=1.0, + Diameter=1.0, + Flutes=1, + Length=10.0, + ShankDiameter=1.0, + ) + shape_b = ToolBitShapeDrill( + id="dummy_b", + Diameter=2.0, + Length=20.0, + ShankDiameter=2.0, + Flutes=2, + TipAngle=118.0, + ) + shape_c = ToolBitShapeVBit( + id="dummy_c", + Diameter=3.0, + Angle=90.0, + Length=30.0, + ShankDiameter=3.0, + CuttingEdgeAngle=30.0, + CuttingEdgeHeight=10.0, + Flutes=2, + TipDiameter=1.0, + ) + library._bit_nos = { + 1: ToolBitEndmill(shape_a), + 5: ToolBitDrill(shape_b), + 2: ToolBitVBit(shape_c), + } + self.assertEqual(library.get_next_bit_no(), 6) + library._bit_nos = {} + self.assertEqual(library.get_next_bit_no(), 1) + + def test_get_bit_no_from_bit(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_1", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_1", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + library.add_bit(bit1, 1) + library.add_bit(bit2, 2) + self.assertEqual(library.get_bit_no_from_bit(bit1), 1) + self.assertEqual(library.get_bit_no_from_bit(bit2), 2) + shape_cutter = ToolBitShapeVBit( + id="dummy_cutter_1", + Diameter=3.0, + Angle=90.0, + Length=30.0, + ShankDiameter=3.0, + CuttingEdgeAngle=30.0, + CuttingEdgeHeight=10.0, + Flutes=2, + TipDiameter=1.0, + ) + self.assertIsNone(library.get_bit_no_from_bit(ToolBitVBit(shape_cutter))) + + def test_assign_new_bit_no(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_2", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_2", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + + # Assign bit1 without specifying number (should get 1) + library.add_bit(bit1) + self.assertEqual(len(library._bits), 1) + self.assertEqual(len(library._bit_nos), 1) + self.assertIn(1, library._bit_nos) + self.assertEqual(library._bit_nos[1], bit1) + + # Assign bit2 to number 1 (should reassign bit1) + library.add_bit(bit2, 1) + self.assertEqual(len(library._bits), 2) + self.assertEqual(len(library._bit_nos), 2) + self.assertIn(1, library._bit_nos) + self.assertEqual(library._bit_nos[1], bit2) + # Check if bit1 was reassigned to a new bit number (should be 2) + self.assertIn(2, library._bit_nos) + self.assertEqual(library._bit_nos[2], bit1) + + # Assign bit2 to number 10 + library.assign_new_bit_no(bit2, 10) + self.assertEqual(len(library._bits), 2) + self.assertEqual(len(library._bit_nos), 2) + self.assertIn(10, library._bit_nos) + self.assertEqual(library._bit_nos[10], bit2) + self.assertNotIn(1, library._bit_nos) # bit2 should no longer be at 1 + self.assertIn(2, library._bit_nos) + self.assertEqual(library._bit_nos[2], bit1) + + # Assign bit1 to number 5 + library.assign_new_bit_no(bit1, 5) + self.assertEqual(len(library._bits), 2) + self.assertEqual(len(library._bit_nos), 2) + self.assertIn(5, library._bit_nos) + self.assertEqual(library._bit_nos[5], bit1) + self.assertNotIn(2, library._bit_nos) # bit1 should no longer be at 2 + self.assertIn(10, library._bit_nos) + self.assertEqual(library._bit_nos[10], bit2) + + def test_add_bit(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_3", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_3", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + + library.add_bit(bit1) + self.assertEqual(len(library._bits), 1) + self.assertIn(bit1, library._bits) + self.assertEqual(len(library._bit_nos), 1) + self.assertIn(1, library._bit_nos) + self.assertEqual(library._bit_nos[1], bit1) + + library.add_bit(bit2, 5) + self.assertEqual(len(library._bits), 2) + self.assertIn(bit1, library._bits) + self.assertIn(bit2, library._bits) + self.assertEqual(len(library._bit_nos), 2) + self.assertIn(1, library._bit_nos) + self.assertEqual(library._bit_nos[1], bit1) + self.assertIn(5, library._bit_nos) + self.assertEqual(library._bit_nos[5], bit2) + + # Add bit1 again (should not increase bit count in _bits list) + library.add_bit(bit1, 10) + self.assertEqual(len(library._bits), 2) + self.assertIn(bit1, library._bits) + self.assertIn(bit2, library._bits) + self.assertEqual(len(library._bit_nos), 2) # _bit_nos count remains 2 + self.assertIn(10, library._bit_nos) + self.assertEqual(library._bit_nos[10], bit1) + self.assertIn(5, library._bit_nos) + self.assertEqual(library._bit_nos[5], bit2) + self.assertNotIn(1, library._bit_nos) # bit1 should no longer be at 1 + + def test_get_bits(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_4", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_4", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + self.assertEqual(library.get_bits(), []) + library.add_bit(bit1) + library.add_bit(bit2) + _bits_list = library.get_bits() + self.assertEqual(len(_bits_list), 2) + self.assertIn(bit1, _bits_list) + self.assertIn(bit2, _bits_list) + + def test_has_bit(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_5", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_5", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + library.add_bit(bit1) + self.assertTrue(library.has_bit(bit1)) + self.assertFalse(library.has_bit(bit2)) + # Create a new ToolBit with the same properties but different instance + shape1_copy = ToolBitShapeEndmill( + id="dummy_endmill_5_copy", # Use a different ID for the copy + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + bit1_copy = ToolBitEndmill(shape1_copy) + self.assertFalse(library.has_bit(bit1_copy)) + + def test_remove_bit(self): + library = Library("Test Library") + shape1 = ToolBitShapeEndmill( + id="dummy_endmill_6", + CuttingEdgeHeight=10.0, + Diameter=10.0, + Flutes=2, + Length=50.0, + ShankDiameter=10.0, + ) + shape2 = ToolBitShapeDrill( + id="dummy_drill_6", + Diameter=5.0, + Length=40.0, + ShankDiameter=5.0, + Flutes=2, + TipAngle=118.0, + ) + shape3 = ToolBitShapeVBit( + id="dummy_cutter_6", + Diameter=3.0, + Angle=90.0, + Length=30.0, + ShankDiameter=3.0, + CuttingEdgeAngle=30.0, + CuttingEdgeHeight=10.0, + Flutes=2, + TipDiameter=1.0, + ) + bit1 = ToolBitEndmill(shape1) + bit2 = ToolBitDrill(shape2) + bit3 = ToolBitVBit(shape3) + + library.add_bit(bit1, 1) + library.add_bit(bit2, 2) + library.add_bit(bit3, 3) + self.assertEqual(len(library._bits), 3) + self.assertEqual(len(library._bit_nos), 3) + + library.remove_bit(bit2) + self.assertEqual(len(library._bits), 2) + self.assertNotIn(bit2, library._bits) + self.assertEqual(len(library._bit_nos), 2) + self.assertNotIn(2, library._bit_nos) + self.assertNotIn(bit2, library._bit_nos.values()) + + library.remove_bit(bit1) + self.assertEqual(len(library._bits), 1) + self.assertNotIn(bit1, library._bits) + self.assertEqual(len(library._bit_nos), 1) + self.assertNotIn(1, library._bit_nos) + self.assertNotIn(bit1, library._bit_nos.values()) + + library.remove_bit(bit3) + self.assertEqual(len(library._bits), 0) + self.assertNotIn(bit3, library._bits) + self.assertEqual(len(library._bit_nos), 0) + self.assertNotIn(3, library._bit_nos) + self.assertNotIn(bit3, library._bit_nos.values()) + + # Removing a non-existent bit should not raise an error + shape_nonexistent = ToolBitShapeEndmill( + id="dummy_nonexistent_6", + CuttingEdgeHeight=99.0, + Diameter=99.0, + Flutes=1, + Length=99.0, + ShankDiameter=99.0, + ) + library.remove_bit(ToolBitEndmill(shape_nonexistent)) + self.assertEqual(len(library._bits), 0) + self.assertEqual(len(library._bit_nos), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py b/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py new file mode 100644 index 0000000000..3ac4f6c253 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolLibrarySerializer.py @@ -0,0 +1,128 @@ +import unittest +import json +import FreeCAD +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.library import Library +from Path.Tool.toolbit import ToolBitEndmill +from Path.Tool.shape import ToolBitShapeEndmill +from Path.Tool.library.serializers import CamoticsLibrarySerializer, LinuxCNCSerializer + + +class TestPathToolLibrarySerializerBase(PathTestWithAssets): + """Base class for Library serializer tests.""" + + def setUp(self): + super().setUp() + self.test_library_id = "test_library" + self.test_library_label = "Test Library" + self.test_library = Library(self.test_library_label, id=self.test_library_id) + + # Create some dummy tool bits + shape1 = ToolBitShapeEndmill("endmill_1") + shape1.set_parameter("Diameter", FreeCAD.Units.Quantity("6.0 mm")) + shape1.set_parameter("Length", FreeCAD.Units.Quantity("20.0 mm")) + tool1 = ToolBitEndmill(shape1, id="tool_1") + tool1.label = "Endmill 6mm" + + shape2 = ToolBitShapeEndmill("endmill_2") + shape2.set_parameter("Diameter", FreeCAD.Units.Quantity("3.0 mm")) + shape2.set_parameter("Length", FreeCAD.Units.Quantity("15.0 mm")) + tool2 = ToolBitEndmill(shape2, id="tool_2") + tool2.label = "Endmill 3mm" + + self.test_library.add_bit(tool1, 1) + self.test_library.add_bit(tool2, 2) + + +class TestCamoticsLibrarySerializer(TestPathToolLibrarySerializerBase): + """Tests for the CamoticsLibrarySerializer.""" + + def test_camotics_serialize(self): + serializer = CamoticsLibrarySerializer + serialized_data = serializer.serialize(self.test_library) + self.assertIsInstance(serialized_data, bytes) + + # Verify the content structure (basic check) + data_dict = json.loads(serialized_data.decode("utf-8")) + self.assertIn("1", data_dict) + self.assertIn("2", data_dict) + self.assertEqual(data_dict["1"]["description"], self.test_library._bit_nos[1].label) + self.assertEqual( + data_dict["2"]["diameter"], + self.test_library._bit_nos[2]._tool_bit_shape.get_parameter("Diameter"), + ) + + def test_camotics_deserialize(self): + serializer = CamoticsLibrarySerializer + # Create a dummy serialized data matching the expected format + dummy_data = { + "10": { + "units": "metric", + "shape": "Ballnose", + "length": 25, + "diameter": 8, + "description": "Ballnose 8mm", + }, + "20": { + "units": "metric", + "shape": "Cylindrical", + "length": 30, + "diameter": 10, + "description": "Endmill 10mm", + }, + } + dummy_bytes = json.dumps(dummy_data, indent=2).encode("utf-8") + + # Deserialize the data + deserialized_library = serializer.deserialize(dummy_bytes, "deserialized_lib", {}) + + self.assertIsInstance(deserialized_library, Library) + self.assertEqual(deserialized_library.get_id(), "deserialized_lib") + self.assertEqual(len(deserialized_library._bit_nos), 2) + + tool_10 = deserialized_library._bit_nos.get(10) + assert tool_10 is not None, "tool not in the library" + self.assertEqual(tool_10.label, "Ballnose 8mm") + self.assertEqual(tool_10._tool_bit_shape.name, "Ballend") + self.assertEqual( + tool_10._tool_bit_shape.get_parameter("Diameter"), FreeCAD.Units.Quantity("8 mm") + ) + self.assertEqual( + tool_10._tool_bit_shape.get_parameter("Length"), FreeCAD.Units.Quantity("25 mm") + ) + + tool_20 = deserialized_library._bit_nos.get(20) + assert tool_20 is not None, "tool not in the library" + self.assertEqual(tool_20.label, "Endmill 10mm") + self.assertEqual(tool_20._tool_bit_shape.name, "Endmill") + self.assertEqual( + tool_20._tool_bit_shape.get_parameter("Diameter"), FreeCAD.Units.Quantity("10 mm") + ) + self.assertEqual( + tool_20._tool_bit_shape.get_parameter("Length"), FreeCAD.Units.Quantity("30 mm") + ) + + +class TestLinuxCNCLibrarySerializer(TestPathToolLibrarySerializerBase): + """Tests for the LinuxCNCLibrarySerializer.""" + + def test_linuxcnc_serialize(self): + serializer = LinuxCNCSerializer + serialized_data = serializer.serialize(self.test_library) + self.assertIsInstance(serialized_data, bytes) + + # Verify the content format (basic check) + lines = serialized_data.decode("ascii", "ignore").strip().split("\n") + self.assertEqual(len(lines), 2) + self.assertTrue(lines[0].startswith("T1 P D6.0 ;Endmill 6mm")) + self.assertTrue(lines[1].startswith("T2 P D3.0 ;Endmill 3mm")) + + def test_linuxcnc_deserialize_not_implemented(self): + serializer = LinuxCNCSerializer + dummy_data = b"T1 D6.0 ;Endmill 6mm\n" + with self.assertRaises(NotImplementedError): + serializer.deserialize(dummy_data, "dummy_id", {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolMachine.py b/src/Mod/CAM/CAMTests/TestPathToolMachine.py new file mode 100644 index 0000000000..d158b0ff39 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolMachine.py @@ -0,0 +1,206 @@ +import unittest +import FreeCAD +from Path.Tool.machine.models.machine import Machine + + +class TestPathToolMachine(unittest.TestCase): + def setUp(self): + self.default_machine = Machine() + + def test_initialization_defaults(self): + self.assertEqual(self.default_machine.label, "Machine") + self.assertAlmostEqual(self.default_machine.max_power.getValueAs("W").Value, 2000) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 3000) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 60000) + self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 1) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 2000) + expected_peak_torque_rpm = 60000 / 3 + self.assertAlmostEqual( + self.default_machine.get_peak_torque_rpm_value(), + expected_peak_torque_rpm, + ) + expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm + self.assertAlmostEqual( + self.default_machine.max_torque.getValueAs("Nm").Value, + expected_max_torque_nm, + ) + self.assertIsNotNone(self.default_machine.id) + + def test_initialization_custom_values(self): + custom_machine = Machine( + label="Custom Machine", + max_power=5, + min_rpm=1000, + max_rpm=20000, + max_torque=50, + peak_torque_rpm=15000, + min_feed=10, + max_feed=5000, + id="custom-id", + ) + self.assertEqual(custom_machine.label, "Custom Machine") + self.assertAlmostEqual(custom_machine.max_power.getValueAs("W").Value, 5000) + self.assertAlmostEqual(custom_machine.get_min_rpm_value(), 1000) + self.assertAlmostEqual(custom_machine.get_max_rpm_value(), 20000) + self.assertAlmostEqual(custom_machine.max_torque.getValueAs("Nm").Value, 50) + self.assertAlmostEqual(custom_machine.get_peak_torque_rpm_value(), 15000) + self.assertAlmostEqual(custom_machine.min_feed.getValueAs("mm/min").Value, 10) + self.assertAlmostEqual(custom_machine.max_feed.getValueAs("mm/min").Value, 5000) + self.assertEqual(custom_machine.id, "custom-id") + + def test_initialization_custom_torque_quantity(self): + custom_torque_machine = Machine(max_torque=FreeCAD.Units.Quantity(100, "Nm")) + self.assertAlmostEqual(custom_torque_machine.max_torque.getValueAs("Nm").Value, 100) + + def test_validate_valid(self): + try: + self.default_machine.validate() + except AttributeError as e: + self.fail(f"Validation failed unexpectedly: {e}") + + def test_validate_missing_label(self): + self.default_machine.label = "" + with self.assertRaisesRegex(AttributeError, "Machine name is required"): + self.default_machine.validate() + + def test_validate_peak_torque_rpm_greater_than_max_rpm(self): + self.default_machine.set_peak_torque_rpm(70000) + with self.assertRaisesRegex(AttributeError, "Peak Torque RPM.*must be less than max RPM"): + self.default_machine.validate() + + def test_validate_max_rpm_less_than_min_rpm(self): + self.default_machine = Machine() + self.default_machine.set_min_rpm(4000) # min_rpm = 4000 RPM + self.default_machine.set_peak_torque_rpm(1000) # peak_torque_rpm = 1000 RPM + self.default_machine._max_rpm = 2000 / 60.0 # max_rpm = 2000 RPM (33.33 1/s) + self.assertLess( + self.default_machine.get_max_rpm_value(), + self.default_machine.get_min_rpm_value(), + ) + with self.assertRaisesRegex(AttributeError, "Max RPM must be larger than min RPM"): + self.default_machine.validate() + + def test_validate_max_feed_less_than_min_feed(self): + self.default_machine.set_min_feed(1000) + self.default_machine._max_feed = 500 + with self.assertRaisesRegex(AttributeError, "Max feed must be larger than min feed"): + self.default_machine.validate() + + def test_get_torque_at_rpm(self): + torque_below_peak = self.default_machine.get_torque_at_rpm(10000) + expected_peak_torque_rpm = 60000 / 3 + expected_max_torque_nm = 2000 * 9.5488 / expected_peak_torque_rpm + expected_torque_below_peak = expected_max_torque_nm / expected_peak_torque_rpm * 10000 + self.assertAlmostEqual(torque_below_peak, expected_torque_below_peak) + + torque_at_peak = self.default_machine.get_torque_at_rpm( + self.default_machine.get_peak_torque_rpm_value() + ) + self.assertAlmostEqual( + torque_at_peak, + self.default_machine.max_torque.getValueAs("Nm").Value, + ) + + torque_above_peak = self.default_machine.get_torque_at_rpm(50000) + expected_torque_above_peak = 2000 * 9.5488 / 50000 + self.assertAlmostEqual(torque_above_peak, expected_torque_above_peak) + + def test_set_label(self): + self.default_machine.label = "New Label" + self.assertEqual(self.default_machine.label, "New Label") + + def test_set_max_power(self): + self.default_machine = Machine() + self.default_machine.set_max_power(5, "hp") + self.assertAlmostEqual( + self.default_machine.max_power.getValueAs("W").Value, + 5 * 745.7, + places=4, + ) + with self.assertRaisesRegex(AttributeError, "Max power must be positive"): + self.default_machine.set_max_power(0) + + def test_set_min_rpm(self): + self.default_machine = Machine() + self.default_machine.set_min_rpm(5000) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 5000) + with self.assertRaisesRegex(AttributeError, "Min RPM cannot be negative"): + self.default_machine.set_min_rpm(-100) + self.default_machine = Machine() + self.default_machine.set_min_rpm(70000) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 70000) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 70001) + + def test_set_max_rpm(self): + self.default_machine = Machine() + self.default_machine.set_max_rpm(50000) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 50000) + with self.assertRaisesRegex(AttributeError, "Max RPM must be positive"): + self.default_machine.set_max_rpm(0) + self.default_machine = Machine() + self.default_machine.set_max_rpm(2000) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 2000) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 1999) + self.default_machine = Machine() + self.default_machine.set_max_rpm(0.5) + self.assertAlmostEqual(self.default_machine.get_max_rpm_value(), 0.5) + self.assertAlmostEqual(self.default_machine.get_min_rpm_value(), 0) + + def test_set_min_feed(self): + self.default_machine = Machine() + self.default_machine.set_min_feed(500, "inch/min") + self.assertAlmostEqual( + self.default_machine.min_feed.getValueAs("mm/min").Value, + 500 * 25.4, + places=4, + ) + with self.assertRaisesRegex(AttributeError, "Min feed cannot be negative"): + self.default_machine.set_min_feed(-10) + self.default_machine = Machine() + self.default_machine.set_min_feed(3000) + self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 3000) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3001) + + def test_set_max_feed(self): + self.default_machine = Machine() + self.default_machine.set_max_feed(3000) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 3000) + with self.assertRaisesRegex(AttributeError, "Max feed must be positive"): + self.default_machine.set_max_feed(0) + self.default_machine = Machine() + self.default_machine.set_min_feed(600) + self.default_machine.set_max_feed(500) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 500) + self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 499) + self.default_machine = Machine() + self.default_machine.set_max_feed(0.5) + self.assertAlmostEqual(self.default_machine.max_feed.getValueAs("mm/min").Value, 0.5) + self.assertAlmostEqual(self.default_machine.min_feed.getValueAs("mm/min").Value, 0) + + def test_set_peak_torque_rpm(self): + self.default_machine = Machine() + self.default_machine.set_peak_torque_rpm(40000) + self.assertAlmostEqual(self.default_machine.get_peak_torque_rpm_value(), 40000) + with self.assertRaisesRegex(AttributeError, "Peak torque RPM cannot be negative"): + self.default_machine.set_peak_torque_rpm(-100) + + def test_set_max_torque(self): + self.default_machine = Machine() + self.default_machine.set_max_torque(200, "in-lbf") + self.assertAlmostEqual( + self.default_machine.max_torque.getValueAs("Nm").Value, + 200 * 0.112985, + places=4, + ) + with self.assertRaisesRegex(AttributeError, "Max torque must be positive"): + self.default_machine.set_max_torque(0) + + def test_dump(self): + try: + self.default_machine.dump(False) + except Exception as e: + self.fail(f"dump() method failed unexpectedly: {e}") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py b/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py new file mode 100644 index 0000000000..aa34fd4f66 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolShapeClasses.py @@ -0,0 +1,391 @@ +# -*- coding: utf-8 -*- +# Unit tests for the Path.Tool.Shape module and its utilities. + +from pathlib import Path +from typing import Mapping, Tuple +import FreeCAD +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.assets import DummyAssetSerializer +from Path.Tool.shape import ( + ToolBitShape, + ToolBitShapeBallend, + ToolBitShapeVBit, + ToolBitShapeBullnose, + ToolBitShapeSlittingSaw, +) + + +# Helper dummy class for testing abstract methods +class DummyShape(ToolBitShape): + name = "dummy" + + def __init__(self, id, **kwargs): + super().__init__(id=id, **kwargs) + # Always define defaults in the subclass + self._defaults = { + "Param1": FreeCAD.Units.Quantity("10 mm"), + "Param2": FreeCAD.Units.Quantity("5 deg"), + } + # Merge defaults into _params, allowing kwargs to override + self._params = self._defaults | self._params + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "Param1": ( + FreeCAD.Qt.translate("Param1", "Parameter 1"), + "App::PropertyLength", + ), + "Param2": ( + FreeCAD.Qt.translate("Param2", "Parameter 2"), + "App::PropertyAngle", + ), + } + + @property + def label(self): + return "Dummy Shape" + + +def unit(param): + return param.getUserPreferred()[2] + + +class TestPathToolShapeClasses(PathTestWithAssets): + """Tests for the concrete ToolBitShape subclasses.""" + + def _test_shape_common(self, alias): + uri = ToolBitShape.resolve_name(alias) + shape = self.assets.get(uri) + return shape.get_parameters() + + def test_base_init_with_defaults(self): + """Test base class initialization uses default parameters.""" + # Provide a dummy filepath and id for instantiation + shape = DummyShape(id="dummy_shape_1", filepath=Path("/fake/dummy.fcstd")) + self.assertEqual(str(shape.get_parameter("Param1")), "10.0 mm") + self.assertEqual(str(shape.get_parameter("Param2")), "5.0 deg") + + def test_base_init_with_kwargs(self): + """Test base class initialization overrides defaults with kwargs.""" + # Provide a dummy filepath for instantiation + shape = DummyShape( + id="dummy_shape_2", + filepath=Path("/fake/dummy.fcstd"), + Param1=FreeCAD.Units.Quantity("20 mm"), + Param3="Ignored", + ) + self.assertEqual(shape.get_parameter("Param1").Value, 20.0) + self.assertEqual(shape.get_parameter("Param1").Value, 20.0) + self.assertEqual( + str(shape.get_parameter("Param1").Unit), + "Unit: mm (1,0,0,0,0,0,0,0) [Length]", + ) + self.assertEqual(shape.get_parameter("Param2").Value, 5.0) + self.assertEqual( + str(shape.get_parameter("Param2").Unit), + "Unit: deg (0,0,0,0,0,0,0,1) [Angle]", + ) # Should remain default + + def test_base_get_set_parameter(self): + """Test getting and setting individual parameters.""" + # Provide a dummy filepath for instantiation + shape = DummyShape(id="dummy_shape_3", filepath=Path("/fake/dummy.fcstd")) + self.assertEqual(shape.get_parameter("Param1").Value, 10.0) + self.assertEqual(shape.get_parameter("Param1").Unit, FreeCAD.Units.Unit("mm")) + shape.set_parameter("Param1", FreeCAD.Units.Quantity("15 mm")) + self.assertEqual(shape.get_parameter("Param1").Value, 15.0) + self.assertEqual(shape.get_parameter("Param1").Unit, FreeCAD.Units.Unit("mm")) + with self.assertRaisesRegex(KeyError, "Shape 'dummy' has no parameter 'InvalidParam'"): + shape.get_parameter("InvalidParam") + + def test_base_get_parameters(self): + """Test getting the full parameter dictionary.""" + # Provide a dummy filepath for instantiation + shape = DummyShape( + id="dummy_shape_4", + filepath=Path("/fake/dummy.fcstd"), + Param1=FreeCAD.Units.Quantity("12 mm"), + ) + # Create mock quantity instances using the configured mock class + expected_param1 = FreeCAD.Units.Quantity("12.0 mm") + expected_param2 = FreeCAD.Units.Quantity("5.0 deg") + + expected = {"Param1": expected_param1, "Param2": expected_param2} + params = shape.get_parameters() + self.assertEqual(params["Param1"].Value, expected["Param1"].Value) + self.assertEqual(str(params["Param1"].Unit), str(expected["Param1"].Unit)) + self.assertEqual(params["Param2"].Value, expected["Param2"].Value) + self.assertEqual(str(params["Param2"].Unit), str(expected["Param2"].Unit)) + + def test_base_name_property(self): + """Test the name property returns the primary alias.""" + # Provide a dummy filepath for instantiation + shape = DummyShape(id="dummy_shape_5", filepath=Path("/fake/dummy.fcstd")) + self.assertEqual(shape.name, "dummy") + + def test_base_get_parameter_label(self): + """Test retrieving parameter labels.""" + # Provide a dummy filepath for instantiation + shape = DummyShape(id="dummy_shape_6", filepath=Path("/fake/dummy.fcstd")) + self.assertEqual(shape.get_parameter_label("Param1"), "Parameter 1") + self.assertEqual(shape.get_parameter_label("Param2"), "Parameter 2") + # Test fallback for unknown parameter + self.assertEqual(shape.get_parameter_label("UnknownParam"), "UnknownParam") + + def test_base_get_expected_shape_parameters(self): + """Test retrieving the list of expected parameter names.""" + expected = ["Param1", "Param2"] + self.assertCountEqual(DummyShape.get_expected_shape_parameters(), expected) + + def test_base_str_repr(self): + """Test string representation.""" + # Provide a dummy filepath for instantiation + shape = DummyShape(id="dummy_shape_7", filepath=Path("/fake/dummy.fcstd")) + # Dynamically construct the expected string using the actual parameter string representations + params_str = ", ".join(f"{name}={str(val)}" for name, val in shape.get_parameters().items()) + expected_str = f"dummy({params_str})" + self.assertEqual(str(shape), expected_str) + self.assertEqual(repr(shape), expected_str) + + def test_base_resolve_name(self): + """Test resolving shape aliases to canonical names.""" + self.assertEqual(ToolBitShape.resolve_name("ballend").asset_id, "ballend") + self.assertEqual(ToolBitShape.resolve_name("Ballend").asset_id, "ballend") + self.assertEqual(ToolBitShape.resolve_name("v-bit").asset_id, "vbit") + self.assertEqual(ToolBitShape.resolve_name("VBit").asset_id, "vbit") + self.assertEqual(ToolBitShape.resolve_name("torus").asset_id, "bullnose") + self.assertEqual(ToolBitShape.resolve_name("bullnose").asset_id, "bullnose") + self.assertEqual(ToolBitShape.resolve_name("slitting-saw").asset_id, "slittingsaw") + self.assertEqual(ToolBitShape.resolve_name("SlittingSaw").asset_id, "slittingsaw") + # Test unknown name - should return the input name + self.assertEqual(ToolBitShape.resolve_name("nonexistent").asset_id, "nonexistent") + self.assertEqual(ToolBitShape.resolve_name("UnknownShape").asset_id, "UnknownShape") + + def test_concrete_classes_instantiation(self): + """Test that all concrete classes can be instantiated.""" + # No patching of FreeCAD document operations here. + # The test relies on the actual FreeCAD environment. + + shape_uris = self.assets.list_assets(asset_type="toolbitshape") + for uri in shape_uris: + # Skip the DummyShape asset if it exists + if uri.asset_id == "dummy": + continue + + with self.subTest(uri=uri): + instance = self.assets.get(uri) + self.assertIsInstance(instance, ToolBitShape) + # Check if default params were set by checking if the + # parameters dictionary is not empty. + self.assertTrue(instance.get_parameters()) + + def test_get_shape_class(self): + """Test the get_shape_class function.""" + uri = ToolBitShape.resolve_name("ballend") + self.assets.get(uri) # Ensure it's loadable + + self.assertEqual(ToolBitShape.get_subclass_by_name("ballend"), ToolBitShapeBallend) + self.assertEqual(ToolBitShape.get_subclass_by_name("v-bit"), ToolBitShapeVBit) + self.assertEqual(ToolBitShape.get_subclass_by_name("VBit"), ToolBitShapeVBit) + self.assertEqual(ToolBitShape.get_subclass_by_name("torus"), ToolBitShapeBullnose) + self.assertEqual(ToolBitShape.get_subclass_by_name("slitting-saw"), ToolBitShapeSlittingSaw) + self.assertIsNone(ToolBitShape.get_subclass_by_name("nonexistent")) + + # The following tests for default parameters and labels + # should also not use mocks for FreeCAD document operations or Units. + # They should rely on the actual FreeCAD environment and the + # load_file method of the base class. + + def test_toolbitshapeballend_defaults(self): + """Test ToolBitShapeBallend default parameters and labels.""" + # Provide a dummy filepath for instantiation. + # The actual file content is not loaded in this test, + # only the default parameters are checked. + shape = self._test_shape_common("ballend") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["Length"].Value, 50.0) + self.assertEqual(unit(shape["Length"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("ballend") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter") + self.assertEqual(instance.get_parameter_label("Length"), "Overall tool length") + + def test_toolbitshapedrill_defaults(self): + """Test ToolBitShapeDrill default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("drill") + self.assertEqual(shape["Diameter"].Value, 3.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["TipAngle"].Value, 119.0) + self.assertEqual(unit(shape["TipAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("drill") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter") + self.assertEqual(instance.get_parameter_label("TipAngle"), "Tip angle") + + def test_toolbitshapechamfer_defaults(self): + """Test ToolBitShapeChamfer default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("chamfer") + self.assertEqual(shape["Diameter"].Value, 12.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["CuttingEdgeAngle"].Value, 60.0) + self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("chamfer") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter") + + def test_toolbitshapedovetail_defaults(self): + """Test ToolBitShapeDovetail default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("dovetail") + self.assertEqual(shape["Diameter"].Value, 20.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["CuttingEdgeAngle"].Value, 60.0) + self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("dovetail") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("CuttingEdgeAngle"), "Cutting angle") + + def test_toolbitshapeendmill_defaults(self): + """Test ToolBitShapeEndmill default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("endmill") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["CuttingEdgeHeight"].Value, 30.0) + self.assertEqual(unit(shape["CuttingEdgeHeight"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("endmill") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("CuttingEdgeHeight"), "Cutting edge height") + + def test_toolbitshapeprobe_defaults(self): + """Test ToolBitShapeProbe default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("probe") + self.assertEqual(shape["Diameter"].Value, 6.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["ShaftDiameter"].Value, 4.0) + self.assertEqual(unit(shape["ShaftDiameter"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("probe") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Ball diameter") + self.assertEqual(instance.get_parameter_label("ShaftDiameter"), "Shaft diameter") + + def test_toolbitshapereamer_defaults(self): + """Test ToolBitShapeReamer default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("reamer") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["Length"].Value, 50.0) + self.assertEqual(unit(shape["Length"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("reamer") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("Diameter"), "Diameter") + + def test_toolbitshapeslittingsaw_defaults(self): + """Test ToolBitShapeSlittingSaw default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("slittingsaw") + self.assertEqual(shape["Diameter"].Value, 100.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["BladeThickness"].Value, 3.0) + self.assertEqual(unit(shape["BladeThickness"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("slittingsaw") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("BladeThickness"), "Blade thickness") + + def test_toolbitshapetap_defaults(self): + """Test ToolBitShapeTap default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("tap") + self.assertAlmostEqual(shape["Diameter"].Value, 8, 4) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["TipAngle"].Value, 90.0) + self.assertEqual(unit(shape["TipAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("tap") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("TipAngle"), "Tip angle") + + def test_toolbitshapethreadmill_defaults(self): + """Test ToolBitShapeThreadMill default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("threadmill") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["cuttingAngle"].Value, 60.0) + self.assertEqual(unit(shape["cuttingAngle"]), "°") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("threadmill") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("cuttingAngle"), "Cutting angle") + + def test_toolbitshapebullnose_defaults(self): + """Test ToolBitShapeBullnose default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("bullnose") + self.assertEqual(shape["Diameter"].Value, 5.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["FlatRadius"].Value, 1.5) + self.assertEqual(unit(shape["FlatRadius"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("bullnose") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("FlatRadius"), "Torus radius") + + def test_toolbitshapevbit_defaults(self): + """Test ToolBitShapeVBit default parameters and labels.""" + # Provide a dummy filepath for instantiation. + shape = self._test_shape_common("vbit") + self.assertEqual(shape["Diameter"].Value, 10.0) + self.assertEqual(unit(shape["Diameter"]), "mm") + self.assertEqual(shape["CuttingEdgeAngle"].Value, 90.0) + self.assertEqual(unit(shape["CuttingEdgeAngle"]), "°") + self.assertEqual(shape["TipDiameter"].Value, 1.0) + self.assertEqual(unit(shape["TipDiameter"]), "mm") + # Need an instance to get parameter labels, get it from the asset manager + uri = ToolBitShape.resolve_name("vbit") + instance = self.assets.get(uri) + self.assertEqual(instance.get_parameter_label("CuttingEdgeAngle"), "Cutting edge angle") + + def test_serialize_deserialize(self): + """ + Tests serialization and deserialization of a ToolBitShape object + using the Asset interface methods. + """ + # Load a shape instance from a fixture file + fixture_path = ( + Path(__file__).parent / "Tools" / "Shape" / "test-path-tool-bit-shape-00.fcstd" + ) + original_shape = ToolBitShape.from_file(fixture_path) + + # Serialize the shape using the to_bytes method + serialized_data = original_shape.to_bytes(DummyAssetSerializer) + + # Assert that the serialized data is bytes and not empty + self.assertIsInstance(serialized_data, bytes) + self.assertTrue(len(serialized_data) > 0) + + # Deserialize the data using the from_bytes classmethod + # Provide an empty dependencies mapping for this test + deserialized_shape = ToolBitShape.from_bytes( + serialized_data, original_shape.get_id(), {}, DummyAssetSerializer + ) + + # Assert that the deserialized object is a ToolBitShape instance + self.assertIsInstance(deserialized_shape, ToolBitShape) + # Assert that the deserialized shape has the same parameters as the original + self.assertEqual(original_shape.get_parameters(), deserialized_shape.get_parameters()) + self.assertEqual(original_shape.name, deserialized_shape.name) diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py b/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py new file mode 100644 index 0000000000..02e024c732 --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolShapeDoc.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +# Unit tests for the Path.Tool.Shape module and its document utilities. +import unittest +from unittest.mock import patch, MagicMock, call +from Path.Tool.shape import doc +import os + + +mock_freecad = MagicMock(Name="FreeCAD_Mock") +mock_freecad.Console = MagicMock() +mock_freecad.Console.PrintWarning = MagicMock() +mock_freecad.Console.PrintError = MagicMock() + +mock_obj = MagicMock(Name="Object_Mock") +mock_obj.Label = "MockObjectLabel" +mock_obj.Name = "MockObjectName" + +mock_doc = MagicMock(Name="Document_Mock") +mock_doc.Objects = [mock_obj] + + +class TestPathToolShapeDoc(unittest.TestCase): + def setUp(self): + """Reset mocks before each test.""" + # Resetting the top-level mock recursively resets its children + # (newDocument, getDocument, openDocument, closeDocument, Console, etc.) + # and their call counts, return_values, side_effects. + mock_freecad.reset_mock() + mock_doc.reset_mock() + mock_obj.reset_mock() + + # Re-establish default state/attributes potentially cleared by reset_mock + # or needed for tests. + mock_doc.Objects = [mock_obj] + mock_obj.Label = "MockObjectLabel" + mock_obj.Name = "MockObjectName" + # Ensure mock_doc also has a Name attribute used in tests/code + mock_doc.Name = "Document_Mock" # Used in closeDocument calls + + # Clear attributes potentially added by setattr in previous tests. + # reset_mock() doesn't remove attributes added this way. + # Focus on attributes known to be added by tests in this file. + for attr_name in ["Diameter", "Length", "Height"]: + if hasattr(mock_obj, attr_name): + try: + delattr(mock_obj, attr_name) + except AttributeError: + pass # Ignore if already gone + + """Tests for the document utility functions in Path/Tool/Shape/doc.py""" + + def test_doc_find_shape_object_body_priority(self): + """Test find_shape_object prioritizes PartDesign::Body.""" + body_obj = MagicMock(Name="Body_Mock") + body_obj.isDerivedFrom = lambda typeName: typeName == "PartDesign::Body" + part_obj = MagicMock(Name="Part_Mock") + part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature" + mock_doc.Objects = [part_obj, body_obj] + found = doc.find_shape_object(mock_doc) + self.assertEqual(found, body_obj) + + def test_doc_find_shape_object_part_fallback(self): + """Test find_shape_object falls back to Part::Feature.""" + part_obj = MagicMock(Name="Part_Mock") + part_obj.isDerivedFrom = lambda typeName: typeName == "Part::Feature" + other_obj = MagicMock(Name="Other_Mock") + other_obj.isDerivedFrom = lambda typeName: False + mock_doc.Objects = [other_obj, part_obj] + found = doc.find_shape_object(mock_doc) + self.assertEqual(found, part_obj) + + def test_doc_find_shape_object_first_obj_fallback(self): + """Test find_shape_object falls back to the first object.""" + other_obj1 = MagicMock(Name="Other1_Mock") + other_obj1.isDerivedFrom = lambda typeName: False + other_obj2 = MagicMock(Name="Other2_Mock") + other_obj2.isDerivedFrom = lambda typeName: False + mock_doc.Objects = [other_obj1, other_obj2] + found = doc.find_shape_object(mock_doc) + self.assertEqual(found, other_obj1) + + def test_doc_find_shape_object_no_objects(self): + """Test find_shape_object returns None if document has no objects.""" + mock_doc.Objects = [] + found = doc.find_shape_object(mock_doc) + self.assertIsNone(found) + + def test_doc_get_object_properties_found(self): + """Test get_object_properties extracts existing properties.""" + setattr(mock_obj, "Diameter", "10 mm") + setattr(mock_obj, "Length", "50 mm") + params = doc.get_object_properties(mock_obj, ["Diameter", "Length"]) + # Expecting just the values, not tuples + self.assertEqual(params, {"Diameter": "10 mm", "Length": "50 mm"}) + mock_freecad.Console.PrintWarning.assert_not_called() + + @patch("Path.Tool.shape.doc.FreeCAD", new=mock_freecad) + def test_doc_get_object_properties_missing(self): + """Test get_object_properties handles missing properties with warning.""" + # Re-import doc within the patch context to use the mocked FreeCAD + import Path.Tool.shape.doc as doc_patched + + setattr(mock_obj, "Diameter", "10 mm") + # Explicitly delete Height to ensure hasattr returns False for MagicMock + if hasattr(mock_obj, "Height"): + delattr(mock_obj, "Height") + params = doc_patched.get_object_properties(mock_obj, ["Diameter", "Height"]) + # Expecting just the values, not tuples + self.assertEqual(params, {"Diameter": "10 mm", "Height": None}) # Height is missing + expected_calls = [ + # The 'Could not get type' warning is from base.py's set_parameter, + # not get_object_properties. Removing it from expected calls here. + call( + "Parameter 'Height' not found on object 'MockObjectLabel' " + "(MockObjectName). Default value will be used by the shape " + "class.\n" + ) + ] + mock_freecad.Console.PrintWarning.assert_has_calls(expected_calls, any_order=True) + + @patch("FreeCAD.openDocument") + @patch("FreeCAD.getDocument") + @patch("FreeCAD.closeDocument") + def test_ShapeDocFromBytes(self, mock_close_doc, mock_get_doc, mock_open_doc): + """Test ShapeDocFromBytes loads doc from a byte string.""" + content = b"fake_content" + mock_opened_doc = MagicMock(Name="OpenedDoc_Mock") + mock_get_doc.return_value = mock_opened_doc + + temp_file_path = None + try: + with doc.ShapeDocFromBytes(content=content) as temp_doc: + # Verify temp file creation and content + mock_open_doc.assert_called_once() + temp_file_path = mock_open_doc.call_args[0][0] + self.assertTrue(os.path.exists(temp_file_path)) + with open(temp_file_path, "rb") as f: + self.assertEqual(f.read(), content) + + self.assertEqual(temp_doc, mock_open_doc.return_value) + + # Verify cleanup after exiting the context + mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name) + self.assertFalse(os.path.exists(temp_file_path)) + + finally: + # Ensure cleanup even if test fails before assertion + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + + @patch("FreeCAD.openDocument") + @patch("FreeCAD.getDocument") + @patch("FreeCAD.closeDocument") + def test_ShapeDocFromBytes_open_exception(self, mock_close_doc, mock_get_doc, mock_open_doc): + """Test ShapeDocFromBytes propagates exceptions and cleans up.""" + content = b"fake_content_exception" + load_error = Exception("Fake load error") + mock_open_doc.side_effect = load_error + + temp_file_path = None + try: + with self.assertRaises(Exception) as cm: + with doc.ShapeDocFromBytes(content=content): + pass + + self.assertEqual(cm.exception, load_error) + + # Verify temp file was created before the exception + mock_open_doc.assert_called_once() + temp_file_path = mock_open_doc.call_args[0][0] + self.assertTrue(os.path.exists(temp_file_path)) + with open(temp_file_path, "rb") as f: + self.assertEqual(f.read(), content) + + mock_get_doc.assert_not_called() + # closeDocument is called in __exit__ only if _doc is not None, + # which it will be if openDocument failed. + mock_close_doc.assert_not_called() + + finally: + # Verify cleanup after exiting the context (even with exception) + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + # Assert removal only if temp_file_path was set + if temp_file_path: + self.assertFalse(os.path.exists(temp_file_path)) + + @patch("FreeCAD.openDocument") + @patch("FreeCAD.getDocument") + @patch("FreeCAD.closeDocument") + def test_ShapeDocFromBytes_exit_cleans_up(self, mock_close_doc, mock_get_doc, mock_open_doc): + """Test ShapeDocFromBytes __exit__ cleans up temp file.""" + content = b"fake_content_cleanup" + mock_opened_doc = MagicMock(Name="OpenedDoc_Cleanup_Mock") + mock_get_doc.return_value = mock_opened_doc + + temp_file_path = None + try: + with doc.ShapeDocFromBytes(content=content): + mock_open_doc.assert_called_once() + temp_file_path = mock_open_doc.call_args[0][0] + self.assertTrue(os.path.exists(temp_file_path)) + with open(temp_file_path, "rb") as f: + self.assertEqual(f.read(), content) + # No assertions on the returned doc here, focus is on cleanup + pass # Exit the context + + # Verify cleanup after exiting the context + mock_close_doc.assert_called_once_with(mock_open_doc.return_value.Name) + self.assertFalse(os.path.exists(temp_file_path)) + + finally: + # Ensure cleanup even if test fails before assertion + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + + +# Test execution +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathToolShapeIcon.py b/src/Mod/CAM/CAMTests/TestPathToolShapeIcon.py new file mode 100644 index 0000000000..0b70cb683b --- /dev/null +++ b/src/Mod/CAM/CAMTests/TestPathToolShapeIcon.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +import unittest +import unittest.mock +import pathlib +from tempfile import TemporaryDirectory +from PySide import QtCore, QtGui +from CAMTests.PathTestUtils import PathTestWithAssets +from Path.Tool.assets import DummyAssetSerializer +from Path.Tool.shape.models.icon import ( + ToolBitShapeIcon, + ToolBitShapeSvgIcon, + ToolBitShapePngIcon, +) + + +class TestToolBitShapeIconBase(PathTestWithAssets): + """Base class for ToolBitShapeIcon tests.""" + + ICON_CLASS = ToolBitShapeIcon + + def setUp(self): + super().setUp() + # Ensure a QApplication exists for QPixmap tests + self.app = QtGui.QApplication.instance() + + # Create a test shape and a test SVG icon. + self.test_shape = self.assets.get("toolbitshape://ballend") + self.test_svg = self.test_shape.icon + assert self.test_svg is not None + self.icon = self.ICON_CLASS("test_icon_base", b"") + + def tearDown(self): + self.app = None + return super().tearDown() + + def test_create_instance(self): + # Test basic instance creation + icon_id = "test_icon_123.dat" + icon = self.ICON_CLASS(icon_id, b"") + self.assertEqual(icon.get_id(), icon_id) + self.assertEqual(icon.data, b"") + self.assertIsInstance(icon.abbreviations, dict) + + def test_to_bytes(self): + # Test serializing to bytes + icon_id = "test_to_bytes.bin" + icon_data = b"some_binary_data" + icon = ToolBitShapeIcon(icon_id, icon_data) + self.assertEqual(icon.to_bytes(DummyAssetSerializer), icon_data) + + def test_get_size_in_bytes(self): + # Test getting icon data length + icon_with_data = ToolBitShapeIcon("with_data.bin", b"abc") + self.assertEqual(icon_with_data.get_size_in_bytes(), 3) + + icon_no_data = ToolBitShapeIcon("no_data.dat", b"") + self.assertEqual(icon_no_data.get_size_in_bytes(), 0) + + @unittest.mock.patch("Path.Tool.shape.util.create_thumbnail_from_data") + def test_from_shape_data_success(self, mock_create_thumbnail): + # Test creating instance from shape data - success case + shape_id = "test_shape" + thumbnail_data = b"png thumbnail data" + mock_create_thumbnail.return_value = thumbnail_data + icon = ToolBitShapeIcon.from_shape_data(self.test_svg.data, shape_id) + + mock_create_thumbnail.assert_called_once_with(self.test_svg.data) + self.assertIsNotNone(icon) + self.assertIsInstance(icon, ToolBitShapePngIcon) + self.assertEqual(icon.get_id(), shape_id) + self.assertEqual(icon.data, thumbnail_data) + self.assertEqual(icon.abbreviations, {}) + + @unittest.mock.patch("Path.Tool.shape.util.create_thumbnail_from_data") + def test_from_shape_data_failure(self, mock_create_thumbnail): + # Test creating instance from shape data - failure case + shape_id = "test_shape" + mock_create_thumbnail.return_value = None + icon_failed = ToolBitShapeIcon.from_shape_data(self.test_svg.data, shape_id) + + mock_create_thumbnail.assert_called_once_with(self.test_svg.data) + self.assertIsNone(icon_failed) + + def test_get_png(self): + if not self.app: + self.skipTest("QApplication not available, skipping test_get_png") + if type(self) is TestToolBitShapeIconBase: + self.skipTest("Skipping test on abstract base class") + # Test getting PNG data from the icon + icon_size = QtCore.QSize(16, 16) + png_data = self.icon.get_png(icon_size) + self.assertIsInstance(png_data, bytes) + self.assertTrue(len(png_data) > 0) + + def test_get_qpixmap(self): + if not self.app: + self.skipTest("QApplication not available, skipping test_get_qpixmap") + if type(self) is TestToolBitShapeIconBase: + self.skipTest("Skipping test on abstract base class") + # Test getting QPixmap from the icon + icon_size = QtCore.QSize(31, 32) + pixmap = self.icon.get_qpixmap(icon_size) + self.assertIsInstance(pixmap, QtGui.QPixmap) + self.assertFalse(pixmap.isNull()) + self.assertEqual(pixmap.size().width(), 31) + self.assertEqual(pixmap.size().height(), 32) + + +class TestToolBitShapeSvgIcon(TestToolBitShapeIconBase): + """Tests specifically for ToolBitShapeSvgIcon.""" + + ICON_CLASS = ToolBitShapeSvgIcon + + def setUp(self): + super().setUp() + self.icon = ToolBitShapeSvgIcon("test_icon_svg", self.test_svg.data) + + def test_from_bytes_svg(self): + # Test creating instance from bytes with SVG + icon_svg = ToolBitShapeSvgIcon.from_bytes( + self.test_svg.data, "test_from_bytes.svg", {}, DummyAssetSerializer + ) + self.assertEqual(icon_svg.get_id(), "test_from_bytes.svg") + self.assertEqual(icon_svg.data, self.test_svg.data) + self.assertIsInstance(icon_svg.abbreviations, dict) + + def test_round_trip_serialization_svg(self): + # Test serialization and deserialization round trip for SVG + svg_id = "round_trip_svg.svg" + icon_svg = ToolBitShapeSvgIcon(svg_id, self.test_svg.data) + serialized_svg = icon_svg.to_bytes(DummyAssetSerializer) + deserialized_svg = ToolBitShapeSvgIcon.from_bytes( + serialized_svg, svg_id, {}, DummyAssetSerializer + ) + self.assertEqual(deserialized_svg.get_id(), svg_id) + self.assertEqual(deserialized_svg.data, self.test_svg.data) + # Abbreviations are extracted on access, so we don't check the dict directly + self.assertIsInstance(deserialized_svg.abbreviations, dict) + + def test_from_file_svg(self): + # We cannot use NamedTemporaryFile on Windows, because there + # we may not have permission to read the tempfile while it is + # still open. + # So we use TemporaryDirectory instead, to ensure cleanup while + # still having a the temporary file inside it. + with TemporaryDirectory() as thedir: + tempfile = pathlib.Path(thedir, "test.svg") + tempfile.write_bytes(self.test_svg.data) + + icon_id = "dummy_icon" + icon = ToolBitShapeIcon.from_file(tempfile, icon_id) + self.assertIsInstance(icon, ToolBitShapeSvgIcon) + self.assertEqual(icon.get_id(), icon_id) + self.assertEqual(icon.data, self.test_svg.data) + self.assertIsInstance(icon.abbreviations, dict) + + def test_abbreviations_cached_property_svg(self): + # Test abbreviations property and caching for SVG + icon_svg = ToolBitShapeSvgIcon("cached_abbr.svg", self.test_svg.data) + + # Accessing the property should call the static method + with unittest.mock.patch.object( + ToolBitShapeSvgIcon, "get_abbreviations_from_svg" + ) as mock_get_abbr: + mock_get_abbr.return_value = {"param1": "A1"} + abbr1 = icon_svg.abbreviations + abbr2 = icon_svg.abbreviations + mock_get_abbr.assert_called_once_with(self.test_svg.data) + self.assertEqual(abbr1, {"param1": "A1"}) + self.assertEqual(abbr2, {"param1": "A1"}) + + def test_get_abbr_svg(self): + # Test getting abbreviations for SVG + icon_data = self.test_svg.data + icon = ToolBitShapeSvgIcon("abbr_test.svg", icon_data) + # Assuming the test_svg data has 'diameter' and 'length' ids + self.assertIsNotNone(icon.get_abbr("Diameter")) + self.assertIsNotNone(icon.get_abbr("Length")) + self.assertIsNone(icon.get_abbr("NonExistent")) + + def test_get_abbreviations_from_svg_static(self): + # Test static method get_abbreviations_from_svg + svg_content = self.test_svg.data + abbr = ToolBitShapeSvgIcon.get_abbreviations_from_svg(svg_content) + # Assuming the test_svg data has 'diameter' and 'length' ids + self.assertIn("diameter", abbr) + self.assertIn("length", abbr) + + # Test with invalid SVG + invalid_svg = b"A1" # Missing closing tag + abbr_invalid = ToolBitShapeSvgIcon.get_abbreviations_from_svg(invalid_svg) + self.assertEqual(abbr_invalid, {}) + + # Test with no text elements + no_text_svg = b'' + abbr_no_text = ToolBitShapeSvgIcon.get_abbreviations_from_svg(no_text_svg) + self.assertEqual(abbr_no_text, {}) + + +class TestToolBitShapePngIcon(TestToolBitShapeIconBase): + """Tests specifically for ToolBitShapePngIcon.""" + + ICON_CLASS = ToolBitShapePngIcon + + def setUp(self): + super().setUp() + self.png_data = b"\x89PNG\r\n\x1a\n" # Basic PNG signature + self.icon = ToolBitShapePngIcon("test_icon_png", self.png_data) + + def test_from_bytes_png(self): + # Test creating instance from bytes with PNG + icon_png = ToolBitShapePngIcon.from_bytes( + self.png_data, "test_from_bytes.png", {}, DummyAssetSerializer + ) + self.assertEqual(icon_png.get_id(), "test_from_bytes.png") + self.assertEqual(icon_png.data, self.png_data) + self.assertEqual(icon_png.abbreviations, {}) # No abbreviations for PNG + + def test_round_trip_serialization_png(self): + # Test serialization and deserialization round trip for PNG + png_id = "round_trip_png.png" + serialized_png = self.icon.to_bytes(DummyAssetSerializer) + deserialized_png = ToolBitShapePngIcon.from_bytes( + serialized_png, png_id, {}, DummyAssetSerializer + ) + self.assertEqual(deserialized_png.get_id(), png_id) + self.assertEqual(deserialized_png.data, self.png_data) + self.assertEqual(deserialized_png.abbreviations, {}) + + def test_from_file_png(self): + png_data = b"\\x89PNG\\r\\n\\x1a\\n" + # We cannot use NamedTemporaryFile on Windows, because there + # we may not have permission to read the tempfile while it is + # still open. + # So we use TemporaryDirectory instead, to ensure cleanup while + # still having a the temporary file inside it. + with TemporaryDirectory() as thedir: + tempfile = pathlib.Path(thedir, "test.png") + tempfile.write_bytes(png_data) + + icon_id = "dummy_icon" + icon = ToolBitShapeIcon.from_file(tempfile, icon_id) + self.assertIsInstance(icon, ToolBitShapePngIcon) + self.assertEqual(icon.get_id(), icon_id) + self.assertEqual(icon.data, png_data) + self.assertEqual(icon.abbreviations, {}) + + def test_abbreviations_cached_property_png(self): + # Test abbreviations property and caching for PNG + self.assertEqual(self.icon.abbreviations, {}) + + def test_get_abbr_png(self): + # Test getting abbreviations for PNG + self.assertIsNone(self.icon.get_abbr("Diameter")) + + def test_get_qpixmap(self): + self.skipTest("Skipping test, have no test data") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/Mod/CAM/CAMTests/TestPathVcarve.py b/src/Mod/CAM/CAMTests/TestPathVcarve.py index f1beb771a9..6e0345fdeb 100644 --- a/src/Mod/CAM/CAMTests/TestPathVcarve.py +++ b/src/Mod/CAM/CAMTests/TestPathVcarve.py @@ -24,10 +24,8 @@ import FreeCAD import Part import Path.Main.Job as PathJob import Path.Op.Vcarve as PathVcarve -import Path.Tool.Bit as PathToolBit import math - -from CAMTests.PathTestUtils import PathTestBase +from CAMTests.PathTestUtils import PathTestWithAssets class VbitTool(object): @@ -43,10 +41,11 @@ Scale45 = 2.414214 Scale60 = math.sqrt(3) -class TestPathVcarve(PathTestBase): +class TestPathVcarve(PathTestWithAssets): """Test Vcarve milling basics.""" def tearDown(self): + super().tearDown() if hasattr(self, "doc"): FreeCAD.closeDocument(self.doc.Name) @@ -56,8 +55,9 @@ class TestPathVcarve(PathTestBase): rect = Part.makePolygon([(0, 0, 0), (5, 0, 0), (5, 10, 0), (0, 10, 0), (0, 0, 0)]) part.Shape = Part.makeFace(rect, "Part::FaceMakerSimple") job = PathJob.Create("Job", [part]) - tool_file = PathToolBit.findToolBit("60degree_Vbit.fctb") - job.Tools.Group[0].Tool = PathToolBit.Factory.CreateFrom(tool_file) + toolbit = self.assets.get("toolbit://60degree_Vbit") + loaded_tool = toolbit.attach_to_doc(doc=job.Document) + job.Tools.Group[0].Tool = loaded_tool op = PathVcarve.Create("TestVCarve") op.Base = job.Model.Group[0] diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 06312b6300..dcae63ade9 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -10,6 +10,7 @@ set(Path_Scripts Init.py PathCommands.py TestCAMApp.py + TestCAMGui.py ) if(BUILD_GUI) @@ -114,20 +115,160 @@ SET(PathPythonMainSanity_SRCS SET(PathPythonTools_SRCS Path/Tool/__init__.py - Path/Tool/Bit.py + Path/Tool/camassets.py Path/Tool/Controller.py ) +SET(PathPythonToolsAssets_SRCS + Path/Tool/assets/__init__.py + Path/Tool/assets/asset.py + Path/Tool/assets/cache.py + Path/Tool/assets/manager.py + Path/Tool/assets/serializer.py + Path/Tool/assets/uri.py +) + +SET(PathPythonToolsAssetsStore_SRCS + Path/Tool/assets/store/__init__.py + Path/Tool/assets/store/base.py + Path/Tool/assets/store/memory.py + Path/Tool/assets/store/filestore.py +) + +SET(PathPythonToolsAssetsUi_SRCS + Path/Tool/assets/ui/__init__.py + Path/Tool/assets/ui/filedialog.py + Path/Tool/assets/ui/preferences.py + Path/Tool/assets/ui/util.py +) + SET(PathPythonToolsGui_SRCS Path/Tool/Gui/__init__.py - Path/Tool/Gui/Bit.py - Path/Tool/Gui/BitCmd.py - Path/Tool/Gui/BitEdit.py - Path/Tool/Gui/BitLibraryCmd.py - Path/Tool/Gui/BitLibrary.py Path/Tool/Gui/Controller.py ) +SET(PathPythonToolsUi_SRCS + Path/Tool/ui/__init__.py + Path/Tool/ui/docobject.py + Path/Tool/ui/property.py +) + +SET(PathPythonToolsToolBit_SRCS + Path/Tool/toolbit/__init__.py + Path/Tool/toolbit/docobject.py + Path/Tool/toolbit/util.py +) + +SET(PathPythonToolsToolBitMixins_SRCS + Path/Tool/toolbit/mixins/__init__.py + Path/Tool/toolbit/mixins/rotary.py + Path/Tool/toolbit/mixins/cutting.py +) + +SET(PathPythonToolsToolBitModels_SRCS + Path/Tool/toolbit/models/__init__.py + Path/Tool/toolbit/models/ballend.py + Path/Tool/toolbit/models/base.py + Path/Tool/toolbit/models/bullnose.py + Path/Tool/toolbit/models/chamfer.py + Path/Tool/toolbit/models/dovetail.py + Path/Tool/toolbit/models/drill.py + Path/Tool/toolbit/models/endmill.py + Path/Tool/toolbit/models/fillet.py + Path/Tool/toolbit/models/probe.py + Path/Tool/toolbit/models/reamer.py + Path/Tool/toolbit/models/slittingsaw.py + Path/Tool/toolbit/models/tap.py + Path/Tool/toolbit/models/threadmill.py + Path/Tool/toolbit/models/vbit.py +) + +SET(PathPythonToolsToolBitSerializers_SRCS + Path/Tool/toolbit/serializers/__init__.py + Path/Tool/toolbit/serializers/camotics.py + Path/Tool/toolbit/serializers/fctb.py +) + +SET(PathPythonToolsToolBitUi_SRCS + Path/Tool/toolbit/ui/__init__.py + Path/Tool/toolbit/ui/editor.py + Path/Tool/toolbit/ui/cmd.py + Path/Tool/toolbit/ui/browser.py + Path/Tool/toolbit/ui/file.py + Path/Tool/toolbit/ui/panel.py + Path/Tool/toolbit/ui/selector.py + Path/Tool/toolbit/ui/tablecell.py + Path/Tool/toolbit/ui/toollist.py + Path/Tool/toolbit/ui/view.py +) + +SET(PathPythonToolsLibrary_SRCS + Path/Tool/library/__init__.py + Path/Tool/library/util.py +) + +SET(PathPythonToolsLibraryModels_SRCS + Path/Tool/library/models/__init__.py + Path/Tool/library/models/library.py +) + +SET(PathPythonToolsLibrarySerializers_SRCS + Path/Tool/library/serializers/__init__.py + Path/Tool/library/serializers/fctl.py + Path/Tool/library/serializers/camotics.py + Path/Tool/library/serializers/linuxcnc.py +) + +SET(PathPythonToolsLibraryUi_SRCS + Path/Tool/library/ui/__init__.py + Path/Tool/library/ui/cmd.py + Path/Tool/library/ui/dock.py + Path/Tool/library/ui/editor.py + Path/Tool/library/ui/browser.py +) + +SET(PathPythonToolsMachine_SRCS + Path/Tool/machine/__init__.py +) + +SET(PathPythonToolsMachineModels_SRCS + Path/Tool/machine/models/__init__.py + Path/Tool/machine/models/machine.py +) + +SET(PathPythonToolsShape_SRCS + Path/Tool/shape/__init__.py + Path/Tool/shape/util.py + Path/Tool/shape/doc.py +) + +SET(PathPythonToolsShapeModels_SRCS + Path/Tool/shape/models/__init__.py + Path/Tool/shape/models/ballend.py + Path/Tool/shape/models/base.py + Path/Tool/shape/models/bullnose.py + Path/Tool/shape/models/chamfer.py + Path/Tool/shape/models/dovetail.py + Path/Tool/shape/models/drill.py + Path/Tool/shape/models/endmill.py + Path/Tool/shape/models/fillet.py + Path/Tool/shape/models/icon.py + Path/Tool/shape/models/probe.py + Path/Tool/shape/models/reamer.py + Path/Tool/shape/models/slittingsaw.py + Path/Tool/shape/models/tap.py + Path/Tool/shape/models/threadmill.py + Path/Tool/shape/models/vbit.py +) + +SET(PathPythonToolsShapeUi_SRCS + Path/Tool/shape/ui/__init__.py + Path/Tool/shape/ui/flowlayout.py + Path/Tool/shape/ui/shapebutton.py + Path/Tool/shape/ui/shapeselector.py + Path/Tool/shape/ui/shapewidget.py +) + SET(PathPythonPost_SRCS Path/Post/__init__.py Path/Post/Command.py @@ -267,14 +408,17 @@ SET(Tools_SRCS ) SET(Tools_Bit_SRCS + Tools/Bit/30degree_Vbit.fctb Tools/Bit/375-16_Tap.fctb Tools/Bit/45degree_chamfer.fctb + Tools/Bit/45degree_Vbit.fctb Tools/Bit/5mm-thread-cutter.fctb Tools/Bit/5mm_Drill.fctb Tools/Bit/5mm_Endmill.fctb Tools/Bit/60degree_Vbit.fctb Tools/Bit/6mm_Ball_End.fctb Tools/Bit/6mm_Bullnose.fctb + Tools/Bit/90degree_Vbit.fctb Tools/Bit/probe.fctb Tools/Bit/slittingsaw.fctb ) @@ -285,16 +429,31 @@ SET(Tools_Library_SRCS SET(Tools_Shape_SRCS Tools/Shape/ballend.fcstd + Tools/Shape/ballend.svg Tools/Shape/bullnose.fcstd + Tools/Shape/bullnose.svg Tools/Shape/chamfer.fcstd + Tools/Shape/chamfer.svg Tools/Shape/dovetail.fcstd + Tools/Shape/dovetail.svg Tools/Shape/drill.fcstd + Tools/Shape/drill.svg Tools/Shape/endmill.fcstd + Tools/Shape/endmill.svg + Tools/Shape/fillet.fcstd + Tools/Shape/fillet.svg Tools/Shape/probe.fcstd + Tools/Shape/probe.svg + Tools/Shape/reamer.fcstd + Tools/Shape/reamer.svg Tools/Shape/slittingsaw.fcstd + Tools/Shape/slittingsaw.svg Tools/Shape/tap.fcstd - Tools/Shape/thread-mill.fcstd - Tools/Shape/v-bit.fcstd + Tools/Shape/tap.svg + Tools/Shape/threadmill.fcstd + Tools/Shape/threadmill.svg + Tools/Shape/vbit.fcstd + Tools/Shape/vbit.svg ) SET(Tests_SRCS @@ -346,7 +505,24 @@ SET(Tests_SRCS CAMTests/TestPathToolChangeGenerator.py CAMTests/TestPathThreadMilling.py CAMTests/TestPathThreadMillingGenerator.py + CAMTests/TestPathToolAsset.py + CAMTests/TestPathToolAssetCache.py + CAMTests/TestPathToolAssetUri.py + CAMTests/TestPathToolAssetStore.py + CAMTests/TestPathToolAssetManager.py CAMTests/TestPathToolBit.py + CAMTests/TestPathToolBitSerializer.py + CAMTests/TestPathToolBitBrowserWidget.py + CAMTests/TestPathToolBitEditorWidget.py + CAMTests/TestPathToolBitListWidget.py + CAMTests/TestPathToolBitPropertyEditorWidget.py + CAMTests/TestPathToolDocumentObjectEditorWidget.py + CAMTests/TestPathToolShapeClasses.py + CAMTests/TestPathToolShapeDoc.py + CAMTests/TestPathToolShapeIcon.py + CAMTests/TestPathToolLibrary.py + CAMTests/TestPathToolLibrarySerializer.py + CAMTests/TestPathToolMachine.py CAMTests/TestPathToolController.py CAMTests/TestPathUtil.py CAMTests/TestPathVcarve.py @@ -415,7 +591,25 @@ SET(all_files ${PathPythonPost_SRCS} ${PathPythonPostScripts_SRCS} ${PathPythonTools_SRCS} + ${PathPythonToolsAssets_SRCS} + ${PathPythonToolsAssetsStore_SRCS} + ${PathPythonToolsAssetsUi_SRCS} ${PathPythonToolsGui_SRCS} + ${PathPythonToolsUi_SRCS} + ${PathPythonToolsShape_SRCS} + ${PathPythonToolsShapeModels_SRCS} + ${PathPythonToolsShapeUi_SRCS} + ${PathPythonToolsToolBit_SRCS} + ${PathPythonToolsToolBitMixins_SRCS} + ${PathPythonToolsToolBitModels_SRCS} + ${PathPythonToolsToolBitSerializers_SRCS} + ${PathPythonToolsToolBitUi_SRCS} + ${PathPythonToolsLibrary_SRCS} + ${PathPythonToolsLibraryModels_SRCS} + ${PathPythonToolsLibrarySerializers_SRCS} + ${PathPythonToolsLibraryUi_SRCS} + ${PathPythonToolsMachine_SRCS} + ${PathPythonToolsMachineModels_SRCS} ${PathPythonGui_SRCS} ${Tools_SRCS} ${Tools_Bit_SRCS} @@ -544,6 +738,27 @@ INSTALL( Mod/CAM/Path/Tool ) +INSTALL( + FILES + ${PathPythonToolsAssets_SRCS} + DESTINATION + Mod/CAM/Path/Tool/assets +) + +INSTALL( + FILES + ${PathPythonToolsAssetsStore_SRCS} + DESTINATION + Mod/CAM/Path/Tool/assets/store +) + +INSTALL( + FILES + ${PathPythonToolsAssetsUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/assets/ui +) + INSTALL( FILES ${PathPythonToolsGui_SRCS} @@ -551,6 +766,111 @@ INSTALL( Mod/CAM/Path/Tool/Gui ) +INSTALL( + FILES + ${PathPythonToolsUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/ui +) + +INSTALL( + FILES + ${PathPythonToolsShape_SRCS} + DESTINATION + Mod/CAM/Path/Tool/shape +) + +INSTALL( + FILES + ${PathPythonToolsShapeUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/shape/ui +) + +INSTALL( + FILES + ${PathPythonToolsShapeModels_SRCS} + DESTINATION + Mod/CAM/Path/Tool/shape/models +) + +INSTALL( + FILES + ${PathPythonToolsToolBit_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit +) + +INSTALL( + FILES + ${PathPythonToolsToolBitMixins_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit/mixins +) + +INSTALL( + FILES + ${PathPythonToolsToolBitModels_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit/models +) + +INSTALL( + FILES + ${PathPythonToolsToolBitSerializers_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit/serializers +) + +INSTALL( + FILES + ${PathPythonToolsToolBitUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/toolbit/ui +) + +INSTALL( + FILES + ${PathPythonToolsLibrary_SRCS} + DESTINATION + Mod/CAM/Path/Tool/library +) + +INSTALL( + FILES + ${PathPythonToolsLibraryModels_SRCS} + DESTINATION + Mod/CAM/Path/Tool/library/models +) + +INSTALL( + FILES + ${PathPythonToolsLibrarySerializers_SRCS} + DESTINATION + Mod/CAM/Path/Tool/library/serializers +) + +INSTALL( + FILES + ${PathPythonToolsLibraryUi_SRCS} + DESTINATION + Mod/CAM/Path/Tool/library/ui +) + +INSTALL( + FILES + ${PathPythonToolsMachine_SRCS} + DESTINATION + Mod/CAM/Path/Tool/machine +) + +INSTALL( + FILES + ${PathPythonToolsMachineModels_SRCS} + DESTINATION + Mod/CAM/Path/Tool/machine/models +) + INSTALL( FILES ${Tests_SRCS} diff --git a/src/Mod/CAM/Gui/Resources/Path.qrc b/src/Mod/CAM/Gui/Resources/Path.qrc index e4967a5efb..287d7a2a61 100644 --- a/src/Mod/CAM/Gui/Resources/Path.qrc +++ b/src/Mod/CAM/Gui/Resources/Path.qrc @@ -119,11 +119,11 @@ panels/PointEdit.ui panels/PropertyBag.ui panels/PropertyCreate.ui + panels/ShapeSelector.ui panels/SetupGlobal.ui panels/SetupOp.ui panels/ToolBitEditor.ui panels/ToolBitLibraryEdit.ui - panels/ToolBitSelector.ui panels/TaskPathCamoticsSim.ui panels/TaskPathSimulator.ui panels/TaskCAMSimulator.ui diff --git a/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui new file mode 100644 index 0000000000..395f051c3f --- /dev/null +++ b/src/Mod/CAM/Gui/Resources/panels/ShapeSelector.ui @@ -0,0 +1,65 @@ + + + ShapeSelector + + + + 0 + 0 + 900 + 600 + + + + Select a Tool Shape + + + + + + + + 1 + + + + + 0 + 0 + 880 + 487 + + + + Standard Tools + + + + + + 0 + 0 + 880 + 487 + + + + My Tools + + + + + + + + + + QDialogButtonBox::Cancel + + + + + + + + diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui index 7dae7882cb..ebb60688a5 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ToolBitEditor.ui @@ -1,237 +1,232 @@ - ToolBitAttributes - + Dialog + 0 0 - 489 - 715 + 1000 + 900 - Tool Bit Attributes + Tool Parameter Editor - - - - - - 0 - 0 - + + + + + true - - 0 - - - - Shape - - + + + + 0 + 0 + 980 + 849 + + + - - - - 0 - 0 - - - - Tool Bit - - - - - - Name - - - - - - - Display name of the Tool Bit (initial value taken from the shape file). - - - 50 - - - Display Name - - - - - - - Shape File - - - - - + + + + + + 0 + 0 + + + + + 1000 + 1000 + + + + + 1000 + 1000 + + + + 0 + + - + 0 0 - - - 0 - - - 0 - - - 0 - - - 0 - + + Tool + + - - - The file which defines the type and shape of the Tool Bit. - - - path - - + - - - Change file defining type and shape of Tool Bit. + + + + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + Notes + + + + + + QLayout::SetDefaultConstraint - - ... + + 12 + + + 6 + + + + + Coating: + + + + + + + + + + Hardness: + + + + + + + + + + Materials: + + + + + + + + + + + + + Supplier: + + + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 16777215 + 16777215 + - - - - - - - - Parameter - - - - QFormLayout::AllNonFixedFieldsGrow - - - - - Point/Tip Angle - - - - - - - 0 ° - - - ° - - - - - - - Cutting Edge Height - - - - - - - 0 mm - - - mm - - - - - - - - - - - 210 - 297 - - - - Image - - - Qt::AlignCenter - - - - - - - Qt::Vertical - - - - 20 - 277 - - - - - - - - - Attributes - - - - - - - 0 - 2 - - - - - 0 - 300 - - - - QAbstractItemView::AllEditTriggers - - + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + - - - Gui::InputField - QLineEdit -
Gui/InputField.h
-
-
- - - - + + tabWidget + lineEditCoating + lineEditMaterials + lineEditHardness + lineEditSupplier + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + +
diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui index d07da00243..edb58a2ae2 100644 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui +++ b/src/Mod/CAM/Gui/Resources/panels/ToolBitLibraryEdit.ui @@ -88,27 +88,7 @@ - - - - 16777215 - 16777215 - - - - Select a working path for the tool library editor. - - - - - - - :/icons/document-open.svg:/icons/document-open.svg - - - - - + 16777215 @@ -133,8 +113,9 @@ + - + Save the selected library with a new name or export to another format @@ -252,7 +233,7 @@ - + 0 @@ -266,7 +247,7 @@ - Save the current Library + Close the library editor Close diff --git a/src/Mod/CAM/Gui/Resources/panels/ToolBitSelector.ui b/src/Mod/CAM/Gui/Resources/panels/ToolBitSelector.ui deleted file mode 100644 index 4112472b0a..0000000000 --- a/src/Mod/CAM/Gui/Resources/panels/ToolBitSelector.ui +++ /dev/null @@ -1,132 +0,0 @@ - - - ToolSelector - - - - 0 - 0 - 350 - 542 - - - - - 0 - 0 - - - - Tool Selector - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - Library editor... - - - - - - - :/icons/edit-edit.svg:/icons/edit-edit.svg - - - - 16 - 16 - - - - - - - - - - - - Available Tool Bits to choose from. - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::ExtendedSelection - - - false - - - - - - - - - - - false - - - Create ToolControllers for the selected toolbits and add them to the Job - - - Add To Job - - - - :/icons/edit_OK.svg:/icons/edit_OK.svg - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - - diff --git a/src/Mod/CAM/InitGui.py b/src/Mod/CAM/InitGui.py index 29aa791901..bd0c16a02a 100644 --- a/src/Mod/CAM/InitGui.py +++ b/src/Mod/CAM/InitGui.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2014 Yorik van Havre * # * * @@ -20,6 +21,10 @@ # * USA * # * * # *************************************************************************** +import FreeCAD + + +FreeCAD.__unit_test__ += ["TestCAMGui"] class PathCommandGroup: @@ -58,6 +63,7 @@ class CAMWorkbench(Workbench): # Add preferences pages - before loading PathGui to properly order pages of Path group import Path.Dressup.Gui.Preferences as PathPreferencesPathDressup + import Path.Tool.assets.ui.preferences as AssetPreferences import Path.Main.Gui.PreferencesJob as PathPreferencesPathJob translate = FreeCAD.Qt.translate @@ -74,8 +80,8 @@ class CAMWorkbench(Workbench): from Path.Main.Gui import JobCmd as PathJobCmd from Path.Main.Gui import SanityCmd as SanityCmd - from Path.Tool.Gui import BitCmd as PathToolBitCmd - from Path.Tool.Gui import BitLibraryCmd as PathToolBitLibraryCmd + from Path.Tool.toolbit.ui import cmd as PathToolBitCmd + from Path.Tool.library.ui import cmd as PathToolBitLibraryCmd from PySide.QtCore import QT_TRANSLATE_NOOP @@ -87,6 +93,10 @@ class CAMWorkbench(Workbench): PathPreferencesPathJob.JobPreferencesPage, QT_TRANSLATE_NOOP("QObject", "CAM"), ) + FreeCADGui.addPreferencePage( + AssetPreferences.AssetPreferencesPage, + QT_TRANSLATE_NOOP("QObject", "CAM"), + ) FreeCADGui.addPreferencePage( PathPreferencesPathDressup.DressupPreferencesPage, QT_TRANSLATE_NOOP("QObject", "CAM"), @@ -335,7 +345,7 @@ class CAMWorkbench(Workbench): for cmd in self.dressupcmds: self.appendContextMenu("", [cmd]) menuAppended = True - if isinstance(obj.Proxy, Path.Tool.Bit.ToolBit): + if isinstance(obj.Proxy, Path.Tool.ToolBit): self.appendContextMenu("", ["CAM_ToolBitSave", "CAM_ToolBitSaveAs"]) menuAppended = True if menuAppended: diff --git a/src/Mod/CAM/Path/Base/Gui/SetupSheet.py b/src/Mod/CAM/Path/Base/Gui/SetupSheet.py index dabcbb610d..97d6e0084e 100644 --- a/src/Mod/CAM/Path/Base/Gui/SetupSheet.py +++ b/src/Mod/CAM/Path/Base/Gui/SetupSheet.py @@ -321,10 +321,10 @@ class GlobalEditor(object): self.form.setupStepDownExpr.setText(self.obj.StepDownExpression) self.form.setupClearanceHeightExpr.setText(self.obj.ClearanceHeightExpression) self.form.setupSafeHeightExpr.setText(self.obj.SafeHeightExpression) - self.clearanceHeightOffs.updateSpinBox() - self.safeHeightOffs.updateSpinBox() - self.rapidVertical.updateSpinBox() - self.rapidHorizontal.updateSpinBox() + self.clearanceHeightOffs.updateWidget() + self.safeHeightOffs.updateWidget() + self.rapidVertical.updateWidget() + self.rapidHorizontal.updateWidget() self.selectInComboBox(self.obj.CoolantMode, self.form.setupCoolantMode) def updateModel(self, recomp=True): diff --git a/src/Mod/CAM/Path/Base/Gui/Util.py b/src/Mod/CAM/Path/Base/Gui/Util.py index 2d311b1cb1..4cf15a4ad8 100644 --- a/src/Mod/CAM/Path/Base/Gui/Util.py +++ b/src/Mod/CAM/Path/Base/Gui/Util.py @@ -138,7 +138,8 @@ class QuantitySpinBox(QtCore.QObject): return False def onWidgetValueChanged(self): - """onWidgetValueChanged()... Slot method for determining if a change + """ + Slot method for determining if a change in widget value is a result of an expression edit, or a simple spinbox change. If the former, emit a manual `editingFinished` signal because the Expression editor window returned a value to the base widget, leaving it in read-only mode, @@ -150,7 +151,7 @@ class QuantitySpinBox(QtCore.QObject): self.widget.editingFinished.emit() def attachTo(self, obj, prop=None): - """attachTo(obj, prop=None) ... use an existing editor for the given object and property""" + """use an existing editor for the given object and property""" Path.Log.track(self.prop, prop) self.obj = obj self.prop = prop @@ -168,21 +169,22 @@ class QuantitySpinBox(QtCore.QObject): self.valid = False def expression(self): - """expression() ... returns the expression if one is bound to the property""" + """returns the expression if one is bound to the property""" Path.Log.track(self.prop, self.valid) if self.valid: return self.widget.property("expression") return "" def setMinimum(self, quantity): - """setMinimum(quantity) ... set the minimum""" + """set the minimum""" Path.Log.track(self.prop, self.valid) if self.valid: value = quantity.Value if hasattr(quantity, "Value") else quantity self.widget.setProperty("setMinimum", value) - def updateSpinBox(self, quantity=None): - """updateSpinBox(quantity=None) ... update the display value of the spin box. + def updateWidget(self, quantity=None): + """ + update the display value of the spin box. If no value is provided the value of the bound property is used. quantity can be of type Quantity or Float.""" Path.Log.track(self.prop, self.valid, quantity) @@ -222,6 +224,215 @@ class QuantitySpinBox(QtCore.QObject): return None +class PropertyComboBox(QtCore.QObject): + """Base controller class for properties represented as QComboBox.""" + + def __init__(self, widget, obj, prop, onBeforeChange=None): + super().__init__() + Path.Log.track(widget) + self.widget = widget + self.onBeforeChange = onBeforeChange + self.prop = None + self.obj = obj + self.valid = False + self.attachTo(obj, prop) + self.widget.currentIndexChanged.connect(self.updateProperty) + + def attachTo(self, obj, prop=None): + """use an existing editor for the given object and property""" + Path.Log.track(self.prop, prop) + self.obj = obj + self.prop = prop + if obj and prop: + attr = PathUtil.getProperty(obj, prop) + if attr is not None: + self.valid = True + self._populateComboBox() + self.updateWidget() + else: + Path.Log.warning("Cannot find property {} of {}".format(prop, obj.Label)) + self.valid = False + else: + self.valid = False + + def _populateComboBox(self): + """To be implemented by subclasses""" + raise NotImplementedError + + def updateWidget(self, value=None): + """update the display value of the combo box.""" + Path.Log.track(self.prop, self.valid, value) + if self.valid: + if value is None: + value = PathUtil.getProperty(self.obj, self.prop) + index = ( + self.widget.findData(value) + if hasattr(self.widget, "findData") + else self.widget.findText(str(value)) + ) + if index >= 0: + self.widget.setCurrentIndex(index) + + def updateProperty(self): + """update the bound property with the value from the combo box""" + Path.Log.track(self.prop, self.valid) + if self.valid and self.prop: + if self.onBeforeChange: + self.onBeforeChange() + + current_value = PathUtil.getProperty(self.obj, self.prop) + new_value = ( + self.widget.currentData() + if hasattr(self.widget, "currentData") + else self.widget.currentText() + ) + + if str(new_value) != str(current_value): + setattr(self.obj, self.prop, new_value) + return True + return False + + +class IntegerSpinBox(QtCore.QObject): + """Controller class for integer properties represented as QSpinBox. + IntegerSpinBox(widget, obj, prop, onBeforeChange=None) + widget ... expected to be reference to a QSpinBox + obj ... document object + prop ... canonical name of the (sub-) property + onBeforeChange ... optional callback before property change + """ + + def __init__(self, widget, obj, prop, onBeforeChange=None): + super().__init__() + self.widget = widget + self.onBeforeChange = onBeforeChange + self.prop = None + self.obj = obj + self.valid = False + + # Configure spin box defaults + self.widget.setMinimum(-2147483647) # Qt's minimum for spin boxes + self.widget.setMaximum(2147483647) # Qt's maximum for spin boxes + + self.attachTo(obj, prop) + self.widget.valueChanged.connect(self.updateProperty) + + def attachTo(self, obj, prop=None): + """bind to the given object and property""" + self.obj = obj + self.prop = prop + if obj and prop: + try: + prop_value = PathUtil.getProperty(obj, prop) + if prop_value is not None: + self.valid = True + self.updateWidget() + else: + Path.Log.warning(f"Cannot get value for property {prop} of {obj.Label}") + self.valid = False + except Exception as e: + Path.Log.error(f"Error attaching to property {prop}: {str(e)}") + self.valid = False + else: + self.valid = False + + def updateWidget(self, value=None): + """update the spin box value""" + if self.valid: + try: + if value is None: + value = PathUtil.getProperty(self.obj, self.prop) + + # Handle both direct values and Quantity objects + if hasattr(value, "Value"): # For Quantity properties + value = int(value.Value) + + self.widget.setValue(int(value)) + except Exception as e: + Path.Log.error(f"Error updating spin box: {str(e)}") + + def updateProperty(self): + """update the bound property with the spin box value""" + if self.valid and self.prop: + if self.onBeforeChange: + self.onBeforeChange() + + new_value = self.widget.value() + current_value = PathUtil.getProperty(self.obj, self.prop) + + # Handle Quantity properties + if hasattr(current_value, "Value"): + if new_value != current_value.Value: + current_value.Value = new_value + return True + elif new_value != current_value: + setattr(self.obj, self.prop, new_value) + return True + return False + + def setRange(self, min_val, max_val): + """set minimum and maximum values""" + self.widget.setMinimum(min_val) + self.widget.setMaximum(max_val) + + def setSingleStep(self, step): + """setSingleStep(step) ... set the step size""" + self.widget.setSingleStep(step) + + +class BooleanComboBox(PropertyComboBox): + """Controller class for boolean properties represented as QComboBox.""" + + def _populateComboBox(self): + self.widget.clear() + self.widget.addItem("True", True) + self.widget.addItem("False", False) + + +class EnumerationComboBox(PropertyComboBox): + """Controller class for enumeration properties represented as QComboBox.""" + + def _populateComboBox(self): + self.widget.clear() + enums = self.obj.getEnumerationsOfProperty(self.prop) + for item in enums: + self.widget.addItem(item, item) + + +class PropertyLabel(QtCore.QObject): + """Controller class for read-only property display as QLabel.""" + + def __init__(self, widget, obj, prop, onBeforeChange=None): + super().__init__() + self.widget = widget + self.obj = obj + self.prop = prop + self.valid = False + self.attachTo(obj, prop) + + def attachTo(self, obj, prop=None): + """bind to the given object and property""" + self.obj = obj + self.prop = prop + if obj and prop: + attr = PathUtil.getProperty(obj, prop) + if attr is not None: + self.valid = True + self.updateWidget() + else: + Path.Log.warning(f"Cannot find property {prop} of {obj.Label}") + self.valid = False + else: + self.valid = False + + def updateWidget(self, value=None): + """update the label text""" + if self.valid: + if value is None: + value = PathUtil.getProperty(self.obj, self.prop) + self.widget.setText(str(value)) + + def getDocNode(): doc = FreeCADGui.ActiveDocument.Document.Name tws = FreeCADGui.getMainWindow().findChildren(QtGui.QTreeWidget) diff --git a/src/Mod/CAM/Path/Base/Util.py b/src/Mod/CAM/Path/Base/Util.py index 999da0a6c7..e8bcbde5f9 100644 --- a/src/Mod/CAM/Path/Base/Util.py +++ b/src/Mod/CAM/Path/Base/Util.py @@ -96,7 +96,7 @@ def isValidBaseObject(obj): # Can't link to anything inside a geo feature group anymore Path.Log.debug("%s is inside a geo feature group" % obj.Label) return False - if hasattr(obj, "BitBody") and hasattr(obj, "BitShape"): + if hasattr(obj, "BitBody") and hasattr(obj, "ShapeName"): # ToolBit's are not valid base objects return False if obj.TypeId in NotValidBaseTypeIds: diff --git a/src/Mod/CAM/Path/Main/Gui/Job.py b/src/Mod/CAM/Path/Main/Gui/Job.py index 6d480cd4ad..7561afbee4 100644 --- a/src/Mod/CAM/Path/Main/Gui/Job.py +++ b/src/Mod/CAM/Path/Main/Gui/Job.py @@ -35,10 +35,9 @@ import Path.Main.Gui.JobCmd as PathJobCmd import Path.Main.Gui.JobDlg as PathJobDlg import Path.Main.Job as PathJob import Path.Main.Stock as PathStock -import Path.Tool.Gui.Bit as PathToolBitGui import Path.Tool.Gui.Controller as PathToolControllerGui import PathScripts.PathUtils as PathUtils -import json +from Path.Tool.toolbit.ui.selector import ToolBitSelector import math import traceback from PySide import QtWidgets @@ -1073,29 +1072,14 @@ class TaskPanel: self.toolControllerSelect() def toolControllerAdd(self): - # adding a TC from a toolbit directly. - # Try to find a tool number from the currently selected lib. Otherwise - # use next available number - - tools = PathToolBitGui.LoadTools() - - curLib = Path.Preferences.lastFileToolLibrary() - - library = None - if curLib is not None: - with open(curLib) as fp: - library = json.load(fp) - - for tool in tools: - toolNum = self.obj.Proxy.nextToolNumber() - if library is not None: - for toolBit in library["tools"]: - - if toolBit["path"] == tool.File: - toolNum = toolBit["nr"] - - tc = PathToolControllerGui.Create(name=tool.Label, tool=tool, toolNumber=toolNum) - self.obj.Proxy.addToolController(tc) + selector = ToolBitSelector(compact=True) + if not selector.exec_(): + return + toolbit = selector.get_selected_tool() + toolbit.attach_to_doc(FreeCAD.ActiveDocument) + toolNum = self.obj.Proxy.nextToolNumber() + tc = PathToolControllerGui.Create(name=toolbit.label, tool=toolbit.obj, toolNumber=toolNum) + self.obj.Proxy.addToolController(tc) FreeCAD.ActiveDocument.recompute() self.updateToolController() diff --git a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py index 6b4abdc7e5..2b98f123b3 100644 --- a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py +++ b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py @@ -67,7 +67,6 @@ class JobPreferencesPage: policy = str(self.form.cboOutputPolicy.currentText()) Path.Preferences.setOutputFileDefaults(path, policy) self.saveStockSettings() - self.saveToolsSettings() def saveStockSettings(self): if self.form.stockGroup.isChecked(): @@ -116,9 +115,6 @@ class JobPreferencesPage: else: Path.Preferences.setDefaultStockTemplate("") - def saveToolsSettings(self): - Path.Preferences.setToolsSettings(self.form.toolsAbsolutePaths.isChecked()) - def selectComboEntry(self, widget, text): index = widget.findText(text, QtCore.Qt.MatchFixedString) if index >= 0: @@ -189,7 +185,6 @@ class JobPreferencesPage: self.form.tbOutputFile.clicked.connect(self.browseOutputFile) self.loadStockSettings() - self.loadToolSettings() def loadStockSettings(self): stock = Path.Preferences.defaultStockTemplate() @@ -283,9 +278,6 @@ class JobPreferencesPage: self.form.stockCreateBox.hide() self.form.stockCreateCylinder.hide() - def loadToolSettings(self): - self.form.toolsAbsolutePaths.setChecked(Path.Preferences.toolsStoreAbsolutePaths()) - def getPostProcessor(self, name): if not name in self.processor: processor = PostProcessorFactory.get_post_processor(None, name) diff --git a/src/Mod/CAM/Path/Main/Sanity/Sanity.py b/src/Mod/CAM/Path/Main/Sanity/Sanity.py index 974ab774da..14aa2a0bea 100644 --- a/src/Mod/CAM/Path/Main/Sanity/Sanity.py +++ b/src/Mod/CAM/Path/Main/Sanity/Sanity.py @@ -368,8 +368,8 @@ class CAMSanity: ) continue # skip old-style tools tooldata = data.setdefault(str(TC.ToolNumber), {}) - bitshape = tooldata.setdefault("BitShape", "") - if bitshape not in ["", TC.Tool.BitShape]: + bitshape = tooldata.setdefault("ShapeType", "") + if bitshape not in ["", TC.Tool.ShapeType]: data["squawkData"].append( self.squawk( "CAMSanity", @@ -379,18 +379,18 @@ class CAMSanity: squawkType="CAUTION", ) ) - tooldata["bitShape"] = TC.Tool.BitShape + tooldata["bitShape"] = TC.Tool.ShapeType tooldata["description"] = TC.Tool.Label tooldata["manufacturer"] = "" tooldata["url"] = "" tooldata["inspectionNotes"] = "" tooldata["diameter"] = str(TC.Tool.Diameter) - tooldata["shape"] = TC.Tool.ShapeName + tooldata["shape"] = TC.Tool.ShapeType tooldata["partNumber"] = "" - if os.path.isfile(TC.Tool.BitShape): - imagedata = TC.Tool.Proxy.getBitThumbnail(TC.Tool) + if os.path.isfile(TC.Tool.ShapeType): + imagedata = TC.Tool.Proxy.get_thumbnail() else: imagedata = None data["squawkData"].append( diff --git a/src/Mod/CAM/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py index 88dee7a4dc..326c9078e3 100644 --- a/src/Mod/CAM/Path/Preferences.py +++ b/src/Mod/CAM/Path/Preferences.py @@ -24,6 +24,10 @@ import FreeCAD import Path import glob import os +import pathlib +from collections import defaultdict +from typing import Optional + if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -31,8 +35,11 @@ if False: else: Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + translate = FreeCAD.Qt.translate +PreferencesGroup = "User parameter:BaseApp/Preferences/Mod/CAM" + DefaultFilePath = "DefaultFilePath" DefaultJobTemplate = "DefaultJobTemplate" DefaultStockTemplate = "DefaultStockTemplate" @@ -44,17 +51,9 @@ PostProcessorBlacklist = "PostProcessorBlacklist" PostProcessorOutputFile = "PostProcessorOutputFile" PostProcessorOutputPolicy = "PostProcessorOutputPolicy" -LastPathToolBit = "LastPathToolBit" -LastPathToolLibrary = "LastPathToolLibrary" -LastPathToolShape = "LastPathToolShape" -LastPathToolTable = "LastPathToolTable" - -LastFileToolBit = "LastFileToolBit" -LastFileToolLibrary = "LastFileToolLibrary" -LastFileToolShape = "LastFileToolShape" - -UseAbsoluteToolPaths = "UseAbsoluteToolPaths" -# OpenLastLibrary = "OpenLastLibrary" +ToolGroup = PreferencesGroup + "/Tools" +ToolPath = "ToolPath" +LastToolLibrary = "LastToolLibrary" # Linear tolerance to use when generating Paths, eg when tessellating geometry GeometryTolerance = "GeometryTolerance" @@ -69,18 +68,84 @@ EnableExperimentalFeatures = "EnableExperimentalFeatures" EnableAdvancedOCLFeatures = "EnableAdvancedOCLFeatures" +_observers = defaultdict(list) # maps group name to callback functions + + +def _add_group_observer(group, callback): + """Add an observer for any changes on the given parameter group""" + _observers[group].append(callback) + + +def _emit_change(group, *args): + for cb in _observers[group]: + cb(group, *args) + + def preferences(): - return FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/CAM") + return FreeCAD.ParamGet(PreferencesGroup) + + +def tool_preferences(): + return FreeCAD.ParamGet(ToolGroup) + + +def addToolPreferenceObserver(callback): + _add_group_observer(ToolGroup, callback) def pathPostSourcePath(): return os.path.join(FreeCAD.getHomePath(), "Mod/CAM/Path/Post/") -def pathDefaultToolsPath(sub=None): - if sub: - return os.path.join(FreeCAD.getHomePath(), "Mod/CAM/Tools/", sub) - return os.path.join(FreeCAD.getHomePath(), "Mod/CAM/Tools/") +def getBuiltinToolPath() -> pathlib.Path: + home = pathlib.Path(FreeCAD.getHomePath()) + return home / "Mod" / "CAM" / "Tools" + + +def getBuiltinLibraryPath() -> pathlib.Path: + return getBuiltinToolPath() / "Library" + + +def getBuiltinShapePath() -> pathlib.Path: + return getBuiltinToolPath() / "Shape" + + +def getBuiltinToolBitPath() -> pathlib.Path: + return getBuiltinToolPath() / "Bit" + + +def getDefaultAssetPath(): + config = pathlib.Path(FreeCAD.ConfigGet("UserConfigPath")) + return config / "Mod" / "CAM" / "Tools" + + +def getAssetPath() -> pathlib.Path: + pref = tool_preferences() + default = getDefaultAssetPath() + path = pref.GetString(ToolPath, str(default)) + return pathlib.Path(path or default) + + +def setAssetPath(path: pathlib.Path): + assert path.is_dir(), f"Cannot put a non-initialized asset directory into preferences: {path}" + pref = tool_preferences() + pref.SetString(ToolPath, str(path)) + _emit_change(ToolGroup, ToolPath, path) + + +def getToolBitPath() -> pathlib.Path: + return getAssetPath() / "Bit" + + +def getLastToolLibrary() -> Optional[str]: + pref = tool_preferences() + return pref.GetString(LastToolLibrary) or None + + +def setLastToolLibrary(name: str): + assert isinstance(name, str), f"Library name '{name}' is not a string" + pref = tool_preferences() + pref.SetString(LastToolLibrary, name) def allAvailablePostProcessors(): @@ -161,26 +226,6 @@ def searchPathsPost(): return paths -def searchPathsTool(sub): - paths = [] - paths.append(os.path.join(FreeCAD.getHomePath(), "Mod", "CAM", "Tools", sub)) - return paths - - -def toolsStoreAbsolutePaths(): - return preferences().GetBool(UseAbsoluteToolPaths, False) - - -# def toolsOpenLastLibrary(): -# return preferences().GetBool(OpenLastLibrary, False) - - -def setToolsSettings(relative): - pref = preferences() - pref.SetBool(UseAbsoluteToolPaths, relative) - # pref.SetBool(OpenLastLibrary, lastlibrary) - - def defaultJobTemplate(): template = preferences().GetString(DefaultJobTemplate) if "xml" not in template: @@ -284,65 +329,3 @@ def setPreferencesAdvanced(ocl, warnSpeeds, warnRapids, warnModes, warnOCL, warn preferences().SetBool(WarningSuppressSelectionMode, warnModes) preferences().SetBool(WarningSuppressOpenCamLib, warnOCL) preferences().SetBool(WarningSuppressVelocity, warnVelocity) - - -def lastFileToolLibrary(): - filename = preferences().GetString(LastFileToolLibrary) - if filename.endswith(".fctl") and os.path.isfile(filename): - return filename - - libpath = preferences().GetString(LastPathToolLibrary, pathDefaultToolsPath("Library")) - libFiles = [f for f in glob.glob(libpath + "/*.fctl")] - libFiles.sort() - if len(libFiles) >= 1: - filename = libFiles[0] - setLastFileToolLibrary(filename) - Path.Log.track(filename) - return filename - else: - return None - - -def setLastFileToolLibrary(path): - Path.Log.track(path) - if os.path.isfile(path): # keep the path and file in sync - preferences().SetString(LastPathToolLibrary, os.path.split(path)[0]) - return preferences().SetString(LastFileToolLibrary, path) - - -def lastPathToolBit(): - return preferences().GetString(LastPathToolBit, pathDefaultToolsPath("Bit")) - - -def setLastPathToolBit(path): - return preferences().SetString(LastPathToolBit, path) - - -def lastPathToolLibrary(): - Path.Log.track() - return preferences().GetString(LastPathToolLibrary, pathDefaultToolsPath("Library")) - - -def setLastPathToolLibrary(path): - Path.Log.track(path) - curLib = lastFileToolLibrary() - Path.Log.debug("curLib: {}".format(curLib)) - if curLib and os.path.split(curLib)[0] != path: - setLastFileToolLibrary("") # a path is known but not specific file - return preferences().SetString(LastPathToolLibrary, path) - - -def lastPathToolShape(): - return preferences().GetString(LastPathToolShape, pathDefaultToolsPath("Shape")) - - -def setLastPathToolShape(path): - return preferences().SetString(LastPathToolShape, path) - - -def lastPathToolTable(): - return preferences().GetString(LastPathToolTable, "") - - -def setLastPathToolTable(table): - return preferences().SetString(LastPathToolTable, table) diff --git a/src/Mod/CAM/Path/Tool/Bit.py b/src/Mod/CAM/Path/Tool/Bit.py deleted file mode 100644 index b2b2905128..0000000000 --- a/src/Mod/CAM/Path/Tool/Bit.py +++ /dev/null @@ -1,500 +0,0 @@ -# -*- coding: utf-8 -*- -# *************************************************************************** -# * Copyright (c) 2019 sliptonic * -# * * -# * 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. * -# * * -# * This program 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 Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -import FreeCAD -import Path -import Path.Base.Util as PathUtil -import Path.Base.PropertyBag as PathPropertyBag -import json -import os -import zipfile -from PySide.QtCore import QT_TRANSLATE_NOOP - -# lazily loaded modules -from lazy_loader.lazy_loader import LazyLoader - -Part = LazyLoader("Part", globals(), "Part") - -__title__ = "Tool bits." -__author__ = "sliptonic (Brad Collette)" -__url__ = "https://www.freecad.org" -__doc__ = "Class to deal with and represent a tool bit." - -PropertyGroupShape = "Shape" - -_DebugFindTool = False - - -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 _findToolFile(name, containerFile, typ): - Path.Log.track(name) - if os.path.exists(name): # absolute reference - return name - - if containerFile: - rootPath = os.path.dirname(os.path.dirname(containerFile)) - paths = [os.path.join(rootPath, typ)] - else: - paths = [] - paths.extend(Path.Preferences.searchPathsTool(typ)) - - def _findFile(path, name): - Path.Log.track(path, name) - fullPath = os.path.join(path, name) - if os.path.exists(fullPath): - return (True, fullPath) - for root, ds, fs in os.walk(path): - for d in ds: - found, fullPath = _findFile(d, name) - if found: - return (True, fullPath) - return (False, None) - - for p in paths: - found, path = _findFile(p, name) - if found: - return path - return None - - -def findToolShape(name, path=None): - """findToolShape(name, path) ... search for name, if relative path look in path""" - Path.Log.track(name, path) - return _findToolFile(name, path, "Shape") - - -def findToolBit(name, path=None): - """findToolBit(name, path) ... search for name, if relative path look in path""" - Path.Log.track(name, path) - if name.endswith(".fctb"): - return _findToolFile(name, path, "Bit") - return _findToolFile("{}.fctb".format(name), path, "Bit") - - -# Only used in ToolBit unit test module: TestPathToolBit.py -def findToolLibrary(name, path=None): - """findToolLibrary(name, path) ... search for name, if relative path look in path""" - Path.Log.track(name, path) - if name.endswith(".fctl"): - return _findToolFile(name, path, "Library") - return _findToolFile("{}.fctl".format(name), path, "Library") - - -def _findRelativePath(path, typ): - Path.Log.track(path, typ) - relative = path - for p in Path.Preferences.searchPathsTool(typ): - if path.startswith(p): - p = path[len(p) :] - if os.path.sep == p[0]: - p = p[1:] - if len(p) < len(relative): - relative = p - return relative - - -# Unused due to bug fix related to relative paths -""" -def findRelativePathShape(path): - return _findRelativePath(path, 'Shape') - - -def findRelativePathTool(path): - return _findRelativePath(path, 'Bit') -""" - - -def findRelativePathLibrary(path): - return _findRelativePath(path, "Library") - - -class ToolBit(object): - def __init__(self, obj, shapeFile, path=None): - Path.Log.track(obj.Label, shapeFile, path) - self.obj = obj - obj.addProperty( - "App::PropertyFile", - "BitShape", - "Base", - QT_TRANSLATE_NOOP("App::Property", "Shape for bit shape"), - ) - obj.addProperty( - "App::PropertyLink", - "BitBody", - "Base", - QT_TRANSLATE_NOOP("App::Property", "The parametrized body representing the tool bit"), - ) - obj.addProperty( - "App::PropertyFile", - "File", - "Base", - QT_TRANSLATE_NOOP("App::Property", "The file of the tool"), - ) - obj.addProperty( - "App::PropertyString", - "ShapeName", - "Base", - QT_TRANSLATE_NOOP("App::Property", "The name of the shape file"), - ) - obj.addProperty( - "App::PropertyStringList", - "BitPropertyNames", - "Base", - QT_TRANSLATE_NOOP("App::Property", "List of all properties inherited from the bit"), - ) - - if path: - obj.File = path - if shapeFile is None: - obj.BitShape = "endmill.fcstd" - self._setupBitShape(obj) - self.unloadBitBody(obj) - else: - obj.BitShape = shapeFile - self._setupBitShape(obj) - self.onDocumentRestored(obj) - - def dumps(self): - return None - - def loads(self, state): - for obj in FreeCAD.ActiveDocument.Objects: - if hasattr(obj, "Proxy") and obj.Proxy == self: - self.obj = obj - break - return None - - def onDocumentRestored(self, obj): - # when files are shared it is essential to be able to change/set the shape file, - # otherwise the file is hard to use - # obj.setEditorMode('BitShape', 1) - obj.setEditorMode("BitBody", 2) - obj.setEditorMode("File", 1) - obj.setEditorMode("Shape", 2) - if not hasattr(obj, "BitPropertyNames"): - obj.addProperty( - "App::PropertyStringList", - "BitPropertyNames", - "Base", - QT_TRANSLATE_NOOP("App::Property", "List of all properties inherited from the bit"), - ) - propNames = [] - for prop in obj.PropertiesList: - if obj.getGroupOfProperty(prop) == "Bit": - val = obj.getPropertyByName(prop) - typ = obj.getTypeIdOfProperty(prop) - dsc = obj.getDocumentationOfProperty(prop) - - obj.removeProperty(prop) - obj.addProperty(typ, prop, PropertyGroupShape, dsc) - - PathUtil.setProperty(obj, prop, val) - propNames.append(prop) - elif obj.getGroupOfProperty(prop) == "Attribute": - propNames.append(prop) - obj.BitPropertyNames = propNames - obj.setEditorMode("BitPropertyNames", 2) - - for prop in obj.BitPropertyNames: - if obj.getGroupOfProperty(prop) == PropertyGroupShape: - # properties in the Shape group can only be modified while the actual - # shape is loaded, so we have to disable direct property editing - obj.setEditorMode(prop, 1) - else: - # all other custom properties can and should be edited directly in the - # property editor widget, not much value in re-implementing that - obj.setEditorMode(prop, 0) - - def onChanged(self, obj, prop): - Path.Log.track(obj.Label, prop) - if prop == "BitShape" and "Restore" not in obj.State: - self._setupBitShape(obj) - - def onDelete(self, obj, arg2=None): - Path.Log.track(obj.Label) - self.unloadBitBody(obj) - obj.Document.removeObject(obj.Name) - - def _updateBitShape(self, obj, properties=None): - if obj.BitBody is not None: - for attributes in [ - o - for o in obj.BitBody.Group - if hasattr(o, "Proxy") and hasattr(o.Proxy, "getCustomProperties") - ]: - for prop in attributes.Proxy.getCustomProperties(): - # the property might not exist in our local object (new attribute in shape) - # for such attributes we just keep the default - if hasattr(obj, prop): - setattr(attributes, prop, obj.getPropertyByName(prop)) - else: - # if the template shape has a new attribute defined we should add that - # to the local object - self._setupProperty(obj, prop, attributes) - propNames = obj.BitPropertyNames - propNames.append(prop) - obj.BitPropertyNames = propNames - self._copyBitShape(obj) - - def _copyBitShape(self, obj): - obj.Document.recompute() - if obj.BitBody and obj.BitBody.Shape: - obj.Shape = obj.BitBody.Shape - else: - obj.Shape = Part.Shape() - - def _loadBitBody(self, obj, path=None): - Path.Log.track(obj.Label, path) - p = path if path else obj.BitShape - docOpened = False - doc = None - for d in FreeCAD.listDocuments(): - if FreeCAD.getDocument(d).FileName == p: - doc = FreeCAD.getDocument(d) - break - if doc is None: - p = findToolShape(p, path if path else obj.File) - if p is None: - raise FileNotFoundError - - if not path and p != obj.BitShape: - obj.BitShape = p - Path.Log.debug("ToolBit {} using shape file: {}".format(obj.Label, p)) - doc = FreeCAD.openDocument(p, True) - obj.ShapeName = doc.Name - docOpened = True - else: - Path.Log.debug("ToolBit {} already open: {}".format(obj.Label, doc)) - return (doc, docOpened) - - def _removeBitBody(self, obj): - if obj.BitBody: - obj.BitBody.removeObjectsFromDocument() - obj.Document.removeObject(obj.BitBody.Name) - obj.BitBody = None - - def _deleteBitSetup(self, obj): - Path.Log.track(obj.Label) - self._removeBitBody(obj) - self._copyBitShape(obj) - for prop in obj.BitPropertyNames: - obj.removeProperty(prop) - - def loadBitBody(self, obj, force=False): - if force or not obj.BitBody: - activeDoc = FreeCAD.ActiveDocument - if force: - self._removeBitBody(obj) - (doc, opened) = self._loadBitBody(obj) - obj.BitBody = obj.Document.copyObject(doc.RootObjects[0], True) - if opened: - FreeCAD.setActiveDocument(activeDoc.Name) - FreeCAD.closeDocument(doc.Name) - self._updateBitShape(obj) - - def unloadBitBody(self, obj): - self._removeBitBody(obj) - - def _setupProperty(self, obj, prop, orig): - # extract property parameters and values so it can be copied - val = orig.getPropertyByName(prop) - typ = orig.getTypeIdOfProperty(prop) - grp = orig.getGroupOfProperty(prop) - dsc = orig.getDocumentationOfProperty(prop) - - obj.addProperty(typ, prop, grp, dsc) - if "App::PropertyEnumeration" == typ: - setattr(obj, prop, orig.getEnumerationsOfProperty(prop)) - - obj.setEditorMode(prop, 1) - PathUtil.setProperty(obj, prop, val) - - def _setupBitShape(self, obj, path=None): - Path.Log.track(obj.Label) - - activeDoc = FreeCAD.ActiveDocument - try: - (doc, docOpened) = self._loadBitBody(obj, path) - except FileNotFoundError: - Path.Log.error( - "Could not find shape file {} for tool bit {}".format(obj.BitShape, obj.Label) - ) - return - - obj.Label = doc.RootObjects[0].Label - self._deleteBitSetup(obj) - bitBody = obj.Document.copyObject(doc.RootObjects[0], True) - - docName = doc.Name - if docOpened: - FreeCAD.setActiveDocument(activeDoc.Name) - FreeCAD.closeDocument(doc.Name) - - if bitBody.ViewObject: - bitBody.ViewObject.Visibility = False - - Path.Log.debug("bitBody.{} ({}): {}".format(bitBody.Label, bitBody.Name, type(bitBody))) - - propNames = [] - for attributes in [o for o in bitBody.Group if PathPropertyBag.IsPropertyBag(o)]: - Path.Log.debug("Process properties from {}".format(attributes.Label)) - for prop in attributes.Proxy.getCustomProperties(): - self._setupProperty(obj, prop, attributes) - propNames.append(prop) - if not propNames: - Path.Log.error( - "Did not find a PropertyBag in {} - not a ToolBit shape?".format(docName) - ) - - # has to happen last because it could trigger op.execute evaluations - obj.BitPropertyNames = propNames - obj.BitBody = bitBody - self._copyBitShape(obj) - - def toolShapeProperties(self, obj): - """toolShapeProperties(obj) ... return all properties defining it's shape""" - return sorted( - [ - prop - for prop in obj.BitPropertyNames - if obj.getGroupOfProperty(prop) == PropertyGroupShape - ] - ) - - def toolAdditionalProperties(self, obj): - """toolShapeProperties(obj) ... return all properties unrelated to it's shape""" - return sorted( - [ - prop - for prop in obj.BitPropertyNames - if obj.getGroupOfProperty(prop) != PropertyGroupShape - ] - ) - - def toolGroupsAndProperties(self, obj, includeShape=True): - """toolGroupsAndProperties(obj) ... returns a dictionary of group names with a list of property names.""" - category = {} - for prop in obj.BitPropertyNames: - group = obj.getGroupOfProperty(prop) - if includeShape or group != PropertyGroupShape: - properties = category.get(group, []) - properties.append(prop) - category[group] = properties - return category - - def getBitThumbnail(self, obj): - if obj.BitShape: - path = findToolShape(obj.BitShape) - if path: - with open(path, "rb") as fd: - try: - zf = zipfile.ZipFile(fd) - pf = zf.open("thumbnails/Thumbnail.png", "r") - data = pf.read() - pf.close() - return data - except KeyError: - pass - return None - - def saveToFile(self, obj, path, setFile=True): - Path.Log.track(path) - try: - with open(path, "w") as fp: - json.dump(self.templateAttrs(obj), fp, indent=" ") - if setFile: - obj.File = path - return True - except (OSError, IOError) as e: - Path.Log.error("Could not save tool {} to {} ({})".format(obj.Label, path, e)) - raise - - def templateAttrs(self, obj): - attrs = {} - attrs["version"] = 2 - attrs["name"] = obj.Label - if Path.Preferences.toolsStoreAbsolutePaths(): - attrs["shape"] = obj.BitShape - else: - # attrs['shape'] = findRelativePathShape(obj.BitShape) - # Extract the name of the shape file - __, filShp = os.path.split( - obj.BitShape - ) # __ is an ignored placeholder acknowledged by LGTM - attrs["shape"] = str(filShp) - params = {} - for name in obj.BitPropertyNames: - params[name] = PathUtil.getPropertyValueString(obj, name) - attrs["parameter"] = params - params = {} - attrs["attribute"] = params - return attrs - - -def Declaration(path): - Path.Log.track(path) - with open(path, "r") as fp: - return json.load(fp) - - -class ToolBitFactory(object): - def CreateFromAttrs(self, attrs, name="ToolBit", path=None): - Path.Log.track(attrs, path) - obj = Factory.Create(name, attrs["shape"], path) - obj.Label = attrs["name"] - params = attrs["parameter"] - for prop in params: - PathUtil.setProperty(obj, prop, params[prop]) - attributes = attrs["attribute"] - for att in attributes: - PathUtil.setProperty(obj, att, attributes[att]) - obj.Proxy._updateBitShape(obj) - obj.Proxy.unloadBitBody(obj) - return obj - - def CreateFrom(self, path, name="ToolBit"): - Path.Log.track(name, path) - - if not os.path.isfile(path): - raise FileNotFoundError(f"{path} not found") - try: - data = Declaration(path) - bit = Factory.CreateFromAttrs(data, name, path) - return bit - except (OSError, IOError) as e: - Path.Log.error("%s not a valid tool file (%s)" % (path, e)) - raise - - def Create(self, name="ToolBit", shapeFile=None, path=None): - Path.Log.track(name, shapeFile, path) - obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", name) - obj.Proxy = ToolBit(obj, shapeFile, path) - return obj - - -Factory = ToolBitFactory() diff --git a/src/Mod/CAM/Path/Tool/Controller.py b/src/Mod/CAM/Path/Tool/Controller.py index 8b5e4b3fb5..ebc94ccf82 100644 --- a/src/Mod/CAM/Path/Tool/Controller.py +++ b/src/Mod/CAM/Path/Tool/Controller.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2015 Dan Falck * +# * 2025 Samuel Abels * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -25,7 +26,7 @@ from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCAD import Path -import Path.Tool.Bit as PathToolBit +from Path.Tool.toolbit import ToolBit import Path.Base.Generator.toolchange as toolchange @@ -113,7 +114,7 @@ class ToolController: self.ensureToolBit(obj) @classmethod - def propertyEnumerations(self, dataType="data"): + def propertyEnumerations(cls, dataType="data"): """helixOpPropertyEnumerations(dataType="data")... return property enumeration lists of specified dataType. Args: dataType = 'data', 'raw', 'translated' @@ -182,13 +183,11 @@ class ToolController: obj.ToolNumber = int(template.get(ToolControllerTemplate.ToolNumber)) if template.get(ToolControllerTemplate.Tool): self.ensureToolBit(obj) - toolVersion = template.get(ToolControllerTemplate.Tool).get( - ToolControllerTemplate.Version - ) + tool_data = template.get(ToolControllerTemplate.Tool) + toolVersion = tool_data.get(ToolControllerTemplate.Version) if toolVersion == 2: - obj.Tool = PathToolBit.Factory.CreateFromAttrs( - template.get(ToolControllerTemplate.Tool) - ) + toolbit_instance = ToolBit.from_dict(tool_data) + obj.Tool = toolbit_instance.attach_to_doc(doc=obj.Document) else: obj.Tool = None if toolVersion == 1: @@ -230,7 +229,7 @@ class ToolController: attrs[ToolControllerTemplate.HorizRapid] = "%s" % (obj.HorizRapid) attrs[ToolControllerTemplate.SpindleSpeed] = obj.SpindleSpeed attrs[ToolControllerTemplate.SpindleDir] = obj.SpindleDir - attrs[ToolControllerTemplate.Tool] = obj.Tool.Proxy.templateAttrs(obj.Tool) + attrs[ToolControllerTemplate.Tool] = obj.Tool.Proxy.to_dict() expressions = [] for expr in obj.ExpressionEngine: Path.Log.debug("%s: %s" % (expr[0], expr[1])) @@ -251,26 +250,9 @@ class ToolController: "toolnumber": obj.ToolNumber, "toollabel": obj.Label, "spindlespeed": obj.SpindleSpeed, - "spindledirection": toolchange.SpindleDirection.OFF, + "spindledirection": obj.Tool.Proxy.get_spindle_direction(), } - if hasattr(obj.Tool, "SpindlePower"): - if not obj.Tool.SpindlePower: - args["spindledirection"] = toolchange.SpindleDirection.OFF - else: - if obj.SpindleDir == "Forward": - args["spindledirection"] = toolchange.SpindleDirection.CW - else: - args["spindledirection"] = toolchange.SpindleDirection.CCW - - elif obj.SpindleDir == "None": - args["spindledirection"] = toolchange.SpindleDirection.OFF - else: - if obj.SpindleDir == "Forward": - args["spindledirection"] = toolchange.SpindleDirection.CW - else: - args["spindledirection"] = toolchange.SpindleDirection.CCW - commands = toolchange.generate(**args) path = Path.Path(commands) @@ -314,7 +296,10 @@ def Create( if assignTool: if not tool: - tool = PathToolBit.Factory.Create() + # Create a default endmill tool bit and attach it to a new DocumentObject + toolbit = ToolBit.from_shape_id("endmill.fcstd") + Path.Log.info(f"Controller.Create: Created toolbit with ID: {toolbit.id}") + tool = toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument) if tool.ViewObject: tool.ViewObject.Visibility = False obj.Tool = tool diff --git a/src/Mod/CAM/Path/Tool/Gui/Bit.py b/src/Mod/CAM/Path/Tool/Gui/Bit.py deleted file mode 100644 index 423e6f9c66..0000000000 --- a/src/Mod/CAM/Path/Tool/Gui/Bit.py +++ /dev/null @@ -1,270 +0,0 @@ -# -*- coding: utf-8 -*- -# *************************************************************************** -# * Copyright (c) 2019 sliptonic * -# * * -# * 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. * -# * * -# * This program 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 Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -from PySide import QtCore, QtGui -from PySide.QtCore import QT_TRANSLATE_NOOP -import FreeCAD -import FreeCADGui -import Path -import Path.Base.Gui.IconViewProvider as PathIconViewProvider -import Path.Tool.Bit as PathToolBit -import Path.Tool.Gui.BitEdit as PathToolBitEdit -import os - -__title__ = "Tool Bit UI" -__author__ = "sliptonic (Brad Collette)" -__url__ = "https://www.freecad.org" -__doc__ = "Task panel editor for a ToolBit" - - -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()) - -translate = FreeCAD.Qt.translate - - -class ViewProvider(object): - """ViewProvider for a ToolBit. - It's sole job is to provide an icon and invoke the TaskPanel on edit.""" - - def __init__(self, vobj, name): - Path.Log.track(name, vobj.Object) - self.panel = None - self.icon = name - self.obj = vobj.Object - self.vobj = vobj - vobj.Proxy = self - - def attach(self, vobj): - Path.Log.track(vobj.Object) - self.vobj = vobj - self.obj = vobj.Object - - def getIcon(self): - png = self.obj.Proxy.getBitThumbnail(self.obj) - if png: - pixmap = QtGui.QPixmap() - pixmap.loadFromData(png, "PNG") - return QtGui.QIcon(pixmap) - return ":/icons/CAM_ToolBit.svg" - - def dumps(self): - return None - - def loads(self, state): - return None - - def onDelete(self, vobj, arg2=None): - Path.Log.track(vobj.Object.Label) - vobj.Object.Proxy.onDelete(vobj.Object) - - def getDisplayMode(self, mode): - return "Default" - - def _openTaskPanel(self, vobj, deleteOnReject): - Path.Log.track() - self.panel = TaskPanel(vobj, deleteOnReject) - FreeCADGui.Control.closeDialog() - FreeCADGui.Control.showDialog(self.panel) - self.panel.setupUi() - - def setCreate(self, vobj): - Path.Log.track() - self._openTaskPanel(vobj, True) - - def setEdit(self, vobj, mode=0): - self._openTaskPanel(vobj, False) - return True - - def unsetEdit(self, vobj, mode): - FreeCADGui.Control.closeDialog() - self.panel = None - return - - def claimChildren(self): - if self.obj.BitBody: - return [self.obj.BitBody] - return [] - - def doubleClicked(self, vobj): - if os.path.exists(vobj.Object.BitShape): - self.setEdit(vobj) - else: - msg = translate("CAM_Toolbit", "Toolbit cannot be edited: Shapefile not found") - diag = QtGui.QMessageBox(QtGui.QMessageBox.Warning, "Error", msg) - diag.setWindowModality(QtCore.Qt.ApplicationModal) - diag.exec_() - - -class TaskPanel: - """TaskPanel for the SetupSheet - if it is being edited directly.""" - - def __init__(self, vobj, deleteOnReject): - Path.Log.track(vobj.Object.Label) - self.vobj = vobj - self.obj = vobj.Object - self.editor = PathToolBitEdit.ToolBitEditor(self.obj) - self.form = self.editor.form - self.deleteOnReject = deleteOnReject - FreeCAD.ActiveDocument.openTransaction("Edit ToolBit") - - def reject(self): - FreeCAD.ActiveDocument.abortTransaction() - self.editor.reject() - FreeCADGui.Control.closeDialog() - if self.deleteOnReject: - FreeCAD.ActiveDocument.openTransaction("Uncreate ToolBit") - self.editor.reject() - FreeCAD.ActiveDocument.removeObject(self.obj.Name) - FreeCAD.ActiveDocument.commitTransaction() - FreeCAD.ActiveDocument.recompute() - - def accept(self): - self.editor.accept() - - FreeCAD.ActiveDocument.commitTransaction() - FreeCADGui.ActiveDocument.resetEdit() - FreeCADGui.Control.closeDialog() - FreeCAD.ActiveDocument.recompute() - - def updateUI(self): - Path.Log.track() - self.editor.updateUI() - - def updateModel(self): - self.editor.updateTool() - FreeCAD.ActiveDocument.recompute() - - def setupUi(self): - self.editor.setupUI() - - -class ToolBitGuiFactory(PathToolBit.ToolBitFactory): - def Create(self, name="ToolBit", shapeFile=None, path=None): - """Create(name = 'ToolBit') ... creates a new tool bit. - It is assumed the tool will be edited immediately so the internal bit body is still attached. - """ - - Path.Log.track(name, shapeFile, path) - FreeCAD.ActiveDocument.openTransaction("Create ToolBit") - tool = PathToolBit.ToolBitFactory.Create(self, name, shapeFile, path) - PathIconViewProvider.Attach(tool.ViewObject, name) - FreeCAD.ActiveDocument.commitTransaction() - return tool - - -def isValidFileName(filename): - print(filename) - try: - with open(filename, "w") as tempfile: - return True - except Exception: - return False - - -def GetNewToolFile(parent=None): - if parent is None: - parent = QtGui.QApplication.activeWindow() - - foo = QtGui.QFileDialog.getSaveFileName( - parent, translate("CAM_Toolbit", "Tool"), Path.Preferences.lastPathToolBit(), "*.fctb" - ) - if foo and foo[0]: - if not isValidFileName(foo[0]): - msgBox = QtGui.QMessageBox() - msg = translate("CAM_Toolbit", "Invalid Filename") - msgBox.setText(msg) - msgBox.exec_() - else: - Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0])) - return foo[0] - return None - - -def GetToolFile(parent=None): - if parent is None: - parent = QtGui.QApplication.activeWindow() - foo = QtGui.QFileDialog.getOpenFileName( - parent, "Tool", Path.Preferences.lastPathToolBit(), "*.fctb" - ) - if foo and foo[0]: - Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0])) - return foo[0] - return None - - -def GetToolFiles(parent=None): - if parent is None: - parent = QtGui.QApplication.activeWindow() - foo = QtGui.QFileDialog.getOpenFileNames( - parent, "Tool", Path.Preferences.lastPathToolBit(), "*.fctb" - ) - if foo and foo[0]: - Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0][0])) - return foo[0] - return [] - - -def GetToolShapeFile(parent=None): - if parent is None: - parent = QtGui.QApplication.activeWindow() - - location = Path.Preferences.lastPathToolShape() - if os.path.isfile(location): - location = os.path.split(location)[0] - elif not os.path.isdir(location): - location = Path.Preferences.filePath() - - fname = QtGui.QFileDialog.getOpenFileName( - parent, translate("CAM_Toolbit", "Select Tool Shape"), location, "*.fcstd" - ) - if fname and fname[0]: - if fname != location: - newloc = os.path.dirname(fname[0]) - Path.Preferences.setLastPathToolShape(newloc) - return fname[0] - else: - return None - - -def LoadTool(parent=None): - """ - LoadTool(parent=None) ... Open a file dialog to load a tool from a file. - """ - foo = GetToolFile(parent) - return PathToolBit.Factory.CreateFrom(foo) if foo else foo - - -def LoadTools(parent=None): - """ - LoadTool(parent=None) ... Open a file dialog to load a tool from a file. - """ - return [PathToolBit.Factory.CreateFrom(foo) for foo in GetToolFiles(parent)] - - -# Set the factory so all tools are created with UI -PathToolBit.Factory = ToolBitGuiFactory() - -PathIconViewProvider.RegisterViewProvider("ToolBit", ViewProvider) diff --git a/src/Mod/CAM/Path/Tool/Gui/BitEdit.py b/src/Mod/CAM/Path/Tool/Gui/BitEdit.py deleted file mode 100644 index c23af9e196..0000000000 --- a/src/Mod/CAM/Path/Tool/Gui/BitEdit.py +++ /dev/null @@ -1,275 +0,0 @@ -# -*- coding: utf-8 -*- -# *************************************************************************** -# * Copyright (c) 2019 sliptonic * -# * * -# * 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. * -# * * -# * This program 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 Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - -from PySide import QtCore, QtGui -import FreeCADGui -import Path -import Path.Base.Gui.PropertyEditor as PathPropertyEditor -import Path.Base.Gui.Util as PathGuiUtil -import Path.Base.Util as PathUtil -import os -import re - - -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 _Delegate(QtGui.QStyledItemDelegate): - """Handles the creation of an appropriate editing widget for a given property.""" - - ObjectRole = QtCore.Qt.UserRole + 1 - PropertyRole = QtCore.Qt.UserRole + 2 - EditorRole = QtCore.Qt.UserRole + 3 - - def createEditor(self, parent, option, index): - editor = index.data(self.EditorRole) - if editor is None: - obj = index.data(self.ObjectRole) - prp = index.data(self.PropertyRole) - editor = PathPropertyEditor.Editor(obj, prp) - index.model().setData(index, editor, self.EditorRole) - return editor.widget(parent) - - def setEditorData(self, widget, index): - # called to update the widget with the current data - index.data(self.EditorRole).setEditorData(widget) - - def setModelData(self, widget, model, index): - # called to update the model with the data from the widget - editor = index.data(self.EditorRole) - editor.setModelData(widget) - index.model().setData( - index, - PathUtil.getPropertyValueString(editor.obj, editor.prop), - QtCore.Qt.DisplayRole, - ) - - -class ToolBitEditor(object): - """UI and controller for editing a ToolBit. - The controller embeds the UI to the parentWidget which has to have a - layout attached to it. - """ - - def __init__(self, tool, parentWidget=None, loadBitBody=True): - Path.Log.track() - self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui") - - if parentWidget: - self.form.setParent(parentWidget) - parentWidget.layout().addWidget(self.form) - - self.tool = tool - self.loadbitbody = loadBitBody - if not tool.BitShape: - self.tool.BitShape = "endmill.fcstd" - - if self.loadbitbody: - self.tool.Proxy.loadBitBody(self.tool) - - # remove example widgets - layout = self.form.bitParams.layout() - for i in range(layout.rowCount() - 1, -1, -1): - layout.removeRow(i) - # used to track property widgets and editors - self.widgets = [] - - self.setupTool(self.tool) - self.setupAttributes(self.tool) - - def setupTool(self, tool): - Path.Log.track() - # Can't delete and add fields to the form because of dangling references in case of - # a focus change. see https://forum.freecad.org/viewtopic.php?f=10&t=52246#p458583 - # Instead we keep widgets once created and use them for new properties, and hide all - # which aren't being needed anymore. - - def labelText(name): - return re.sub(r"([A-Z][a-z]+)", r" \1", re.sub(r"([A-Z]+)", r" \1", name)) - - layout = self.form.bitParams.layout() - ui = FreeCADGui.UiLoader() - - # for all properties either assign them to existing labels and editors - # or create additional ones for them if not enough have already been - # created. - usedRows = 0 - for nr, name in enumerate(tool.Proxy.toolShapeProperties(tool)): - if nr < len(self.widgets): - Path.Log.debug("reuse row: {} [{}]".format(nr, name)) - label, qsb, editor = self.widgets[nr] - label.setText(labelText(name)) - editor.attachTo(tool, name) - label.show() - qsb.show() - else: - qsb = ui.createWidget("Gui::QuantitySpinBox") - editor = PathGuiUtil.QuantitySpinBox(qsb, tool, name) - label = QtGui.QLabel(labelText(name)) - self.widgets.append((label, qsb, editor)) - Path.Log.debug("create row: {} [{}] {}".format(nr, name, type(qsb))) - if hasattr(qsb, "editingFinished"): - qsb.editingFinished.connect(self.updateTool) - - if nr >= layout.rowCount(): - layout.addRow(label, qsb) - usedRows = usedRows + 1 - - # hide all rows which aren't being used - Path.Log.track(usedRows, len(self.widgets)) - for i in range(usedRows, len(self.widgets)): - label, qsb, editor = self.widgets[i] - label.hide() - qsb.hide() - editor.attachTo(None) - Path.Log.debug(" hide row: {}".format(i)) - - img = tool.Proxy.getBitThumbnail(tool) - if img: - self.form.image.setPixmap(QtGui.QPixmap(QtGui.QImage.fromData(img))) - else: - self.form.image.setPixmap(QtGui.QPixmap()) - - def setupAttributes(self, tool): - Path.Log.track() - - setup = True - if not hasattr(self, "delegate"): - self.delegate = _Delegate(self.form.attrTree) - self.model = QtGui.QStandardItemModel(self.form.attrTree) - self.model.setHorizontalHeaderLabels(["Property", "Value"]) - else: - self.model.removeRows(0, self.model.rowCount()) - setup = False - - attributes = tool.Proxy.toolGroupsAndProperties(tool, False) - for name in attributes: - group = QtGui.QStandardItem() - group.setData(name, QtCore.Qt.EditRole) - group.setEditable(False) - for prop in attributes[name]: - label = QtGui.QStandardItem() - label.setData(prop, QtCore.Qt.EditRole) - label.setEditable(False) - - value = QtGui.QStandardItem() - value.setData(PathUtil.getPropertyValueString(tool, prop), QtCore.Qt.DisplayRole) - value.setData(tool, _Delegate.ObjectRole) - value.setData(prop, _Delegate.PropertyRole) - - group.appendRow([label, value]) - self.model.appendRow(group) - - if setup: - self.form.attrTree.setModel(self.model) - self.form.attrTree.setItemDelegateForColumn(1, self.delegate) - self.form.attrTree.expandAll() - self.form.attrTree.resizeColumnToContents(0) - self.form.attrTree.resizeColumnToContents(1) - # self.form.attrTree.collapseAll() - - def accept(self): - Path.Log.track() - self.refresh() - self.tool.Proxy.unloadBitBody(self.tool) - - def reject(self): - Path.Log.track() - self.tool.Proxy.unloadBitBody(self.tool) - - def updateUI(self): - Path.Log.track() - self.form.toolName.setText(self.tool.Label) - self.form.shapePath.setText(self.tool.BitShape) - - for lbl, qsb, editor in self.widgets: - editor.updateSpinBox() - - def _updateBitShape(self, shapePath): - # Only need to go through this exercise if the shape actually changed. - if self.tool.BitShape != shapePath: - # Before setting a new bitshape we need to make sure that none of - # editors fires an event and tries to access its old property, which - # might not exist anymore. - for lbl, qsb, editor in self.widgets: - editor.attachTo(self.tool, "File") - self.tool.BitShape = shapePath - self.setupTool(self.tool) - self.form.toolName.setText(self.tool.Label) - if self.tool.BitBody and self.tool.BitBody.ViewObject: - if not self.tool.BitBody.ViewObject.Visibility: - self.tool.BitBody.ViewObject.Visibility = True - self.setupAttributes(self.tool) - return True - return False - - def updateShape(self): - Path.Log.track() - shapePath = str(self.form.shapePath.text()) - # Only need to go through this exercise if the shape actually changed. - if self._updateBitShape(shapePath): - for lbl, qsb, editor in self.widgets: - editor.updateSpinBox() - - def updateTool(self): - Path.Log.track() - - label = str(self.form.toolName.text()) - shape = str(self.form.shapePath.text()) - if self.tool.Label != label: - self.tool.Label = label - self._updateBitShape(shape) - - for lbl, qsb, editor in self.widgets: - editor.updateProperty() - - self.tool.Proxy._updateBitShape(self.tool) - - def refresh(self): - Path.Log.track() - self.form.blockSignals(True) - self.updateTool() - self.updateUI() - self.form.blockSignals(False) - - def selectShape(self): - Path.Log.track() - path = self.tool.BitShape - if not path: - path = Path.Preferences.lastPathToolShape() - foo = QtGui.QFileDialog.getOpenFileName(self.form, "Path - Tool Shape", path, "*.fcstd") - if foo and foo[0]: - Path.Preferences.setLastPathToolShape(os.path.dirname(foo[0])) - self.form.shapePath.setText(foo[0]) - self.updateShape() - - def setupUI(self): - Path.Log.track() - self.updateUI() - - self.form.toolName.editingFinished.connect(self.refresh) - self.form.shapePath.editingFinished.connect(self.updateShape) - self.form.shapeSet.clicked.connect(self.selectShape) diff --git a/src/Mod/CAM/Path/Tool/Gui/BitLibrary.py b/src/Mod/CAM/Path/Tool/Gui/BitLibrary.py deleted file mode 100644 index 8604a1575c..0000000000 --- a/src/Mod/CAM/Path/Tool/Gui/BitLibrary.py +++ /dev/null @@ -1,1045 +0,0 @@ -# -*- coding: utf-8 -*- -# *************************************************************************** -# * Copyright (c) 2019 sliptonic * -# * Copyright (c) 2020 Schildkroet * -# * * -# * 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. * -# * * -# * This program 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 Library General Public License for more details. * -# * * -# * You should have received a copy of the GNU Library General Public * -# * License along with this program; if not, write to the Free Software * -# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * -# * USA * -# * * -# *************************************************************************** - - -import FreeCAD -import FreeCADGui -import Path -import Path.Tool.Bit as PathToolBit -import Path.Tool.Gui.Bit as PathToolBitGui -import Path.Tool.Gui.BitEdit as PathToolBitEdit -import Path.Tool.Gui.Controller as PathToolControllerGui -import PathGui -import PathScripts.PathUtilsGui as PathUtilsGui -import PySide -import glob -import json -import os -import shutil -import uuid as UUID - -from functools import partial - -from PySide.QtGui import QStandardItem, QStandardItemModel, QPixmap -from PySide.QtCore import Qt - - -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()) - - -_UuidRole = PySide.QtCore.Qt.UserRole + 1 -_PathRole = PySide.QtCore.Qt.UserRole + 2 - - -translate = FreeCAD.Qt.translate - - -def checkWorkingDir(): - """Check the tool library directory writable and configure a new library if required""" - # users shouldn't use the example toolbits and libraries. - # working directory should be writable - Path.Log.track() - - workingdir = os.path.dirname(Path.Preferences.lastPathToolLibrary()) - defaultdir = os.path.dirname(Path.Preferences.pathDefaultToolsPath()) - - Path.Log.debug("workingdir: {} defaultdir: {}".format(workingdir, defaultdir)) - - dirOK = lambda: workingdir != defaultdir and (os.access(workingdir, os.W_OK)) - - if dirOK(): - return True - - qm = PySide.QtGui.QMessageBox - ret = qm.question( - None, - "", - translate("CAM_ToolBit", "Toolbit working directory not set up. Do that now?"), - qm.Yes | qm.No, - ) - - if ret == qm.No: - return False - - msg = translate("CAM_ToolBit", "Choose a writable location for your toolbits") - while not dirOK(): - workingdir = PySide.QtGui.QFileDialog.getExistingDirectory( - None, msg, Path.Preferences.filePath() - ) - - if workingdir[-8:] == os.path.sep + "Library": - workingdir = workingdir[:-8] # trim off trailing /Library if user chose it - - Path.Preferences.setLastPathToolLibrary("{}{}Library".format(workingdir, os.path.sep)) - Path.Preferences.setLastPathToolBit("{}{}Bit".format(workingdir, os.path.sep)) - Path.Log.debug("setting workingdir to: {}".format(workingdir)) - - # Copy only files of default Path/Tool folder to working directory - # (targeting the README.md help file) - src_toolfiles = os.listdir(defaultdir) - for file_name in src_toolfiles: - if file_name in ["README.md"]: - full_file_name = os.path.join(defaultdir, file_name) - if os.path.isfile(full_file_name): - shutil.copy(full_file_name, workingdir) - - # Determine which subdirectories are missing - subdirlist = ["Bit", "Library", "Shape"] - mode = 0o777 - for dir in subdirlist.copy(): - subdir = "{}{}{}".format(workingdir, os.path.sep, dir) - if os.path.exists(subdir): - subdirlist.remove(dir) - - # Query user for creation permission of any missing subdirectories - if len(subdirlist) >= 1: - needed = ", ".join([str(d) for d in subdirlist]) - qm = PySide.QtGui.QMessageBox - ret = qm.question( - None, - "", - translate( - "CAM_ToolBit", - "Toolbit Working directory {} needs these sudirectories:\n {} \n Create them?", - ).format(workingdir, needed), - qm.Yes | qm.No, - ) - - if ret == qm.No: - return False - else: - # Create missing subdirectories if user agrees to creation - for dir in subdirlist: - subdir = "{}{}{}".format(workingdir, os.path.sep, dir) - os.mkdir(subdir, mode) - # Query user to copy example files into subdirectories created - if dir != "Shape": - qm = PySide.QtGui.QMessageBox - ret = qm.question( - None, - "", - translate("CAM_ToolBit", "Copy example files to new {} directory?").format( - dir - ), - qm.Yes | qm.No, - ) - if ret == qm.Yes: - src = "{}{}{}".format(defaultdir, os.path.sep, dir) - src_files = os.listdir(src) - for file_name in src_files: - full_file_name = os.path.join(src, file_name) - if os.path.isfile(full_file_name): - shutil.copy(full_file_name, subdir) - - # if no library is set, choose the first one in the Library directory - if Path.Preferences.lastFileToolLibrary() is None: - libFiles = [ - f for f in glob.glob(Path.Preferences.lastPathToolLibrary() + os.path.sep + "*.fctl") - ] - Path.Preferences.setLastFileToolLibrary(libFiles[0]) - - return True - - -class _TableView(PySide.QtGui.QTableView): - """Subclass of QTableView to support rearrange and copying of ToolBits""" - - def __init__(self, parent): - PySide.QtGui.QTableView.__init__(self, parent) - self.setDragEnabled(False) - self.setAcceptDrops(False) - self.setDropIndicatorShown(False) - self.setDragDropMode(PySide.QtGui.QAbstractItemView.DragOnly) - self.setDefaultDropAction(PySide.QtCore.Qt.IgnoreAction) - self.setSortingEnabled(True) - self.setSelectionBehavior(PySide.QtGui.QAbstractItemView.SelectRows) - self.verticalHeader().hide() - - def supportedDropActions(self): - return [PySide.QtCore.Qt.CopyAction, PySide.QtCore.Qt.MoveAction] - - def _uuidOfRow(self, row): - model = self.toolModel() - return model.data(model.index(row, 0), _UuidRole) - - def _rowWithUuid(self, uuid): - model = self.toolModel() - for row in range(model.rowCount()): - if self._uuidOfRow(row) == uuid: - return row - return None - - def _copyTool(self, uuid_, dstRow): - model = self.toolModel() - model.insertRow(dstRow) - srcRow = self._rowWithUuid(uuid_) - for col in range(model.columnCount()): - srcItem = model.item(srcRow, col) - - model.setData( - model.index(dstRow, col), - srcItem.data(PySide.QtCore.Qt.EditRole), - PySide.QtCore.Qt.EditRole, - ) - if col == 0: - model.setData(model.index(dstRow, col), srcItem.data(_PathRole), _PathRole) - # Even a clone of a tool gets its own uuid so it can be identified when - # rearranging the order or inserting/deleting rows - model.setData(model.index(dstRow, col), UUID.uuid4(), _UuidRole) - else: - model.item(dstRow, col).setEditable(False) - - def _copyTools(self, uuids, dst): - for i, uuid in enumerate(uuids): - self._copyTool(uuid, dst + i) - - def dropEvent(self, event): - """Handle drop events on the tool table""" - Path.Log.track() - mime = event.mimeData() - data = mime.data("application/x-qstandarditemmodeldatalist") - stream = PySide.QtCore.QDataStream(data) - srcRows = [] - while not stream.atEnd(): - row = stream.readInt32() - srcRows.append(row) - - # get the uuids of all srcRows - model = self.toolModel() - srcUuids = [self._uuidOfRow(row) for row in set(srcRows)] - destRow = self.rowAt(event.pos().y()) - - self._copyTools(srcUuids, destRow) - if PySide.QtCore.Qt.DropAction.MoveAction == event.proposedAction(): - for uuid in srcUuids: - model.removeRow(self._rowWithUuid(uuid)) - - -class ModelFactory: - """Helper class to generate qtdata models for toolbit libraries""" - - @staticmethod - def find_libraries(model) -> QStandardItemModel: - """ - Finds all the fctl files in a location. - Returns a QStandardItemModel. - """ - Path.Log.track() - path = Path.Preferences.lastPathToolLibrary() - model.clear() - - if os.path.isdir(path): # opening all tables in a directory - libFiles = [f for f in glob.glob(path + os.path.sep + "*.fctl")] - libFiles.sort() - for libFile in libFiles: - loc, fnlong = os.path.split(libFile) - fn, ext = os.path.splitext(fnlong) - libItem = QStandardItem(fn) - libItem.setToolTip(loc) - libItem.setData(libFile, _PathRole) - libItem.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) - model.appendRow(libItem) - - Path.Log.debug("model rows: {}".format(model.rowCount())) - return model - - @staticmethod - def __library_load(path: str, data_model: QStandardItemModel): - Path.Log.track(path) - Path.Preferences.setLastFileToolLibrary(path) - - try: - with open(path) as fp: - library = json.load(fp) - except Exception as e: - Path.Log.error(f"Failed to load library from {path}: {e}") - return - - for tool_bit in library.get("tools", []): - try: - nr = tool_bit["nr"] - bit = PathToolBit.findToolBit(tool_bit["path"], path) - if bit: - Path.Log.track(bit) - tool = PathToolBit.Declaration(bit) - data_model.appendRow(ModelFactory._tool_add(nr, tool, bit)) - else: - Path.Log.error(f"Could not find tool #{nr}: {tool_bit['path']}") - except Exception as e: - msg = f"Error loading tool: {tool_bit['path']} : {e}" - FreeCAD.Console.PrintError(msg) - - @staticmethod - def _generate_tooltip(toolbit: dict) -> str: - """ - Generate an HTML tooltip for a given toolbit dictionary. - - Args: - toolbit (dict): A dictionary containing toolbit information. - - Returns: - str: An HTML string representing the tooltip. - """ - tooltip = f"
Name: {toolbit['name']}
" - tooltip += f"Shape File: {toolbit['shape']}
" - tooltip += "Parameters:
" - parameters = toolbit.get("parameter", {}) - if parameters: - for key, value in parameters.items(): - tooltip += f" {key}: {value}
" - else: - tooltip += " No parameters provided.
" - - attributes = toolbit.get("attribute", {}) - if attributes: - tooltip += "Attributes:
" - for key, value in attributes.items(): - tooltip += f" {key}: {value}
" - - return tooltip - - @staticmethod - def _tool_add(nr: int, tool: dict, path: str): - str_shape = os.path.splitext(os.path.basename(tool["shape"]))[0] - tooltip = ModelFactory._generate_tooltip(tool) - - tool_nr = QStandardItem() - tool_nr.setData(nr, Qt.EditRole) - tool_nr.setData(path, _PathRole) - tool_nr.setData(UUID.uuid4(), _UuidRole) - tool_nr.setToolTip(tooltip) - - tool_name = QStandardItem() - tool_name.setData(tool["name"], Qt.EditRole) - tool_name.setEditable(False) - tool_name.setToolTip(tooltip) - - tool_shape = QStandardItem() - tool_shape.setData(str_shape, Qt.EditRole) - tool_shape.setEditable(False) - - return [tool_nr, tool_name, tool_shape] - - @staticmethod - def new_tool(datamodel: QStandardItemModel, path: str): - """ - Adds a toolbit item to a model. - """ - Path.Log.track() - - try: - nr = ( - max( - ( - int(datamodel.item(row, 0).data(Qt.EditRole)) - for row in range(datamodel.rowCount()) - ), - default=0, - ) - + 1 - ) - tool = PathToolBit.Declaration(path) - except Exception as e: - Path.Log.error(e) - return - - datamodel.appendRow(ModelFactory._tool_add(nr, tool, path)) - - @staticmethod - def library_open(model: QStandardItemModel, lib: str = "") -> QStandardItemModel: - """ - Opens the tools in a library. - Returns a QStandardItemModel. - """ - Path.Log.track(lib) - - if not lib: - lib = Path.Preferences.lastFileToolLibrary() - - if not lib or not os.path.isfile(lib): - return model - - ModelFactory.__library_load(lib, model) - - Path.Log.debug("model rows: {}".format(model.rowCount())) - return model - - -class ToolBitSelector(object): - """Controller for displaying a library and creating ToolControllers""" - - def __init__(self): - checkWorkingDir() - self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitSelector.ui") - self.factory = ModelFactory() - self.toolModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames())) - self.libraryModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames())) - - self.setupUI() - self.title = self.form.windowTitle() - - def columnNames(self): - """Define the column names to display""" - return ["#", "Tool"] - - def currentLibrary(self, shortNameOnly): - """Get the file path for the current tool library""" - libfile = Path.Preferences.lastFileToolLibrary() - if libfile is None or libfile == "": - return "" - elif shortNameOnly: - return os.path.splitext(os.path.basename(libfile))[0] - return libfile - - def loadData(self): - """Load the toolbits for the selected tool library""" - Path.Log.track() - self.toolModel.clear() - self.toolModel.setHorizontalHeaderLabels(self.columnNames()) - - # Get the currently selected index in the combobox - currentIndex = self.form.cboLibraries.currentIndex() - - if currentIndex != -1: - # Get the data for the selected index - libPath = self.libraryModel.item(currentIndex).data(_PathRole) - self.factory.library_open(self.toolModel, libPath) - - self.toolModel.takeColumn(3) - self.toolModel.takeColumn(2) - - def loadToolLibraries(self): - """ - Load the tool libraries in to self.libraryModel - and populate the tooldock form combobox with the - libraries names - """ - Path.Log.track() - - # Get the current library so we can try and maintain any previous selection - current_lib = self.currentLibrary(True) # True to get short name only - - # load the tool libraries - self.factory.find_libraries(self.libraryModel) - - # Set the library model to the combobox - self.form.cboLibraries.setModel(self.libraryModel) - - # Set the current library as the selected item in the combobox - currentIndex = self.form.cboLibraries.findText(current_lib) - - # Set the selected library as the currentIndex in the combobox - if currentIndex == -1 and self.libraryModel.rowCount() > 0: - # If current library is not found, default to the first item - currentIndex = 0 - - self.form.cboLibraries.setCurrentIndex(currentIndex) - - def setupUI(self): - """Setup the form and load the tooltable data""" - Path.Log.track() - - # Connect the library change to reload data and update tooltip - self.form.cboLibraries.currentIndexChanged.connect(self.loadData) - self.form.cboLibraries.currentIndexChanged.connect(self.updateLibraryTooltip) - - # Load the tool libraries. - # This will trigger a change in current index of the cboLibraries combobox - self.loadToolLibraries() - - self.form.tools.setModel(self.toolModel) - self.form.tools.selectionModel().selectionChanged.connect(self.enableButtons) - self.form.tools.doubleClicked.connect(partial(self.selectedOrAllToolControllers)) - - self.form.libraryEditorOpen.clicked.connect(self.libraryEditorOpen) - self.form.addToolController.clicked.connect(self.selectedOrAllToolControllers) - - def updateLibraryTooltip(self, index): - """Add a tooltip to the combobox""" - if index != -1: - item = self.libraryModel.item(index) - if item: - libPath = item.data(_PathRole) - self.form.cboLibraries.setToolTip(f"{libPath}") - else: - self.form.cboLibraries.setToolTip(translate("CAM_Toolbit", "Select a library")) - else: - self.form.cboLibraries.setToolTip(translate("CAM_Toolbit", "No library selected")) - - def enableButtons(self): - """Enable button to add tool controller when a tool is selected""" - # Set buttons inactive - self.form.addToolController.setEnabled(False) - selected = len(self.form.tools.selectedIndexes()) >= 1 - if selected: - jobs = len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1 - self.form.addToolController.setEnabled(selected and jobs) - - def libraryEditorOpen(self): - library = ToolBitLibrary() - library.open() - self.loadToolLibraries() - - def selectedOrAllTools(self): - """ - Iterate the selection and add individual tools - If a group is selected, iterate and add children - """ - - itemsToProcess = [] - for index in self.form.tools.selectedIndexes(): - item = index.model().itemFromIndex(index) - - if item.hasChildren(): - for i in range(item.rowCount() - 1): - if item.child(i).column() == 0: - itemsToProcess.append(item.child(i)) - - elif item.column() == 0: - itemsToProcess.append(item) - - tools = [] - for item in itemsToProcess: - toolNr = int(item.data(PySide.QtCore.Qt.EditRole)) - toolPath = item.data(_PathRole) - tools.append((toolNr, PathToolBit.Factory.CreateFrom(toolPath))) - return tools - - def selectedOrAllToolControllers(self, index=None): - """ - if no jobs, don't do anything, otherwise all TCs for all - selected toolbits - """ - jobs = PathUtilsGui.PathUtils.GetJobs() - if len(jobs) == 0: - return - elif len(jobs) == 1: - job = jobs[0] - else: - userinput = PathUtilsGui.PathUtilsUserInput() - job = userinput.chooseJob(jobs) - - if job is None: # user may have canceled - return - - tools = self.selectedOrAllTools() - - for tool in tools: - tc = PathToolControllerGui.Create("TC: {}".format(tool[1].Label), tool[1], tool[0]) - job.Proxy.addToolController(tc) - FreeCAD.ActiveDocument.recompute() - - def open(self, path=None): - """load library stored in path and bring up ui""" - docs = FreeCADGui.getMainWindow().findChildren(PySide.QtGui.QDockWidget) - for doc in docs: - if doc.objectName() == "ToolSelector": - if doc.isVisible(): - doc.deleteLater() - return - else: - doc.setVisible(True) - return - - mw = FreeCADGui.getMainWindow() - mw.addDockWidget( - PySide.QtCore.Qt.RightDockWidgetArea, - self.form, - PySide.QtCore.Qt.Orientation.Vertical, - ) - - -class ToolBitLibrary(object): - """ToolBitLibrary is the controller for - displaying/selecting/creating/editing a collection of ToolBits.""" - - def __init__(self): - Path.Log.track() - checkWorkingDir() - self.factory = ModelFactory() - self.temptool = None - self.toolModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames())) - self.listModel = PySide.QtGui.QStandardItemModel() - self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui") - self.toolTableView = _TableView(self.form.toolTableGroup) - self.form.toolTableGroup.layout().replaceWidget(self.form.toolTable, self.toolTableView) - self.form.toolTable.hide() - self.setupUI() - self.title = self.form.windowTitle() - - def toolBitNew(self): - """Create a new toolbit""" - Path.Log.track() - - # select the shape file - shapefile = PathToolBitGui.GetToolShapeFile() - if shapefile is None: # user canceled - return - - # select the bit file location and filename - filename = PathToolBitGui.GetNewToolFile() - if filename is None: - return - - # Parse out the name of the file and write the structure - loc, fil = os.path.split(filename) - fname = os.path.splitext(fil)[0] - fullpath = "{}{}{}.fctb".format(loc, os.path.sep, fname) - Path.Log.debug("fullpath: {}".format(fullpath)) - - self.temptool = PathToolBit.ToolBitFactory().Create(name=fname) - self.temptool.BitShape = shapefile - self.temptool.Proxy.unloadBitBody(self.temptool) - self.temptool.Label = fname - self.temptool.Proxy.saveToFile(self.temptool, fullpath) - self.temptool.Document.removeObject(self.temptool.Name) - self.temptool = None - - # add it to the model - self.factory.new_tool(self.toolModel, fullpath) - self.librarySave() - - def toolBitExisting(self): - """Add an existing toolbit to the library""" - - filenames = PathToolBitGui.GetToolFiles() - - if len(filenames) == 0: - return - - for f in filenames: - - loc, fil = os.path.split(f) - fname = os.path.splitext(fil)[0] - fullpath = "{}{}{}.fctb".format(loc, os.path.sep, fname) - - self.factory.new_tool(self.toolModel, fullpath) - self.librarySave() - - def toolDelete(self): - """Delete a tool""" - Path.Log.track() - selectedRows = set([index.row() for index in self.toolTableView.selectedIndexes()]) - for row in sorted(list(selectedRows), key=lambda r: -r): - self.toolModel.removeRows(row, 1) - self.librarySave() - - def toolSelect(self, selected, deselected): - sel = len(self.toolTableView.selectedIndexes()) > 0 - self.form.toolDelete.setEnabled(sel) - - def tableSelected(self, index): - """loads the tools for the selected tool table""" - Path.Log.track() - item = index.model().itemFromIndex(index) - libpath = item.data(_PathRole) - self.loadData(libpath) - self.path = libpath - - def open(self): - Path.Log.track() - return self.form.exec_() - - def libraryPath(self): - """Select and load a tool library""" - Path.Log.track() - path = PySide.QtGui.QFileDialog.getExistingDirectory( - self.form, "Tool Library Path", Path.Preferences.lastPathToolLibrary() - ) - if len(path) == 0: - return - - Path.Preferences.setLastPathToolLibrary(path) - self.loadData() - - def cleanupDocument(self): - """Clean up the document""" - # This feels like a hack. Remove the toolbit object - # remove the editor from the dialog - # re-enable all the controls - self.temptool.Proxy.unloadBitBody(self.temptool) - self.temptool.Document.removeObject(self.temptool.Name) - self.temptool = None - widget = self.form.toolTableGroup.children()[-1] - widget.setParent(None) - self.editor = None - self.lockoff() - - def accept(self): - """Handle accept signal""" - self.editor.accept() - self.temptool.Proxy.saveToFile(self.temptool, self.temptool.File) - self.librarySave() - self.loadData() - self.cleanupDocument() - - def reject(self): - """Handle reject signal""" - self.cleanupDocument() - - def lockon(self): - """Set the state of the form widgets: inactive""" - self.toolTableView.setEnabled(False) - self.form.toolCreate.setEnabled(False) - self.form.toolDelete.setEnabled(False) - self.form.toolAdd.setEnabled(False) - self.form.TableList.setEnabled(False) - self.form.libraryOpen.setEnabled(False) - self.form.libraryExport.setEnabled(False) - self.form.addToolTable.setEnabled(False) - self.form.librarySave.setEnabled(False) - - def lockoff(self): - """Set the state of the form widgets: active""" - self.toolTableView.setEnabled(True) - self.form.toolCreate.setEnabled(True) - self.form.toolDelete.setEnabled(True) - self.form.toolAdd.setEnabled(True) - self.form.toolTable.setEnabled(True) - self.form.TableList.setEnabled(True) - self.form.libraryOpen.setEnabled(True) - self.form.libraryExport.setEnabled(True) - self.form.addToolTable.setEnabled(True) - self.form.librarySave.setEnabled(True) - - def toolEdit(self, selected): - """Edit the selected tool bit""" - Path.Log.track() - item = self.toolModel.item(selected.row(), 0) - - if self.temptool is not None: - self.temptool.Document.removeObject(self.temptool.Name) - - if selected.column() == 0: # editing Nr - pass - else: - tbpath = item.data(_PathRole) - self.temptool = PathToolBit.ToolBitFactory().CreateFrom(tbpath, "temptool") - self.editor = PathToolBitEdit.ToolBitEditor( - self.temptool, self.form.toolTableGroup, loadBitBody=False - ) - - QBtn = PySide.QtGui.QDialogButtonBox.Ok | PySide.QtGui.QDialogButtonBox.Cancel - buttonBox = PySide.QtGui.QDialogButtonBox(QBtn) - buttonBox.accepted.connect(self.accept) - buttonBox.rejected.connect(self.reject) - - layout = self.editor.form.layout() - layout.addWidget(buttonBox) - self.lockon() - self.editor.setupUI() - - def toolEditDone(self, success=True): - FreeCAD.ActiveDocument.removeObject("temptool") - print("all done") - - def libraryNew(self): - """Create a new tool library""" - TooltableTypeJSON = translate("CAM_ToolBit", "Tooltable JSON (*.fctl)") - - filename = PySide.QtGui.QFileDialog.getSaveFileName( - self.form, - translate("CAM_ToolBit", "Save toolbit library"), - Path.Preferences.lastPathToolLibrary(), - "{}".format(TooltableTypeJSON), - ) - - if not (filename and filename[0]): - self.loadData() - - path = filename[0] if filename[0].endswith(".fctl") else "{}.fctl".format(filename[0]) - library = {} - tools = [] - library["version"] = 1 - library["tools"] = tools - with open(path, "w") as fp: - json.dump(library, fp, sort_keys=True, indent=2) - - self.loadData() - - def librarySave(self): - """Save the tool library""" - library = {} - tools = [] - library["version"] = 1 - library["tools"] = tools - for row in range(self.toolModel.rowCount()): - toolNr = self.toolModel.data(self.toolModel.index(row, 0), PySide.QtCore.Qt.EditRole) - toolPath = self.toolModel.data(self.toolModel.index(row, 0), _PathRole) - if Path.Preferences.toolsStoreAbsolutePaths(): - bitPath = toolPath - else: - # bitPath = PathToolBit.findRelativePathTool(toolPath) - # Extract the name of the shape file - __, filShp = os.path.split( - toolPath - ) # __ is an ignored placeholder acknowledged by LGTM - bitPath = str(filShp) - tools.append({"nr": toolNr, "path": bitPath}) - - if self.path is not None: - with open(self.path, "w") as fp: - json.dump(library, fp, sort_keys=True, indent=2) - - def libraryOk(self): - self.librarySave() - self.form.close() - - def libPaths(self): - """Get the file path for the last used tool library""" - lib = Path.Preferences.lastFileToolLibrary() - loc = Path.Preferences.lastPathToolLibrary() - - Path.Log.track("lib: {} loc: {}".format(lib, loc)) - return lib, loc - - def columnNames(self): - return [ - "Tn", - translate("CAM_ToolBit", "Tool"), - translate("CAM_ToolBit", "Shape"), - ] - - def loadData(self, path=None): - """Load tooltable data""" - Path.Log.track(path) - self.toolTableView.setUpdatesEnabled(False) - self.form.TableList.setUpdatesEnabled(False) - - if path is None: - path, loc = self.libPaths() - - self.toolModel.clear() - self.listModel.clear() - self.factory.library_open(self.toolModel, lib=path) - self.factory.find_libraries(self.listModel) - - else: - self.toolModel.clear() - self.factory.library_open(self.toolModel, lib=path) - - self.path = path - self.form.setWindowTitle("{}".format(Path.Preferences.lastPathToolLibrary())) - self.toolModel.setHorizontalHeaderLabels(self.columnNames()) - self.listModel.setHorizontalHeaderLabels(["Library"]) - - # Select the current library in the list of tables - curIndex = None - for i in range(self.listModel.rowCount()): - item = self.listModel.item(i) - if item.data(_PathRole) == path: - curIndex = self.listModel.indexFromItem(item) - - if curIndex: - sm = self.form.TableList.selectionModel() - sm.select(curIndex, PySide.QtCore.QItemSelectionModel.Select) - - self.toolTableView.setUpdatesEnabled(True) - self.form.TableList.setUpdatesEnabled(True) - - def setupUI(self): - """Setup the form and load the tool library data""" - Path.Log.track() - self.form.TableList.setModel(self.listModel) - self.toolTableView.setModel(self.toolModel) - - self.loadData() - - self.toolTableView.resizeColumnsToContents() - self.toolTableView.selectionModel().selectionChanged.connect(self.toolSelect) - self.toolTableView.doubleClicked.connect(self.toolEdit) - - self.form.TableList.clicked.connect(self.tableSelected) - - self.form.toolAdd.clicked.connect(self.toolBitExisting) - self.form.toolDelete.clicked.connect(self.toolDelete) - self.form.toolCreate.clicked.connect(self.toolBitNew) - - self.form.addToolTable.clicked.connect(self.libraryNew) - - self.form.libraryOpen.clicked.connect(self.libraryPath) - self.form.librarySave.clicked.connect(self.libraryOk) - self.form.libraryExport.clicked.connect(self.librarySaveAs) - - self.toolSelect([], []) - - def librarySaveAs(self, path): - """Save the tooltable to a format to use with an external system""" - TooltableTypeJSON = translate("CAM_ToolBit", "Tooltable JSON (*.fctl)") - TooltableTypeLinuxCNC = translate("CAM_ToolBit", "LinuxCNC tooltable (*.tbl)") - TooltableTypeCamotics = translate("CAM_ToolBit", "CAMotics tooltable (*.json)") - - filename = PySide.QtGui.QFileDialog.getSaveFileName( - self.form, - translate("CAM_ToolBit", "Save toolbit library"), - Path.Preferences.lastPathToolLibrary(), - "{};;{};;{}".format(TooltableTypeJSON, TooltableTypeLinuxCNC, TooltableTypeCamotics), - ) - if filename and filename[0]: - if filename[1] == TooltableTypeLinuxCNC: - path = filename[0] if filename[0].endswith(".tbl") else "{}.tbl".format(filename[0]) - self.libararySaveLinuxCNC(path) - elif filename[1] == TooltableTypeCamotics: - path = ( - filename[0] if filename[0].endswith(".json") else "{}.json".format(filename[0]) - ) - self.libararySaveCamotics(path) - else: - path = ( - filename[0] if filename[0].endswith(".fctl") else "{}.fctl".format(filename[0]) - ) - self.path = path - self.librarySave() - - def libararySaveLinuxCNC(self, path): - """Export the tool table to a file for use with linuxcnc""" - LIN = "T{} P{} X{} Y{} Z{} A{} B{} C{} U{} V{} W{} D{} I{} J{} Q{}; {}" - with open(path, "w") as fp: - fp.write(";\n") - - for row in range(self.toolModel.rowCount()): - toolNr = self.toolModel.data( - self.toolModel.index(row, 0), PySide.QtCore.Qt.EditRole - ) - toolPath = self.toolModel.data(self.toolModel.index(row, 0), _PathRole) - - bit = PathToolBit.Factory.CreateFrom(toolPath) - if bit: - Path.Log.track(bit) - - pocket = bit.Pocket if hasattr(bit, "Pocket") else "0" - xoffset = bit.Xoffset if hasattr(bit, "Xoffset") else "0" - yoffset = bit.Yoffset if hasattr(bit, "Yoffset") else "0" - zoffset = bit.Zoffset if hasattr(bit, "Zoffset") else "0" - aoffset = bit.Aoffset if hasattr(bit, "Aoffset") else "0" - boffset = bit.Boffset if hasattr(bit, "Boffset") else "0" - coffset = bit.Coffset if hasattr(bit, "Coffset") else "0" - uoffset = bit.Uoffset if hasattr(bit, "Uoffset") else "0" - voffset = bit.Voffset if hasattr(bit, "Voffset") else "0" - woffset = bit.Woffset if hasattr(bit, "Woffset") else "0" - - diameter = ( - bit.Diameter.getUserPreferred()[0].split()[0] - if hasattr(bit, "Diameter") - else "0" - ) - frontangle = bit.FrontAngle if hasattr(bit, "FrontAngle") else "0" - backangle = bit.BackAngle if hasattr(bit, "BackAngle") else "0" - orientation = bit.Orientation if hasattr(bit, "Orientation") else "0" - remark = bit.Label - - fp.write( - LIN.format( - toolNr, - pocket, - xoffset, - yoffset, - zoffset, - aoffset, - boffset, - coffset, - uoffset, - voffset, - woffset, - diameter, - frontangle, - backangle, - orientation, - remark, - ) - + "\n" - ) - - FreeCAD.ActiveDocument.removeObject(bit.Name) - - else: - Path.Log.error("Could not find tool #{} ".format(toolNr)) - - def libararySaveCamotics(self, path): - """Export the tool table to a file for use with camotics""" - - SHAPEMAP = { - "ballend": "Ballnose", - "endmill": "Cylindrical", - "v-bit": "Conical", - "chamfer": "Snubnose", - } - - tooltemplate = { - "units": "metric", - "shape": "cylindrical", - "length": 10, - "diameter": 3.125, - "description": "", - } - toollist = {} - - unitstring = "imperial" if FreeCAD.Units.getSchema() in [2, 3, 5, 7] else "metric" - - for row in range(self.toolModel.rowCount()): - toolNr = self.toolModel.data(self.toolModel.index(row, 0), PySide.QtCore.Qt.EditRole) - - toolPath = self.toolModel.data(self.toolModel.index(row, 0), _PathRole) - Path.Log.debug(toolPath) - try: - bit = PathToolBit.Factory.CreateFrom(toolPath) - except FileNotFoundError as e: - FreeCAD.Console.PrintError(e) - continue - except Exception as e: - raise e - - if not bit: - continue - - Path.Log.track(bit) - - toolitem = tooltemplate.copy() - - toolitem["diameter"] = ( - float(bit.Diameter.getUserPreferred()[0].split()[0]) - if hasattr(bit, "Diameter") - else 2 - ) - toolitem["description"] = bit.Label - toolitem["length"] = ( - float(bit.Length.getUserPreferred()[0].split()[0]) if hasattr(bit, "Length") else 10 - ) - - if hasattr(bit, "Camotics"): - toolitem["shape"] = bit.Camotics - else: - toolitem["shape"] = SHAPEMAP.get(bit.ShapeName, "Cylindrical") - - toolitem["units"] = unitstring - FreeCAD.ActiveDocument.removeObject(bit.Name) - - toollist[toolNr] = toolitem - - if len(toollist) > 0: - with open(path, "w") as fp: - fp.write(json.dumps(toollist, indent=2)) diff --git a/src/Mod/CAM/Path/Tool/Gui/Controller.py b/src/Mod/CAM/Path/Tool/Gui/Controller.py index 7035ba5bb5..9e07e953b9 100644 --- a/src/Mod/CAM/Path/Tool/Gui/Controller.py +++ b/src/Mod/CAM/Path/Tool/Gui/Controller.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -20,6 +21,7 @@ # * * # *************************************************************************** +from lazy_loader.lazy_loader import LazyLoader from PySide import QtCore, QtGui from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCAD @@ -28,11 +30,7 @@ import Path import Path.Base.Gui.Util as PathGuiUtil import Path.Base.Util as PathUtil import Path.Tool.Controller as PathToolController -import Path.Tool.Gui.Bit as PathToolBitGui -import PathGui - -# lazily loaded modules -from lazy_loader.lazy_loader import LazyLoader +from Path.Tool.toolbit.ui.selector import ToolBitSelector Part = LazyLoader("Part", globals(), "Part") @@ -162,19 +160,30 @@ class CommandPathToolController(object): def Activated(self): Path.Log.track() job = self.selectedJob() - if job: - tool = PathToolBitGui.ToolBitSelector().getTool() - if tool: - toolNr = None - for tc in job.Tools.Group: - if tc.Tool == tool: - toolNr = tc.ToolNumber - break - if not toolNr: - toolNr = max([tc.ToolNumber for tc in job.Tools.Group]) + 1 - tc = Create("TC: {}".format(tool.Label), tool, toolNr) - job.Proxy.addToolController(tc) - FreeCAD.ActiveDocument.recompute() + if not job: + return + + # Let the user select a toolbit + selector = ToolBitSelector() + if not selector.exec_(): + return + tool = selector.get_selected_tool() + if not tool: + return + + # Find a tool number + toolNr = None + for tc in job.Tools.Group: + if tc.Tool == tool: + toolNr = tc.ToolNumber + break + if not toolNr: + toolNr = max([tc.ToolNumber for tc in job.Tools.Group]) + 1 + + # Create the new tool controller with the tool. + tc = Create("TC: {}".format(tool.Label), tool, toolNr) + job.Proxy.addToolController(tc) + FreeCAD.ActiveDocument.recompute() class ToolControllerEditor(object): diff --git a/src/Mod/CAM/Path/Tool/__init__.py b/src/Mod/CAM/Path/Tool/__init__.py index e69de29bb2..36cb227147 100644 --- a/src/Mod/CAM/Path/Tool/__init__.py +++ b/src/Mod/CAM/Path/Tool/__init__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +import sys +from lazy_loader.lazy_loader import LazyLoader +from . import toolbit +from .assets import DummyAssetSerializer +from .camassets import cam_assets +from .library import Library +from .library.serializers import FCTLSerializer +from .toolbit import ToolBit +from .toolbit.serializers import FCTBSerializer +from .shape import ToolBitShape, ToolBitShapePngIcon, ToolBitShapeSvgIcon +from .machine import Machine + +# Register asset classes and serializers. +cam_assets.register_asset(Library, FCTLSerializer) +cam_assets.register_asset(ToolBit, FCTBSerializer) +cam_assets.register_asset(ToolBitShape, DummyAssetSerializer) +cam_assets.register_asset(ToolBitShapePngIcon, DummyAssetSerializer) +cam_assets.register_asset(ToolBitShapeSvgIcon, DummyAssetSerializer) +cam_assets.register_asset(Machine, DummyAssetSerializer) + +# For backward compatibility with files saved before the toolbit rename +# This makes the Path.Tool.toolbit.base module available as Path.Tool.Bit. +# Since C++ does not use the standard Python import mechanism and instead +# unpickles existing objects after looking them up in sys.modules, we +# need to update sys.modules here. +sys.modules[__name__ + ".Bit"] = toolbit.models.base +sys.modules[__name__ + ".Gui.Bit"] = LazyLoader( + "Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view" +) + +# Define __all__ for explicit public interface +__all__ = [ + "ToolBit", + "cam_assets", +] diff --git a/src/Mod/CAM/Path/Tool/assets/README.md b/src/Mod/CAM/Path/Tool/assets/README.md new file mode 100644 index 0000000000..669d456bfb --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/README.md @@ -0,0 +1,323 @@ +# Asset Management Module + +This module implements an asset manager that provides methods for storing, +updating, deleting, and receiving assets for the FreeCAD CAM workbench. + +## Goals of the asset manager + +While currently the AssetManager has no UI yet, the plan is to add one. + +The ultimate vision for the asset manager is to provide a unified UI that +can download assets from arbitrary sources, such as online databases, +Git repositories, and also local storage. It should also allow for copying +between these storages, effectively allowing for publishing assets. + +Essentially, something similar to what Blender has: + +![Blender Asset Manager](docs/blender-assets.jpg) + +## What are assets in CAM? + +Assets are arbitrary data, such as FreeCAD models, Tools, and many more. +Specifically in the context of CAM, assets are: + +- Tool bit libraries +- Tool bits +- Tool bit shape files +- Tool bit shape icons +- Machines +- Fixtures +- Post processors +- ... + +**Assets have dependencies:** For example, a ToolBitLibrary requires ToolBits, +and a ToolBit requires a ToolBitShape (which is a FreeCAD model). + + +## Challenges + +In the current codebase, CAM objects are monoliths that handle everything: +in-memory data, serialization, deserialization, storage. They are tightly +coupled to files, and make assumptions about how other objects are stored. + +Examples: + +- Tool bits have "File" attributes that they use to collect dependencies + such as ToolBit files and shape files. +- It also writes directly to the disk. +- GuiToolBit performs serialization directly in UI functions. +- ToolBits could not be created without an active document. + +As the code base grows, separation of concerns becomes more important. +Managing dependencies between asset becomes a hassle if every object tries +to resolve them in their own way. + + +# Solution + +The main effort went into two key areas: + +1. **The generic AssetManager:** + - **Manages storage** while existing FreeCAD tool library file structures retained + - **Manages dependencies** including detection of cyclic dependencies, deep vs. shallow fetching + - **Manages threading** for asynchronous storage, while FreeCAD objects are assembled in the main UI thread + - **Defining a generic asset interface** that classes can implement to become "storable" + +2. **Refactoring existing CAM objects for clear separation of concerns:** + - **View**: Should handle user interface only. Existing file system access methods were removed. + - **Object Model**: In-memory representation of an object, for example a ToolBit, Icon, or a ToolBitShape. By giving all classes `from_bytes()` and `to_bytes()` methods; the objects no longer need to handle storage themselves. + - **Storage**: Persisting an object to a file system, database system, or API. This is now handled by the AssetManager + - **Serialization** A serialization protocol needs to be defined. This will allow for better import/export mechanisms in the future + +FreeCAD is now fully usable with the changes in place. Work remains to be done on the serializers. + +## Asset Manager API usage example + +```python +import pathlib +from typing import Any, Mapping, List, Type +from Path.Tool.assets import AssetManager, FileStore, AssetUri, Asset + +# Define a simple Material class implementing the Asset interface +class Material(Asset): + asset_type: str = "material" + + def __init__(self, name: str): + self.name = name + + def get_id() -> str: + return self.name.lower().replace(" ", "-") + + @classmethod + def dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def from_bytes(cls, data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]]) -> Material: + return cls(data.decode('utf-8')) + + def to_bytes(self) -> bytes: + return self.name.encode('utf-8') + +manager = AssetManager() + +# Register FileStore and the simple asset class +manager.register_store(FileStore("local", pathlib.Path("/tmp/assets"))) +manager.register_asset(Material) + +# Create and get an asset +asset_uri = manager.add(Material("Copper")) +print(f"Stored with URI: {asset_uri}") +retrieved_asset = manager.get(asset_uri) +print(f"Retrieved: {retrieved_asset}") +``` + +## The Serializer Protocol + +The serializer protocol defines how assets are converted to and from bytes and how their +dependencies are identified. This separation of concerns allows assets to be stored and +retrieved independently of their specific serialization format. + +The core components of the protocol are the [`Asset`](src/Mod/CAM/Path/Tool/assets/asset.py:11) +and [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) classes. + +- The [`Asset`](src/Mod/CAM/Path/Tool/assets/asset.py:11) class represents an asset object in + memory. It provides methods like [`to_bytes()`](src/Mod/CAM/Path/Tool/assets/asset.py:69) + and [`from_bytes()`](src/Mod/CAM/Path/Tool/assets/asset.py:56) which delegate the actual + serialization and deserialization to an [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) + instance. It also has an [`extract_dependencies()`](src/Mod/CAM/Path/Tool/assets/asset.py:49) + method that uses the serializer to find dependencies within the raw asset data. + +- The [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) is an abstract base + class that defines the interface for serializers. Concrete implementations of + [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) are responsible for the + specific logic of converting an asset object to bytes ([`serialize()`](src/Mod/CAM/Path/Tool/assets/serializer.py:21)), + converting bytes back to an asset object ([`deserialize()`](src/Mod/CAM/Path/Tool/assets/serializer.py:27)), + and extracting dependency URIs from the raw byte data + ([`extract_dependencies()`](src/Mod/CAM/Path/Tool/assets/serializer.py:15)). + +This design allows the AssetManager to work with various asset types and serialization formats +by simply registering the appropriate [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) +for each asset type. + +## Class diagram + +```mermaid +classDiagram + direction LR + + %% -------------- Asset Manager Module -------------- + note for AssetManager "AssetUri structure: + <asset_type>:\//<asset_id>[/<version>]
+ Examples: + material:\//1234567/1 + toolbitshape:\//endmill/1 + material:\//aluminium-6012/2" + + class AssetManager["AssetManager + Creates, assembles or deletes assets from URIs"] { + stores: Mapping[str, AssetStore] // maps protocol to store + register_store(store: AssetStore, cacheable: bool = False) + register_asset(asset_class: Type[Asset], serializer: Type[AssetSerializer]) + get(uri: AssetUri | str, store: str = "local", depth: Optional[int] = None) Any + get_raw(uri: AssetUri | str, store: str = "local") bytes + add(obj: Asset, store: str = "local") AssetUri + add_raw(asset_type: str, asset_id: str, data: bytes, store: str = "local") AssetUri + delete(uri: AssetUri | str, store: str = "local") + is_empty(asset_type: str | None = None, store: str = "local") bool + list_assets(asset_type: str | None = None, limit: int | None = None, offset: int | None = None, store: str = "local") List[AssetUri] + list_versions(uri: AssetUri | str, store: str = "local") List[AssetUri] + get_bulk(uris: Sequence[AssetUri | str], store: str = "local", depth: Optional[int] = None) List[Any] + fetch(asset_type: str | None = None, limit: int | None = None, offset: int | None = None, store: str = "local", depth: Optional[int] = None) List[Asset] + } + + class AssetStore["AssetStore + Stores/Retrieves assets as raw bytes"] { + <> + async get(uri: AssetUri) bytes + async count_assets(asset_type: str | None = None) int + async delete(uri: AssetUri) + async create(asset_type: str, asset_id: str, data: bytes) AssetUri + async update(uri: AssetUri, data: bytes) AssetUri + async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri] + async list_versions(uri: AssetUri) List[AssetUri] + async is_empty(asset_type: str | None = None) bool + } + AssetStore *-- AssetManager: has many + + class FileStore["FileStore + Stores/Retrieves versioned assets as directories/files"] { + + __init__(name: str, filepath: pathlib.Path) + set_dir(new_dir: pathlib.Path) + async get(uri: AssetUri) bytes + async delete(uri: AssetUri) + async create(asset_type: str, asset_id: str, data: bytes) AssetUri + async update(uri: AssetUri, data: bytes) AssetUri + async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri] + async list_versions(uri: AssetUri) List[AssetUri] + async is_empty(asset_type: str | None = None) bool + } + FileStore <|-- AssetStore: is + + class MemoryStore["MemoryStore + In-memory store, mostly for testing/demonstration"] { + __init__(name: str) + async get(uri: AssetUri) bytes + async delete(uri: AssetUri) + async create(asset_type: str, asset_id: str, data: bytes) AssetUri + async update(uri: AssetUri, data: bytes) AssetUri + async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri] + async list_versions(uri: AssetUri) List[AssetUri] + async is_empty(asset_type: str | None) bool + dump(print: bool) Dict | None + } + MemoryStore <|-- AssetStore: is + + class AssetSerializer["AssetSerializer
Abstract base class for asset serializers"] { + <> + for_class: Type[Asset] + extensions: Tuple[str] + mime_type: str + extract_dependencies(data: bytes) List[AssetUri] + serialize(asset: Asset) bytes + deserialize(data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]]) Asset + } + AssetSerializer *-- AssetManager: has many + Asset --> AssetSerializer: uses + + class Asset["Asset
Common interface for all asset types"] { + <> + asset_type: str // type of the asset type, e.g., toolbit + + get_id() str // Returns a unique ID of the asset + to_bytes(serializer: AssetSerializer) bytes + from_bytes(data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]], serializer: Type[AssetSerializer]) Asset + extract_dependencies(data: bytes, serializer: Type[AssetSerializer]) List[AssetUri] // Extracts dependency URIs from bytes + } + Asset *-- AssetManager: creates + + namespace AssetManagerModule { + class AssetManager + class AssetStore + class FileStore + class MemoryStore + class AssetSerializer + class Asset + } + + %% -------------- CAM Module (as an example) -------------- + class ToolBitShape["ToolBitShape
for assets with type toolbitshape"] { + <> + asset_type: str = "toolbitshape" + + get_id() str // Returns a unique ID + from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) ToolBitShape + to_bytes(obj: ToolBitShape) bytes + dependencies(data: bytes) List[AssetUri] + } + ToolBitShape ..|> Asset: is + + class ToolBit["ToolBit
for assets with type toolbit"] { + <> + asset_type: str = "toolbit" + + get_id() str // Returns a unique ID + from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) ToolBit + to_bytes(obj: ToolBit) bytes + dependencies(data: bytes) List[AssetUri] + } + ToolBit ..|> Asset: is + ToolBit --> ToolBitShape: has + + namespace CAMModule { + class ToolBitShape + class ToolBit + } + + %% -------------- Materials Module (as an example) -------------- + class Material["Material
for assets with type material"] { + <> + asset_type: str = "material" + + get_id() str // Returns a unique ID + dependencies(data: bytes) List[AssetUri] + from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) Material + to_bytes(obj: Material) bytes + } + Material ..|> Asset: is + + namespace MaterialModule { + class Material + class Material + } +``` + +# UI Helpers + +The `ui` directory contains helper modules for the asset manager's user interface. + +- [`filedialog.py`](src/Mod/CAM/Path/Tool/assets/ui/filedialog.py): + Provides file dialogs for importing and exporting assets. + +- [`util.py`](src/Mod/CAM/Path/Tool/assets/ui/util.py): Contains general utility + functions used within the asset manager UI. + +# What's next + +## Shorter term + +- Improving the integration of serializers. Ideally the asset manager could help here too: + We can define a common serializer protocol for **all** assets. It could then become the + central point for imports and exports. + + +## Potential future extensions (longer term) + +- Adding a AssetManager UI, to allow for browsing and searching stores for all kinds of + assets (Machines, Fixtures, Libraries, Tools, Shapes, Post Processors, ...) + from all kings of sources (online DB, git repository, etc.). + +- Adding a GitStore, to connect to things like the [FreeCAD library](https://github.com/FreeCAD/FreeCAD-library). + +- Adding an HttpStore for connectivity to online databases. diff --git a/src/Mod/CAM/Path/Tool/assets/__init__.py b/src/Mod/CAM/Path/Tool/assets/__init__.py new file mode 100644 index 0000000000..ad97f375cc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from .asset import Asset +from .manager import AssetManager +from .uri import AssetUri +from .serializer import AssetSerializer, DummyAssetSerializer +from .store.base import AssetStore +from .store.memory import MemoryStore +from .store.filestore import FileStore + +__all__ = [ + "Asset", + "AssetUri", + "AssetManager", + "AssetSerializer", + "DummyAssetSerializer", + "AssetStore", + "MemoryStore", + "FileStore", +] diff --git a/src/Mod/CAM/Path/Tool/assets/asset.py b/src/Mod/CAM/Path/Tool/assets/asset.py new file mode 100644 index 0000000000..f605d396d0 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/asset.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from __future__ import annotations +import abc +from abc import ABC +from typing import Mapping, List, Optional, Type, TYPE_CHECKING +from .uri import AssetUri + +if TYPE_CHECKING: + from .serializer import AssetSerializer + + +class Asset(ABC): + asset_type: str + + def __init__(self, *args, **kwargs): + if not hasattr(self, "asset_type"): + raise ValueError("Asset subclasses must define 'asset_type'.") + + @property + def label(self) -> str: + return self.__class__.__name__ + + @abc.abstractmethod + def get_id(self) -> str: + """Returns the unique ID of an asset object.""" + pass + + def get_uri(self) -> AssetUri: + return AssetUri.build(asset_type=self.asset_type, asset_id=self.get_id()) + + @classmethod + def resolve_name(cls, identifier: str) -> AssetUri: + """ + Resolves an identifier (id, name, or URI) to an AssetUri object. + """ + # 1. If the input is a url string, return the Uri object for it. + if AssetUri.is_uri(identifier): + return AssetUri(identifier) + + # 2. Construct the Uri using Uri.build() and return it + return AssetUri.build( + asset_type=cls.asset_type, + asset_id=identifier, + ) + + @classmethod + def get_uri_from_id(cls, asset_id): + return AssetUri.build(cls.asset_type, asset_id=asset_id) + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + return serializer.extract_dependencies(data) + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], + ) -> Asset: + """ + Creates an object from serialized data and resolved dependencies. + If dependencies is None, it indicates a shallow load where dependencies were not resolved. + """ + return serializer.deserialize(data, id, dependencies) + + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: + """Serializes an object into bytes.""" + return serializer.serialize(self) diff --git a/src/Mod/CAM/Path/Tool/assets/cache.py b/src/Mod/CAM/Path/Tool/assets/cache.py new file mode 100644 index 0000000000..bdbc77108d --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/cache.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import time +import hashlib +import logging +from collections import OrderedDict +from typing import Any, Dict, Set, NamedTuple, Optional, Tuple + +# For type hinting Asset and AssetUri to avoid circular imports +# from typing import TYPE_CHECKING +# if TYPE_CHECKING: +# from .asset import Asset +# from .uri import AssetUri + +logger = logging.getLogger(__name__) + + +class CacheKey(NamedTuple): + store_name: str + asset_uri_str: str + raw_data_hash: int + dependency_signature: Tuple + + +class CachedAssetEntry(NamedTuple): + asset: Any # Actually type Asset + size_bytes: int # Estimated size of the raw_data + timestamp: float # For LRU, or just use OrderedDict nature + + +class AssetCache: + def __init__(self, max_size_bytes: int = 100 * 1024 * 1024): # Default 100MB + self.max_size_bytes: int = max_size_bytes + self.current_size_bytes: int = 0 + + self._cache: Dict[CacheKey, CachedAssetEntry] = {} + self._lru_order: OrderedDict[CacheKey, None] = OrderedDict() + + self._cache_dependents_map: Dict[str, Set[CacheKey]] = {} + self._cache_dependencies_map: Dict[CacheKey, Set[str]] = {} + + def _evict_lru(self): + while self.current_size_bytes > self.max_size_bytes and self._lru_order: + oldest_key, _ = self._lru_order.popitem(last=False) + if oldest_key in self._cache: + evicted_entry = self._cache.pop(oldest_key) + self.current_size_bytes -= evicted_entry.size_bytes + logger.debug( + f"Cache Evict (LRU): {oldest_key}, " + f"size {evicted_entry.size_bytes}. " + f"New size: {self.current_size_bytes}" + ) + self._remove_key_from_dependency_maps(oldest_key) + + def _remove_key_from_dependency_maps(self, cache_key_to_remove: CacheKey): + direct_deps_of_removed = self._cache_dependencies_map.pop(cache_key_to_remove, set()) + for dep_uri_str in direct_deps_of_removed: + if dep_uri_str in self._cache_dependents_map: + self._cache_dependents_map[dep_uri_str].discard(cache_key_to_remove) + if not self._cache_dependents_map[dep_uri_str]: + del self._cache_dependents_map[dep_uri_str] + + def get(self, key: CacheKey) -> Optional[Any]: + if key in self._cache: + self._lru_order.move_to_end(key) + logger.debug(f"Cache HIT: {key}") + return self._cache[key].asset + logger.debug(f"Cache MISS: {key}") + return None + + def put( + self, + key: CacheKey, + asset: Any, + raw_data_size_bytes: int, + direct_dependency_uri_strs: Set[str], + ): + if key in self._cache: + self._remove_key_from_dependency_maps(key) + self.current_size_bytes -= self._cache[key].size_bytes + del self._cache[key] + self._lru_order.pop(key, None) + + if raw_data_size_bytes > self.max_size_bytes: + logger.warning( + f"Asset {key.asset_uri_str} (size {raw_data_size_bytes}) " + f"too large for cache (max {self.max_size_bytes}). Not caching." + ) + return + + self.current_size_bytes += raw_data_size_bytes + entry = CachedAssetEntry(asset=asset, size_bytes=raw_data_size_bytes, timestamp=time.time()) + self._cache[key] = entry + self._lru_order[key] = None + self._lru_order.move_to_end(key) + + self._cache_dependencies_map[key] = direct_dependency_uri_strs + for dep_uri_str in direct_dependency_uri_strs: + self._cache_dependents_map.setdefault(dep_uri_str, set()).add(key) + + logger.debug( + f"Cache PUT: {key}, size {raw_data_size_bytes}. " + f"Total cache size: {self.current_size_bytes}" + ) + self._evict_lru() + + def invalidate_for_uri(self, updated_asset_uri_str: str): + keys_to_remove_from_cache: Set[CacheKey] = set() + invalidation_queue: list[str] = [updated_asset_uri_str] + processed_uris_for_invalidation_round: Set[str] = set() + + while invalidation_queue: + current_uri_to_check_str = invalidation_queue.pop(0) + if current_uri_to_check_str in processed_uris_for_invalidation_round: + continue + processed_uris_for_invalidation_round.add(current_uri_to_check_str) + + for ck in list(self._cache.keys()): + if ck.asset_uri_str == current_uri_to_check_str: + keys_to_remove_from_cache.add(ck) + + dependent_cache_keys = self._cache_dependents_map.get( + current_uri_to_check_str, set() + ).copy() + + for dep_ck in dependent_cache_keys: + if dep_ck not in keys_to_remove_from_cache: + keys_to_remove_from_cache.add(dep_ck) + parent_uri_of_dep_ck = dep_ck.asset_uri_str + if parent_uri_of_dep_ck not in processed_uris_for_invalidation_round: + invalidation_queue.append(parent_uri_of_dep_ck) + + for ck_to_remove in keys_to_remove_from_cache: + if ck_to_remove in self._cache: + entry_to_remove = self._cache.pop(ck_to_remove) + self.current_size_bytes -= entry_to_remove.size_bytes + self._lru_order.pop(ck_to_remove, None) + self._remove_key_from_dependency_maps(ck_to_remove) + + if keys_to_remove_from_cache: + logger.debug( + f"Cache invalidated for URI '{updated_asset_uri_str}' and " + f"its dependents. Removed {len(keys_to_remove_from_cache)} " + f"entries. New size: {self.current_size_bytes}" + ) + + def clear(self): + self._cache.clear() + self._lru_order.clear() + self._cache_dependents_map.clear() + self._cache_dependencies_map.clear() + self.current_size_bytes = 0 + logger.info("AssetCache cleared.") diff --git a/src/Mod/CAM/Path/Tool/assets/docs/blender-assets.jpg b/src/Mod/CAM/Path/Tool/assets/docs/blender-assets.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ce4577ee63d477361dbd06821e0cd576fd753616 GIT binary patch literal 183010 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¨Pu-Wz@tzTTKN zMcscgp(!69&`{7(2((rAFrOe7OAR_>T|u!{%(6n}k7VJ?bW9ZUnTHN271AB5ZXp`s} zcvgOYmJKRxsO=ZAI+zU?pOn_@4aZcE8|DUkrCeQ?DdZ10D+@Zyt_!FeBMO?a6ZasZ zY?E!s-G#GF@N)iu)mw7X(MtIw@3DrKUQs^A*N2gJyRx$~#yZx{q}Un!S#j0vg3~ay z`f}}8uR?qbY~;wWIRl=fA~r?W7i9RF3!|-}!JuB<6MKb~3x-Bx9JmaweCzO5z>*c} zW169dAqU`UnRw5PtFQJLRh4vW*mwkgmN_l-V+>t~ftnk$PgED}LlA;HfbT{i-C zGn>%U0`F+cW9UBW{tkKLoe!a8m|`ekliZEt8oY;?`iANIq2+0Sq-17K&EthNXn@+3 z{5M4I;Rz?j6JUnip`;d#T9en2>e&7ma2z9WU~1Q%_Q&~Ns0$o_?pqV4?hL*;2PtXN z9$;A29D;x9a4tUryFnG@9O~OKBmBo~$4#=1@MM$aQpYLg%0DWp-|wmv2V5 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�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%0A*`g03m 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 literal 0 HcmV?d00001 diff --git a/src/Mod/CAM/Path/Tool/assets/manager.py b/src/Mod/CAM/Path/Tool/assets/manager.py new file mode 100644 index 0000000000..165344c588 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/manager.py @@ -0,0 +1,768 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import logging +import asyncio +import threading +import pathlib +import hashlib +from typing import Dict, Any, Type, Optional, List, Sequence, Union, Set, Mapping, Tuple +from dataclasses import dataclass +from PySide import QtCore, QtGui +from .store.base import AssetStore +from .asset import Asset +from .serializer import AssetSerializer +from .uri import AssetUri +from .cache import AssetCache, CacheKey + + +logger = logging.getLogger(__name__) + + +@dataclass +class _AssetConstructionData: + """Holds raw data and type info needed to construct an asset instance.""" + + uri: AssetUri + raw_data: bytes + asset_class: Type[Asset] + # Stores AssetConstructionData for dependencies, keyed by their AssetUri + dependencies_data: Optional[Dict[AssetUri, Optional["_AssetConstructionData"]]] = None + + +class AssetManager: + def __init__(self, cache_max_size_bytes: int = 100 * 1024 * 1024): + self.stores: Dict[str, AssetStore] = {} + self._serializers: List[Tuple[Type[AssetSerializer], Type[Asset]]] = [] + self._asset_classes: Dict[str, Type[Asset]] = {} + self.asset_cache = AssetCache(max_size_bytes=cache_max_size_bytes) + self._cacheable_stores: Set[str] = set() + logger.debug(f"AssetManager initialized (Thread: {threading.current_thread().name})") + + def register_store(self, store: AssetStore, cacheable: bool = False): + """Registers an AssetStore with the manager.""" + logger.debug(f"Registering store: {store.name}, cacheable: {cacheable}") + self.stores[store.name] = store + if cacheable: + self._cacheable_stores.add(store.name) + + def get_serializer_for_class(self, asset_class: Type[Asset]): + for serializer, theasset_class in self._serializers: + if issubclass(asset_class, theasset_class): + return serializer + raise ValueError(f"No serializer found for class {asset_class}") + + def register_asset(self, asset_class: Type[Asset], serializer: Type[AssetSerializer]): + """Registers an Asset class with the manager.""" + if not issubclass(asset_class, Asset): + raise TypeError(f"Item '{asset_class.__name__}' must be a subclass of Asset.") + if not issubclass(serializer, AssetSerializer): + raise TypeError(f"Item '{serializer.__name__}' must be a subclass of AssetSerializer.") + self._serializers.append((serializer, asset_class)) + + asset_type_name = getattr(asset_class, "asset_type", None) + if not isinstance(asset_type_name, str) or not asset_type_name: # Ensure not empty + raise TypeError( + f"Asset class '{asset_class.__name__}' must have a non-empty string 'asset_type' attribute." + ) + + logger.debug(f"Registering asset type: '{asset_type_name}' -> {asset_class.__name__}") + self._asset_classes[asset_type_name] = asset_class + + async def _fetch_asset_construction_data_recursive_async( + self, + uri: AssetUri, + store_name: str, + visited_uris: Set[AssetUri], + depth: Optional[int] = None, + ) -> Optional[_AssetConstructionData]: + logger.debug( + f"_fetch_asset_construction_data_recursive_async called {store_name} {uri} {depth}" + ) + + if uri in visited_uris: + logger.error(f"Cyclic dependency detected for URI: {uri}") + raise RuntimeError(f"Cyclic dependency encountered for URI: {uri}") + + # Check arguments + store = self.stores.get(store_name) + if not store: + raise ValueError(f"No store registered for name: {store_name}") + asset_class = self._asset_classes.get(uri.asset_type) + if not asset_class: + raise ValueError(f"No asset class registered for asset type: {asset_class}") + + # Fetch the requested asset + try: + raw_data = await store.get(uri) + except FileNotFoundError: + logger.debug( + f"_fetch_asset_construction_data_recursive_async: Asset not found for {uri}" + ) + return None # Primary asset not found + + if depth == 0: + return _AssetConstructionData( + uri=uri, + raw_data=raw_data, + asset_class=asset_class, + dependencies_data=None, # Indicates that no attempt was made to fetch deps + ) + + # Extract the list of dependencies (non-recursive) + serializer = self.get_serializer_for_class(asset_class) + dependency_uris = asset_class.extract_dependencies(raw_data, serializer) + + # Initialize deps_construction_data_map. Any dependencies mapped to None + # indicate that dependencies were intentionally not fetched. + deps_construction_data: Dict[AssetUri, Optional[_AssetConstructionData]] = {} + + for dep_uri in dependency_uris: + visited_uris.add(uri) + try: + dep_data = await self._fetch_asset_construction_data_recursive_async( + dep_uri, + store_name, + visited_uris, + None if depth is None else depth - 1, + ) + finally: + visited_uris.remove(uri) + deps_construction_data[dep_uri] = dep_data + + logger.debug( + f"ToolBitShape '{uri.asset_id}' dependencies_data: {deps_construction_data is None}" + ) + + return _AssetConstructionData( + uri=uri, + raw_data=raw_data, + asset_class=asset_class, + dependencies_data=deps_construction_data, # Can be None or Dict + ) + + def _calculate_cache_key_from_construction_data( + self, + construction_data: _AssetConstructionData, + store_name_for_cache: str, + ) -> Optional[CacheKey]: + if not construction_data or not construction_data.raw_data: + return None + + if construction_data.dependencies_data is None: + deps_signature_tuple: Tuple = ("shallow_children",) + else: + deps_signature_tuple = tuple( + sorted(str(uri) for uri in construction_data.dependencies_data.keys()) + ) + + raw_data_hash = int(hashlib.sha256(construction_data.raw_data).hexdigest(), 16) + + return CacheKey( + store_name=store_name_for_cache, + asset_uri_str=str(construction_data.uri), + raw_data_hash=raw_data_hash, + dependency_signature=deps_signature_tuple, + ) + + def _build_asset_tree_from_data_sync( + self, + construction_data: Optional[_AssetConstructionData], + store_name_for_cache: str, + ) -> Asset | None: + """ + Synchronously and recursively builds an asset instance. + Integrates caching logic. + """ + if not construction_data: + return None + + cache_key: Optional[CacheKey] = None + if store_name_for_cache in self._cacheable_stores: + cache_key = self._calculate_cache_key_from_construction_data( + construction_data, store_name_for_cache + ) + if cache_key: + cached_asset = self.asset_cache.get(cache_key) + if cached_asset is not None: + return cached_asset + + logger.debug( + f"BuildAssetTreeSync: Instantiating '{construction_data.uri}' " + f"of type '{construction_data.asset_class.__name__}'" + ) + + resolved_dependencies: Optional[Mapping[AssetUri, Any]] = None + if construction_data.dependencies_data is not None: + resolved_dependencies = {} + for ( + dep_uri, + dep_data_node, + ) in construction_data.dependencies_data.items(): + # Assuming dependencies are fetched from the same store context + # for caching purposes. If a dependency *could* be from a + # different store and that store has different cacheability, + # this would need more complex store_name propagation. + # For now, use the parent's store_name_for_cache. + try: + dep = self._build_asset_tree_from_data_sync(dep_data_node, store_name_for_cache) + except Exception as e: + logger.error( + f"Error building dependency '{dep_uri}' for asset '{construction_data.uri}': {e}", + exc_info=True, + ) + else: + resolved_dependencies[dep_uri] = dep + + asset_class = construction_data.asset_class + serializer = self.get_serializer_for_class(asset_class) + try: + final_asset = asset_class.from_bytes( + construction_data.raw_data, + construction_data.uri.asset_id, + resolved_dependencies, + serializer, + ) + except Exception as e: + logger.error( + f"Error instantiating asset '{construction_data.uri}' of type '{asset_class.__name__}': {e}", + exc_info=True, + ) + return None + + if final_asset is not None and cache_key: + # This check implies store_name_for_cache was in _cacheable_stores + direct_deps_uris_strs: Set[str] = set() + if construction_data.dependencies_data is not None: + direct_deps_uris_strs = { + str(uri) for uri in construction_data.dependencies_data.keys() + } + raw_data_size = len(construction_data.raw_data) + self.asset_cache.put( + cache_key, + final_asset, + raw_data_size, + direct_deps_uris_strs, + ) + return final_asset + + def get( + self, + uri: Union[AssetUri, str], + store: str = "local", + depth: Optional[int] = None, + ) -> Asset: + """ + Retrieves an asset by its URI (synchronous wrapper), to a specified depth. + IMPORTANT: Assumes this method is CALLED ONLY from the main UI thread + if Asset.from_bytes performs UI operations. + Depth None means infinite depth. Depth 0 means only this asset, no dependencies. + """ + # Log entry with thread info for verification + calling_thread_name = threading.current_thread().name + logger.debug( + f"AssetManager.get(uri='{uri}', store='{store}', depth='{depth}') called from thread: {calling_thread_name}" + ) + if ( + QtGui.QApplication.instance() + and QtCore.QThread.currentThread() is not QtGui.QApplication.instance().thread() + ): + logger.warning( + "AssetManager.get() called from a non-main thread! UI in from_bytes may fail!" + ) + + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + + # Step 1: Fetch all data using asyncio.run + try: + logger.debug( + f"Get: Starting asyncio.run for data fetching of '{asset_uri_obj}', depth {depth}." + ) + all_construction_data = asyncio.run( + self._fetch_asset_construction_data_recursive_async( + asset_uri_obj, store, set(), depth + ) + ) + logger.debug( + f"Get: asyncio.run for data fetching of '{asset_uri_obj}', depth {depth} completed." + ) + except Exception as e: + logger.error( + f"Get: Error during asyncio.run data fetching for '{asset_uri_obj}': {e}", + exc_info=False, + ) + raise # Re-raise the exception from the async part + + if all_construction_data is None: + # This means the top-level asset itself was not found by _fetch_... + raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in store '{store}'.") + + # Step 2: Synchronously build the asset tree (and call from_bytes) + # This happens in the current thread (which is assumed to be the main UI thread) + deps_count = 0 + found_deps_count = 0 + if all_construction_data.dependencies_data is not None: + deps_count = len(all_construction_data.dependencies_data) + found_deps_count = sum( + 1 + for d in all_construction_data.dependencies_data.values() + if d is not None # Count actual data, not None placeholders + ) + + logger.debug( + f"Get: Starting synchronous asset tree build for '{asset_uri_obj}' " + f"and {deps_count} dependencies ({found_deps_count} resolved)." + ) + final_asset = self._build_asset_tree_from_data_sync( + all_construction_data, store_name_for_cache=store + ) + logger.debug(f"Get: Synchronous asset tree build for '{asset_uri_obj}' completed.") + return final_asset + + def get_or_none( + self, + uri: Union[AssetUri, str], + store: str = "local", + depth: Optional[int] = None, + ) -> Asset | None: + """ + Convenience wrapper for get() that does not raise FileNotFoundError; returns + None instead + """ + try: + return self.get(uri, store, depth) + except FileNotFoundError: + return None + + async def get_async( + self, + uri: Union[AssetUri, str], + store: str = "local", + depth: Optional[int] = None, + ) -> Optional[Asset]: + """ + Retrieves an asset by its URI (asynchronous), to a specified depth. + NOTE: If Asset.from_bytes does UI work, this method should ideally be awaited + from an asyncio loop that is integrated with the main UI thread (e.g., via QtAsyncio). + If awaited from a plain worker thread's asyncio loop, from_bytes will run on that worker. + """ + calling_thread_name = threading.current_thread().name + logger.debug( + f"AssetManager.get_async(uri='{uri}', store='{store}', depth='{depth}') called from thread: {calling_thread_name}" + ) + + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + + all_construction_data = await self._fetch_asset_construction_data_recursive_async( + asset_uri_obj, store, set(), depth + ) + + if all_construction_data is None: + # Consistent with get(), if the top-level asset is not found, + # raise FileNotFoundError. + raise FileNotFoundError( + f"Asset '{asset_uri_obj}' not found in store '{store}' (async path)." + ) + # return None # Alternative: if Optional[Asset] means asset might not exist + + # Instantiation happens in the context of where this get_async was awaited. + logger.debug( + f"get_async: Building asset tree for '{asset_uri_obj}', depth {depth} in current async context." + ) + return self._build_asset_tree_from_data_sync( + all_construction_data, store_name_for_cache=store + ) + + def get_raw(self, uri: Union[AssetUri, str], store: str = "local") -> bytes: + """Retrieves raw asset data by its URI (synchronous wrapper).""" + logger.debug( + f"AssetManager.get_raw(uri='{uri}', store='{store}') from T:{threading.current_thread().name}" + ) + + async def _fetch_raw_async(): + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + logger.debug( + f"GetRawAsync (internal): Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + try: + selected_store = self.stores[store] + except KeyError: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.get(asset_uri_obj) + + try: + return asyncio.run(_fetch_raw_async()) + except Exception as e: + logger.error( + f"GetRaw: Error during asyncio.run for '{uri}': {e}", + exc_info=False, + ) + raise + + async def get_raw_async(self, uri: Union[AssetUri, str], store: str = "local") -> bytes: + """Retrieves raw asset data by its URI (asynchronous).""" + logger.debug( + f"AssetManager.get_raw_async(uri='{uri}', store='{store}') from T:{threading.current_thread().name}" + ) + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + selected_store = self.stores[store] + return await selected_store.get(asset_uri_obj) + + def get_bulk( + self, + uris: Sequence[Union[AssetUri, str]], + store: str = "local", + depth: Optional[int] = None, + ) -> List[Any]: + """Retrieves multiple assets by their URIs (synchronous wrapper), to a specified depth.""" + logger.debug( + f"AssetManager.get_bulk for {len(uris)} URIs from store '{store}', depth '{depth}'" + ) + + async def _fetch_all_construction_data_bulk_async(): + tasks = [ + self._fetch_asset_construction_data_recursive_async( + AssetUri(u) if isinstance(u, str) else u, + store, + set(), + depth, + ) + for u in uris + ] + # Gather all construction data concurrently + # return_exceptions=True means results list can contain exceptions + return await asyncio.gather(*tasks, return_exceptions=True) + + try: + logger.debug("GetBulk: Starting bulk data fetching") + all_construction_data_list = asyncio.run(_fetch_all_construction_data_bulk_async()) + logger.debug("GetBulk: bulk data fetching completed") + except Exception as e: # Should ideally not happen if gather returns exceptions + logger.error( + f"GetBulk: Unexpected error during asyncio.run for bulk data: {e}", + exc_info=False, + ) + raise + + assets = [] + for i, data_or_exc in enumerate(all_construction_data_list): + original_uri_input = uris[i] + # Explicitly re-raise exceptions found in the results list + if isinstance(data_or_exc, Exception): + logger.error( + f"GetBulk: Re-raising exception for '{original_uri_input}': {data_or_exc}", + exc_info=False, + ) + raise data_or_exc + elif isinstance(data_or_exc, _AssetConstructionData): + # Build asset instance synchronously. Exceptions during build should propagate. + assets.append( + self._build_asset_tree_from_data_sync(data_or_exc, store_name_for_cache=store) + ) + elif data_or_exc is None: # From _fetch_... returning None for not found + logger.debug(f"GetBulk: Asset '{original_uri_input}' not found") + assets.append(None) + else: # Should not happen + logger.error( + f"GetBulk: Unexpected item in construction data list for '{original_uri_input}': {type(data_or_exc)}" + ) + # Raise an exception for unexpected data types + raise RuntimeError( + f"Unexpected data type for {original_uri_input}: {type(data_or_exc)}" + ) + return assets + + async def get_bulk_async( + self, + uris: Sequence[Union[AssetUri, str]], + store: str = "local", + depth: Optional[int] = None, + ) -> List[Any]: + """Retrieves multiple assets by their URIs (asynchronous), to a specified depth.""" + logger.debug( + f"AssetManager.get_bulk_async for {len(uris)} URIs from store '{store}', depth '{depth}'" + ) + tasks = [ + self._fetch_asset_construction_data_recursive_async( + AssetUri(u) if isinstance(u, str) else u, store, set(), depth + ) + for u in uris + ] + all_construction_data_list = await asyncio.gather(*tasks, return_exceptions=True) + + assets = [] + for i, data_or_exc in enumerate(all_construction_data_list): + if isinstance(data_or_exc, _AssetConstructionData): + assets.append( + self._build_asset_tree_from_data_sync(data_or_exc, store_name_for_cache=store) + ) + elif isinstance(data_or_exc, FileNotFoundError) or data_or_exc is None: + assets.append(None) + elif isinstance(data_or_exc, Exception): + assets.append(data_or_exc) # Caller must check + return assets + + def fetch( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + store: str = "local", + depth: Optional[int] = None, + ) -> List[Asset]: + """Fetches asset instances based on type, limit, and offset (synchronous), to a specified depth.""" + logger.debug(f"Fetch(type='{asset_type}', store='{store}', depth='{depth}')") + asset_uris = self.list_assets( + asset_type, limit, offset, store + ) # list_assets doesn't need depth + results = self.get_bulk(asset_uris, store, depth) # Pass depth to get_bulk + # Filter out non-Asset objects (e.g., None for not found, or exceptions if collected) + return [asset for asset in results if isinstance(asset, Asset)] + + async def fetch_async( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + store: str = "local", + depth: Optional[int] = None, + ) -> List[Asset]: + """Fetches asset instances based on type, limit, and offset (asynchronous), to a specified depth.""" + logger.debug(f"FetchAsync(type='{asset_type}', store='{store}', depth='{depth}')") + asset_uris = await self.list_assets_async( + asset_type, limit, offset, store # list_assets_async doesn't need depth + ) + results = await self.get_bulk_async( + asset_uris, store, depth + ) # Pass depth to get_bulk_async + return [asset for asset in results if isinstance(asset, Asset)] + + def list_assets( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + store: str = "local", + ) -> List[AssetUri]: + logger.debug(f"ListAssets(type='{asset_type}', store='{store}')") + return asyncio.run(self.list_assets_async(asset_type, limit, offset, store)) + + async def list_assets_async( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + store: str = "local", + ) -> List[AssetUri]: + logger.debug(f"ListAssetsAsync executing for type='{asset_type}', store='{store}'") + logger.debug( + f"ListAssetsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + try: + selected_store = self.stores[store] + except KeyError: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.list_assets(asset_type, limit, offset) + + def count_assets( + self, + asset_type: Optional[str] = None, + store: str = "local", + ) -> int: + logger.debug(f"CountAssets(type='{asset_type}', store='{store}')") + return asyncio.run(self.count_assets_async(asset_type, store)) + + async def count_assets_async( + self, + asset_type: Optional[str] = None, + store: str = "local", + ) -> int: + logger.debug(f"CountAssetsAsync executing for type='{asset_type}', store='{store}'") + logger.debug( + f"CountAssetsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + try: + selected_store = self.stores[store] + except KeyError: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.count_assets(asset_type) + + def _is_registered_type(self, obj: Asset) -> bool: + """Helper to extract asset_type, id, and data from an object instance.""" + for registered_class_type in self._asset_classes.values(): + if isinstance(obj, registered_class_type): + return True + return False + + async def add_async(self, obj: Asset, store: str = "local") -> AssetUri: + """ + Adds an asset to the store, either creating a new one or updating an existing one. + Uses obj.get_url() to determine if the asset exists. + """ + logger.debug(f"AddAsync: Adding {type(obj).__name__} to store '{store}'") + uri = obj.get_uri() + if not self._is_registered_type(obj): + logger.warning(f"Asset has unregistered type '{uri.asset_type}' ({type(obj).__name__})") + + serializer = self.get_serializer_for_class(obj.__class__) + data = obj.to_bytes(serializer) + return await self.add_raw_async(uri.asset_type, uri.asset_id, data, store) + + def add(self, obj: Asset, store: str = "local") -> AssetUri: + """Synchronous wrapper for adding an asset to the store.""" + logger.debug( + f"Add: Adding {type(obj).__name__} to store '{store}' from T:{threading.current_thread().name}" + ) + return asyncio.run(self.add_async(obj, store)) + + async def add_raw_async( + self, asset_type: str, asset_id: str, data: bytes, store: str = "local" + ) -> AssetUri: + """ + Adds raw asset data to the store, either creating a new asset or updating an existing one. + """ + logger.debug(f"AddRawAsync: type='{asset_type}', id='{asset_id}', store='{store}'") + if not asset_type or not asset_id: + raise ValueError("asset_type and asset_id must be provided for add_raw.") + if not isinstance(data, bytes): + raise TypeError("Data for add_raw must be bytes.") + selected_store = self.stores.get(store) + if not selected_store: + raise ValueError(f"No store registered for name: {store}") + uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id) + try: + uri = await selected_store.update(uri, data) + logger.debug(f"AddRawAsync: Updated existing asset at {uri}") + except FileNotFoundError: + logger.debug( + f"AddRawAsync: Asset not found, creating new asset with {asset_type} and {asset_id}" + ) + uri = await selected_store.create(asset_type, asset_id, data) + + if store in self._cacheable_stores: + self.asset_cache.invalidate_for_uri(str(uri)) # Invalidate after add/update + return uri + + def add_raw( + self, asset_type: str, asset_id: str, data: bytes, store: str = "local" + ) -> AssetUri: + """Synchronous wrapper for adding raw asset data to the store.""" + logger.debug( + f"AddRaw: type='{asset_type}', id='{asset_id}', store='{store}' from T:{threading.current_thread().name}" + ) + try: + return asyncio.run(self.add_raw_async(asset_type, asset_id, data, store)) + except Exception as e: + logger.error( + f"AddRaw: Error for type='{asset_type}', id='{asset_id}': {e}", exc_info=False + ) + raise + + def add_file( + self, + asset_type: str, + path: pathlib.Path, + store: str = "local", + asset_id: str | None = None, + ) -> AssetUri: + """ + Convenience wrapper around add_raw(). + If asset_id is None, the path.stem is used as the id. + """ + return self.add_raw(asset_type, asset_id or path.stem, path.read_bytes(), store=store) + + def delete(self, uri: Union[AssetUri, str], store: str = "local") -> None: + logger.debug(f"Delete URI '{uri}' from store '{store}'") + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + + async def _do_delete_async(): + selected_store = self.stores[store] + await selected_store.delete(asset_uri_obj) + if store in self._cacheable_stores: + self.asset_cache.invalidate_for_uri(str(asset_uri_obj)) + + asyncio.run(_do_delete_async()) + + async def delete_async(self, uri: Union[AssetUri, str], store: str = "local") -> None: + logger.debug(f"DeleteAsync URI '{uri}' from store '{store}'") + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + selected_store = self.stores[store] + await selected_store.delete(asset_uri_obj) + if store in self._cacheable_stores: + self.asset_cache.invalidate_for_uri(str(asset_uri_obj)) + + async def is_empty_async(self, asset_type: Optional[str] = None, store: str = "local") -> bool: + """Checks if the asset store has any assets of a given type (asynchronous).""" + logger.debug(f"IsEmptyAsync: type='{asset_type}', store='{store}'") + logger.debug( + f"IsEmptyAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + selected_store = self.stores.get(store) + if not selected_store: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.is_empty(asset_type) + + def is_empty(self, asset_type: Optional[str] = None, store: str = "local") -> bool: + """Checks if the asset store has any assets of a given type (synchronous wrapper).""" + logger.debug( + f"IsEmpty: type='{asset_type}', store='{store}' from T:{threading.current_thread().name}" + ) + try: + return asyncio.run(self.is_empty_async(asset_type, store)) + except Exception as e: + logger.error( + f"IsEmpty: Error for type='{asset_type}', store='{store}': {e}", + exc_info=False, + ) # Changed exc_info to False + raise + + async def list_versions_async( + self, uri: Union[AssetUri, str], store: str = "local" + ) -> List[AssetUri]: + """Lists available versions for a given asset URI (asynchronous).""" + logger.debug(f"ListVersionsAsync: uri='{uri}', store='{store}'") + asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri + + logger.debug( + f"ListVersionsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}" + ) + selected_store = self.stores.get(store) + if not selected_store: + raise ValueError(f"No store registered for name: {store}") + return await selected_store.list_versions(asset_uri_obj) + + def list_versions(self, uri: Union[AssetUri, str], store: str = "local") -> List[AssetUri]: + """Lists available versions for a given asset URI (synchronous wrapper).""" + logger.debug( + f"ListVersions: uri='{uri}', store='{store}' from T:{threading.current_thread().name}" + ) + try: + return asyncio.run(self.list_versions_async(uri, store)) + except Exception as e: + logger.error( + f"ListVersions: Error for uri='{uri}', store='{store}': {e}", + exc_info=False, + ) # Changed exc_info to False + return [] # Return empty list on error to satisfy type hint + + def get_registered_asset_types(self) -> List[str]: + """Returns a list of registered asset type names.""" + return list(self._asset_classes.keys()) diff --git a/src/Mod/CAM/Path/Tool/assets/serializer.py b/src/Mod/CAM/Path/Tool/assets/serializer.py new file mode 100644 index 0000000000..327cc4680f --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/serializer.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import abc +from abc import ABC +from typing import Mapping, List, Optional, Tuple, Type +from .uri import AssetUri +from .asset import Asset + + +class AssetSerializer(ABC): + for_class: Type[Asset] + extensions: Tuple[str] = tuple() + mime_type: str + can_import: bool = True + can_export: bool = True + + @classmethod + @abc.abstractmethod + def get_label(cls) -> str: + pass + + @classmethod + @abc.abstractmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + pass + + @classmethod + @abc.abstractmethod + def serialize(cls, asset: Asset) -> bytes: + """Serializes an asset object into bytes.""" + pass + + @classmethod + @abc.abstractmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> "Asset": + """ + Creates an asset object from serialized data and resolved dependencies. + If dependencies is None, it indicates a shallow load where dependencies + were not resolved. + """ + pass + + @classmethod + @abc.abstractmethod + def deep_deserialize(cls, data: bytes) -> Asset: + """ + Like deserialize(), but builds dependencies itself if they are + sufficiently defined in the data. + + This method is used for export/import, where some dependencies + may be embedded in the data, while others may not. + """ + pass + + +class DummyAssetSerializer(AssetSerializer): + """ + A serializer that does nothing. Can be used by simple assets that don't + need a non-native serialization. These type of assets can implement + extract_dependencies(), to_bytes() and from_bytes() methods that ignore + the given serializer. + """ + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + return b"" + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> Asset: + raise RuntimeError("DummySerializer.deserialize() was called") diff --git a/src/Mod/CAM/Path/Tool/assets/store/__init__.py b/src/Mod/CAM/Path/Tool/assets/store/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/assets/store/base.py b/src/Mod/CAM/Path/Tool/assets/store/base.py new file mode 100644 index 0000000000..3722d08839 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/store/base.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import abc +from typing import List +from ..uri import AssetUri + + +class AssetStore(abc.ABC): + """ + Abstract base class for storing and retrieving asset data as raw bytes. + + Stores are responsible for handling the low-level interaction with a + specific storage backend (e.g., local filesystem, HTTP server) based + on the URI protocol. + """ + + def __init__(self, name: str, *args, **kwargs): + self.name = name + + @abc.abstractmethod + async def get(self, uri: AssetUri) -> bytes: + """ + Retrieve the raw byte data for the asset at the given URI. + + Args: + uri: The unique identifier for the asset. + + Returns: + The raw byte data of the asset. + + Raises: + FileNotFoundError: If the asset does not exist at the URI. + # Other store-specific exceptions may be raised. + """ + raise NotImplementedError + + @abc.abstractmethod + async def delete(self, uri: AssetUri) -> None: + """ + Delete the asset at the given URI. + + Args: + uri: The unique identifier for the asset to delete. + + Raises: + FileNotFoundError: If the asset does not exist at the URI. + # Other store-specific exceptions may be raised. + """ + raise NotImplementedError + + @abc.abstractmethod + async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri: + """ + Create a new asset in the store with the given data. + + The store determines the final URI for the new asset. The + `asset_type` can be used to influence the storage location + or URI structure (e.g., as part of the path). + + Args: + asset_type: The type of the asset (e.g., 'material', + 'toolbitshape'). + asset_id: The unique identifier for the asset. + data: The raw byte data of the asset to create. + + Returns: + The URI of the newly created asset. + + Raises: + # Store-specific exceptions may be raised (e.g., write errors). + """ + raise NotImplementedError + + @abc.abstractmethod + async def update(self, uri: AssetUri, data: bytes) -> AssetUri: + """ + Update the asset at the given URI with new data, creating a new version. + + Args: + uri: The unique identifier of the asset to update. + data: The new raw byte data for the asset. + + Raises: + FileNotFoundError: If the asset does not exist at the URI. + # Other store-specific exceptions may be raised (e.g., write errors). + """ + raise NotImplementedError + + @abc.abstractmethod + async def list_assets( + self, asset_type: str | None = None, limit: int | None = None, offset: int | None = None + ) -> List[AssetUri]: + """ + List assets in the store, optionally filtered by asset type and + with pagination. For versioned stores, this lists the latest + version of each asset. + + Args: + asset_type: Optional filter for asset type. + limit: Maximum number of assets to return. + offset: Number of assets to skip from the beginning. + + Returns: + A list of URIs for the assets. + """ + raise NotImplementedError + + @abc.abstractmethod + async def count_assets(self, asset_type: str | None = None) -> int: + """ + Counts assets in the store, optionally filtered by asset type. + + Args: + asset_type: Optional filter for asset type. + + Returns: + The number of assets. + """ + raise NotImplementedError + + @abc.abstractmethod + async def list_versions(self, uri: AssetUri) -> List[AssetUri]: + """ + Lists available version identifiers for a specific asset URI. + + Args: + uri: The URI of the asset (version component is ignored). + + Returns: + A list of URIs pointing to the specific versions of the asset. + """ + raise NotImplementedError + + @abc.abstractmethod + async def is_empty(self, asset_type: str | None = None) -> bool: + """ + Checks if the store contains any assets, optionally filtered by asset + type. + + Args: + asset_type: Optional filter for asset type. + + Returns: + True if the store is empty (or empty for the given asset type), + False otherwise. + """ + raise NotImplementedError diff --git a/src/Mod/CAM/Path/Tool/assets/store/filestore.py b/src/Mod/CAM/Path/Tool/assets/store/filestore.py new file mode 100644 index 0000000000..753d8d9cac --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/store/filestore.py @@ -0,0 +1,501 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import re +import pathlib +from typing import List, Dict, Tuple, Optional, cast +from ..uri import AssetUri +from .base import AssetStore + + +class FileStore(AssetStore): + """ + Asset store implementation for the local filesystem with optional + versioning. + + Maps URIs of the form ://[/] + to paths within a base directory. + + The mapping to file system paths is configurable depending on the asset + type. Example mapping: + + mapping = { + "*": "{asset_type}/{asset_id}/{version}.dat", + "model": "models_dir/{asset_id}-{version}.ml", + "dataset": "data/{asset_id}.csv" # Unversioned (conceptual version "1") + } + + Placeholders like {version} are matched greedily (.*), but for compatibility, + versions are expected to be numeric strings for versioned assets. + """ + + DEFAULT_MAPPING = { + # Default from original problem doc was "{asset_type}/{asset_id}/{id}/" + # Adjusted to a more common simple case: + "*": "{asset_type}/{asset_id}/{version}" + } + + KNOWN_PLACEHOLDERS = {"asset_type", "asset_id", "id", "version"} + + def __init__( + self, + name: str, + base_dir: pathlib.Path, + mapping: Optional[Dict[str, str]] = None, + ): + super().__init__(name) + self._base_dir = base_dir.resolve() + self._mapping = mapping if mapping is not None else self.DEFAULT_MAPPING.copy() + self._validate_patterns_on_init() + # For _path_to_uri: iterate specific keys before '*' to ensure correct pattern matching + self._sorted_mapping_keys = sorted(self._mapping.keys(), key=lambda k: (k == "*", k)) + + def _validate_patterns_on_init(self): + if not self._mapping: + raise ValueError("Asset store mapping cannot be empty.") + + for asset_type_key, path_format in self._mapping.items(): + if not isinstance(path_format, str): + raise TypeError(f"Path format for key '{asset_type_key}' must be a string.") + + placeholders_in_format = set(re.findall(r"\{([^}]+)\}", path_format)) + for ph_name in placeholders_in_format: + if ph_name not in self.KNOWN_PLACEHOLDERS: + raise ValueError( + f"Unknown placeholder {{{ph_name}}} in pattern: '{path_format}'. Allowed: {self.KNOWN_PLACEHOLDERS}" + ) + + has_asset_id_ph = "asset_id" in placeholders_in_format or "id" in placeholders_in_format + if not has_asset_id_ph: + raise ValueError( + f"Pattern '{path_format}' for key '{asset_type_key}' must include {{asset_id}} or {{id}}." + ) + + # CORRECTED LINE: Check for the placeholder name "asset_type" not "{asset_type}" + if asset_type_key == "*" and "asset_type" not in placeholders_in_format: + raise ValueError( + f"Pattern '{path_format}' for wildcard key '*' must include {{asset_type}}." + ) + + @staticmethod + def _match_path_to_format_string(format_str: str, path_str_posix: str) -> Dict[str, str]: + """Matches a POSIX-style path string against a format string.""" + tokens = re.split(r"\{(.*?)\}", format_str) # format_str uses / + if len(tokens) == 1: # No placeholders + if format_str == path_str_posix: + return {} + raise ValueError(f"Path '{path_str_posix}' does not match pattern '{format_str}'") + + keywords = tokens[1::2] + regex_parts = [] + for i, literal_part in enumerate(tokens[0::2]): + # Literal parts from format_str (using /) are escaped. + # The path_str_posix is already normalized to /, so direct matching works. + regex_parts.append(re.escape(literal_part)) + if i < len(keywords): + regex_parts.append(f"(?P<{keywords[i]}>.*)") + + pattern_regex_str = "".join(regex_parts) + match_obj = re.fullmatch(pattern_regex_str, path_str_posix) + + if not match_obj: + raise ValueError( + f"Path '{path_str_posix}' does not match format '{format_str}' (regex: '{pattern_regex_str}')" + ) + return {kw: match_obj.group(kw) for kw in keywords} + + def _get_path_format_for_uri_type(self, uri_asset_type: str) -> str: + if uri_asset_type in self._mapping: + return self._mapping[uri_asset_type] + if "*" in self._mapping: + return self._mapping["*"] + raise ValueError( + f"No mapping pattern for asset_type '{uri_asset_type}' and no '*' fallback." + ) + + def _path_to_uri(self, file_path: pathlib.Path) -> Optional[AssetUri]: + """Converts a filesystem path to an AssetUri, if it matches a pattern.""" + if not file_path.is_file(): + return None + + try: + # Convert to relative path object first, then to POSIX string for matching + relative_path_obj = file_path.relative_to(self._base_dir) + relative_path_posix = relative_path_obj.as_posix() + except ValueError: + return None # Path not under base_dir + + for asset_type_key in self._sorted_mapping_keys: + path_format_str = self._mapping[asset_type_key] # Pattern uses / + try: + components = FileStore._match_path_to_format_string( + path_format_str, relative_path_posix + ) + + asset_id = components.get("asset_id", components.get("id")) + if not asset_id: + continue + + current_asset_type: str + if "{asset_type}" in path_format_str: + current_asset_type = components.get("asset_type", "") + if not current_asset_type: + continue + else: + current_asset_type = asset_type_key + if current_asset_type == "*": + continue # Invalid state, caught by validation + + version_str: str + if "{version}" in path_format_str: + version_str = components.get("version", "") + if not version_str or not version_str.isdigit(): + continue + else: + version_str = "1" + + return AssetUri.build( + asset_type=current_asset_type, + asset_id=asset_id, + version=version_str, + ) + except ValueError: # No match + continue + return None + + def set_dir(self, new_dir: pathlib.Path): + """Sets the base directory for the store.""" + self._base_dir = new_dir.resolve() + + def _uri_to_path(self, uri: AssetUri) -> pathlib.Path: + """Converts an AssetUri to a filesystem path using mapping.""" + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + + format_values: Dict[str, str] = { + "asset_type": uri.asset_type, + "asset_id": uri.asset_id, + "id": uri.asset_id, + } + + # Only add 'version' to format_values if the pattern expects it AND uri.version is set. + # uri.version must be a string for .format() (e.g. "1", not None). + if "{version}" in path_format_str: + if uri.version is None: + # This state implies an issue: a versioned pattern is being used + # but the URI hasn't had its version appropriately set (e.g. to "1" for create, + # or resolved from "latest"). + raise ValueError( + f"URI version is None for versioned pattern '{path_format_str}'. URI: {uri}" + ) + format_values["version"] = uri.version + + try: + # Patterns use '/', pathlib handles OS-specific path construction. + resolved_path_str = path_format_str.format(**format_values) + except KeyError as e: + raise ValueError( + f"Pattern '{path_format_str}' placeholder {{{e}}} missing in URI data for {uri}." + ) + + return self._base_dir / resolved_path_str + + async def get(self, uri: AssetUri) -> bytes: + """Retrieve the raw byte data for the asset at the given URI.""" + path_to_read: pathlib.Path + + if uri.version == "latest": + query_uri = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + params=uri.params, + ) + versions = await self.list_versions(query_uri) + if not versions: + raise FileNotFoundError(f"No versions found for {uri.asset_type}://{uri.asset_id}") + latest_version_uri = versions[-1] # list_versions now returns AssetUri with params + path_to_read = self._uri_to_path(latest_version_uri) + else: + request_uri = uri + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + is_versioned_pattern = "{version}" in path_format_str + + if not is_versioned_pattern: + if uri.version is not None and uri.version != "1": + raise FileNotFoundError( + f"Asset type '{uri.asset_type}' is unversioned. " + f"Version '{uri.version}' invalid for URI {uri}. Use '1' or no version." + ) + if uri.version is None: # Conceptual "type://id" -> "type://id/1" + request_uri = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version="1", + params=uri.params, + ) + elif ( + uri.version is None + ): # Versioned pattern but URI has version=None (and not "latest") + raise FileNotFoundError( + f"Version required for asset type '{uri.asset_type}' (pattern: '{path_format_str}'). URI: {uri}" + ) + path_to_read = self._uri_to_path(request_uri) + + try: + with open(path_to_read, mode="rb") as f: + return f.read() + except FileNotFoundError: + raise FileNotFoundError(f"Asset for URI {uri} not found at path {path_to_read}") + except IsADirectoryError: + raise FileNotFoundError(f"Asset URI {uri} resolved to a directory: {path_to_read}") + + async def delete(self, uri: AssetUri) -> None: + """Delete the asset at the given URI.""" + paths_to_delete: List[pathlib.Path] = [] + parent_dirs_of_deleted_files = set() # To track for cleanup + + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + is_versioned_pattern = "{version}" in path_format_str + + if uri.version is None: # Delete all versions or the single unversioned file + for path_obj in self._base_dir.rglob("*"): + parsed_uri = self._path_to_uri(path_obj) + if ( + parsed_uri + and parsed_uri.asset_type == uri.asset_type + and parsed_uri.asset_id == uri.asset_id + ): + paths_to_delete.append(path_obj) + else: # Delete a specific version or an unversioned file (if version is "1") + target_uri_for_path = uri + if not is_versioned_pattern: + if uri.version != "1": + return # Idempotent: non-"1" version of unversioned asset "deleted" + target_uri_for_path = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version="1", + params=uri.params, + ) + + path = self._uri_to_path(target_uri_for_path) + if path.is_file(): + paths_to_delete.append(path) + + for p_del in paths_to_delete: + try: + p_del.unlink() + parent_dirs_of_deleted_files.add(p_del.parent) + except FileNotFoundError: + pass + + # Clean up empty parent directories, from deepest first + sorted_parents = sorted( + list(parent_dirs_of_deleted_files), + key=lambda p: len(p.parts), + reverse=True, + ) + for parent_dir in sorted_parents: + current_cleanup_path = parent_dir + while ( + current_cleanup_path.exists() + and current_cleanup_path.is_dir() + and current_cleanup_path != self._base_dir + and current_cleanup_path.is_relative_to(self._base_dir) + and not any(current_cleanup_path.iterdir()) + ): + try: + current_cleanup_path.rmdir() + current_cleanup_path = current_cleanup_path.parent + except OSError: + break + + async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri: + """Create a new asset in the store with the given data.""" + # New assets are conceptually version "1" + uri_to_create = AssetUri.build(asset_type=asset_type, asset_id=asset_id, version="1") + asset_path = self._uri_to_path(uri_to_create) + + if asset_path.exists(): + # More specific error messages based on what exists + if asset_path.is_file(): + raise FileExistsError(f"Asset file already exists at {asset_path}") + if asset_path.is_dir(): + raise IsADirectoryError(f"A directory exists at target path {asset_path}") + raise FileExistsError(f"Path {asset_path} already exists (unknown type).") + + asset_path.parent.mkdir(parents=True, exist_ok=True) + with open(asset_path, mode="wb") as f: + f.write(data) + return uri_to_create + + async def update(self, uri: AssetUri, data: bytes) -> AssetUri: + """Update the asset at the given URI with new data, creating a new version.""" + # Get a Uri without the version number, use it to find all versions. + query_uri = AssetUri.build( + asset_type=uri.asset_type, asset_id=uri.asset_id, params=uri.params + ) + existing_versions = await self.list_versions(query_uri) + if not existing_versions: + raise FileNotFoundError( + f"No versions for asset {uri.asset_type}://{uri.asset_id} to update." + ) + + # Create a Uri for the NEXT version number. + latest_version_uri = existing_versions[-1] + latest_version_num = int(cast(str, latest_version_uri.version)) + next_version_str = str(latest_version_num + 1) + next_uri = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version=next_version_str, + params=uri.params, + ) + asset_path = self._uri_to_path(next_uri) + + # If the file is versioned, then the new version should not yet exist. + # Double check to be sure. + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + is_versioned_pattern = "{version}" in path_format_str + if asset_path.exists() and is_versioned_pattern: + raise FileExistsError(f"Asset path for new version {asset_path} already exists.") + + # Done. Write to disk. + asset_path.parent.mkdir(parents=True, exist_ok=True) + with open(asset_path, mode="wb") as f: + f.write(data) + return next_uri + + async def list_assets( + self, + asset_type: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> List[AssetUri]: + """ + List assets in the store, optionally filtered by asset type and + with pagination. For versioned stores, this lists the latest + version of each asset. + """ + latest_asset_versions: Dict[Tuple[str, str], str] = {} + + for path_obj in self._base_dir.rglob("*"): + parsed_uri = self._path_to_uri(path_obj) + if parsed_uri: + if asset_type is not None and parsed_uri.asset_type != asset_type: + continue + + key = (parsed_uri.asset_type, parsed_uri.asset_id) + current_version_str = cast(str, parsed_uri.version) # Is "1" or numeric string + + if key not in latest_asset_versions or int(current_version_str) > int( + latest_asset_versions[key] + ): + latest_asset_versions[key] = current_version_str + + result_uris: List[AssetUri] = [ + AssetUri.build( + asset_type=atype, asset_id=aid, version=vstr + ) # Params not included in list_assets results + for (atype, aid), vstr in latest_asset_versions.items() + ] + result_uris.sort(key=lambda u: (u.asset_type, u.asset_id, int(cast(str, u.version)))) + + start = offset if offset is not None else 0 + end = start + limit if limit is not None else len(result_uris) + return result_uris[start:end] + + async def count_assets(self, asset_type: Optional[str] = None) -> int: + """ + Counts assets in the store, optionally filtered by asset type. + """ + unique_assets: set[Tuple[str, str]] = set() + + for path_obj in self._base_dir.rglob("*"): + parsed_uri = self._path_to_uri(path_obj) + if parsed_uri: + if asset_type is not None and parsed_uri.asset_type != asset_type: + continue + unique_assets.add((parsed_uri.asset_type, parsed_uri.asset_id)) + + return len(unique_assets) + + async def list_versions(self, uri: AssetUri) -> List[AssetUri]: + """ + Lists available version identifiers for a specific asset URI. + Args: + uri: The URI of the asset (version component is ignored, params preserved). + Returns: + A list of AssetUri objects, sorted by version in ascending order. + """ + if uri.asset_id is None: + raise ValueError(f"Asset ID must be specified for listing versions: {uri}") + + path_format_str = self._get_path_format_for_uri_type(uri.asset_type) + is_versioned_pattern = "{version}" in path_format_str + + if not is_versioned_pattern: + # Check existence of the single unversioned file + # Conceptual version is "1", params from input URI are preserved + path_check_uri = AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version="1", + params=uri.params, + ) + path_to_asset = self._uri_to_path(path_check_uri) + if path_to_asset.is_file(): + return [path_check_uri] # Returns URI with version "1" and original params + return [] + + found_versions_strs: List[str] = [] + for path_obj in self._base_dir.rglob("*"): + parsed_uri = self._path_to_uri(path_obj) # This parsed_uri does not have params + if ( + parsed_uri + and parsed_uri.asset_type == uri.asset_type + and parsed_uri.asset_id == uri.asset_id + ): + # Version from path is guaranteed numeric string by _path_to_uri for versioned patterns + found_versions_strs.append(cast(str, parsed_uri.version)) + + if not found_versions_strs: + return [] + sorted_unique_versions = sorted(list(set(found_versions_strs)), key=int) + + return [ + AssetUri.build( + asset_type=uri.asset_type, + asset_id=uri.asset_id, + version=v_str, + params=uri.params, + ) + for v_str in sorted_unique_versions + ] + + async def is_empty(self, asset_type: Optional[str] = None) -> bool: + """ + Checks if the store contains any assets, optionally filtered by asset + type. + """ + # Reuses list_assets which iterates files. + # Limit=1 makes it stop after finding the first asset. + assets = await self.list_assets(asset_type=asset_type, limit=1) + return not bool(assets) diff --git a/src/Mod/CAM/Path/Tool/assets/store/memory.py b/src/Mod/CAM/Path/Tool/assets/store/memory.py new file mode 100644 index 0000000000..6410b5acef --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/store/memory.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pprint +from typing import Dict, List, Optional +from ..uri import AssetUri +from .base import AssetStore + + +class MemoryStore(AssetStore): + """ + An in-memory implementation of the AssetStore. + + This store keeps all asset data in memory and is primarily intended for + testing and demonstration purposes. It does not provide persistence. + """ + + def __init__(self, name: str, *args, **kwargs): + super().__init__(name, *args, **kwargs) + self._data: Dict[str, Dict[str, Dict[str, bytes]]] = {} + self._versions: Dict[str, Dict[str, List[str]]] = {} + + async def get(self, uri: AssetUri) -> bytes: + asset_type = uri.asset_type + asset_id = uri.asset_id + version = uri.version or self._get_latest_version(asset_type, asset_id) + + if ( + asset_type not in self._data + or asset_id not in self._data[asset_type] + or version not in self._data[asset_type][asset_id] + ): + raise FileNotFoundError(f"Asset not found: {uri}") + + return self._data[asset_type][asset_id][version] + + async def delete(self, uri: AssetUri) -> None: + asset_type = uri.asset_type + asset_id = uri.asset_id + version = uri.version # Capture the version from the URI + + if asset_type not in self._data or asset_id not in self._data[asset_type]: + # Deleting non-existent asset should not raise an error + return + + if version: + # If a version is specified, try to delete only that version + if version in self._data[asset_type][asset_id]: + del self._data[asset_type][asset_id][version] + # Remove version from the versions list + if ( + asset_type in self._versions + and asset_id in self._versions[asset_type] + and version in self._versions[asset_type][asset_id] + ): + self._versions[asset_type][asset_id].remove(version) + + # If no versions left for this asset_id, clean up + if not self._data[asset_type][asset_id]: + del self._data[asset_type][asset_id] + if asset_type in self._versions and asset_id in self._versions[asset_type]: + del self._versions[asset_type][asset_id] + else: + # If no version is specified, delete the entire asset + del self._data[asset_type][asset_id] + if asset_type in self._versions and asset_id in self._versions[asset_type]: + del self._versions[asset_type][asset_id] + + # Clean up empty asset types + if asset_type in self._data and not self._data[asset_type]: + del self._data[asset_type] + if asset_type in self._versions and not self._versions[asset_type]: + del self._versions[asset_type] + + async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri: + if asset_type not in self._data: + self._data[asset_type] = {} + self._versions[asset_type] = {} + + if asset_id in self._data[asset_type]: + # For simplicity, create overwrites existing in this memory store + # A real store might handle this differently or raise an error + pass + + if asset_id not in self._data[asset_type]: + self._data[asset_type][asset_id] = {} + self._versions[asset_type][asset_id] = [] + + version = "1" + self._data[asset_type][asset_id][version] = data + self._versions[asset_type][asset_id].append(version) + + return AssetUri(f"{asset_type}://{asset_id}/{version}") + + async def update(self, uri: AssetUri, data: bytes) -> AssetUri: + asset_type = uri.asset_type + asset_id = uri.asset_id + + if asset_type not in self._data or asset_id not in self._data[asset_type]: + raise FileNotFoundError(f"Asset not found for update: {uri}") + + # Update should create a new version + latest_version = self._get_latest_version(asset_type, asset_id) + version = str(int(latest_version or 0) + 1) + + self._data[asset_type][asset_id][version] = data + self._versions[asset_type][asset_id].append(version) + + return AssetUri(f"{asset_type}://{asset_id}/{version}") + + async def list_assets( + self, asset_type: str | None = None, limit: int | None = None, offset: int | None = None + ) -> List[AssetUri]: + all_uris: List[AssetUri] = [] + for current_type, assets in self._data.items(): + if asset_type is None or current_type == asset_type: + for asset_id in assets: + latest_version = self._get_latest_version(current_type, asset_id) + if latest_version: + all_uris.append(AssetUri(f"{current_type}://{asset_id}/{latest_version}")) + + # Apply offset and limit + start = offset if offset is not None else 0 + end = start + limit if limit is not None else len(all_uris) + return all_uris[start:end] + + async def count_assets(self, asset_type: str | None = None) -> int: + """ + Counts assets in the store, optionally filtered by asset type. + """ + if asset_type is None: + count = 0 + for assets_by_id in self._data.values(): + count += len(assets_by_id) + return count + else: + if asset_type in self._data: + return len(self._data[asset_type]) + return 0 + + async def list_versions(self, uri: AssetUri) -> List[AssetUri]: + asset_type = uri.asset_type + asset_id = uri.asset_id + + if asset_type not in self._versions or asset_id not in self._versions[asset_type]: + return [] + + version_uris: List[AssetUri] = [] + for version in self._versions[asset_type][asset_id]: + version_uris.append(AssetUri(f"{asset_type}://{asset_id}/{version}")) + return version_uris + + async def is_empty(self, asset_type: str | None = None) -> bool: + if asset_type is None: + return not bool(self._data) + else: + return asset_type not in self._data or not bool(self._data[asset_type]) + + def _get_latest_version(self, asset_type: str, asset_id: str) -> Optional[str]: + if ( + asset_type in self._versions + and asset_id in self._versions[asset_type] + and self._versions[asset_type][asset_id] + ): + return self._versions[asset_type][asset_id][-1] + return None + + def dump(self, print: bool = False) -> Dict[str, Dict[str, Dict[str, bytes]]] | None: + """ + Dumps the entire content of the memory store. + + Args: + print (bool): If True, pretty-prints the data to the console, + excluding the asset data itself. + + Returns: + Dict[str, Dict[str, Dict[str, bytes]]] | None: The stored data as a + dictionary, or None if print is True. + """ + if not print: + return self._data + + printable_data = {} + for asset_type, assets in self._data.items(): + printable_data[asset_type] = {} + for asset_id, versions in assets.items(): + printable_data[asset_type][asset_id] = {} + for version, data_bytes in versions.items(): + printable_data[asset_type][asset_id][ + version + ] = f"" + + pprint.pprint(printable_data, indent=4) + return self._data diff --git a/src/Mod/CAM/Path/Tool/assets/ui/__init__.py b/src/Mod/CAM/Path/Tool/assets/ui/__init__.py new file mode 100644 index 0000000000..d2104431b0 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/ui/__init__.py @@ -0,0 +1,6 @@ +from .filedialog import AssetOpenDialog, AssetSaveDialog + +__all__ = [ + "AssetOpenDialog", + "AssetSaveDialog", +] diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py new file mode 100644 index 0000000000..58eadc19c9 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pathlib +import FreeCAD +import Path +from typing import Optional, Tuple, Type, Iterable +from PySide.QtWidgets import QFileDialog, QMessageBox +from ..serializer import AssetSerializer, Asset +from .util import ( + make_import_filters, + make_export_filters, + get_serializer_from_extension, +) + + +class AssetOpenDialog(QFileDialog): + def __init__( + self, + asset_type: Type[Asset], + serializers: Iterable[Type[AssetSerializer]], + parent=None, + ): + super().__init__(parent) + self.asset_type = asset_type + self.serializers = list(serializers) + self.setWindowTitle("Open an asset") + self.setFileMode(QFileDialog.ExistingFile) + filters = make_import_filters(self.serializers) + self.setNameFilters(filters) + if filters: + self.selectNameFilter(filters[0]) # Default to "All supported files" + + def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[Asset]: + """Deserialize the selected file using the appropriate serializer.""" + file_extension = file_path.suffix.lower() + serializer_class = get_serializer_from_extension( + self.serializers, file_extension, for_import=True + ) + if not serializer_class: + QMessageBox.critical( + self, + "Error", + f"No supported serializer found for file extension '{file_extension}'", + ) + return None + try: + raw_data = file_path.read_bytes() + asset = serializer_class.deep_deserialize(raw_data) + if not isinstance(asset, self.asset_type): + raise TypeError(f"Deserialized asset is not of type {self.asset_type.asset_type}") + return asset + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to import asset: {e}") + return None + + def exec(self) -> Optional[Tuple[pathlib.Path, Asset]]: + if super().exec_(): + filenames = self.selectedFiles() + if filenames: + file_path = pathlib.Path(filenames[0]) + asset = self._deserialize_selected_file(file_path) + if asset: + return file_path, asset + return None + + +class AssetSaveDialog(QFileDialog): + def __init__( + self, + asset_type: Type[Asset], + serializers: Iterable[Type[AssetSerializer]], + parent=None, + ): + super().__init__(parent) + self.asset_type = asset_type + self.serializers = list(serializers) + self.setFileMode(QFileDialog.AnyFile) + self.setAcceptMode(QFileDialog.AcceptSave) + self.filters, self.serializer_map = make_export_filters(self.serializers) + self.setNameFilters(self.filters) + if self.filters: + self.selectNameFilter(self.filters[0]) # Default to "Automatic" + self.filterSelected.connect(self.update_default_suffix) + + def update_default_suffix(self, filter_str: str): + """Update the default suffix based on the selected filter.""" + if filter_str == "Automatic (*)": + self.setDefaultSuffix("") # No default for Automatic + else: + serializer = self.serializer_map.get(filter_str) + if serializer and serializer.extensions: + self.setDefaultSuffix(serializer.extensions[0]) + + def _serialize_selected_file( + self, + file_path: pathlib.Path, + asset: Asset, + serializer_class: Type[AssetSerializer], + ) -> bool: + """Serialize and save the asset.""" + try: + raw_data = serializer_class.serialize(asset) + file_path.write_bytes(raw_data) + return True + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to export asset: {e}") + return False + + def exec(self, asset: Asset) -> Optional[Tuple[pathlib.Path, Type[AssetSerializer]]]: + self.setWindowTitle(f"Save {asset.label}") + if super().exec_(): + selected_filter = self.selectedNameFilter() + file_path = pathlib.Path(self.selectedFiles()[0]) + if selected_filter == "Automatic (*)": + if not file_path.suffix: + QMessageBox.critical( + self, + "Error", + "Please specify a file extension for automatic serializer selection.", + ) + return None + file_extension = file_path.suffix.lower() + serializer_class = get_serializer_from_extension( + self.serializers, file_extension, for_import=False + ) + if not serializer_class: + QMessageBox.critical( + self, + "Error", + f"No serializer found for extension '{file_extension}'", + ) + return None + else: + serializer_class = self.serializer_map.get(selected_filter) + if not serializer_class: + raise ValueError(f"No serializer found for filter '{selected_filter}'") + if self._serialize_selected_file(file_path, asset, serializer_class): + return file_path, serializer_class + return None diff --git a/src/Mod/CAM/Path/Tool/assets/ui/preferences.py b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py new file mode 100644 index 0000000000..5231c1194b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/ui/preferences.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import pathlib +import tempfile +import FreeCAD +import Path +from PySide import QtGui, QtCore + +translate = FreeCAD.Qt.translate + + +def _is_writable_dir(path: pathlib.Path) -> bool: + """ + Check if a path is a writable directory. + Returns True if writable, False otherwise. + """ + if not path.is_dir(): + return False + try: + with tempfile.NamedTemporaryFile(dir=str(path), delete=True): + return True + except (OSError, PermissionError): + return False + + +class AssetPreferencesPage: + def __init__(self, parent=None): + self.form = QtGui.QToolBox() + self.form.setWindowTitle(translate("CAM_PreferencesAssets", "Assets")) + + asset_path_widget = QtGui.QWidget() + main_layout = QtGui.QHBoxLayout(asset_path_widget) + + # Create widgets + self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Asset Directory:")) + self.asset_path_edit = QtGui.QLineEdit() + self.asset_path_note_label = QtGui.QLabel( + translate( + "CAM_PreferencesAssets", + "Note: Select the directory that will contain the " + "Bit/, Shape/, and Library/ subfolders.", + ) + ) + self.asset_path_note_label.setWordWrap(True) + self.select_path_button = QtGui.QToolButton() + self.select_path_button.setIcon(QtGui.QIcon.fromTheme("folder-open")) + self.select_path_button.clicked.connect(self.selectAssetPath) + self.reset_path_button = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Reset")) + self.reset_path_button.clicked.connect(self.resetAssetPath) + + # Set note label font to italic + font = self.asset_path_note_label.font() + font.setItalic(True) + self.asset_path_note_label.setFont(font) + + # Layout for asset path section + edit_button_layout = QtGui.QGridLayout() + edit_button_layout.addWidget(self.asset_path_label, 0, 0, QtCore.Qt.AlignVCenter) + edit_button_layout.addWidget(self.asset_path_edit, 0, 1, QtCore.Qt.AlignVCenter) + edit_button_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter) + edit_button_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter) + edit_button_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop) + edit_button_layout.addItem( + QtGui.QSpacerItem(0, 0, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding), + 2, + 0, + 1, + 4, + ) + + main_layout.addLayout(edit_button_layout, QtCore.Qt.AlignTop) + + self.form.addItem(asset_path_widget, translate("CAM_PreferencesAssets", "Assets")) + + def selectAssetPath(self): + # Implement directory selection dialog + path = QtGui.QFileDialog.getExistingDirectory( + self.form, + translate("CAM_PreferencesAssets", "Select Asset Directory"), + self.asset_path_edit.text(), + ) + if path: + self.asset_path_edit.setText(str(path)) + + def resetAssetPath(self): + # Implement resetting path to default + default_path = Path.Preferences.getDefaultAssetPath() + self.asset_path_edit.setText(str(default_path)) + + def saveSettings(self): + # Check path is writable, then call Path.Preferences.setAssetPath() + asset_path = pathlib.Path(self.asset_path_edit.text()) + param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Path") + if param.GetBool("CheckAssetPathWritable", True): + if not _is_writable_dir(asset_path): + QtGui.QMessageBox.warning( + self.form, + translate("CAM_PreferencesAssets", "Warning"), + translate("CAM_PreferencesAssets", "The selected asset path is not writable."), + ) + return False + Path.Preferences.setAssetPath(asset_path) + return True + + def loadSettings(self): + # use getAssetPath() to initialize UI + asset_path = Path.Preferences.getAssetPath() + self.asset_path_edit.setText(str(asset_path)) diff --git a/src/Mod/CAM/Path/Tool/assets/ui/util.py b/src/Mod/CAM/Path/Tool/assets/ui/util.py new file mode 100644 index 0000000000..ffa51da6d1 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/ui/util.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from typing import List, Dict, Optional, Iterable, Type +from ..serializer import AssetSerializer + + +def make_import_filters(serializers: Iterable[Type[AssetSerializer]]) -> List[str]: + """ + Generates file dialog filters for importing assets. + + Args: + serializers: A list of AssetSerializer classes. + + Returns: + A list of filter strings, starting with "All supported files". + """ + all_extensions = [] + filters = [] + + for serializer_class in serializers: + if not serializer_class.can_import or not serializer_class.extensions: + continue + all_extensions.extend(serializer_class.extensions) + label = serializer_class.get_label() + extensions = " ".join([f"*{ext}" for ext in serializer_class.extensions]) + filters.append(f"{label} ({extensions})") + + # Add "All supported files" filter if there are any extensions + if all_extensions: + combined_extensions = " ".join([f"*{ext}" for ext in sorted(list(set(all_extensions)))]) + filters.insert(0, f"All supported files ({combined_extensions})") + + return filters + + +def make_export_filters( + serializers: Iterable[Type[AssetSerializer]], +) -> tuple[List[str], Dict[str, Type[AssetSerializer]]]: + """ + Generates file dialog filters for exporting assets and a serializer map. + + Args: + serializers: A list of AssetSerializer classes. + + Returns: + A tuple of (filters, serializer_map) where filters is a list of filter strings + starting with "Automatic", and serializer_map maps filter strings to serializers. + """ + filters = ["Automatic (*)"] + serializer_map = {} + + for serializer_class in serializers: + if not serializer_class.can_export or not serializer_class.extensions: + continue + label = serializer_class.get_label() + extensions = " ".join([f"*{ext}" for ext in serializer_class.extensions]) + filter_str = f"{label} ({extensions})" + filters.append(filter_str) + serializer_map[filter_str] = serializer_class + + return filters, serializer_map + + +def get_serializer_from_extension( + serializers: Iterable[Type[AssetSerializer]], + file_extension: str, + for_import: bool | None = None, +) -> Optional[Type[AssetSerializer]]: + """ + Finds a serializer class based on the file extension and import/export capability. + + Args: + serializers: A list of AssetSerializer classes. + file_extension: The file extension (without the leading dot). + for_import: None = both, True = import, False = export + + Returns: + The matching AssetSerializer class, or None if not found. + """ + for_export = for_import is not True + for_import = for_import is True + + for ser in serializers: + if for_import and not ser.can_import: + continue + if for_export and not ser.can_export: + continue + if file_extension in ser.extensions: + return ser + return None diff --git a/src/Mod/CAM/Path/Tool/assets/uri.py b/src/Mod/CAM/Path/Tool/assets/uri.py new file mode 100644 index 0000000000..1106806254 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/assets/uri.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from __future__ import annotations +import urllib.parse +from typing import Dict, Any, Mapping + + +class AssetUri: + """ + Represents an asset URI with components. + + The URI structure is: ://[/version] + """ + + def __init__(self, uri_string: str): + # Manually parse the URI string + parts = uri_string.split("://", 1) + if len(parts) != 2: + raise ValueError(f"Invalid URI structure: {uri_string}") + + self.asset_type = parts[0] + rest = parts[1] + + # Split asset_id, version, and params + path_and_query = rest.split("?", 1) + path_parts = path_and_query[0].split("/") + + if not path_parts or not path_parts[0]: + raise ValueError(f"Invalid URI structure: {uri_string}") + + self.asset_id = path_parts[0] + self.version = path_parts[1] if len(path_parts) > 1 else None + + if len(path_parts) > 2: + raise ValueError(f"Invalid URI path structure: {uri_string}") + + self.params: Dict[str, list[str]] = {} + if len(path_and_query) > 1: + self.params = urllib.parse.parse_qs(path_and_query[1]) + + if not self.asset_type or not self.asset_id: + raise ValueError(f"Invalid URI structure: {uri_string}") + + def __str__(self) -> str: + path = f"/{self.version}" if self.version else "" + + query = urllib.parse.urlencode(self.params, doseq=True) if self.params else "" + + uri_string = urllib.parse.urlunparse((self.asset_type, self.asset_id, path, "", query, "")) + return uri_string + + def __repr__(self) -> str: + return f"AssetUri('{str(self)}')" + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, AssetUri): + return NotImplemented + return ( + self.asset_type == other.asset_type + and self.asset_id == other.asset_id + and self.version == other.version + and self.params == other.params + ) + + def __hash__(self) -> int: + """Returns a hash value for the AssetUri.""" + return hash((self.asset_type, self.asset_id, self.version, frozenset(self.params.items()))) + + @classmethod + def is_uri(cls, uri: AssetUri | str) -> bool: + """Checks if the given string is a valid URI.""" + if isinstance(uri, AssetUri): + return True + + try: + AssetUri(uri) + except ValueError: + return False + return True + + @staticmethod + def build( + asset_type: str, + asset_id: str, + version: str | None = None, + params: Mapping[str, str | list[str]] | None = None, + ) -> AssetUri: + """Builds a Uri object from components.""" + uri = AssetUri.__new__(AssetUri) # Create a new instance without calling __init__ + uri.asset_type = asset_type + uri.asset_id = asset_id + uri.version = version + uri.params = {} + if params: + for key, value in params.items(): + if isinstance(value, list): + uri.params[key] = value + else: + uri.params[key] = [value] + return uri diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py new file mode 100644 index 0000000000..1b6d2c57e6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import Path +from Path import Preferences +from Path.Preferences import addToolPreferenceObserver +from .assets import AssetManager, FileStore + + +def ensure_library_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): + """ + Ensures the given store is initialized with built-in library + if it is currently empty. + """ + builtin_library_path = Preferences.getBuiltinLibraryPath() + + if asset_manager.is_empty("toolbitlibrary", store=store_name): + for path in builtin_library_path.glob("*.fctl"): + asset_manager.add_file("toolbitlibrary", path) + + +def ensure_toolbit_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): + """ + Ensures the given store is initialized with built-in bits + if it is currently empty. + """ + builtin_toolbit_path = Preferences.getBuiltinToolBitPath() + + if asset_manager.is_empty("toolbit", store=store_name): + for path in builtin_toolbit_path.glob("*.fctb"): + asset_manager.add_file("toolbit", path) + + +def ensure_toolbitshape_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): + """ + Ensures the given store is initialized with built-in shapes + if it is currently empty. + """ + builtin_shape_path = Preferences.getBuiltinShapePath() + + if asset_manager.is_empty("toolbitshape", store=store_name): + for path in builtin_shape_path.glob("*.fcstd"): + asset_manager.add_file("toolbitshape", path) + + if asset_manager.is_empty("toolbitshapesvg", store=store_name): + for path in builtin_shape_path.glob("*.svg"): + asset_manager.add_file("toolbitshapesvg", path, asset_id=path.stem + ".svg") + + if asset_manager.is_empty("toolbitshapepng", store=store_name): + for path in builtin_shape_path.glob("*.png"): + asset_manager.add_file("toolbitshapepng", path, asset_id=path.stem + ".png") + + +def ensure_assets_initialized(asset_manager: AssetManager, store="local"): + """ + Ensures the given store is initialized with built-in assets. + """ + ensure_library_assets_initialized(asset_manager, store) + ensure_toolbit_assets_initialized(asset_manager, store) + ensure_toolbitshape_assets_initialized(asset_manager, store) + + +def _on_asset_path_changed(group, key, value): + Path.Log.info(f"CAM asset directory changed in preferences: {group} {key} {value}") + cam_asset_store.set_dir(Preferences.getAssetPath()) + ensure_assets_initialized(cam_assets) + + +# Set up the local CAM asset storage. +cam_asset_store = FileStore( + name="local", + base_dir=Preferences.getAssetPath(), + mapping={ + "toolbitlibrary": "Library/{asset_id}.fctl", + "toolbit": "Bit/{asset_id}.fctb", + "toolbitshape": "Shape/{asset_id}.fcstd", + "toolbitshapesvg": "Shape/{asset_id}", # Asset ID has ".svg" included + "toolbitshapepng": "Shape/{asset_id}", # Asset ID has ".png" included + "machine": "Machine/{asset_id}.fcm", + }, +) + +# Set up the CAM asset manager. +Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) +cam_assets = AssetManager() +cam_assets.register_store(cam_asset_store) +try: + ensure_assets_initialized(cam_assets) +except Exception as e: + Path.Log.error(f"Failed to initialize CAM assets in {cam_asset_store._base_dir}: {e}") +else: + Path.Log.debug(f"Using CAM assets in {cam_asset_store._base_dir}") +addToolPreferenceObserver(_on_asset_path_changed) diff --git a/src/Mod/CAM/Path/Tool/library/__init__.py b/src/Mod/CAM/Path/Tool/library/__init__.py new file mode 100644 index 0000000000..8eccc2f24b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from .models.library import Library + +__all__ = [ + "Library", +] diff --git a/src/Mod/CAM/Path/Tool/library/models/__init__.py b/src/Mod/CAM/Path/Tool/library/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/library/models/library.py b/src/Mod/CAM/Path/Tool/library/models/library.py new file mode 100644 index 0000000000..d2964d0ea8 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/models/library.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import uuid +import pathlib +from typing import Mapping, Union, Optional, List, Dict, cast +import Path +from ...assets import Asset, AssetUri +from ...toolbit import ToolBit + + +class Library(Asset): + asset_type: str = "toolbitlibrary" + API_VERSION = 1 + + def __init__(self, label, id=None): + self.id = id if id is not None else str(uuid.uuid4()) + self._label = label + self._bits: List[ToolBit] = [] + self._bit_nos: Dict[int, ToolBit] = {} + self._bit_urls: Dict[AssetUri, ToolBit] = {} + + @property + def label(self) -> str: + return self._label + + def get_id(self) -> str: + """Returns the unique identifier for the Library instance.""" + return self.id + + @classmethod + def resolve_name(cls, identifier: Union[str, AssetUri, pathlib.Path]) -> AssetUri: + """ + Resolves various forms of library identifiers to a canonical AssetUri string. + Handles direct AssetUri objects, URI strings, asset IDs, or legacy filenames. + Returns the canonical URI string or None if resolution fails. + """ + if isinstance(identifier, AssetUri): + return identifier + + if isinstance(identifier, str) and AssetUri.is_uri(identifier): + return AssetUri(identifier) + + if isinstance(identifier, pathlib.Path): # Handle direct Path objects (legacy filenames) + identifier = identifier.stem # Use the filename stem as potential ID + + if not isinstance(identifier, str): + raise ValueError("Failed to resolve {identifier} to a Uri") + + return AssetUri.build(asset_type=Library.asset_type, asset_id=identifier) + + def to_dict(self) -> dict: + """Returns a dictionary representation of the Library in the specified format.""" + tools_list = [] + for tool_no, tool in self._bit_nos.items(): + tools_list.append( + {"nr": tool_no, "path": f"{tool.get_id()}.fctb"} # Tool ID with .fctb extension + ) + return {"label": self.label, "tools": tools_list, "version": self.API_VERSION} + + @classmethod + def from_dict( + cls, + data_dict: dict, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> "Library": + """ + Creates a Library instance from a dictionary and resolved dependencies. + If dependencies is None, it's a shallow load, and tools are not populated. + """ + library = cls(data_dict.get("label", id or "Unnamed Library"), id=id) + + if dependencies is None: + Path.Log.debug( + f"Library.from_dict: Shallow load for library '{library.label}' (id: {id}). Tools not populated." + ) + return library # Only process tools if dependencies were resolved + + tools_list = data_dict.get("tools", []) + for tool_data in tools_list: + tool_no = tool_data["nr"] + tool_id = pathlib.Path(tool_data["path"]).stem # Extract tool ID + tool_uri = AssetUri(f"toolbit://{tool_id}") + bit = cast(ToolBit, dependencies.get(tool_uri)) + if bit: + library.add_bit(bit, bit_no=tool_no) + else: + raise ValueError(f"Tool with id {tool_id} not found in dependencies") + return library + + def __str__(self): + return '{} "{}"'.format(self.id, self.label) + + def __eq__(self, other): + return self.id == other.id + + def __iter__(self): + return self._bits.__iter__() + + def get_next_bit_no(self): + bit_nolist = sorted(self._bit_nos, reverse=True) + return bit_nolist[0] + 1 if bit_nolist else 1 + + def get_bit_no_from_bit(self, bit: ToolBit) -> Optional[int]: + for bit_no, thebit in self._bit_nos.items(): + if bit == thebit: + return bit_no + return None + + def get_tool_by_uri(self, uri: AssetUri) -> Optional[ToolBit]: + for tool in self._bit_nos.values(): + if tool.get_uri() == uri: + return tool + return None + + def assign_new_bit_no(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]: + if bit not in self._bits: + return + + # If no specific bit_no was requested, assign a new one. + if bit_no is None: + bit_no = self.get_next_bit_no() + elif self._bit_nos.get(bit_no) == bit: + return + + # Otherwise, add the bit. Since the requested bit_no may already + # be in use, we need to account for that. In this case, we will + # add the removed bit into a new bit_no. + old_bit = self._bit_nos.pop(bit_no, None) + old_bit_no = self.get_bit_no_from_bit(bit) + if old_bit_no: + del self._bit_nos[old_bit_no] + self._bit_nos[bit_no] = bit + if old_bit: + self.assign_new_bit_no(old_bit) + return bit_no + + def add_bit(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]: + if bit not in self._bits: + self._bits.append(bit) + return self.assign_new_bit_no(bit, bit_no) + + def get_bits(self) -> List[ToolBit]: + return self._bits + + def has_bit(self, bit: ToolBit) -> bool: + for t in self._bits: + if bit.id == t.id: + return True + return False + + def remove_bit(self, bit: ToolBit): + self._bits = [t for t in self._bits if t.id != bit.id] + self._bit_nos = {k: v for (k, v) in self._bit_nos.items() if v.id != bit.id} + + def dump(self, summarize: bool = False): + title = 'Library "{}" ({}) (instance {})'.format(self.label, self.id, id(self)) + print("-" * len(title)) + print(title) + print("-" * len(title)) + for bit in self._bits: + print(f"- {bit.label} ({bit.get_id()})") + print() diff --git a/src/Mod/CAM/Path/Tool/library/serializers/__init__.py b/src/Mod/CAM/Path/Tool/library/serializers/__init__.py new file mode 100644 index 0000000000..2c4998e421 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/serializers/__init__.py @@ -0,0 +1,13 @@ +from .camotics import CamoticsLibrarySerializer +from .fctl import FCTLSerializer +from .linuxcnc import LinuxCNCSerializer + + +all_serializers = CamoticsLibrarySerializer, FCTLSerializer, LinuxCNCSerializer + + +__all__ = [ + "CamoticsLibrarySerializer", + "FCTLSerializer", + "LinuxCNCSerializer", +] diff --git a/src/Mod/CAM/Path/Tool/library/serializers/camotics.py b/src/Mod/CAM/Path/Tool/library/serializers/camotics.py new file mode 100644 index 0000000000..5d8a317769 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/serializers/camotics.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import uuid +import json +from typing import Mapping, List, Optional, Type +import FreeCAD +from ...assets import Asset, AssetUri, AssetSerializer +from ...toolbit import ToolBit +from ...toolbit.mixins import RotaryToolBitMixin +from ...shape import ToolBitShape, ToolBitShapeEndmill +from ..models.library import Library + +SHAPEMAP = { + "ballend": "Ballnose", + "endmill": "Cylindrical", + "v-bit": "Conical", + "chamfer": "Snubnose", +} +SHAPEMAP_REVERSE = dict((v, k) for k, v in SHAPEMAP.items()) + +tooltemplate = { + "units": "metric", + "shape": "Cylindrical", + "length": 10, + "diameter": 3.125, + "description": "", +} + + +class CamoticsLibrarySerializer(AssetSerializer): + for_class: Type[Asset] = Library + extensions: tuple[str] = (".ctbl",) + mime_type: str = "application/json" + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "Camotics Tool Library") + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + if not isinstance(asset, Library): + raise TypeError("Asset must be a Library instance") + + toollist = {} + for tool_no, tool in asset._bit_nos.items(): + assert isinstance(tool, RotaryToolBitMixin) + toolitem = tooltemplate.copy() + + diameter_value = tool.get_diameter() + # Ensure diameter is a float, handle Quantity and other types + diameter_serializable = 2.0 # Default value as float + if isinstance(diameter_value, FreeCAD.Units.Quantity): + try: + val_mm = diameter_value.getValueAs("mm") + if val_mm is not None: + diameter_serializable = float(val_mm) + except ValueError: + # Fallback to raw value if unit conversion fails + raw_val = diameter_value.Value if hasattr(diameter_value, "Value") else None + if isinstance(raw_val, (int, float)): + diameter_serializable = float(raw_val) + elif isinstance(diameter_value, (int, float)): + diameter_serializable = float(diameter_value) if diameter_value is not None else 2.0 + + toolitem["diameter"] = diameter_serializable + + toolitem["description"] = tool.label + + length_value = tool.get_length() + # Ensure length is a float, handle Quantity and other types + length_serializable = 10.0 # Default value as float + if isinstance(length_value, FreeCAD.Units.Quantity): + try: + val_mm = length_value.getValueAs("mm") + if val_mm is not None: + length_serializable = float(val_mm) + except ValueError: + # Fallback to raw value if unit conversion fails + raw_val = length_value.Value if hasattr(length_value, "Value") else None + if isinstance(raw_val, (int, float)): + length_serializable = float(raw_val) + elif isinstance(length_value, (int, float)): + length_serializable = float(length_value) if length_value is not None else 10.0 + + toolitem["length"] = length_serializable + + toolitem["shape"] = SHAPEMAP.get(tool._tool_bit_shape.name, "Cylindrical") + toollist[str(tool_no)] = toolitem + + return json.dumps(toollist, indent=2).encode("utf-8") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> Library: + try: + data_dict = json.loads(data.decode("utf-8")) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to decode JSON data: {e}") from e + + library = Library(id, id=id) + for tool_no_str, toolitem in data_dict.items(): + try: + tool_no = int(tool_no_str) + except ValueError: + print(f"Warning: Skipping invalid tool number: {tool_no_str}") + continue + + # Find the shape class to use + shape_name_str = SHAPEMAP_REVERSE.get(toolitem.get("shape", "Cylindrical"), "endmill") + shape_class = ToolBitShape.get_subclass_by_name(shape_name_str) + if not shape_class: + print(f"Warning: Unknown shape name '{shape_name_str}', defaulting to endmill") + shape_class = ToolBitShapeEndmill + + # Translate parameters to FreeCAD types + params = {} + try: + diameter = float(toolitem.get("diameter", 2)) + params["Diameter"] = FreeCAD.Units.Quantity(f"{diameter} mm") + except (ValueError, TypeError): + print(f"Warning: Invalid diameter for tool {tool_no_str}, skipping.") + + try: + length = float(toolitem.get("length", 10)) + params["Length"] = FreeCAD.Units.Quantity(f"{length} mm") + except (ValueError, TypeError): + print(f"Warning: Invalid length for tool {tool_no_str}, skipping.") + + # Create the shape + shape_id = shape_name_str.lower() + tool_bit_shape = shape_class(shape_id, **params) + + # Create the toolbit + tool = ToolBit(tool_bit_shape, id=f"camotics_tool_{tool_no_str}") + tool.label = toolitem.get("description", "") + + library.add_bit(tool, tool_no) + + return library + + @classmethod + def deep_deserialize(cls, data: bytes) -> Library: + # TODO: Build tools here + return cls.deserialize(data, str(uuid.uuid4()), {}) diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py new file mode 100644 index 0000000000..c4de58afc8 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import uuid +import json +from typing import Mapping, List, Optional +import pathlib +import FreeCAD +import Path +from ...assets import Asset, AssetUri, AssetSerializer +from ...toolbit import ToolBit +from ..models.library import Library + + +class FCTLSerializer(AssetSerializer): + for_class = Library + extensions = (".fctl",) + mime_type = "application/x-freecad-toolbit-library" + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "FreeCAD Tool Library") + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + data_dict = json.loads(data.decode("utf-8")) + tools_list = data_dict.get("tools", []) + tool_ids = [pathlib.Path(tool["path"]).stem for tool in tools_list] + return [AssetUri(f"toolbit://{tool_id}") for tool_id in tool_ids] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + """Serializes a Library object into bytes.""" + if not isinstance(asset, Library): + raise TypeError(f"Expected Library instance, got {type(asset).__name__}") + attrs = asset.to_dict() + return json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> Library: + """ + Creates a Library instance from serialized data and resolved + dependencies. + """ + data_dict = json.loads(data.decode("utf-8")) + # The id parameter from the Asset.from_bytes method is the canonical ID + # for the asset being deserialized. We should use this ID for the library + # instance, overriding any 'id' that might be in the data_dict (which + # is from an older version of the format). + library = Library(data_dict.get("label", id or "Unnamed Library"), id=id) + + if dependencies is None: + Path.Log.debug( + f"FCTLSerializer.deserialize: Shallow load for library '{library.label}' (id: {id}). Tools not populated." + ) + return library # Only process tools if dependencies were resolved + + tools_list = data_dict.get("tools", []) + for tool_data in tools_list: + tool_no = tool_data["nr"] + tool_id = pathlib.Path(tool_data["path"]).stem # Extract tool ID + tool_uri = AssetUri(f"toolbit://{tool_id}") + tool = dependencies.get(tool_uri) + if tool: + # Ensure the dependency is a ToolBit instance + if not isinstance(tool, ToolBit): + Path.Log.warning( + f"Dependency for tool '{tool_id}' is not a ToolBit instance. Skipping." + ) + continue + library.add_bit(tool, bit_no=tool_no) + else: + # This should not happen if dependencies were resolved correctly, + # but as a safeguard, log a warning and skip the tool. + Path.Log.warning( + f"Tool with id {tool_id} not found in dependencies during deserialization." + ) + return library + + @classmethod + def deep_deserialize(cls, data: bytes) -> Library: + # TODO: attempt to fetch tools from the asset manager here + return cls.deserialize(data, str(uuid.uuid4()), {}) diff --git a/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py new file mode 100644 index 0000000000..c3880dc44e --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import io +from typing import Mapping, List, Optional, Type +import FreeCAD +import Path +from ...assets import Asset, AssetUri, AssetSerializer +from ...toolbit import ToolBit +from ...toolbit.mixins import RotaryToolBitMixin +from ..models.library import Library + + +class LinuxCNCSerializer(AssetSerializer): + for_class: Type[Asset] = Library + extensions: tuple[str] = (".tbl",) + mime_type: str = "text/plain" + can_import = False + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "LinuxCNC Tool Table") + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + return [] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + if not isinstance(asset, Library): + raise TypeError("Asset must be a Library instance") + + output = io.BytesIO() + for bit_no, bit in sorted(asset._bit_nos.items()): + assert isinstance(bit, ToolBit) + if not isinstance(bit, RotaryToolBitMixin): + Path.Log.warning( + f"Skipping too {bit.label} (bit.id) because it is not a rotary tool" + ) + continue + diameter = bit.get_diameter() + pocket = "P" # TODO: is there a better way? + # Format diameter to one decimal place and remove units + diameter_value = diameter.Value if hasattr(diameter, "Value") else diameter + line = f"T{bit_no} {pocket} D{diameter_value:.1f} ;{bit.label}\n" + output.write(line.encode("ascii", "ignore")) + + return output.getvalue() + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> Library: + # LinuxCNC .tbl files do not contain enough information to fully + # reconstruct a Library and its ToolBits. + # Therefore, deserialization is not supported. + raise NotImplementedError("Deserialization is not supported for LinuxCNC .tbl files.") diff --git a/src/Mod/CAM/Path/Tool/library/ui/__init__.py b/src/Mod/CAM/Path/Tool/library/ui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/library/ui/browser.py b/src/Mod/CAM/Path/Tool/library/ui/browser.py new file mode 100644 index 0000000000..24e0c1559e --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/ui/browser.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for browsing Tool Library assets with filtering and sorting.""" + +from typing import cast +from PySide import QtGui +import Path +from ...toolbit.ui.browser import ToolBitBrowserWidget +from ...assets import AssetManager +from ...library import Library + + +class LibraryBrowserWidget(ToolBitBrowserWidget): + """ + A widget to browse, filter, and select Tool Library assets from the + AssetManager, with sorting and batch insertion, including library selection. + """ + + def __init__( + self, + asset_manager: AssetManager, + store: str = "local", + parent=None, + compact=True, + ): + self._library_combo = QtGui.QComboBox() + + super().__init__( + asset_manager=asset_manager, + store=store, + parent=parent, + tool_no_factory=self.get_tool_no_from_current_library, + compact=compact, + ) + + # Create the library dropdown and insert it into the top layout + self._top_layout.insertWidget(0, self._library_combo) + self._library_combo.currentIndexChanged.connect(self._on_library_changed) + + def refresh(self): + """Refreshes the library dropdown and fetches all assets.""" + self._library_combo.clear() + self._fetch_all_assets() + + def _fetch_all_assets(self): + """Populates the library dropdown with available libraries.""" + # Use list_assets("toolbitlibrary") to get URIs + libraries = self._asset_manager.fetch("toolbitlibrary", store=self._store_name, depth=0) + for library in sorted(libraries, key=lambda x: x.label): + self._library_combo.addItem(library.label, userData=library) + + if not self._library_combo.count(): + return + + # Trigger initial load after populating libraries + self._on_library_changed(0) + + def get_tool_no_from_current_library(self, toolbit): + """ + Retrieves the tool number for a toolbit based on the currently + selected library. + """ + selected_library = self._library_combo.currentData() + if selected_library is None: + return None + # Use the get_bit_no_from_bit method of the Library object + # This method returns the tool number or None + tool_no = selected_library.get_bit_no_from_bit(toolbit) + return tool_no + + def _on_library_changed(self, index): + """Handles library selection change.""" + # Get the selected library from the combo box + selected_library = self._library_combo.currentData() + if not isinstance(selected_library, Library): + self._all_assets = [] + return + + # Fetch the library from the asset manager + library_uri = selected_library.get_uri() + try: + library = self._asset_manager.get(library_uri, store=self._store_name, depth=1) + # Update the combo box item's user data with the fully fetched library + self._library_combo.setItemData(index, library) + except FileNotFoundError: + Path.Log.error(f"Library {library_uri} not found in store {self._store_name}.") + self._all_assets = [] + return + + # Update _all_assets based on the selected library + library = cast(Library, library) + self._all_assets = [t for t in library] + self._sort_assets() + self._tool_list_widget.clear_list() + self._scroll_position = 0 + self._trigger_fetch() # Display data for the selected library diff --git a/src/Mod/CAM/Path/Tool/Gui/BitLibraryCmd.py b/src/Mod/CAM/Path/Tool/library/ui/cmd.py similarity index 83% rename from src/Mod/CAM/Path/Tool/Gui/BitLibraryCmd.py rename to src/Mod/CAM/Path/Tool/library/ui/cmd.py index ceb23ef406..3118935b39 100644 --- a/src/Mod/CAM/Path/Tool/Gui/BitLibraryCmd.py +++ b/src/Mod/CAM/Path/Tool/library/ui/cmd.py @@ -24,6 +24,9 @@ from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCAD import FreeCADGui import Path +from Path.Tool.library.ui.dock import ToolBitLibraryDock +from Path.Tool.library.ui.editor import LibraryEditor + if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -34,9 +37,9 @@ else: translate = FreeCAD.Qt.translate -class CommandToolBitSelectorOpen: +class CommandToolBitLibraryDockOpen: """ - Command to toggle the ToolBitSelector Dock + Command to toggle the ToolBitLibraryDock """ def __init__(self): @@ -52,16 +55,14 @@ class CommandToolBitSelectorOpen: } def IsActive(self): - return FreeCAD.ActiveDocument is not None + return True def Activated(self): - import Path.Tool.Gui.BitLibrary as PathToolBitLibraryGui - - dock = PathToolBitLibraryGui.ToolBitSelector() + dock = ToolBitLibraryDock() dock.open() -class CommandToolBitLibraryOpen: +class CommandLibraryEditorOpen: """ Command to open ToolBitLibrary editor. """ @@ -80,19 +81,16 @@ class CommandToolBitLibraryOpen: } def IsActive(self): - return FreeCAD.ActiveDocument is not None + return True def Activated(self): - import Path.Tool.Gui.BitLibrary as PathToolBitLibraryGui - - library = PathToolBitLibraryGui.ToolBitLibrary() - + library = LibraryEditor() library.open() if FreeCAD.GuiUp: - FreeCADGui.addCommand("CAM_ToolBitLibraryOpen", CommandToolBitLibraryOpen()) - FreeCADGui.addCommand("CAM_ToolBitDock", CommandToolBitSelectorOpen()) + FreeCADGui.addCommand("CAM_ToolBitLibraryOpen", CommandLibraryEditorOpen()) + FreeCADGui.addCommand("CAM_ToolBitDock", CommandToolBitLibraryDockOpen()) BarList = ["CAM_ToolBitDock"] MenuList = ["CAM_ToolBitLibraryOpen", "CAM_ToolBitDock"] diff --git a/src/Mod/CAM/Path/Tool/library/ui/dock.py b/src/Mod/CAM/Path/Tool/library/ui/dock.py new file mode 100644 index 0000000000..f610de45d5 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/ui/dock.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2020 Schildkroet * +# * 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""ToolBit Library Dock Widget.""" +import FreeCAD +import FreeCADGui +import Path +import Path.Tool.Gui.Controller as PathToolControllerGui +import PathScripts.PathUtilsGui as PathUtilsGui +from PySide import QtGui, QtCore +from functools import partial +from typing import List, Tuple +from ...assets import AssetUri +from ...camassets import cam_assets, ensure_assets_initialized +from ...toolbit import ToolBit +from .editor import LibraryEditor +from .browser import LibraryBrowserWidget + + +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()) + + +translate = FreeCAD.Qt.translate + + +class ToolBitLibraryDock(object): + """Controller for displaying a library and creating ToolControllers""" + + def __init__(self): + ensure_assets_initialized(cam_assets) + # Create the main form widget directly + self.form = QtGui.QDockWidget() + self.form.setObjectName("ToolSelector") + self.form.setWindowTitle(translate("CAM_ToolBit", "Tool Selector")) + + # Create the browser widget + self.browser_widget = LibraryBrowserWidget(asset_manager=cam_assets) + + self._setup_ui() + + def _setup_ui(self): + """Setup the form and load the tooltable data""" + Path.Log.track() + + # Create a main widget and layout for the dock + main_widget = QtGui.QWidget() + main_layout = QtGui.QVBoxLayout(main_widget) + + # Add the browser widget to the layout + main_layout.addWidget(self.browser_widget) + + # Create buttons + self.libraryEditorOpenButton = QtGui.QPushButton( + translate("CAM_ToolBit", "Open Library Editor") + ) + self.addToolControllerButton = QtGui.QPushButton(translate("CAM_ToolBit", "Add to Job")) + + # Add buttons to a horizontal layout + button_layout = QtGui.QHBoxLayout() + button_layout.addWidget(self.libraryEditorOpenButton) + button_layout.addWidget(self.addToolControllerButton) + + # Add the button layout to the main layout + main_layout.addLayout(button_layout) + + # Set the main widget as the dock's widget + self.form.setWidget(main_widget) + + # Connect signals from the browser widget and buttons + self.browser_widget.toolSelected.connect(self._update_state) + self.browser_widget.itemDoubleClicked.connect(partial(self._add_tool_controller_to_doc)) + self.libraryEditorOpenButton.clicked.connect(self._open_editor) + self.addToolControllerButton.clicked.connect(partial(self._add_tool_controller_to_doc)) + + # Initial state of buttons + self._update_state() + + def _update_state(self): + """Enable button to add tool controller when a tool is selected""" + # Set buttons inactive + self.addToolControllerButton.setEnabled(False) + # Check if any tool is selected in the browser widget + selected = self.browser_widget._tool_list_widget.selectedItems() + if selected and FreeCAD.ActiveDocument: + jobs = len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1 + self.addToolControllerButton.setEnabled(len(selected) >= 1 and jobs) + + def _open_editor(self): + library = LibraryEditor() + library.open() + # After editing, we might need to refresh the libraries in the browser widget + # Assuming _populate_libraries is the correct method to call + self.browser_widget.refresh() + + def _add_tool_to_doc(self) -> List[Tuple[int, ToolBit]]: + """ + Get the selected toolbit assets from the browser widget. + """ + Path.Log.track() + tools = [] + selected_toolbits = self.browser_widget.get_selected_bits() + + for toolbit in selected_toolbits: + # Need to get the tool number for this toolbit from the currently + # selected library in the browser widget. + toolNr = self.browser_widget.get_tool_no_from_current_library(toolbit) + if toolNr is not None: + toolbit.attach_to_doc(FreeCAD.ActiveDocument) + tools.append((toolNr, toolbit)) + else: + Path.Log.warning( + f"Could not get tool number for toolbit {toolbit.get_uri()} in selected library." + ) + + return tools + + def _add_tool_controller_to_doc(self, index=None): + """ + if no jobs, don't do anything, otherwise all TCs for all + selected toolbit assets + """ + Path.Log.track() + jobs = PathUtilsGui.PathUtils.GetJobs() + if len(jobs) == 0: + QtGui.QMessageBox.information( + self.form, + translate("CAM_ToolBit", "No Job Found"), + translate("CAM_ToolBit", "Please create a Job first."), + ) + return + elif len(jobs) == 1: + job = jobs[0] + else: + userinput = PathUtilsGui.PathUtilsUserInput() + job = userinput.chooseJob(jobs) + + if job is None: # user may have canceled + return + + # Get the selected toolbit assets + selected_tools = self._add_tool_to_doc() + + for toolNr, toolbit in selected_tools: + tc = PathToolControllerGui.Create(f"TC: {toolbit.label}", toolbit.obj, toolNr) + job.Proxy.addToolController(tc) + FreeCAD.ActiveDocument.recompute() + + def open(self, path=None): + """load library stored in path and bring up ui""" + docs = FreeCADGui.getMainWindow().findChildren(QtGui.QDockWidget) + for doc in docs: + if doc.objectName() == "ToolSelector": + if doc.isVisible(): + doc.deleteLater() + return + else: + doc.setVisible(True) + return + + mw = FreeCADGui.getMainWindow() + mw.addDockWidget( + QtCore.Qt.RightDockWidgetArea, + self.form, + QtCore.Qt.Orientation.Vertical, + ) diff --git a/src/Mod/CAM/Path/Tool/library/ui/editor.py b/src/Mod/CAM/Path/Tool/library/ui/editor.py new file mode 100644 index 0000000000..d8f9852b65 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/ui/editor.py @@ -0,0 +1,642 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2020 Schildkroet * +# * 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + + +import FreeCAD +import FreeCADGui +import Path +import PySide +from PySide.QtGui import QStandardItem, QStandardItemModel, QPixmap +from PySide.QtCore import Qt +import os +import uuid as UUID +from typing import List, cast +from ...assets import AssetUri +from ...assets.ui import AssetOpenDialog, AssetSaveDialog +from ...camassets import cam_assets, ensure_assets_initialized +from ...shape.ui.shapeselector import ShapeSelector +from ...toolbit import ToolBit +from ...toolbit.serializers import all_serializers as toolbit_serializers +from ...toolbit.ui import ToolBitEditor +from ...library import Library +from ...library.serializers import all_serializers as library_serializers + + +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()) + + +_UuidRole = PySide.QtCore.Qt.UserRole + 1 +_PathRole = PySide.QtCore.Qt.UserRole + 2 +_LibraryRole = PySide.QtCore.Qt.UserRole + 3 + + +translate = FreeCAD.Qt.translate + + +class _TableView(PySide.QtGui.QTableView): + """Subclass of QTableView to support rearrange and copying of ToolBits""" + + def __init__(self, parent): + PySide.QtGui.QTableView.__init__(self, parent) + self.setDragEnabled(False) + self.setAcceptDrops(False) + self.setDropIndicatorShown(False) + self.setDragDropMode(PySide.QtGui.QAbstractItemView.DragOnly) + self.setDefaultDropAction(PySide.QtCore.Qt.IgnoreAction) + self.setSortingEnabled(True) + self.setSelectionBehavior(PySide.QtGui.QAbstractItemView.SelectRows) + self.verticalHeader().hide() + + def supportedDropActions(self): + return [PySide.QtCore.Qt.CopyAction, PySide.QtCore.Qt.MoveAction] + + def _uuidOfRow(self, row): + model = self.toolModel() + return model.data(model.index(row, 0), _UuidRole) + + def _rowWithUuid(self, uuid): + model = self.toolModel() + for row in range(model.rowCount()): + if self._uuidOfRow(row) == uuid: + return row + return None + + def _copyTool(self, uuid_, dstRow): + model = self.toolModel() + model.insertRow(dstRow) + srcRow = self._rowWithUuid(uuid_) + for col in range(model.columnCount()): + srcItem = model.item(srcRow, col) + + model.setData( + model.index(dstRow, col), + srcItem.data(PySide.QtCore.Qt.EditRole), + PySide.QtCore.Qt.EditRole, + ) + if col == 0: + model.setData(model.index(dstRow, col), srcItem.data(_PathRole), _PathRole) + # Even a clone of a tool gets its own uuid so it can be identified when + # rearranging the order or inserting/deleting rows + model.setData(model.index(dstRow, col), UUID.uuid4(), _UuidRole) + else: + model.item(dstRow, col).setEditable(False) + + def _copyTools(self, uuids, dst): + for i, uuid in enumerate(uuids): + self._copyTool(uuid, dst + i) + + def dropEvent(self, event): + """Handle drop events on the tool table""" + Path.Log.track() + mime = event.mimeData() + data = mime.data("application/x-qstandarditemmodeldatalist") + stream = PySide.QtCore.QDataStream(data) + srcRows = [] + while not stream.atEnd(): + row = stream.readInt32() + srcRows.append(row) + + # get the uuids of all srcRows + model = self.toolModel() + srcUuids = [self._uuidOfRow(row) for row in set(srcRows)] + destRow = self.rowAt(event.pos().y()) + + self._copyTools(srcUuids, destRow) + if PySide.QtCore.Qt.DropAction.MoveAction == event.proposedAction(): + for uuid in srcUuids: + model.removeRow(self._rowWithUuid(uuid)) + + +class ModelFactory: + """Helper class to generate qtdata models for toolbit libraries""" + + @staticmethod + def find_libraries(model) -> QStandardItemModel: + """ + Finds all the fctl files in a location. + Returns a QStandardItemModel. + """ + Path.Log.track() + model.clear() + + # Use AssetManager to fetch library assets (depth=0 for shallow fetch) + try: + # Fetch library assets themselves, not their deep dependencies (toolbits). + # depth=0 means "fetch this asset, but not its dependencies" + # The 'fetch' method returns actual Asset objects. + libraries = cast(List[Library], cam_assets.fetch(asset_type="toolbitlibrary", depth=0)) + except Exception as e: + Path.Log.error(f"Failed to fetch toolbit libraries: {e}") + return model # Return empty model on error + + # Sort by label for consistent ordering, falling back to asset_id if label is missing + def get_sort_key(library): + label = getattr(library, "label", None) + return label if label else library.get_id() + + for library in sorted(libraries, key=get_sort_key): + lib_uri_str = str(library.get_uri()) + libItem = QStandardItem(library.label or library.get_id()) + libItem.setToolTip(f"ID: {library.get_id()}\nURI: {lib_uri_str}") + libItem.setData(lib_uri_str, _LibraryRole) # Store the URI string + libItem.setIcon(QPixmap(":/icons/CAM_ToolTable.svg")) + model.appendRow(libItem) + + Path.Log.debug("model rows: {}".format(model.rowCount())) + return model + + @staticmethod + def __library_load(library_uri: str, data_model: QStandardItemModel): + Path.Log.track(library_uri) + + if library_uri: + # Store the AssetUri string, not just the name + Path.Preferences.setLastToolLibrary(library_uri) + + try: + # Load the library asset using AssetManager + loaded_library = cam_assets.get(AssetUri(library_uri), depth=1) + except Exception as e: + Path.Log.error(f"Failed to load library from {library_uri}: {e}") + raise + + # Iterate over the loaded ToolBit asset instances + for tool_no, tool_bit in sorted(loaded_library._bit_nos.items()): + data_model.appendRow( + ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri())) + ) + + @staticmethod + def _generate_tooltip(toolbit: dict) -> str: + """ + Generate an HTML tooltip for a given toolbit dictionary. + + Args: + toolbit (dict): A dictionary containing toolbit information. + + Returns: + str: An HTML string representing the tooltip. + """ + tooltip = f"Name: {toolbit['name']}
" + tooltip += f"Shape File: {toolbit['shape']}
" + tooltip += "Parameters:
" + parameters = toolbit.get("parameter", {}) + if parameters: + for key, value in parameters.items(): + tooltip += f" {key}: {value}
" + else: + tooltip += " No parameters provided.
" + + attributes = toolbit.get("attribute", {}) + if attributes: + tooltip += "Attributes:
" + for key, value in attributes.items(): + tooltip += f" {key}: {value}
" + + return tooltip + + @staticmethod + def _tool_add(nr: int, tool: dict, path: str): + str_shape = os.path.splitext(os.path.basename(tool["shape"]))[0] + tooltip = ModelFactory._generate_tooltip(tool) + + tool_nr = QStandardItem() + tool_nr.setData(nr, Qt.EditRole) + tool_nr.setData(path, _PathRole) + tool_nr.setData(UUID.uuid4(), _UuidRole) + tool_nr.setToolTip(tooltip) + + tool_name = QStandardItem() + tool_name.setData(tool["name"], Qt.EditRole) + tool_name.setEditable(False) + tool_name.setToolTip(tooltip) + + tool_shape = QStandardItem() + tool_shape.setData(str_shape, Qt.EditRole) + tool_shape.setEditable(False) + + return [tool_nr, tool_name, tool_shape] + + @staticmethod + def library_open(model: QStandardItemModel, library_uri: str) -> QStandardItemModel: + """ + Opens the tools in a library using its AssetUri. + Returns a QStandardItemModel. + """ + Path.Log.track(library_uri) + ModelFactory.__library_load(library_uri, model) + Path.Log.debug("model rows: {}".format(model.rowCount())) + return model + + +class LibraryEditor(object): + """LibraryEditor is the controller for + displaying/selecting/creating/editing a collection of ToolBits.""" + + def __init__(self): + Path.Log.track() + ensure_assets_initialized(cam_assets) + self.factory = ModelFactory() + self.toolModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames())) + self.listModel = PySide.QtGui.QStandardItemModel() + self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui") + self.toolTableView = _TableView(self.form.toolTableGroup) + self.form.toolTableGroup.layout().replaceWidget(self.form.toolTable, self.toolTableView) + self.form.toolTable.hide() + + self.setupUI() + self.title = self.form.windowTitle() + + # Connect signals for tool editing + self.toolTableView.doubleClicked.connect(self.toolEdit) + + def toolBitNew(self): + """Create a new toolbit asset and add it to the current library""" + Path.Log.track() + + if not self.current_library: + PySide.QtGui.QMessageBox.warning( + self.form, + translate("CAM_ToolBit", "No Library Loaded"), + translate("CAM_ToolBit", "Load or create a tool library first."), + ) + return + + # Select the shape for the new toolbit + selector = ShapeSelector() + shape = selector.show() + if shape is None: # user canceled + return + + try: + # Find the appropriate ToolBit subclass based on the shape name + tool_bit_classes = {b.SHAPE_CLASS.name: b for b in ToolBit.__subclasses__()} + tool_bit_class = tool_bit_classes.get(shape.name) + + if not tool_bit_class: + raise ValueError(f"No ToolBit subclass found for shape '{shape.name}'") + + # Create a new ToolBit instance using the subclass constructor + # The constructor will generate a UUID + toolbit = tool_bit_class(shape) + + # 1. Save the individual toolbit asset first. + tool_asset_uri = cam_assets.add(toolbit) + Path.Log.debug(f"toolBitNew: Saved tool with URI: {tool_asset_uri}") + + # 2. Add the toolbit (which now has a persisted URI) to the current library's model + tool_no = self.current_library.add_bit(toolbit) + Path.Log.debug( + f"toolBitNew: Added toolbit {toolbit.get_id()} (URI: {toolbit.get_uri()}) " + f"to current_library with tool number {tool_no}." + ) + + # 3. Add the new tool directly to the UI model + new_row_items = ModelFactory._tool_add( + tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit + ) + self.toolModel.appendRow(new_row_items) + + # 4. Save the library (which now references the saved toolbit) + self._saveCurrentLibrary() + + except Exception as e: + Path.Log.error(f"Failed to create or add new toolbit: {e}") + PySide.QtGui.QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Creating Toolbit"), + str(e), + ) + raise + + def toolBitExisting(self): + """Add an existing toolbit asset to the current library""" + Path.Log.track() + + if not self.current_library: + PySide.QtGui.QMessageBox.warning( + self.form, + translate("CAM_ToolBit", "No Library Loaded"), + translate("CAM_ToolBit", "Load or create a tool library first."), + ) + return + + # Open the file dialog + dialog = AssetOpenDialog(ToolBit, toolbit_serializers, self.form) + dialog_result = dialog.exec() + if not dialog_result: + return # User canceled or error + file_path, toolbit = dialog_result + toolbit = cast(ToolBit, toolbit) + + try: + # Add the existing toolbit to the current library's model + # The add_bit method handles assigning a tool number and returns it. + cam_assets.add(toolbit) + tool_no = self.current_library.add_bit(toolbit) + + # Add the new tool directly to the UI model + new_row_items = ModelFactory._tool_add( + tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit + ) + self.toolModel.appendRow(new_row_items) + + # Save the library (which now references the added toolbit) + # Use cam_assets.add directly for internal save on existing toolbit + self._saveCurrentLibrary() + + except Exception as e: + Path.Log.error( + f"Failed to add imported toolbit {toolbit.get_id()} " + f"from {file_path} to library: {e}" + ) + PySide.QtGui.QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Adding Imported Toolbit"), + str(e), + ) + raise + + def toolDelete(self): + """Delete a tool""" + Path.Log.track() + selected_indices = self.toolTableView.selectedIndexes() + if not selected_indices: + return + + if not self.current_library: + Path.Log.error("toolDelete: No current_library loaded. Cannot delete tools.") + return + + # Collect unique rows to process, as selectedIndexes can return multiple indices per row + selected_rows = sorted(list(set(index.row() for index in selected_indices)), reverse=True) + + # Remove the rows from the library model. + for row in selected_rows: + item_tool_nr_or_uri = self.toolModel.item(row, 0) # Column 0 stores _PathRole + tool_uri_string = item_tool_nr_or_uri.data(_PathRole) + tool_uri = AssetUri(tool_uri_string) + bit = self.current_library.get_tool_by_uri(tool_uri) + self.current_library.remove_bit(bit) + self.toolModel.removeRows(row, 1) + + Path.Log.info(f"toolDelete: Removed {len(selected_rows)} rows from UI model.") + + # Save the library after deleting a tool + self._saveCurrentLibrary() + + def toolSelect(self, selected, deselected): + sel = len(self.toolTableView.selectedIndexes()) > 0 + self.form.toolDelete.setEnabled(sel) + + def tableSelected(self, index): + """loads the tools for the selected tool table""" + Path.Log.track() + item = index.model().itemFromIndex(index) + library_uri_string = item.data(_LibraryRole) + self._loadSelectedLibraryTools(library_uri_string) + + def open(self): + Path.Log.track() + return self.form.exec_() + + def toolEdit(self, selected): + """Edit the selected tool bit asset""" + Path.Log.track() + item = self.toolModel.item(selected.row(), 0) + + if selected.column() == 0: + return # Assuming tool number editing is handled directly in the table model + + toolbit_uri_string = item.data(_PathRole) + if not toolbit_uri_string: + Path.Log.error("No toolbit URI found for selected item.") + return + toolbit_uri = AssetUri(toolbit_uri_string) + + # Load the toolbit asset for editing + try: + bit = cam_assets.get(toolbit_uri) + editor_dialog = ToolBitEditor(bit, self.form) # Create dialog instance + result = editor_dialog.show() # Show as modal dialog + + if result == PySide.QtGui.QDialog.Accepted: + # The editor updates the toolbit directly, so we just need to save + cam_assets.add(bit) + Path.Log.info(f"Toolbit {bit.get_id()} saved.") + # Refresh the display and save the library + self._loadSelectedLibraryTools( + self.current_library.get_uri() if self.current_library else None + ) + # Save the library after editing a toolbit + self._saveCurrentLibrary() + + except Exception as e: + Path.Log.error(f"Failed to load or edit toolbit asset {toolbit_uri_string}: {e}") + PySide.QtGui.QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Editing Toolbit"), + str(e), + ) + raise + + def libraryNew(self): + """Create a new tool library asset""" + Path.Log.track() + + # Get the desired library name (label) from the user + library_label, ok = PySide.QtGui.QInputDialog.getText( + self.form, + translate("CAM_ToolBit", "New Tool Library"), + translate("CAM_ToolBit", "Enter a name for the new library:"), + ) + if not ok or not library_label: + return + + # Create a new Library asset instance, UUID will be auto-generated + new_library = Library(library_label) + uri = cam_assets.add(new_library) + Path.Log.info(f"New library created: {uri}") + + # Refresh the list of libraries in the UI + self._refreshLibraryListModel() + self._loadSelectedLibraryTools(uri) + + # Attempt to select the newly added library in the list + for i in range(self.listModel.rowCount()): + item = self.listModel.item(i) + if item and item.data(_LibraryRole) == str(uri): + curIndex = self.listModel.indexFromItem(item) + self.form.TableList.setCurrentIndex(curIndex) + Path.Log.debug(f"libraryNew: Selected new library '{str(uri)}' in TableList.") + break + + def _refreshLibraryListModel(self): + """Clears and repopulates the self.listModel with available libraries.""" + Path.Log.track() + self.listModel.clear() + self.factory.find_libraries(self.listModel) + self.listModel.setHorizontalHeaderLabels(["Library"]) + + def _saveCurrentLibrary(self): + """Internal method to save the current tool library asset""" + Path.Log.track() + if not self.current_library: + Path.Log.warning("_saveCurrentLibrary: No library asset loaded to save.") + return + + try: + cam_assets.add(self.current_library) + Path.Log.info( + f"_saveCurrentLibrary: Library " f"{self.current_library.get_uri()} saved." + ) + except Exception as e: + Path.Log.error( + f"_saveCurrentLibrary: Failed to save library " + f"{self.current_library.get_uri()}: {e}" + ) + PySide.QtGui.QMessageBox.critical( + self.form, + translate("CAM_ToolBit", "Error Saving Library"), + str(e), + ) + raise + + def exportLibrary(self): + """Export the current tool library asset to a file""" + Path.Log.track() + if not self.current_library: + PySide.QtGui.QMessageBox.warning( + self.form, + translate("CAM_ToolBit", "No Library Loaded"), + translate("CAM_ToolBit", "Load or create a tool library first."), + ) + return + + dialog = AssetSaveDialog(self.current_library, library_serializers, self.form) + dialog_result = dialog.exec(self.current_library) + if not dialog_result: + return # User canceled or error + + file_path, serializer_class = dialog_result + + Path.Log.info( + f"Exported library {self.current_library.label} " + f"to {file_path} using serializer {serializer_class.__name__}" + ) + + def columnNames(self): + return [ + "Tn", + translate("CAM_ToolBit", "Tool"), + translate("CAM_ToolBit", "Shape"), + ] + + def _loadSelectedLibraryTools(self, library_uri: AssetUri | str | None = None): + """Loads tools for the given library_uri into self.toolModel and selects it in the list.""" + Path.Log.track(library_uri) + self.toolModel.clear() + # library_uri is now expected to be a string URI or None when called from setupUI/tableSelected. + # AssetUri object conversion is handled by cam_assets.get() if needed. + + self.current_library = None # Reset current_library before loading + + if not library_uri: + self.form.setWindowTitle("Tool Library Editor - No Library Selected") + return + + # Fetch the library from the asset manager + try: + self.current_library = cam_assets.get(library_uri, depth=1) + except Exception as e: + Path.Log.error(f"Failed to load library asset {library_uri}: {e}") + self.form.setWindowTitle("Tool Library Editor - Error") + return + + # Success! Add the tools to the toolModel. + self.toolTableView.setUpdatesEnabled(False) + self.form.setWindowTitle(f"Tool Library Editor - {self.current_library.label}") + for tool_no, tool_bit in sorted(self.current_library._bit_nos.items()): + self.toolModel.appendRow( + ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri())) + ) + + self.toolModel.setHorizontalHeaderLabels(self.columnNames()) + self.toolTableView.setUpdatesEnabled(True) + + def setupUI(self): + """Setup the form and load the tool library data""" + Path.Log.track() + + self.form.TableList.setModel(self.listModel) + self._refreshLibraryListModel() + + self.toolTableView.setModel(self.toolModel) + + # Find the last used library. + last_used_lib_identifier = Path.Preferences.getLastToolLibrary() + Path.Log.debug( + f"setupUI: Last used library identifier from prefs: '{last_used_lib_identifier}'" + ) + last_used_lib_uri = None + if last_used_lib_identifier: + last_used_lib_uri = Library.resolve_name(last_used_lib_identifier) + + # Find it in the list. + index = 0 + for i in range(self.listModel.rowCount()): + item = self.listModel.item(i) + if item and item.data(_LibraryRole) == str(last_used_lib_uri): + index = i + break + + # Select it. + if index <= self.listModel.rowCount(): + item = self.listModel.item(index) + if item: # Should always be true, but... + library_uri_str = item.data(_LibraryRole) + self.form.TableList.setCurrentIndex(self.listModel.index(index, 0)) + + # Load tools for the selected library. + self._loadSelectedLibraryTools(library_uri_str) + + self.toolTableView.resizeColumnsToContents() + self.toolTableView.selectionModel().selectionChanged.connect(self.toolSelect) + + self.form.TableList.clicked.connect(self.tableSelected) + + self.form.toolAdd.clicked.connect(self.toolBitExisting) + self.form.toolDelete.clicked.connect(self.toolDelete) + self.form.toolCreate.clicked.connect(self.toolBitNew) + + self.form.addLibrary.clicked.connect(self.libraryNew) + self.form.exportLibrary.clicked.connect(self.exportLibrary) + + self.form.okButton.clicked.connect(self.form.close) + + self.toolSelect([], []) diff --git a/src/Mod/CAM/Path/Tool/library/util.py b/src/Mod/CAM/Path/Tool/library/util.py new file mode 100644 index 0000000000..4d11d075f6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/library/util.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 The FreeCAD team * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** diff --git a/src/Mod/CAM/Path/Tool/machine/__init__.py b/src/Mod/CAM/Path/Tool/machine/__init__.py new file mode 100644 index 0000000000..79eefb9f19 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/machine/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +from .models.machine import Machine + +__all__ = [ + "Machine", +] diff --git a/src/Mod/CAM/Path/Tool/machine/models/__init__.py b/src/Mod/CAM/Path/Tool/machine/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/machine/models/machine.py b/src/Mod/CAM/Path/Tool/machine/models/machine.py new file mode 100644 index 0000000000..17519f3649 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/machine/models/machine.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import uuid +import json +import FreeCAD +from FreeCAD import Base +from typing import Optional, Union, Mapping, List +from ...assets import Asset, AssetUri, AssetSerializer + + +class Machine(Asset): + """Represents a machine with various operational parameters.""" + + asset_type: str = "machine" + API_VERSION = 1 + + UNIT_CONVERSIONS = { + "hp": 745.7, # hp to W + "in-lbf": 0.112985, # in-lbf to N*m + "inch/min": 25.4, # inch/min to mm/min + "rpm": 1.0 / 60.0, # rpm to 1/s + "kW": 1000.0, # kW to W + "Nm": 1.0, # Nm to N*m + "mm/min": 1.0, # mm/min to mm/min + } + + def __init__( + self, + label: str = "Machine", + max_power: Union[int, float, FreeCAD.Units.Quantity] = 2, + min_rpm: Union[int, float, FreeCAD.Units.Quantity] = 3000, + max_rpm: Union[int, float, FreeCAD.Units.Quantity] = 60000, + max_torque: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None, + peak_torque_rpm: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None, + min_feed: Union[int, float, FreeCAD.Units.Quantity] = 1, + max_feed: Union[int, float, FreeCAD.Units.Quantity] = 2000, + id: Optional[str] = None, + ) -> None: + """ + Initializes a Machine object. + + Args: + label: The label of the machine. + max_power: The maximum power of the machine (kW or Quantity). + min_rpm: The minimum RPM of the machine (RPM or Quantity). + max_rpm: The maximum RPM of the machine (RPM or Quantity). + max_torque: The maximum torque of the machine (Nm or Quantity). + peak_torque_rpm: The RPM at which peak torque is achieved + (RPM or Quantity). + min_feed: The minimum feed rate of the machine + (mm/min or Quantity). + max_feed: The maximum feed rate of the machine + (mm/min or Quantity). + id: The unique identifier of the machine. + """ + self.id = id or str(uuid.uuid1()) + self._label = label + + # Initialize max_power (W) + if isinstance(max_power, FreeCAD.Units.Quantity): + self._max_power = max_power.getValueAs("W").Value + elif isinstance(max_power, (int, float)): + self._max_power = max_power * self.UNIT_CONVERSIONS["kW"] + else: + self._max_power = 2000.0 + + # Initialize min_rpm (1/s) + if isinstance(min_rpm, FreeCAD.Units.Quantity): + try: + self._min_rpm = min_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + self._min_rpm = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + elif isinstance(min_rpm, (int, float)): + self._min_rpm = min_rpm * self.UNIT_CONVERSIONS["rpm"] + else: + self._min_rpm = 3000 * self.UNIT_CONVERSIONS["rpm"] + + # Initialize max_rpm (1/s) + if isinstance(max_rpm, FreeCAD.Units.Quantity): + try: + self._max_rpm = max_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + self._max_rpm = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + elif isinstance(max_rpm, (int, float)): + self._max_rpm = max_rpm * self.UNIT_CONVERSIONS["rpm"] + else: + self._max_rpm = 60000 * self.UNIT_CONVERSIONS["rpm"] + + # Initialize min_feed (mm/min) + if isinstance(min_feed, FreeCAD.Units.Quantity): + self._min_feed = min_feed.getValueAs("mm/min").Value + elif isinstance(min_feed, (int, float)): + self._min_feed = min_feed + else: + self._min_feed = 1.0 + + # Initialize max_feed (mm/min) + if isinstance(max_feed, FreeCAD.Units.Quantity): + self._max_feed = max_feed.getValueAs("mm/min").Value + elif isinstance(max_feed, (int, float)): + self._max_feed = max_feed + else: + self._max_feed = 2000.0 + + # Initialize peak_torque_rpm (1/s) + if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity): + try: + self._peak_torque_rpm = peak_torque_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + self._peak_torque_rpm = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + elif isinstance(peak_torque_rpm, (int, float)): + self._peak_torque_rpm = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"] + else: + self._peak_torque_rpm = self._max_rpm / 3 + + # Initialize max_torque (N*m) + if isinstance(max_torque, FreeCAD.Units.Quantity): + self._max_torque = max_torque.getValueAs("Nm").Value + elif isinstance(max_torque, (int, float)): + self._max_torque = max_torque + else: + # Convert 1/s to rpm + peak_rpm_for_calc = self._peak_torque_rpm * 60 + self._max_torque = ( + self._max_power * 9.5488 / peak_rpm_for_calc if peak_rpm_for_calc else float("inf") + ) + + def get_id(self) -> str: + """Returns the unique identifier for the Machine instance.""" + return self.id + + def to_dict(self) -> dict: + """Returns a dictionary representation of the Machine.""" + return { + "version": self.API_VERSION, + "id": self.id, + "label": self.label, + "max_power": self._max_power, # W + "min_rpm": self._min_rpm, # 1/s + "max_rpm": self._max_rpm, # 1/s + "max_torque": self._max_torque, # Nm + "peak_torque_rpm": self._peak_torque_rpm, # 1/s + "min_feed": self._min_feed, # mm/min + "max_feed": self._max_feed, # mm/min + } + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + """Serializes the Machine object to bytes using to_dict.""" + data_dict = self.to_dict() + json_str = json.dumps(data_dict) + return json_str.encode("utf-8") + + @classmethod + def from_dict(cls, data_dict: dict, id: str) -> "Machine": + """Creates a Machine instance from a dictionary.""" + machine = cls( + label=data_dict.get("label", "Machine"), + max_power=data_dict.get("max_power", 2000.0), # W + min_rpm=data_dict.get("min_rpm", 3000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s + max_rpm=data_dict.get("max_rpm", 60000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s + max_torque=data_dict.get("max_torque", None), # Nm + peak_torque_rpm=data_dict.get("peak_torque_rpm", None), # 1/s + min_feed=data_dict.get("min_feed", 1.0), # mm/min + max_feed=data_dict.get("max_feed", 2000.0), # mm/min + id=id, + ) + return machine + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> "Machine": + """ + Deserializes bytes into a Machine instance using from_dict. + """ + # If dependencies is None, it's fine as Machine doesn't use it. + data_dict = json.loads(data.decode("utf-8")) + return cls.from_dict(data_dict, id) + + @classmethod + def dependencies(cls, data: bytes) -> List[AssetUri]: + """Returns a list of AssetUri dependencies parsed from the serialized data.""" + return [] # Machine has no dependencies + + @property + def max_power(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._max_power, "W") + + @property + def min_rpm(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._min_rpm, "1/s") + + @property + def max_rpm(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._max_rpm, "1/s") + + @property + def max_torque(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._max_torque, "Nm") + + @property + def peak_torque_rpm(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._peak_torque_rpm, "1/s") + + @property + def min_feed(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._min_feed, "mm/min") + + @property + def max_feed(self) -> FreeCAD.Units.Quantity: + return FreeCAD.Units.Quantity(self._max_feed, "mm/min") + + @property + def label(self) -> str: + return self._label + + @label.setter + def label(self, label: str) -> None: + self._label = label + + def get_min_rpm_value(self) -> float: + """Helper method to get minimum RPM value for display/testing.""" + return self._min_rpm * 60 + + def get_max_rpm_value(self) -> float: + """Helper method to get maximum RPM value for display/testing.""" + return self._max_rpm * 60 + + def get_peak_torque_rpm_value(self) -> float: + """Helper method to get peak torque RPM value for display/testing.""" + return self._peak_torque_rpm * 60 + + def validate(self) -> None: + """Validates the machine parameters.""" + if not self.label: + raise AttributeError("Machine name is required") + if self._peak_torque_rpm > self._max_rpm: + err = ("Peak Torque RPM {ptrpm:.2f} must be less than max RPM " "{max_rpm:.2f}").format( + ptrpm=self._peak_torque_rpm * 60, max_rpm=self._max_rpm * 60 + ) + raise AttributeError(err) + if self._max_rpm <= self._min_rpm: + raise AttributeError("Max RPM must be larger than min RPM") + if self._max_feed <= self._min_feed: + raise AttributeError("Max feed must be larger than min feed") + + def get_torque_at_rpm(self, rpm: Union[int, float, FreeCAD.Units.Quantity]) -> float: + """ + Calculates the torque at a given RPM. + + Args: + rpm: The RPM value (int, float, or Quantity). + + Returns: + The torque at the given RPM in Nm. + """ + if isinstance(rpm, FreeCAD.Units.Quantity): + try: + rpm_hz = rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + rpm_hz = rpm.Value * self.UNIT_CONVERSIONS["rpm"] + else: + rpm_hz = rpm * self.UNIT_CONVERSIONS["rpm"] + max_torque_nm = self._max_torque + peak_torque_rpm_hz = self._peak_torque_rpm + peak_rpm_for_calc = peak_torque_rpm_hz * 60 + rpm_for_calc = rpm_hz * 60 + torque_at_current_rpm = ( + self._max_power * 9.5488 / rpm_for_calc if rpm_for_calc else float("inf") + ) + if rpm_for_calc <= peak_rpm_for_calc: + torque_at_current_rpm = ( + max_torque_nm / peak_rpm_for_calc * rpm_for_calc + if peak_rpm_for_calc + else float("inf") + ) + return min(max_torque_nm, torque_at_current_rpm) + + def set_max_power(self, power: Union[int, float], unit: Optional[str] = None) -> None: + """Sets the maximum power of the machine.""" + unit = unit or "kW" + if unit in self.UNIT_CONVERSIONS: + power_value = power * self.UNIT_CONVERSIONS[unit] + else: + power_value = FreeCAD.Units.Quantity(power, unit).getValueAs("W").Value + self._max_power = power_value + if self._max_power <= 0: + raise AttributeError("Max power must be positive") + + def set_min_rpm(self, min_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None: + """Sets the minimum RPM of the machine.""" + if isinstance(min_rpm, FreeCAD.Units.Quantity): + try: + min_rpm_value = min_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + min_rpm_value = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + else: + min_rpm_value = min_rpm * self.UNIT_CONVERSIONS["rpm"] + self._min_rpm = min_rpm_value + if self._min_rpm < 0: + raise AttributeError("Min RPM cannot be negative") + if self._min_rpm >= self._max_rpm: + self._max_rpm = min_rpm_value + 1.0 / 60.0 + + def set_max_rpm(self, max_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None: + """Sets the maximum RPM of the machine.""" + if isinstance(max_rpm, FreeCAD.Units.Quantity): + try: + max_rpm_value = max_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + max_rpm_value = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + else: + max_rpm_value = max_rpm * self.UNIT_CONVERSIONS["rpm"] + self._max_rpm = max_rpm_value + if self._max_rpm <= 0: + raise AttributeError("Max RPM must be positive") + if self._max_rpm <= self._min_rpm: + self._min_rpm = max(0, max_rpm_value - 1.0 / 60.0) + + def set_min_feed( + self, + min_feed: Union[int, float, FreeCAD.Units.Quantity], + unit: Optional[str] = None, + ) -> None: + """Sets the minimum feed rate of the machine.""" + unit = unit or "mm/min" + if unit in self.UNIT_CONVERSIONS: + min_feed_value = min_feed * self.UNIT_CONVERSIONS[unit] + else: + min_feed_value = FreeCAD.Units.Quantity(min_feed, unit).getValueAs("mm/min").Value + self._min_feed = min_feed_value + if self._min_feed < 0: + raise AttributeError("Min feed cannot be negative") + if self._min_feed >= self._max_feed: + self._max_feed = min_feed_value + 1.0 + + def set_max_feed( + self, + max_feed: Union[int, float, FreeCAD.Units.Quantity], + unit: Optional[str] = None, + ) -> None: + """Sets the maximum feed rate of the machine.""" + unit = unit or "mm/min" + if unit in self.UNIT_CONVERSIONS: + max_feed_value = max_feed * self.UNIT_CONVERSIONS[unit] + else: + max_feed_value = FreeCAD.Units.Quantity(max_feed, unit).getValueAs("mm/min").Value + self._max_feed = max_feed_value + if self._max_feed <= 0: + raise AttributeError("Max feed must be positive") + if self._max_feed <= self._min_feed: + self._min_feed = max(0, max_feed_value - 1.0) + + def set_peak_torque_rpm( + self, peak_torque_rpm: Union[int, float, FreeCAD.Units.Quantity] + ) -> None: + """Sets the peak torque RPM of the machine.""" + if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity): + try: + peak_torque_rpm_value = peak_torque_rpm.getValueAs("1/s").Value + except (Base.ParserError, ValueError): + peak_torque_rpm_value = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"] + else: + peak_torque_rpm_value = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"] + self._peak_torque_rpm = peak_torque_rpm_value + if self._peak_torque_rpm < 0: + raise AttributeError("Peak torque RPM cannot be negative") + + def set_max_torque( + self, + max_torque: Union[int, float, FreeCAD.Units.Quantity], + unit: Optional[str] = None, + ) -> None: + """Sets the maximum torque of the machine.""" + unit = unit or "Nm" + if unit in self.UNIT_CONVERSIONS: + max_torque_value = max_torque * self.UNIT_CONVERSIONS[unit] + else: + max_torque_value = FreeCAD.Units.Quantity(max_torque, unit).getValueAs("Nm").Value + self._max_torque = max_torque_value + if self._max_torque <= 0: + raise AttributeError("Max torque must be positive") + + def dump(self, do_print: bool = True) -> Optional[str]: + """ + Dumps machine information to console or returns it as a string. + + Args: + do_print: If True, prints the information to the console. + If False, returns the information as a string. + + Returns: + A formatted string containing machine information if do_print is + False, otherwise None. + """ + min_rpm_value = self._min_rpm * 60 + max_rpm_value = self._max_rpm * 60 + peak_torque_rpm_value = self._peak_torque_rpm * 60 + + output = "" + output += f"Machine {self.label}:\n" + output += f" Max power: {self._max_power:.2f} W\n" + output += f" RPM: {min_rpm_value:.2f} RPM - {max_rpm_value:.2f} RPM\n" + output += f" Feed: {self.min_feed.UserString} - " f"{self.max_feed.UserString}\n" + output += ( + f" Peak torque: {self._max_torque:.2f} Nm at " f"{peak_torque_rpm_value:.2f} RPM\n" + ) + output += f" Max_torque: {self._max_torque} Nm\n" + + if do_print: + print(output) + return output diff --git a/src/Mod/CAM/Path/Tool/shape/__init__.py b/src/Mod/CAM/Path/Tool/shape/__init__.py new file mode 100644 index 0000000000..d70cd5de4a --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/__init__.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# This package aggregates tool bit shape classes. + +# Import the base class and all concrete shape classes +from .models.base import ToolBitShape +from .models.ballend import ToolBitShapeBallend +from .models.chamfer import ToolBitShapeChamfer +from .models.dovetail import ToolBitShapeDovetail +from .models.drill import ToolBitShapeDrill +from .models.endmill import ToolBitShapeEndmill +from .models.fillet import ToolBitShapeFillet +from .models.probe import ToolBitShapeProbe +from .models.reamer import ToolBitShapeReamer +from .models.slittingsaw import ToolBitShapeSlittingSaw +from .models.tap import ToolBitShapeTap +from .models.threadmill import ToolBitShapeThreadMill +from .models.bullnose import ToolBitShapeBullnose +from .models.vbit import ToolBitShapeVBit +from .models.icon import ( + ToolBitShapeIcon, + ToolBitShapePngIcon, + ToolBitShapeSvgIcon, +) + +# A list of the name of each ToolBitShape +TOOL_BIT_SHAPE_NAMES = sorted([cls.name for cls in ToolBitShape.__subclasses__()]) + +# Define __all__ for explicit public interface +__all__ = [ + "ToolBitShape", + "ToolBitShapeBallend", + "ToolBitShapeChamfer", + "ToolBitShapeDovetail", + "ToolBitShapeDrill", + "ToolBitShapeEndmill", + "ToolBitShapeFillet", + "ToolBitShapeProbe", + "ToolBitShapeReamer", + "ToolBitShapeSlittingSaw", + "ToolBitShapeTap", + "ToolBitShapeThreadMill", + "ToolBitShapeBullnose", + "ToolBitShapeVBit", + "TOOL_BIT_SHAPE_NAMES", + "ToolBitShapeIcon", + "ToolBitShapeSvgIcon", + "ToolBitShapePngIcon", +] diff --git a/src/Mod/CAM/Path/Tool/shape/doc.py b/src/Mod/CAM/Path/Tool/shape/doc.py new file mode 100644 index 0000000000..1e2d306a02 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/doc.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +import Path.Base.Util as PathUtil +from typing import Dict, List, Any, Optional +import tempfile +import os + + +def find_shape_object(doc: "FreeCAD.Document") -> Optional["FreeCAD.DocumentObject"]: + """ + Find the primary object representing the shape in a document. + + Looks for PartDesign::Body, then Part::Feature. Falls back to the first + object if no better candidate is found. + + Args: + doc (FreeCAD.Document): The document to search within. + + Returns: + Optional[FreeCAD.DocumentObject]: The found object or None. + """ + obj = None + # Prioritize Body + for o in doc.Objects: + if o.isDerivedFrom("PartDesign::Body"): + return o + # Keep track of the first Part::Feature found as a fallback + if obj is None and o.isDerivedFrom("Part::Feature"): + obj = o + if obj: + return obj + # Fallback to the very first object if nothing else suitable found + return doc.Objects[0] if doc.Objects else None + + +def get_object_properties( + obj: "FreeCAD.DocumentObject", expected_params: List[str] +) -> Dict[str, Any]: + """ + Extract properties matching expected_params from a FreeCAD PropertyBag. + + Issues warnings for missing parameters but does not raise an error. + + Args: + obj: The PropertyBag to extract properties from. + expected_params (List[str]): A list of property names to look for. + + Returns: + Dict[str, Any]: A dictionary mapping property names to their values. + Values are FreeCAD native types. + """ + properties = {} + for name in expected_params: + if hasattr(obj, name): + properties[name] = getattr(obj, name) + else: + # Log a warning if a parameter expected by the shape class is missing + FreeCAD.Console.PrintWarning( + f"Parameter '{name}' not found on object '{obj.Label}' " + f"({obj.Name}). Default value will be used by the shape class.\n" + ) + properties[name] = None # Indicate missing value + return properties + + +def update_shape_object_properties( + obj: "FreeCAD.DocumentObject", parameters: Dict[str, Any] +) -> None: + """ + Update properties of a FreeCAD PropertyBag based on a dictionary of parameters. + + Args: + obj (FreeCAD.DocumentObject): The PropertyBag to update properties on. + parameters (Dict[str, Any]): A dictionary of property names and values. + """ + for name, value in parameters.items(): + if hasattr(obj, name): + try: + PathUtil.setProperty(obj, name, value) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"Failed to set property '{name}' on object '{obj.Label}'" + f" ({obj.Name}) with value '{value}': {e}\n" + ) + else: + FreeCAD.Console.PrintWarning( + f"Property '{name}' not found on object '{obj.Label}'" f" ({obj.Name}). Skipping.\n" + ) + + +def get_doc_state() -> Any: + """ + Used to make a "snapshot" of the current state of FreeCAD, to allow + for restoring the ActiveDocument and selection state later. + """ + doc_name = FreeCAD.ActiveDocument.Name if FreeCAD.ActiveDocument else None + if FreeCAD.GuiUp: + import FreeCADGui + + selection = FreeCADGui.Selection.getSelection() + else: + selection = [] + return doc_name, selection + + +def restore_doc_state(state): + doc_name, selection = state + if doc_name: + FreeCAD.setActiveDocument(doc_name) + if FreeCAD.GuiUp: + import FreeCADGui + + for sel in selection: + FreeCADGui.Selection.addSelection(doc_name, sel.Name) + + +class ShapeDocFromBytes: + """ + Context manager to create and manage a temporary FreeCAD document, + loading content from a byte string. + """ + + def __init__(self, content: bytes): + self._content = content + self._doc = None + self._temp_file = None + self._old_state = None + + def __enter__(self) -> "FreeCAD.Document": + """Creates a new temporary FreeCAD document or loads cache if provided.""" + # Create a temporary file and write the cache content to it + with tempfile.NamedTemporaryFile(suffix=".FCStd", delete=False) as tmp_file: + tmp_file.write(self._content) + self._temp_file = tmp_file.name + + # When we open a new document, FreeCAD loses the state, of the active + # document (i.e. current selection), even if the newly opened document + # is a hidden one. + # So we need to restore the active document state at the end. + self._old_state = get_doc_state() + + # Open the document from the temporary file + # Use a specific name to avoid clashes if multiple docs are open + # Open the document from the temporary file + self._doc = FreeCAD.openDocument(self._temp_file, hidden=True) + if not self._doc: + raise RuntimeError(f"Failed to open document from {self._temp_file}") + return self._doc + + def __exit__(self, exc_type, exc_value, traceback) -> None: + """Closes the temporary FreeCAD document and cleans up the temp file.""" + if self._doc: + # Note that .closeDocument() is extremely slow; it takes + # almost 400ms per document - much longer than opening! + FreeCAD.closeDocument(self._doc.Name) + self._doc = None + + # Restore the original active document + restore_doc_state(self._old_state) + + # Clean up the temporary file if it was created + if self._temp_file and os.path.exists(self._temp_file): + try: + os.remove(self._temp_file) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"Failed to remove temporary file {self._temp_file}: {e}\n" + ) diff --git a/src/Mod/CAM/Path/Tool/shape/models/__init__.py b/src/Mod/CAM/Path/Tool/shape/models/__init__.py new file mode 100644 index 0000000000..40a96afc6f --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/src/Mod/CAM/Path/Tool/shape/models/ballend.py b/src/Mod/CAM/Path/Tool/shape/models/ballend.py new file mode 100644 index 0000000000..98088d1e82 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/ballend.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeBallend(ToolBitShape): + name: str = "Ballend" + aliases = ("ballend",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Ballend") diff --git a/src/Mod/CAM/Path/Tool/shape/models/base.py b/src/Mod/CAM/Path/Tool/shape/models/base.py new file mode 100644 index 0000000000..301bb7ec30 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/base.py @@ -0,0 +1,630 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import pathlib +import FreeCAD +import Path +import os +from typing import Dict, List, Any, Mapping, Optional, Tuple, Type, cast +import zipfile +import xml.etree.ElementTree as ET +import io +import tempfile +from ...assets import Asset, AssetUri, AssetSerializer, DummyAssetSerializer +from ...camassets import cam_assets +from ..doc import ( + find_shape_object, + get_object_properties, + update_shape_object_properties, + ShapeDocFromBytes, +) +from .icon import ToolBitShapeIcon + + +class ToolBitShape(Asset): + """Abstract base class for tool bit shapes.""" + + asset_type: str = "toolbitshape" + + # The name is used... + # 1. as a base for the default filename. E.g. if the name is + # "Endmill", then by default the file is "endmill.fcstd". + # 2. to identify the shape class from a shape.fcstd file. + # Upon loading a shape, the name of the body in the shape + # file is read. It MUST match one of the names. + name: str + + # Aliases exist for backward compatibility. If an existing .fctb file + # references a shape such as "v-bit.fctb", and that shape file cannot + # be found, then we can attempt to find a shape class from the string + # "v-bit", "vbit", etc. + aliases: Tuple[str, ...] = tuple() + + def __init__(self, id: str, **kwargs: Any): + """ + Initialize the shape. + + Args: + id (str): The unique identifier for the shape. + **kwargs: Keyword arguments for shape parameters (e.g., Diameter). + Values should be FreeCAD.Units.Quantity where applicable. + """ + # _params will be populated with default values after loading + self._params: Dict[str, Any] = {} + + # Stores default parameter values loaded from the FCStd file + self._defaults: Dict[str, Any] = {} + + # Keeps the loaded FreeCAD document content for this instance + self._data: Optional[bytes] = None + + self.id: str = id + + self.is_builtin: bool = True + + self.icon: Optional[ToolBitShapeIcon] = None + + # Assign parameters + for param, value in kwargs.items(): + self.set_parameter(param, value) + + def __str__(self): + params_str = ", ".join(f"{name}={val}" for name, val in self._params.items()) + return f"{self.name}({params_str})" + + def __repr__(self): + return self.__str__() + + def get_id(self) -> str: + """ + Get the ID of the shape. + + Returns: + str: The ID of the shape. + """ + return self.id + + @classmethod + def _get_shape_class_from_doc(cls, doc: "FreeCAD.Document") -> Type["ToolBitShape"]: + # Find the Body object to identify the shape type + body_obj = find_shape_object(doc) + if not body_obj: + raise ValueError(f"No 'PartDesign::Body' object found in {doc}") + + # Find the correct subclass based on the body label + shape_classes = {c.name: c for c in ToolBitShape.__subclasses__()} + shape_class = shape_classes.get(body_obj.Label) + if not shape_class: + raise ValueError( + f"No ToolBitShape subclass found matching Body label '{body_obj.Label}' in {doc}" + ) + return shape_class + + @classmethod + def get_shape_class_from_bytes(cls, data: bytes) -> Type["ToolBitShape"]: + """ + Identifies the ToolBitShape subclass from the raw bytes of an FCStd file + by parsing the XML content to find the Body label. + + Args: + data (bytes): The raw bytes of the .FCStd file. + + Returns: + Type[ToolBitShape]: The appropriate ToolBitShape subclass. + + Raises: + ValueError: If the data is not a valid FCStd file, Document.xml is + missing, no Body object is found, or the Body label + does not match a known shape name. + """ + try: + # FCStd files are zip archives + with zipfile.ZipFile(io.BytesIO(data)) as zf: + # Read Document.xml from the archive + with zf.open("Document.xml") as doc_xml_file: + tree = ET.parse(doc_xml_file) + root = tree.getroot() + + # Extract name of the main Body from XML tree using xpath. + # The body should be a PartDesign::Body, and its label is + # stored in an Property element with a matching name. + body_label = None + xpath = './/Object[@name="Body"]//Property[@name="Label"]/String' + body_label_elem = root.find(xpath) + if body_label_elem is not None: + body_label = body_label_elem.get("value") + + if not body_label: + raise ValueError( + "No 'Label' property found for 'PartDesign::Body' object using XPath" + ) + + # Find the correct subclass based on the body label + shape_class = cls.get_subclass_by_name(body_label) + if not shape_class: + raise ValueError( + f"No ToolBitShape subclass found matching Body label '{body_label}'" + ) + return shape_class + + except zipfile.BadZipFile: + raise ValueError("Invalid FCStd file data (not a valid zip archive)") + except KeyError: + raise ValueError("Invalid FCStd file data (Document.xml not found)") + except ET.ParseError: + raise ValueError("Error parsing Document.xml") + except Exception as e: + # Catch any other unexpected errors during parsing + raise ValueError(f"Error processing FCStd data: {e}") + + @classmethod + def _find_property_object(cls, doc: "FreeCAD.Document") -> Optional["FreeCAD.DocumentObject"]: + """ + Find the PropertyBag object named "Attributes" in a document. + + Args: + doc (FreeCAD.Document): The document to search within. + + Returns: + Optional[FreeCAD.DocumentObject]: The found object or None. + """ + for o in doc.Objects: + # Check if the object has a Label property and if its value is "Attributes" + # This seems to be the convention in the shape files. + if hasattr(o, "Label") and o.Label == "Attributes": + # We assume this object holds the parameters. + # Further type checking (e.g., for App::FeaturePython or PropertyBag) + # could be added if needed, but Label check might be sufficient. + return o + return None + + @classmethod + def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]: + """ + Extracts URIs of dependencies from the raw bytes of an FCStd file. + For ToolBitShape, this is the associated ToolBitShapeIcon, identified + by the same ID as the shape asset. + """ + Path.Log.debug(f"ToolBitShape.extract_dependencies called for {cls.__name__}") + assert ( + serializer == DummyAssetSerializer + ), f"ToolBitShape supports only native import, not {serializer}" + + # A ToolBitShape asset depends on a ToolBitShapeIcon asset with the same ID. + # We need to extract the shape ID from the FCStd data. + try: + # Open the shape data temporarily to get the Body label, which can + # be used to derive the ID if needed, or assume the ID is available + # in the data somehow (e.g., in a property). + # For now, let's assume the ID is implicitly the asset name derived + # from the Body label. + shape_class = cls.get_shape_class_from_bytes(data) + shape_id = shape_class.name.lower() # Assuming ID is lowercase name + + # Construct the URI for the corresponding icon asset + svg_uri = AssetUri.build( + asset_type="toolbitshapesvg", + asset_id=shape_id + ".svg", + ) + png_uri = AssetUri.build( + asset_type="toolbitshapepng", + asset_id=shape_id + ".png", + ) + return [svg_uri, png_uri] + + except Exception as e: + # If we can't extract the shape ID or something goes wrong, + # assume no dependencies for now. + Path.Log.error(f"Failed to extract dependencies from shape data: {e}") + return [] + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: Type[AssetSerializer], + ) -> "ToolBitShape": + """ + Create a ToolBitShape instance from the raw bytes of an FCStd file. + + Identifies the correct subclass based on the Body label in the file, + loads parameters, and caches the document content. + + Args: + data (bytes): The raw bytes of the .FCStd file. + id (str): The unique identifier for the shape. + dependencies (Optional[Mapping[AssetUri, Any]]): A mapping of + resolved dependencies. If None, shallow load was attempted. + + Returns: + ToolBitShape: An instance of the appropriate ToolBitShape subclass. + + Raises: + ValueError: If the data cannot be opened, no Body or PropertyBag + is found, or the Body label does not match a known + shape name. + Exception: For other potential FreeCAD errors during loading. + """ + assert serializer == DummyAssetSerializer, "ToolBitShape supports only native import" + + # Open the shape data temporarily to get the Body label and parameters + with ShapeDocFromBytes(data) as temp_doc: + if not temp_doc: + # This case might be covered by ShapeDocFromBytes exceptions, + # but keeping for clarity. + raise ValueError("Failed to open shape document from bytes") + + # Determine the specific subclass of ToolBitShape using the new method + shape_class = ToolBitShape.get_shape_class_from_bytes(data) + + # Load properties from the temporary document + props_obj = ToolBitShape._find_property_object(temp_doc) + if not props_obj: + raise ValueError("No 'Attributes' PropertyBag object found in document bytes") + + # Get properties from the properties object + expected_params = shape_class.get_expected_shape_parameters() + loaded_params = get_object_properties(props_obj, expected_params) + + missing_params = [ + name + for name in expected_params + if name not in loaded_params or loaded_params[name] is None + ] + + if missing_params: + raise ValueError( + f"Validation error: Object '{props_obj.Label}' in document bytes " + + f"is missing parameters for {shape_class.__name__}: {', '.join(missing_params)}" + ) + + # Instantiate the specific subclass with the provided ID + instance = shape_class(id=id) + instance._data = data # Cache the byte content + instance._defaults = loaded_params + + if dependencies: # dependencies is None = shallow load + # Assign resolved dependencies (like the icon) to the instance + # The icon has the same ID as the shape, with .png or .svg appended. + icon_uri = AssetUri.build( + asset_type="toolbitshapesvg", + asset_id=id + ".svg", + ) + instance.icon = cast(ToolBitShapeIcon, dependencies.get(icon_uri)) + if not instance.icon: + icon_uri = AssetUri.build( + asset_type="toolbitshapepng", + asset_id=id + ".png", + ) + instance.icon = cast(ToolBitShapeIcon, dependencies.get(icon_uri)) + + # Update instance parameters, prioritizing loaded defaults but not + # overwriting parameters that may already be set during __init__ + instance._params = instance._defaults | instance._params + + return instance + + def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes: + """ + Serializes a ToolBitShape object to bytes (e.g., an fcstd file). + This is required by the Asset interface. + """ + assert serializer == DummyAssetSerializer, "ToolBitShape supports only native export" + doc = None + try: + # Create a new temporary document + doc = FreeCAD.newDocument("TemporaryShapeDoc", hidden=True) + + # Add the shape's body to the temporary document + self.make_body(doc) + + # Recompute the document to ensure the body is created + doc.recompute() + + # Save the temporary document to a temporary file + # We cannot use NamedTemporaryFile on Windows, because there + # doc.saveAs() may not have permission to access the tempfile + # while the NamedTemporaryFile is open. + # So we use TemporaryDirectory instead, to ensure cleanup while + # still having a the temporary file inside it. + with tempfile.TemporaryDirectory() as thedir: + temp_file_path = pathlib.Path(thedir, "temp.FCStd") + doc.saveAs(str(temp_file_path)) + return temp_file_path.read_bytes() + + finally: + # Clean up the temporary document + if doc: + FreeCAD.closeDocument(doc.Name) + + @classmethod + def from_file(cls, filepath: pathlib.Path, **kwargs: Any) -> "ToolBitShape": + """ + Create a ToolBitShape instance from an FCStd file. + + Reads the file bytes and delegates to from_bytes(). + + Args: + filepath (pathlib.Path): Path to the .FCStd file. + **kwargs: Keyword arguments for shape parameters to override defaults. + + Returns: + ToolBitShape: An instance of the appropriate ToolBitShape subclass. + + Raises: + FileNotFoundError: If the file does not exist. + ValueError: If the file cannot be opened, no Body or PropertyBag + is found, or the Body label does not match a known + shape name. + Exception: For other potential FreeCAD errors during loading. + """ + if not filepath.exists(): + raise FileNotFoundError(f"Shape file not found: {filepath}") + + try: + data = filepath.read_bytes() + # Extract the ID from the filename (without extension) + shape_id = filepath.stem + # Pass an empty dictionary for dependencies when loading from a single file + # TODO: pass ToolBitShapeIcon as a dependency + instance = cls.from_bytes(data, shape_id, {}, DummyAssetSerializer) + # Apply kwargs parameters after loading from bytes + if kwargs: + instance.set_parameters(**kwargs) + return instance + except (FileNotFoundError, ValueError) as e: + raise e + except Exception as e: + raise RuntimeError(f"Failed to create shape from {filepath}: {e}") + + @classmethod + def get_subclass_by_name( + cls, name: str, default: Type["ToolBitShape"] | None = None + ) -> Optional[Type["ToolBitShape"]]: + """ + Retrieves a ToolBitShape class by its name or alias. + """ + name = name.lower() + for thecls in cls.__subclasses__(): + if ( + thecls.name.lower() == name + or thecls.__name__.lower() == name + or name in thecls.aliases + ): + return thecls + return default + + @classmethod + def resolve_name(cls, identifier: str) -> AssetUri: + """ + Resolves an identifier (alias, name, filename, or URI) to a Uri object. + """ + # 1. If the input is a url string, return the AssetUri for it. + if AssetUri.is_uri(identifier): + return AssetUri(identifier) + + # 2. If the input is a filename (with extension), assume the asset + # name is the base name. + asset_name = identifier + if identifier.endswith(".fcstd"): + asset_name = os.path.splitext(os.path.basename(identifier))[0] + + # 3. Use get_subclass_by_name to try to resolve alias to a class. + # if one is found, use the class.name. + shape_class = cls.get_subclass_by_name(asset_name.lower()) + if shape_class: + asset_name = shape_class.name.lower() + + # 4. Construct the Uri using AssetUri.build() and return it + return AssetUri.build( + asset_type="toolbitshape", + asset_id=asset_name, + ) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + """ + Subclasses must define the dictionary mapping parameter names to + translations and FreeCAD property type strings (e.g., + 'App::PropertyLength'). + + The schema defines any parameters that MUST be in the shape file. + Any attempt to load a shape file that does not match the schema + will cause an error. + """ + raise NotImplementedError + + @property + def label(self) -> str: + """Return a user friendly, translatable display name.""" + raise NotImplementedError + + def reset_parameters(self): + """Reset parameters to their default values.""" + self._params.update(self._defaults) + + def get_parameter_label(self, param_name: str) -> str: + """ + Get the user-facing label for a given parameter name. + """ + str_param_name = str(param_name) + entry = self.schema().get(param_name) + return entry[0] if entry else str_param_name + + def get_parameter_property_type(self, param_name: str) -> str: + """ + Get the FreeCAD property type string for a given parameter name. + """ + return self.schema()[param_name][1] + + def get_parameters(self) -> Dict[str, Any]: + """ + Get the dictionary of current parameters and their values. + + Returns: + dict: A dictionary mapping parameter names to their values. + """ + return self._params + + def get_parameter(self, name: str) -> Any: + """ + Get the value of a specific parameter. + + Args: + name (str): The name of the parameter. + + Returns: + The value of the parameter (often a FreeCAD.Units.Quantity). + + Raises: + KeyError: If the parameter name is not valid for this shape. + """ + if name not in self.schema(): + raise KeyError(f"Shape '{self.name}' has no parameter '{name}'") + return self._params[name] + + def set_parameter(self, name: str, value: Any): + """ + Set the value of a specific parameter. + + Args: + name (str): The name of the parameter. + value: The new value for the parameter. Should be compatible + with the expected type (e.g., FreeCAD.Units.Quantity). + + Raises: + KeyError: If the parameter name is not valid for this shape. + """ + if name not in self.schema().keys(): + Path.Log.debug( + f"Shape '{self.name}' was given an invalid parameter '{name}'. Has {self._params}\n" + ) + # Log to confirm this path is taken when an invalid parameter is given + Path.Log.debug( + f"Invalid parameter '{name}' for shape " + f"'{self.name}', returning without raising KeyError." + ) + return + + self._params[name] = value + + def set_parameters(self, **kwargs): + """ + Set multiple parameters using keyword arguments. + + Args: + **kwargs: Keyword arguments where keys are parameter names. + """ + for name, value in kwargs.items(): + try: + self.set_parameter(name, value) + except KeyError: + Path.Log.debug(f"Ignoring unknown parameter '{name}' for shape '{self.name}'.\n") + + @classmethod + def get_expected_shape_parameters(cls) -> List[str]: + """ + Get a list of parameter names expected by this shape class based on + its schema. + + Returns: + list[str]: List of parameter names. + """ + return list(cls.schema().keys()) + + def make_body(self, doc: "FreeCAD.Document"): + """ + Generates the body of the ToolBitShape and copies it to the provided + document. + """ + assert self._data is not None + with ShapeDocFromBytes(self._data) as tmp_doc: + shape = find_shape_object(tmp_doc) + if not shape: + FreeCAD.Console.PrintWarning( + "No suitable shape object found in document. " "Cannot create solid shape.\n" + ) + return None + + props = self._find_property_object(tmp_doc) + if not props: + FreeCAD.Console.PrintWarning( + "No suitable shape object found in document. " "Cannot create solid shape.\n" + ) + return None + + update_shape_object_properties(props, self.get_parameters()) + + # Recompute the document to apply property changes + tmp_doc.recompute() + + # Copy the body to the given document without immediate compute. + return doc.copyObject(shape, True) + + """ + Retrieves the thumbnail data for the tool bit shape in PNG format. + """ + + def get_icon(self) -> Optional[ToolBitShapeIcon]: + """ + Get the associated ToolBitShapeIcon instance. Tries to load one from + the asset manager if none was assigned. + + Returns: + Optional[ToolBitShapeIcon]: The icon instance, or None if none found. + """ + if self.icon: + return self.icon + + # Try to get a matching SVG from the asset manager. + self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.id}.svg") + if self.icon: + return self.icon + self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.name.lower()}.svg") + if self.icon: + return self.icon + + # Try to get a matching PNG from the asset manager. + self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.id}.png") + if self.icon: + return self.icon + self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.name.lower()}.png") + if self.icon: + return self.icon + return None + + def get_thumbnail(self) -> Optional[bytes]: + """ + Retrieves the thumbnail data for the tool bit shape in PNG format, + as embedded in the shape file. + """ + if not self._data: + return None + with zipfile.ZipFile(io.BytesIO(self._data)) as zf: + try: + with zf.open("thumbnails/Thumbnail.png", "r") as tn: + return tn.read() + except KeyError: + pass + return None diff --git a/src/Mod/CAM/Path/Tool/shape/models/bullnose.py b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py new file mode 100644 index 0000000000..faeb13dc45 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeBullnose(ToolBitShape): + name = "Bullnose" + aliases = "bullnose", "torus" + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + "FlatRadius": ( + FreeCAD.Qt.translate("ToolBitShape", "Torus radius"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Torus") diff --git a/src/Mod/CAM/Path/Tool/shape/models/chamfer.py b/src/Mod/CAM/Path/Tool/shape/models/chamfer.py new file mode 100644 index 0000000000..ce880cbdbc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/chamfer.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeChamfer(ToolBitShape): + name = "Chamfer" + aliases = ("chamfer",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge angle"), + "App::PropertyAngle", + ), + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Chamfer") diff --git a/src/Mod/CAM/Path/Tool/shape/models/dovetail.py b/src/Mod/CAM/Path/Tool/shape/models/dovetail.py new file mode 100644 index 0000000000..7e52e8ec48 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/dovetail.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeDovetail(ToolBitShape): + name = "Dovetail" + aliases = ("dovetail",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "TipDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Crest height"), + "App::PropertyLength", + ), + "CuttingEdgeAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting angle"), + "App::PropertyAngle", + ), + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Dovetail height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Major diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "NeckDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Neck diameter"), + "App::PropertyLength", + ), + "NeckHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Neck length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Dovetail") diff --git a/src/Mod/CAM/Path/Tool/shape/models/drill.py b/src/Mod/CAM/Path/Tool/shape/models/drill.py new file mode 100644 index 0000000000..3dd7ea68dc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/drill.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeDrill(ToolBitShape): + name = "Drill" + aliases = ("drill",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "TipAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Tip angle"), + "App::PropertyAngle", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("CAM", "Drill") diff --git a/src/Mod/CAM/Path/Tool/shape/models/endmill.py b/src/Mod/CAM/Path/Tool/shape/models/endmill.py new file mode 100644 index 0000000000..bce686aeb7 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/endmill.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeEndmill(ToolBitShape): + name = "Endmill" + aliases = ("endmill",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitToolBitShapeShapeEndMill", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Endmill") diff --git a/src/Mod/CAM/Path/Tool/shape/models/fillet.py b/src/Mod/CAM/Path/Tool/shape/models/fillet.py new file mode 100644 index 0000000000..0156b910ff --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/fillet.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeFillet(ToolBitShape): + name = "Fillet" + aliases = ("fillet",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CrownHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Crown height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "FilletRadius": ( + FreeCAD.Qt.translate("ToolBitShape", "Fillet radius"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Filleted Chamfer") diff --git a/src/Mod/CAM/Path/Tool/shape/models/icon.py b/src/Mod/CAM/Path/Tool/shape/models/icon.py new file mode 100644 index 0000000000..702f7e448a --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/icon.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pathlib +import xml.etree.ElementTree as ET +from typing import Mapping, Optional +from functools import cached_property +from ...assets import Asset, AssetUri, AssetSerializer, DummyAssetSerializer +import Path.Tool.shape.util as util +from PySide import QtCore, QtGui, QtSvg + +_svg_ns = {"s": "http://www.w3.org/2000/svg"} + + +class ToolBitShapeIcon(Asset): + """Abstract base class for tool bit shape icons.""" + + def __init__(self, id: str, data: bytes): + """ + Initialize the icon. + + Args: + id (str): The unique identifier for the icon, including extension. + data (bytes): The raw icon data (e.g., SVG or PNG bytes). + """ + self.id: str = id + self.data: bytes = data + + def get_id(self) -> str: + """ + Get the ID of the icon. + + Returns: + str: The ID of the icon. + """ + return self.id + + @classmethod + def from_bytes( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + serializer: AssetSerializer, + ) -> "ToolBitShapeIcon": + """ + Create a ToolBitShapeIcon instance from raw bytes. + + Args: + data (bytes): The raw bytes of the icon file. + id (str): The ID of the asset, including extension. + dependencies (Optional[Mapping[AssetUri, Asset]]): A mapping of resolved dependencies (not used for icons). + + Returns: + ToolBitShapeIcon: An instance of ToolBitShapeIcon. + """ + assert serializer == DummyAssetSerializer, "ToolBitShapeIcon supports only native import" + return cls(id=id, data=data) + + def to_bytes(self, serializer: AssetSerializer) -> bytes: + """ + Serializes a ToolBitShapeIcon object to bytes. + """ + assert serializer == DummyAssetSerializer, "ToolBitShapeIcon supports only native export" + return self.data + + @classmethod + def from_file(cls, filepath: pathlib.Path, id: str) -> "ToolBitShapeIcon": + """ + Create a ToolBitShapeIcon instance from a file. + + Args: + filepath (pathlib.Path): Path to the icon file (.svg or .png). + shape_id_base (str): The base ID of the associated shape. + + Returns: + ToolBitShapeIcon: An instance of ToolBitShapeIcon. + + Raises: + FileNotFoundError: If the file does not exist. + """ + if not filepath.exists(): + raise FileNotFoundError(f"Icon file not found: {filepath}") + + data = filepath.read_bytes() + if filepath.suffix.lower() == ".png": + return ToolBitShapePngIcon(id, data) + elif filepath.suffix.lower() == ".svg": + return ToolBitShapeSvgIcon(id, data) + else: + raise NotImplementedError(f"unsupported icon file: {filepath}") + + @classmethod + def from_shape_data(cls, shape_data: bytes, id: str) -> Optional["ToolBitShapeIcon"]: + """ + Create a thumbnail icon from shape data bytes. + + Args: + shape_data (bytes): The raw bytes of the shape file (.FCStd). + shape_id_base (str): The base ID of the associated shape. + + Returns: + Optional[ToolBitShapeIcon]: An instance of ToolBitShapeIcon (PNG), or None. + """ + image_bytes = util.create_thumbnail_from_data(shape_data) + if not image_bytes: + return None + + # Assuming create_thumbnail_from_data returns PNG data + return ToolBitShapePngIcon(id=id, data=image_bytes) + + def get_size_in_bytes(self) -> int: + """ + Get the size of the icon data in bytes. + """ + return len(self.data) + + @cached_property + def abbreviations(self) -> Mapping[str, str]: + """ + Returns a cached mapping of parameter abbreviations from the icon data. + """ + return {} + + def get_abbr(self, param_name: str) -> Optional[str]: + """ + Retrieves the abbreviation for a given parameter name. + + Args: + param_name: The name of the parameter. + + Returns: + The abbreviation string, or None if not found. + """ + normalized_param_name = param_name.lower().replace(" ", "_") + return self.abbreviations.get(normalized_param_name) + + def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes: + """ + Returns the icon data as PNG bytes. + """ + raise NotImplementedError + + def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap: + """ + Returns the icon data as a QPixmap. + """ + raise NotImplementedError + + +class ToolBitShapeSvgIcon(ToolBitShapeIcon): + asset_type: str = "toolbitshapesvg" + + def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes: + """ + Converts SVG icon data to PNG and returns it using QtSvg. + """ + if icon_size is None: + icon_size = QtCore.QSize(48, 48) + image = QtGui.QImage(icon_size, QtGui.QImage.Format_ARGB32) + image.fill(QtGui.Qt.transparent) + painter = QtGui.QPainter(image) + + buffer = QtCore.QBuffer(QtCore.QByteArray(self.data)) + buffer.open(QtCore.QIODevice.ReadOnly) + svg_renderer = QtSvg.QSvgRenderer(buffer) + svg_renderer.setAspectRatioMode(QtCore.Qt.KeepAspectRatio) + svg_renderer.render(painter) + painter.end() + + byte_array = QtCore.QByteArray() + buffer = QtCore.QBuffer(byte_array) + buffer.open(QtCore.QIODevice.WriteOnly) + image.save(buffer, "PNG") + + return bytes(byte_array) + + def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap: + """ + Returns the SVG icon data as a QPixmap using QtSvg. + """ + if icon_size is None: + icon_size = QtCore.QSize(48, 48) + icon_ba = QtCore.QByteArray(self.data) + image = QtGui.QImage(icon_size, QtGui.QImage.Format_ARGB32) + image.fill(QtGui.Qt.transparent) + painter = QtGui.QPainter(image) + + buffer = QtCore.QBuffer(icon_ba) # PySide6 + buffer.open(QtCore.QIODevice.ReadOnly) + data = QtCore.QXmlStreamReader(buffer) + renderer = QtSvg.QSvgRenderer(data) + renderer.setAspectRatioMode(QtCore.Qt.KeepAspectRatio) + renderer.render(painter) + painter.end() + + return QtGui.QPixmap.fromImage(image) + + @cached_property + def abbreviations(self) -> Mapping[str, str]: + """ + Returns a cached mapping of parameter abbreviations from the icon data. + + Only applicable for SVG icons. + """ + if self.data: + return self.get_abbreviations_from_svg(self.data) + return {} + + def get_abbr(self, param_name: str) -> Optional[str]: + """ + Retrieves the abbreviation for a given parameter name. + + Args: + param_name: The name of the parameter. + + Returns: + The abbreviation string, or None if not found. + """ + normalized_param_name = param_name.lower().replace(" ", "_") + return self.abbreviations.get(normalized_param_name) + + @staticmethod + def get_abbreviations_from_svg(svg: bytes) -> Mapping[str, str]: + """ + Extract abbreviations from SVG text elements. + """ + try: + tree = ET.fromstring(svg) + except ET.ParseError: + return {} + + result = {} + for text_elem in tree.findall(".//s:text", _svg_ns): + id = text_elem.attrib.get("id", _svg_ns) + if id is None or not isinstance(id, str): + continue + + abbr = text_elem.text + if abbr is not None: + result[id.lower()] = abbr + + span_elem = text_elem.find(".//s:tspan", _svg_ns) + if span_elem is None: + continue + abbr = span_elem.text + result[id.lower()] = abbr + + return result + + +class ToolBitShapePngIcon(ToolBitShapeIcon): + asset_type: str = "toolbitshapepng" + + def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes: + """ + Returns the PNG icon data. + """ + # For PNG, resizing might be needed if icon_size is different + # from the original size. Simple return for now. + return self.data + + def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap: + """ + Returns the PNG icon data as a QPixmap. + """ + if icon_size is None: + icon_size = QtCore.QSize(48, 48) + pixmap = QtGui.QPixmap() + pixmap.loadFromData(self.data, "PNG") + # Scale the pixmap if the requested size is different + if pixmap.size() != icon_size: + pixmap = pixmap.scaled( + icon_size, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation, + ) + return pixmap diff --git a/src/Mod/CAM/Path/Tool/shape/models/probe.py b/src/Mod/CAM/Path/Tool/shape/models/probe.py new file mode 100644 index 0000000000..9096308a94 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/probe.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeProbe(ToolBitShape): + name = "Probe" + aliases = ("probe",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Ball diameter"), + "App::PropertyLength", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Length of probe"), + "App::PropertyLength", + ), + "ShaftDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shaft diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Probe") diff --git a/src/Mod/CAM/Path/Tool/shape/models/reamer.py b/src/Mod/CAM/Path/Tool/shape/models/reamer.py new file mode 100644 index 0000000000..af643a0e95 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/reamer.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeReamer(ToolBitShape): + name = "Reamer" + aliases = ("reamer",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Reamer") diff --git a/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py b/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py new file mode 100644 index 0000000000..caca9e5fde --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeSlittingSaw(ToolBitShape): + name = "SlittingSaw" + aliases = "slittingsaw", "slitting-saw" + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "BladeThickness": ( + FreeCAD.Qt.translate("ToolBitShape", "Blade thickness"), + "App::PropertyLength", + ), + "CapDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Cap diameter"), + "App::PropertyLength", + ), + "CapHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cap height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Slitting Saw") diff --git a/src/Mod/CAM/Path/Tool/shape/models/tap.py b/src/Mod/CAM/Path/Tool/shape/models/tap.py new file mode 100644 index 0000000000..494d0f3164 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/tap.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeTap(ToolBitShape): + name = "Tap" + aliases = ("Tap",) + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeLength": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge length"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Tap diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall length of tap"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + "TipAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Tip angle"), + "App::PropertyAngle", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Tap") diff --git a/src/Mod/CAM/Path/Tool/shape/models/threadmill.py b/src/Mod/CAM/Path/Tool/shape/models/threadmill.py new file mode 100644 index 0000000000..15beccdf6d --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/threadmill.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeThreadMill(ToolBitShape): + name = "ThreadMill" + aliases = "threadmill", "thread-mill" + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "Crest": ( + FreeCAD.Qt.translate("ToolBitShape", "Crest height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Major diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "NeckDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Neck diameter"), + "App::PropertyLength", + ), + "NeckLength": ( + FreeCAD.Qt.translate("ToolBitShape", "Neck length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + "cuttingAngle": ( # TODO rename to CuttingAngle + FreeCAD.Qt.translate("ToolBitShape", "Cutting angle"), + "App::PropertyAngle", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "Thread Mill") diff --git a/src/Mod/CAM/Path/Tool/shape/models/vbit.py b/src/Mod/CAM/Path/Tool/shape/models/vbit.py new file mode 100644 index 0000000000..2c82b0d18e --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/models/vbit.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +from typing import Tuple, Mapping +from .base import ToolBitShape + + +class ToolBitShapeVBit(ToolBitShape): + name = "VBit" + aliases = "vbit", "v-bit" + + @classmethod + def schema(cls) -> Mapping[str, Tuple[str, str]]: + return { + "CuttingEdgeAngle": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge angle"), + "App::PropertyAngle", + ), + "CuttingEdgeHeight": ( + FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"), + "App::PropertyLength", + ), + "Diameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Diameter"), + "App::PropertyLength", + ), + "Flutes": ( + FreeCAD.Qt.translate("ToolBitShape", "Flutes"), + "App::PropertyInteger", + ), + "Length": ( + FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"), + "App::PropertyLength", + ), + "ShankDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"), + "App::PropertyLength", + ), + "TipDiameter": ( + FreeCAD.Qt.translate("ToolBitShape", "Tip diameter"), + "App::PropertyLength", + ), + } + + @property + def label(self) -> str: + return FreeCAD.Qt.translate("ToolBitShape", "V-Bit") diff --git a/src/Mod/CAM/Path/Tool/shape/ui/__init__.py b/src/Mod/CAM/Path/Tool/shape/ui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py b/src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py new file mode 100644 index 0000000000..ca8d168aa6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from PySide.QtCore import * +from PySide.QtGui import * + + +class FlowLayout(QLayout): + widthChanged = Signal(int) + + def __init__(self, parent=None, margin=0, spacing=-1, orientation=Qt.Horizontal): + super(FlowLayout, self).__init__(parent) + + if parent is not None: + self.setContentsMargins(margin, margin, margin, margin) + + self.setSpacing(spacing) + self.itemList = [] + self.orientation = orientation + + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) + + def addItem(self, item): + self.itemList.append(item) + + def count(self): + return len(self.itemList) + + def itemAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList[index] + + return None + + def takeAt(self, index): + if index >= 0 and index < len(self.itemList): + return self.itemList.pop(index) + + return None + + def expandingDirections(self): + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + if self.orientation == Qt.Horizontal: + return self.doLayoutHorizontal(QRect(0, 0, width, 0), True) + elif self.orientation == Qt.Vertical: + return self.doLayoutVertical(QRect(0, 0, width, 0), True) + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + if self.orientation == Qt.Horizontal: + self.doLayoutHorizontal(rect, False) + elif self.orientation == Qt.Vertical: + self.doLayoutVertical(rect, False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + + for item in self.itemList: + size = size.expandedTo(item.minimumSize()) + + margin, _, _, _ = self.getContentsMargins() + + size += QSize(2 * margin, 2 * margin) + return size + + def doLayoutHorizontal(self, rect, testOnly): + # Get initial coordinates of the drawing region (should be 0, 0) + x = rect.x() + y = rect.y() + lineHeight = 0 + i = 0 + for item in self.itemList: + wid = item.widget() + # Space X and Y is item spacing horizontally and vertically + spaceX = self.spacing() + wid.style().layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal + ) + spaceY = self.spacing() + wid.style().layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical + ) + # Determine the coordinate we want to place the item at + # It should be placed at : initial coordinate of the rect + width of the item + spacing + nextX = x + item.sizeHint().width() + spaceX + # If the calculated nextX is greater than the outer bound... + if nextX - spaceX > rect.right() and lineHeight > 0: + x = rect.x() # Reset X coordinate to origin of drawing region + y = y + lineHeight + spaceY # Move Y coordinate to the next line + nextX = ( + x + item.sizeHint().width() + spaceX + ) # Recalculate nextX based on the new X coordinate + lineHeight = 0 + + if not testOnly: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = nextX # Store the next starting X coordinate for next item + lineHeight = max(lineHeight, item.sizeHint().height()) + i = i + 1 + + return y + lineHeight - rect.y() + + def doLayoutVertical(self, rect, testOnly): + # Get initial coordinates of the drawing region (should be 0, 0) + x = rect.x() + y = rect.y() + # Initialize column width and line height + columnWidth = 0 + lineHeight = 0 + + # Space between items + spaceX = 0 + spaceY = 0 + + # Variables that will represent the position of the widgets in a 2D Array + i = 0 + j = 0 + for item in self.itemList: + wid = item.widget() + # Space X and Y is item spacing horizontally and vertically + spaceX = self.spacing() + wid.style().layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal + ) + spaceY = self.spacing() + wid.style().layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical + ) + # Determine the coordinate we want to place the item at + # It should be placed at : initial coordinate of the rect + width of the item + spacing + nextY = y + item.sizeHint().height() + spaceY + # If the calculated nextY is greater than the outer bound, move to the next column + if nextY - spaceY > rect.bottom() and columnWidth > 0: + y = rect.y() # Reset y coordinate to origin of drawing region + x = x + columnWidth + spaceX # Move X coordinate to the next column + nextY = ( + y + item.sizeHint().height() + spaceY + ) # Recalculate nextX based on the new X coordinate + # Reset the column width + columnWidth = 0 + + # Set indexes of the item for the 2D array + j += 1 + i = 0 + + # Assign 2D array indexes + item.x_index = i + item.y_index = j + + # Only call setGeometry (which place the actual widget using coordinates) if testOnly is false + # For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??) + if not testOnly: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + y = nextY # Store the next starting Y coordinate for next item + columnWidth = max( + columnWidth, item.sizeHint().width() + ) # Update the width of the column + lineHeight = max(lineHeight, item.sizeHint().height()) # Update the height of the line + + i += 1 # Increment i + + # Only call setGeometry (which place the actual widget using coordinates) if testOnly is false + # For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??) + if not testOnly: + self.calculateMaxWidth(i) + self.widthChanged.emit(self.totalMaxWidth + spaceX * self.itemsOnWidestRow) + return lineHeight + + # Method to calculate the maximum width among each "row" of the flow layout + # This will be useful to let the UI know the total width of the flow layout + def calculateMaxWidth(self, numberOfRows): + # Init variables + self.totalMaxWidth = 0 + self.itemsOnWidestRow = 0 + + # For each "row", calculate the total width by adding the width of each item + # and then update the totalMaxWidth if the calculated width is greater than the current value + # Also update the number of items on the widest row + for i in range(numberOfRows): + rowWidth = 0 + itemsOnWidestRow = 0 + for item in self.itemList: + # Only compare items from the same row + if item.x_index == i: + rowWidth += item.sizeHint().width() + itemsOnWidestRow += 1 + if rowWidth > self.totalMaxWidth: + self.totalMaxWidth = rowWidth + self.itemsOnWidestRow = itemsOnWidestRow diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py b/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py new file mode 100644 index 0000000000..75855ca7fc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from PySide import QtGui, QtCore + + +class ShapeButton(QtGui.QToolButton): + def __init__(self, shape, parent=None): + super(ShapeButton, self).__init__(parent) + self.shape = shape + + self.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon) + self.setText(shape.label) + + self.setFixedSize(128, 128) + self.setBaseSize(128, 128) + self.icon_size = QtCore.QSize(71, 100) + self.setIconSize(self.icon_size) + + self._update_icon() + + def set_text(self, text): + self.label.setText(text) + + def _update_icon(self): + icon = self.shape.get_icon() + if icon: + pixmap = icon.get_qpixmap(self.icon_size) + self.setIcon(QtGui.QIcon(pixmap)) diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py b/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py new file mode 100644 index 0000000000..4fa3e1885b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from typing import Optional, cast +import FreeCADGui +from functools import partial +from PySide import QtGui +from ...camassets import cam_assets +from .. import ToolBitShape +from .flowlayout import FlowLayout +from .shapebutton import ShapeButton + + +class ShapeSelector: + def __init__(self): + self.shape = None + self.form = FreeCADGui.PySideUic.loadUi(":/panels/ShapeSelector.ui") + + self.form.buttonBox.clicked.connect(self.form.close) + + self.flows = {} + + self.update_shapes() + self.form.toolBox.setCurrentIndex(0) + + def _add_shape_group(self, toolbox): + if toolbox in self.flows: + return self.flows[toolbox] + flow = FlowLayout(toolbox, orientation=QtGui.Qt.Horizontal) + flow.widthChanged.connect(lambda x: toolbox.setMinimumWidth(x)) + self.flows[toolbox] = flow + return flow + + def _add_shapes(self, toolbox, shapes): + flow = self._add_shape_group(toolbox) + + # Remove all shapes first. + for i in reversed(range(flow.count())): + flow.itemAt(i).widget().setParent(None) + + # Add all shapes. + for shape in sorted(shapes, key=lambda x: x.label): + button = ShapeButton(shape) + flow.addWidget(button) + cb = partial(self.on_shape_button_clicked, shape) + button.clicked.connect(cb) + + def update_shapes(self): + # Retrieve each shape asset + shapes = set(cam_assets.fetch(asset_type="toolbitshape")) + + builtin = set(s for s in shapes if cast(ToolBitShape, s).is_builtin) + self._add_shapes(self.form.standardTools, builtin) + self._add_shapes(self.form.customTools, shapes - builtin) + + def on_shape_button_clicked(self, shape): + self.shape = shape + self.form.close() + + def show(self) -> Optional[ToolBitShape]: + self.form.exec() + return self.shape diff --git a/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py b/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py new file mode 100644 index 0000000000..2bea5969f2 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from PySide import QtGui, QtCore + + +class ShapeWidget(QtGui.QWidget): + def __init__(self, shape, parent=None): + super(ShapeWidget, self).__init__(parent) + self.layout = QtGui.QVBoxLayout(self) + self.layout.setAlignment(QtCore.Qt.AlignHCenter) + + self.shape = shape + ratio = self.devicePixelRatioF() + self.icon_size = QtCore.QSize(200 * ratio, 235 * ratio) + self.icon_widget = QtGui.QLabel() + self.layout.addWidget(self.icon_widget) + + self._update_icon() + + def _update_icon(self): + icon = self.shape.get_icon() + if icon: + pixmap = icon.get_qpixmap(self.icon_size) + self.icon_widget.setPixmap(pixmap) diff --git a/src/Mod/CAM/Path/Tool/shape/util.py b/src/Mod/CAM/Path/Tool/shape/util.py new file mode 100644 index 0000000000..0d6e8aba91 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/shape/util.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pathlib +from typing import Optional +import FreeCAD +import tempfile +import os +from .doc import ShapeDocFromBytes + + +_svg_ns = {"s": "http://www.w3.org/2000/svg"} + + +def file_is_newer(reference: pathlib.Path, file: pathlib.Path): + return reference.stat().st_mtime > file.stat().st_mtime + + +def create_thumbnail(filepath: pathlib.Path, w: int = 200, h: int = 200) -> Optional[pathlib.Path]: + if not FreeCAD.GuiUp: + return None + + try: + import FreeCADGui + except ImportError: + raise RuntimeError("Error: Could not load UI - is it up?") + + doc = FreeCAD.openDocument(str(filepath)) + view = FreeCADGui.activeDocument().ActiveView + out_filepath = filepath.with_suffix(".png") + if not view: + print("No view active, cannot make thumbnail for {}".format(filepath)) + return + + view.viewFront() + view.fitAll() + view.setAxisCross(False) + view.saveImage(str(out_filepath), w, h, "Transparent") + + FreeCAD.closeDocument(doc.Name) + return out_filepath + + +def create_thumbnail_from_data(shape_data: bytes, w: int = 200, h: int = 200) -> Optional[bytes]: + """ + Create a thumbnail icon from shape data bytes using a temporary document. + + Args: + shape_data (bytes): The raw bytes of the shape file (.FCStd). + w (int): Width of the thumbnail. + h (int): Height of the thumbnail. + + Returns: + Optional[bytes]: PNG image bytes, or None if generation fails. + """ + if not FreeCAD.GuiUp: + return None + + try: + import FreeCADGui + except ImportError: + raise RuntimeError("Error: Could not load UI - is it up?") + + temp_png_path = None + try: + with ShapeDocFromBytes(shape_data) as doc: + view = FreeCADGui.activeDocument().ActiveView + + if not view: + print("No view active, cannot make thumbnail from data") + return None + + view.viewFront() + view.fitAll() + view.setAxisCross(False) + + # Create a temporary file path for the output PNG + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file: + temp_png_path = pathlib.Path(temp_file.name) + + view.saveImage(str(temp_png_path), w, h, "Transparent") + + # Read the PNG bytes + with open(temp_png_path, "rb") as f: + png_bytes = f.read() + + return png_bytes + + except Exception as e: + print(f"Error creating thumbnail from data: {e}") + return None + + finally: + # Clean up temporary PNG file + if temp_png_path and temp_png_path.exists(): + os.remove(temp_png_path) diff --git a/src/Mod/CAM/Path/Tool/toolbit/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/__init__.py new file mode 100644 index 0000000000..5b1def1d18 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/__init__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# This package aggregates tool bit classes. + +# Import the base class and all concrete shape classes +from .models.base import ToolBit +from .models.ballend import ToolBitBallend +from .models.chamfer import ToolBitChamfer +from .models.dovetail import ToolBitDovetail +from .models.drill import ToolBitDrill +from .models.endmill import ToolBitEndmill +from .models.fillet import ToolBitFillet +from .models.probe import ToolBitProbe +from .models.reamer import ToolBitReamer +from .models.slittingsaw import ToolBitSlittingSaw +from .models.tap import ToolBitTap +from .models.threadmill import ToolBitThreadMill +from .models.bullnose import ToolBitBullnose +from .models.vbit import ToolBitVBit + +# Define __all__ for explicit public interface +__all__ = [ + "ToolBit", + "ToolBitBallend", + "ToolBitChamfer", + "ToolBitDovetail", + "ToolBitDrill", + "ToolBitEndmill", + "ToolBitFillet", + "ToolBitProbe", + "ToolBitReamer", + "ToolBitSlittingSaw", + "ToolBitTap", + "ToolBitThreadMill", + "ToolBitBullnose", + "ToolBitVBit", +] diff --git a/src/Mod/CAM/Path/Tool/toolbit/docobject.py b/src/Mod/CAM/Path/Tool/toolbit/docobject.py new file mode 100644 index 0000000000..5bc8f29662 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/docobject.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from typing import Any, Dict, List, Optional + + +class DetachedDocumentObject: + """ + A lightweight class mimicking the property API of a FreeCAD DocumentObject. + + This class is used by ToolBit instances when they are not associated + with a real FreeCAD DocumentObject, allowing properties to be stored + and accessed in a detached state. + """ + + def __init__(self, label: str = "DetachedObject"): + self.Label: str = label + self.Name: str = label.replace(" ", "_") + self.PropertiesList: List[str] = [] + self._properties: Dict[str, Any] = {} + self._property_groups: Dict[str, Optional[str]] = {} + self._property_types: Dict[str, Optional[str]] = {} + self._property_docs: Dict[str, Optional[str]] = {} + self._editor_modes: Dict[str, int] = {} + self._property_enums: Dict[str, List[str]] = {} + + def addProperty( + self, + thetype: Optional[str], + name: str, + group: Optional[str], + doc: Optional[str], + ) -> None: + """Mimics FreeCAD DocumentObject.addProperty.""" + if name not in self._properties: + self.PropertiesList.append(name) + self._properties[name] = None + self._property_groups[name] = group + self._property_types[name] = thetype + self._property_docs[name] = doc + + def getPropertyByName(self, name: str) -> Any: + """Mimics FreeCAD DocumentObject.getPropertyByName.""" + return self._properties.get(name) + + def setPropertyByName(self, name: str, value: Any) -> None: + """Mimics FreeCAD DocumentObject.setPropertyByName.""" + self._properties[name] = value + + def __setattr__(self, name: str, value: Any) -> None: + """ + Intercept attribute assignment. This is done to behave like + FreeCAD's DocumentObject, which may have any property assigned, + pre-defined or not. + Without this, code linters report an error when trying to set + a property that is not defined in the class. + + Handles assignment of enumeration choices (lists/tuples) and + converts string representations of Quantity types to Quantity objects. + """ + if name in ("PropertiesList", "Label", "Name") or name.startswith("_"): + super().__setattr__(name, value) + return + + # Handle assignment of enumeration choices (list/tuple) + prop_type = self._property_types.get(name) + if prop_type == "App::PropertyEnumeration" and isinstance(value, (list, tuple)): + self._property_enums[name] = list(value) + assert len(value) > 0, f"Enum property '{name}' must have at least one entry" + self._properties.setdefault(name, value[0]) + return + + # Attempt to convert string values to Quantity if the property type is Quantity + elif prop_type in [ + "App::PropertyQuantity", + "App::PropertyLength", + "App::PropertyArea", + "App::PropertyVolume", + "App::PropertyAngle", + ]: + value = FreeCAD.Units.Quantity(value) + + # Store the (potentially converted) value + self._properties[name] = value + Path.Log.debug( + f"DetachedDocumentObject: Set property '{name}' to " + f"value {value} (type: {type(value)})" + ) + + def __getattr__(self, name: str) -> Any: + """Intercept attribute access.""" + if name in self._properties: + return self._properties[name] + # Default behaviour: raise AttributeError + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + def setEditorMode(self, name: str, mode: int) -> None: + """Stores editor mode settings in detached state.""" + self._editor_modes[name] = mode + + def getEditorMode(self, name: str) -> int: + """Stores editor mode settings in detached state.""" + return self._editor_modes.get(name, 0) or 0 + + def getGroupOfProperty(self, name: str) -> Optional[str]: + """Returns the stored group for a property in detached state.""" + return self._property_groups.get(name) + + def getTypeIdOfProperty(self, name: str) -> Optional[str]: + """Returns the stored type string for a property in detached state.""" + return self._property_types.get(name) + + def getEnumerationsOfProperty(self, name: str) -> List[str]: + """Returns the stored enumeration list for a property.""" + return self._property_enums.get(name, []) + + @property + def ExpressionEngine(self) -> List[Any]: + """Mimics the ExpressionEngine attribute of a real DocumentObject.""" + return [] # Return an empty list to satisfy iteration + + def copy_to(self, obj: FreeCAD.DocumentObject) -> None: + """ + Copies properties from this detached object to a real DocumentObject. + """ + for prop_name in self.PropertiesList: + if not hasattr(self, prop_name): + continue + + prop_value = self.getPropertyByName(prop_name) + prop_type = self._property_types.get(prop_name) + prop_group = self._property_groups.get(prop_name) + prop_doc = self._property_docs.get(prop_name, "") + prop_editor_mode = self._editor_modes.get(prop_name) + + # If the property doesn't exist in the target object, add it + if not hasattr(obj, prop_name): + # For enums, addProperty expects "App::PropertyEnumeration" + # The list of choices is set afterwards. + obj.addProperty(prop_type, prop_name, prop_group, prop_doc) + + # If it's an enumeration, set its list of choices first + if prop_type == "App::PropertyEnumeration": + enum_choices = self._property_enums.get(prop_name) + assert enum_choices is not None + setattr(obj, prop_name, enum_choices) + + # Set the property value and editor mode + try: + if prop_type == "App::PropertyEnumeration": + first_choice = self._property_enums[prop_name][0] + setattr(obj, prop_name, first_choice) + else: + setattr(obj, prop_name, prop_value) + + except Exception as e: + Path.Log.error( + f"Error setting property {prop_name} to {prop_value} " + f"(type: {type(prop_value)}, expected type: {prop_type}): {e}" + ) + raise + + if prop_editor_mode is not None: + obj.setEditorMode(prop_name, prop_editor_mode) diff --git a/src/Mod/CAM/Path/Tool/toolbit/mixins/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/mixins/__init__.py new file mode 100644 index 0000000000..67219906ff --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/mixins/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +from .rotary import RotaryToolBitMixin +from .cutting import CuttingToolMixin + +__all__ = [ + "RotaryToolBitMixin", + "CuttingToolMixin", +] diff --git a/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py b/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py new file mode 100644 index 0000000000..73eb9cec37 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +from PySide.QtCore import QT_TRANSLATE_NOOP + + +class CuttingToolMixin: + """ + This is a interface class to indicate that the ToolBit can chip, i.e. + it has a Chipload property. + It is used to determine if the tool bit can be used for chip removal. + """ + + def __init__(self, obj, *args, **kwargs): + obj.addProperty( + "App::PropertyLength", + "Chipload", + "Base", + QT_TRANSLATE_NOOP("App::Property", "Chipload per tooth"), + ) + obj.Chipload = FreeCAD.Units.Quantity("0.0 mm") diff --git a/src/Mod/CAM/Path/Tool/toolbit/mixins/rotary.py b/src/Mod/CAM/Path/Tool/toolbit/mixins/rotary.py new file mode 100644 index 0000000000..b5bf795511 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/mixins/rotary.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD + + +class RotaryToolBitMixin: + """ + Mixin class for rotary tool bits. + Provides methods for accessing diameter and length from the shape. + """ + + def can_rotate(self) -> bool: + return True + + def get_diameter(self) -> FreeCAD.Units.Quantity: + """ + Get the diameter of the rotary tool bit from the shape. + """ + return self.obj.Diameter + + def set_diameter(self, diameter: FreeCAD.Units.Quantity): + """ + Set the diameter of the rotary tool bit on the shape. + """ + if not isinstance(diameter, FreeCAD.Units.Quantity): + raise ValueError("Diameter must be a FreeCAD Units.Quantity") + self.obj.Diameter = diameter + + def get_length(self) -> FreeCAD.Units.Quantity: + """ + Get the length of the rotary tool bit from the shape. + """ + return self.obj.Length + + def set_length(self, length: FreeCAD.Units.Quantity): + """ + Set the length of the rotary tool bit on the shape. + """ + if not isinstance(length, FreeCAD.Units.Quantity): + raise ValueError("Length must be a FreeCAD Units.Quantity") + self.obj.Length = length diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/models/__init__.py new file mode 100644 index 0000000000..40a96afc6f --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py new file mode 100644 index 0000000000..9edaf3516d --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeBallend +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitBallend(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeBallend + + def __init__(self, shape: ToolBitShapeBallend, id: str | None = None): + Path.Log.track(f"ToolBitBallend __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} {flutes}-flute ballend, {cutting_edge_height} cutting edge" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py new file mode 100644 index 0000000000..1c4d846c03 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -0,0 +1,810 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +import Path +import Path.Base.Util as PathUtil +import json +import uuid +import pathlib +from abc import ABC +from itertools import chain +from lazy_loader.lazy_loader import LazyLoader +from typing import Any, List, Optional, Tuple, Type, Union, Mapping, cast +from PySide.QtCore import QT_TRANSLATE_NOOP +import Part +from Path.Base.Generator import toolchange +from ...assets import Asset +from ...camassets import cam_assets +from ...shape import ToolBitShape, ToolBitShapeEndmill, ToolBitShapeIcon +from ..docobject import DetachedDocumentObject +from ..util import to_json, format_value + + +ToolBitView = LazyLoader("Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view") + + +PropertyGroupShape = "Shape" + +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 ToolBit(Asset, ABC): + asset_type: str = "toolbit" + SHAPE_CLASS: Type[ToolBitShape] # Abstract class attribute + + def __init__(self, tool_bit_shape: ToolBitShape, id: str | None = None): + Path.Log.track("ToolBit __init__ called") + self.id = id if id is not None else str(uuid.uuid4()) + self.obj = DetachedDocumentObject() + self.obj.Proxy = self + self._tool_bit_shape: ToolBitShape = tool_bit_shape + self._in_update = False + + self._create_base_properties() + self.obj.ToolBitID = self.get_id() + self.obj.ShapeID = tool_bit_shape.get_id() + self.obj.ShapeType = tool_bit_shape.name + self.obj.Label = tool_bit_shape.label or f"New {tool_bit_shape.name}" + + # Initialize properties + self._update_tool_properties() + + def __eq__(self, other): + """Compare ToolBit objects based on their unique ID.""" + if not isinstance(other, ToolBit): + return False + return self.id == other.id + + @staticmethod + def _find_subclass_for_shape(shape: ToolBitShape) -> Type["ToolBit"]: + """ + Finds the appropriate ToolBit subclass for a given ToolBitShape instance. + """ + for subclass in ToolBit.__subclasses__(): + if isinstance(shape, subclass.SHAPE_CLASS): + return subclass + raise ValueError(f"No ToolBit subclass found for shape {type(shape).__name__}") + + @classmethod + def from_dict(cls, attrs: Mapping, shallow: bool = False) -> "ToolBit": + """ + Creates and populates a ToolBit instance from a dictionary. + """ + # Find the shape ID. + shape_id = pathlib.Path( + str(attrs.get("shape", "")) + ).stem # backward compatibility. used to be a filename + if not shape_id: + raise ValueError("ToolBit dictionary is missing 'shape' key") + + # Find the shape type. + shape_type = attrs.get("shape-type") + shape_class = None + if shape_type is None: + shape_class = ToolBitShape.get_subclass_by_name(shape_id) + if not shape_class: + Path.Log.error(f'failed to infer shape type from {shape_id}; using "endmill"') + shape_class = ToolBitShapeEndmill + shape_type = shape_class.name + + # Try to load the shape, if the asset exists. + tool_bit_shape = None + if not shallow: # Shallow means: skip loading of child assets + shape_asset_uri = ToolBitShape.resolve_name(shape_id) + try: + tool_bit_shape = cast(ToolBitShape, cam_assets.get(shape_asset_uri)) + except FileNotFoundError: + pass # Rely on the fallback below + + # If it does not exist, create a new instance from scratch. + params = attrs.get("parameter", {}) + if tool_bit_shape is None: + if not shape_class: + shape_class = ToolBitShape.get_subclass_by_name(shape_type) + if not shape_class: + raise ValueError(f"failed to get shape class from {shape_id}") + tool_bit_shape = shape_class(shape_id, **params) + + # Now that we have a shape, create the toolbit instance. + return cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id")) + + @classmethod + def from_shape(cls, tool_bit_shape: ToolBitShape, attrs: Mapping, id: str | None) -> "ToolBit": + selected_toolbit_subclass = cls._find_subclass_for_shape(tool_bit_shape) + toolbit = selected_toolbit_subclass(tool_bit_shape, id=id) + toolbit.label = attrs.get("name") or tool_bit_shape.label + + # Get params and attributes. + params = attrs.get("parameter", {}) + attr = attrs.get("attribute", {}) + + # Update parameters; these are stored in the document model object. + for param_name, param_value in params.items(): + if hasattr(toolbit.obj, param_name): + PathUtil.setProperty(toolbit.obj, param_name, param_value) + else: + Path.Log.warning( + f" ToolBit {id} Parameter '{param_name}' not found on" + f" {selected_toolbit_subclass.__name__} ({tool_bit_shape})" + f" '{toolbit.obj.Label}'. Skipping." + ) + + # Update parameters; these are stored in the document model object. + for attr_name, attr_value in attr.items(): + if hasattr(toolbit.obj, attr_name): + PathUtil.setProperty(toolbit.obj, attr_name, attr_value) + else: + Path.Log.warning( + f"ToolBit {id} Attribute '{attr_name}' not found on" + f" {selected_toolbit_subclass.__name__} ({tool_bit_shape})" + f" '{toolbit.obj.Label}'. Skipping." + ) + + return toolbit + + @classmethod + def from_shape_id(cls, shape_id: str, label: Optional[str] = None) -> "ToolBit": + """ + Creates and populates a ToolBit instance from a shape ID. + """ + attrs = {"shape": shape_id, "name": label} + return cls.from_dict(attrs) + + @classmethod + def from_file(cls, path: Union[str, pathlib.Path]) -> "ToolBit": + """ + Creates and populates a ToolBit instance from a .fctb file. + """ + path = pathlib.Path(path) + with path.open("r") as fp: + attrs_map = json.load(fp) + return cls.from_dict(attrs_map) + + @property + def label(self) -> str: + return self.obj.Label + + @label.setter + def label(self, label: str): + self.obj.Label = label + + def get_shape_name(self) -> str: + """Returns the shape name of the tool bit.""" + return self._tool_bit_shape.name + + def set_shape_name(self, name: str): + """Sets the shape name of the tool bit.""" + self._tool_bit_shape.name = name + + @property + def summary(self) -> str: + """ + To be overridden by subclasses to provide a better summary + including parameter values. Used as "subtitle" for the tool + in the UI. + + Example: "3.2 mm endmill, 4-flute, 8 mm cutting edge" + """ + return self.get_shape_name() + + def _create_base_properties(self): + # Create the properties in the Base group. + if not hasattr(self.obj, "ShapeID"): + self.obj.addProperty( + "App::PropertyString", + "ShapeID", + "Base", + QT_TRANSLATE_NOOP( + "App::Property", + "The unique ID of the tool shape (.fcstd)", + ), + ) + if not hasattr(self.obj, "ShapeType"): + self.obj.addProperty( + "App::PropertyEnumeration", + "ShapeType", + "Base", + QT_TRANSLATE_NOOP( + "App::Property", + "The tool shape type", + ), + ) + names = [c.name for c in ToolBitShape.__subclasses__()] + self.obj.ShapeType = names + self.obj.ShapeType = ToolBitShapeEndmill.name + if not hasattr(self.obj, "BitBody"): + self.obj.addProperty( + "App::PropertyLink", + "BitBody", + "Base", + QT_TRANSLATE_NOOP( + "App::Property", + "The parametrized body representing the tool bit", + ), + ) + if not hasattr(self.obj, "ToolBitID"): + self.obj.addProperty( + "App::PropertyString", + "ToolBitID", + "Base", + QT_TRANSLATE_NOOP("App::Property", "The unique ID of the toolbit"), + ) + + # 0 = read/write, 1 = read only, 2 = hide + self.obj.setEditorMode("ShapeID", 1) + self.obj.setEditorMode("ShapeType", 1) + self.obj.setEditorMode("ToolBitID", 1) + self.obj.setEditorMode("BitBody", 2) + self.obj.setEditorMode("Shape", 2) + + # Create the ToolBit properties that are shared by all tool bits + if not hasattr(self.obj, "SpindleDirection"): + self.obj.addProperty( + "App::PropertyEnumeration", + "SpindleDirection", + "Attributes", + QT_TRANSLATE_NOOP("App::Property", "Direction of spindle rotation"), + ) + self.obj.SpindleDirection = ["Forward", "Reverse", "None"] + self.obj.SpindleDirection = "Forward" # Default value + if not hasattr(self.obj, "Material"): + self.obj.addProperty( + "App::PropertyEnumeration", + "Material", + "Attributes", + QT_TRANSLATE_NOOP("App::Property", "Tool material"), + ) + self.obj.Material = ["HSS", "Carbide"] + self.obj.Material = "HSS" # Default value + + def get_id(self) -> str: + """Returns the unique ID of the tool bit.""" + return self.id + + def _promote_toolbit(self): + """ + Updates the toolbit properties for backward compatibility. + Ensure obj.ShapeID and obj.ToolBitID are set, handling legacy cases. + """ + Path.Log.track(f"Promoting tool bit {self.obj.Label}") + + # Ensure ShapeID is set (handling legacy BitShape/ShapeName) + name = None + if hasattr(self.obj, "ShapeID") and self.obj.ShapeID: + name = self.obj.ShapeID + elif hasattr(self.obj, "ShapeFile") and self.obj.ShapeFile: + name = pathlib.Path(self.obj.ShapeFile).stem + elif hasattr(self.obj, "BitShape") and self.obj.BitShape: + name = pathlib.Path(self.obj.BitShape).stem + elif hasattr(self.obj, "ShapeName") and self.obj.ShapeName: + name = pathlib.Path(self.obj.ShapeName).stem + if name is None: + raise ValueError("ToolBit is missing a shape ID") + + uri = ToolBitShape.resolve_name(name) + if uri is None: + raise ValueError(f"Failed to identify ID of ToolBit from '{name}'") + self.obj.ShapeID = uri.asset_id + + # Ensure ShapeType is set + thetype = None + if hasattr(self.obj, "ShapeType") and self.obj.ShapeType: + thetype = self.obj.ShapeType + elif hasattr(self.obj, "ShapeFile") and self.obj.ShapeFile: + thetype = pathlib.Path(self.obj.ShapeFile).stem + elif hasattr(self.obj, "BitShape") and self.obj.BitShape: + thetype = pathlib.Path(self.obj.BitShape).stem + elif hasattr(self.obj, "ShapeName") and self.obj.ShapeName: + thetype = pathlib.Path(self.obj.ShapeName).stem + if thetype is None: + raise ValueError("ToolBit is missing a shape type") + + shape_class = ToolBitShape.get_subclass_by_name(thetype) + if shape_class is None: + raise ValueError(f"Failed to identify shape of ToolBit from '{thetype}'") + self.obj.ShapeType = shape_class.name + + # Ensure ToolBitID is set + if hasattr(self.obj, "File"): + self.id = pathlib.Path(self.obj.File).stem + self.obj.ToolBitID = self.id + Path.Log.debug(f"Set ToolBitID to {self.obj.ToolBitID}") + + # Update SpindleDirection: + # Old tools may still have "CCW", "CW", "Off", "None". + # New tools use "None", "Forward", "Reverse". + normalized_direction = old_direction = self.obj.SpindleDirection + + if isinstance(old_direction, str): + lower_direction = old_direction.lower() + if lower_direction in ("none", "off"): + normalized_direction = "None" + elif lower_direction in ("cw", "forward"): + normalized_direction = "Forward" + elif lower_direction in ("ccw", "reverse"): + normalized_direction = "Reverse" + + self.obj.SpindleDirection = ["Forward", "Reverse", "None"] + self.obj.SpindleDirection = normalized_direction + if old_direction != normalized_direction: + Path.Log.info( + f"Promoted tool bit {self.obj.Label}: SpindleDirection from {old_direction} to {self.obj.SpindleDirection}" + ) + + # Drop legacy properties. + legacy = "ShapeFile", "File", "BitShape", "ShapeName" + for name in legacy: + if hasattr(self.obj, name): + value = getattr(self.obj, name) + self.obj.removeProperty(name) + Path.Log.debug(f"Removed obsolete property '{name}' ('{value}').") + + # Get the schema properties from the current shape + shape_cls = ToolBitShape.get_subclass_by_name(self.obj.ShapeType) + if not shape_cls: + raise ValueError(f"Failed to find shape class named '{self.obj.ShapeType}'") + shape_schema_props = shape_cls.schema().keys() + + # Move properties that are part of the shape schema to the "Shape" group + for prop_name in self.obj.PropertiesList: + if ( + self.obj.getGroupOfProperty(prop_name) == PropertyGroupShape + or prop_name not in shape_schema_props + ): + continue + try: + Path.Log.debug(f"Moving property '{prop_name}' to group '{PropertyGroupShape}'") + + # Get property details before removing + prop_type = self.obj.getTypeIdOfProperty(prop_name) + prop_doc = self.obj.getDocumentationOfProperty(prop_name) + prop_value = self.obj.getPropertyByName(prop_name) + + # Remove the property + self.obj.removeProperty(prop_name) + + # Add the property back to the Shape group + self.obj.addProperty(prop_type, prop_name, PropertyGroupShape, prop_doc) + self._in_update = True # Prevent onChanged from running + PathUtil.setProperty(self.obj, prop_name, prop_value) + Path.Log.info(f"Moved property '{prop_name}' to group '{PropertyGroupShape}'") + except Exception as e: + Path.Log.error( + f"Failed to move property '{prop_name}' to group '{PropertyGroupShape}': {e}" + ) + raise + finally: + self._in_update = False + + def onDocumentRestored(self, obj): + Path.Log.track(obj.Label) + + # Assign self.obj to the restored object + self.obj = obj + self.obj.Proxy = self + if not hasattr(self, "id"): + self.id = str(uuid.uuid4()) + Path.Log.debug( + f"Assigned new id {self.id} for ToolBit {obj.Label} during document restore" + ) + + # Our constructor previously created the base properties in the + # DetachedDocumentObject, which was now replaced. + # So here we need to ensure to set them up in the new (real) DocumentObject + # as well. + self._create_base_properties() + self._promote_toolbit() + + # Get the shape instance based on the ShapeType. We try two approaches + # to find the shape and shape class: + # 1. If the asset with the given type exists, use that. + # 2. Otherwise create a new empty instance + shape_uri = ToolBitShape.resolve_name(self.obj.ShapeType) + try: + # Best case: we directly find the shape file in our assets. + self._tool_bit_shape = cast(ToolBitShape, cam_assets.get(shape_uri)) + except FileNotFoundError: + # Otherwise, try to at least identify the type of the shape. + shape_class = ToolBitShape.get_subclass_by_name(shape_uri.asset_id) + if not shape_class: + raise ValueError( + "Failed to identify class of ToolBitShape from name " + f"'{self.obj.ShapeType}' (asset id {shape_uri.asset_id})" + ) + self._tool_bit_shape = shape_class(shape_uri.asset_id) + + # If BitBody exists and is in a different document after document restore, + # it means a shallow copy occurred. We need to re-initialize the visual + # representation and properties to ensure a deep copy of the BitBody + # and its properties. + # Only re-initialize properties from shape if not restoring from file + if self.obj.BitBody and self.obj.BitBody.Document != self.obj.Document: + Path.Log.debug( + f"onDocumeformat_valuentRestored: Re-initializing BitBody for {self.obj.Label} after copy" + ) + self._update_visual_representation() + + # Ensure the correct ViewProvider is attached during document restore, + # because some legacy fcstd files may still have references to old view + # providers. + if hasattr(self.obj, "ViewObject") and self.obj.ViewObject: + if hasattr(self.obj.ViewObject, "Proxy") and not isinstance( + self.obj.ViewObject.Proxy, ToolBitView.ViewProvider + ): + Path.Log.debug(f"onDocumentRestored: Attaching ViewProvider for {self.obj.Label}") + ToolBitView.ViewProvider(self.obj.ViewObject, "ToolBit") + + # Copy properties from the restored object to the ToolBitShape. + for name, item in self._tool_bit_shape.schema().items(): + if name in self.obj.PropertiesList: + value = self.obj.getPropertyByName(name) + self._tool_bit_shape.set_parameter(name, value) + + # Ensure property state is correct after restore. + self._update_tool_properties() + + def attach_to_doc( + self, doc: FreeCAD.Document, label: Optional[str] = None + ) -> FreeCAD.DocumentObject: + """ + Creates a new FreeCAD DocumentObject in the given document and attaches + this ToolBit instance to it. + """ + label = label or self.label or self._tool_bit_shape.label + tool_doc_obj = doc.addObject("Part::FeaturePython", label) + self.attach_to_obj(tool_doc_obj, label=label) + return tool_doc_obj + + def attach_to_obj(self, tool_doc_obj: FreeCAD.DocumentObject, label: Optional[str] = None): + """ + Attaches the ToolBit instance to an existing FreeCAD DocumentObject. + + Transfers properties from the internal DetachedDocumentObject to the + tool_doc_obj and updates the visual representation. + """ + if not isinstance(self.obj, DetachedDocumentObject): + Path.Log.warning( + f"ToolBit {self.obj.Label} is already attached to a " + "DocumentObject. Skipping attach_to_obj." + ) + return + + Path.Log.track(f"Attaching ToolBit to {tool_doc_obj.Label}") + + temp_obj = self.obj + self.obj = tool_doc_obj + self.obj.Proxy = self + + self._create_base_properties() + + # Transfer property values from the detached object to the real object + temp_obj.copy_to(self.obj) + + # Ensure label is set + self.obj.Label = label or self.label or self._tool_bit_shape.label + + # Update the visual representation now that it's attached + self._update_tool_properties() + self._update_visual_representation() + + def onChanged(self, obj, prop): + Path.Log.track(obj.Label, prop) + # Avoid acting during document restore or internal updates + if "Restore" in obj.State: + return + + if hasattr(self, "_in_update") and self._in_update: + Path.Log.debug(f"Skipping onChanged for {obj.Label} due to active update.") + return + + # We only care about updates that affect the Shape + if obj.getGroupOfProperty(prop) != PropertyGroupShape: + return + + self._in_update = True + try: + new_value = obj.getPropertyByName(prop) + Path.Log.debug( + f"Shape parameter '{prop}' changed to {new_value}. " + f"Updating visual representation." + ) + self._tool_bit_shape.set_parameter(prop, new_value) + self._update_visual_representation() + finally: + self._in_update = False + + def onDelete(self, obj, arg2=None): + Path.Log.track(obj.Label) + self._removeBitBody() + obj.Document.removeObject(obj.Name) + + def _removeBitBody(self): + if self.obj.BitBody: + self.obj.BitBody.removeObjectsFromDocument() + self.obj.Document.removeObject(self.obj.BitBody.Name) + self.obj.BitBody = None + + def _setupProperty(self, prop, orig): + # extract property parameters and values so it can be copied + val = orig.getPropertyByName(prop) + typ = orig.getTypeIdOfProperty(prop) + grp = orig.getGroupOfProperty(prop) + dsc = orig.getDocumentationOfProperty(prop) + + self.obj.addProperty(typ, prop, grp, dsc) + if "App::PropertyEnumeration" == typ: + setattr(self.obj, prop, orig.getEnumerationsOfProperty(prop)) + self.obj.setEditorMode(prop, 1) + PathUtil.setProperty(self.obj, prop, val) + + def _get_props(self, group: Optional[Union[str, Tuple[str, ...]]] = None) -> List[str]: + """ + Returns a list of property names from the given group(s) for the object. + Returns all groups if the group argument is None. + """ + props_in_group = [] + # Use PropertiesList to get all property names + for prop in self.obj.PropertiesList: + prop_group = self.obj.getGroupOfProperty(prop) + if group is None: + props_in_group.append(prop) + elif isinstance(group, str) and prop_group == group: + props_in_group.append(prop) + elif isinstance(group, tuple) and prop_group in group: + props_in_group.append(prop) + return props_in_group + + def get_property(self, name: str): + return self.obj.getPropertyByName(name) + + def get_property_str(self, name: str, default: str | None = None) -> str | None: + value = self.get_property(name) + return format_value(value) if value else default + + def set_property(self, name: str, value: Any): + return self.obj.setPropertyByName(name, value) + + def get_property_label_from_name(self, name: str): + return self.obj.getPropertyByName + + def get_icon(self) -> Optional[ToolBitShapeIcon]: + """ + Retrieves the thumbnail data for the tool bit shape, as + taken from the explicit SVG or PNG, if the shape has one. + """ + if self._tool_bit_shape: + return self._tool_bit_shape.get_icon() + return None + + def get_thumbnail(self) -> Optional[bytes]: + """ + Retrieves the thumbnail data for the tool bit shape in PNG format, + as embedded in the shape file. + Fallback to the icon from get_icon() (converted to PNG) + """ + if not self._tool_bit_shape: + return None + png_data = self._tool_bit_shape.get_thumbnail() + if png_data: + return png_data + icon = self.get_icon() + if icon: + return icon.get_png() + return None + + def _remove_properties(self, group, prop_names): + for name in prop_names: + if hasattr(self.obj, name): + if self.obj.getGroupOfProperty(name) == group: + try: + self.obj.removeProperty(name) + Path.Log.debug(f"Removed property: {group}.{name}") + except Exception as e: + Path.Log.error(f"Failed removing property '{group}.{name}': {e}") + else: + Path.Log.warning(f"'{group}.{name}' failed to remove property, not found") + + def _update_tool_properties(self): + """ + Initializes or updates the tool bit's properties based on the current + _tool_bit_shape. Adds/updates shape parameters, removes obsolete shape + parameters, and updates the edit state of them. + Does not handle updating the visual representation. + """ + Path.Log.track(self.obj.Label) + + # 1. Add/Update properties for the new shape + for name, item in self._tool_bit_shape.schema().items(): + docstring = item[0] + prop_type = item[1] + + if not prop_type: + Path.Log.error( + f"No property type for parameter '{name}' in shape " + f"'{self._tool_bit_shape.name}'. Skipping." + ) + continue + + docstring = self._tool_bit_shape.get_parameter_label(name) + + # Add new property + if not hasattr(self.obj, name): + self.obj.addProperty(prop_type, name, "Shape", docstring) + Path.Log.debug(f"Added new shape property: {name}") + + # Ensure editor mode is correct + self.obj.setEditorMode(name, 0) + + try: + value = self._tool_bit_shape.get_parameter(name) + except KeyError: + continue # Retain existing property value. + + # Conditional to avoid unnecessary migration warning when called + # from onDocumentRestored. + if getattr(self.obj, name) != value: + setattr(self.obj, name, value) + + # 2. Remove obsolete shape properties + # These are properties currently listed AND in the Shape group, + # but not required by the new shape. + current_shape_prop_names = set(self._get_props("Shape")) + new_shape_param_names = self._tool_bit_shape.schema().keys() + obsolete = current_shape_prop_names - new_shape_param_names + self._remove_properties("Shape", obsolete) + + def _update_visual_representation(self): + """ + Updates the visual representation of the tool bit based on the current + _tool_bit_shape. Creates or updates the BitBody and copies its shape + to the main object. + """ + if isinstance(self.obj, DetachedDocumentObject): + return + Path.Log.track(self.obj.Label) + + # Remove existing BitBody if it exists + self._removeBitBody() + + try: + # Use the shape's make_body method to create the visual representation + body = self._tool_bit_shape.make_body(self.obj.Document) + + if not body: + Path.Log.error( + f"Failed to create visual representation for shape " + f"'{self._tool_bit_shape.name}'" + ) + return + + # Assign the created object to BitBody and copy its shape + self.obj.BitBody = body + self.obj.Shape = self.obj.BitBody.Shape # Copy the evaluated Solid shape + + # Hide the visual representation and remove from tree + if hasattr(self.obj.BitBody, "ViewObject") and self.obj.BitBody.ViewObject: + self.obj.BitBody.ViewObject.Visibility = False + self.obj.BitBody.ViewObject.ShowInTree = False + + except Exception as e: + Path.Log.error( + f"Failed to create visual representation using make_body for shape" + f" '{self._tool_bit_shape.name}': {e}" + ) + raise + + def to_dict(self): + """ + Returns a dictionary representation of the tool bit. + + Returns: + A dictionary with tool bit properties, JSON-serializable. + """ + Path.Log.track(self.obj.Label) + attrs = {} + attrs["version"] = 2 + attrs["name"] = self.obj.Label + attrs["shape"] = self._tool_bit_shape.get_id() + ".fcstd" + attrs["shape-type"] = self._tool_bit_shape.name + attrs["parameter"] = {} + attrs["attribute"] = {} + + # Store all shape parameter names and attribute names + param_names = self._tool_bit_shape.get_parameters() + attr_props = self._get_props("Attributes") + property_names = list(chain(param_names, attr_props)) + for name in property_names: + value = getattr(self.obj, name, None) + if value is None or isinstance(value, FreeCAD.DocumentObject): + Path.Log.warning( + f"Excluding property '{name}' from serialization " + f"(type {type(value).__name__ if value is not None else 'None'}, value {value})" + ) + try: + serialized_value = to_json(value) + attrs["parameter"][name] = serialized_value + except (TypeError, ValueError) as e: + Path.Log.warning( + f"Excluding property '{name}' from serialization " + f"(type {type(value).__name__}, value {value}): {e}" + ) + + Path.Log.debug(f"to_dict output for {self.obj.Label}: {attrs}") + return attrs + + def __getstate__(self): + """ + Prepare the ToolBit for pickling by excluding non-picklable attributes. + + Returns: + A dictionary with picklable and JSON-serializable state. + """ + Path.Log.track("ToolBit.__getstate__") + state = { + "id": getattr(self, "id", str(uuid.uuid4())), # Fallback to new UUID + "_in_update": getattr(self, "_in_update", False), # Fallback to False + "_obj_data": self.to_dict(), + } + + if not getattr(self, "_tool_bit_shape", None): + return state + + # Store minimal shape data to reconstruct _tool_bit_shape + state["_shape_data"] = { + "id": self._tool_bit_shape.get_id(), + "name": self._tool_bit_shape.name, + "parameters": { + name: to_json(getattr(self.obj, name, None)) + for name in self._tool_bit_shape.get_parameters() + if not isinstance(getattr(self.obj, name, None), FreeCAD.DocumentObject) + }, + } + + return state + + def get_spindle_direction(self) -> toolchange.SpindleDirection: + # To be safe, never allow non-rotatable shapes (such as probes) to rotate. + if not self.can_rotate(): + return toolchange.SpindleDirection.OFF + + # Otherwise use power from defined attribute. + if hasattr(self.obj, "SpindleDirection") and self.obj.SpindleDirection is not None: + if self.obj.SpindleDirection.lower() in ("cw", "forward"): + return toolchange.SpindleDirection.CW + else: + return toolchange.SpindleDirection.CCW + + # Default to keeping spindle off. + return toolchange.SpindleDirection.OFF + + def can_rotate(self) -> bool: + """ + Whether the spindle is allowed to rotate for this kind of ToolBit. + This mostly exists as a safe-hold for probes, which should never rotate. + """ + return True diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py new file mode 100644 index 0000000000..caf497d423 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeBullnose +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitBullnose(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeBullnose + + def __init__(self, tool_bit_shape: ToolBitShapeBullnose, id: str | None = None): + Path.Log.track(f"ToolBitBullnose __init__ called with id: {id}") + super().__init__(tool_bit_shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + flat_radius = self.get_property_str("FlatRadius", "?") + + return FreeCAD.Qt.translate( + "CAM", + f"{diameter} {flutes}-flute bullnose, {cutting_edge_height} cutting edge, {flat_radius} flat radius", + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py new file mode 100644 index 0000000000..da0abce4d6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeChamfer +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitChamfer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeChamfer + + def __init__(self, shape: ToolBitShapeChamfer, id: str | None = None): + Path.Log.track(f"ToolBitChamfer __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} {cutting_edge_angle} chamfer bit, {flutes}-flute" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py new file mode 100644 index 0000000000..aac48338b6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeDovetail +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitDovetail(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeDovetail + + def __init__(self, shape: ToolBitShapeDovetail, id: str | None = None): + Path.Log.track(f"ToolBitDovetail __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + flutes = self.get_property("Flutes") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} {cutting_edge_angle} dovetail bit, {flutes}-flute" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/drill.py b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py new file mode 100644 index 0000000000..cc5055d372 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeDrill +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitDrill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeDrill + + def __init__(self, shape: ToolBitShapeDrill, id: str | None = None): + Path.Log.track(f"ToolBitDrill __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + tip_angle = self.get_property_str("TipAngle", "?") + flutes = self.get_property("Flutes") + + return FreeCAD.Qt.translate("CAM", f"{diameter} drill, {tip_angle} tip, {flutes}-flute") diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py b/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py new file mode 100644 index 0000000000..6651705540 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeEndmill +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitEndmill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeEndmill + + def __init__(self, shape: ToolBitShapeEndmill, id: str | None = None): + Path.Log.track(f"ToolBitEndmill __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} {flutes}-flute endmill, {cutting_edge_height} cutting edge" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py b/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py new file mode 100644 index 0000000000..a23f82ecf0 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/fillet.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeFillet +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitFillet(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeFillet + + def __init__(self, shape: ToolBitShapeFillet, id: str | None = None): + Path.Log.track(f"ToolBitFillet __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + radius = self.get_property_str("FilletRadius", "?") + flutes = self.get_property("Flutes") + diameter = self.get_property_str("ShankDiameter", "?") + + return FreeCAD.Qt.translate( + "CAM", f"R{radius} fillet bit, {diameter} shank, {flutes}-flute" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/probe.py b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py new file mode 100644 index 0000000000..f0330084ef --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeProbe +from .base import ToolBit + + +class ToolBitProbe(ToolBit): + SHAPE_CLASS = ToolBitShapeProbe + + def __init__(self, shape: ToolBitShapeProbe, id: str | None = None): + Path.Log.track(f"ToolBitProbe __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + self.obj.SpindleDirection = "None" + self.obj.setEditorMode("SpindleDirection", 2) # Read-only + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + length = self.get_property_str("Length", "?") + shaft_diameter = self.get_property_str("ShaftDiameter", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} probe, {length} length, {shaft_diameter} shaft" + ) + + def can_rotate(self) -> bool: + return False diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py new file mode 100644 index 0000000000..d8b7fbcefb --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeReamer +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitReamer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeReamer + + def __init__(self, shape: ToolBitShapeReamer, id: str | None = None): + Path.Log.track(f"ToolBitReamer __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?") + + return FreeCAD.Qt.translate("CAM", f"{diameter} reamer, {cutting_edge_height} cutting edge") diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py b/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py new file mode 100644 index 0000000000..2c779edc33 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeSlittingSaw +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitSlittingSaw(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeSlittingSaw + + def __init__(self, shape: ToolBitShapeSlittingSaw, id: str | None = None): + Path.Log.track(f"ToolBitSlittingSaw __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + blade_thickness = self.get_property_str("BladeThickness", "?") + flutes = self.get_property("Flutes") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} slitting saw, {blade_thickness} blade, {flutes}-flute" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py new file mode 100644 index 0000000000..662a2e7376 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeTap +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitTap(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeTap + + def __init__(self, shape: ToolBitShapeTap, id: str | None = None): + Path.Log.track(f"ToolBitTap __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_edge_length = self.get_property_str("CuttingEdgeLength", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} tap, {flutes}-flute, {cutting_edge_length} cutting edge" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py b/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py new file mode 100644 index 0000000000..131be1abb4 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeThreadMill +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitThreadMill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeThreadMill + + def __init__(self, shape: ToolBitShapeThreadMill, id: str | None = None): + Path.Log.track(f"ToolBitThreadMill __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + flutes = self.get_property("Flutes") + cutting_angle = self.get_property_str("cuttingAngle", "?") + + return FreeCAD.Qt.translate( + "CAM", f"{diameter} thread mill, {flutes}-flute, {cutting_angle} cutting angle" + ) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py b/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py new file mode 100644 index 0000000000..cfabf0e978 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD +import Path +from ...shape import ToolBitShapeVBit +from ..mixins import RotaryToolBitMixin, CuttingToolMixin +from .base import ToolBit + + +class ToolBitVBit(ToolBit, CuttingToolMixin, RotaryToolBitMixin): + SHAPE_CLASS = ToolBitShapeVBit + + def __init__(self, shape: ToolBitShapeVBit, id: str | None = None): + Path.Log.track(f"ToolBitVBit __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id) + CuttingToolMixin.__init__(self, self.obj) + + @property + def summary(self) -> str: + diameter = self.get_property_str("Diameter", "?") + cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?") + flutes = self.get_property("Flutes") + + return FreeCAD.Qt.translate("CAM", f"{diameter} {cutting_edge_angle} v-bit, {flutes}-flute") diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py new file mode 100644 index 0000000000..3ef9d0b167 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py @@ -0,0 +1,12 @@ +from .camotics import CamoticsToolBitSerializer +from .fctb import FCTBSerializer + + +all_serializers = CamoticsToolBitSerializer, FCTBSerializer + + +__all__ = [ + "CamoticsToolBitSerializer", + "FCTBSerializer", + "all_serializers", +] diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py new file mode 100644 index 0000000000..aea9787a19 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import json +from typing import Optional, Mapping +import FreeCAD +import Path +from ...camassets import cam_assets +from ..mixins import RotaryToolBitMixin +from ..models.base import ToolBit +from ...assets.serializer import AssetSerializer +from ...assets.uri import AssetUri +from ...assets.asset import Asset + +SHAPEMAP = { + "ballend": "Ballnose", + "endmill": "Cylindrical", + "vbit": "Conical", + "chamfer": "Snubnose", +} +SHAPEMAP_REVERSE = dict((v, k) for k, v in SHAPEMAP.items()) + +tooltemplate = { + "units": "metric", + "shape": "Cylindrical", + "length": 10, + "diameter": 3, + "description": "", +} + + +class CamoticsToolBitSerializer(AssetSerializer): + for_class = ToolBit + extensions = tuple() # Camotics does not have tool files; tools are rows in tool tables + mime_type = "application/json" + can_import = False + can_export = False + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "Camotics Tool") + + @classmethod + def extract_dependencies(cls, data: bytes) -> list[AssetUri]: + return [] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + assert isinstance(asset, ToolBit) + if not isinstance(asset, RotaryToolBitMixin): + lbl = asset.label + name = asset.get_shape_name() + Path.Log.info( + f"Skipping export of toolbit {lbl} ({name}) because it is not a rotary tool." + ) + return b"{}" + toolitem = tooltemplate.copy() + toolitem["diameter"] = asset.get_diameter().Value or 2 + toolitem["description"] = asset.label + toolitem["length"] = asset.get_length().Value or 10 + toolitem["shape"] = SHAPEMAP.get(asset.get_shape_name(), "Cylindrical") + return json.dumps(toolitem).encode("ascii", "ignore") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> ToolBit: + # Create an instance of the ToolBitShape class + attrs: dict = json.loads(data.decode("ascii", "ignore")) + shape = cam_assets.get("toolbitshape://endmill") + + # Create an instance of the ToolBit class + bit = ToolBit.from_shape_id(shape.get_id()) + bit.label = attrs["description"] + + if not isinstance(bit, RotaryToolBitMixin): + raise NotImplementedError( + f"Only export of rotary tools is supported ({bit.label} ({bit.id})" + ) + + bit.set_diameter(FreeCAD.Units.Quantity(float(attrs["diameter"]), "mm")) + bit.set_length(FreeCAD.Units.Quantity(float(attrs["length"]), "mm")) + return bit diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py new file mode 100644 index 0000000000..f6f38af6b6 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import json +import Path +from typing import Mapping, List, Optional, cast +import FreeCAD +from ...assets import Asset, AssetUri, AssetSerializer +from ...shape import ToolBitShape +from ..models.base import ToolBit +from Path.Base import Util as PathUtil + + +class FCTBSerializer(AssetSerializer): + for_class = ToolBit + mime_type = "application/x-freecad-toolbit" + extensions = (".fctb",) + + @classmethod + def get_label(cls) -> str: + return FreeCAD.Qt.translate("CAM", "FreeCAD Tool") + + @classmethod + def extract_dependencies(cls, data: bytes) -> List[AssetUri]: + """Extracts URIs of dependencies from serialized data.""" + Path.Log.info(f"FCTBSerializer.extract_dependencies: raw data = {data!r}") + data_dict = json.loads(data.decode("utf-8")) + shape = data_dict["shape"] + return [ToolBitShape.resolve_name(shape)] + + @classmethod + def serialize(cls, asset: Asset) -> bytes: + # Ensure the asset is a ToolBit instance before serializing + if not isinstance(asset, ToolBit): + raise TypeError(f"Expected ToolBit instance, got {type(asset).__name__}") + attrs = asset.to_dict() + return json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8") + + @classmethod + def deserialize( + cls, + data: bytes, + id: str, + dependencies: Optional[Mapping[AssetUri, Asset]], + ) -> ToolBit: + """ + Creates a ToolBit instance from serialized data and resolved + dependencies. + """ + attrs = json.loads(data.decode("utf-8", "ignore")) + attrs["id"] = id # Ensure id is available for from_dict + + if dependencies is None: + # Shallow load: dependencies are not resolved. + # Delegate to from_dict with shallow=True. + return ToolBit.from_dict(attrs, shallow=True) + + # Full load: dependencies are resolved. + # Proceed with existing logic to use the resolved shape. + shape_id = attrs.get("shape") + if not shape_id: + Path.Log.warning("ToolBit data is missing 'shape' key, defaulting to 'endmill'") + shape_id = "endmill" + + shape_uri = ToolBitShape.resolve_name(shape_id) + shape = dependencies.get(shape_uri) + + if shape is None: + raise ValueError( + f"Dependency for shape '{shape_id}' not found by uri {shape_uri}" f" {dependencies}" + ) + elif not isinstance(shape, ToolBitShape): + raise ValueError( + f"Dependency for shape '{shape_id}' found by uri {shape_uri} " + f"is not a ToolBitShape instance. {dependencies}" + ) + + # Find the correct ToolBit subclass for the shape + return ToolBit.from_shape(shape, attrs, id) + + @classmethod + def deep_deserialize(cls, data: bytes) -> ToolBit: + attrs_map = json.loads(data) + asset_class = cast(ToolBit, cls.for_class) + return asset_class.from_dict(attrs_map) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py b/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py new file mode 100644 index 0000000000..b09ba55eef --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py @@ -0,0 +1,6 @@ +from .editor import ToolBitEditorPanel, ToolBitEditor + +__all__ = [ + "ToolBitEditor", + "ToolBitEditorPanel", +] diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py new file mode 100644 index 0000000000..f776a73dec --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -0,0 +1,263 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for browsing ToolBit assets with filtering and sorting.""" + +from typing import List, cast +from PySide import QtGui, QtCore +from typing import List, cast +from PySide import QtGui, QtCore +from ...assets import AssetManager, AssetUri +from ...toolbit import ToolBit +from .toollist import ToolBitListWidget, CompactToolBitListWidget, ToolBitUriRole + + +class ToolBitBrowserWidget(QtGui.QWidget): + """ + A widget to browse, filter, and select ToolBit assets from the + AssetManager, with sorting and batch insertion. + """ + + # Signal emitted when a tool is selected in the list + toolSelected = QtCore.Signal(str) # Emits ToolBit URI string + # Signal emitted when a tool is requested for editing (e.g., double-click) + itemDoubleClicked = QtCore.Signal(str) # Emits ToolBit URI string + + # Debounce timer for search input + _search_timer_interval = 300 # milliseconds + _batch_size = 20 # Number of items to insert per batch + + def __init__( + self, + asset_manager: AssetManager, + store: str = "local", + parent=None, + tool_no_factory=None, + compact=False, + ): + super().__init__(parent) + self._asset_manager = asset_manager + self._tool_no_factory = tool_no_factory + self._compact_mode = compact + + self._is_fetching = False + self._store_name = store + self._all_assets: List[ToolBit] = [] # Store all fetched assets + self._current_search = "" # Track current search term + self._scroll_position = 0 # Track scroll position + self._sort_key = "tool_no" if tool_no_factory else "label" + + # UI Elements + self._search_edit = QtGui.QLineEdit() + self._search_edit.setPlaceholderText("Search tools...") + + # Sorting dropdown + self._sort_combo = QtGui.QComboBox() + if self._tool_no_factory: + self._sort_combo.addItem("Sort by Tool Number", "tool_no") + self._sort_combo.addItem("Sort by Label", "label") + self._sort_combo.setCurrentIndex(0) + self._sort_combo.setVisible(self._tool_no_factory is not None) # Hide if no tool_no_factory + + # Top layout for search and sort + self._top_layout = QtGui.QHBoxLayout() + self._top_layout.addWidget(self._search_edit, 3) # Give search more space + self._top_layout.addWidget(self._sort_combo, 1) + + if self._compact_mode: + self._tool_list_widget = CompactToolBitListWidget(tool_no_factory=self._tool_no_factory) + else: + self._tool_list_widget = ToolBitListWidget(tool_no_factory=self._tool_no_factory) + + # Main layout + layout = QtGui.QVBoxLayout(self) + layout.addLayout(self._top_layout) + layout.addWidget(self._tool_list_widget) + + # Connections + self._search_timer = QtCore.QTimer(self) + self._search_timer.setSingleShot(True) + self._search_timer.setInterval(self._search_timer_interval) + self._search_timer.timeout.connect(self._trigger_fetch) + self._search_edit.textChanged.connect(self._search_timer.start) + self._sort_combo.currentIndexChanged.connect(self._on_sort_changed) + + scrollbar = self._tool_list_widget.verticalScrollBar() + scrollbar.valueChanged.connect(self._on_scroll) + + self._tool_list_widget.itemDoubleClicked.connect(self._on_item_double_clicked) + self._tool_list_widget.currentItemChanged.connect(self._on_item_selection_changed) + + # Note that fetching of assets is done at showEvent(), + # because we need to know the widget size to calculate the number + # of items that need to be fetched. + + def showEvent(self, event): + """Handles the widget show event to trigger initial data fetch.""" + super().showEvent(event) + # Fetch all assets the first time the widget is shown + if not self._all_assets and not self._is_fetching: + self._fetch_all_assets() + + def _fetch_all_assets(self): + """Fetches all ToolBit assets and stores them in memory.""" + if self._is_fetching: + return + self._is_fetching = True + try: + self._all_assets = cast( + List[ToolBit], + self._asset_manager.fetch( + asset_type="toolbit", + depth=0, # do not fetch dependencies (e.g. shape, icon) + store=self._store_name, + ), + ) + self._sort_assets() + self._trigger_fetch() + finally: + self._is_fetching = False + + def _sort_assets(self): + """Sorts the in-memory assets based on the current sort key.""" + if self._sort_key == "label": + self._all_assets.sort(key=lambda x: x.label.lower()) + elif self._sort_key == "tool_no" and self._tool_no_factory: + self._all_assets.sort( + key=lambda x: (self._tool_no_factory(x) or 0) if self._tool_no_factory else 0 + ) + + def _trigger_fetch(self): + """Initiates a data fetch, clearing the list only if search term changes.""" + new_search = self._search_edit.text() + if new_search != self._current_search: + self._current_search = new_search + self._tool_list_widget.clear_list() + self._scroll_position = 0 + self._fetch_data() + + def _fetch_batch(self, offset): + """Inserts a batch of filtered assets into the list widget.""" + filtered_assets = [ + asset + for asset in self._all_assets + if not self._current_search or self._matches_search(asset, self._current_search) + ] + end_idx = min(offset + self._batch_size, len(filtered_assets)) + for i in range(offset, end_idx): + self._tool_list_widget.add_toolbit(filtered_assets[i]) + return end_idx < len(filtered_assets) # Return True if more items remain + + def _matches_search(self, toolbit, search_term): + """Checks if a ToolBit matches the search term.""" + search_term = search_term.lower() + return search_term in toolbit.label.lower() or search_term in toolbit.summary.lower() + + def _fetch_data(self): + """Inserts filtered and sorted ToolBit assets into the list widget.""" + if self._is_fetching: + return + self._is_fetching = True + try: + # Save current scroll position and selected item + scrollbar = self._tool_list_widget.verticalScrollBar() + self._scroll_position = scrollbar.value() + selected_uri = self._tool_list_widget.get_selected_toolbit_uri() + + # Insert initial batches to fill the viewport + offset = self._tool_list_widget.count() + more_items = True + while more_items: + more_items = self._fetch_batch(offset) + offset += self._batch_size + if scrollbar.maximum() != 0: + break + + # Apply filter to ensure UI consistency + self._tool_list_widget.apply_filter(self._current_search) + + # Restore scroll position and selection + scrollbar.setValue(self._scroll_position) + if selected_uri: + for i in range(self._tool_list_widget.count()): + item = self._tool_list_widget.item(i) + if item.data(ToolBitUriRole) == selected_uri and not item.isHidden(): + self._tool_list_widget.setCurrentItem(item) + break + + finally: + self._is_fetching = False + + def _on_scroll(self, value): + """Handles scroll events for lazy batch insertion.""" + scrollbar = self._tool_list_widget.verticalScrollBar() + is_near_bottom = value >= scrollbar.maximum() - scrollbar.singleStep() + filtered_count = sum( + 1 + for asset in self._all_assets + if not self._current_search or self._matches_search(asset, self._current_search) + ) + more_might_exist = self._tool_list_widget.count() < filtered_count + + if is_near_bottom and more_might_exist and not self._is_fetching: + self._fetch_data() + + def _on_sort_changed(self): + """Handles sort order change from the dropdown.""" + self._sort_key = self._sort_combo.itemData(self._sort_combo.currentIndex()) + self._sort_assets() + self._tool_list_widget.clear_list() + self._scroll_position = 0 + self._fetch_data() + + def _on_item_double_clicked(self, item): + """Emits itemDoubleClicked signal when an item is double-clicked.""" + uri = item.data(ToolBitUriRole) + if uri: + self.itemDoubleClicked.emit(uri) + + def _on_item_selection_changed(self, current_item, previous_item): + """Emits toolSelected signal when the selection changes.""" + uri = None + if current_item: + uri = current_item.data(ToolBitUriRole) + self.toolSelected.emit(uri if current_item else None) + + def get_selected_bit_uris(self) -> List[str]: + """ + Returns a list of URIs for the currently selected ToolBit items. + Delegates to the underlying list widget. + """ + return self._tool_list_widget.get_selected_toolbit_uris() + + def get_selected_bits(self) -> List[ToolBit]: + """ + Returns a list of selected ToolBit objects. + Retrieves the full ToolBit objects using the asset manager. + """ + selected_bits = [] + selected_uris = self.get_selected_bit_uris() + for uri_string in selected_uris: + toolbit = self._asset_manager.get(AssetUri(uri_string)) + if toolbit: + selected_bits.append(toolbit) + return selected_bits diff --git a/src/Mod/CAM/Path/Tool/Gui/BitCmd.py b/src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py similarity index 80% rename from src/Mod/CAM/Path/Tool/Gui/BitCmd.py rename to src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py index d8094e5a24..f4c8808ff3 100644 --- a/src/Mod/CAM/Path/Tool/Gui/BitCmd.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/cmd.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * @@ -24,9 +25,11 @@ import FreeCAD import FreeCADGui import Path import Path.Tool -import os -from PySide import QtCore from PySide.QtCore import QT_TRANSLATE_NOOP +from ...toolbit import ToolBit +from ...assets.ui import AssetSaveDialog +from ..serializers import all_serializers as toolbit_serializers +from .file import ToolBitOpenDialog if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -54,7 +57,9 @@ class CommandToolBitCreate: return FreeCAD.ActiveDocument is not None def Activated(self): - obj = Path.Tool.Bit.Factory.Create() + # Create a default endmill tool bit and attach it to a new DocumentObject + toolbit = ToolBit.from_shape_id("endmill.fcstd") + obj = toolbit.attach_to_doc(FreeCAD.ActiveDocument) obj.ViewObject.Proxy.setCreate(obj.ViewObject) @@ -81,7 +86,7 @@ class CommandToolBitSave: def selectedTool(self): sel = FreeCADGui.Selection.getSelectionEx() - if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.Bit.ToolBit): + if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.ToolBit): return sel[0].Object return None @@ -94,32 +99,15 @@ class CommandToolBitSave: return False def Activated(self): - from PySide import QtGui + tool_obj = self.selectedTool() + if not tool_obj: + return + toolbit = tool_obj.Proxy - tool = self.selectedTool() - if tool: - path = None - if not tool.File or self.saveAs: - if tool.File: - fname = tool.File - else: - fname = os.path.join( - Path.Preferences.lastPathToolBit(), - tool.Label + ".fctb", - ) - foo = QtGui.QFileDialog.getSaveFileName( - QtGui.QApplication.activeWindow(), "Tool", fname, "*.fctb" - ) - if foo: - path = foo[0] - else: - path = tool.File - - if path: - if not path.endswith(".fctb"): - path += ".fctb" - tool.Proxy.saveToFile(tool, path) - Path.Preferences.setLastPathToolBit(os.path.dirname(path)) + dialog = AssetSaveDialog(ToolBit, toolbit_serializers, FreeCADGui.getMainWindow()) + dialog_result = dialog.exec(toolbit) + if not dialog_result: + return class CommandToolBitLoad: @@ -141,7 +129,7 @@ class CommandToolBitLoad: def selectedTool(self): sel = FreeCADGui.Selection.getSelectionEx() - if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.Bit.ToolBit): + if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.ToolBit): return sel[0].Object return None @@ -149,7 +137,11 @@ class CommandToolBitLoad: return FreeCAD.ActiveDocument is not None def Activated(self): - if Path.Tool.Bit.Gui.LoadTools(): + dialog = ToolBitOpenDialog(toolbit_serializers, FreeCADGui.getMainWindow()) + toolbits = dialog.exec() + for toolbit in toolbits: + toolbit.attach_to_doc(FreeCAD.ActiveDocument) + if toolbits: FreeCAD.ActiveDocument.recompute() @@ -165,5 +157,3 @@ CommandList = [ "CAM_ToolBitSave", "CAM_ToolBitSaveAs", ] - -FreeCAD.Console.PrintLog("Loading PathToolBitCmd... done\n") diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py new file mode 100644 index 0000000000..eb4d77065c --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for editing a ToolBit object.""" + +from functools import partial +import FreeCAD +import FreeCADGui +from PySide import QtGui, QtCore +from ..models.base import ToolBit +from ...shape.ui.shapewidget import ShapeWidget +from ...ui.docobject import DocumentObjectEditorWidget + + +class ToolBitPropertiesWidget(QtGui.QWidget): + """ + A composite widget for editing the properties and shape of a ToolBit. + """ + + # Signal emitted when the toolbit data has been modified + toolBitChanged = QtCore.Signal() + + def __init__(self, toolbit: ToolBit | None = None, parent=None, icon: bool = True): + super().__init__(parent) + self._toolbit = None + self._show_shape = icon + + # UI Elements + self._label_edit = QtGui.QLineEdit() + self._id_label = QtGui.QLabel() # Read-only ID + self._id_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) + + self._property_editor = DocumentObjectEditorWidget() + self._property_editor.setSizePolicy( + QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding + ) + self._shape_widget = None # Will be created in load_toolbit + + # Layout + toolbit_group_box = QtGui.QGroupBox(FreeCAD.Qt.translate("CAM", "Tool Bit")) + form_layout = QtGui.QFormLayout(toolbit_group_box) + form_layout.addRow("Label:", self._label_edit) + form_layout.addRow("ID:", self._id_label) + + main_layout = QtGui.QVBoxLayout(self) + main_layout.addWidget(toolbit_group_box) + + properties_group_box = QtGui.QGroupBox(FreeCAD.Qt.translate("CAM", "Properties")) + properties_group_box.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) + properties_layout = QtGui.QVBoxLayout(properties_group_box) + properties_layout.setSpacing(5) + properties_layout.addWidget(self._property_editor) + + # Ensure the layout expands horizontally + properties_layout.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + + # Set stretch factor to make property editor expand + properties_layout.setStretchFactor(self._property_editor, 1) + + main_layout.addWidget(properties_group_box) + + # Add stretch before shape widget to push it towards the bottom + main_layout.addStretch(1) + + # Layout for centering the shape widget (created later) + self._shape_display_layout = QtGui.QHBoxLayout() + self._shape_display_layout.addStretch(1) + + # Placeholder for the widget + self._shape_display_layout.addStretch(1) + main_layout.addLayout(self._shape_display_layout) + + # Connections + self._label_edit.editingFinished.connect(self._on_label_changed) + self._property_editor.propertyChanged.connect(self.toolBitChanged) + + if toolbit: + self.load_toolbit(toolbit) + + def _on_label_changed(self): + """Update the toolbit's label when the line edit changes.""" + if self._toolbit and self._toolbit.obj: + new_label = self._label_edit.text() + if self._toolbit.obj.Label != new_label: + self._toolbit.obj.Label = new_label + self.toolBitChanged.emit() + + def load_toolbit(self, toolbit: ToolBit): + """Load a ToolBit object into the editor.""" + self._toolbit = toolbit + if not self._toolbit or not self._toolbit.obj: + # Clear or disable fields if toolbit is invalid + self._label_edit.clear() + self._label_edit.setEnabled(False) + self._id_label.clear() + self._property_editor.setObject(None) + # Clear existing shape widget if any + if self._shape_widget: + self._shape_display_layout.removeWidget(self._shape_widget) + self._shape_widget.deleteLater() + self._shape_widget = None + self.setEnabled(False) + return + + self.setEnabled(True) + self._label_edit.setEnabled(True) + self._label_edit.setText(self._toolbit.obj.Label) + self._id_label.setText(self._toolbit.get_id()) + + # Get properties and suffixes + props_to_show = self._toolbit._get_props(("Shape", "Attributes")) + icon = self._toolbit._tool_bit_shape.get_icon() + suffixes = icon.abbreviations if icon else {} + self._property_editor.setObject(self._toolbit.obj) + self._property_editor.setPropertiesToShow(props_to_show, suffixes) + + # Clear old shape widget and create/add new one if shape exists + if self._shape_widget: + self._shape_display_layout.removeWidget(self._shape_widget) + self._shape_widget.deleteLater() + self._shape_widget = None + + if self._show_shape and self._toolbit._tool_bit_shape: + self._shape_widget = ShapeWidget(shape=self._toolbit._tool_bit_shape, parent=self) + self._shape_widget.setMinimumSize(200, 150) + # Insert into the middle slot of the HBox layout + self._shape_display_layout.insertWidget(1, self._shape_widget) + + def save_toolbit(self): + """ + Applies changes from the editor widgets back to the ToolBit object. + Note: Most changes are applied via signals, but this can be called + for explicit save actions. + """ + # Ensure label is updated if focus is lost without pressing Enter + self._on_label_changed() + + # No need to explicitly save the toolbit object itself here, + # as properties were modified directly on toolbit.obj + + +class ToolBitEditorPanel(QtGui.QWidget): + """ + A widget for editing a ToolBit object, wrapping ToolBitEditorWidget + and providing standard dialog buttons. + """ + + # Signals + accepted = QtCore.Signal(ToolBit) + rejected = QtCore.Signal() + toolBitChanged = QtCore.Signal() # Re-emit signal from inner widget + + def __init__(self, toolbit: ToolBit | None = None, parent=None): + super().__init__(parent) + + # Create the main editor widget + self._editor_widget = ToolBitPropertiesWidget(toolbit, self) + + # Create the button box + buttons = QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel + self._button_box = QtGui.QDialogButtonBox(buttons) + + # Connect button box signals to custom signals + self._button_box.accepted.connect(self._accepted) + self._button_box.rejected.connect(self.rejected.emit) + + # Layout + main_layout = QtGui.QVBoxLayout(self) + main_layout.addWidget(self._editor_widget) + main_layout.addWidget(self._button_box) + + # Connect the toolBitChanged signal from the inner widget + self._editor_widget.toolBitChanged.connect(self.toolBitChanged) + + def _accepted(self): + self.accepted.emit(self._editor_widget._toolbit) + + def load_toolbit(self, toolbit: ToolBit): + """Load a ToolBit object into the editor.""" + self._editor_widget.load_toolbit(toolbit) + + def save_toolbit(self): + """Applies changes from the editor widgets back to the ToolBit object.""" + self._editor_widget.save_toolbit() + + +class ToolBitEditor(QtGui.QWidget): + """ + A widget for editing a ToolBit object, wrapping ToolBitEditorWidget + and providing standard dialog buttons. + """ + + # Signals + toolBitChanged = QtCore.Signal() # Re-emit signal from inner widget + + def __init__(self, toolbit: ToolBit, parent=None): + super().__init__(parent) + self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui") + + self.toolbit = toolbit + # self.tool_no = tool_no + self.default_title = self.form.windowTitle() + + # Get first tab from the form, add the shape widget at the top. + tool_tab_layout = self.form.toolTabLayout + widget = ShapeWidget(toolbit._tool_bit_shape) + tool_tab_layout.addWidget(widget) + + # Add tool properties editor to the same tab. + props = ToolBitPropertiesWidget(toolbit, self, icon=False) + props.toolBitChanged.connect(self._update) + # props.toolNoChanged.connect(self._on_tool_no_changed) + tool_tab_layout.addWidget(props) + + self.form.tabWidget.setCurrentIndex(0) + self.form.tabWidget.currentChanged.connect(self._on_tab_switched) + + # Hide second tab (tool notes) for now. + self.form.tabWidget.setTabVisible(1, False) + + # Feeds & Speeds + self.feeds_tab_idx = None + """ + TODO: disabled for now. + if tool.supports_feeds_and_speeds(): + label = translate('CAM', 'Feeds && Speeds') + self.feeds = FeedsAndSpeedsWidget(db, serializer, tool, parent=self) + self.feeds_tab_idx = self.form.tabWidget.insertTab(1, self.feeds, label) + else: + self.feeds = None + self.feeds_tab_idx = None + + self.form.lineEditCoating.setText(toolbit.get_coating()) + self.form.lineEditCoating.textChanged.connect(toolbit.set_coating) + self.form.lineEditHardness.setText(toolbit.get_hardness()) + self.form.lineEditHardness.textChanged.connect(toolbit.set_hardness) + self.form.lineEditMaterials.setText(toolbit.get_materials()) + self.form.lineEditMaterials.textChanged.connect(toolbit.set_materials) + self.form.lineEditSupplier.setText(toolbit.get_supplier()) + self.form.lineEditSupplier.textChanged.connect(toolbit.set_supplier) + self.form.plainTextEditNotes.setPlainText(tool.get_notes()) + self.form.plainTextEditNotes.textChanged.connect(self._on_notes_changed) + """ + + def _update(self): + title = self.default_title + tool_name = self.toolbit.label + if tool_name: + title = "{} - {}".format(tool_name, title) + self.form.setWindowTitle(title) + + def _on_tab_switched(self, index): + if index == self.feeds_tab_idx: + self.feeds.update() + + def _on_notes_changed(self): + self.toolbit.set_notes(self.form.plainTextEditNotes.toPlainText()) + + def _on_tool_no_changed(self, value): + self.tool_no = value + + def show(self): + return self.form.exec_() diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/file.py b/src/Mod/CAM/Path/Tool/toolbit/ui/file.py new file mode 100644 index 0000000000..0a7c4cddf2 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/file.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import pathlib +from typing import Optional, List, Type, Iterable +from PySide.QtWidgets import QFileDialog, QMessageBox +from ...assets import AssetSerializer +from ...assets.ui.util import ( + make_import_filters, + get_serializer_from_extension, +) +from ..models.base import ToolBit +from ..serializers import all_serializers + + +class ToolBitOpenDialog(QFileDialog): + def __init__( + self, + serializers: Iterable[Type[AssetSerializer]] | None, + parent=None, + ): + super().__init__(parent) + self.serializers = list(serializers) if serializers else all_serializers + self.setWindowTitle("Open ToolBit(s)") + self.setFileMode(QFileDialog.ExistingFiles) # Allow multiple files + filters = make_import_filters(self.serializers) + self.setNameFilters(filters) + if filters: + self.selectNameFilter(filters[0]) + + def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[ToolBit]: + """Deserialize the selected file using the appropriate serializer.""" + file_extension = file_path.suffix.lower() + serializer_class = get_serializer_from_extension( + self.serializers, file_extension, for_import=True + ) + if not serializer_class: + QMessageBox.critical( + self, + "Error", + f"No supported serializer found for file extension '{file_extension}'", + ) + return None + try: + raw_data = file_path.read_bytes() + toolbit = serializer_class.deep_deserialize(raw_data) + if not isinstance(toolbit, ToolBit): + raise TypeError("Deserialized asset is not of type ToolBit") + return toolbit + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to import toolbit: {e}") + return None + + def exec(self) -> List[ToolBit]: + toolbits = [] + if super().exec_(): + filenames = self.selectedFiles() + for filename in filenames: + file_path = pathlib.Path(filename) + toolbit = self._deserialize_selected_file(file_path) + if toolbit: + toolbits.append(toolbit) + return toolbits diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/panel.py b/src/Mod/CAM/Path/Tool/toolbit/ui/panel.py new file mode 100644 index 0000000000..98027cfa0d --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/panel.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +import FreeCAD +import FreeCADGui +import Path +from Path.Tool.toolbit.ui import ToolBitEditorPanel + + +class TaskPanel: + """TaskPanel for the SetupSheet - if it is being edited directly.""" + + def __init__(self, vobj, deleteOnReject): + Path.Log.track(vobj.Object.Label) + self.vobj = vobj + self.obj = vobj.Object + self.editor = ToolBitEditorPanel(self.obj, self.editor.form) + self.deleteOnReject = deleteOnReject + FreeCAD.ActiveDocument.openTransaction("Edit ToolBit") + + def reject(self): + FreeCAD.ActiveDocument.abortTransaction() + self.editor.reject() + FreeCADGui.Control.closeDialog() + if self.deleteOnReject: + FreeCAD.ActiveDocument.openTransaction("Uncreate ToolBit") + self.editor.reject() + FreeCAD.ActiveDocument.removeObject(self.obj.Name) + FreeCAD.ActiveDocument.commitTransaction() + FreeCAD.ActiveDocument.recompute() + + def accept(self): + self.editor.accept() + + FreeCAD.ActiveDocument.commitTransaction() + FreeCADGui.ActiveDocument.resetEdit() + FreeCADGui.Control.closeDialog() + FreeCAD.ActiveDocument.recompute() + + def updateUI(self): + Path.Log.track() + self.editor.updateUI() + + def updateModel(self): + self.editor.updateTool() + FreeCAD.ActiveDocument.recompute() + + def setupUi(self): + self.editor.setupUI() diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py b/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py new file mode 100644 index 0000000000..40d73f6a7b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""ToolBit selector dialog.""" + +from PySide import QtWidgets +import FreeCAD +from ...camassets import cam_assets +from ...toolbit import ToolBit +from .browser import ToolBitBrowserWidget + + +class ToolBitSelector(QtWidgets.QDialog): + """ + A dialog for selecting ToolBits using the ToolBitBrowserWidget. + """ + + def __init__( + self, parent=None, compact=False, button_label=FreeCAD.Qt.translate("CAM", "Add Tool") + ): + super().__init__(parent) + + self.setMinimumSize(600, 400) + + self.setWindowTitle(FreeCAD.Qt.translate("CAM", "Select Tool Bit")) + + self._browser_widget = ToolBitBrowserWidget(cam_assets, compact=compact) + + # Create OK and Cancel buttons + self._ok_button = QtWidgets.QPushButton(button_label) + self._cancel_button = QtWidgets.QPushButton("Cancel") + + # Connect buttons to their actions + self._ok_button.clicked.connect(self.accept) + self._cancel_button.clicked.connect(self.reject) + + # Layout setup + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self._browser_widget) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.addStretch() + button_layout.addWidget(self._cancel_button) + button_layout.addWidget(self._ok_button) + + layout.addLayout(button_layout) + + # Disable OK button initially until a tool is selected + self._ok_button.setEnabled(False) + self._browser_widget.toolSelected.connect(self._on_tool_selected) + self._browser_widget.itemDoubleClicked.connect(self.accept) + + self._selected_tool_uri = None + + def _on_tool_selected(self, uri): + """Enables/disables OK button based on selection.""" + self._selected_tool_uri = uri + self._ok_button.setEnabled(uri is not None) + + def get_selected_tool_uri(self): + """Returns the URI of the selected tool bit.""" + return self._selected_tool_uri + + def get_selected_tool(self) -> ToolBit: + """Returns the selected ToolBit object, or None if none selected.""" + uri = self.get_selected_tool_uri() + if uri: + # Assuming ToolBit.from_uri exists and loads the ToolBit object + return cam_assets.get(uri) + return None diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py new file mode 100644 index 0000000000..c604d666bc --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import re +from PySide import QtGui, QtCore +import FreeCAD +from ...shape import ToolBitShape + + +def isub(text, old, repl_pattern): + pattern = "|".join(re.escape(o) for o in old) + return re.sub("(" + pattern + ")", repl_pattern, text, flags=re.I) + + +def interpolate_colors(start_color, end_color, ratio): + r = 1.0 - ratio + red = start_color.red() * r + end_color.red() * ratio + green = start_color.green() * r + end_color.green() * ratio + blue = start_color.blue() * r + end_color.blue() * ratio + return QtGui.QColor(int(red), int(green), int(blue)) + + +class TwoLineTableCell(QtGui.QWidget): + def __init__(self, parent=None): + super(TwoLineTableCell, self).__init__(parent) + self.tool_no = "" + self.pocket = "" + self.upper_text = "" + self.lower_text = "" + self.search_highlight = "" + + palette = self.palette() + bg_role = self.backgroundRole() + bg_color = palette.color(bg_role) + fg_role = self.foregroundRole() + fg_color = palette.color(fg_role) + + self.vbox = QtGui.QVBoxLayout() + self.label_upper = QtGui.QLabel() + self.label_upper.setStyleSheet("margin-top: 8px") + + color = interpolate_colors(bg_color, fg_color, 0.8) + style = "margin-bottom: 8px; color: {};".format(color.name()) + self.label_lower = QtGui.QLabel() + self.label_lower.setStyleSheet(style) + self.vbox.addWidget(self.label_upper) + self.vbox.addWidget(self.label_lower) + + style = "color: {}".format(fg_color.name()) + self.label_left = QtGui.QLabel() + self.label_left.setMinimumWidth(40) + self.label_left.setTextFormat(QtCore.Qt.RichText) + self.label_left.setAlignment(QtCore.Qt.AlignCenter | QtCore.Qt.AlignVCenter) + self.label_left.setStyleSheet(style) + + ratio = self.devicePixelRatioF() + self.icon_size = QtCore.QSize(50 * ratio, 60 * ratio) + self.icon_widget = QtGui.QLabel() + + style = "color: {}".format(fg_color.name()) + self.label_right = QtGui.QLabel() + self.label_right.setMinimumWidth(40) + self.label_right.setTextFormat(QtCore.Qt.RichText) + self.label_right.setAlignment(QtCore.Qt.AlignCenter) + self.label_right.setStyleSheet(style) + + self.hbox = QtGui.QHBoxLayout() + self.hbox.addWidget(self.label_left, 0) + self.hbox.addWidget(self.icon_widget, 0) + self.hbox.addLayout(self.vbox, 1) + self.hbox.addWidget(self.label_right, 0) + + self.setLayout(self.hbox) + + def _highlight(self, text): + if not self.search_highlight: + return text + highlight_fmt = r'\1' + return isub(text, self.search_highlight.split(" "), highlight_fmt) + + def _update(self): + # Handle tool number display + if self.tool_no is not None and self.tool_no != "": + text = self._highlight(str(self.tool_no)) + self.label_left.setText(f"{text}") + self.label_left.setVisible(True) + else: + self.label_left.setVisible(False) + + text = self._highlight(self.pocket) + lbl = FreeCAD.Qt.translate("CAM_Toolbit", "Pocket") + text = f"{lbl}\n

{text}

" if text else "" + self.label_right.setText(text) + + text = self._highlight(self.upper_text) + self.label_upper.setText(f"{text}") + + text = self._highlight(self.lower_text) + self.label_lower.setText(text) + self.label_lower.setText(f"{text}") + + def set_tool_no(self, no): + self.tool_no = no + self._update() + + def set_pocket(self, pocket): + self.pocket = str(pocket) if pocket else "" + self._update() + + def set_upper_text(self, text): + self.upper_text = text + self._update() + + def set_lower_text(self, text): + self.lower_text = text + self._update() + + def set_icon(self, pixmap): + self.hbox.removeWidget(self.icon_widget) + self.icon_widget = QtGui.QLabel() + self.icon_widget.setPixmap(pixmap) + self.hbox.insertWidget(1, self.icon_widget, 0) + + def set_icon_from_shape(self, shape: ToolBitShape): + icon = shape.get_icon() + if not icon: + return + pixmap = icon.get_qpixmap(self.icon_size) + if pixmap: + self.set_icon(pixmap) + + def contains_text(self, text): + for term in text.lower().split(" "): + tool_no_str = str(self.tool_no) if self.tool_no is not None else "" + # Check against the raw text content, not the HTML-formatted text + if ( + term not in tool_no_str.lower() + and term not in self.upper_text.lower() + and term not in self.lower_text.lower() + ): + return False + return True + + def highlight(self, text): + self.search_highlight = text + self._update() + + +class CompactTwoLineTableCell(TwoLineTableCell): + def __init__(self, parent=None): + super(CompactTwoLineTableCell, self).__init__(parent) + + # Reduce icon size + ratio = self.devicePixelRatioF() + self.icon_size = QtCore.QSize(32 * ratio, 32 * ratio) + + # Reduce margins + self.label_upper.setStyleSheet("margin: 2px 0px 0px 0px; font-size: .8em;") + self.label_lower.setStyleSheet("margin: 0px 0px 2px 0px; font-size: .8em;") + self.vbox.setSpacing(0) + self.hbox.setContentsMargins(0, 0, 0, 0) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py b/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py new file mode 100644 index 0000000000..fd60d068d4 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for displaying a list of ToolBits using TwoLineTableCell.""" + +from typing import Callable, List +from PySide import QtGui, QtCore +from .tablecell import TwoLineTableCell, CompactTwoLineTableCell +from ..models.base import ToolBit # For type hinting + +# Role for storing the ToolBit URI string +ToolBitUriRole = QtCore.Qt.UserRole + 1 + + +class ToolBitListWidget(QtGui.QListWidget): + """ + A QListWidget specialized for displaying ToolBit items using + TwoLineTableCell widgets. + """ + + def __init__(self, parent=None, tool_no_factory: Callable | None = None): + super().__init__(parent) + self._tool_no_factory = tool_no_factory + # Optimize view for custom widgets + self.setUniformItemSizes(False) # Allow different heights if needed + self.setAutoScroll(True) + self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection) + # Consider setting view mode if needed, default is ListMode + # self.setViewMode(QtGui.QListView.ListMode) + # self.setResizeMode(QtGui.QListView.Adjust) # Adjust items on resize + + def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None): + """ + Adds a ToolBit to the list. + + Args: + toolbit (ToolBit): The ToolBit object to add. + tool_no (int | None): The tool number associated with the ToolBit, + or None if not applicable. + """ + # Use the factory function if provided, otherwise use the passed tool_no + final_tool_no = None + if self._tool_no_factory: + final_tool_no = self._tool_no_factory(toolbit) + elif tool_no is not None: + final_tool_no = tool_no + + # Add item to this widget + item = QtGui.QListWidgetItem(self) + cell = TwoLineTableCell(self) + + # Populate the cell widget + cell.set_tool_no(final_tool_no) + cell.set_upper_text(toolbit.label) + cell.set_lower_text(toolbit.summary) + + # Set the custom widget for the list item + item.setSizeHint(cell.sizeHint()) + self.setItemWidget(item, cell) + + # Store the ToolBit URI for later retrieval + item.setData(ToolBitUriRole, str(toolbit.get_uri())) + + def clear_list(self): + """Removes all items from the list.""" + self.clear() + + def apply_filter(self, search_text: str): + """ + Filters the list items based on the search text. + Items are hidden if they don't contain the text in their + tool number, upper text, or lower text. + Also applies highlighting to the visible matching text. + """ + search_text_lower = search_text.lower() + for i in range(self.count()): + item = self.item(i) + cell = self.itemWidget(item) + + if isinstance(cell, TwoLineTableCell): + cell.highlight(search_text) # Apply highlighting + # Determine visibility based on content + contains = cell.contains_text(search_text_lower) + item.setHidden(not contains) + else: + # Fallback for items without the expected widget (shouldn't happen) + item_text = item.text().lower() # Basic text search + item.setHidden(search_text_lower not in item_text) + + def count_visible_items(self) -> int: + """ + Counts and returns the number of visible items in the list. + """ + visible_count = 0 + for i in range(self.count()): + item = self.item(i) + if not item.isHidden(): + visible_count += 1 + return visible_count + + def get_selected_toolbit_uri(self) -> str | None: + """ + Returns the URI string of the currently selected ToolBit item. + Returns None if no item is selected. + """ + currentItem = self.currentItem() + if currentItem: + return currentItem.data(ToolBitUriRole) + return None + + def get_selected_toolbit_uris(self) -> List[str]: + """ + Returns a list of URI strings for the currently selected ToolBit items. + Returns an empty list if no item is selected. + """ + selected_uris = [] + selected_items = self.selectedItems() + for item in selected_items: + uri = item.data(ToolBitUriRole) + if uri: + selected_uris.append(uri) + return selected_uris + + +class CompactToolBitListWidget(ToolBitListWidget): + """ + A QListWidget specialized for displaying ToolBit items using + CompactTwoLineTableCell widgets. + """ + + def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None): + """ + Adds a ToolBit to the list using CompactTwoLineTableCell. + + Args: + toolbit (ToolBit): The ToolBit object to add. + tool_no (int | None): The tool number associated with the ToolBit, + or None if not applicable. + """ + # Use the factory function if provided, otherwise use the passed tool_no + final_tool_no = None + if self._tool_no_factory: + final_tool_no = self._tool_no_factory(toolbit) + elif tool_no is not None: + final_tool_no = tool_no + + item = QtGui.QListWidgetItem(self) # Add item to this widget + cell = CompactTwoLineTableCell(self) # Parent the cell to this widget + + # Populate the cell widget + cell.set_tool_no(final_tool_no) + cell.set_upper_text(toolbit.label) + lower_text = toolbit.summary + cell.set_icon_from_shape(toolbit._tool_bit_shape) + cell.set_lower_text(lower_text) + + # Set the custom widget for the list item + item.setSizeHint(cell.sizeHint()) + self.setItemWidget(item, cell) + + # Store the ToolBit URI for later retrieval + item.setData(ToolBitUriRole, str(toolbit.get_uri())) diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/view.py b/src/Mod/CAM/Path/Tool/toolbit/ui/view.py new file mode 100644 index 0000000000..2021a01b14 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/view.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2019 sliptonic * +# * 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +from PySide import QtGui +import FreeCADGui +import Path +from Path.Base.Gui import IconViewProvider +from Path.Tool.toolbit.ui.panel import TaskPanel + + +class ViewProvider(object): + """ + ViewProvider for a ToolBit DocumentObject. + It's sole job is to provide an icon and invoke the TaskPanel + on edit. + """ + + def __init__(self, vobj, name): + Path.Log.track(name, vobj.Object) + self.panel = None + self.icon = name + self.obj = vobj.Object + self.vobj = vobj + vobj.Proxy = self + + def attach(self, vobj): + Path.Log.track(vobj.Object) + self.vobj = vobj + self.obj = vobj.Object + + def getIcon(self): + try: + png_data = self.obj.Proxy.get_thumbnail() + except AttributeError: # Proxy not initialized + png_data = None + if png_data: + pixmap = QtGui.QPixmap() + pixmap.loadFromData(png_data, "PNG") + return QtGui.QIcon(pixmap) + return ":/icons/CAM_ToolBit.svg" + + def dumps(self): + return None + + def loads(self, state): + return None + + def onDelete(self, vobj, arg2=None): + Path.Log.track(vobj.Object.Label) + vobj.Object.Proxy.onDelete(vobj.Object) + + def getDisplayMode(self, mode): + return "Default" + + def _openTaskPanel(self, vobj, deleteOnReject): + Path.Log.track() + self.panel = TaskPanel(vobj, deleteOnReject) + FreeCADGui.Control.closeDialog() + FreeCADGui.Control.showDialog(self.panel) + self.panel.setupUi() + + def setCreate(self, vobj): + Path.Log.track() + self._openTaskPanel(vobj, True) + + def setEdit(self, vobj, mode=0): + self._openTaskPanel(vobj, False) + return True + + def unsetEdit(self, vobj, mode): + FreeCADGui.Control.closeDialog() + self.panel = None + return + + def claimChildren(self): + if self.obj.BitBody: + return [self.obj.BitBody] + return [] + + def doubleClicked(self, vobj): + pass + + def setupContextMenu(self, vobj, menu): + # Override the base class method to prevent adding the "Edit" action + # for ToolBit objects. + pass # TODO: call setEdit here once we have a new editor panel + + +IconViewProvider.RegisterViewProvider("ToolBit", ViewProvider) diff --git a/src/Mod/CAM/Path/Tool/toolbit/util.py b/src/Mod/CAM/Path/Tool/toolbit/util.py new file mode 100644 index 0000000000..3a81493ee2 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/util.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +import FreeCAD + + +def to_json(value): + """Convert a value to JSON format.""" + if isinstance(value, FreeCAD.Units.Quantity): + return str(value) + return value + + +def format_value(value: FreeCAD.Units.Quantity | int | float | None): + if value is None: + return None + elif isinstance(value, FreeCAD.Units.Quantity): + return value.UserString + return str(value) diff --git a/src/Mod/CAM/Path/Tool/ui/__init__.py b/src/Mod/CAM/Path/Tool/ui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Mod/CAM/Path/Tool/ui/docobject.py b/src/Mod/CAM/Path/Tool/ui/docobject.py new file mode 100644 index 0000000000..4a57846058 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/ui/docobject.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +"""Widget for editing a list of properties of a DocumentObject.""" + +import re +from PySide import QtGui, QtCore +from .property import BasePropertyEditorWidget + + +def _get_label_text(prop_name): + """Generate a human-readable label from a property name.""" + # Add space before capital letters (CamelCase splitting) + s1 = re.sub(r"([A-Z][a-z]+)", r" \1", prop_name) + # Add space before sequences of capitals (e.g., ID) followed by lowercase + s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", s1) + # Add space before sequences of capitals followed by end of string + s3 = re.sub(r"([A-Z]+)$", r" \1", s2) + # Remove leading/trailing spaces and capitalize + return s3.strip().capitalize() + + +class DocumentObjectEditorWidget(QtGui.QWidget): + """ + A widget that displays a user friendly form for editing properties of a + FreeCAD DocumentObject. + """ + + # Signal emitted when any underlying property value might have changed + propertyChanged = QtCore.Signal() + + def __init__(self, obj=None, properties_to_show=None, property_suffixes=None, parent=None): + """ + Initialize the editor widget. + + Args: + obj (App.DocumentObject, optional): The object to edit. Defaults to None. + properties_to_show (list[str], optional): List of property names to display. + Defaults to None (shows nothing). + property_suffixes (dict[str, str], optional): Dictionary mapping property names + to suffixes for their labels. + Defaults to None. + parent (QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self._obj = obj + self._properties_to_show = properties_to_show if properties_to_show else [] + self._property_suffixes = property_suffixes if property_suffixes else {} + self._property_editors = {} # Store {prop_name: editor_widget} + + self._layout = QtGui.QFormLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setFieldGrowthPolicy(QtGui.QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + + self._populate_form() + + def _clear_form(self): + """Remove all rows from the form layout.""" + while self._layout.rowCount() > 0: + self._layout.removeRow(0) + self._property_editors.clear() + + def _populate_form(self): + """Create and add property editors to the form.""" + self._clear_form() + if not self._obj: + return + + for prop_name in self._properties_to_show: + # Only create an editor if the property exists on the object + if not hasattr(self._obj, prop_name): + continue + + editor_widget = BasePropertyEditorWidget.for_property(self._obj, prop_name, self) + label_text = _get_label_text(prop_name) + suffix = self._property_suffixes.get(prop_name) + if suffix: + label_text = f"{label_text} ({suffix}):" + else: + label_text = f"{label_text}:" + + label = QtGui.QLabel(label_text) + self._layout.addRow(label, editor_widget) + self._property_editors[prop_name] = editor_widget + + # Connect the editor's signal to our own signal + editor_widget.propertyChanged.connect(self.propertyChanged) + + def setObject(self, obj): + """Set or change the DocumentObject being edited.""" + if obj != self._obj: + self._obj = obj + # Re-populate might be too slow if only object changes, + # better to just re-attach existing editors. + # self._populate_form() + for prop_name, editor in self._property_editors.items(): + editor.attachTo(self._obj, prop_name) + + def setPropertiesToShow(self, properties_to_show, property_suffixes=None): + """Set or change the list of properties to display.""" + self._properties_to_show = properties_to_show if properties_to_show else [] + self._property_suffixes = property_suffixes if property_suffixes else {} + self._populate_form() # Rebuild the form completely + + def updateUI(self): + """Update all child editor widgets from the object's properties.""" + for editor in self._property_editors.values(): + editor.updateWidget() + + def updateObject(self): + """Update the object's properties from all child editor widgets.""" + # This might not be strictly necessary if signals are connected, + # but can be useful for explicit save actions. + for editor in self._property_editors.values(): + editor.updateProperty() diff --git a/src/Mod/CAM/Path/Tool/ui/property.py b/src/Mod/CAM/Path/Tool/ui/property.py new file mode 100644 index 0000000000..66876c763b --- /dev/null +++ b/src/Mod/CAM/Path/Tool/ui/property.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +# *************************************************************************** +# * Copyright (c) 2025 Samuel Abels * +# * * +# * 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. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** + +""" +Widgets for editing specific types of DocumentObject properties. +Includes a factory method to create the appropriate widget based on type. +""" + +import FreeCAD +import FreeCADGui +from PySide import QtGui, QtCore +from typing import Optional + + +class BasePropertyEditorWidget(QtGui.QWidget): + """ + Base class for property editor widgets. Includes a factory method + to create specific subclasses based on property type. + """ + + # Signal emitted when the underlying property value might have changed + propertyChanged = QtCore.Signal() + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(parent) + self._obj = obj + self._prop_name = prop_name + self._layout = QtGui.QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._editor_widget: QtGui.QWidget = None # The actual input widget (SpinBox, ComboBox) + self._editor_mode: int = 0 # Default to editable + self._is_read_only: bool = False + self._update_editor_mode() + self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) + + def attachTo(self, obj: FreeCAD.DocumentObject, prop_name: Optional[str] = None): + """Attach the editor to a (potentially different) object/property.""" + self._obj = obj + self._prop_name = prop_name if prop_name else self._prop_name + self._update_editor_mode() + self.updateWidget() # Ensure widget reflects new state + + def _update_editor_mode(self): + """Fetch and store the current editor mode for the property.""" + if self._obj and self._prop_name: + self._editor_mode = self._obj.getEditorMode(self._prop_name) + self._is_read_only = self._editor_mode == 2 + return + self._editor_mode = 0 + self._is_read_only = False + + def updateWidget(self): + """Update the editor widget's display from the object property.""" + # Implementation specific to subclasses + raise NotImplementedError + + def updateProperty(self): + """Update the object property from the editor widget's value.""" + # Implementation specific to subclasses + # Should emit propertyChanged signal if value actually changes + raise NotImplementedError + + @classmethod + def for_property( + cls, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None + ) -> "BasePropertyEditorWidget": + """ + Factory method to create the appropriate editor widget subclass. + """ + if not obj or not hasattr(obj, "getPropertyByName"): + return LabelPropertyEditorWidget(obj, prop_name, parent) + + prop_value = obj.getPropertyByName(prop_name) + prop_type = obj.getTypeIdOfProperty(prop_name) + + if isinstance(prop_value, FreeCAD.Units.Quantity): + return QuantityPropertyEditorWidget(obj, prop_name, parent) + elif isinstance(prop_value, bool): + return BoolPropertyEditorWidget(obj, prop_name, parent) + elif isinstance(prop_value, int): + return IntPropertyEditorWidget(obj, prop_name, parent) + elif prop_type == "App::PropertyEnumeration": + return EnumPropertyEditorWidget(obj, prop_name, parent) + else: + # Default to a read-only label for other types + return LabelPropertyEditorWidget(obj, prop_name, parent) + + +class QuantityPropertyEditorWidget(BasePropertyEditorWidget): + """Editor widget for Quantity properties.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + ui = FreeCADGui.UiLoader() + self._editor_widget: FreeCADGui.QuantitySpinBox = ui.createWidget("Gui::QuantitySpinBox") + self._layout.addWidget(self._editor_widget) + self.updateWidget() # Set initial value + # Connect signal after setting initial value to avoid premature update + self._editor_widget.editingFinished.connect(self.updateProperty) + + def updateWidget(self): + value: FreeCAD.Units.Quantity = self._obj.getPropertyByName(self._prop_name) + # Block signals temporarily to prevent feedback loops + self._editor_widget.blockSignals(True) + self._editor_widget.setProperty("value", value) + self._editor_widget.blockSignals(False) + self._editor_widget.setEnabled(not self._is_read_only) + + def updateProperty(self): + current_value = self._obj.getPropertyByName(self._prop_name) + new_value_str: str = self._editor_widget.property("value").UserString + new_value = FreeCAD.Units.Quantity(new_value_str) + if new_value_str != current_value: + self._obj.setPropertyByName(self._prop_name, new_value) + self.propertyChanged.emit() + + +class BoolPropertyEditorWidget(BasePropertyEditorWidget): + """Editor widget for Boolean properties.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + self._editor_widget: QtGui.QComboBox = QtGui.QComboBox() + self._editor_widget.addItems(["False", "True"]) + self._layout.addWidget(self._editor_widget) + self.updateWidget() + self._editor_widget.currentIndexChanged.connect(self._on_index_changed) + + def updateWidget(self): + value: bool = self._obj.getPropertyByName(self._prop_name) + self._editor_widget.blockSignals(True) + self._editor_widget.setCurrentIndex(1 if value else 0) + self._editor_widget.blockSignals(False) + self._editor_widget.setEnabled(not self._is_read_only) + + def _on_index_changed(self, index: int): + """Slot connected to currentIndexChanged signal.""" + if self._is_read_only: + return + current_value: bool = self._obj.getPropertyByName(self._prop_name) + new_value: bool = bool(index) + if new_value != current_value: + self._obj.setPropertyByName(self._prop_name, new_value) + self.propertyChanged.emit() + + def updateProperty(self): + """Update property based on current widget state (for consistency).""" + self._on_index_changed(self._editor_widget.currentIndex()) + + +class IntPropertyEditorWidget(BasePropertyEditorWidget): + """Editor widget for Integer properties.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + self._editor_widget: QtGui.QSpinBox = QtGui.QSpinBox() + self._editor_widget.setMinimum(-2147483648) + self._editor_widget.setMaximum(2147483647) + self._layout.addWidget(self._editor_widget) + self.updateWidget() + self._editor_widget.editingFinished.connect(self.updateProperty) + + def updateWidget(self): + value = self._obj.getPropertyByName(self._prop_name) + self._editor_widget.blockSignals(True) + self._editor_widget.setValue(value or 0) + self._editor_widget.blockSignals(False) + self._editor_widget.setEnabled(not self._is_read_only) + + def updateProperty(self): + current_value: int = self._obj.getPropertyByName(self._prop_name) + new_value: int = self._editor_widget.value() + if new_value != current_value: + self._obj.setPropertyByName(self._prop_name, new_value) + self.propertyChanged.emit() + + +class EnumPropertyEditorWidget(BasePropertyEditorWidget): + """Editor widget for Enumeration properties.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + self._editor_widget: QtGui.QComboBox = QtGui.QComboBox() + self._layout.addWidget(self._editor_widget) + self._populate_enum() + self.updateWidget() + self._editor_widget.currentIndexChanged.connect(self._on_index_changed) + + def _populate_enum(self): + self._editor_widget.clear() + enums: list[str] = self._obj.getEnumerationsOfProperty(self._prop_name) + self._editor_widget.addItems(enums) + + def attachTo(self, obj: FreeCAD.DocumentObject, prop_name: Optional[str] = None): + """Override attachTo to repopulate enums if object changes.""" + super().attachTo(obj, prop_name) + self._populate_enum() # Repopulate in case enums are different + + def updateWidget(self): + value: str = self._obj.getPropertyByName(self._prop_name) + self._editor_widget.blockSignals(True) + index: int = self._editor_widget.findText(value) + self._editor_widget.setCurrentIndex(index if index >= 0 else 0) + self._editor_widget.blockSignals(False) + self._editor_widget.setEnabled(not self._is_read_only) + + def _on_index_changed(self, index: int): + """Slot connected to currentIndexChanged signal.""" + if self._is_read_only: + return + current_value: str = self._obj.getPropertyByName(self._prop_name) + new_value: str = self._editor_widget.itemText(index) + if new_value != current_value: + self._obj.setPropertyByName(self._prop_name, new_value) + self.propertyChanged.emit() + + def updateProperty(self): + """Update property based on current widget state (for consistency).""" + self._on_index_changed(self._editor_widget.currentIndex()) + + +class LabelPropertyEditorWidget(BasePropertyEditorWidget): + """Read-only label for unsupported or invalid property types.""" + + def __init__(self, obj: FreeCAD.DocumentObject, prop_name: str, parent: QtGui.QWidget = None): + super().__init__(obj, prop_name, parent) + self._editor_widget: QtGui.QLabel = QtGui.QLabel("N/A") + self._editor_widget.setTextInteractionFlags( + QtGui.Qt.TextSelectableByMouse | QtGui.Qt.TextSelectableByKeyboard + ) + self._layout.addWidget(self._editor_widget) + self.updateWidget() + + def updateWidget(self): + text = "N/A" + text: str = str(self._obj.getPropertyByName(self._prop_name)) + self._editor_widget.setText(text) + + def updateProperty(self): + # Read-only, no action needed + pass diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py index a8e993541f..4fb285c6e0 100644 --- a/src/Mod/CAM/TestCAMApp.py +++ b/src/Mod/CAM/TestCAMApp.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# flake8: noqa import # *************************************************************************** # * Copyright (c) 2016 sliptonic * # * * @@ -61,9 +62,34 @@ from CAMTests.TestPathStock import TestPathStock from CAMTests.TestPathTapGenerator import TestPathTapGenerator from CAMTests.TestPathThreadMilling import TestPathThreadMilling from CAMTests.TestPathThreadMillingGenerator import TestPathThreadMillingGenerator +from CAMTests.TestPathToolAsset import TestPathToolAsset +from CAMTests.TestPathToolAssetCache import ( + TestPathToolAssetCache, + TestPathToolAssetCacheIntegration, +) +from CAMTests.TestPathToolAssetManager import TestPathToolAssetManager +from CAMTests.TestPathToolAssetStore import TestPathToolFileStore, TestPathToolMemoryStore +from CAMTests.TestPathToolAssetUri import TestPathToolAssetUri from CAMTests.TestPathToolBit import TestPathToolBit +from CAMTests.TestPathToolShapeClasses import TestPathToolShapeClasses +from CAMTests.TestPathToolShapeDoc import TestPathToolShapeDoc +from CAMTests.TestPathToolShapeIcon import ( + TestToolBitShapeIconBase, + TestToolBitShapeSvgIcon, + TestToolBitShapePngIcon, +) +from CAMTests.TestPathToolBitSerializer import ( + TestCamoticsToolBitSerializer, + TestFCTBSerializer, +) +from CAMTests.TestPathToolLibrary import TestPathToolLibrary +from CAMTests.TestPathToolLibrarySerializer import ( + TestCamoticsLibrarySerializer, + TestLinuxCNCLibrarySerializer, +) from CAMTests.TestPathToolChangeGenerator import TestPathToolChangeGenerator from CAMTests.TestPathToolController import TestPathToolController +from CAMTests.TestPathToolMachine import TestPathToolMachine from CAMTests.TestPathUtil import TestPathUtil from CAMTests.TestPathVcarve import TestPathVcarve from CAMTests.TestPathVoronoi import TestPathVoronoi @@ -82,61 +108,3 @@ from CAMTests.TestRefactoredTestPost import TestRefactoredTestPost from CAMTests.TestRefactoredTestPostGCodes import TestRefactoredTestPostGCodes from CAMTests.TestRefactoredTestPostMCodes import TestRefactoredTestPostMCodes from CAMTests.TestSnapmakerPost import TestSnapmakerPost - -# dummy usage to get flake8 and lgtm quiet -False if TestCAMSanity.__name__ else True -False if depthTestCases.__name__ else True -False if TestApp.__name__ else True -False if TestBuildPostList.__name__ else True -False if TestDressupDogbone.__name__ else True -False if TestDressupDogboneII.__name__ else True -False if TestFileNameGenerator.__name__ else True -False if TestGeneratorDogboneII.__name__ else True -False if TestHoldingTags.__name__ else True -False if TestPathLanguage.__name__ else True -# False if TestOutputNameSubstitution.__name__ else True -False if TestPathAdaptive.__name__ else True -False if TestPathCore.__name__ else True -False if TestPathOpDeburr.__name__ else True -False if TestPathDrillable.__name__ else True -False if TestPathGeom.__name__ else True -False if TestPathHelpers.__name__ else True -False if TestPathHelix.__name__ else True -False if TestPathLog.__name__ else True -False if TestPathOpUtil.__name__ else True -# False if TestPathPost.__name__ else True -False if TestPostProcessorFactory.__name__ else True -False if TestResolvingPostProcessorName.__name__ else True -False if TestPathPostUtils.__name__ else True -False if TestPathPreferences.__name__ else True -False if TestPathProfile.__name__ else True -False if TestPathPropertyBag.__name__ else True -False if TestPathRotationGenerator.__name__ else True -False if TestPathSetupSheet.__name__ else True -False if TestPathStock.__name__ else True -False if TestPathTapGenerator.__name__ else True -False if TestPathThreadMilling.__name__ else True -False if TestPathThreadMillingGenerator.__name__ else True -False if TestPathToolBit.__name__ else True -False if TestPathToolChangeGenerator.__name__ else True -False if TestPathToolController.__name__ else True -False if TestPathUtil.__name__ else True -False if TestPathVcarve.__name__ else True -False if TestPathVoronoi.__name__ else True -False if TestPathDrillGenerator.__name__ else True -False if TestPathHelixGenerator.__name__ else True - -False if TestCentroidPost.__name__ else True -False if TestGrblPost.__name__ else True -False if TestLinuxCNCPost.__name__ else True -False if TestMach3Mach4Post.__name__ else True -False if TestRefactoredCentroidPost.__name__ else True -False if TestRefactoredGrblPost.__name__ else True -False if TestRefactoredLinuxCNCPost.__name__ else True -False if TestRefactoredMassoG3Post.__name__ else True -False if TestRefactoredMach3Mach4Post.__name__ else True -False if TestRefactoredTestDressupPost.__name__ else True -False if TestRefactoredTestPost.__name__ else True -False if TestRefactoredTestPostGCodes.__name__ else True -False if TestRefactoredTestPostMCodes.__name__ else True -False if TestSnapmakerPost.__name__ else True diff --git a/src/Mod/CAM/TestCAMGui.py b/src/Mod/CAM/TestCAMGui.py new file mode 100644 index 0000000000..4cb882d88c --- /dev/null +++ b/src/Mod/CAM/TestCAMGui.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# flake8: noqa import +# *************************************************************************** +# * Copyright (c) 2024 FreeCAD Team * +# * * +# * 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 LICENSE text file. * +# * * +# * This program 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 Library General Public License for more details. * +# * * +# * You should have received a copy of the GNU Library General Public * +# * License along with this program; if not, write to the Free Software * +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * +# * USA * +# * * +# *************************************************************************** +from CAMTests.TestPathToolBitPropertyEditorWidget import ( + TestBoolPropertyEditorWidget, + TestEnumPropertyEditorWidget, + TestIntPropertyEditorWidget, + TestLabelPropertyEditorWidget, + TestPropertyEditorFactory, + TestQuantityPropertyEditorWidget, +) +from CAMTests.TestPathToolDocumentObjectEditorWidget import TestDocumentObjectEditorWidget +from CAMTests.TestPathToolBitBrowserWidget import TestToolBitBrowserWidget +from CAMTests.TestPathToolBitEditorWidget import TestToolBitPropertiesWidget +from CAMTests.TestPathToolBitListWidget import TestToolBitListWidget diff --git a/src/Mod/CAM/Tools/Shape/ballend.fcstd b/src/Mod/CAM/Tools/Shape/ballend.fcstd index 1060b6983ea0cac5769cff4a28ed61672c45339d..d9bf4dc73084f47f349c319d36a21a2602d1feac 100644 GIT binary patch delta 13152 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 OGcyq(QKJ9m8~+b*Y?(p; delta 12199 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 + + +SDHL diff --git a/src/Mod/CAM/Tools/Shape/bullnose.fcstd b/src/Mod/CAM/Tools/Shape/bullnose.fcstd index 24a6a18869bd8cb909ea71e7fd65ea58a206eecf..121fa6339757fc68540f06b99db859324b6ab469 100644 GIT binary patch delta 9499 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 delta 8707 zcmZvB1z1%}_xGWc?mBdrba%IOigX+#rKQ~<(#;`6KqREQ8w4a(KspcU0cnt~53kqn zeeeCh^E~^RnKkRTYOmSPn%VKb2_AsfV)!lc~y{tYBC4wkXlBB@Izr?VeeO6rP=%a*ym9qO@#q%fmq`8%Xn#5RB`?1nH;QQn$C)B%92uXD!*z}Pxdl`tf27xQlysw!5RTB) zM@rqu(!M{?IWT}jaCBwED;PiUN z?^r9++7&5;U{;ErL66*ATc1d#J`sg7Z(CC)7tMV>752o;2ALNu7x+MYo+;0@_qQG7LmVHxy?Y2vUn?(hC5gOc#Fu$ z5GFXY8VFHSjbJDji8pZCPU?I6dU@2xoz9Pbf4;H-Vwt7_43Up`sM_UxnSG0eS=jN8 zz}$G&*D<$%^%WC;=@GSagVD|T;gM5w91!E=I0Q1%z7*A)Q|-i=Pbw~T#@doKCR9@) zQ_hvtDw~^Velg>ZDl2Y_@4J`uWF(%_V35tUg_bIbo8trYHKb4**UvgCZ~^_MsLF|F z2y?ld@^yL}@X>dvzlxA;yOo5j0&-65!$Z}HY#zpt5QuhrJraM6#7<*&Q?BIdkQ1s~f|$Y20iSdLRcJ0DqhL^_2l?k|fUNgQ-DOvK$?bKSu19B2qi z%rUzKDf!ROg5)p1)0A~lsl8r6MxdQRAn3tCItbwb-d!VWz_OUysN!3#=gWD3w+fka z=FSf@32Mx=bRJj=Jz47cbfM3?F2)ECfZ}Z)evoR9T6rB0J6Y=N&QL+7PP+Qj70t_I7v{4UTC#)pe60{rwhPeu0+S>y zc!2|@84{!iQ4$@Dq5Onvhj7_I{L_?Ula;Ha8#WozS&o*i`p9B^ZIYqpR`#{keBOzRMQaeg5KPs$gb+xq~E5u$L3RxpYy(+?Z^^| zVms87jCZPJwH3VeYNB)>!9@(hzKgCTb&R`CtdF^rZHX_=FKH>hh_Xd^%#Fvirt1&_ z5`u%Zv6wT24rhSz#rM#9p|sx$p!FnaSJql_ZUzy~>TiPkJM>U;<~3TMNvqmzLF1 zfk#t*%1W_~;^XA7l^1tkB1O=EB$$^28@O`wPcBy3JTqaM0TiR5jFz5GxlerO5VabR zqtH>3rc}js;5@#?fZ1pi>9#soDhp?beS%rvdcYBjJBY;K^{!L%^0jh$v=07<&pydx zom6)YdODGGvib^S@j0JF`^gm{UJyiWMs#bVNiV(FE-$p<)uazDR;k5WFE?g^NF&p4 z5lBKjXOwCcUKO67@>~%JOkm^F9^DJs#7!@k#qUCHuJ;k)BJ1KxnQ%Hht-fOd!E=A? zi+z#x&byG=o43OgAle+Lc&iJ4SO1FZ&A}!+S-eUe6y5o#^y=-*sXfW#e(C7m z#KNotNvGH4$NE2ayc4*r3uzwbONJn6%gZTxy~knK-)V$=oS4V2t9xO-o7Ye_6h?#2 z)Z5g#N2qFkXuwUsAz6scn7z}rHy=kSsTVO#s8Wh=Ud`45@#G*LPbyRauD@Um@LjbH zCR~&3eBhxHV~01mu<@sn(0k}H@>b$1ZY&9KlT@Z#P;aTGSO6n=bFm7LFTEtjRL~D> zBznnkwnHOHZLJa}^qmrZlIsSlb5jXdvzxc^u7(6FFVJLlS%;ZSbas;gf$U_}ULC%# zGd6tz^}bb6u}>5_a`!?3eG?AK*F&_F0TZ4FA@GU0(GTJUuKQJE?GmJm0>m$^70*sB zJR1i$hbZ!fed2^i6#6_!$xSElJXIzjEHv6AIh@hyV=4Oj$td}oTH{0vj_G5N`*(H` zAQtISwT8t#1K8eDh zTv8Ary4X`QZO{nbTCyw^<2a(&*;9So*^Dlya53YK;5)bGOe!{BRVO6r;B$y(VsD= z8{RwF))(nwHnW12DDldv2IXL`zu-%b!&h@-WPJU|F{)>e zPnOTi+%rVbdVn;HX2#Ob21Yjkas57TzE%Bdg3shZ+8*;f4t>v-I_8`Urn}WQk7TQe zJ@kO!`iob#0l_Hmx&l|wO1cToZw6HvDP>{Q=4^w}!1>LgLe`PO_hW_C8De{{`EOyA zDrpb~xuisgw@kFo!OXZ`xUWjs(MlLLqjG=zSmRd6;87?VAQE1SY6|Nnxt-u5!51i0PQJ@XdP zQBm~)v>8$#Ycco+Gdm~+*RZg*NYi-z zsHYYaNG%~=!qwFJ!K1LxqO9j-L_FXrpgrjs_MRn4#1+xWom$IqyuJBn9z@$%Jei!kk5LW753(+ z1TYTJtXtYHJ@&uMt++-`-gz3TKO(c@qUrcOGoE?Qi#csi;tgGJBwif)KH44HSm#ra zCT*Otl<H?R-;ba;)mQiWyZ#O&=;xT7-;?X091xt=jIDvka1{d?UW$*=!YVcv20o|$`61A_E!0!^nO0UkbLzVGT{D5 zN-7k`M>^}7;zCWenHPq*8Dgff)+olHC}8{Wf!n;Z$~aYa@hD z(^H&x?NNS#@#N5Fd7(9LxuKv2bMUAP_?-b5xq`Q=+#t-zpCHlPEi~=9aA{j`Mm0*g zc#N|R->kyAaP1eX6pbEvt&mqmXaKp0R*J%!cx9N#?)rzZLu`ZVRLNgj%$h}XytwsG6CcUKi9k5%gFk2F$bp~i^VND5= zz7C!CaxGCY4Hv1Nol%-mOnX5wQCH5&)^HQo)0Edg#g0zDVWSq;!DL%<8^bqbMY&E| z+4GO?No`k49XCZfEf;S*L+-O)2!^BK4D&qDQ%#{v(1A3n*~fiZCe-Z~p-bU+H=ARK zrQivKUs(5Cm`wQ?Pxj0qUXo`=T*y}rfiH+$@!*=NweOul*uwUkX0||GO?1~ z%q0{l&y{#4Fv+SDqY#y}l(B9e#`;1lb2xPeO|IVcUAM<{@0j(;3yFqWvMr6y^ael8 z5I&3sg!arb&%7W3A7|ZBt{Z(agtH^nzrH5x4~yGPB&UJTp(@D(>*E|x*jrYSV%;lo zvp1LNWw#$*6I~%Jy=v_?6j#KSn3NHYW_A&De^uBm`B8T^l|Wo*=V8N1*b<^|xR~GB z@Dx7xAA_92R6=iq z!0|&zBLl7p+F_4X;ibmX0kmC=5D%8Myvti*Z>yxlIz6;_ zc?c4-+_y1pG)vv|G`6uOH2f_H7dxU9o-X`i(i?^N5I;%;?6c6P+NC|$_qB66bBmn5 z%a~8sQgHnHbn;jJ_Re%i&qp>m7ygmcj9adkXAbp2Cdt)}zzJynEdyLfvYyM;Mq*GuR$)8-WJ+VcfARAL$=6em2$Z(DZuTUue2rNu?J0?|q^62Q57R8R+ z(vIuM%gQ@Rz{2_2nVb8X8+7NhIlCr3+@#;SSzI+xMwLu>?;5W|U|!y+rlR&qIhzRM z7%6AxAR6xvUwzIEVai#8e!iIo7BQ@Rl&}sG_0_!nam`!fuA|RuQz(7JG*!!e$K80O z2^4n3$gQ(L&kh-t zu#|xFv>HZMrC!X)5A)<8zCCQU8ksQr@X4n4s2dkgH}cSkpO+1!RvLp@$9$i&h|t@a z-i~C)X{*rbMiU-Oe#~3IQ6haUZ;4cc?bZl_z9dhTDfXq!q?*$9-viqUUSWMTejYIA zZ>Bv&3GnOso8DO(_f{k+P$py}u3jA7>!Gd+y)@mCJpz5Yr2^Nfu@3bmrWVBZ{U0Sw=ygt1=ucfZ^{VS};0a5xX-t?qBu zh`DZi#*8?8bRw5kR= zf*7<#Dx>uobBe2I{i~_5*v@+yl5)jwX*#hE`7=$jd#YA%ZbbQH*G_5;7x_kF?3~w6 zYH{z7{&@pb>xlOr3HjGu5EO@~`}bgyi2tuaEf#SG;%`zJ>FjS(B)H>m_Q#aJ*=zBr z{*cCL+kUe{=|q8FGG!DSt_L21K!67bgmZttq~vA)k1HlqW8F1D!k2KvqAj(v56I@L zq`Ea?D>Z3f_t@oFFs`~I&EZQ2GbT?@%!DZ5WWYaeM|g#F#r!x64epD zJ>akUh^vi5P9`eKgpS9*Q1d#O0|iP{@^uT5aq2K2uz*xw0v)O|M+&5Q@V6f3CV{N zp{R)H0PF|X<&*NAVCs37YHmp2-hr`5KHgQIwhRwa6W}UKF)7w-cz~@oRf; z6CoDyPUgW!`-xdKmvJ5K>_+b|%scuZbU(Q)MTKHeAN47|C{FCqt>@KyoTVj&WXtu~ z{$-AeA|LkK8SQl5X{VMgyKCVNdfT3J&>FTO|G3_!P`g4b>6eW@Zqbh4&Seion@(vG@aLt9~L=Q z)1LdtU&v9|l9BCABIv25@`7uRN5(fE9}}kDnFqd9+Ztg$}hxc51JOb+xj7c0hn9e=J<=lf+Iy zZm)NLO-glv;Q*t(lSu~37o{0R!_CMGLdM`X6=v2u#po^&)ExU+KsKUa{McD}L`%qh zH`0YU4gdD##7SoL8a;IC~i;a97dh{(6dDC2wjnEumoFksrKHv z6T0c9%etKMN?sZ5sNupk-*dVPIbEUf@9%Z32;N3;lo&-iD88h4Y*%7MHut@Nr*9^o zFpd~W9V`(C$s2X60In#y`VP0gJ@Lk>?^6d+EQFhMj|sAc_vJvg1U9M=MVsbNBnb7@ z_;JKZ>QU0=1=^6Ys^BZMZ47Id;Da;440x^4xju6dHqG%%Z@woWgzuMcJ|^!bkmN$l)=5> zFF8)2=oyNV_S-KxLiJlU72Sd`541*Z!6B}R+Fh)HcC`Ssu@Q`eNM66>P`!XJ^&*s9 zWTm-pt7IZ}!~B=?2k460ObsqP9)%~EeTqA9_N}P*%!J(P64w*qt&&wr^>U2VtCB{M zbiF8l*gJOvF#*zOkxHHo1wChmzY#t~$={hhSMRfE?c~1@<*Ff~$Y$ANSKxV6ReoJ+ z=1>jc=Z6OMhHxF}g&crKJzhX6zVgc@Z_DCPKB>PCih$1xlW^j*{ zx_jF8lTTF0GK_)+yvOlk*aLFZo-0~BCEWlNG&C7Pr}?b(2PKY=(;xCb8&D8GRfB(+ zelsUtcaD#py|Zee#A$~ya=;e$!KlFYX(i1kW2)Vm)v>s&%(pW$1?$3shi_vXO5scn z&E%7eN2EZ`CLwxWVuFP6O7~MIvjNhE6#t`H5OFoL`G~)!S#Q5ic_*Vfp{t(wgpYE4 zbFJf9cXfThG^1YMhaMubrgbvrM|)PiX*FNZt~#5G3&TkpqGXoDrk4V1$BJIVdRYYb zl_GG?8ku9{1K6QlVhxtq!*DaqI4wf@uo(>tcoYCAOO1Kl^qSiib0uWEAce_Fytt-A z%6MoZuC*$e+DYowGa6gP{zU5Fxh@KmBTl~KWlrjq^osb#nKGTRVBU^t0jzKT(M}3{ z#cVw(F{yA&lea#bOzoN^*{>k*lB4@ekhPN+gm!##{tT%s>ef?pU zOIr=xxISeRrl-l3h-wQ$PyIdsMG^Wk-C50tvAsBL;O%2R&&GzuwTtk@X>i`tFPXvD zaW_hXm%+t5%=zZE-c!5Qb}Z-ex$x92!MYR<`vRf_e&gcl#sCgp>#yvL^8?9z^XXDg z_ifB%46=Eu)8=;Im#~?=T82~A9`g^QC&zw(0{C`YRc-e(2}D!PXqG_N)-p{&r<^mY z8K=FL6~OXm7i!#y?QwT(X>((VoQo1(bbYcU{MmC&YvfMeJ^iQIgGUncUs5CQa!FId z3nzoZ-^}zM^_~S+Np5Lxp1O&ZT%R?X>u=zI!rCz0+SbTDP2Jkx`<}wh%S&6wzc(HO z!?p}-_&6Bm2!vIYVEuA~#vjC^x%;c-N;S|0mOrMA3X5BoL?)1v1#Dv;c)&N3R;?^7 zmQQ}VqTTm4AWLi}8ZV=+C0u1UVjp>8KJEib1k1kSxqxq&m}QagIOAxEJeJI0StJ1l zdow+SQVxW1k@9MUt}X8UV;eSh2cN8LL%SX@#iEeErZU}Iy&Lqp%r%?sH~G50a03sy zTD)_dg3LSJTwizq$sV_Vf`53N%~74 zrSf*cdGWi2W{+bhwpa$A7mHu!13bQ*OjiU;ui7VWTp#~{YF5OUnYxQN)kyf`XH7wTwG6Igarzc9zH_LY` zWMWrb6#a(89Tqzn^FZhAer09G_z2RCfrX{DM2Xr;tG(P&m4~j#9xNc|9&Yk)1 zt^+MZjm=J5dP^i9U%_m`Tpqi0a?A&wIiKG)oqX2;+M&#v6?4XhdW4>>yt%e_-!nv} z%>eazie|x`lj#zNElPIS0{A6~zTw?VFRv5NoAZvfcxqxjJ^e+$yG1gmNpkY6rbbb# zMI|UE{Bi>huQc!MxP@hBb8&IO^4B`f>DDfC>DkGDj*K+IIvvvp&9A14OlaorJe_BL z408eMw%LJVXFXw2?QYvW22vbRm?*CXnRR1q`+ECsZw?Q6s-A#I#N4Sp7XnF3D%ao4 zPG57s*@C8}^-l$Ge_U3GBpPKV;d5WdS*q~9zP-6}KEzQFJKI?byrSw^hiXbnNpW#< zV*gBx&AjHq@^%S>8JYu&hG%EfpE>7QxVLdvduwFu!tr8%|LM;azndE%#@htR48C*R0b&mZ zi<{ni%=*Lkj|t z{XyLKb-~wAEFsq47*QcI#12X*wh$QVB1G^PPVJ&kE8!mZgboDy<;tH{7yKHUBSim~ zK}3Q&d{OrccnBa6=^uvszAjji4!S~12E7%+{H*|6m<;g&Jv4`o2FfOcY5KnoV}I~< z!SeL~=V+$&_@5#D&qw^ + + +SDHRL diff --git a/src/Mod/CAM/Tools/Shape/chamfer.fcstd b/src/Mod/CAM/Tools/Shape/chamfer.fcstd index a904db4cd3ed8686ad676a1ce17e1994b245a8a8..2c8ef596a5f7a969612dea82f5f4e4b2868a70fa 100644 GIT binary patch delta 13423 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 delta 12482 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 diff --git a/src/Mod/CAM/Tools/Shape/chamfer.svg b/src/Mod/CAM/Tools/Shape/chamfer.svg new file mode 100644 index 0000000000..3886c44d95 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/chamfer.svg @@ -0,0 +1,448 @@ + +image/svg+xmlSDhLα diff --git a/src/Mod/CAM/Tools/Shape/dovetail.fcstd b/src/Mod/CAM/Tools/Shape/dovetail.fcstd index 1fca39c020b85a41396497f5a8ae20c5f17fbe31..548726c2c3c6472593f5bd4a63bdcad9b9fd8e50 100644 GIT binary patch literal 16950 zcmb`u1yo(hviN;~-~%-gs8mb&)l`WALn zPUdDO91Au}EVXC%P#PR|hD9nLfKDygWy2FJ=E4vqk8>8&8soBrg*U(9+c&T{c+N}@ z;|wRfZ7No_12hlSYM8=x_8qe_f$)5FX{I$xNQ4r)uwC&Nw@+! z%8Or~<{T}h*MUTAwZ?V$v}!9)+sZzc;EQoMBhbF6rn7J(b>!7%fN;YY90Dy|<5OH;H%c(e1er zubFpuZ^y>LCaS$j%$|a?&$FLyN2mes^~ogFLnO>`HdbW;e8@RPo(LkEFxW>*Lw+&F zc1Sg?y+L$r84_)|^1-0Lp<^@6j=dY=YvUuCfw8OXit~b+EA}LaUaMze4{f=BZ^d!C z?uWw~YKmasiuw3xAmky>pZ>0N`{NK{&4j%&7!s^OPhP7lSx-kGL7gWQ~>1{@&^y zm#}gyh{HozhxhRI76j)qcWi zNmWKYm7G*&!Vqa_Wna2a=AU1h^ppBCqc2zOPd=y!TeBJPt@LIsSkaW1l$bZm^T_LK z>@1~TYJO@|dA{5^Y<3Cvgrua`^&%VsdEc+@OaoA#7>_N5Vr->2_a}uF;K3L)&^^SY zsCT~rVaWX8yrIQ476PL%K&s9Pgs#m@nTgn>ZvYv+lbCUfAsI zyed@wj=bcNX&g)TR{Zot?-Kf!rSr=%^|I&7eZoB$3Vhk?Ey@-H@4d|s8bV^@ZD&hO ziYAMzRG2+uqTp0IeU!Gu`Pt&PGrl3>G7pmLY_USJ#SJanlZ5X(^MQgCWRL0Y< zuXsN$!?dM*IUt}LI_|$|B_|(htlM%O#2l0@FZ5M?n~M?g)M0)9Ac-GHe>70p04+YN z$~#E2|2V^_rlHm+;nA&LAix&i61t?X#(s*rQ@WvcA&sdvJ+4;??DSnChwdvA7wxWb zf134Obn`vK9XOqjGL7$6zAb|QtU?)>Kl$j$J9Ln{<-P*&Gj+sAHpok0^N%#%kD+O5 z>~u5Gm?=Q+m*1o0!K@wwfpyh@rHT_n+a3z~zbl^&Hl|1=I;a>pK15jNkHu^94!9$? z^C7q6+^$)et?=mM!o}gE@fw@^G}q%JVl-*; za`1aO#KURq%-t7Xt zx{d`6dc@pr-NXyhTu;Uq#{T2YAP6K-lTZFAS5$kk;%AfZI#rMaD~pWFvAv5w#6OV6 zY5EuCNqKA^@Tka6meVrBUnNVG+EK;zW?2EnG&%RmHusG^+tnwd_C9hQoW1tauY&Zt z_B&DYr&FP>rcZWo(e}S~S3V4ADyg@Rf6>1Y4P(%gLSx(cI5^$rcORSzegy)THbj3 zZ#lhquQp6%QGue2RMbV*hxxO<3T6Tnl_ZB6Mm;;7Jh(WW9bx{y>FP}r!1T^Boh@CW zw=WrXiXZQ4$W|VGCs&AbDQIJ*b9r_+u%eD3aa3BQ>g`gExC)Kxtt2L2 zaTq5Xb>Iq$vMwS~k>ybsD%14KYAo0%Te?)3#V#?gPi6SpT4DG^ew<`DlTBmUz_7>Z z8i_PJD&KHo%AJw(wQ507%WF*N0SyKd z>sZQ%$x|B@xZI~ow@?P%!WOSS`W~))nQ`@$YFWy4ffHF(h-n6&Nw&CEp2BZ(l0G(u z-|uzOwqAKUvM(w0?X*$Ynx>~JS%)3M7G&B7cG|-0(viZMaW-ypi9DIMTpDPPp7wMvVd z--FAumwt#`9`y zZ?HDGqq5YHWgyMW%o`uQoa|w+8gw~#=RV3h__fjcd4B^1tyDM7u93zk7-y=psbXhj zFe|^B)TU{ScBI=a3Uo1+nBZfm7U%?!H$x=F>vC0EKSDq^o!Y(PsNxVb?@DXxVa_VV z_B8Gu+m7QF(X0U0@)ydL6unIZ2O z*x=elz>c55qGYz3J0?CtA|oq0!Az9Z^;y^a5`I4Y#UIL`KDQMf^R$R`KeXbVpCEeq8e&V#EsQA2o({zT(|+(Jf3)pC|CN?N@XAo-sxwZh75#MyrOFI zs(sJE5fomwTLUX#PTyWLk{=3=TXT-RH5@j+d5)u!ZbiEi!?x5GrS-{vX2`-2tV#vZ zSIJ7hY)Wka)Ok|1cd0q8G_=$20nZ;c&`?-*wNZv!?OeVtE*+3!01u|#D8&EJ^snVm zZPYw6vw@KF@}=^i>|2^LO4+l=YU2exHZNmH2ZlIdL5}V%D-|g&N1;hJo($~$AxGBc zN3LGxl3iM%CeT*0GJAzleG@(Fv(@B)U_NuT!ik_hiSew>Uk&PMcY?oI#P`6454D&e zU6HSI#w|x^!;ysBzwI%g#9#CLL0TJ}UvAzV0U5dyFR+{L`^dN~#j-XUvLm-(&s_LX zolKa6=G3eu-3_7fBrHUsom37IUaowio?rK~OwJ{2e?jwOaCMV3o_br&+IM_;2X5iG z6;v4&znH;-+LQ{p`Ip4dkFB)^QU&P`pE-3Z0PCX8w5*Mv_EA8d;U9U2`(nihiE zSX^s`3UDgpN@i2Ay|CAHAH{`3_EmcbO7>q3DGZsLU?w!%0kzInOoZG{SKF+GiMO2z zuEsxF%$k8G?R$>$tYveU(=!uO?qDPA4mz_WHrvUNg{427g+Er2lP`HOT%JE_oMTpZ z4w@dnpz9>dGki0jm zwpsvfu`*ZG2nDw zgjA9@fn z+^!?^dy*~{?}_LhAX~Chs;IG|no@90*f6p46y)2+>*E$rZu)SOn?SrX-s%yublcZ> zpc8OCcPBvYtZ5%KIrzD}R=cP!y3RybY>$GUiXHxK=SFC&F1-0iHagI zsvx51j01eqMsdEkF2D4|b<6Dr=c+d89Jf^AO3nIp0P1tPO+cedSJo7K4buk#xwT#Sm z#xY&{1*xLbsP@+iICZQ$1+~#UCb8R-CYpGumu@7B<#Z%x-OiaVhpancF7LDA#4#&C2)E@WZADZMt*@*&n&AWoPFGqaYw) zd=oEQIt0)|63IODgSb%Fk5r}P--I=!QDmc1qEp1&NKs$)m$M|7WTUnxvELQ$w!eQ_ z%GVMIkwAIM)f6X-PB_VaTn z*J>B!>TvT5+Y5}}qbKo}fgaVehXha&VLoY@p{wbcIMaJ?29zKQsJpTqEe4XN22KVQ ztg&PV-z^Np_oapE-t1$zY#Yvm1+|@@(<0=zpj_Lp|NQ?VA)`y zDPKj2_p@Y<*bNfl?nF~^emdfyjibS$iL;}PyMz>*4A_>67ZG`Yk|E|9BveWjX~crz z9zi#2YaVnpIjgodwS(u0|3T@D7mP-#@__H+k%ie4x`+B%FfA7KY|C^ha3Lu*_q-n9F3J#Ou1_1WbXd9yD{X%6b1q(Kg|?*ZNC=aSUHjh)Z>|vkvOxb=pc&T1XzbUGpnm+wv+5EPIJt)`LiRz2fGX

Wu`E9GutkPM=}292~~V@rU=o74n()_CEl;_XAw%gJMlz5Y-nGI>rVx$abmQ+gfTClb=Gi z6dq9>Hp;0)lkgS9-7I(A2coy^O>C#mBNn4qTt?0_Z6EY}$jM%_x#GNPZ#XyJ+!qgF zVJo#}W3`Pb4Tq2q2>c)S+pXAfux$7VJI4zB}iFC+YKC;S?@0@aBN_d#Uc5&j0fWY0O)T+lhJpu zG_$uewzQz)(bJQ)*ZH@n?I?*^F44fW9x3kEGuq-Fp6Et7xcHil}D1 zdc-DuwROR(ul(&{y zQH+SW&q=GKz7epGV79gRNb@mxHzXvC@Ds8**a)d8(*6otf+$q9v?}z}mlg_#0}UQJ zQ5)F1_&a$PR#cv>tQ-hW{)v6hLf2TcuAVsd3rQUp*>q+CE(NjKWxT(!_8ao!t(Zrk&{3chQL z9Q{(j^f;U?&ZIv0Q*$DWTqBZ!q*LID&Zv8W1`ld9a0z1~{N?bpNToSBPH;3`-Rhdu zwkq89084crp>0TJoq}-oR~rq}owy^Ju>eUveAA$JMuW==gIy4yER>%Oa)^n0C~i=7 zAuPm7;QYSoTMh|dWzIjq`aKs|rk5TSvURMCZoWKTn=uvU&B4`$xHoHU1uT>u0;?vU zj59foFZO#(?B&t*?MGl#JF67sI?3KAnIB{#is*};rF0a365CO^+#JUcUu$hlSLU%S zKC|QTOvuy{S<*7xN%Z=bV{BN{3L60CwILK7T;81?lj41zHGo zgTy!Km?VM3M^8V8&QR{)*CEWd!M#TKsz$BcSJP~JO_Ob2P7yaiTnsToSX;zGx znUMhTx$IS*+(v9Td3I7$xi#DZHZb|VjF+y<&mK1Y2NfH(T_^h(NtQ+g3Qi~!gcY4v zxR+{vThPs-49@UnC&NR;SCQUwN0r($mnVk4d5FjT@3JdN2wt&V0*Sy9Klt7d<)M4~ ze~1GgmoZw05&I#RXueHcA#j{9kM2@GNX74k%V)|Njeri$K!9}wD{OCA6+T3e5GM*-XOf-JbHY)rPtj6VIa&Rcbc$ec#W!Iutt zK_M3D&>e+?BQauG_b@rTPg3uP*)tMO(fqC# zIRvztiUI(Y#rEXRkY#9fO?656kpr(~?x|nx#2!~W>aU~NrB|qteG4Wgxc?O={}N38 z5=MS~GkE08;!byo@!7wEBIV~*js<-1TvG;z!Whe|f&>CqiyzTSuNX_bm`BNezA$=S zm%^S=ywP{}Ac@!rd9u4y_9!cTiy~6$igiDuNU-INhk26cuH zc2pf(ZS+V`i7BE&e1n2Fpjnw-VYr13SdsfHv5TY_B~@D0aIN6P=}c659Vcc1(uqdC z&^!sfP7w8*qFmDx+;)PX5V;{5$zlHUd=O0A#nJH#8dTHES9~h-Ft(_j`p^%}i4Y96 zauz~%!aF)ty6Gw%%$0$)@stot^mCBPc}C=-;g(HJa^tz+OhGJ|5kqdp+SeM!hH;P+ zDu@#MF=J<%{=>5!@07wV%WfVsfPLf<2qx)#H@_Z+*oGRLnyv*w!PSJ7Ps)9MgyerJ zqCOivDr9Y+8hw5oJD4z&kr=b2Iy)na{kS&#B1d9)Fu2JmhT*E=wx+i8&a*l#!KaA?8gDR6E>Ie=nBfBKit><`tMX#mC*5d>d zAI?5$>ZeIt$)~i>!ytYQ%-ZXAo>(rs!n`)aFUfd!4`=+9$<@{U8#05YF0Tz7lbTc{Z4^&-uZh z{v(pDIwE2+K=8g?#%b=u>Xg;WihJ>+4#QSR{*XPvD0gvnYmTGQYRGk?(qLZ=e7RS5 zDi57D1b(I`(63GiBl8LKk5KY+r~Nl4{C5!Hc?%-&UpJoFdobI1!E_N;lx3DlOB>+~ zzI!tvhfQu_z<}a4`90y#vkIU2qUpuLOa9=&@y{E&4rb8jTlUo6guF^okM z*7wj`hoe7;-NPPPfwL(5hGbqinRH~gcovRar9i<4#j+%AlR(u*n_D8bgyOJJI-V@! z_?4+6muvmE9m$)@|9(^WEfmVyTUpuY+uG{u{X3le{=Oh6{hj&-8~`}61pwgQBnjCY z|57HSZf={+1pktznK_xbn&5w~6DqQG#6s;jH@dPcalEV1x=dPYRvdsp;D=Z@^{O%8 z<%^}GD-I(PJFI6`EesdpkUj&&MAEkQMU6&)p}pI*ZBqUOnOh?rrkvTN4Qk=M{O0g> z4xEGw3me;u87Z@Bl$C|#*~;hh4+;o)VBOxQ^}GH3-CaQ}n;=fi0>AL~NGTdTB%tGq z2*mz7h@v8FOzg{-Xe#bHEmWDqGk&}QF`o)Wm0?e-&(cF!-nePDoVk^PFMtpg^w zFH)+kM81j9foNnEk6Acm*xHh;DkQc+dEM_p@2ZyvqzRFHRbL#wPK=O<<~e!ov+BX8 z^n6R;&d=SfxKqTDW@7(J0Gi>PU^K4407_FjFSh?|L=o_CNYrDfjYJ5-h&-On>#Npv zfoHz+{W`JA>CP7Ojw;0+7o=M#<^D;k?=~U=u>v|`jh?M73JT(*o_4t#K|I@Qm#$Ga zwK>!ZJS0E}4I(_cl{x!y|0eL&IkcI|Mmjx{=RNIZSzVR5TDpOmcyq*@r|mwgm%t`u zsp%Pc{b}`*s9v(pmtm<4t1EQB(vK*W6)sR9Ij!$28W}RF{AE8cy`##=<5L54kNbf8U^|7v2L(iB`xAto7q1518c z;+Q6)c_F^O8K2p(w>vkEcz)V2o5OJ-FBq?@64;ZpIrVVI5=L^lY)J{R=L><^>T9Eu7Hc_@3cst*t(*t={JlXGjWspDk87-Rw4ydlrSZMNc%V z+?52wTidS=7hLgouOBG2S@?Q&ogx@9+?}ZZ3)kgv{n;5VPTR z{`jitors_`jL~)}$Y;+mMDwZ3g6+L3y&Z+c)2xCShiE5D_$r-U_6~#NkKG;i{1RE^ zCf9uGHTF7|dE+Y4x!{KU6C3Z*7aQ+M^TE3xJ>7K=S8!Hy)G)GMNwww}jj5*c1?;(o zH=1&|8bEXIacU$DUu@JlrWHWv(4vnB=UQM&ABo?-O5JXBDJX#|r6-z0-eMb3ct7gk zlC8g#cls^NpJIIx>e{#4-AmTu~L-ZZ6VJG4ynJGTkD|tPWNI((G2E zGF6X#z=2U(71_*Cq?V%FA>te8jFl6!}qO=dk)l~oZ|5|2fJS2MC+MsgX_`q_W7ip?e6h^W>;0Z?U zTgIgOnVkK4BEb&{Ir9xRJgLx}YB1#mx95rDICGy%2R$5HhW49@`B%AVq9thrwL!BS zb&mt@-*0HI3JvxWMvR!Xt27=!2%@x>f3XnMa-kXYjA)J`RVhzJI8r%qWj=G_eycX> z)*W-xa0yXW^+!qPwT^X1!X^VXpFxKBR9sjit~!^_0}8C{yp2E+ahzMwH%crYQiMG= zkcVAUB$REJ+{h0^fRxIkP0zSJYEk_ir7(mI%$$xu6GjUe&AI6~v^g-`wC17ofC)&b zhae26L5BU&ff`el`P!ZLsNQNnGf1-3s&*5*;G`Kyr}e{_CwQlDw@|}Pr6I;{IqzxD zI!q6qj^7CqtjiPM3r5SNq)gJP8@I;|CJpkTbxaV98n)F1chI8}Cv8%^zRE;$e|weGdd)YZ@NoeoU;&WCbP zOJwm;OyvE-meWUKr9rAR1gSCRhp|zs$y|%46$!!LTeTKH*DaJ?w^gm6iX@A5t>uiL zOzp~}y<81Go{pd*k3F*WT(y=Arge|jG#2Ec|5&xSmo4&daT9++?74HQ<^Tik=GVgs ze3&;A7eso{U5>zbecmxte|+yncE-CM!^ORZDoJlY`ubG7^yo2rZ)Lp@r0@1YOWUWN zM!w5PNdXb_u+}6gEbj!cB6H)ftL*YgqCm^HVFVb<=m~YdNZ+(}%B&k7JN?LQptsu% zyqTcS`dES90yA$hDai!A)?mRJ18KN}N?;Ec%~JSbTs@_t9l0(%N@*RCpNaln-8PG7 z*YhR$6}fJAoAh2WxeV?F{MY>v=>Qn>5i|h6`ToC7BJGUq&2=oajm>PS<^K4S%F4oU zN70pGdI@{2Es=~*ubD3HV=PQh=;fd;F+cf)AAS^=^LAXHlOD_tLKH7sKYTQu^UgCm zCO^>*JYEdL;G?u&^C`>Tvi+Hg<4Vox>uuAlhU-bq?QIj++SrTR?c!tnf-wP5tGXKi z0JOCQMhL(yUx=i6tJcL|Z@?;>v!=iS)V{VZWa#yrGy$!rGcvK5U`KK?Zts#!ROOqG zl8oeE*-I5BD_ExldQXOwO?D1+_Febt2KCh-^JeHF$-i4@eJ*WQM(O3nqw*%g(uvtF zCo!zuztb>E`w?hPMtkWxIBvOSiOHtNzMPD5eqP6i(Ips`O#p6^a^%ZCRSf&hfrQ84 zk*$Dcy#PG0No$=0ijW_8QTttg6xc@~h*rTa``IXXg+0gZ4fG9t&x5$!_n~N+ z@i98%3k$3*xZN*PFnd^Zsnt>6Cw_FZbQx4bbgJNS2`RxX6p*F0($Jm1lw03wZ@HrP z5};3U`TBdf$W4FubWKRr5mFDAFHJ~a60(EoHn*mHRH|=Gd&(WTo-Ul)LCu4y`U+EZ zZeyUG13Tl_M}>@&_hh=9#?fRudg&n=JnikvXWt>#;s{YoVRrPwdw#9#uRu_k?Fai9 z7Y797vD(&7-4A?O=pk#={0zIUn%obd&{Y8F2>}+NVd27Hx)9ddr6cAC3j|hy(maP! zqk!&bVV;GmL8@DNvE(@EdqOEcDKw&~h@*vbj^o1}a8-e4B#P-2Aze4sjch8bj5mME z=;(Syj(lICv3b9+3OJ=j@T41lsV@n5a;^xZwhh zVn&>9bXRD=i(!=7xzBQ3bl@!5 z(kp9x#1$ znOO`L2NWCPkK#%&XJ9=}JTKHkP2kgscNV8=&64a~fU+22hBB;XY6N+dO`oYF_KPuD z=NRy2nT@(B3B30lFqkd2AhV7JRK+;OrVw;6;##=t{lpkW)kCT0_=OK80Fq|b`0Slr z%5trQiL$Bs-1m>g7M~D1%;GM-mle~{dniDV53+X>*>X81x374$1aHOgBh8d@CJuw> zH!SPGltL;Sν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 literal 16876 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 +image/svg+xmlSDαHNCLh diff --git a/src/Mod/CAM/Tools/Shape/drill.fcstd b/src/Mod/CAM/Tools/Shape/drill.fcstd index ec78c92f066fe66b96232236b9dd63246efc32d3..5d8ef15dea1473fd32072f4b61a3c5a67d081376 100644 GIT binary patch delta 8960 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 bV5l)01@84v4NMqu2V-NA*P<`~-s1lUZjFa9 delta 8476 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 RRL;%P@|lzj)!!dH{6DPpY$pH! diff --git a/src/Mod/CAM/Tools/Shape/drill.svg b/src/Mod/CAM/Tools/Shape/drill.svg new file mode 100644 index 0000000000..0b98c74701 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/drill.svg @@ -0,0 +1,258 @@ + +image/svg+xmlHDα diff --git a/src/Mod/CAM/Tools/Shape/endmill.fcstd b/src/Mod/CAM/Tools/Shape/endmill.fcstd index 47e748acde02cafc8167f817941a93792f197ca9..998a635ac208955275436a091a818845ece3a9e2 100644 GIT binary patch delta 9015 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 bB!Qa}*OMIpzyL@)TUbblNs|A47Uur|UOuyF delta 8419 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + S + D + H + + + + + + + + L + + + + + diff --git a/src/Mod/CAM/Tools/Shape/fillet.fcstd b/src/Mod/CAM/Tools/Shape/fillet.fcstd new file mode 100644 index 0000000000000000000000000000000000000000..536a57f848c404fe0ddb506e9b807b77e2bbe826 GIT binary patch literal 17383 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 +image/svg+xmlSDhLr diff --git a/src/Mod/CAM/Tools/Shape/probe.fcstd b/src/Mod/CAM/Tools/Shape/probe.fcstd index bb156dd580b826ea51c3609acc62caec1b988c48..8bcaf278888e25b5786f88c686daa5307dffd344 100644 GIT binary patch delta 12592 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

2mOJ9CqB$4{*(<#1 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 +image/svg+xmlSdL diff --git a/src/Mod/CAM/Tools/Shape/reamer.fcstd b/src/Mod/CAM/Tools/Shape/reamer.fcstd new file mode 100644 index 0000000000000000000000000000000000000000..56ed8f1ceee8b3f9c008a89905cd7028c888cf52 GIT binary patch literal 14313 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 +image/svg+xmlDH diff --git a/src/Mod/CAM/Tools/Shape/slittingsaw.fcstd b/src/Mod/CAM/Tools/Shape/slittingsaw.fcstd index 904cffeaf6ce17749aa0c0aa87a0c678b212f8c9..2b33b0136c07777d0b74abf0de2bc5672455a05f 100644 GIT binary patch delta 12019 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 N)-qDElz%_c{|D5Ag#7>j delta 11259 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 +image/svg+xmlSDdThL diff --git a/src/Mod/CAM/Tools/Shape/tap.fcstd b/src/Mod/CAM/Tools/Shape/tap.fcstd index 32634aeb580b9b677f2e9cf44e53342cd2a7a0d1..2ee6cbcda3c4d7a3850d9357c1766bf359ed89ed 100644 GIT binary patch literal 15404 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 KA-q3A-v0+0QJNqC literal 13271 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 +image/svg+xmlSDαhL diff --git a/src/Mod/CAM/Tools/Shape/thread-mill.fcstd b/src/Mod/CAM/Tools/Shape/thread-mill.fcstd deleted file mode 100644 index 0e93663ed411f75a05d16a66e18563e73fc80811..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15335 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%0Ad;g5$6nu%QulFk}P-)#I;Ggpw>B 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 diff --git a/src/Mod/CAM/Tools/Shape/threadmill.fcstd b/src/Mod/CAM/Tools/Shape/threadmill.fcstd new file mode 100644 index 0000000000000000000000000000000000000000..e0eb96c9eaebeaf04007502fc60e64f367f4b815 GIT binary patch literal 16823 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 L1ccxHG35Op5};;> literal 0 HcmV?d00001 diff --git a/src/Mod/CAM/Tools/Shape/threadmill.svg b/src/Mod/CAM/Tools/Shape/threadmill.svg new file mode 100644 index 0000000000..f15357d1cc --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/threadmill.svg @@ -0,0 +1,602 @@ + +image/svg+xmlSDαCNn diff --git a/src/Mod/CAM/Tools/Shape/v-bit.fcstd b/src/Mod/CAM/Tools/Shape/v-bit.fcstd deleted file mode 100644 index c87f4dee996ca36134c6316c21d680d1b5431019..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16858 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 diff --git a/src/Mod/CAM/Tools/Shape/vbit.fcstd b/src/Mod/CAM/Tools/Shape/vbit.fcstd new file mode 100644 index 0000000000000000000000000000000000000000..16cd0e630a995741888fed1be1444a4a2f104c16 GIT binary patch literal 18822 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 +image/svg+xmlSDαdhL