From b5d97057be5556155af34a5d4e937113ac92e06f Mon Sep 17 00:00:00 2001 From: Billy Huddleston Date: Tue, 18 Nov 2025 15:24:33 -0500 Subject: [PATCH] CAM: Introduce unified ToolBit parameter migration logic for Bullnose tools - Added new migration system to handle legacy parameter conversion for ToolBit assets and objects. - Implemented ParameterAccessor abstraction for consistent access to dicts and FreeCAD objects. - Centralized migration logic for CornerRadius from TorusRadius or FlatRadius/Diameter, restricted to Bullnose shape-type. - Updated asset and object initialization to use migration logic and filter Bullnose parameters. src/Mod/CAM/Path/Tool/camassets.py: - Integrate new migration logic for ToolBit assets using ParameterAccessor and migrate_parameters. - Ensure shape-type is set before parameter migration; only write changes if migration occurred. src/Mod/CAM/Path/Tool/toolbit/migration.py: - Add ParameterAccessor class and migrate_parameters function for unified migration. - Handle legacy parameter conversion for Bullnose tools. src/Mod/CAM/Path/Tool/toolbit/models/base.py: - Apply migration logic to ToolBit objects on restore. - Filter Bullnose parameters to remove FlatRadius after migration. src/Mod/CAM/Path/Tool/shape/models/bullnose.py: - Add filter_parameters method to remove FlatRadius for Bullnose. src/Mod/CAM/Path/Op/SurfaceSupport.py: - Derive FlatRadius from CornerRadius and Diameter if both are present. src/Mod/CAM/Path/Op/Surface.py: - Remove obsolete setOclCutter method. --- src/Mod/CAM/CMakeLists.txt | 1 + src/Mod/CAM/Path/Op/Surface.py | 65 ------- src/Mod/CAM/Path/Op/SurfaceSupport.py | 4 + src/Mod/CAM/Path/Tool/camassets.py | 53 +++--- .../CAM/Path/Tool/shape/models/bullnose.py | 8 + src/Mod/CAM/Path/Tool/toolbit/migration.py | 170 ++++++++++++++++++ src/Mod/CAM/Path/Tool/toolbit/models/base.py | 23 +++ 7 files changed, 239 insertions(+), 85 deletions(-) create mode 100644 src/Mod/CAM/Path/Tool/toolbit/migration.py diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt index 4950a8d880..290945d182 100644 --- a/src/Mod/CAM/CMakeLists.txt +++ b/src/Mod/CAM/CMakeLists.txt @@ -173,6 +173,7 @@ SET(PathPythonToolsGui_SRCS SET(PathPythonToolsToolBit_SRCS Path/Tool/toolbit/__init__.py Path/Tool/toolbit/util.py + Path/Tool/toolbit/migration.py ) SET(PathPythonToolsToolBitMixins_SRCS diff --git a/src/Mod/CAM/Path/Op/Surface.py b/src/Mod/CAM/Path/Op/Surface.py index 862b3f2db6..98563e394f 100644 --- a/src/Mod/CAM/Path/Op/Surface.py +++ b/src/Mod/CAM/Path/Op/Surface.py @@ -2538,71 +2538,6 @@ class ObjectSurface(PathOp.ObjectOp): del self.useTiltCutter return True - def setOclCutter(self, obj, safe=False): - """setOclCutter(obj) ... Translation function to convert FreeCAD tool definition to OCL formatted tool.""" - # Set cutter details - # https://www.freecad.org/api/dd/dfe/classPath_1_1Tool.html#details - diam_1 = float(obj.ToolController.Tool.Diameter) - lenOfst = ( - obj.ToolController.Tool.LengthOffset - if hasattr(obj.ToolController.Tool, "LengthOffset") - else 0 - ) - FR = ( - obj.ToolController.Tool.FlatRadius - if hasattr(obj.ToolController.Tool, "FlatRadius") - else 0 - ) - CEH = ( - obj.ToolController.Tool.CuttingEdgeHeight - if hasattr(obj.ToolController.Tool, "CuttingEdgeHeight") - else 0 - ) - CEA = ( - obj.ToolController.Tool.CuttingEdgeAngle - if hasattr(obj.ToolController.Tool, "CuttingEdgeAngle") - else 0 - ) - - # Make safeCutter with 2 mm buffer around physical cutter - if safe is True: - diam_1 += 4.0 - if FR != 0.0: - FR += 2.0 - - Path.Log.debug("ToolType: {}".format(obj.ToolController.Tool.ToolType)) - if obj.ToolController.Tool.ToolType == "EndMill": - # Standard End Mill - return ocl.CylCutter(diam_1, (CEH + lenOfst)) - - elif obj.ToolController.Tool.ToolType == "BallEndMill" and FR == 0.0: - # Standard Ball End Mill - # OCL -> BallCutter::BallCutter(diameter, length) - self.useTiltCutter = True - return ocl.BallCutter(diam_1, (diam_1 / 2 + lenOfst)) - - elif obj.ToolController.Tool.ToolType == "BallEndMill" and FR > 0.0: - # Bull Nose or Corner Radius cutter - # Reference: https://www.fine-tools.com/halbstabfraeser.html - # OCL -> BallCutter::BallCutter(diameter, length) - return ocl.BullCutter(diam_1, FR, (CEH + lenOfst)) - - elif obj.ToolController.Tool.ToolType == "Engraver" and FR > 0.0: - # Bull Nose or Corner Radius cutter - # Reference: https://www.fine-tools.com/halbstabfraeser.html - # OCL -> ConeCutter::ConeCutter(diameter, angle, lengthOffset) - return ocl.ConeCutter(diam_1, (CEA / 2), lenOfst) - - elif obj.ToolController.Tool.ToolType == "ChamferMill": - # Bull Nose or Corner Radius cutter - # Reference: https://www.fine-tools.com/halbstabfraeser.html - # OCL -> ConeCutter::ConeCutter(diameter, angle, lengthOffset) - return ocl.ConeCutter(diam_1, (CEA / 2), lenOfst) - else: - # Default to standard end mill - Path.Log.warning("Defaulting cutter to standard end mill.") - return ocl.CylCutter(diam_1, (CEH + lenOfst)) - def _getTransitionLine(self, pdc, p1, p2, obj): """Use an OCL PathDropCutter to generate a safe transition path between two points in the x/y plane.""" diff --git a/src/Mod/CAM/Path/Op/SurfaceSupport.py b/src/Mod/CAM/Path/Op/SurfaceSupport.py index c417d9bb05..0d31e60fff 100644 --- a/src/Mod/CAM/Path/Op/SurfaceSupport.py +++ b/src/Mod/CAM/Path/Op/SurfaceSupport.py @@ -2589,8 +2589,12 @@ class OCL_Tool: return False if hasattr(self.tool, "LengthOffset"): self.lengthOffset = float(self.tool.LengthOffset) + # Derive flatRadius from diameter and cornerRadius if both are present if hasattr(self.tool, "FlatRadius"): self.flatRadius = float(self.tool.FlatRadius) + if hasattr(self.tool, "CornerRadius") and hasattr(self.tool, "Diameter"): + self.cornerRadius = float(self.tool.CornerRadius) + self.flatRadius = (self.diameter / 2.0) - self.cornerRadius if hasattr(self.tool, "CuttingEdgeHeight"): self.cutEdgeHeight = float(self.tool.CuttingEdgeHeight) if hasattr(self.tool, "CuttingEdgeAngle"): diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py index 64bb078ad0..bce9055000 100644 --- a/src/Mod/CAM/Path/Tool/camassets.py +++ b/src/Mod/CAM/Path/Tool/camassets.py @@ -27,6 +27,7 @@ import Path from Path import Preferences from Path.Preferences import addToolPreferenceObserver from .assets import AssetManager, AssetUri, Asset, FileStore +from .toolbit.migration import ParameterAccessor, migrate_parameters if False: Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) @@ -58,28 +59,40 @@ def ensure_toolbits_have_shape_type(asset_manager: AssetManager, store_name: str for uri in toolbit_uris: data = asset_manager.get_raw(uri, store=store_name) attrs = json.loads(data) - if "shape-type" in attrs: - continue + changed = False - shape_id = pathlib.Path( - str(attrs.get("shape", "")) - ).stem # backward compatibility. used to be a filename - if not shape_id: - Path.Log.error(f"ToolBit {uri} missing shape ID") - continue + # --- Step 1: Ensure shape-type exists (migrate if needed) --- + if "shape-type" not in attrs: + shape_id = pathlib.Path( + str(attrs.get("shape", "")) + ).stem # backward compatibility. used to be a filename + if not shape_id: + Path.Log.error(f"ToolBit {uri} missing shape ID") + continue - try: - shape_class = ToolBitShape.get_shape_class_from_id(shape_id) - except Exception as e: - Path.Log.error(f"Failed to load toolbit {uri}: {e}. Skipping") - continue - if not shape_class: - Path.Log.error(f"Toolbit {uri} has no shape-type attribute, and failed to infer it") - continue - attrs["shape-type"] = shape_class.name - Path.Log.info(f"Migrating toolbit {uri}: Adding shape-type attribute '{shape_class.name}'") - data = json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8") - asset_manager.add_raw("toolbit", uri.asset_id, data, store=store_name) + try: + shape_class = ToolBitShape.get_shape_class_from_id(shape_id) + except Exception as e: + Path.Log.error(f"Failed to load toolbit {uri}: {e}. Skipping") + continue + if not shape_class: + Path.Log.error(f"Toolbit {uri} has no shape-type attribute, and failed to infer it") + continue + attrs["shape-type"] = shape_class.name + Path.Log.info( + f"Migrating toolbit {uri}: Adding shape-type attribute '{shape_class.name}'" + ) + changed = True + + # --- Step 2: Migrate legacy parameters (now that shape-type is set) --- + if "parameter" in attrs and isinstance(attrs["parameter"], dict): + if migrate_parameters(ParameterAccessor(attrs)): + changed = True + + # --- Step 3: Write changes if any occurred --- + if changed: + data = json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8") + asset_manager.add_raw("toolbit", uri.asset_id, data, store=store_name) def ensure_toolbit_assets_initialized(asset_manager: AssetManager, store_name: str = "local"): diff --git a/src/Mod/CAM/Path/Tool/shape/models/bullnose.py b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py index 313bdef071..5707a9003b 100644 --- a/src/Mod/CAM/Path/Tool/shape/models/bullnose.py +++ b/src/Mod/CAM/Path/Tool/shape/models/bullnose.py @@ -27,6 +27,14 @@ from .base import ToolBitShape class ToolBitShapeBullnose(ToolBitShape): + @classmethod + def filter_parameters(cls, params: dict) -> dict: + # Remove FlatRadius if present + params = dict(params) # shallow copy + if "FlatRadius" in params: + del params["FlatRadius"] + return params + name = "Bullnose" aliases = "bullnose", "torus" diff --git a/src/Mod/CAM/Path/Tool/toolbit/migration.py b/src/Mod/CAM/Path/Tool/toolbit/migration.py new file mode 100644 index 0000000000..d8597bc113 --- /dev/null +++ b/src/Mod/CAM/Path/Tool/toolbit/migration.py @@ -0,0 +1,170 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 2025 Billy Huddleston * +# * * +# * 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 +import Path +from typing import Dict, Any, Optional, Union + + +class ParameterAccessor: + """ + Unified accessor for dicts and FreeCAD objects for migration logic. + """ + + def __init__(self, target): + self.target = target + self.is_dict = isinstance(target, dict) + + def has(self, key): + if self.is_dict: + # For dicts, check in nested 'parameter' dict + param = self.target.get("parameter", {}) + return key in param if isinstance(param, dict) else False + else: + return key in getattr(self.target, "PropertiesList", []) + + def get(self, key): + if self.is_dict: + # For dicts, get from nested 'parameter' dict + param = self.target.get("parameter", {}) + return param.get(key) if isinstance(param, dict) else None + else: + return self.target.getPropertyByName(key) + + def set(self, key, value): + if self.is_dict: + # For dicts, set in nested 'parameter' dict + if "parameter" not in self.target: + self.target["parameter"] = {} + self.target["parameter"][key] = value + else: + setattr(self.target, key, value) + + def add_property(self, prop_type, key, group, doc): + if self.is_dict: + # For dicts, just set the value + pass # No-op, handled by set() + else: + self.target.addProperty(prop_type, key, group, doc) + + def set_editor_mode(self, key, mode): + if self.is_dict: + pass # No-op + else: + self.target.setEditorMode(key, mode) + + def label(self): + if self.is_dict: + return "toolbit" + else: + return getattr(self.target, "Label", "unknown toolbit") + + def get_shape_type(self): + if self.is_dict: + # For dicts, shape-type is at top level of attrs dict + return self.target.get("shape-type") + else: + # For FreeCAD objects, use ShapeType attribute + return getattr(self.target, "ShapeType", None) + + +if False: + Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) + Path.Log.trackModule(Path.Log.thisModule()) +else: + Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) + + +def migrate_parameters(accessor: ParameterAccessor) -> bool: + """ + Migrates legacy parameters using a unified accessor. + + Currently handles: + - TorusRadius → CornerRadius + - FlatRadius/Diameter → CornerRadius + + Args: + accessor: ParameterAccessor instance wrapping dict or FreeCAD object + + Returns: + True if migration occurred, False otherwise + """ + has_torus = accessor.has("TorusRadius") + has_flat = accessor.has("FlatRadius") + has_diam = accessor.has("Diameter") + has_corner = accessor.has("CornerRadius") + label = accessor.label() + shape_type = accessor.get_shape_type() + + # Only run migration logic if shape type == 'Bullnose' + if shape_type and str(shape_type).lower() == "bullnose": + # Case 1: TorusRadius exists, copy to CornerRadius + if has_torus and not has_corner: + value = accessor.get("TorusRadius") + accessor.add_property( + "App::PropertyLength", + "CornerRadius", + "Shape", + "Corner radius copied from TorusRadius", + ) + accessor.set_editor_mode("CornerRadius", 0) + accessor.set("CornerRadius", value) + Path.Log.info(f"Copied TorusRadius to CornerRadius={value} for {label}") + return True + + # Case 2: FlatRadius and Diameter exist, calculate CornerRadius + if has_flat and has_diam and not has_corner: + try: + diam_raw = accessor.get("Diameter") + flat_raw = accessor.get("FlatRadius") + diameter = FreeCAD.Units.Quantity(diam_raw) + flat_radius = FreeCAD.Units.Quantity(flat_raw) + corner_radius = (float(diameter) / 2.0) - float(flat_radius) + + # Convert to correct unit + if isinstance(diam_raw, str) and diam_raw.strip().endswith("in"): + cr_in = FreeCAD.Units.Quantity(f"{corner_radius} mm").getValueAs("in") + value = f"{float(cr_in):.4f} in" + else: + if isinstance(diam_raw, str) and not diam_raw.strip().endswith("mm"): + cr_mm = FreeCAD.Units.Quantity(f"{corner_radius} in").getValueAs("mm") + value = f"{float(cr_mm):.4f} mm" + else: + value = f"{float(corner_radius):.4f} mm" + + accessor.add_property( + "App::PropertyLength", + "CornerRadius", + "Shape", + "Corner radius migrated from FlatRadius/Diameter", + ) + accessor.set_editor_mode("CornerRadius", 0) + accessor.set("CornerRadius", value) + Path.Log.info(f"Migrated FlatRadius/Diameter to CornerRadius={value} for {label}") + return True + except Exception as e: + Path.Log.error(f"Failed to migrate FlatRadius for toolbit {label}: {e}") + return False + + return False diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py index a672c77b0e..5ff7a713a7 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -39,6 +39,7 @@ from ...assets.asset import Asset from ...camassets import cam_assets from ...shape import ToolBitShape, ToolBitShapeCustom, ToolBitShapeIcon from ..util import to_json, format_value +from ..migration import ParameterAccessor, migrate_parameters ToolBitView = LazyLoader("Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view") @@ -178,6 +179,14 @@ class ToolBit(Asset, ABC): params = attrs.get("parameter", {}) attr = attrs.get("attribute", {}) + # Filter parameters if method exists + if ( + hasattr(tool_bit_shape.__class__, "filter_parameters") + and callable(getattr(tool_bit_shape.__class__, "filter_parameters")) + and isinstance(params, dict) + ): + params = tool_bit_shape.__class__.filter_parameters(params) + # Update parameters. for param_name, param_value in params.items(): tool_bit_shape.set_parameter(param_name, param_value) @@ -509,6 +518,20 @@ class ToolBit(Asset, ABC): Path.Log.debug(f"onDocumentRestored: Attaching ViewProvider for {self.obj.Label}") ToolBitView.ViewProvider(self.obj.ViewObject, "ToolBit") + # Migrate legacy parameters using unified accessor + migrate_parameters(ParameterAccessor(obj)) + + # Filter parameters if method exists (removes FlatRadius from obj) + filter_func = getattr(self._tool_bit_shape.__class__, "filter_parameters", None) + if callable(filter_func): + # Only filter if FlatRadius is present + if "FlatRadius" in self.obj.PropertiesList: + try: + self.obj.removeProperty("FlatRadius") + Path.Log.info(f"Filtered out FlatRadius for {self.obj.Label}") + except Exception as e: + Path.Log.error(f"Failed to remove FlatRadius for {self.obj.Label}: {e}") + # Copy properties from the restored object to the ToolBitShape. for name, item in self._tool_bit_shape.schema().items(): if name in self.obj.PropertiesList: