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.
This commit is contained in:
Billy Huddleston
2025-11-18 15:24:33 -05:00
committed by Chris Hennes
parent 1b886ef961
commit b5d97057be
7 changed files with 239 additions and 85 deletions

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"):

View File

@@ -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"):

View File

@@ -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"

View File

@@ -0,0 +1,170 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 Billy Huddleston <billy@ivdc.com> *
# * *
# * 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
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

View File

@@ -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: