From 5085a286bd9370fdaf2af3cb7b454c485cef1a0b Mon Sep 17 00:00:00 2001 From: Billy Huddleston Date: Mon, 24 Nov 2025 16:47:26 -0500 Subject: [PATCH] CAM: Add Units (Metric/Imperial) property to ToolBit This PR allows each Toolbit to have its own Units property (Metric/Imperial) for better unit management. Short Summary: - Adds a Units property (Metric/Imperial) to ToolBit objects for better unit management. - Ensures ToolBit schema is set and restored properly in the editor and utilities. - Updates formatting and property handling to respect the selected units. - Improves the ToolBit editor widget to refresh and sync schema/UI when units change. - Uses FreeCAD.Units.setSchema and getSchema to switch between unit schemas. NOTE: Toolbit dimensions are read from JSON in their native units (as specified by the Units property), converted to metric for all internal calculations, and displayed in the UI using the toolbit's selected units. This ensures both accurate internal computation and user-friendly display, while storing the correct units in the JSON. This can cause some rounding differences when switching units. Example: 2 mm becomes 0.0787 inches. If you save that as imperial and then switch back to metric, it will show 1.9999 mm src/Mod/CAM/Path/Tool/toolbit/models/base.py: - Add Units property to ToolBit and ensure it's set and synced with toolbit shape parameters. - Update property creation and value formatting to use Units. src/Mod/CAM/Path/Tool/toolbit/ui/editor.py: - Use setToolBitSchema to set schema based on toolbit units. - Add logic to refresh property editor widget when units change. - Restore original schema on close. - Improve docstrings and signal handling. src/Mod/CAM/Path/Tool/toolbit/util.py: - Add setToolBitSchema function for robust schema switching. - Update format_value to use schema and units. - Add docstrings and clarify formatting logic. src/Mod/CAM/Path/Tool/library/ui/browser.py: - Restore original schema after editing toolbit. src/Mod/CAM/Path/Tool/library/ui/editor.py: - Ensure correct schema is set for new toolbits. --- src/Mod/CAM/Path/Tool/library/ui/browser.py | 1 + src/Mod/CAM/Path/Tool/library/ui/editor.py | 2 + src/Mod/CAM/Path/Tool/toolbit/models/base.py | 40 ++++++++- src/Mod/CAM/Path/Tool/toolbit/ui/editor.py | 94 ++++++++++++++++---- src/Mod/CAM/Path/Tool/toolbit/util.py | 51 ++++++++++- 5 files changed, 168 insertions(+), 20 deletions(-) diff --git a/src/Mod/CAM/Path/Tool/library/ui/browser.py b/src/Mod/CAM/Path/Tool/library/ui/browser.py index c9c1df2394..1be151b7ad 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/browser.py +++ b/src/Mod/CAM/Path/Tool/library/ui/browser.py @@ -347,6 +347,7 @@ class LibraryBrowserWidget(ToolBitBrowserWidget): # If the editor was closed with "OK", save the changes self._asset_manager.add(toolbit) Path.Log.info(f"Toolbit {toolbit.get_id()} saved.") + editor._restore_original_schema() # Also save the library because the tool number may have changed. if self.current_library and tool_no != editor.tool_no: diff --git a/src/Mod/CAM/Path/Tool/library/ui/editor.py b/src/Mod/CAM/Path/Tool/library/ui/editor.py index 205a7a4be9..dc62205b22 100644 --- a/src/Mod/CAM/Path/Tool/library/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/library/ui/editor.py @@ -46,6 +46,7 @@ from ...toolbit.serializers import all_serializers as toolbit_serializers from ...toolbit.ui import ToolBitEditor from ...toolbit.ui.toollist import ToolBitUriListMimeType from ...toolbit.ui.util import natural_sort_key +from ...toolbit.util import setToolBitSchema from ..serializers import all_serializers as library_serializers from ..models import Library from .browser import LibraryBrowserWidget @@ -531,6 +532,7 @@ class LibraryEditor(QWidget): ) raise + setToolBitSchema() # Ensure correct schema is set for the new toolbit self.browser.refresh() self.browser.select_by_uri([str(new_toolbit.get_uri())]) self._update_button_states() diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py index 5ff7a713a7..422672541f 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -307,6 +307,16 @@ class ToolBit(Asset, ABC): self.obj.setEditorMode("Shape", 2) # Create the ToolBit properties that are shared by all tool bits + + if not hasattr(self.obj, "Units"): + self.obj.addProperty( + "App::PropertyEnumeration", + "Units", + "Attributes", + QT_TRANSLATE_NOOP("App::Property", "Measurement units for the tool bit"), + ) + self.obj.Units = ["Metric", "Imperial"] + self.obj.Units = "Metric" # Default value if not hasattr(self.obj, "SpindleDirection"): self.obj.addProperty( "App::PropertyEnumeration", @@ -670,7 +680,7 @@ class ToolBit(Asset, ABC): self, name: str, default: str | None = None, precision: int | None = None ) -> str | None: value = self.get_property(name) - return format_value(value, precision=precision) if value else default + return format_value(value, precision=precision, units=self.obj.Units) if value else default def set_property(self, name: str, value: Any): return self.obj.setPropertyByName(name, value) @@ -781,7 +791,23 @@ class ToolBit(Asset, ABC): PathUtil.setProperty(self.obj, name, value) self.obj.setEditorMode(name, 0) - # 3. Ensure SpindleDirection property exists and is set + # 3. Ensure Units property exists and is set + if not hasattr(self.obj, "Units"): + print("Adding Units property") + self.obj.addProperty( + "App::PropertyEnumeration", + "Units", + "Attributes", + QT_TRANSLATE_NOOP("App::Property", "Measurement units for the tool bit"), + ) + self.obj.Units = ["Metric", "Imperial"] + self.obj.Units = "Metric" # Default value + + units_value = self._tool_bit_shape.get_parameters().get("Units") + if units_value in ("Metric", "Imperial") and self.obj.Units != units_value: + PathUtil.setProperty(self.obj, "Units", units_value) + + # 4. Ensure SpindleDirection property exists and is set # Maybe this could be done with a global schema or added to each # shape schema? if not hasattr(self.obj, "SpindleDirection"): @@ -802,7 +828,7 @@ class ToolBit(Asset, ABC): # self.obj.SpindleDirection = spindle_value PathUtil.setProperty(self.obj, "SpindleDirection", spindle_value) - # 4. Ensure Material property exists and is set + # 5. Ensure Material property exists and is set if not hasattr(self.obj, "Material"): self.obj.addProperty( "App::PropertyEnumeration", @@ -959,6 +985,14 @@ class ToolBit(Asset, ABC): return state def get_spindle_direction(self) -> toolchange.SpindleDirection: + """ + Returns the spindle direction for this toolbit. + The direction is determined by the ToolBit's properties and safety rules: + - Returns SpindleDirection.OFF if the tool cannot rotate (e.g., a probe). + - Returns SpindleDirection.CW for clockwise or 'forward' spindle direction. + - Returns SpindleDirection.CCW for counterclockwise or any other value. + - Defaults to SpindleDirection.OFF if not specified. + """ # To be safe, never allow non-rotatable shapes (such as probes) to rotate. if not self.can_rotate(): return toolchange.SpindleDirection.OFF diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py index fff49e6eb7..11ed760bf1 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py +++ b/src/Mod/CAM/Path/Tool/toolbit/ui/editor.py @@ -30,6 +30,7 @@ import FreeCADGui from ...shape.ui.shapewidget import ShapeWidget from ...docobject.ui import DocumentObjectEditorWidget from ..models.base import ToolBit +from ..util import setToolBitSchema translate = FreeCAD.Qt.translate @@ -54,16 +55,7 @@ class ToolBitPropertiesWidget(QtGui.QWidget): self._toolbit = None self._show_shape = icon self._tool_no = tool_no - - # Set schema to user preference if no document is open - # TODO: Add a preference for toolbit unit schema. - # We probably want to look at making it possible to have a toolbit be metric - # or imperial regardless of document settings / or user preferences, but for now this is sufficient. - if FreeCAD.ActiveDocument is None: - pref_schema = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Units").GetInt( - "UserSchema", 0 - ) - FreeCAD.Units.setSchema(pref_schema) + setToolBitSchema() # UI Elements self._label_edit = QtGui.QLineEdit() @@ -82,7 +74,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", "ID:"), self._id_label) # Optional tool number edit field. self._tool_no_edit = QtGui.QSpinBox() @@ -143,6 +135,18 @@ class ToolBitPropertiesWidget(QtGui.QWidget): def load_toolbit(self, toolbit: ToolBit): """Load a ToolBit object into the editor.""" + # Set schema based on the toolbit's Units property if available + toolbit_units = None + if toolbit and hasattr(toolbit.obj, "Units"): + toolbit_units = getattr(toolbit.obj, "Units", None) + # If Units is an enumeration, get the value + if isinstance(toolbit_units, (list, tuple)) and len(toolbit_units) > 0: + toolbit_units = toolbit_units[0] + if toolbit_units in ("Metric", "Imperial"): + setToolBitSchema(toolbit_units) + elif FreeCAD.ActiveDocument is None: + setToolBitSchema() + self._toolbit = toolbit if not self._toolbit or not self._toolbit.obj: # Clear or disable fields if toolbit is invalid @@ -266,16 +270,24 @@ class ToolBitEditor(QtGui.QWidget): self.tool_no = tool_no self.default_title = self.form.windowTitle() + # Store the original schema to restore on close + self._original_schema = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Units").GetInt( + "UserSchema", 6 + ) + self._tab_closed = False + # Get first tab from the form, add the shape widget at the top. tool_tab_layout = self.form.toolTabLayout widget = ShapeWidget(toolbit._tool_bit_shape) tool_tab_layout.addWidget(widget) # Add tool properties editor to the same tab. - props = ToolBitPropertiesWidget(toolbit, tool_no, self, icon=icon) - props.toolBitChanged.connect(self._update) - props.toolNoChanged.connect(self._on_tool_no_changed) - tool_tab_layout.addWidget(props) + self._props = ToolBitPropertiesWidget(toolbit, tool_no, self, icon=icon) + self._last_units_value = self._get_units_value(self._props) + self._props.toolBitChanged.connect(self._on_toolbit_changed) + self._props.toolBitChanged.connect(self._update) + self._props.toolNoChanged.connect(self._on_tool_no_changed) + tool_tab_layout.addWidget(self._props) self.form.tabWidget.setCurrentIndex(0) self.form.tabWidget.currentChanged.connect(self._on_tab_switched) @@ -307,6 +319,58 @@ class ToolBitEditor(QtGui.QWidget): self.form.plainTextEditNotes.textChanged.connect(self._on_notes_changed) """ + self._update() + + def _get_units_value(self, props): + """ + Helper to extract the Units value from the toolbit properties. + """ + if props and hasattr(props._toolbit.obj, "Units"): + units_value = getattr(props._toolbit.obj, "Units", None) + if isinstance(units_value, (list, tuple)) and len(units_value) > 0: + units_value = units_value[0] + return units_value + return None + + def _on_toolbit_changed(self): + """ + Slot called when the toolbit is changed. If the Units value has changed, + refreshes the property editor widget to update the schema and UI. + """ + units_value = self._get_units_value(self._props) + if units_value in ("Metric", "Imperial") and units_value != self._last_units_value: + self._refresh_property_editor() + self._last_units_value = units_value + + def _refresh_property_editor(self): + """ + Refreshes the property editor widget in the tab. + Removes the current ToolBitPropertiesWidget, restores the original units schema, + recreates the widget, and reconnects all signals. This ensures the UI and schema + are in sync with the current toolbit's units, and user changes are preserved + because the ToolBit object is always up to date. + """ + # Remove the current property editor widget + tool_tab_layout = self.form.toolTabLayout + tool_tab_layout.removeWidget(self._props) + self._props.deleteLater() + # Restore the original schema + FreeCAD.Units.setSchema(self._original_schema) + # Recreate the property editor with the current toolbit + self._props = ToolBitPropertiesWidget(self.toolbit, self.tool_no, self, icon=False) + self._last_units_value = self._get_units_value(self._props) + self._props.toolBitChanged.connect(self._on_toolbit_changed) + self._props.toolBitChanged.connect(self._update) + self._props.toolNoChanged.connect(self._on_tool_no_changed) + tool_tab_layout.addWidget(self._props) + self.form.tabWidget.setCurrentIndex(0) + + def _restore_original_schema(self): + """ + Restores the original units schema that was active before the ToolBit editor was opened. + """ + FreeCAD.Units.setSchema(self._original_schema) + def _update(self): title = self.default_title tool_name = self.toolbit.label diff --git a/src/Mod/CAM/Path/Tool/toolbit/util.py b/src/Mod/CAM/Path/Tool/toolbit/util.py index 280d36bd75..3274275857 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/util.py +++ b/src/Mod/CAM/Path/Tool/toolbit/util.py @@ -30,7 +30,24 @@ def to_json(value): return value -def format_value(value: FreeCAD.Units.Quantity | int | float | None, precision: int | None = None): +def format_value( + value: FreeCAD.Units.Quantity | int | float | None, + precision: int | None = None, + units: str | None = None, +) -> str | None: + """ + Format a numeric value as a string, optionally appending a unit and controlling precision. + + This function uses the ToolBitSchema (via setToolBitSchema) to ensure that units are formatted according to the correct schema (Metric or Imperial) when a FreeCAD.Units.Quantity is provided. The schema is temporarily set for formatting and then restored. + + Args: + value: The numeric value to format. + unit: (Optional) The unit string to append (e.g., 'mm', 'in'). + precision: (Optional) Number of decimal places (default: 3). + + Returns: + str: The formatted value as a string, with unit if provided. + """ if value is None: return None elif isinstance(value, FreeCAD.Units.Quantity): @@ -45,7 +62,10 @@ def format_value(value: FreeCAD.Units.Quantity | int | float | None, precision: formatted_value = f"{deg_val:.1f}".rstrip("0").rstrip(".") return f"{formatted_value}°" # Format the value with the specified number of precision and strip trailing zeros - return value.getUserPreferred()[0] + setToolBitSchema(units) + _value = value.getUserPreferred()[0] + setToolBitSchema() + return _value return value.UserString return str(value) @@ -78,3 +98,30 @@ def is_imperial_pitch(pitch_mm, tol=1e-6): if two_dec_clean and not is_whole_tpi: return False # metric return True # imperial + + +def setToolBitSchema(schema=None): + """ + Set the FreeCAD units schema. If passed 'Metric' or 'Imperial', set accordingly (case-insensitive). + Otherwise, if a document is open, set to its schema. If no document, fallback to user preference or provided schema. + """ + units_schema_map = { + "metric": 6, # 6 = Metric schema in FreeCAD + "imperial": 3, # 3 = Imperial schema in FreeCAD + } + if isinstance(schema, str) and schema.lower() in units_schema_map: + FreeCAD.Units.setSchema(units_schema_map[schema.lower()]) + return + if FreeCAD.ActiveDocument is not None: + try: + doc_schema = FreeCAD.ActiveDocument.getSchema() + FreeCAD.Units.setSchema(doc_schema) + return + except Exception: + pass + # Fallback to user preference or provided schema + if schema is None: + schema = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Units").GetInt( + "UserSchema", 6 + ) + FreeCAD.Units.setSchema(schema)