CAM: Remove hardcoded style for Tool Number, Fix TestPathToolBitSerializer

Fix issue with toolshapes
Renamed fillet to radius
Added Tool Type Filter to library
Fix units so that they honor user preference
Remove the QToolBox widget from the Shape Selector page and combine into a single page.
Fix issue with PropertyBag so that CustomPropertyGroups as a string is converted to enum and enums are handled correctly.
Update TestPathPropertyBag test for enum changes.
Update TestPathToolBitListWidget
Update TestPathToolLibrarySerializer to match new LinuxCNC output
Fix LinuxCNC export too handle ALL tool types, use user preferences for units, and include all lcnc fields
This commit is contained in:
Billy
2025-08-24 15:42:56 -04:00
parent 2168e3cd99
commit 81faf7727c
28 changed files with 447 additions and 135 deletions

View File

@@ -25,6 +25,20 @@ import Path.Base.PropertyBag as PathPropertyBag
import CAMTests.PathTestUtils as PathTestUtils
def as_group_list(groups):
"""Normalize CustomPropertyGroups to a list of strings."""
if groups is None:
return []
if isinstance(groups, (list, tuple)):
return list(groups)
if isinstance(groups, str):
return [groups]
try:
return list(groups)
except Exception:
return [str(groups)]
class TestPathPropertyBag(PathTestUtils.PathTestBase):
def setUp(self):
self.doc = FreeCAD.newDocument("test-property-bag")
@@ -37,7 +51,7 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase):
bag = PathPropertyBag.Create()
self.assertTrue(hasattr(bag, "Proxy"))
self.assertEqual(bag.Proxy.getCustomProperties(), [])
self.assertEqual(bag.CustomPropertyGroups, [])
self.assertEqual(as_group_list(bag.CustomPropertyGroups), [])
def test01(self):
"""adding properties to a PropertyBag is tracked properly"""
@@ -48,7 +62,7 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase):
bag.Title = "Madame"
self.assertEqual(bag.Title, "Madame")
self.assertEqual(bag.Proxy.getCustomProperties(), ["Title"])
self.assertEqual(bag.CustomPropertyGroups, ["Address"])
self.assertEqual(as_group_list(bag.CustomPropertyGroups), ["Address"])
def test02(self):
"""refreshCustomPropertyGroups deletes empty groups"""
@@ -59,7 +73,7 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase):
bag.removeProperty("Title")
proxy.refreshCustomPropertyGroups()
self.assertEqual(bag.Proxy.getCustomProperties(), [])
self.assertEqual(bag.CustomPropertyGroups, [])
self.assertEqual(as_group_list(bag.CustomPropertyGroups), [])
def test03(self):
"""refreshCustomPropertyGroups does not delete non-empty groups"""
@@ -72,4 +86,4 @@ class TestPathPropertyBag(PathTestUtils.PathTestBase):
bag.removeProperty("Gender")
proxy.refreshCustomPropertyGroups()
self.assertEqual(bag.Proxy.getCustomProperties(), ["Title"])
self.assertEqual(bag.CustomPropertyGroups, ["Address"])
self.assertEqual(as_group_list(bag.CustomPropertyGroups), ["Address"])

View File

@@ -55,7 +55,7 @@ class TestToolBitListWidget(PathTestWithAssets):
self.assertEqual(cell_widget.tool_no, str(tool_no))
self.assertEqual(cell_widget.upper_text, toolbit.label)
# Assuming the 5mm_Endmill asset has a shape named 'Endmill'
self.assertEqual(cell_widget.lower_text, "5 mm 4-flute endmill, 30 mm cutting edge")
self.assertEqual(cell_widget.lower_text, "5.00 mm 4-flute endmill, 30.00 mm cutting edge")
# Verify URI is stored in item data
stored_uri = item.data(ToolBitUriRole)

View File

@@ -149,7 +149,7 @@ class TestYamlToolBitSerializer(_BaseToolBitSerializerTestCase):
self.assertEqual(data.get("shape"), "endmill.fcstd")
self.assertEqual(data.get("shape-type"), "Endmill")
self.assertEqual(data.get("parameter", {}).get("Diameter"), "4.12 mm")
self.assertEqual(data.get("parameter", {}).get("Length"), "15.0 mm")
self.assertEqual(data.get("parameter", {}).get("Length"), "15.00 mm")
def test_extract_dependencies(self):
"""Test dependency extraction for YAML."""

View File

@@ -144,13 +144,13 @@ class TestLinuxCNCLibrarySerializer(TestPathToolLibrarySerializerBase):
# Verify the content format (basic check)
lines = serialized_data.decode("ascii", "ignore").strip().split("\n")
self.assertEqual(len(lines), 3)
self.assertEqual(lines[0], "T1 P0 D6.000 ;Endmill 6mm")
self.assertEqual(lines[1], "T2 P0 D3.000 ;Endmill 3mm")
self.assertEqual(lines[2], "T3 P0 D5.000 ;Ballend 5mm")
self.assertEqual(lines[0], "T1 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D6.00 I0 J0 Q0 ;Endmill 6mm")
self.assertEqual(lines[1], "T2 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D3.00 I0 J0 Q0 ;Endmill 3mm")
self.assertEqual(lines[2], "T3 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D5.00 I0 J0 Q0 ;Ballend 5mm")
def test_linuxcnc_deserialize_not_implemented(self):
serializer = LinuxCNCSerializer
dummy_data = b"T1 D6.0 ;Endmill 6mm\n"
dummy_data = b"T1 P0 X0 Y0 Z0 A0 B0 C0 U0 V0 W0 D6.00 I0 J0 Q0 ;Endmill 6mm\n"
with self.assertRaises(NotImplementedError):
serializer.deserialize(dummy_data, "dummy_id", {})

View File

@@ -183,7 +183,7 @@ SET(PathPythonToolsToolBitModels_SRCS
Path/Tool/toolbit/models/dovetail.py
Path/Tool/toolbit/models/drill.py
Path/Tool/toolbit/models/endmill.py
Path/Tool/toolbit/models/fillet.py
Path/Tool/toolbit/models/radius.py
Path/Tool/toolbit/models/probe.py
Path/Tool/toolbit/models/reamer.py
Path/Tool/toolbit/models/slittingsaw.py
@@ -264,7 +264,7 @@ SET(PathPythonToolsShapeModels_SRCS
Path/Tool/shape/models/dovetail.py
Path/Tool/shape/models/drill.py
Path/Tool/shape/models/endmill.py
Path/Tool/shape/models/fillet.py
Path/Tool/shape/models/radius.py
Path/Tool/shape/models/icon.py
Path/Tool/shape/models/probe.py
Path/Tool/shape/models/reamer.py
@@ -455,8 +455,8 @@ SET(Tools_Shape_SRCS
Tools/Shape/drill.svg
Tools/Shape/endmill.fcstd
Tools/Shape/endmill.svg
Tools/Shape/fillet.fcstd
Tools/Shape/fillet.svg
Tools/Shape/radius.fcstd
Tools/Shape/radius.svg
Tools/Shape/probe.fcstd
Tools/Shape/probe.svg
Tools/Shape/reamer.fcstd

View File

@@ -17,11 +17,11 @@
<item>
<layout class="QVBoxLayout" name="gridLayout">
<item>
<widget class="QToolBox" name="toolBox">
<property name="currentIndex">
<number>1</number>
<widget class="QScrollArea" name="scrollArea">
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="standardTools">
<widget class="QWidget" name="toolsContainer">
<property name="geometry">
<rect>
<x>0</x>
@@ -30,22 +30,6 @@
<height>487</height>
</rect>
</property>
<attribute name="label">
<string>Standard tools</string>
</attribute>
</widget>
<widget class="QWidget" name="customTools">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>880</width>
<height>487</height>
</rect>
</property>
<attribute name="label">
<string>My tools</string>
</attribute>
</widget>
</widget>
</item>

View File

@@ -150,7 +150,6 @@ class PropertyCreate(object):
self.form.propertyEnum.textChanged.connect(self.updateUI)
def updateUI(self):
typeSet = True
if self.propertyIsEnumeration():
self.form.labelEnum.setEnabled(True)
@@ -239,7 +238,17 @@ class TaskPanel(object):
pass
def _setupProperty(self, i, name):
typ = PathPropertyBag.getPropertyTypeName(self.obj.getTypeIdOfProperty(name))
if name not in self.obj.PropertiesList:
Path.Log.warning(f"Property '{name}' not found in object {self.obj.Name}")
return
prop_type_id = self.obj.getTypeIdOfProperty(name)
try:
typ = PathPropertyBag.getPropertyTypeName(prop_type_id)
except IndexError:
Path.Log.error(
f"Unknown property type id '{prop_type_id}' for property '{name}' in object {self.obj.Name}"
)
return
val = PathUtil.getPropertyValueString(self.obj, name)
info = self.obj.getDocumentationOfProperty(name)

View File

@@ -68,12 +68,14 @@ class PropertyBag(object):
CustomPropertyGroupDefault = "User"
def __init__(self, obj):
obj.addProperty(
"App::PropertyStringList",
self.CustomPropertyGroups,
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of custom property groups"),
)
# Always add as enumeration
if not hasattr(obj, self.CustomPropertyGroups):
obj.addProperty(
"App::PropertyEnumeration",
self.CustomPropertyGroups,
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of custom property groups"),
)
self.onDocumentRestored(obj)
def dumps(self):
@@ -96,15 +98,39 @@ class PropertyBag(object):
def onDocumentRestored(self, obj):
self.obj = obj
obj.setEditorMode(self.CustomPropertyGroups, 2) # hide
cpg = getattr(obj, self.CustomPropertyGroups, None)
# If it's a string list, convert to enum
if isinstance(cpg, list):
vals = cpg
try:
obj.removeProperty(self.CustomPropertyGroups)
except Exception:
pass
obj.addProperty(
"App::PropertyEnumeration",
self.CustomPropertyGroups,
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of custom property groups"),
)
if hasattr(obj, "setEnumerationsOfProperty"):
obj.setEnumerationsOfProperty(self.CustomPropertyGroups, vals)
else:
# Fallback: set the property value directly (may not work in all FreeCAD versions)
setattr(obj, self.CustomPropertyGroups, vals)
if hasattr(obj, "setEditorMode"):
obj.setEditorMode(self.CustomPropertyGroups, 2) # hide
elif hasattr(obj, "getEnumerationsOfProperty"):
if hasattr(obj, "setEditorMode"):
obj.setEditorMode(self.CustomPropertyGroups, 2) # hide
def getCustomProperties(self):
"""getCustomProperties() ... Return a list of all custom properties created in this container."""
return [
p
for p in self.obj.PropertiesList
if self.obj.getGroupOfProperty(p) in self.obj.CustomPropertyGroups
]
"""Return a list of all custom properties created in this container."""
groups = []
if hasattr(self.obj, "getEnumerationsOfProperty"):
groups = list(self.obj.getEnumerationsOfProperty(self.CustomPropertyGroups))
else:
groups = list(getattr(self.obj, self.CustomPropertyGroups, []))
return [p for p in self.obj.PropertiesList if self.obj.getGroupOfProperty(p) in groups]
def addCustomProperty(self, propertyType, name, group=None, desc=None):
"""addCustomProperty(propertyType, name, group=None, desc=None) ... adds a custom property and tracks its group."""
@@ -112,15 +138,23 @@ class PropertyBag(object):
desc = ""
if group is None:
group = self.CustomPropertyGroupDefault
groups = self.obj.CustomPropertyGroups
# Always use enum for groups
if hasattr(self.obj, "getEnumerationsOfProperty"):
groups = list(self.obj.getEnumerationsOfProperty(self.CustomPropertyGroups))
else:
groups = list(getattr(self.obj, self.CustomPropertyGroups, []))
name = self.__sanitizePropertyName(name)
if not re.match("^[A-Za-z0-9_]*$", name):
raise ValueError("Property Name can only contain letters and numbers")
if not group in groups:
if group not in groups:
groups.append(group)
self.obj.CustomPropertyGroups = groups
if hasattr(self.obj, "setEnumerationsOfProperty"):
self.obj.setEnumerationsOfProperty(self.CustomPropertyGroups, groups)
else:
setattr(self.obj, self.CustomPropertyGroups, groups)
self.obj.addProperty(propertyType, name, group, desc)
return name
@@ -129,9 +163,16 @@ class PropertyBag(object):
customGroups = []
for p in self.obj.PropertiesList:
group = self.obj.getGroupOfProperty(p)
if group in self.obj.CustomPropertyGroups and not group in customGroups:
if hasattr(self.obj, "getEnumerationsOfProperty"):
groups = list(self.obj.getEnumerationsOfProperty(self.CustomPropertyGroups))
else:
groups = list(getattr(self.obj, self.CustomPropertyGroups, []))
if group in groups and group not in customGroups:
customGroups.append(group)
self.obj.CustomPropertyGroups = customGroups
if hasattr(self.obj, "setEnumerationsOfProperty"):
self.obj.setEnumerationsOfProperty(self.CustomPropertyGroups, customGroups)
else:
setattr(self.obj, self.CustomPropertyGroups, customGroups)
def Create(name="PropertyBag"):

View File

@@ -78,10 +78,10 @@ def setProperty(obj, prop, value):
"""setProperty(obj, prop, value) ... set the property value of obj's property defined by its canonical name."""
o, attr, name = _getProperty(obj, prop)
if attr is not None and isinstance(value, str):
if isinstance(attr, int):
value = int(value, 0)
elif isinstance(attr, bool):
if isinstance(attr, bool):
value = value.lower() in ["true", "1", "yes", "ok"]
elif isinstance(attr, int):
value = int(value, 0)
if o and name:
setattr(o, name, value)

View File

@@ -50,18 +50,29 @@ class LinuxCNCSerializer(AssetSerializer):
output = io.BytesIO()
for bit_no, bit in sorted(asset._bit_nos.items()):
assert isinstance(bit, ToolBit)
if not isinstance(bit, RotaryToolBitMixin):
Path.Log.warning(
f"Skipping too {bit.label} (bit.id) because it is not a rotary tool"
)
continue
diameter = bit.get_diameter()
# Connor: assert isinstance(bit, ToolBit)
# if not isinstance(bit, RotaryToolBitMixin):
# Path.Log.warning(
# f"Skipping too {bit.label} (bit.id) because it is not a rotary tool"
# )
# continue
# Commenting this out. Why did we skip because it is not a rotary tool?
diameter = bit.get_diameter().getUserPreferred()[0]
pocket = "P0" # TODO: is there a better way?
# Format diameter to one decimal place and remove units
diameter_value = diameter.Value if hasattr(diameter, "Value") else diameter
line = f"T{bit_no} {pocket} D{diameter_value:.3f} ;{bit.label}\n"
# TODO: Strip units by splitting at the first space if diameter is a string
# This is where we need a machine definition so we can export these out correctly
# for a metric or imperial machine
# Using user preferred for now
if hasattr(diameter, "Value"):
diameter_value = diameter.Value
elif isinstance(diameter, str):
diameter_value = diameter.split(" ")[0]
else:
diameter_value = diameter
line = (
f"T{bit_no} {pocket} X0 Y0 Z0 A0 B0 C0 U0 V0 W0 "
f"D{diameter_value} I0 J0 Q0 ;{bit.label}\n"
)
output.write(line.encode("utf-8"))
return output.getvalue()

View File

@@ -65,8 +65,17 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
compact=compact,
)
self.current_library: Optional[Library] = None
self._selected_tool_type: Optional[str] = None
self.layout().setContentsMargins(0, 0, 0, 0)
# Add tool type filter combo box to the base widget
self._tool_type_combo = QtGui.QComboBox()
self._tool_type_combo.setSizePolicy(
QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred
)
self._top_layout.insertWidget(0, self._tool_type_combo, 1)
self._tool_type_combo.currentTextChanged.connect(self._on_tool_type_combo_changed)
self.restore_last_sort_order()
self.load_last_library()
@@ -177,6 +186,35 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
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 []
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)
return sorted(tool_types)
def _get_filtered_assets(self):
"""Get assets filtered by tool type if a specific type is selected."""
if not self._selected_tool_type or self._selected_tool_type == "All Tool 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."""
if self.current_library:
@@ -187,8 +225,34 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
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()
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)
def _add_shortcuts(self):
"""Adds keyboard shortcuts for common actions."""
Path.Log.debug("LibraryBrowserWidget._add_shortcuts: Called.")
@@ -476,6 +540,32 @@ 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("All Tool 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)
self._selected_tool_type = "All Tool Types"
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):
"""
@@ -502,10 +592,15 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget):
self._top_layout.removeWidget(self._search_edit)
layout.insertWidget(1, self._search_edit, 20)
# Library selection combo box
self._library_combo = QtGui.QComboBox()
self._library_combo.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred)
self._top_layout.insertWidget(0, self._library_combo, 1)
self._library_combo.currentIndexChanged.connect(self._on_library_combo_changed)
self._top_layout.removeWidget(self._tool_type_combo)
self._top_layout.insertWidget(1, self._tool_type_combo, 1)
self.current_library_changed.connect(self._on_current_library_changed)
self._in_refresh = False
@@ -554,6 +649,11 @@ class LibraryBrowserWithCombo(LibraryBrowserWidget):
if not libraries:
return
if not self.current_library:
first_library = self._library_combo.itemData(0)
if first_library:
uri = first_library.get_uri()
library = self._asset_manager.get(uri, store=self._store_name, depth=1)
self.set_current_library(library)
self._library_combo.setCurrentIndex(0)
return

View File

@@ -36,7 +36,6 @@ from ...toolbit import ToolBit
from .editor import LibraryEditor
from .browser import LibraryBrowserWithCombo
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
@@ -66,7 +65,7 @@ class ToolBitLibraryDock(object):
self.form_layout.setSpacing(4)
# Create the browser widget
self.browser_widget = LibraryBrowserWidget(asset_manager=cam_assets)
self.browser_widget = LibraryBrowserWithCombo(asset_manager=cam_assets)
self._setup_ui()
@@ -80,8 +79,6 @@ class ToolBitLibraryDock(object):
main_layout.setContentsMargins(4, 4, 4, 4)
main_layout.setSpacing(4)
# Create the browser widget
self.browser_widget = LibraryBrowserWithCombo(asset_manager=cam_assets)
main_layout.addWidget(self.browser_widget)
# Create buttons
@@ -89,11 +86,19 @@ class ToolBitLibraryDock(object):
translate("CAM_ToolBit", "Open Library Editor")
)
self.addToolControllerButton = QtGui.QPushButton(translate("CAM_ToolBit", "Add to Job"))
self.closeButton = QtGui.QPushButton(translate("CAM_ToolBit", "Close"))
# Add buttons to a horizontal layout
button_width = 120
self.libraryEditorOpenButton.setMinimumWidth(button_width)
self.addToolControllerButton.setMinimumWidth(button_width)
self.closeButton.setMinimumWidth(button_width)
# Add buttons to a horizontal layout, right-align Close
button_layout = QtGui.QHBoxLayout()
button_layout.addWidget(self.libraryEditorOpenButton)
button_layout.addWidget(self.addToolControllerButton)
button_layout.addStretch(1)
button_layout.addWidget(self.closeButton)
# Add the button layout to the main layout
main_layout.addLayout(button_layout)
@@ -106,6 +111,7 @@ class ToolBitLibraryDock(object):
self.browser_widget.itemDoubleClicked.connect(self._on_doubleclick)
self.libraryEditorOpenButton.clicked.connect(self._open_editor)
self.addToolControllerButton.clicked.connect(self._add_tool_controller_to_doc)
self.closeButton.clicked.connect(self.form.reject)
# Update the initial state of the UI
self._update_state()

View File

@@ -10,7 +10,7 @@ from .models.custom import ToolBitShapeCustom
from .models.dovetail import ToolBitShapeDovetail
from .models.drill import ToolBitShapeDrill
from .models.endmill import ToolBitShapeEndmill
from .models.fillet import ToolBitShapeFillet
from .models.radius import ToolBitShapeRadius
from .models.probe import ToolBitShapeProbe
from .models.reamer import ToolBitShapeReamer
from .models.slittingsaw import ToolBitShapeSlittingSaw
@@ -36,7 +36,7 @@ __all__ = [
"ToolBitShapeDovetail",
"ToolBitShapeDrill",
"ToolBitShapeEndmill",
"ToolBitShapeFillet",
"ToolBitShapeRadius",
"ToolBitShapeProbe",
"ToolBitShapeReamer",
"ToolBitShapeSlittingSaw",

View File

@@ -34,9 +34,31 @@ class ToolBitShapeCustom(ToolBitShape):
name: str = "Custom"
aliases = ("custom",)
# Connor: We're going to treat custom tools as normal endmills
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {}
return {
"CuttingEdgeHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Diameter"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitToolBitShapeShapeEndMill", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:

View File

@@ -25,23 +25,26 @@ from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeFillet(ToolBitShape):
name = "Fillet"
aliases = ("fillet",)
class ToolBitShapeRadius(ToolBitShape):
name = "Radius"
aliases = (
"radius",
"fillet",
)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CrownHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Crown height"),
"CuttingEdgeHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Diameter"),
"App::PropertyLength",
),
"FilletRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Fillet radius"),
"CuttingRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting radius"),
"App::PropertyLength",
),
"Flutes": (
@@ -60,4 +63,4 @@ class ToolBitShapeFillet(ToolBitShape):
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Filleted Chamfer")
return FreeCAD.Qt.translate("ToolBitShape", "Radius Mill")

View File

@@ -39,7 +39,6 @@ class ShapeSelector:
self.flows = {}
self.update_shapes()
self.form.toolBox.setCurrentIndex(0)
def _add_shape_group(self, toolbox):
if toolbox in self.flows:
@@ -70,8 +69,10 @@ class ShapeSelector:
custom = cam_assets.fetch(asset_type="toolbitshape", store="local")
for shape in custom:
builtin.pop(shape.id, None)
self._add_shapes(self.form.standardTools, builtin.values())
self._add_shapes(self.form.customTools, custom)
# Combine all shapes into a single list
all_shapes = list(builtin.values()) + list(custom)
self._add_shapes(self.form.toolsContainer, all_shapes)
def on_shape_button_clicked(self, shape):
self.shape = shape

View File

@@ -10,7 +10,7 @@ from .models.custom import ToolBitCustom
from .models.dovetail import ToolBitDovetail
from .models.drill import ToolBitDrill
from .models.endmill import ToolBitEndmill
from .models.fillet import ToolBitFillet
from .models.radius import ToolBitRadius
from .models.probe import ToolBitProbe
from .models.reamer import ToolBitReamer
from .models.slittingsaw import ToolBitSlittingSaw
@@ -28,7 +28,7 @@ __all__ = [
"ToolBitDovetail",
"ToolBitDrill",
"ToolBitEndmill",
"ToolBitFillet",
"ToolBitRadius",
"ToolBitProbe",
"ToolBitReamer",
"ToolBitSlittingSaw",

View File

@@ -513,6 +513,7 @@ class ToolBit(Asset, ABC):
self._create_base_properties()
# Transfer property values from the detached object to the real object
self._suppress_visual_update = True
temp_obj.copy_to(self.obj)
# Ensure label is set
@@ -520,6 +521,7 @@ class ToolBit(Asset, ABC):
# Update the visual representation now that it's attached
self._update_tool_properties()
self._suppress_visual_update = False
self._update_visual_representation()
def onChanged(self, obj, prop):
@@ -528,6 +530,9 @@ class ToolBit(Asset, ABC):
if "Restore" in obj.State:
return
if getattr(self, "_suppress_visual_update", False):
return
if hasattr(self, "_in_update") and self._in_update:
Path.Log.debug(f"Skipping onChanged for {obj.Label} due to active update.")
return

View File

@@ -35,3 +35,32 @@ class ToolBitCustom(ToolBit):
@property
def summary(self) -> str:
return FreeCAD.Qt.translate("CAM", "Unknown custom toolbit type")
# Connor: Adding in getters and setters for diameter and length
def get_diameter(self) -> FreeCAD.Units.Quantity:
"""
Get the diameter of the rotary tool bit from the shape.
"""
return self.obj.Diameter
def set_diameter(self, diameter: FreeCAD.Units.Quantity):
"""
Set the diameter of the rotary tool bit on the shape.
"""
if not isinstance(diameter, FreeCAD.Units.Quantity):
raise ValueError("Diameter must be a FreeCAD Units.Quantity")
self.obj.Diameter = diameter
def get_length(self) -> FreeCAD.Units.Quantity:
"""
Get the length of the rotary tool bit from the shape.
"""
return self.obj.Length
def set_length(self, length: FreeCAD.Units.Quantity):
"""
Set the length of the rotary tool bit on the shape.
"""
if not isinstance(length, FreeCAD.Units.Quantity):
raise ValueError("Length must be a FreeCAD Units.Quantity")
self.obj.Length = length

View File

@@ -46,3 +46,32 @@ class ToolBitProbe(ToolBit):
def can_rotate(self) -> bool:
return False
# Connor: Add getters and setters for Diameter and Length
def get_diameter(self) -> FreeCAD.Units.Quantity:
"""
Get the diameter of the rotary tool bit from the shape.
"""
return self.obj.Diameter
def set_diameter(self, diameter: FreeCAD.Units.Quantity):
"""
Set the diameter of the rotary tool bit on the shape.
"""
if not isinstance(diameter, FreeCAD.Units.Quantity):
raise ValueError("Diameter must be a FreeCAD Units.Quantity")
self.obj.Diameter = diameter
def get_length(self) -> FreeCAD.Units.Quantity:
"""
Get the length of the rotary tool bit from the shape.
"""
return self.obj.Length
def set_length(self, length: FreeCAD.Units.Quantity):
"""
Set the length of the rotary tool bit on the shape.
"""
if not isinstance(length, FreeCAD.Units.Quantity):
raise ValueError("Length must be a FreeCAD Units.Quantity")
self.obj.Length = length

View File

@@ -21,25 +21,25 @@
# ***************************************************************************
import FreeCAD
import Path
from ...shape import ToolBitShapeFillet
from ...shape import ToolBitShapeRadius
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitFillet(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeFillet
class ToolBitRadius(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeRadius
def __init__(self, shape: ToolBitShapeFillet, id: str | None = None):
Path.Log.track(f"ToolBitFillet __init__ called with shape: {shape}, id: {id}")
def __init__(self, shape: ToolBitShapeRadius, id: str | None = None):
Path.Log.track(f"ToolBitRadius __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
radius = self.get_property_str("FilletRadius", "?", precision=3)
radius = self.get_property_str("CuttingRadius", "?", precision=3)
flutes = self.get_property("Flutes")
diameter = self.get_property_str("ShankDiameter", "?", precision=3)
return FreeCAD.Qt.translate(
"CAM", f"R{radius} fillet bit, {diameter} shank, {flutes}-flute"
"CAM", f"R{radius} radius mill, {diameter} shank, {flutes}-flute"
)

View File

@@ -66,24 +66,20 @@ class TwoLineTableCell(QtGui.QWidget):
self.vbox.addWidget(self.label_upper)
self.vbox.addWidget(self.label_lower)
style = "color: {}".format(fg_color.name())
self.label_left = QtGui.QLabel()
self.label_left.setMinimumWidth(40)
self.label_left.setTextFormat(QtCore.Qt.RichText)
self.label_left.setAlignment(QtCore.Qt.AlignCenter | QtCore.Qt.AlignVCenter)
self.label_left.setStyleSheet(style)
self.label_left.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
ratio = self.devicePixelRatioF()
self.icon_size = QtCore.QSize(50 * ratio, 60 * ratio)
self.icon_widget = QtGui.QLabel()
style = "color: {}".format(fg_color.name())
self.label_right = QtGui.QLabel()
self.label_right.setMinimumWidth(40)
self.label_right.setTextFormat(QtCore.Qt.RichText)
self.label_right.setAlignment(QtCore.Qt.AlignCenter)
self.label_right.setStyleSheet(style)
self.label_right.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
self.hbox = QtGui.QHBoxLayout()

View File

@@ -34,9 +34,16 @@ def format_value(value: FreeCAD.Units.Quantity | int | float | None, precision:
return None
elif isinstance(value, FreeCAD.Units.Quantity):
if precision is not None:
user_val, _, user_unit = value.getUserPreferred()
if user_unit in ("deg", "°", "degree", "degrees"):
# Remove the last character (degree symbol) and convert to float
try:
deg_val = float(str(user_val)[:-1])
except Exception:
return value.getUserPreferred()[0]
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
formatted_value = f"{value.Value:.{precision}f}".rstrip("0").rstrip(".")
unit = value.getUserPreferred()[2]
return f"{formatted_value} {unit}"
return value.getUserPreferred()[0]
return value.UserString
return str(value)

Binary file not shown.

Binary file not shown.

View File

@@ -5,9 +5,9 @@
viewBox="0 0 210 297"
height="297mm"
width="210mm"
sodipodi:docname="fillet.svg"
sodipodi:docname="radius.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
@@ -26,13 +26,13 @@
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="0.50398562"
inkscape:cx="712.32191"
inkscape:cy="915.70073"
inkscape:window-width="2311"
inkscape:window-height="1509"
inkscape:window-x="1529"
inkscape:window-y="377"
inkscape:zoom="0.41628253"
inkscape:cx="136.92624"
inkscape:cy="599.35256"
inkscape:window-width="1512"
inkscape:window-height="916"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="0"
inkscape:current-layer="svg8" /><defs
id="defs2"><linearGradient
@@ -97,7 +97,11 @@
refY="0"
refX="0"
id="marker4584"
style="overflow:visible"><path
style="overflow:visible"
viewBox="0 0 12.70584107 9.5264135"
markerWidth="12.70584106"
markerHeight="9.5264135"
preserveAspectRatio="xMidYMid"><path
id="path4582"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1"
d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z"
@@ -128,7 +132,11 @@
id="marker3948"
refX="0"
refY="0"
orient="auto"><path
orient="auto"
viewBox="0 0 12.70584107 9.5264135"
markerWidth="12.70584107"
markerHeight="9.5264135"
preserveAspectRatio="xMidYMid"><path
transform="matrix(1.1,0,0,1.1,1.1,0)"
d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1"
@@ -249,7 +257,11 @@
refY="0"
refX="0"
id="marker4856"
style="overflow:visible"><path
style="overflow:visible"
viewBox="0 0 17.77385393 10.15648796"
markerWidth="17.77385393"
markerHeight="10.15648796"
preserveAspectRatio="xMidYMid"><path
id="path4854"
d="M 0,0 5,-5 -12.5,0 5,5 Z"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.00000003pt;stroke-opacity:1"
@@ -329,7 +341,25 @@
y1="117.03271"
x2="136.77219"
y2="117.03271"
gradientUnits="userSpaceOnUse" /></defs><metadata
gradientUnits="userSpaceOnUse" /><marker
orient="auto"
refY="0"
refX="0"
id="marker7593-1"
style="overflow:visible"><path
id="path7591-7"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.625;stroke-linejoin:round;stroke-opacity:1"
d="M 8.7185878,4.0337352 -2.2072895,0.01601326 8.7185884,-4.0017078 c -1.7454984,2.3720609 -1.7354408,5.6174519 -6e-7,8.035443 z"
transform="matrix(1.1,0,0,1.1,1.1,0)" /></marker><marker
orient="auto"
refY="0"
refX="0"
id="marker5072-2"
style="overflow:visible"><path
id="path5070-3"
d="M 0,0 5,-5 -12.5,0 5,5 Z"
style="fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1pt;stroke-opacity:1"
transform="matrix(-0.8,0,0,-0.8,-10,0)" /></marker></defs><metadata
id="metadata5"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><path
@@ -362,7 +392,7 @@
style="fill:none;stroke:#000000;stroke-width:0.743595;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
id="path4542"
d="M 77.321236,58.014043 H 133.53382"
style="fill:none;stroke:#000000;stroke-width:0.682912;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker4186);marker-end:url(#marker4584)" /><path
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker4186);marker-end:url(#marker4584)" /><path
id="path4548"
d="M 41.274623,258.30918 H 166.88345"
style="fill:none;stroke:#000000;stroke-width:0.721845;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker3948);marker-end:url(#marker4328)" /><text
@@ -391,35 +421,46 @@
id="path4538-8"
d="M 38.74436,270.09124 V 164.05264"
style="fill:none;stroke:#000000;stroke-width:0.743595;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
id="path4538-8-9"
d="m 93.265958,250.35549 0,-23.30956"
style="fill:none;stroke:#000000;stroke-width:0.743595;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="cc" /><path
id="path4538-8-9-3"
d="M 114.22859,250.35549 V 227.04593"
style="fill:none;stroke:#000000;stroke-width:0.743595;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="cc" /><path
id="path4548-6"
d="m 175.2226,160.40007 h 18.38097"
style="fill:none;stroke:#000000;stroke-width:0.592962;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
d="m 118.72725,223.34176 74.87632,0"
style="fill:none;stroke:#000000;stroke-width:0.592962;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="cc" /><path
id="path4548-6-8"
d="m 132.26064,181.33547 39.1751,40.80318"
style="fill:none;stroke:#000000;stroke-width:0.757536;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker4186)"
d="m 132.26064,182.39381 39.1751,40.80318"
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker4186)"
sodipodi:nodetypes="cc" /><path
id="sesr3u8"
d="M 184.26391,158.38661 V 136.96645"
style="fill:none;stroke:#000000;stroke-width:0.66145833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker7593);marker-end:url(#marker5072)" /><text
d="m 184.26391,221.22509 0,-84.25864"
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker7593);marker-end:url(#marker4584)"
sodipodi:nodetypes="cc" /><text
transform="scale(0.97096033,1.0299082)"
id="cutting_edge_height"
y="123.2775"
x="180.79047"
y="180.48601"
x="191.77032"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;line-height:1.25;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.28362"
xml:space="preserve"><tspan
y="123.2775"
x="180.79047"
y="180.48601"
x="191.77032"
id="tspan7855"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal">h</tspan></text><path
id="path936"
d="M 21.764217,82.083367 H 72.731009"
style="fill:none;stroke:#000000;stroke-width:0.987384;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
id="path938"
d="m 19.64521,223.34176 h 83.99998"
style="fill:none;stroke:#000000;stroke-width:0.757536;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><path
d="m 21.764217,223.34176 66.827826,0"
style="fill:none;stroke:#000000;stroke-width:0.757536;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="cc" /><path
id="path940"
d="M 28.028504,219.65219 V 84.84232"
style="fill:none;stroke:#000000;stroke-width:1.17078;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker7593);marker-end:url(#marker5072)" /><text
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker7593);marker-end:url(#marker4584)" /><text
transform="scale(0.97096033,1.0299082)"
id="length"
y="153.26979"
@@ -435,11 +476,25 @@
style="fill:none;stroke:#000000;stroke-width:0.592962;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /><text
transform="scale(0.97096033,1.0299082)"
id="cutting_edge_height-7"
y="217.33482"
x="144.21545"
y="195.87868"
x="156.38969"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;line-height:1.25;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.28362"
xml:space="preserve"><tspan
y="217.33482"
x="144.21545"
y="195.87868"
x="156.38969"
id="tspan7855-5"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal">r</tspan></text></svg>
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:28.2222px;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal">r</tspan></text><text
transform="scale(0.97096033,1.0299082)"
id="diameter-9"
y="244.40749"
x="70.96611"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32.286px;line-height:1.25;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.02681"
xml:space="preserve"><tspan
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:32.286px;font-family:'URW Bookman L';-inkscape-font-specification:'URW Bookman L, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:3.02681"
y="244.40749"
x="70.96611"
id="tspan5690-9">d</tspan></text><path
id="sesr3u8-6"
d="m 112.39408,239.97586 -17.090908,0"
style="fill:none;stroke:#000000;stroke-width:0.65;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;marker-start:url(#marker3948);marker-end:url(#marker4584)"
sodipodi:nodetypes="cc" /></svg>

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.