From 69b1b19024ab79e43ff00fd10116d40a06b43eb6 Mon Sep 17 00:00:00 2001 From: Billy Huddleston Date: Fri, 26 Sep 2025 14:56:08 -0400 Subject: [PATCH] CAM: Refactor shape type handling for aliases and subtypes This PR intruduces a way to dynamicly alias toolbits and adds subtype for toolbits. Prior to this change, aliases were hardcoded in multiple locations, making it difficult to maintain and extend. Now, aliases and the newly subtypes are defined in a single JSON file, which is loaded at runtime. This allows for easier management and addition of new aliases or subtypes without modifying the codebase. Updated the shape system to use a dict format for aliases and subtypes, improved label formatting, and fixed normalization logic for toolbit types. Enhanced dropdown display and editor logic to properly handle subtypes and aliases, including special cases for consecutive capitals. Users can add shape_aliases.json to the toolbit Shape dirctory allowing theme to add/remove entries. Adding a "!" in front of a alias or a subtime removes it from the list. This PR also introduces the ability for the user to choose tools from a libary or all tools when adding tools via the Job panel. src/Mod/CAM/Path/Tool/docobject/ui/docobject.py: - Added keep_case and preserve_consecutive_caps options to _get_label_text for improved label formatting src/Mod/CAM/Path/Tool/shape/__init__.py: - Refactored alias/subtype loading to support new dict format - Applied aliases and subtypes to shape classes src/Mod/CAM/Path/Tool/shape/models/base.py: - Added subtypes attribute to ToolBitShape - Updated class lookup to support subtypes src/Mod/CAM/Path/Tool/toolbit/models/base.py: - Improved normalization logic for aliases and subtypes in ToolBit.from_dict src/Mod/CAM/Path/Tool/toolbit/ui/editor.py: - Updated editor logic to use subtypes and aliases for dropdowns - Improved read-only and editable state handling src/Mod/CAM/Path/Tool/toolbit/ui/typefilter.py: - Used _get_label_text for dropdown filter display with improved formatting src/Mod/CAM/Tools/Shape/shape_aliases.json: - Converted to new dict format with separate aliases and subtypes --- src/Mod/CAM/CMakeLists.txt | 2 + src/Mod/CAM/Path/Main/Gui/Job.py | 31 ++- .../CAM/Path/Tool/docobject/ui/docobject.py | 20 +- src/Mod/CAM/Path/Tool/library/ui/browser.py | 120 +++------- src/Mod/CAM/Path/Tool/library/ui/editor.py | 33 ++- src/Mod/CAM/Path/Tool/shape/__init__.py | 98 ++++++++ src/Mod/CAM/Path/Tool/shape/models/ballend.py | 1 - src/Mod/CAM/Path/Tool/shape/models/base.py | 14 +- .../CAM/Path/Tool/shape/models/bullnose.py | 1 - src/Mod/CAM/Path/Tool/shape/models/chamfer.py | 1 - src/Mod/CAM/Path/Tool/shape/models/custom.py | 1 - .../CAM/Path/Tool/shape/models/dovetail.py | 1 - src/Mod/CAM/Path/Tool/shape/models/drill.py | 1 - src/Mod/CAM/Path/Tool/shape/models/endmill.py | 1 - src/Mod/CAM/Path/Tool/shape/models/probe.py | 1 - src/Mod/CAM/Path/Tool/shape/models/radius.py | 4 - src/Mod/CAM/Path/Tool/shape/models/reamer.py | 1 - .../CAM/Path/Tool/shape/models/slittingsaw.py | 1 - src/Mod/CAM/Path/Tool/shape/models/tap.py | 1 - .../Path/Tool/shape/models/taperedballnose.py | 1 - .../CAM/Path/Tool/shape/models/threadmill.py | 1 - src/Mod/CAM/Path/Tool/shape/models/vbit.py | 1 - .../CAM/Path/Tool/toolbit/mixins/cutting.py | 3 +- .../CAM/Path/Tool/toolbit/models/ballend.py | 9 +- src/Mod/CAM/Path/Tool/toolbit/models/base.py | 38 +++- .../CAM/Path/Tool/toolbit/models/bullnose.py | 12 +- .../CAM/Path/Tool/toolbit/models/chamfer.py | 9 +- .../CAM/Path/Tool/toolbit/models/custom.py | 7 +- .../CAM/Path/Tool/toolbit/models/dovetail.py | 9 +- src/Mod/CAM/Path/Tool/toolbit/models/drill.py | 9 +- .../CAM/Path/Tool/toolbit/models/endmill.py | 9 +- src/Mod/CAM/Path/Tool/toolbit/models/probe.py | 7 +- .../CAM/Path/Tool/toolbit/models/radius.py | 9 +- .../CAM/Path/Tool/toolbit/models/reamer.py | 9 +- .../Path/Tool/toolbit/models/slittingsaw.py | 9 +- src/Mod/CAM/Path/Tool/toolbit/models/tap.py | 9 +- .../Tool/toolbit/models/taperedballnose.py | 14 +- .../Path/Tool/toolbit/models/threadmill.py | 11 +- src/Mod/CAM/Path/Tool/toolbit/models/vbit.py | 9 +- src/Mod/CAM/Path/Tool/toolbit/ui/browser.py | 1 + src/Mod/CAM/Path/Tool/toolbit/ui/editor.py | 82 ++++++- src/Mod/CAM/Path/Tool/toolbit/ui/selector.py | 74 ++++-- .../CAM/Path/Tool/toolbit/ui/typefilter.py | 210 ++++++++++++++++++ src/Mod/CAM/Tools/Shape/shape_aliases.json | 107 +++++++++ 44 files changed, 801 insertions(+), 191 deletions(-) create mode 100644 src/Mod/CAM/Path/Tool/toolbit/ui/typefilter.py create mode 100644 src/Mod/CAM/Tools/Shape/shape_aliases.json diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 2eea12e0f0..2d87cfdd13 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -235,6 +235,7 @@ SET(PathPythonToolsToolBitUi_SRCS Path/Tool/toolbit/ui/toollist.py Path/Tool/toolbit/ui/util.py Path/Tool/toolbit/ui/view.py + Path/Tool/toolbit/ui/typefilter.py ) SET(PathPythonToolsLibrary_SRCS @@ -496,6 +497,7 @@ SET(Tools_Shape_SRCS Tools/Shape/thread-mill.svg Tools/Shape/v-bit.fcstd Tools/Shape/v-bit.svg + Tools/Shape/shape_aliases.json ) SET(Tests_SRCS diff --git a/src/Mod/CAM/Path/Main/Gui/Job.py b/src/Mod/CAM/Path/Main/Gui/Job.py index 028c2226f8..38878a31a6 100644 --- a/src/Mod/CAM/Path/Main/Gui/Job.py +++ b/src/Mod/CAM/Path/Main/Gui/Job.py @@ -1143,16 +1143,31 @@ class TaskPanel: self.toolControllerSelect() def toolControllerAdd(self): - selector = ToolBitSelector(compact=True) + selector = ToolBitSelector(compact=True, show_all_tools=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=f"TC: {toolbit.label}", tool=toolbit.obj, toolNumber=toolNum - ) - self.obj.Proxy.addToolController(tc) + + toolbits = selector.get_selected_tools() + if not toolbits: + return + + # Get tool numbers mapping (from library or empty for auto-increment) + tool_numbers = selector.get_tool_numbers() + + # Add each selected tool + for toolbit in toolbits: + toolbit.attach_to_doc(FreeCAD.ActiveDocument) + + # Get tool number: use library number if available, otherwise auto-increment + toolbit_uri = str(toolbit.get_uri()) + toolNum = tool_numbers.get(toolbit_uri) + if toolNum is None: + toolNum = self.obj.Proxy.nextToolNumber() + + tc = PathToolControllerGui.Create( + name=f"TC: {toolbit.label}", tool=toolbit.obj, toolNumber=toolNum + ) + self.obj.Proxy.addToolController(tc) FreeCAD.ActiveDocument.recompute() self.updateToolController() diff --git a/src/Mod/CAM/Path/Tool/docobject/ui/docobject.py b/src/Mod/CAM/Path/Tool/docobject/ui/docobject.py index bc58a62691..d01c4f7bfb 100644 --- a/src/Mod/CAM/Path/Tool/docobject/ui/docobject.py +++ b/src/Mod/CAM/Path/Tool/docobject/ui/docobject.py @@ -28,16 +28,24 @@ from PySide import QtGui, QtCore from .property import BasePropertyEditorWidget -def _get_label_text(prop_name): +def _get_label_text(prop_name, keep_case=False, preserve_consecutive_caps=False): """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) + if preserve_consecutive_caps: + s1 = re.sub(r"(?= 0: - self._tool_type_combo.setCurrentIndex(index) - else: - self._tool_type_combo.setCurrentIndex(0) - finally: - self._tool_type_combo.blockSignals(False) - - def _on_tool_type_combo_changed(self, tool_type): - """Handle tool type filter selection change.""" - self._selected_tool_type = tool_type - self._update_list() - class LibraryBrowserWithCombo(LibraryBrowserWidget): """ @@ -608,6 +545,7 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget): store: str = "local", parent=None, compact=True, + show_all_tools=False, ): super().__init__( asset_manager=asset_manager, @@ -616,6 +554,8 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget): compact=compact, ) + self._show_all_tools = show_all_tools + # Move search box into dedicated row to make space for the # library selection combo box layout = self.layout() @@ -642,7 +582,10 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget): return selected_library = cast(Library, self._library_combo.itemData(index)) + + # Handle "All Tools" selection (userData is None) if not selected_library: + self.set_current_library(None) return # Have to refetch the non-shallow library. @@ -655,12 +598,16 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget): if self.current_library: for i in range(self._library_combo.count()): lib = self._library_combo.itemData(i) - if lib.get_uri() == self.current_library.get_uri(): + if lib and lib.get_uri() == self.current_library.get_uri(): self._library_combo.setCurrentIndex(i) return Path.Log.warning( f"Current library {self.current_library.get_uri()} not found in combo box." ) + else: + # current_library is None, select "All Tools" if available + if self._show_all_tools: + self._library_combo.setCurrentIndex(0) def refresh(self): """Reads available libraries and refreshes the combo box and toolbits.""" @@ -669,6 +616,11 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget): self._in_refresh = True try: self._library_combo.clear() + + # Add "All Tools" option if requested + if self._show_all_tools: + self._library_combo.addItem(FreeCAD.Qt.translate("CAM", "All Tools"), userData=None) + for library in sorted(libraries, key=lambda x: natural_sort_key(x.label)): self._library_combo.addItem(library.label, userData=library) finally: @@ -689,7 +641,7 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget): for i in range(self._library_combo.count()): lib = self._library_combo.itemData(i) - if lib.get_uri() == self.current_library.get_uri(): + if lib and self.current_library and lib.get_uri() == self.current_library.get_uri(): self._library_combo.setCurrentIndex(i) break else: diff --git a/src/Mod/CAM/Path/Tool/library/ui/editor.py b/src/Mod/CAM/Path/Tool/library/ui/editor.py index dc62205b22..321c42d3dd 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/library/ui/editor.py @@ -505,13 +505,25 @@ class LibraryEditor(QWidget): new_toolbit = tool_bit_class(shape) new_toolbit.label = FreeCAD.Qt.translate("CAM", "New Toolbit") - # Save the individual toolbit asset first + # Get next available tool number for the library + tool_no = None + if current_library: + tool_no = current_library.get_next_bit_no() + + editor = ToolBitEditor(new_toolbit, tool_no=tool_no) + result = editor.show() + if result != QDialog.Accepted: + return + + if current_library: + tool_no = editor.get_tool_no() + tool_asset_uri = cam_assets.add(new_toolbit) Path.Log.debug(f"_on_add_toolbit_requested: Saved tool with URI: {tool_asset_uri}") # Add the toolbit to the current library if one is selected if current_library: - toolno = current_library.add_bit(new_toolbit) + toolno = current_library.add_bit(new_toolbit, bit_no=tool_no) Path.Log.debug( f"_on_add_toolbit_requested: Added toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()}) " f"to current_library with number {toolno}." @@ -534,6 +546,23 @@ class LibraryEditor(QWidget): setToolBitSchema() # Ensure correct schema is set for the new toolbit self.browser.refresh() + + # Set the filter dropdown to show the new toolbit's type + subtype = new_toolbit.get_subtype() + if subtype: + # If it has a subtype (e.g., "compression", "variable_flute"), select that + filter_value = subtype + else: + # Otherwise select the parent shape type (e.g., "Endmill", "Probe") + filter_value = new_toolbit.get_shape_name() + + # Find and set the filter dropdown by matching the data value + if hasattr(self.browser, "_tool_type_combo"): + for i in range(self.browser._tool_type_combo.count()): + if self.browser._tool_type_combo.itemData(i) == filter_value: + self.browser._tool_type_combo.setCurrentIndex(i) + break + self.browser.select_by_uri([str(new_toolbit.get_uri())]) self._update_button_states() diff --git a/src/Mod/CAM/Path/Tool/shape/__init__.py b/src/Mod/CAM/Path/Tool/shape/__init__.py index 22dca5562a..dea122a43d 100644 --- a/src/Mod/CAM/Path/Tool/shape/__init__.py +++ b/src/Mod/CAM/Path/Tool/shape/__init__.py @@ -2,6 +2,11 @@ # This package aggregates tool bit shape classes. +import os +import json +import Path +from Path.Preferences import getAssetPath, getBuiltinAssetPath + # Import the base class and all concrete shape classes from .models.base import ToolBitShape from .models.ballend import ToolBitShapeBallend @@ -25,6 +30,99 @@ from .models.icon import ( ToolBitShapeSvgIcon, ) +# Paths for alias files +_INTERNAL_ALIASES_FILE = os.path.join(getBuiltinAssetPath(), "Shape", "shape_aliases.json") +_USER_ALIASES_FILE = os.path.join(getAssetPath(), "Tools", "Shape", "shape_aliases.json") + + +def _load_shape_aliases(): + """Load shape aliases from JSON files (internal default merged with user overrides, avoiding duplicates).""" + aliases = {} + + # Always load internal file first + try: + if os.path.isfile(_INTERNAL_ALIASES_FILE): + with open(_INTERNAL_ALIASES_FILE, "r") as f: + aliases = json.load(f) + Path.Log.debug(f"Loaded default shape aliases from {_INTERNAL_ALIASES_FILE}") + else: + Path.Log.warning( + f"Internal shape aliases file not found: {_INTERNAL_ALIASES_FILE}" + ) # Should not happen in installed system + except Exception as e: + Path.Log.error(f"Failed to load internal shape aliases: {e}") + + # Merge user file if it exists + try: + if os.path.isfile(_USER_ALIASES_FILE): + with open(_USER_ALIASES_FILE, "r") as f: + user_aliases = json.load(f) + + # Merge user aliases into the loaded aliases + for shape_name, data in user_aliases.items(): + if shape_name not in aliases: + aliases[shape_name] = {"aliases": [], "subtypes": []} + + if "aliases" in data: + # Use set to avoid duplicates + existing_aliases = set(aliases[shape_name]["aliases"]) + for alias in data["aliases"]: + if alias.startswith("!"): + # Remove alias (strip the ! prefix) + existing_aliases.discard(alias[1:]) + else: + # Add alias + existing_aliases.add(alias) + aliases[shape_name]["aliases"] = sorted(existing_aliases) + if "subtypes" in data: + # Use set to avoid duplicates + existing_subtypes = set(aliases[shape_name]["subtypes"]) + for subtype in data["subtypes"]: + if subtype.startswith("!"): + # Remove subtype (strip the ! prefix) + existing_subtypes.discard(subtype[1:]) + else: + # Add subtype + existing_subtypes.add(subtype) + aliases[shape_name]["subtypes"] = sorted(existing_subtypes) + + Path.Log.info(f"Merged user shape aliases from {_USER_ALIASES_FILE}") + except Exception as e: + Path.Log.error(f"Failed to load/merge user shape aliases: {e}") + + return aliases + + +def _apply_aliases_to_shape_classes(): + """Apply loaded aliases and subtypes to all registered shape classes.""" + config = _load_shape_aliases() + + # Map class names to actual classes + shape_classes = {cls.name: cls for cls in ToolBitShape.__subclasses__()} + + for shape_name, data in config.items(): + if shape_name not in shape_classes: + Path.Log.warning(f"Unknown shape class '{shape_name}' in aliases file") + continue + + shape_class = shape_classes[shape_name] + + # Expect dict format with aliases/subtypes + if not isinstance(data, dict): + Path.Log.warning(f"Invalid data format for {shape_name} - expected dict") + continue + + aliases_list = data.get("aliases", []) + subtypes_list = data.get("subtypes", []) + shape_class.aliases = tuple(sorted(alias.lower() for alias in aliases_list)) + shape_class.subtypes = tuple(sorted(subtype.lower() for subtype in subtypes_list)) + Path.Log.info(f"Applied aliases to {shape_name}: {aliases_list}") + Path.Log.info(f"Applied subtypes to {shape_name}: {subtypes_list}") + + +# Load and apply aliases immediately after imports +_apply_aliases_to_shape_classes() + # A list of the name of each ToolBitShape TOOL_BIT_SHAPE_NAMES = sorted([cls.name for cls in ToolBitShape.__subclasses__()]) diff --git a/src/Mod/CAM/Path/Tool/shape/models/ballend.py b/src/Mod/CAM/Path/Tool/shape/models/ballend.py index 3ccfc6dcda..3abc0bd209 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/ballend.py +++ b/src/Mod/CAM/Path/Tool/shape/models/ballend.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeBallend(ToolBitShape): name: str = "Ballend" - aliases = ("ballend",) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/base.py b/src/Mod/CAM/Path/Tool/shape/models/base.py index 77cfa8c63b..f00461aaa2 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/base.py +++ b/src/Mod/CAM/Path/Tool/shape/models/base.py @@ -68,6 +68,11 @@ class ToolBitShape(Asset): # "v-bit", "vbit", etc. aliases: Tuple[str, ...] = tuple() + # Subtypes are shape variants (e.g., "roughing" for endmill). + # Unlike aliases, subtypes preserve their identity and don't normalize. + # NOTE: Subtypes are set dynamically from the shape_aliases.json file. + subtypes: Tuple[str, ...] = tuple() + def __init__(self, id: str, **kwargs: Any): """ Initialize the shape. @@ -501,7 +506,8 @@ class ToolBitShape(Asset): cls, name: str, default: Optional[Type["ToolBitShape"]] = None ) -> Optional[Type["ToolBitShape"]]: """ - Retrieves a ToolBitShape class by its name or alias. + Retrieves a ToolBitShape class by its name, alias, or subtype. + Note: Both aliases and subtypes return the parent class. """ name = name.lower() for thecls in cls.__subclasses__(): @@ -509,6 +515,7 @@ class ToolBitShape(Asset): thecls.name.lower() == name or thecls.__name__.lower() == name or name in thecls.aliases + or name in thecls.subtypes ): return thecls return default @@ -518,7 +525,7 @@ class ToolBitShape(Asset): cls, name: str, default: Optional[Type["ToolBitShape"]] = None ) -> Optional[Type["ToolBitShape"]]: """ - Retrieves a ToolBitShape class by its name or alias. + Retrieves a ToolBitShape class by its name, alias, or subtype. """ name = name.lower() for thecls in cls.__subclasses__(): @@ -527,6 +534,9 @@ class ToolBitShape(Asset): for alias in thecls.aliases: if alias.lower() in name: return thecls + for subtype in thecls.subtypes: + if subtype.lower() in name: + return thecls return default @classmethod diff --git a/src/Mod/CAM/Path/Tool/shape/models/bullnose.py b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py index 5707a9003b..e23504ce56 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/bullnose.py +++ b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py @@ -36,7 +36,6 @@ class ToolBitShapeBullnose(ToolBitShape): return params name = "Bullnose" - aliases = "bullnose", "torus" @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/chamfer.py b/src/Mod/CAM/Path/Tool/shape/models/chamfer.py index 546972a651..2b80e6d5ea 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/chamfer.py +++ b/src/Mod/CAM/Path/Tool/shape/models/chamfer.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeChamfer(ToolBitShape): name = "Chamfer" - aliases = ("chamfer",) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/custom.py b/src/Mod/CAM/Path/Tool/shape/models/custom.py index 6b7175d4e7..703dfc7613 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/custom.py +++ b/src/Mod/CAM/Path/Tool/shape/models/custom.py @@ -33,7 +33,6 @@ class ToolBitShapeCustom(ToolBitShape): """ name: str = "Custom" - aliases = ("custom",) # Connor: We're going to treat custom tools as normal endmills @classmethod diff --git a/src/Mod/CAM/Path/Tool/shape/models/dovetail.py b/src/Mod/CAM/Path/Tool/shape/models/dovetail.py index 55a633178d..038248698c 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/dovetail.py +++ b/src/Mod/CAM/Path/Tool/shape/models/dovetail.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeDovetail(ToolBitShape): name = "Dovetail" - aliases = ("dovetail",) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/drill.py b/src/Mod/CAM/Path/Tool/shape/models/drill.py index eeb2f86ba6..16318354e5 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/drill.py +++ b/src/Mod/CAM/Path/Tool/shape/models/drill.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeDrill(ToolBitShape): name = "Drill" - aliases = ("drill",) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/endmill.py b/src/Mod/CAM/Path/Tool/shape/models/endmill.py index 05269f3804..12b6c79759 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/endmill.py +++ b/src/Mod/CAM/Path/Tool/shape/models/endmill.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeEndmill(ToolBitShape): name = "Endmill" - aliases = ("endmill",) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/probe.py b/src/Mod/CAM/Path/Tool/shape/models/probe.py index db2c4dbec6..fb883688d2 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/probe.py +++ b/src/Mod/CAM/Path/Tool/shape/models/probe.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeProbe(ToolBitShape): name = "Probe" - aliases = ("probe",) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/radius.py b/src/Mod/CAM/Path/Tool/shape/models/radius.py index 6a9f2afd93..e24040c794 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/radius.py +++ b/src/Mod/CAM/Path/Tool/shape/models/radius.py @@ -28,10 +28,6 @@ from .base import ToolBitShape class ToolBitShapeRadius(ToolBitShape): name = "Radius" - aliases = ( - "radius", - "fillet", - ) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/reamer.py b/src/Mod/CAM/Path/Tool/shape/models/reamer.py index 48b50ba383..d65222ae6b 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/reamer.py +++ b/src/Mod/CAM/Path/Tool/shape/models/reamer.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeReamer(ToolBitShape): name = "Reamer" - aliases = ("reamer",) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py b/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py index 0674829cbf..f70977880c 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py +++ b/src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeSlittingSaw(ToolBitShape): name = "SlittingSaw" - aliases = "slittingsaw", "slitting-saw" @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/tap.py b/src/Mod/CAM/Path/Tool/shape/models/tap.py index 05f65ab8c9..9826d0f053 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/tap.py +++ b/src/Mod/CAM/Path/Tool/shape/models/tap.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeTap(ToolBitShape): name = "Tap" - aliases = ("Tap",) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/taperedballnose.py b/src/Mod/CAM/Path/Tool/shape/models/taperedballnose.py index 80074e75fe..8aa9f393e1 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/taperedballnose.py +++ b/src/Mod/CAM/Path/Tool/shape/models/taperedballnose.py @@ -26,7 +26,6 @@ from .base import ToolBitShape class ToolBitShapeTaperedBallNose(ToolBitShape): name: str = "TaperedBallNose" - aliases = ("taperedballnose",) @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/threadmill.py b/src/Mod/CAM/Path/Tool/shape/models/threadmill.py index ac662cea8f..b858ac257f 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/threadmill.py +++ b/src/Mod/CAM/Path/Tool/shape/models/threadmill.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeThreadMill(ToolBitShape): name = "ThreadMill" - aliases = "threadmill", "thread-mill" @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/shape/models/vbit.py b/src/Mod/CAM/Path/Tool/shape/models/vbit.py index 88e994d82a..ffabf98808 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/vbit.py +++ b/src/Mod/CAM/Path/Tool/shape/models/vbit.py @@ -28,7 +28,6 @@ from .base import ToolBitShape class ToolBitShapeVBit(ToolBitShape): name = "VBit" - aliases = "vbit", "v-bit" @classmethod def schema(cls) -> Mapping[str, Tuple[str, str]]: diff --git a/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py b/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py index 4f99c14222..11a9e1f2d5 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py +++ b/src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py @@ -31,7 +31,8 @@ class CuttingToolMixin: It is used to determine if the tool bit can be used for chip removal. """ - def __init__(self, obj, *args, **kwargs): + def _init_cutting_properties(self, obj): + """Initialize cutting tool properties. Must be called explicitly after obj is created.""" obj.addProperty( "App::PropertyLength", "Chipload", diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py index c79dc83847..fee0262713 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/ballend.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeBallend from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ from .base import ToolBit class ToolBitBallend(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeBallend - def __init__(self, shape: ToolBitShapeBallend, id: str | None = None): + def __init__( + self, shape: ToolBitShapeBallend, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitBallend __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py index a47658839b..08b7292aa3 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -83,7 +83,13 @@ class ToolBit(Asset, ABC): asset_type: str = "toolbit" SHAPE_CLASS: Type[ToolBitShape] # Abstract class attribute - def __init__(self, tool_bit_shape: ToolBitShape, id: Optional[str] = None): + def __init__( + self, + tool_bit_shape: ToolBitShape, + id: Optional[str] = None, + attrs: Optional[Mapping] = None, + ): + super().__init__() Path.Log.track("ToolBit __init__ called") self.id = id if id is not None else str(uuid.uuid4()) self.obj = DetachedDocumentObject() @@ -96,10 +102,14 @@ class ToolBit(Asset, ABC): 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() + # Preserve the original shape-type from attrs (e.g., "compression", "roughing") + # This is what the user selected and should be saved back to disk + # If not provided, default to the class name (e.g., "Endmill") + self._shape_type = attrs.get("shape-type") if attrs else tool_bit_shape.name + def __eq__(self, other): """Compare ToolBit objects based on their unique ID.""" if not isinstance(other, ToolBit): @@ -133,6 +143,14 @@ class ToolBit(Asset, ABC): attrs["shape-type"] = attrs["shape"] shape_type = attrs.get("shape-type") shape_class = ToolBitShape.get_shape_class_from_id(shape_id, shape_type) + + if shape_class and shape_type: + shape_type_lower = shape_type.lower() + # Normalize aliases to canonical name, but preserve subtypes + if shape_type_lower in shape_class.aliases: + attrs["shape-type"] = shape_class.name + # If it's a subtype, keep it as-is (already set in attrs) + if not shape_class: Path.Log.debug( f"Failed to find usable shape for ID '{shape_id}'" @@ -149,7 +167,8 @@ class ToolBit(Asset, ABC): Path.Log.debug(f"ToolBit.from_dict: Shape asset {shape_asset_uri} not found.") # Rely on the fallback below else: - return cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id")) + toolbit = cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id")) + return toolbit # Ending up here means we either could not load the shape asset, # or we are in shallow mode and do not want to load it. @@ -162,7 +181,9 @@ class ToolBit(Asset, ABC): ) # Now that we have a shape, create the toolbit instance. - return cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id")) + toolbit = cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id")) + + return toolbit @classmethod def from_shape( @@ -172,7 +193,7 @@ class ToolBit(Asset, ABC): id: Optional[str] = None, ) -> "ToolBit": selected_toolbit_subclass = cls._find_subclass_for_shape(tool_bit_shape) - toolbit = selected_toolbit_subclass(tool_bit_shape, id=id) + toolbit = selected_toolbit_subclass(tool_bit_shape, id=id, attrs=attrs) toolbit.label = attrs.get("name") or tool_bit_shape.label # Get params and attributes. @@ -1016,3 +1037,10 @@ class ToolBit(Asset, ABC): This mostly exists as a safe-hold for probes, which should never rotate. """ return True + + def get_subtype(self) -> Optional[str]: + """Returns the alias/subtype used to instantiate this toolbit, if any.""" + # Only return the subtype if it differs from the class name + if self._shape_type.lower() != self._tool_bit_shape.name.lower(): + return self._shape_type.lower() + return None diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py index d406202b0e..bbcf6f348b 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeBullnose from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,15 @@ from .base import ToolBit class ToolBitBullnose(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeBullnose - def __init__(self, tool_bit_shape: ToolBitShapeBullnose, id: str | None = None): + def __init__( + self, + tool_bit_shape: ToolBitShapeBullnose, + id: str | None = None, + attrs: Optional[Mapping] = None, + ): Path.Log.track(f"ToolBitBullnose __init__ called with id: {id}") - super().__init__(tool_bit_shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(tool_bit_shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py index 2e7733f6ca..b1117aff41 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeChamfer from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ from .base import ToolBit class ToolBitChamfer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeChamfer - def __init__(self, shape: ToolBitShapeChamfer, id: str | None = None): + def __init__( + self, shape: ToolBitShapeChamfer, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitChamfer __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/custom.py b/src/Mod/CAM/Path/Tool/toolbit/models/custom.py index aa93bae309..ad23c6eb7e 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/custom.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/custom.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeCustom from .base import ToolBit @@ -29,9 +30,11 @@ from .base import ToolBit class ToolBitCustom(ToolBit): SHAPE_CLASS = ToolBitShapeCustom - def __init__(self, shape: ToolBitShapeCustom, id: str | None = None): + def __init__( + self, shape: ToolBitShapeCustom, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitCustom __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) + super().__init__(shape, id=id, attrs=attrs) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py index a35617a4f2..da03900cf7 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeDovetail from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ from .base import ToolBit class ToolBitDovetail(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeDovetail - def __init__(self, shape: ToolBitShapeDovetail, id: str | None = None): + def __init__( + self, shape: ToolBitShapeDovetail, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitDovetail __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/drill.py b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py index 9ed2d3562a..40cf99120a 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/drill.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/drill.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeDrill from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ from .base import ToolBit class ToolBitDrill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeDrill - def __init__(self, shape: ToolBitShapeDrill, id: str | None = None): + def __init__( + self, shape: ToolBitShapeDrill, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitDrill __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py b/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py index 8f2bf0a932..90eb410c37 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/endmill.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeEndmill from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ from .base import ToolBit class ToolBitEndmill(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeEndmill - def __init__(self, shape: ToolBitShapeEndmill, id: str | None = None): + def __init__( + self, shape: ToolBitShapeEndmill, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitEndmill __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/probe.py b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py index f57f1447b8..a6669b3280 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/probe.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/probe.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeProbe from .base import ToolBit @@ -29,9 +30,11 @@ from .base import ToolBit class ToolBitProbe(ToolBit): SHAPE_CLASS = ToolBitShapeProbe - def __init__(self, shape: ToolBitShapeProbe, id: str | None = None): + def __init__( + self, shape: ToolBitShapeProbe, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitProbe __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) + super().__init__(shape, id=id, attrs=attrs) self.obj.SpindleDirection = "None" self.obj.setEditorMode("SpindleDirection", 2) # Read-only diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/radius.py b/src/Mod/CAM/Path/Tool/toolbit/models/radius.py index 9c75a7cb2f..a7f64a163a 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/radius.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/radius.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeRadius from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ from .base import ToolBit class ToolBitRadius(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeRadius - def __init__(self, shape: ToolBitShapeRadius, id: str | None = None): + def __init__( + self, shape: ToolBitShapeRadius, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitRadius __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py index d7190c4994..cee06988f5 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/reamer.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeReamer from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ from .base import ToolBit class ToolBitReamer(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeReamer - def __init__(self, shape: ToolBitShapeReamer, id: str | None = None): + def __init__( + self, shape: ToolBitShapeReamer, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitReamer __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py b/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py index 6931008338..72d0b6bd93 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeSlittingSaw from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ from .base import ToolBit class ToolBitSlittingSaw(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeSlittingSaw - def __init__(self, shape: ToolBitShapeSlittingSaw, id: str | None = None): + def __init__( + self, shape: ToolBitShapeSlittingSaw, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitSlittingSaw __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py index f0586da046..792a8b7404 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/tap.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/tap.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeTap from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -31,10 +32,12 @@ from ..util import is_imperial_pitch class ToolBitTap(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeTap - def __init__(self, shape: ToolBitShapeTap, id: str | None = None): + def __init__( + self, shape: ToolBitShapeTap, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitTap __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/taperedballnose.py b/src/Mod/CAM/Path/Tool/toolbit/models/taperedballnose.py index 2ab1124403..e3927468e3 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/taperedballnose.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/taperedballnose.py @@ -20,6 +20,7 @@ ################################################################################ import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeTaperedBallNose from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -28,10 +29,15 @@ from .base import ToolBit class ToolBitTaperedBallNose(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeTaperedBallNose - def __init__(self, shape: ToolBitShapeTaperedBallNose, id: str | None = None): - Path.Log.track(f"ToolBitTaperedBallNose __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + def __init__( + self, + tool_bit_shape: ToolBitShapeTaperedBallNose, + id: str | None = None, + attrs: Optional[Mapping] = None, + ): + Path.Log.track(f"ToolBitTaperedBallNose __init__ called with id: {id}") + super().__init__(tool_bit_shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py b/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py index da93caf204..41b14f6cb9 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeThreadMill from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ 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) + def __init__( + self, shape: ToolBitShapeThreadMill, id: str | None = None, attrs: Optional[Mapping] = None + ): + Path.Log.track(f"ToolBitThreadmill __init__ called with shape: {shape}, id: {id}") + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py b/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py index 58fc019680..4042ced2e2 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/vbit.py @@ -22,6 +22,7 @@ # *************************************************************************** import FreeCAD import Path +from typing import Optional, Mapping from ...shape import ToolBitShapeVBit from ..mixins import RotaryToolBitMixin, CuttingToolMixin from .base import ToolBit @@ -30,10 +31,12 @@ from .base import ToolBit class ToolBitVBit(ToolBit, CuttingToolMixin, RotaryToolBitMixin): SHAPE_CLASS = ToolBitShapeVBit - def __init__(self, shape: ToolBitShapeVBit, id: str | None = None): + def __init__( + self, shape: ToolBitShapeVBit, id: str | None = None, attrs: Optional[Mapping] = None + ): Path.Log.track(f"ToolBitVBit __init__ called with shape: {shape}, id: {id}") - super().__init__(shape, id=id) - CuttingToolMixin.__init__(self, self.obj) + super().__init__(shape, id=id, attrs=attrs) + self._init_cutting_properties(self.obj) @property def summary(self) -> str: diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py index 604d6e57ae..ad8469e9a5 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py @@ -81,6 +81,7 @@ class ToolBitBrowserWidget(QtGui.QWidget): # UI Elements self._search_edit = QtGui.QLineEdit() self._search_edit.setPlaceholderText("Search toolbits...") + self._search_edit.setClearButtonEnabled(True) # Sorting dropdown self._sort_combo = QtGui.QComboBox() diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py index afa6ee9f97..52233b0155 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py @@ -27,6 +27,8 @@ from typing import Optional from PySide import QtGui, QtCore import FreeCAD import FreeCADGui +import os +from Path.Preferences import getAssetPath from ...shape.ui.shapewidget import ShapeWidget from ...docobject.ui import DocumentObjectEditorWidget from ..models.base import ToolBit @@ -59,6 +61,14 @@ class ToolBitPropertiesWidget(QtGui.QWidget): # UI Elements self._label_edit = QtGui.QLineEdit() + self._toolbit_type_container = QtGui.QWidget() + container_layout = QtGui.QHBoxLayout(self._toolbit_type_container) + container_layout.setContentsMargins(0, 0, 0, 0) + self._toolbit_type_edit = QtGui.QLineEdit() + self._toolbit_type_edit.setReadOnly(True) + self._toolbit_type_combo = QtGui.QComboBox() + container_layout.addWidget(self._toolbit_type_edit) + container_layout.addWidget(self._toolbit_type_combo) self._id_label = QtGui.QLabel() # Read-only ID self._id_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) @@ -74,7 +84,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget): toolbit_group_box = QtGui.QGroupBox(translate("CAM", "Toolbit")) form_layout = QtGui.QFormLayout(toolbit_group_box) form_layout.addRow(translate("CAM", "Label:"), self._label_edit) - # form_layout.addRow(translate("CAM", "ID:"), self._id_label) + form_layout.addRow(translate("CAM", "Toolbit Type:"), self._toolbit_type_container) # Optional tool number edit field. self._tool_no_edit = QtGui.QSpinBox() @@ -112,6 +122,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget): # Connections self._label_edit.editingFinished.connect(self._on_label_changed) self._tool_no_edit.valueChanged.connect(self._on_tool_no_changed) + self._toolbit_type_combo.currentIndexChanged.connect(self._on_toolbit_type_changed) self._property_editor.propertyChanged.connect(self.toolBitChanged) if toolbit: @@ -131,6 +142,14 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._tool_no = value self.toolNoChanged.emit(value) + def _on_toolbit_type_changed(self, index): + """Update the toolbit's type when the combo changes.""" + if self._toolbit: + selected = self._toolbit_type_combo.itemData(index) + self._toolbit._shape_type = selected # Save the user's selection + self._toolbit.obj.ShapeType = selected + self.toolBitChanged.emit() + def load_toolbit(self, toolbit: ToolBit): """Load a ToolBit object into the editor.""" # Set schema based on the toolbit's Units property if available @@ -150,6 +169,10 @@ class ToolBitPropertiesWidget(QtGui.QWidget): # Clear or disable fields if toolbit is invalid self._label_edit.clear() self._label_edit.setEnabled(False) + self._toolbit_type_edit.clear() + self._toolbit_type_edit.hide() + self._toolbit_type_combo.clear() + self._toolbit_type_combo.hide() self._id_label.clear() self._tool_no_edit.clear() self._property_editor.setObject(None) @@ -166,6 +189,63 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._label_edit.setEnabled(True) self._label_edit.setText(self._toolbit.obj.Label) self._id_label.setText(self._toolbit.get_id()) + + # Set toolbit type + shape_class = self._toolbit._tool_bit_shape.__class__ + base = shape_class.name + subtypes = shape_class.subtypes + current_type = ( + self._toolbit._shape_type + if hasattr(self._toolbit, "_shape_type") + else shape_class.name.lower() + ) + shape_dir = os.path.join(getAssetPath(), "Tools", "Shape") + + has_file = ( + bool(current_type) + and current_type != base.lower() + and os.path.isfile(os.path.join(shape_dir, f"{current_type}.fcstd")) + ) + + editable_types = [base.lower()] + for subtype in subtypes: + if not os.path.isfile(os.path.join(shape_dir, f"{subtype}.fcstd")): + editable_types.append(subtype) + + is_readonly = has_file or len(editable_types) <= 1 + + if is_readonly: + # Read-only + raw_text = current_type or base.lower() + display_text = raw_text.replace("_", " ").replace("-", " ").title() + self._toolbit_type_edit.setText(display_text) + self._toolbit_type_edit.show() + self._toolbit_type_combo.hide() + else: + # Editable - populate combo with aliases that don't have independent shape files + self._toolbit_type_combo.clear() + for type_name in editable_types: + display = type_name.replace("_", " ").replace("-", " ").title() + self._toolbit_type_combo.addItem(display, type_name) + + for i in range(self._toolbit_type_combo.count()): + if self._toolbit_type_combo.itemData(i) == current_type: + self._toolbit_type_combo.setCurrentIndex(i) + break + else: + # No match found - check if current_type is the base class name + if self._toolbit_type_combo.count() > 0: + if current_type == base or current_type == base.lower(): + # It's using the class name (e.g., "Endmill") - keep it + # Set to first item in combo for display but don't change _shape_type + self._toolbit_type_combo.setCurrentIndex(0) + else: + # Unknown type - default to first item (class name) + self._toolbit_type_combo.setCurrentIndex(0) + self._toolbit._shape_type = editable_types[0] if editable_types else base + self._toolbit_type_edit.hide() + self._toolbit_type_combo.show() + self._tool_no_edit.setValue(int(self._tool_no or 1)) # Get properties and suffixes diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py b/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py index 83539ba1c7..e99ff7bd9f 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/selector.py @@ -23,32 +23,42 @@ """ToolBit selector dialog.""" -from PySide import QtWidgets +from PySide import QtWidgets, QtGui import FreeCAD from ...camassets import cam_assets from ...toolbit import ToolBit -from .browser import ToolBitBrowserWidget +from ...library.ui.browser import LibraryBrowserWithCombo class ToolBitSelector(QtWidgets.QDialog): """ - A dialog for selecting ToolBits using the ToolBitBrowserWidget. + A dialog for selecting ToolBits using the LibraryBrowserWithCombo. + Can show tools from libraries or all available toolbits. """ def __init__( - self, parent=None, compact=False, button_label=FreeCAD.Qt.translate("CAM", "Add Tool") + self, + parent=None, + compact=False, + button_label=FreeCAD.Qt.translate("CAM", "Add Tool"), + show_all_tools=False, ): super().__init__(parent) - self.setMinimumSize(600, 400) - + self.setMinimumSize(700, 500) self.setWindowTitle(FreeCAD.Qt.translate("CAM", "Select Toolbit")) - self._browser_widget = ToolBitBrowserWidget(cam_assets, compact=compact) + # Use LibraryBrowserWithCombo which handles library selection and "All Tools" option + self._browser_widget = LibraryBrowserWithCombo( + asset_manager=cam_assets, + store="local", + compact=compact, + show_all_tools=show_all_tools, + ) # Create OK and Cancel buttons self._ok_button = QtWidgets.QPushButton(button_label) - self._cancel_button = QtWidgets.QPushButton("Cancel") + self._cancel_button = QtWidgets.QPushButton(FreeCAD.Qt.translate("CAM", "Cancel")) # Connect buttons to their actions self._ok_button.clicked.connect(self.accept) @@ -70,21 +80,47 @@ class ToolBitSelector(QtWidgets.QDialog): self._browser_widget.toolSelected.connect(self._on_tool_selected) self._browser_widget.itemDoubleClicked.connect(self.accept) - self._selected_tool_uri = None + # Initialize the browser + self._browser_widget.refresh() 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) + """Returns the first selected ToolBit object, or None if none selected.""" + tools = self.get_selected_tools() + return tools[0] if tools else None + + def get_selected_tools(self) -> list: + """Returns a list of all selected ToolBit objects.""" + return self._browser_widget.get_selected_bits() + + def get_tool_numbers(self) -> dict: + """ + Returns a dict mapping ToolBit URIs to tool numbers. + If a library is selected, uses library tool numbers. + If \"All Tools\" is selected, returns empty dict (auto-increment). + """ + tool_numbers = {} + + # Only get library numbers if a specific library is selected + if self._browser_widget.current_library: + for toolbit in self.get_selected_tools(): + tool_no = self._browser_widget.get_tool_no_from_current_library(toolbit) + if tool_no is not None: + tool_numbers[str(toolbit.get_uri())] = tool_no + + return tool_numbers # Empty dict means auto-increment for all + + def get_tool_number(self): + """ + Returns the tool number for the first selected tool. + Kept for backward compatibility. + """ + tool_numbers = self.get_tool_numbers() + if tool_numbers: + first_tool = self.get_selected_tool() + if first_tool: + return tool_numbers.get(str(first_tool.get_uri())) return None diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/typefilter.py b/src/Mod/CAM/Path/Tool/toolbit/ui/typefilter.py new file mode 100644 index 0000000000..40018954d7 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/typefilter.py @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 Billy * +# * * +# * This file is part of FreeCAD. * +# * * +# * FreeCAD is free software: you can redistribute it and/or modify it * +# * under the terms of the GNU Lesser General Public License as * +# * published by the Free Software Foundation, either version 2.1 of the * +# * License, or (at your option) any later version. * +# * * +# * FreeCAD is distributed in the hope that it will be useful, but * +# * WITHOUT ANY WARRANTY; without even the implied warranty of * +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * +# * Lesser General Public License for more details. * +# * * +# * You should have received a copy of the GNU Lesser General Public * +# * License along with FreeCAD. If not, see * +# * . * +# * * +# *************************************************************************** + +import FreeCAD +from ...docobject.ui.docobject import _get_label_text + + +class ToolBitTypeFilterMixin: + """ + Mixin providing shared methods for filtering toolbits by type and subtype. + + Classes using this mixin should have: + - _all_assets: list of ToolBit objects + - _tool_type_combo: QComboBox for type selection + - _selected_tool_type: Optional[str] for storing current selection + + Classes must implement: + - _get_assets_for_type_filter() -> list: Returns list of assets to extract types from + - _on_type_filter_changed(): Called when type filter changes + """ + + def _get_available_tool_types(self, assets): + """Get all available tool types and subtypes, grouped for display. + + Args: + assets: List of ToolBit objects to extract types from + + Returns: + List of tuples: (display_text, actual_value) + """ + # Build a mapping: {ParentType: {actual_subtype: display_subtype}} + type_map = {} + for asset in assets: + parent = asset.get_shape_name() # Preserve original case (e.g., "Probe") + subtype = asset.get_subtype() + if subtype: + # Preserve underscores/hyphens but make displayable + subtype_disp = subtype.replace("_", " ").replace("-", " ").title() + type_map.setdefault(parent, {})[subtype] = subtype_disp + else: + type_map.setdefault(parent, {}) + + # Flatten for combo: parent, then indented subtypes + # Return tuples of (display, value) + result = [] + for parent in sorted(type_map): + parent_display = _get_label_text(parent, keep_case=True, preserve_consecutive_caps=True) + result.append((parent_display, parent)) # Parent with formatted display + for subtype_val, subtype_disp in sorted(type_map[parent].items()): + subtype_display = _get_label_text( + subtype_val, keep_case=True, preserve_consecutive_caps=True + ) + result.append( + (f" {subtype_display}", subtype_val) + ) # Indent display, preserve value + return result + + def _get_filtered_assets(self, assets): + """Filter assets by selected type or subtype, showing subtypes under parent. + + Args: + assets: List of ToolBit objects to filter + + Returns: + Filtered list of ToolBit objects + """ + if self._tool_type_combo.currentIndex() == 0: + return assets + + # Get the actual value (not display text) from combo item data + sel_value = self._tool_type_combo.currentData() + if not sel_value: + return assets + + # Check if it's a subtype by looking at all subtypes + all_subtypes = set() + for asset in assets: + subtype = asset.get_subtype() + if subtype: + all_subtypes.add(subtype) + + if sel_value in all_subtypes: + # It's a subtype - filter by exact subtype match + return [a for a in assets if a.get_subtype() == sel_value] + else: + # It's a parent type - show all with this shape name (including those without subtypes) + return [a for a in assets if a.get_shape_name() == sel_value] + + def _update_tool_type_combo(self): + """Update the tool type combo box with available types. + + Calls _get_assets_for_type_filter() to get the list of assets. + """ + current_data = self._tool_type_combo.currentData() # Save current selection by data + self._tool_type_combo.blockSignals(True) + try: + self._tool_type_combo.clear() + self._tool_type_combo.addItem(FreeCAD.Qt.translate("CAM", "All Toolbit Types"), None) + + assets = self._get_assets_for_type_filter() + available_types = self._get_available_tool_types(assets) + for display_text, value in available_types: + self._tool_type_combo.addItem(display_text, value) + + # Restore previous selection if it still exists + if current_data is not None: + for i in range(self._tool_type_combo.count()): + if self._tool_type_combo.itemData(i) == current_data: + self._tool_type_combo.setCurrentIndex(i) + break + else: + self._tool_type_combo.setCurrentIndex(0) + else: + self._tool_type_combo.setCurrentIndex(0) + finally: + self._tool_type_combo.blockSignals(False) + + def _on_tool_type_combo_changed(self, index): + """Handle tool type filter selection change. + + Stores the current selection and calls _on_type_filter_changed() + which should be implemented by the subclass. + """ + # Store both text (for backward compat) and data + self._selected_tool_type = self._tool_type_combo.currentText() + self._on_type_filter_changed() + + def _get_assets_for_type_filter(self): + """Returns the list of assets to use for type filtering. + + Subclasses should override this method to provide their asset list. + Default implementation returns _all_assets if it exists. + """ + return getattr(self, "_all_assets", []) + + def _on_type_filter_changed(self): + """Called when the type filter changes. + + Subclasses should override this to update their UI accordingly. + Default implementation does nothing. + """ + pass + + def _apply_type_and_search_filter(self, assets, search_term=""): + """Apply both type and search filtering to assets. + + Args: + assets: List of ToolBit objects to filter + search_term: Optional search string to filter by (searches label and summary) + + Returns: + Filtered list of ToolBit objects + """ + # First apply type filter + filtered_assets = self._get_filtered_assets(assets) + + # Then apply search filter if provided + if search_term: + search_term_lower = search_term.lower() + search_filtered = [] + for asset in filtered_assets: + if search_term_lower in asset.label.lower(): + search_filtered.append(asset) + continue + if search_term_lower in asset.summary.lower(): + search_filtered.append(asset) + continue + # Also search in tool type + if hasattr(asset, "get_shape_name"): + tool_type = asset.get_shape_name() + if tool_type and search_term_lower in tool_type.lower(): + search_filtered.append(asset) + filtered_assets = search_filtered + + return filtered_assets + + def _refresh_filtered_list(self, tool_list_widget, search_edit, assets): + """Refresh the list widget with filtered assets. + + Args: + tool_list_widget: The QListWidget to update + search_edit: The QLineEdit containing search text + assets: List of ToolBit objects to filter and display + """ + tool_list_widget.clear_list() + search_term = search_edit.text() + filtered_assets = self._apply_type_and_search_filter(assets, search_term) + + for asset in filtered_assets: + tool_list_widget.add_toolbit(asset) diff --git a/src/Mod/CAM/Tools/Shape/shape_aliases.json b/src/Mod/CAM/Tools/Shape/shape_aliases.json new file mode 100644 index 0000000000..06afccc4e4 --- /dev/null +++ b/src/Mod/CAM/Tools/Shape/shape_aliases.json @@ -0,0 +1,107 @@ +{ + "Ballend": { + "aliases": [ + "ballend" + ], + "subtypes": [] + }, + "Bullnose": { + "aliases": [ + "bullnose", + "torus" + ], + "subtypes": [] + }, + "Chamfer": { + "aliases": [ + "chamfer" + ], + "subtypes": [] + }, + "Custom": { + "aliases": [ + "custom" + ], + "subtypes": [] + }, + "Dovetail": { + "aliases": [ + "dovetail" + ], + "subtypes": [] + }, + "Drill": { + "aliases": [ + "drill" + ], + "subtypes": [ + "jobber", + "stub", + "spot", + "center", + "brad_point" + ] + }, + "Endmill": { + "aliases": [ + "endmill" + ], + "subtypes": [] + }, + "Probe": { + "aliases": [ + "probe" + ], + "subtypes": [] + }, + "Radius": { + "aliases": [ + "radius", + "fillet" + ], + "subtypes": [] + }, + "Reamer": { + "aliases": [ + "reamer" + ], + "subtypes": [] + }, + "SlittingSaw": { + "aliases": [ + "slittingsaw", + "slitting-saw", + "slitting_saw" + ], + "subtypes": [] + }, + "Tap": { + "aliases": [ + "tap" + ], + "subtypes": [] + }, + "TaperedBallNose": { + "aliases": [ + "taperballnose", + "conicalballnose" + ], + "subtypes": [] + }, + "ThreadMill": { + "aliases": [ + "threadmill", + "thread-mill", + "thread_mill" + ], + "subtypes": [] + }, + "VBit": { + "aliases": [ + "vbit", + "v-bit", + "v_bit" + ], + "subtypes": [] + } +}