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
This commit is contained in:
Billy Huddleston
2026-02-20 18:48:46 -05:00
parent e3857b11a1
commit 69b1b19024
44 changed files with 801 additions and 191 deletions
+2
View File
@@ -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
+23 -8
View File
@@ -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()
@@ -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"(?<![A-Z])([A-Z][a-z]+)", r" \1", prop_name)
# Skip splitting short capital sequences (e.g., VBit stays VBit)
s2 = re.sub(r"([A-Z]{3,})([A-Z][a-z])", r"\1 \2", s1)
else:
s1 = re.sub(r"([A-Z][a-z]+)", r" \1", prop_name)
# Add space before sequences of capitals (e.g., ID) followed by lowercase
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", s1)
# Add space before sequences of capitals followed by end of string
s3 = re.sub(r"([A-Z]+)$", r" \1", s2)
# Remove leading/trailing spaces and capitalize
return s3.strip().capitalize()
# Remove leading/trailing spaces
result = s3.strip()
if not keep_case:
return result.capitalize()
return result.title()
class DocumentObjectEditorWidget(QtGui.QWidget):
+36 -84
View File
@@ -35,6 +35,7 @@ from ...toolbit import ToolBit
from ...toolbit.ui import ToolBitEditor
from ...toolbit.ui.util import natural_sort_key
from ...toolbit.ui.browser import ToolBitBrowserWidget, ToolBitUriRole
from ...toolbit.ui.typefilter import ToolBitTypeFilterMixin
from ...toolbit.serializers import YamlToolBitSerializer
from ..models.library import Library
@@ -43,7 +44,7 @@ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
class LibraryBrowserWidget(ToolBitBrowserWidget):
class LibraryBrowserWidget(ToolBitBrowserWidget, ToolBitTypeFilterMixin):
"""
A widget to browse, filter, and select Tool Library assets from the
AssetManager, with sorting and batch insertion, using a current library.
@@ -57,6 +58,7 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
store: str = "local",
parent=None,
compact=True,
show_all_tools=False,
):
super().__init__(
asset_manager=asset_manager,
@@ -67,6 +69,7 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
)
self.current_library: Optional[Library] = None
self._selected_tool_type: Optional[str] = None
self._show_all_tools = show_all_tools
self.layout().setContentsMargins(0, 0, 0, 0)
# Add tool type filter combo box to the base widget
@@ -166,7 +169,7 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
except FileNotFoundError:
Path.Log.error(f"Library {library_uri} not found.")
self.current_library = None
self._update_tool_list()
self._reload_assets_from_library()
def get_tool_no_from_current_library(self, toolbit):
"""
@@ -180,79 +183,39 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
def set_current_library(self, library):
"""Sets the current library and updates the tool list."""
self.current_library = library
self._update_tool_list()
self._reload_assets_from_library()
self.current_library_changed.emit()
# Save the selected library to preferences
if library:
Path.Preferences.setLastToolLibrary(str(library.get_uri()))
def _get_available_tool_types(self):
"""Get all available tool types from the current assets."""
tool_types = set()
# Make sure we have assets to work with
if not hasattr(self, "_all_assets") or not self._all_assets:
return []
def _get_assets_for_type_filter(self):
"""Returns the list of assets to use for type filtering."""
return getattr(self, "_all_assets", [])
for asset in self._all_assets:
# Use get_shape_name() method to get the tool type
if hasattr(asset, "get_shape_name"):
tool_type = asset.get_shape_name()
if tool_type:
tool_types.add(tool_type)
def _on_type_filter_changed(self):
"""Called when the type filter changes - updates the list."""
self._refresh_filtered_list(self._tool_list_widget, self._search_edit, self._all_assets)
return sorted(tool_types)
def _get_filtered_assets(self):
"""Get assets filtered by tool type if a specific type is selected."""
if self._tool_type_combo.currentIndex() == 0: # "All Toolbit Types"
return self._all_assets
filtered_assets = []
for asset in self._all_assets:
if hasattr(asset, "get_shape_name"):
tool_type = asset.get_shape_name()
if tool_type == self._selected_tool_type:
filtered_assets.append(asset)
return filtered_assets
def _update_tool_list(self):
"""Updates the tool list based on the current library."""
def _reload_assets_from_library(self):
"""Reloads assets from the current library and refreshes the UI."""
if self.current_library:
self._all_assets = [t for t in self.current_library]
else:
# Fetch all toolbits
# Fetch all toolbits (when no library selected or "All Tools" selected)
all_toolbits = self._asset_manager.fetch(asset_type="toolbit", depth=0)
self._all_assets = cast(List[ToolBit], all_toolbits)
self._sort_assets()
self._tool_list_widget.clear_list()
# Update tool type combo after assets are loaded
if hasattr(self, "_tool_type_combo"):
self._update_tool_type_combo()
self._update_list()
self._refresh_filtered_list(self._tool_list_widget, self._search_edit, self._all_assets)
def _update_list(self):
"""Updates the list widget with filtered assets."""
self._tool_list_widget.clear_list()
filtered_assets = self._get_filtered_assets()
# Apply search filter if there is one
search_term = self._search_edit.text().lower()
if search_term:
search_filtered = []
for asset in filtered_assets:
if search_term in asset.label.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 in tool_type.lower():
search_filtered.append(asset)
filtered_assets = search_filtered
for asset in filtered_assets:
self._tool_list_widget.add_toolbit(asset)
"""Override base class to prevent search highlighting."""
super()._update_list()
# Remove any highlighting by passing empty string
def _add_shortcuts(self):
"""Adds keyboard shortcuts for common actions."""
@@ -356,7 +319,6 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
state = self._get_state()
self.refresh()
self._update_list()
self._set_state(state)
def _on_cut_requested(self):
@@ -571,31 +533,6 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
self._asset_manager.add(library)
self.refresh()
def _update_tool_type_combo(self):
"""Update the tool type combo box with available types."""
current_selection = self._tool_type_combo.currentText()
self._tool_type_combo.blockSignals(True)
try:
self._tool_type_combo.clear()
self._tool_type_combo.addItem(FreeCAD.Qt.translate("CAM", "All Toolbit Types"))
for tool_type in self._get_available_tool_types():
self._tool_type_combo.addItem(tool_type)
# Restore selection if it still exists
index = self._tool_type_combo.findText(current_selection)
if index >= 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:
+31 -2
View File
@@ -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()
+98
View File
@@ -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__()])
@@ -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]]:
+12 -2
View File
@@ -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
@@ -36,7 +36,6 @@ class ToolBitShapeBullnose(ToolBitShape):
return params
name = "Bullnose"
aliases = "bullnose", "torus"
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
@@ -28,7 +28,6 @@ from .base import ToolBitShape
class ToolBitShapeChamfer(ToolBitShape):
name = "Chamfer"
aliases = ("chamfer",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
@@ -33,7 +33,6 @@ class ToolBitShapeCustom(ToolBitShape):
"""
name: str = "Custom"
aliases = ("custom",)
# Connor: We're going to treat custom tools as normal endmills
@classmethod
@@ -28,7 +28,6 @@ from .base import ToolBitShape
class ToolBitShapeDovetail(ToolBitShape):
name = "Dovetail"
aliases = ("dovetail",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
@@ -28,7 +28,6 @@ from .base import ToolBitShape
class ToolBitShapeDrill(ToolBitShape):
name = "Drill"
aliases = ("drill",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
@@ -28,7 +28,6 @@ from .base import ToolBitShape
class ToolBitShapeEndmill(ToolBitShape):
name = "Endmill"
aliases = ("endmill",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
@@ -28,7 +28,6 @@ from .base import ToolBitShape
class ToolBitShapeProbe(ToolBitShape):
name = "Probe"
aliases = ("probe",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
@@ -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]]:
@@ -28,7 +28,6 @@ from .base import ToolBitShape
class ToolBitShapeReamer(ToolBitShape):
name = "Reamer"
aliases = ("reamer",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
@@ -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]]:
@@ -28,7 +28,6 @@ from .base import ToolBitShape
class ToolBitShapeTap(ToolBitShape):
name = "Tap"
aliases = ("Tap",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
@@ -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]]:
@@ -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]]:
@@ -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]]:
@@ -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",
@@ -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:
+33 -5
View File
@@ -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
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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
@@ -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:
@@ -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:
@@ -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:
+6 -3
View File
@@ -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:
@@ -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:
@@ -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:
+6 -3
View File
@@ -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:
@@ -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()
+81 -1
View File
@@ -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
+55 -19
View File
@@ -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
@@ -0,0 +1,210 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 Billy <[email protected]> *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
import 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)
+107
View File
@@ -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": []
}
}