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": [] + } +}