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:
committed by
Chris Hennes
parent
1b886ef961
commit
b5d97057be
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
170
src/Mod/CAM/Path/Tool/toolbit/migration.py
Normal file
170
src/Mod/CAM/Path/Tool/toolbit/migration.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user