CAM: Replace complete tool management (PR 21425)

This commit is contained in:
Samuel Abels
2025-05-19 20:25:00 +02:00
parent ecb3ede295
commit b14d8ff98e
169 changed files with 22274 additions and 2905 deletions

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# This package aggregates tool bit shape classes.
# Import the base class and all concrete shape classes
from .models.base import ToolBitShape
from .models.ballend import ToolBitShapeBallend
from .models.chamfer import ToolBitShapeChamfer
from .models.dovetail import ToolBitShapeDovetail
from .models.drill import ToolBitShapeDrill
from .models.endmill import ToolBitShapeEndmill
from .models.fillet import ToolBitShapeFillet
from .models.probe import ToolBitShapeProbe
from .models.reamer import ToolBitShapeReamer
from .models.slittingsaw import ToolBitShapeSlittingSaw
from .models.tap import ToolBitShapeTap
from .models.threadmill import ToolBitShapeThreadMill
from .models.bullnose import ToolBitShapeBullnose
from .models.vbit import ToolBitShapeVBit
from .models.icon import (
ToolBitShapeIcon,
ToolBitShapePngIcon,
ToolBitShapeSvgIcon,
)
# A list of the name of each ToolBitShape
TOOL_BIT_SHAPE_NAMES = sorted([cls.name for cls in ToolBitShape.__subclasses__()])
# Define __all__ for explicit public interface
__all__ = [
"ToolBitShape",
"ToolBitShapeBallend",
"ToolBitShapeChamfer",
"ToolBitShapeDovetail",
"ToolBitShapeDrill",
"ToolBitShapeEndmill",
"ToolBitShapeFillet",
"ToolBitShapeProbe",
"ToolBitShapeReamer",
"ToolBitShapeSlittingSaw",
"ToolBitShapeTap",
"ToolBitShapeThreadMill",
"ToolBitShapeBullnose",
"ToolBitShapeVBit",
"TOOL_BIT_SHAPE_NAMES",
"ToolBitShapeIcon",
"ToolBitShapeSvgIcon",
"ToolBitShapePngIcon",
]

View File

@@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
import Path.Base.Util as PathUtil
from typing import Dict, List, Any, Optional
import tempfile
import os
def find_shape_object(doc: "FreeCAD.Document") -> Optional["FreeCAD.DocumentObject"]:
"""
Find the primary object representing the shape in a document.
Looks for PartDesign::Body, then Part::Feature. Falls back to the first
object if no better candidate is found.
Args:
doc (FreeCAD.Document): The document to search within.
Returns:
Optional[FreeCAD.DocumentObject]: The found object or None.
"""
obj = None
# Prioritize Body
for o in doc.Objects:
if o.isDerivedFrom("PartDesign::Body"):
return o
# Keep track of the first Part::Feature found as a fallback
if obj is None and o.isDerivedFrom("Part::Feature"):
obj = o
if obj:
return obj
# Fallback to the very first object if nothing else suitable found
return doc.Objects[0] if doc.Objects else None
def get_object_properties(
obj: "FreeCAD.DocumentObject", expected_params: List[str]
) -> Dict[str, Any]:
"""
Extract properties matching expected_params from a FreeCAD PropertyBag.
Issues warnings for missing parameters but does not raise an error.
Args:
obj: The PropertyBag to extract properties from.
expected_params (List[str]): A list of property names to look for.
Returns:
Dict[str, Any]: A dictionary mapping property names to their values.
Values are FreeCAD native types.
"""
properties = {}
for name in expected_params:
if hasattr(obj, name):
properties[name] = getattr(obj, name)
else:
# Log a warning if a parameter expected by the shape class is missing
FreeCAD.Console.PrintWarning(
f"Parameter '{name}' not found on object '{obj.Label}' "
f"({obj.Name}). Default value will be used by the shape class.\n"
)
properties[name] = None # Indicate missing value
return properties
def update_shape_object_properties(
obj: "FreeCAD.DocumentObject", parameters: Dict[str, Any]
) -> None:
"""
Update properties of a FreeCAD PropertyBag based on a dictionary of parameters.
Args:
obj (FreeCAD.DocumentObject): The PropertyBag to update properties on.
parameters (Dict[str, Any]): A dictionary of property names and values.
"""
for name, value in parameters.items():
if hasattr(obj, name):
try:
PathUtil.setProperty(obj, name, value)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Failed to set property '{name}' on object '{obj.Label}'"
f" ({obj.Name}) with value '{value}': {e}\n"
)
else:
FreeCAD.Console.PrintWarning(
f"Property '{name}' not found on object '{obj.Label}'" f" ({obj.Name}). Skipping.\n"
)
def get_doc_state() -> Any:
"""
Used to make a "snapshot" of the current state of FreeCAD, to allow
for restoring the ActiveDocument and selection state later.
"""
doc_name = FreeCAD.ActiveDocument.Name if FreeCAD.ActiveDocument else None
if FreeCAD.GuiUp:
import FreeCADGui
selection = FreeCADGui.Selection.getSelection()
else:
selection = []
return doc_name, selection
def restore_doc_state(state):
doc_name, selection = state
if doc_name:
FreeCAD.setActiveDocument(doc_name)
if FreeCAD.GuiUp:
import FreeCADGui
for sel in selection:
FreeCADGui.Selection.addSelection(doc_name, sel.Name)
class ShapeDocFromBytes:
"""
Context manager to create and manage a temporary FreeCAD document,
loading content from a byte string.
"""
def __init__(self, content: bytes):
self._content = content
self._doc = None
self._temp_file = None
self._old_state = None
def __enter__(self) -> "FreeCAD.Document":
"""Creates a new temporary FreeCAD document or loads cache if provided."""
# Create a temporary file and write the cache content to it
with tempfile.NamedTemporaryFile(suffix=".FCStd", delete=False) as tmp_file:
tmp_file.write(self._content)
self._temp_file = tmp_file.name
# When we open a new document, FreeCAD loses the state, of the active
# document (i.e. current selection), even if the newly opened document
# is a hidden one.
# So we need to restore the active document state at the end.
self._old_state = get_doc_state()
# Open the document from the temporary file
# Use a specific name to avoid clashes if multiple docs are open
# Open the document from the temporary file
self._doc = FreeCAD.openDocument(self._temp_file, hidden=True)
if not self._doc:
raise RuntimeError(f"Failed to open document from {self._temp_file}")
return self._doc
def __exit__(self, exc_type, exc_value, traceback) -> None:
"""Closes the temporary FreeCAD document and cleans up the temp file."""
if self._doc:
# Note that .closeDocument() is extremely slow; it takes
# almost 400ms per document - much longer than opening!
FreeCAD.closeDocument(self._doc.Name)
self._doc = None
# Restore the original active document
restore_doc_state(self._old_state)
# Clean up the temporary file if it was created
if self._temp_file and os.path.exists(self._temp_file):
try:
os.remove(self._temp_file)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Failed to remove temporary file {self._temp_file}: {e}\n"
)

View File

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeBallend(ToolBitShape):
name: str = "Ballend"
aliases = ("ballend",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Ballend")

View File

@@ -0,0 +1,630 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import pathlib
import FreeCAD
import Path
import os
from typing import Dict, List, Any, Mapping, Optional, Tuple, Type, cast
import zipfile
import xml.etree.ElementTree as ET
import io
import tempfile
from ...assets import Asset, AssetUri, AssetSerializer, DummyAssetSerializer
from ...camassets import cam_assets
from ..doc import (
find_shape_object,
get_object_properties,
update_shape_object_properties,
ShapeDocFromBytes,
)
from .icon import ToolBitShapeIcon
class ToolBitShape(Asset):
"""Abstract base class for tool bit shapes."""
asset_type: str = "toolbitshape"
# The name is used...
# 1. as a base for the default filename. E.g. if the name is
# "Endmill", then by default the file is "endmill.fcstd".
# 2. to identify the shape class from a shape.fcstd file.
# Upon loading a shape, the name of the body in the shape
# file is read. It MUST match one of the names.
name: str
# Aliases exist for backward compatibility. If an existing .fctb file
# references a shape such as "v-bit.fctb", and that shape file cannot
# be found, then we can attempt to find a shape class from the string
# "v-bit", "vbit", etc.
aliases: Tuple[str, ...] = tuple()
def __init__(self, id: str, **kwargs: Any):
"""
Initialize the shape.
Args:
id (str): The unique identifier for the shape.
**kwargs: Keyword arguments for shape parameters (e.g., Diameter).
Values should be FreeCAD.Units.Quantity where applicable.
"""
# _params will be populated with default values after loading
self._params: Dict[str, Any] = {}
# Stores default parameter values loaded from the FCStd file
self._defaults: Dict[str, Any] = {}
# Keeps the loaded FreeCAD document content for this instance
self._data: Optional[bytes] = None
self.id: str = id
self.is_builtin: bool = True
self.icon: Optional[ToolBitShapeIcon] = None
# Assign parameters
for param, value in kwargs.items():
self.set_parameter(param, value)
def __str__(self):
params_str = ", ".join(f"{name}={val}" for name, val in self._params.items())
return f"{self.name}({params_str})"
def __repr__(self):
return self.__str__()
def get_id(self) -> str:
"""
Get the ID of the shape.
Returns:
str: The ID of the shape.
"""
return self.id
@classmethod
def _get_shape_class_from_doc(cls, doc: "FreeCAD.Document") -> Type["ToolBitShape"]:
# Find the Body object to identify the shape type
body_obj = find_shape_object(doc)
if not body_obj:
raise ValueError(f"No 'PartDesign::Body' object found in {doc}")
# Find the correct subclass based on the body label
shape_classes = {c.name: c for c in ToolBitShape.__subclasses__()}
shape_class = shape_classes.get(body_obj.Label)
if not shape_class:
raise ValueError(
f"No ToolBitShape subclass found matching Body label '{body_obj.Label}' in {doc}"
)
return shape_class
@classmethod
def get_shape_class_from_bytes(cls, data: bytes) -> Type["ToolBitShape"]:
"""
Identifies the ToolBitShape subclass from the raw bytes of an FCStd file
by parsing the XML content to find the Body label.
Args:
data (bytes): The raw bytes of the .FCStd file.
Returns:
Type[ToolBitShape]: The appropriate ToolBitShape subclass.
Raises:
ValueError: If the data is not a valid FCStd file, Document.xml is
missing, no Body object is found, or the Body label
does not match a known shape name.
"""
try:
# FCStd files are zip archives
with zipfile.ZipFile(io.BytesIO(data)) as zf:
# Read Document.xml from the archive
with zf.open("Document.xml") as doc_xml_file:
tree = ET.parse(doc_xml_file)
root = tree.getroot()
# Extract name of the main Body from XML tree using xpath.
# The body should be a PartDesign::Body, and its label is
# stored in an Property element with a matching name.
body_label = None
xpath = './/Object[@name="Body"]//Property[@name="Label"]/String'
body_label_elem = root.find(xpath)
if body_label_elem is not None:
body_label = body_label_elem.get("value")
if not body_label:
raise ValueError(
"No 'Label' property found for 'PartDesign::Body' object using XPath"
)
# Find the correct subclass based on the body label
shape_class = cls.get_subclass_by_name(body_label)
if not shape_class:
raise ValueError(
f"No ToolBitShape subclass found matching Body label '{body_label}'"
)
return shape_class
except zipfile.BadZipFile:
raise ValueError("Invalid FCStd file data (not a valid zip archive)")
except KeyError:
raise ValueError("Invalid FCStd file data (Document.xml not found)")
except ET.ParseError:
raise ValueError("Error parsing Document.xml")
except Exception as e:
# Catch any other unexpected errors during parsing
raise ValueError(f"Error processing FCStd data: {e}")
@classmethod
def _find_property_object(cls, doc: "FreeCAD.Document") -> Optional["FreeCAD.DocumentObject"]:
"""
Find the PropertyBag object named "Attributes" in a document.
Args:
doc (FreeCAD.Document): The document to search within.
Returns:
Optional[FreeCAD.DocumentObject]: The found object or None.
"""
for o in doc.Objects:
# Check if the object has a Label property and if its value is "Attributes"
# This seems to be the convention in the shape files.
if hasattr(o, "Label") and o.Label == "Attributes":
# We assume this object holds the parameters.
# Further type checking (e.g., for App::FeaturePython or PropertyBag)
# could be added if needed, but Label check might be sufficient.
return o
return None
@classmethod
def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]:
"""
Extracts URIs of dependencies from the raw bytes of an FCStd file.
For ToolBitShape, this is the associated ToolBitShapeIcon, identified
by the same ID as the shape asset.
"""
Path.Log.debug(f"ToolBitShape.extract_dependencies called for {cls.__name__}")
assert (
serializer == DummyAssetSerializer
), f"ToolBitShape supports only native import, not {serializer}"
# A ToolBitShape asset depends on a ToolBitShapeIcon asset with the same ID.
# We need to extract the shape ID from the FCStd data.
try:
# Open the shape data temporarily to get the Body label, which can
# be used to derive the ID if needed, or assume the ID is available
# in the data somehow (e.g., in a property).
# For now, let's assume the ID is implicitly the asset name derived
# from the Body label.
shape_class = cls.get_shape_class_from_bytes(data)
shape_id = shape_class.name.lower() # Assuming ID is lowercase name
# Construct the URI for the corresponding icon asset
svg_uri = AssetUri.build(
asset_type="toolbitshapesvg",
asset_id=shape_id + ".svg",
)
png_uri = AssetUri.build(
asset_type="toolbitshapepng",
asset_id=shape_id + ".png",
)
return [svg_uri, png_uri]
except Exception as e:
# If we can't extract the shape ID or something goes wrong,
# assume no dependencies for now.
Path.Log.error(f"Failed to extract dependencies from shape data: {e}")
return []
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
serializer: Type[AssetSerializer],
) -> "ToolBitShape":
"""
Create a ToolBitShape instance from the raw bytes of an FCStd file.
Identifies the correct subclass based on the Body label in the file,
loads parameters, and caches the document content.
Args:
data (bytes): The raw bytes of the .FCStd file.
id (str): The unique identifier for the shape.
dependencies (Optional[Mapping[AssetUri, Any]]): A mapping of
resolved dependencies. If None, shallow load was attempted.
Returns:
ToolBitShape: An instance of the appropriate ToolBitShape subclass.
Raises:
ValueError: If the data cannot be opened, no Body or PropertyBag
is found, or the Body label does not match a known
shape name.
Exception: For other potential FreeCAD errors during loading.
"""
assert serializer == DummyAssetSerializer, "ToolBitShape supports only native import"
# Open the shape data temporarily to get the Body label and parameters
with ShapeDocFromBytes(data) as temp_doc:
if not temp_doc:
# This case might be covered by ShapeDocFromBytes exceptions,
# but keeping for clarity.
raise ValueError("Failed to open shape document from bytes")
# Determine the specific subclass of ToolBitShape using the new method
shape_class = ToolBitShape.get_shape_class_from_bytes(data)
# Load properties from the temporary document
props_obj = ToolBitShape._find_property_object(temp_doc)
if not props_obj:
raise ValueError("No 'Attributes' PropertyBag object found in document bytes")
# Get properties from the properties object
expected_params = shape_class.get_expected_shape_parameters()
loaded_params = get_object_properties(props_obj, expected_params)
missing_params = [
name
for name in expected_params
if name not in loaded_params or loaded_params[name] is None
]
if missing_params:
raise ValueError(
f"Validation error: Object '{props_obj.Label}' in document bytes "
+ f"is missing parameters for {shape_class.__name__}: {', '.join(missing_params)}"
)
# Instantiate the specific subclass with the provided ID
instance = shape_class(id=id)
instance._data = data # Cache the byte content
instance._defaults = loaded_params
if dependencies: # dependencies is None = shallow load
# Assign resolved dependencies (like the icon) to the instance
# The icon has the same ID as the shape, with .png or .svg appended.
icon_uri = AssetUri.build(
asset_type="toolbitshapesvg",
asset_id=id + ".svg",
)
instance.icon = cast(ToolBitShapeIcon, dependencies.get(icon_uri))
if not instance.icon:
icon_uri = AssetUri.build(
asset_type="toolbitshapepng",
asset_id=id + ".png",
)
instance.icon = cast(ToolBitShapeIcon, dependencies.get(icon_uri))
# Update instance parameters, prioritizing loaded defaults but not
# overwriting parameters that may already be set during __init__
instance._params = instance._defaults | instance._params
return instance
def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes:
"""
Serializes a ToolBitShape object to bytes (e.g., an fcstd file).
This is required by the Asset interface.
"""
assert serializer == DummyAssetSerializer, "ToolBitShape supports only native export"
doc = None
try:
# Create a new temporary document
doc = FreeCAD.newDocument("TemporaryShapeDoc", hidden=True)
# Add the shape's body to the temporary document
self.make_body(doc)
# Recompute the document to ensure the body is created
doc.recompute()
# Save the temporary document to a temporary file
# We cannot use NamedTemporaryFile on Windows, because there
# doc.saveAs() may not have permission to access the tempfile
# while the NamedTemporaryFile is open.
# So we use TemporaryDirectory instead, to ensure cleanup while
# still having a the temporary file inside it.
with tempfile.TemporaryDirectory() as thedir:
temp_file_path = pathlib.Path(thedir, "temp.FCStd")
doc.saveAs(str(temp_file_path))
return temp_file_path.read_bytes()
finally:
# Clean up the temporary document
if doc:
FreeCAD.closeDocument(doc.Name)
@classmethod
def from_file(cls, filepath: pathlib.Path, **kwargs: Any) -> "ToolBitShape":
"""
Create a ToolBitShape instance from an FCStd file.
Reads the file bytes and delegates to from_bytes().
Args:
filepath (pathlib.Path): Path to the .FCStd file.
**kwargs: Keyword arguments for shape parameters to override defaults.
Returns:
ToolBitShape: An instance of the appropriate ToolBitShape subclass.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If the file cannot be opened, no Body or PropertyBag
is found, or the Body label does not match a known
shape name.
Exception: For other potential FreeCAD errors during loading.
"""
if not filepath.exists():
raise FileNotFoundError(f"Shape file not found: {filepath}")
try:
data = filepath.read_bytes()
# Extract the ID from the filename (without extension)
shape_id = filepath.stem
# Pass an empty dictionary for dependencies when loading from a single file
# TODO: pass ToolBitShapeIcon as a dependency
instance = cls.from_bytes(data, shape_id, {}, DummyAssetSerializer)
# Apply kwargs parameters after loading from bytes
if kwargs:
instance.set_parameters(**kwargs)
return instance
except (FileNotFoundError, ValueError) as e:
raise e
except Exception as e:
raise RuntimeError(f"Failed to create shape from {filepath}: {e}")
@classmethod
def get_subclass_by_name(
cls, name: str, default: Type["ToolBitShape"] | None = None
) -> Optional[Type["ToolBitShape"]]:
"""
Retrieves a ToolBitShape class by its name or alias.
"""
name = name.lower()
for thecls in cls.__subclasses__():
if (
thecls.name.lower() == name
or thecls.__name__.lower() == name
or name in thecls.aliases
):
return thecls
return default
@classmethod
def resolve_name(cls, identifier: str) -> AssetUri:
"""
Resolves an identifier (alias, name, filename, or URI) to a Uri object.
"""
# 1. If the input is a url string, return the AssetUri for it.
if AssetUri.is_uri(identifier):
return AssetUri(identifier)
# 2. If the input is a filename (with extension), assume the asset
# name is the base name.
asset_name = identifier
if identifier.endswith(".fcstd"):
asset_name = os.path.splitext(os.path.basename(identifier))[0]
# 3. Use get_subclass_by_name to try to resolve alias to a class.
# if one is found, use the class.name.
shape_class = cls.get_subclass_by_name(asset_name.lower())
if shape_class:
asset_name = shape_class.name.lower()
# 4. Construct the Uri using AssetUri.build() and return it
return AssetUri.build(
asset_type="toolbitshape",
asset_id=asset_name,
)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
"""
Subclasses must define the dictionary mapping parameter names to
translations and FreeCAD property type strings (e.g.,
'App::PropertyLength').
The schema defines any parameters that MUST be in the shape file.
Any attempt to load a shape file that does not match the schema
will cause an error.
"""
raise NotImplementedError
@property
def label(self) -> str:
"""Return a user friendly, translatable display name."""
raise NotImplementedError
def reset_parameters(self):
"""Reset parameters to their default values."""
self._params.update(self._defaults)
def get_parameter_label(self, param_name: str) -> str:
"""
Get the user-facing label for a given parameter name.
"""
str_param_name = str(param_name)
entry = self.schema().get(param_name)
return entry[0] if entry else str_param_name
def get_parameter_property_type(self, param_name: str) -> str:
"""
Get the FreeCAD property type string for a given parameter name.
"""
return self.schema()[param_name][1]
def get_parameters(self) -> Dict[str, Any]:
"""
Get the dictionary of current parameters and their values.
Returns:
dict: A dictionary mapping parameter names to their values.
"""
return self._params
def get_parameter(self, name: str) -> Any:
"""
Get the value of a specific parameter.
Args:
name (str): The name of the parameter.
Returns:
The value of the parameter (often a FreeCAD.Units.Quantity).
Raises:
KeyError: If the parameter name is not valid for this shape.
"""
if name not in self.schema():
raise KeyError(f"Shape '{self.name}' has no parameter '{name}'")
return self._params[name]
def set_parameter(self, name: str, value: Any):
"""
Set the value of a specific parameter.
Args:
name (str): The name of the parameter.
value: The new value for the parameter. Should be compatible
with the expected type (e.g., FreeCAD.Units.Quantity).
Raises:
KeyError: If the parameter name is not valid for this shape.
"""
if name not in self.schema().keys():
Path.Log.debug(
f"Shape '{self.name}' was given an invalid parameter '{name}'. Has {self._params}\n"
)
# Log to confirm this path is taken when an invalid parameter is given
Path.Log.debug(
f"Invalid parameter '{name}' for shape "
f"'{self.name}', returning without raising KeyError."
)
return
self._params[name] = value
def set_parameters(self, **kwargs):
"""
Set multiple parameters using keyword arguments.
Args:
**kwargs: Keyword arguments where keys are parameter names.
"""
for name, value in kwargs.items():
try:
self.set_parameter(name, value)
except KeyError:
Path.Log.debug(f"Ignoring unknown parameter '{name}' for shape '{self.name}'.\n")
@classmethod
def get_expected_shape_parameters(cls) -> List[str]:
"""
Get a list of parameter names expected by this shape class based on
its schema.
Returns:
list[str]: List of parameter names.
"""
return list(cls.schema().keys())
def make_body(self, doc: "FreeCAD.Document"):
"""
Generates the body of the ToolBitShape and copies it to the provided
document.
"""
assert self._data is not None
with ShapeDocFromBytes(self._data) as tmp_doc:
shape = find_shape_object(tmp_doc)
if not shape:
FreeCAD.Console.PrintWarning(
"No suitable shape object found in document. " "Cannot create solid shape.\n"
)
return None
props = self._find_property_object(tmp_doc)
if not props:
FreeCAD.Console.PrintWarning(
"No suitable shape object found in document. " "Cannot create solid shape.\n"
)
return None
update_shape_object_properties(props, self.get_parameters())
# Recompute the document to apply property changes
tmp_doc.recompute()
# Copy the body to the given document without immediate compute.
return doc.copyObject(shape, True)
"""
Retrieves the thumbnail data for the tool bit shape in PNG format.
"""
def get_icon(self) -> Optional[ToolBitShapeIcon]:
"""
Get the associated ToolBitShapeIcon instance. Tries to load one from
the asset manager if none was assigned.
Returns:
Optional[ToolBitShapeIcon]: The icon instance, or None if none found.
"""
if self.icon:
return self.icon
# Try to get a matching SVG from the asset manager.
self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.id}.svg")
if self.icon:
return self.icon
self.icon = cam_assets.get_or_none(f"toolbitshapesvg://{self.name.lower()}.svg")
if self.icon:
return self.icon
# Try to get a matching PNG from the asset manager.
self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.id}.png")
if self.icon:
return self.icon
self.icon = cam_assets.get_or_none(f"toolbitshapepng://{self.name.lower()}.png")
if self.icon:
return self.icon
return None
def get_thumbnail(self) -> Optional[bytes]:
"""
Retrieves the thumbnail data for the tool bit shape in PNG format,
as embedded in the shape file.
"""
if not self._data:
return None
with zipfile.ZipFile(io.BytesIO(self._data)) as zf:
try:
with zf.open("thumbnails/Thumbnail.png", "r") as tn:
return tn.read()
except KeyError:
pass
return None

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeBullnose(ToolBitShape):
name = "Bullnose"
aliases = "bullnose", "torus"
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
"FlatRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Torus radius"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Torus")

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeChamfer(ToolBitShape):
name = "Chamfer"
aliases = ("chamfer",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CuttingEdgeAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge angle"),
"App::PropertyAngle",
),
"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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Chamfer")

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeDovetail(ToolBitShape):
name = "Dovetail"
aliases = ("dovetail",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"TipDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Crest height"),
"App::PropertyLength",
),
"CuttingEdgeAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting angle"),
"App::PropertyAngle",
),
"CuttingEdgeHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Dovetail height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Major diameter"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"NeckDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Neck diameter"),
"App::PropertyLength",
),
"NeckHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Neck length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Dovetail")

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeDrill(ToolBitShape):
name = "Drill"
aliases = ("drill",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"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",
),
"TipAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Tip angle"),
"App::PropertyAngle",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("CAM", "Drill")

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeEndmill(ToolBitShape):
name = "Endmill"
aliases = ("endmill",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
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:
return FreeCAD.Qt.translate("ToolBitShape", "Endmill")

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeFillet(ToolBitShape):
name = "Fillet"
aliases = ("fillet",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CrownHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Crown height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Diameter"),
"App::PropertyLength",
),
"FilletRadius": (
FreeCAD.Qt.translate("ToolBitShape", "Fillet radius"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Filleted Chamfer")

View File

@@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import pathlib
import xml.etree.ElementTree as ET
from typing import Mapping, Optional
from functools import cached_property
from ...assets import Asset, AssetUri, AssetSerializer, DummyAssetSerializer
import Path.Tool.shape.util as util
from PySide import QtCore, QtGui, QtSvg
_svg_ns = {"s": "http://www.w3.org/2000/svg"}
class ToolBitShapeIcon(Asset):
"""Abstract base class for tool bit shape icons."""
def __init__(self, id: str, data: bytes):
"""
Initialize the icon.
Args:
id (str): The unique identifier for the icon, including extension.
data (bytes): The raw icon data (e.g., SVG or PNG bytes).
"""
self.id: str = id
self.data: bytes = data
def get_id(self) -> str:
"""
Get the ID of the icon.
Returns:
str: The ID of the icon.
"""
return self.id
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
serializer: AssetSerializer,
) -> "ToolBitShapeIcon":
"""
Create a ToolBitShapeIcon instance from raw bytes.
Args:
data (bytes): The raw bytes of the icon file.
id (str): The ID of the asset, including extension.
dependencies (Optional[Mapping[AssetUri, Asset]]): A mapping of resolved dependencies (not used for icons).
Returns:
ToolBitShapeIcon: An instance of ToolBitShapeIcon.
"""
assert serializer == DummyAssetSerializer, "ToolBitShapeIcon supports only native import"
return cls(id=id, data=data)
def to_bytes(self, serializer: AssetSerializer) -> bytes:
"""
Serializes a ToolBitShapeIcon object to bytes.
"""
assert serializer == DummyAssetSerializer, "ToolBitShapeIcon supports only native export"
return self.data
@classmethod
def from_file(cls, filepath: pathlib.Path, id: str) -> "ToolBitShapeIcon":
"""
Create a ToolBitShapeIcon instance from a file.
Args:
filepath (pathlib.Path): Path to the icon file (.svg or .png).
shape_id_base (str): The base ID of the associated shape.
Returns:
ToolBitShapeIcon: An instance of ToolBitShapeIcon.
Raises:
FileNotFoundError: If the file does not exist.
"""
if not filepath.exists():
raise FileNotFoundError(f"Icon file not found: {filepath}")
data = filepath.read_bytes()
if filepath.suffix.lower() == ".png":
return ToolBitShapePngIcon(id, data)
elif filepath.suffix.lower() == ".svg":
return ToolBitShapeSvgIcon(id, data)
else:
raise NotImplementedError(f"unsupported icon file: {filepath}")
@classmethod
def from_shape_data(cls, shape_data: bytes, id: str) -> Optional["ToolBitShapeIcon"]:
"""
Create a thumbnail icon from shape data bytes.
Args:
shape_data (bytes): The raw bytes of the shape file (.FCStd).
shape_id_base (str): The base ID of the associated shape.
Returns:
Optional[ToolBitShapeIcon]: An instance of ToolBitShapeIcon (PNG), or None.
"""
image_bytes = util.create_thumbnail_from_data(shape_data)
if not image_bytes:
return None
# Assuming create_thumbnail_from_data returns PNG data
return ToolBitShapePngIcon(id=id, data=image_bytes)
def get_size_in_bytes(self) -> int:
"""
Get the size of the icon data in bytes.
"""
return len(self.data)
@cached_property
def abbreviations(self) -> Mapping[str, str]:
"""
Returns a cached mapping of parameter abbreviations from the icon data.
"""
return {}
def get_abbr(self, param_name: str) -> Optional[str]:
"""
Retrieves the abbreviation for a given parameter name.
Args:
param_name: The name of the parameter.
Returns:
The abbreviation string, or None if not found.
"""
normalized_param_name = param_name.lower().replace(" ", "_")
return self.abbreviations.get(normalized_param_name)
def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes:
"""
Returns the icon data as PNG bytes.
"""
raise NotImplementedError
def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap:
"""
Returns the icon data as a QPixmap.
"""
raise NotImplementedError
class ToolBitShapeSvgIcon(ToolBitShapeIcon):
asset_type: str = "toolbitshapesvg"
def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes:
"""
Converts SVG icon data to PNG and returns it using QtSvg.
"""
if icon_size is None:
icon_size = QtCore.QSize(48, 48)
image = QtGui.QImage(icon_size, QtGui.QImage.Format_ARGB32)
image.fill(QtGui.Qt.transparent)
painter = QtGui.QPainter(image)
buffer = QtCore.QBuffer(QtCore.QByteArray(self.data))
buffer.open(QtCore.QIODevice.ReadOnly)
svg_renderer = QtSvg.QSvgRenderer(buffer)
svg_renderer.setAspectRatioMode(QtCore.Qt.KeepAspectRatio)
svg_renderer.render(painter)
painter.end()
byte_array = QtCore.QByteArray()
buffer = QtCore.QBuffer(byte_array)
buffer.open(QtCore.QIODevice.WriteOnly)
image.save(buffer, "PNG")
return bytes(byte_array)
def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap:
"""
Returns the SVG icon data as a QPixmap using QtSvg.
"""
if icon_size is None:
icon_size = QtCore.QSize(48, 48)
icon_ba = QtCore.QByteArray(self.data)
image = QtGui.QImage(icon_size, QtGui.QImage.Format_ARGB32)
image.fill(QtGui.Qt.transparent)
painter = QtGui.QPainter(image)
buffer = QtCore.QBuffer(icon_ba) # PySide6
buffer.open(QtCore.QIODevice.ReadOnly)
data = QtCore.QXmlStreamReader(buffer)
renderer = QtSvg.QSvgRenderer(data)
renderer.setAspectRatioMode(QtCore.Qt.KeepAspectRatio)
renderer.render(painter)
painter.end()
return QtGui.QPixmap.fromImage(image)
@cached_property
def abbreviations(self) -> Mapping[str, str]:
"""
Returns a cached mapping of parameter abbreviations from the icon data.
Only applicable for SVG icons.
"""
if self.data:
return self.get_abbreviations_from_svg(self.data)
return {}
def get_abbr(self, param_name: str) -> Optional[str]:
"""
Retrieves the abbreviation for a given parameter name.
Args:
param_name: The name of the parameter.
Returns:
The abbreviation string, or None if not found.
"""
normalized_param_name = param_name.lower().replace(" ", "_")
return self.abbreviations.get(normalized_param_name)
@staticmethod
def get_abbreviations_from_svg(svg: bytes) -> Mapping[str, str]:
"""
Extract abbreviations from SVG text elements.
"""
try:
tree = ET.fromstring(svg)
except ET.ParseError:
return {}
result = {}
for text_elem in tree.findall(".//s:text", _svg_ns):
id = text_elem.attrib.get("id", _svg_ns)
if id is None or not isinstance(id, str):
continue
abbr = text_elem.text
if abbr is not None:
result[id.lower()] = abbr
span_elem = text_elem.find(".//s:tspan", _svg_ns)
if span_elem is None:
continue
abbr = span_elem.text
result[id.lower()] = abbr
return result
class ToolBitShapePngIcon(ToolBitShapeIcon):
asset_type: str = "toolbitshapepng"
def get_png(self, icon_size: Optional[QtCore.QSize] = None) -> bytes:
"""
Returns the PNG icon data.
"""
# For PNG, resizing might be needed if icon_size is different
# from the original size. Simple return for now.
return self.data
def get_qpixmap(self, icon_size: Optional[QtCore.QSize] = None) -> QtGui.QPixmap:
"""
Returns the PNG icon data as a QPixmap.
"""
if icon_size is None:
icon_size = QtCore.QSize(48, 48)
pixmap = QtGui.QPixmap()
pixmap.loadFromData(self.data, "PNG")
# Scale the pixmap if the requested size is different
if pixmap.size() != icon_size:
pixmap = pixmap.scaled(
icon_size,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation,
)
return pixmap

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeProbe(ToolBitShape):
name = "Probe"
aliases = ("probe",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Ball diameter"),
"App::PropertyLength",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Length of probe"),
"App::PropertyLength",
),
"ShaftDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shaft diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Probe")

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeReamer(ToolBitShape):
name = "Reamer"
aliases = ("reamer",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CuttingEdgeHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Diameter"),
"App::PropertyLength",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Reamer")

View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeSlittingSaw(ToolBitShape):
name = "SlittingSaw"
aliases = "slittingsaw", "slitting-saw"
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"BladeThickness": (
FreeCAD.Qt.translate("ToolBitShape", "Blade thickness"),
"App::PropertyLength",
),
"CapDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Cap diameter"),
"App::PropertyLength",
),
"CapHeight": (
FreeCAD.Qt.translate("ToolBitShape", "Cap 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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Slitting Saw")

View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeTap(ToolBitShape):
name = "Tap"
aliases = ("Tap",)
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CuttingEdgeLength": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge length"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Tap diameter"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall length of tap"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
"TipAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Tip angle"),
"App::PropertyAngle",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Tap")

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeThreadMill(ToolBitShape):
name = "ThreadMill"
aliases = "threadmill", "thread-mill"
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"Crest": (
FreeCAD.Qt.translate("ToolBitShape", "Crest height"),
"App::PropertyLength",
),
"Diameter": (
FreeCAD.Qt.translate("ToolBitShape", "Major diameter"),
"App::PropertyLength",
),
"Flutes": (
FreeCAD.Qt.translate("ToolBitShape", "Flutes"),
"App::PropertyInteger",
),
"Length": (
FreeCAD.Qt.translate("ToolBitShape", "Overall tool length"),
"App::PropertyLength",
),
"NeckDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Neck diameter"),
"App::PropertyLength",
),
"NeckLength": (
FreeCAD.Qt.translate("ToolBitShape", "Neck length"),
"App::PropertyLength",
),
"ShankDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
"cuttingAngle": ( # TODO rename to CuttingAngle
FreeCAD.Qt.translate("ToolBitShape", "Cutting angle"),
"App::PropertyAngle",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "Thread Mill")

View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import FreeCAD
from typing import Tuple, Mapping
from .base import ToolBitShape
class ToolBitShapeVBit(ToolBitShape):
name = "VBit"
aliases = "vbit", "v-bit"
@classmethod
def schema(cls) -> Mapping[str, Tuple[str, str]]:
return {
"CuttingEdgeAngle": (
FreeCAD.Qt.translate("ToolBitShape", "Cutting edge angle"),
"App::PropertyAngle",
),
"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("ToolBitShape", "Shank diameter"),
"App::PropertyLength",
),
"TipDiameter": (
FreeCAD.Qt.translate("ToolBitShape", "Tip diameter"),
"App::PropertyLength",
),
}
@property
def label(self) -> str:
return FreeCAD.Qt.translate("ToolBitShape", "V-Bit")

View File

@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from PySide.QtCore import *
from PySide.QtGui import *
class FlowLayout(QLayout):
widthChanged = Signal(int)
def __init__(self, parent=None, margin=0, spacing=-1, orientation=Qt.Horizontal):
super(FlowLayout, self).__init__(parent)
if parent is not None:
self.setContentsMargins(margin, margin, margin, margin)
self.setSpacing(spacing)
self.itemList = []
self.orientation = orientation
def __del__(self):
item = self.takeAt(0)
while item:
item = self.takeAt(0)
def addItem(self, item):
self.itemList.append(item)
def count(self):
return len(self.itemList)
def itemAt(self, index):
if index >= 0 and index < len(self.itemList):
return self.itemList[index]
return None
def takeAt(self, index):
if index >= 0 and index < len(self.itemList):
return self.itemList.pop(index)
return None
def expandingDirections(self):
return Qt.Orientations(Qt.Orientation(0))
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
if self.orientation == Qt.Horizontal:
return self.doLayoutHorizontal(QRect(0, 0, width, 0), True)
elif self.orientation == Qt.Vertical:
return self.doLayoutVertical(QRect(0, 0, width, 0), True)
def setGeometry(self, rect):
super(FlowLayout, self).setGeometry(rect)
if self.orientation == Qt.Horizontal:
self.doLayoutHorizontal(rect, False)
elif self.orientation == Qt.Vertical:
self.doLayoutVertical(rect, False)
def sizeHint(self):
return self.minimumSize()
def minimumSize(self):
size = QSize()
for item in self.itemList:
size = size.expandedTo(item.minimumSize())
margin, _, _, _ = self.getContentsMargins()
size += QSize(2 * margin, 2 * margin)
return size
def doLayoutHorizontal(self, rect, testOnly):
# Get initial coordinates of the drawing region (should be 0, 0)
x = rect.x()
y = rect.y()
lineHeight = 0
i = 0
for item in self.itemList:
wid = item.widget()
# Space X and Y is item spacing horizontally and vertically
spaceX = self.spacing() + wid.style().layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
)
spaceY = self.spacing() + wid.style().layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
)
# Determine the coordinate we want to place the item at
# It should be placed at : initial coordinate of the rect + width of the item + spacing
nextX = x + item.sizeHint().width() + spaceX
# If the calculated nextX is greater than the outer bound...
if nextX - spaceX > rect.right() and lineHeight > 0:
x = rect.x() # Reset X coordinate to origin of drawing region
y = y + lineHeight + spaceY # Move Y coordinate to the next line
nextX = (
x + item.sizeHint().width() + spaceX
) # Recalculate nextX based on the new X coordinate
lineHeight = 0
if not testOnly:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = nextX # Store the next starting X coordinate for next item
lineHeight = max(lineHeight, item.sizeHint().height())
i = i + 1
return y + lineHeight - rect.y()
def doLayoutVertical(self, rect, testOnly):
# Get initial coordinates of the drawing region (should be 0, 0)
x = rect.x()
y = rect.y()
# Initialize column width and line height
columnWidth = 0
lineHeight = 0
# Space between items
spaceX = 0
spaceY = 0
# Variables that will represent the position of the widgets in a 2D Array
i = 0
j = 0
for item in self.itemList:
wid = item.widget()
# Space X and Y is item spacing horizontally and vertically
spaceX = self.spacing() + wid.style().layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
)
spaceY = self.spacing() + wid.style().layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
)
# Determine the coordinate we want to place the item at
# It should be placed at : initial coordinate of the rect + width of the item + spacing
nextY = y + item.sizeHint().height() + spaceY
# If the calculated nextY is greater than the outer bound, move to the next column
if nextY - spaceY > rect.bottom() and columnWidth > 0:
y = rect.y() # Reset y coordinate to origin of drawing region
x = x + columnWidth + spaceX # Move X coordinate to the next column
nextY = (
y + item.sizeHint().height() + spaceY
) # Recalculate nextX based on the new X coordinate
# Reset the column width
columnWidth = 0
# Set indexes of the item for the 2D array
j += 1
i = 0
# Assign 2D array indexes
item.x_index = i
item.y_index = j
# Only call setGeometry (which place the actual widget using coordinates) if testOnly is false
# For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??)
if not testOnly:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
y = nextY # Store the next starting Y coordinate for next item
columnWidth = max(
columnWidth, item.sizeHint().width()
) # Update the width of the column
lineHeight = max(lineHeight, item.sizeHint().height()) # Update the height of the line
i += 1 # Increment i
# Only call setGeometry (which place the actual widget using coordinates) if testOnly is false
# For some reason, Qt framework calls the doLayout methods with testOnly set to true (WTF ??)
if not testOnly:
self.calculateMaxWidth(i)
self.widthChanged.emit(self.totalMaxWidth + spaceX * self.itemsOnWidestRow)
return lineHeight
# Method to calculate the maximum width among each "row" of the flow layout
# This will be useful to let the UI know the total width of the flow layout
def calculateMaxWidth(self, numberOfRows):
# Init variables
self.totalMaxWidth = 0
self.itemsOnWidestRow = 0
# For each "row", calculate the total width by adding the width of each item
# and then update the totalMaxWidth if the calculated width is greater than the current value
# Also update the number of items on the widest row
for i in range(numberOfRows):
rowWidth = 0
itemsOnWidestRow = 0
for item in self.itemList:
# Only compare items from the same row
if item.x_index == i:
rowWidth += item.sizeHint().width()
itemsOnWidestRow += 1
if rowWidth > self.totalMaxWidth:
self.totalMaxWidth = rowWidth
self.itemsOnWidestRow = itemsOnWidestRow

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from PySide import QtGui, QtCore
class ShapeButton(QtGui.QToolButton):
def __init__(self, shape, parent=None):
super(ShapeButton, self).__init__(parent)
self.shape = shape
self.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon)
self.setText(shape.label)
self.setFixedSize(128, 128)
self.setBaseSize(128, 128)
self.icon_size = QtCore.QSize(71, 100)
self.setIconSize(self.icon_size)
self._update_icon()
def set_text(self, text):
self.label.setText(text)
def _update_icon(self):
icon = self.shape.get_icon()
if icon:
pixmap = icon.get_qpixmap(self.icon_size)
self.setIcon(QtGui.QIcon(pixmap))

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from typing import Optional, cast
import FreeCADGui
from functools import partial
from PySide import QtGui
from ...camassets import cam_assets
from .. import ToolBitShape
from .flowlayout import FlowLayout
from .shapebutton import ShapeButton
class ShapeSelector:
def __init__(self):
self.shape = None
self.form = FreeCADGui.PySideUic.loadUi(":/panels/ShapeSelector.ui")
self.form.buttonBox.clicked.connect(self.form.close)
self.flows = {}
self.update_shapes()
self.form.toolBox.setCurrentIndex(0)
def _add_shape_group(self, toolbox):
if toolbox in self.flows:
return self.flows[toolbox]
flow = FlowLayout(toolbox, orientation=QtGui.Qt.Horizontal)
flow.widthChanged.connect(lambda x: toolbox.setMinimumWidth(x))
self.flows[toolbox] = flow
return flow
def _add_shapes(self, toolbox, shapes):
flow = self._add_shape_group(toolbox)
# Remove all shapes first.
for i in reversed(range(flow.count())):
flow.itemAt(i).widget().setParent(None)
# Add all shapes.
for shape in sorted(shapes, key=lambda x: x.label):
button = ShapeButton(shape)
flow.addWidget(button)
cb = partial(self.on_shape_button_clicked, shape)
button.clicked.connect(cb)
def update_shapes(self):
# Retrieve each shape asset
shapes = set(cam_assets.fetch(asset_type="toolbitshape"))
builtin = set(s for s in shapes if cast(ToolBitShape, s).is_builtin)
self._add_shapes(self.form.standardTools, builtin)
self._add_shapes(self.form.customTools, shapes - builtin)
def on_shape_button_clicked(self, shape):
self.shape = shape
self.form.close()
def show(self) -> Optional[ToolBitShape]:
self.form.exec()
return self.shape

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
from PySide import QtGui, QtCore
class ShapeWidget(QtGui.QWidget):
def __init__(self, shape, parent=None):
super(ShapeWidget, self).__init__(parent)
self.layout = QtGui.QVBoxLayout(self)
self.layout.setAlignment(QtCore.Qt.AlignHCenter)
self.shape = shape
ratio = self.devicePixelRatioF()
self.icon_size = QtCore.QSize(200 * ratio, 235 * ratio)
self.icon_widget = QtGui.QLabel()
self.layout.addWidget(self.icon_widget)
self._update_icon()
def _update_icon(self):
icon = self.shape.get_icon()
if icon:
pixmap = icon.get_qpixmap(self.icon_size)
self.icon_widget.setPixmap(pixmap)

View File

@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 Samuel Abels <knipknap@gmail.com> *
# * *
# * This program is free software; you can redistribute it and/or modify *
# * it under the terms of the GNU Lesser General Public License (LGPL) *
# * as published by the Free Software Foundation; either version 2 of *
# * the License, or (at your option) any later version. *
# * for detail see the LICENCE text file. *
# * *
# * This program 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 Library General Public License for more details. *
# * *
# * You should have received a copy of the GNU Library General Public *
# * License along with this program; if not, write to the Free Software *
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
# * USA *
# * *
# ***************************************************************************
import pathlib
from typing import Optional
import FreeCAD
import tempfile
import os
from .doc import ShapeDocFromBytes
_svg_ns = {"s": "http://www.w3.org/2000/svg"}
def file_is_newer(reference: pathlib.Path, file: pathlib.Path):
return reference.stat().st_mtime > file.stat().st_mtime
def create_thumbnail(filepath: pathlib.Path, w: int = 200, h: int = 200) -> Optional[pathlib.Path]:
if not FreeCAD.GuiUp:
return None
try:
import FreeCADGui
except ImportError:
raise RuntimeError("Error: Could not load UI - is it up?")
doc = FreeCAD.openDocument(str(filepath))
view = FreeCADGui.activeDocument().ActiveView
out_filepath = filepath.with_suffix(".png")
if not view:
print("No view active, cannot make thumbnail for {}".format(filepath))
return
view.viewFront()
view.fitAll()
view.setAxisCross(False)
view.saveImage(str(out_filepath), w, h, "Transparent")
FreeCAD.closeDocument(doc.Name)
return out_filepath
def create_thumbnail_from_data(shape_data: bytes, w: int = 200, h: int = 200) -> Optional[bytes]:
"""
Create a thumbnail icon from shape data bytes using a temporary document.
Args:
shape_data (bytes): The raw bytes of the shape file (.FCStd).
w (int): Width of the thumbnail.
h (int): Height of the thumbnail.
Returns:
Optional[bytes]: PNG image bytes, or None if generation fails.
"""
if not FreeCAD.GuiUp:
return None
try:
import FreeCADGui
except ImportError:
raise RuntimeError("Error: Could not load UI - is it up?")
temp_png_path = None
try:
with ShapeDocFromBytes(shape_data) as doc:
view = FreeCADGui.activeDocument().ActiveView
if not view:
print("No view active, cannot make thumbnail from data")
return None
view.viewFront()
view.fitAll()
view.setAxisCross(False)
# Create a temporary file path for the output PNG
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
temp_png_path = pathlib.Path(temp_file.name)
view.saveImage(str(temp_png_path), w, h, "Transparent")
# Read the PNG bytes
with open(temp_png_path, "rb") as f:
png_bytes = f.read()
return png_bytes
except Exception as e:
print(f"Error creating thumbnail from data: {e}")
return None
finally:
# Clean up temporary PNG file
if temp_png_path and temp_png_path.exists():
os.remove(temp_png_path)