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

@@ -1,500 +0,0 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@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
import Path.Base.Util as PathUtil
import Path.Base.PropertyBag as PathPropertyBag
import json
import os
import zipfile
from PySide.QtCore import QT_TRANSLATE_NOOP
# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader
Part = LazyLoader("Part", globals(), "Part")
__title__ = "Tool bits."
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "Class to deal with and represent a tool bit."
PropertyGroupShape = "Shape"
_DebugFindTool = False
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
def _findToolFile(name, containerFile, typ):
Path.Log.track(name)
if os.path.exists(name): # absolute reference
return name
if containerFile:
rootPath = os.path.dirname(os.path.dirname(containerFile))
paths = [os.path.join(rootPath, typ)]
else:
paths = []
paths.extend(Path.Preferences.searchPathsTool(typ))
def _findFile(path, name):
Path.Log.track(path, name)
fullPath = os.path.join(path, name)
if os.path.exists(fullPath):
return (True, fullPath)
for root, ds, fs in os.walk(path):
for d in ds:
found, fullPath = _findFile(d, name)
if found:
return (True, fullPath)
return (False, None)
for p in paths:
found, path = _findFile(p, name)
if found:
return path
return None
def findToolShape(name, path=None):
"""findToolShape(name, path) ... search for name, if relative path look in path"""
Path.Log.track(name, path)
return _findToolFile(name, path, "Shape")
def findToolBit(name, path=None):
"""findToolBit(name, path) ... search for name, if relative path look in path"""
Path.Log.track(name, path)
if name.endswith(".fctb"):
return _findToolFile(name, path, "Bit")
return _findToolFile("{}.fctb".format(name), path, "Bit")
# Only used in ToolBit unit test module: TestPathToolBit.py
def findToolLibrary(name, path=None):
"""findToolLibrary(name, path) ... search for name, if relative path look in path"""
Path.Log.track(name, path)
if name.endswith(".fctl"):
return _findToolFile(name, path, "Library")
return _findToolFile("{}.fctl".format(name), path, "Library")
def _findRelativePath(path, typ):
Path.Log.track(path, typ)
relative = path
for p in Path.Preferences.searchPathsTool(typ):
if path.startswith(p):
p = path[len(p) :]
if os.path.sep == p[0]:
p = p[1:]
if len(p) < len(relative):
relative = p
return relative
# Unused due to bug fix related to relative paths
"""
def findRelativePathShape(path):
return _findRelativePath(path, 'Shape')
def findRelativePathTool(path):
return _findRelativePath(path, 'Bit')
"""
def findRelativePathLibrary(path):
return _findRelativePath(path, "Library")
class ToolBit(object):
def __init__(self, obj, shapeFile, path=None):
Path.Log.track(obj.Label, shapeFile, path)
self.obj = obj
obj.addProperty(
"App::PropertyFile",
"BitShape",
"Base",
QT_TRANSLATE_NOOP("App::Property", "Shape for bit shape"),
)
obj.addProperty(
"App::PropertyLink",
"BitBody",
"Base",
QT_TRANSLATE_NOOP("App::Property", "The parametrized body representing the tool bit"),
)
obj.addProperty(
"App::PropertyFile",
"File",
"Base",
QT_TRANSLATE_NOOP("App::Property", "The file of the tool"),
)
obj.addProperty(
"App::PropertyString",
"ShapeName",
"Base",
QT_TRANSLATE_NOOP("App::Property", "The name of the shape file"),
)
obj.addProperty(
"App::PropertyStringList",
"BitPropertyNames",
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of all properties inherited from the bit"),
)
if path:
obj.File = path
if shapeFile is None:
obj.BitShape = "endmill.fcstd"
self._setupBitShape(obj)
self.unloadBitBody(obj)
else:
obj.BitShape = shapeFile
self._setupBitShape(obj)
self.onDocumentRestored(obj)
def dumps(self):
return None
def loads(self, state):
for obj in FreeCAD.ActiveDocument.Objects:
if hasattr(obj, "Proxy") and obj.Proxy == self:
self.obj = obj
break
return None
def onDocumentRestored(self, obj):
# when files are shared it is essential to be able to change/set the shape file,
# otherwise the file is hard to use
# obj.setEditorMode('BitShape', 1)
obj.setEditorMode("BitBody", 2)
obj.setEditorMode("File", 1)
obj.setEditorMode("Shape", 2)
if not hasattr(obj, "BitPropertyNames"):
obj.addProperty(
"App::PropertyStringList",
"BitPropertyNames",
"Base",
QT_TRANSLATE_NOOP("App::Property", "List of all properties inherited from the bit"),
)
propNames = []
for prop in obj.PropertiesList:
if obj.getGroupOfProperty(prop) == "Bit":
val = obj.getPropertyByName(prop)
typ = obj.getTypeIdOfProperty(prop)
dsc = obj.getDocumentationOfProperty(prop)
obj.removeProperty(prop)
obj.addProperty(typ, prop, PropertyGroupShape, dsc)
PathUtil.setProperty(obj, prop, val)
propNames.append(prop)
elif obj.getGroupOfProperty(prop) == "Attribute":
propNames.append(prop)
obj.BitPropertyNames = propNames
obj.setEditorMode("BitPropertyNames", 2)
for prop in obj.BitPropertyNames:
if obj.getGroupOfProperty(prop) == PropertyGroupShape:
# properties in the Shape group can only be modified while the actual
# shape is loaded, so we have to disable direct property editing
obj.setEditorMode(prop, 1)
else:
# all other custom properties can and should be edited directly in the
# property editor widget, not much value in re-implementing that
obj.setEditorMode(prop, 0)
def onChanged(self, obj, prop):
Path.Log.track(obj.Label, prop)
if prop == "BitShape" and "Restore" not in obj.State:
self._setupBitShape(obj)
def onDelete(self, obj, arg2=None):
Path.Log.track(obj.Label)
self.unloadBitBody(obj)
obj.Document.removeObject(obj.Name)
def _updateBitShape(self, obj, properties=None):
if obj.BitBody is not None:
for attributes in [
o
for o in obj.BitBody.Group
if hasattr(o, "Proxy") and hasattr(o.Proxy, "getCustomProperties")
]:
for prop in attributes.Proxy.getCustomProperties():
# the property might not exist in our local object (new attribute in shape)
# for such attributes we just keep the default
if hasattr(obj, prop):
setattr(attributes, prop, obj.getPropertyByName(prop))
else:
# if the template shape has a new attribute defined we should add that
# to the local object
self._setupProperty(obj, prop, attributes)
propNames = obj.BitPropertyNames
propNames.append(prop)
obj.BitPropertyNames = propNames
self._copyBitShape(obj)
def _copyBitShape(self, obj):
obj.Document.recompute()
if obj.BitBody and obj.BitBody.Shape:
obj.Shape = obj.BitBody.Shape
else:
obj.Shape = Part.Shape()
def _loadBitBody(self, obj, path=None):
Path.Log.track(obj.Label, path)
p = path if path else obj.BitShape
docOpened = False
doc = None
for d in FreeCAD.listDocuments():
if FreeCAD.getDocument(d).FileName == p:
doc = FreeCAD.getDocument(d)
break
if doc is None:
p = findToolShape(p, path if path else obj.File)
if p is None:
raise FileNotFoundError
if not path and p != obj.BitShape:
obj.BitShape = p
Path.Log.debug("ToolBit {} using shape file: {}".format(obj.Label, p))
doc = FreeCAD.openDocument(p, True)
obj.ShapeName = doc.Name
docOpened = True
else:
Path.Log.debug("ToolBit {} already open: {}".format(obj.Label, doc))
return (doc, docOpened)
def _removeBitBody(self, obj):
if obj.BitBody:
obj.BitBody.removeObjectsFromDocument()
obj.Document.removeObject(obj.BitBody.Name)
obj.BitBody = None
def _deleteBitSetup(self, obj):
Path.Log.track(obj.Label)
self._removeBitBody(obj)
self._copyBitShape(obj)
for prop in obj.BitPropertyNames:
obj.removeProperty(prop)
def loadBitBody(self, obj, force=False):
if force or not obj.BitBody:
activeDoc = FreeCAD.ActiveDocument
if force:
self._removeBitBody(obj)
(doc, opened) = self._loadBitBody(obj)
obj.BitBody = obj.Document.copyObject(doc.RootObjects[0], True)
if opened:
FreeCAD.setActiveDocument(activeDoc.Name)
FreeCAD.closeDocument(doc.Name)
self._updateBitShape(obj)
def unloadBitBody(self, obj):
self._removeBitBody(obj)
def _setupProperty(self, obj, prop, orig):
# extract property parameters and values so it can be copied
val = orig.getPropertyByName(prop)
typ = orig.getTypeIdOfProperty(prop)
grp = orig.getGroupOfProperty(prop)
dsc = orig.getDocumentationOfProperty(prop)
obj.addProperty(typ, prop, grp, dsc)
if "App::PropertyEnumeration" == typ:
setattr(obj, prop, orig.getEnumerationsOfProperty(prop))
obj.setEditorMode(prop, 1)
PathUtil.setProperty(obj, prop, val)
def _setupBitShape(self, obj, path=None):
Path.Log.track(obj.Label)
activeDoc = FreeCAD.ActiveDocument
try:
(doc, docOpened) = self._loadBitBody(obj, path)
except FileNotFoundError:
Path.Log.error(
"Could not find shape file {} for tool bit {}".format(obj.BitShape, obj.Label)
)
return
obj.Label = doc.RootObjects[0].Label
self._deleteBitSetup(obj)
bitBody = obj.Document.copyObject(doc.RootObjects[0], True)
docName = doc.Name
if docOpened:
FreeCAD.setActiveDocument(activeDoc.Name)
FreeCAD.closeDocument(doc.Name)
if bitBody.ViewObject:
bitBody.ViewObject.Visibility = False
Path.Log.debug("bitBody.{} ({}): {}".format(bitBody.Label, bitBody.Name, type(bitBody)))
propNames = []
for attributes in [o for o in bitBody.Group if PathPropertyBag.IsPropertyBag(o)]:
Path.Log.debug("Process properties from {}".format(attributes.Label))
for prop in attributes.Proxy.getCustomProperties():
self._setupProperty(obj, prop, attributes)
propNames.append(prop)
if not propNames:
Path.Log.error(
"Did not find a PropertyBag in {} - not a ToolBit shape?".format(docName)
)
# has to happen last because it could trigger op.execute evaluations
obj.BitPropertyNames = propNames
obj.BitBody = bitBody
self._copyBitShape(obj)
def toolShapeProperties(self, obj):
"""toolShapeProperties(obj) ... return all properties defining it's shape"""
return sorted(
[
prop
for prop in obj.BitPropertyNames
if obj.getGroupOfProperty(prop) == PropertyGroupShape
]
)
def toolAdditionalProperties(self, obj):
"""toolShapeProperties(obj) ... return all properties unrelated to it's shape"""
return sorted(
[
prop
for prop in obj.BitPropertyNames
if obj.getGroupOfProperty(prop) != PropertyGroupShape
]
)
def toolGroupsAndProperties(self, obj, includeShape=True):
"""toolGroupsAndProperties(obj) ... returns a dictionary of group names with a list of property names."""
category = {}
for prop in obj.BitPropertyNames:
group = obj.getGroupOfProperty(prop)
if includeShape or group != PropertyGroupShape:
properties = category.get(group, [])
properties.append(prop)
category[group] = properties
return category
def getBitThumbnail(self, obj):
if obj.BitShape:
path = findToolShape(obj.BitShape)
if path:
with open(path, "rb") as fd:
try:
zf = zipfile.ZipFile(fd)
pf = zf.open("thumbnails/Thumbnail.png", "r")
data = pf.read()
pf.close()
return data
except KeyError:
pass
return None
def saveToFile(self, obj, path, setFile=True):
Path.Log.track(path)
try:
with open(path, "w") as fp:
json.dump(self.templateAttrs(obj), fp, indent=" ")
if setFile:
obj.File = path
return True
except (OSError, IOError) as e:
Path.Log.error("Could not save tool {} to {} ({})".format(obj.Label, path, e))
raise
def templateAttrs(self, obj):
attrs = {}
attrs["version"] = 2
attrs["name"] = obj.Label
if Path.Preferences.toolsStoreAbsolutePaths():
attrs["shape"] = obj.BitShape
else:
# attrs['shape'] = findRelativePathShape(obj.BitShape)
# Extract the name of the shape file
__, filShp = os.path.split(
obj.BitShape
) # __ is an ignored placeholder acknowledged by LGTM
attrs["shape"] = str(filShp)
params = {}
for name in obj.BitPropertyNames:
params[name] = PathUtil.getPropertyValueString(obj, name)
attrs["parameter"] = params
params = {}
attrs["attribute"] = params
return attrs
def Declaration(path):
Path.Log.track(path)
with open(path, "r") as fp:
return json.load(fp)
class ToolBitFactory(object):
def CreateFromAttrs(self, attrs, name="ToolBit", path=None):
Path.Log.track(attrs, path)
obj = Factory.Create(name, attrs["shape"], path)
obj.Label = attrs["name"]
params = attrs["parameter"]
for prop in params:
PathUtil.setProperty(obj, prop, params[prop])
attributes = attrs["attribute"]
for att in attributes:
PathUtil.setProperty(obj, att, attributes[att])
obj.Proxy._updateBitShape(obj)
obj.Proxy.unloadBitBody(obj)
return obj
def CreateFrom(self, path, name="ToolBit"):
Path.Log.track(name, path)
if not os.path.isfile(path):
raise FileNotFoundError(f"{path} not found")
try:
data = Declaration(path)
bit = Factory.CreateFromAttrs(data, name, path)
return bit
except (OSError, IOError) as e:
Path.Log.error("%s not a valid tool file (%s)" % (path, e))
raise
def Create(self, name="ToolBit", shapeFile=None, path=None):
Path.Log.track(name, shapeFile, path)
obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", name)
obj.Proxy = ToolBit(obj, shapeFile, path)
return obj
Factory = ToolBitFactory()

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2015 Dan Falck <ddfalck@gmail.com> *
# * 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) *
@@ -25,7 +26,7 @@
from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
import Path
import Path.Tool.Bit as PathToolBit
from Path.Tool.toolbit import ToolBit
import Path.Base.Generator.toolchange as toolchange
@@ -113,7 +114,7 @@ class ToolController:
self.ensureToolBit(obj)
@classmethod
def propertyEnumerations(self, dataType="data"):
def propertyEnumerations(cls, dataType="data"):
"""helixOpPropertyEnumerations(dataType="data")... return property enumeration lists of specified dataType.
Args:
dataType = 'data', 'raw', 'translated'
@@ -182,13 +183,11 @@ class ToolController:
obj.ToolNumber = int(template.get(ToolControllerTemplate.ToolNumber))
if template.get(ToolControllerTemplate.Tool):
self.ensureToolBit(obj)
toolVersion = template.get(ToolControllerTemplate.Tool).get(
ToolControllerTemplate.Version
)
tool_data = template.get(ToolControllerTemplate.Tool)
toolVersion = tool_data.get(ToolControllerTemplate.Version)
if toolVersion == 2:
obj.Tool = PathToolBit.Factory.CreateFromAttrs(
template.get(ToolControllerTemplate.Tool)
)
toolbit_instance = ToolBit.from_dict(tool_data)
obj.Tool = toolbit_instance.attach_to_doc(doc=obj.Document)
else:
obj.Tool = None
if toolVersion == 1:
@@ -230,7 +229,7 @@ class ToolController:
attrs[ToolControllerTemplate.HorizRapid] = "%s" % (obj.HorizRapid)
attrs[ToolControllerTemplate.SpindleSpeed] = obj.SpindleSpeed
attrs[ToolControllerTemplate.SpindleDir] = obj.SpindleDir
attrs[ToolControllerTemplate.Tool] = obj.Tool.Proxy.templateAttrs(obj.Tool)
attrs[ToolControllerTemplate.Tool] = obj.Tool.Proxy.to_dict()
expressions = []
for expr in obj.ExpressionEngine:
Path.Log.debug("%s: %s" % (expr[0], expr[1]))
@@ -251,26 +250,9 @@ class ToolController:
"toolnumber": obj.ToolNumber,
"toollabel": obj.Label,
"spindlespeed": obj.SpindleSpeed,
"spindledirection": toolchange.SpindleDirection.OFF,
"spindledirection": obj.Tool.Proxy.get_spindle_direction(),
}
if hasattr(obj.Tool, "SpindlePower"):
if not obj.Tool.SpindlePower:
args["spindledirection"] = toolchange.SpindleDirection.OFF
else:
if obj.SpindleDir == "Forward":
args["spindledirection"] = toolchange.SpindleDirection.CW
else:
args["spindledirection"] = toolchange.SpindleDirection.CCW
elif obj.SpindleDir == "None":
args["spindledirection"] = toolchange.SpindleDirection.OFF
else:
if obj.SpindleDir == "Forward":
args["spindledirection"] = toolchange.SpindleDirection.CW
else:
args["spindledirection"] = toolchange.SpindleDirection.CCW
commands = toolchange.generate(**args)
path = Path.Path(commands)
@@ -314,7 +296,10 @@ def Create(
if assignTool:
if not tool:
tool = PathToolBit.Factory.Create()
# Create a default endmill tool bit and attach it to a new DocumentObject
toolbit = ToolBit.from_shape_id("endmill.fcstd")
Path.Log.info(f"Controller.Create: Created toolbit with ID: {toolbit.id}")
tool = toolbit.attach_to_doc(doc=FreeCAD.ActiveDocument)
if tool.ViewObject:
tool.ViewObject.Visibility = False
obj.Tool = tool

View File

@@ -1,270 +0,0 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@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 QtCore, QtGui
from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
import FreeCADGui
import Path
import Path.Base.Gui.IconViewProvider as PathIconViewProvider
import Path.Tool.Bit as PathToolBit
import Path.Tool.Gui.BitEdit as PathToolBitEdit
import os
__title__ = "Tool Bit UI"
__author__ = "sliptonic (Brad Collette)"
__url__ = "https://www.freecad.org"
__doc__ = "Task panel editor for a ToolBit"
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
translate = FreeCAD.Qt.translate
class ViewProvider(object):
"""ViewProvider for a ToolBit.
It's sole job is to provide an icon and invoke the TaskPanel on edit."""
def __init__(self, vobj, name):
Path.Log.track(name, vobj.Object)
self.panel = None
self.icon = name
self.obj = vobj.Object
self.vobj = vobj
vobj.Proxy = self
def attach(self, vobj):
Path.Log.track(vobj.Object)
self.vobj = vobj
self.obj = vobj.Object
def getIcon(self):
png = self.obj.Proxy.getBitThumbnail(self.obj)
if png:
pixmap = QtGui.QPixmap()
pixmap.loadFromData(png, "PNG")
return QtGui.QIcon(pixmap)
return ":/icons/CAM_ToolBit.svg"
def dumps(self):
return None
def loads(self, state):
return None
def onDelete(self, vobj, arg2=None):
Path.Log.track(vobj.Object.Label)
vobj.Object.Proxy.onDelete(vobj.Object)
def getDisplayMode(self, mode):
return "Default"
def _openTaskPanel(self, vobj, deleteOnReject):
Path.Log.track()
self.panel = TaskPanel(vobj, deleteOnReject)
FreeCADGui.Control.closeDialog()
FreeCADGui.Control.showDialog(self.panel)
self.panel.setupUi()
def setCreate(self, vobj):
Path.Log.track()
self._openTaskPanel(vobj, True)
def setEdit(self, vobj, mode=0):
self._openTaskPanel(vobj, False)
return True
def unsetEdit(self, vobj, mode):
FreeCADGui.Control.closeDialog()
self.panel = None
return
def claimChildren(self):
if self.obj.BitBody:
return [self.obj.BitBody]
return []
def doubleClicked(self, vobj):
if os.path.exists(vobj.Object.BitShape):
self.setEdit(vobj)
else:
msg = translate("CAM_Toolbit", "Toolbit cannot be edited: Shapefile not found")
diag = QtGui.QMessageBox(QtGui.QMessageBox.Warning, "Error", msg)
diag.setWindowModality(QtCore.Qt.ApplicationModal)
diag.exec_()
class TaskPanel:
"""TaskPanel for the SetupSheet - if it is being edited directly."""
def __init__(self, vobj, deleteOnReject):
Path.Log.track(vobj.Object.Label)
self.vobj = vobj
self.obj = vobj.Object
self.editor = PathToolBitEdit.ToolBitEditor(self.obj)
self.form = self.editor.form
self.deleteOnReject = deleteOnReject
FreeCAD.ActiveDocument.openTransaction("Edit ToolBit")
def reject(self):
FreeCAD.ActiveDocument.abortTransaction()
self.editor.reject()
FreeCADGui.Control.closeDialog()
if self.deleteOnReject:
FreeCAD.ActiveDocument.openTransaction("Uncreate ToolBit")
self.editor.reject()
FreeCAD.ActiveDocument.removeObject(self.obj.Name)
FreeCAD.ActiveDocument.commitTransaction()
FreeCAD.ActiveDocument.recompute()
def accept(self):
self.editor.accept()
FreeCAD.ActiveDocument.commitTransaction()
FreeCADGui.ActiveDocument.resetEdit()
FreeCADGui.Control.closeDialog()
FreeCAD.ActiveDocument.recompute()
def updateUI(self):
Path.Log.track()
self.editor.updateUI()
def updateModel(self):
self.editor.updateTool()
FreeCAD.ActiveDocument.recompute()
def setupUi(self):
self.editor.setupUI()
class ToolBitGuiFactory(PathToolBit.ToolBitFactory):
def Create(self, name="ToolBit", shapeFile=None, path=None):
"""Create(name = 'ToolBit') ... creates a new tool bit.
It is assumed the tool will be edited immediately so the internal bit body is still attached.
"""
Path.Log.track(name, shapeFile, path)
FreeCAD.ActiveDocument.openTransaction("Create ToolBit")
tool = PathToolBit.ToolBitFactory.Create(self, name, shapeFile, path)
PathIconViewProvider.Attach(tool.ViewObject, name)
FreeCAD.ActiveDocument.commitTransaction()
return tool
def isValidFileName(filename):
print(filename)
try:
with open(filename, "w") as tempfile:
return True
except Exception:
return False
def GetNewToolFile(parent=None):
if parent is None:
parent = QtGui.QApplication.activeWindow()
foo = QtGui.QFileDialog.getSaveFileName(
parent, translate("CAM_Toolbit", "Tool"), Path.Preferences.lastPathToolBit(), "*.fctb"
)
if foo and foo[0]:
if not isValidFileName(foo[0]):
msgBox = QtGui.QMessageBox()
msg = translate("CAM_Toolbit", "Invalid Filename")
msgBox.setText(msg)
msgBox.exec_()
else:
Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0]))
return foo[0]
return None
def GetToolFile(parent=None):
if parent is None:
parent = QtGui.QApplication.activeWindow()
foo = QtGui.QFileDialog.getOpenFileName(
parent, "Tool", Path.Preferences.lastPathToolBit(), "*.fctb"
)
if foo and foo[0]:
Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0]))
return foo[0]
return None
def GetToolFiles(parent=None):
if parent is None:
parent = QtGui.QApplication.activeWindow()
foo = QtGui.QFileDialog.getOpenFileNames(
parent, "Tool", Path.Preferences.lastPathToolBit(), "*.fctb"
)
if foo and foo[0]:
Path.Preferences.setLastPathToolBit(os.path.dirname(foo[0][0]))
return foo[0]
return []
def GetToolShapeFile(parent=None):
if parent is None:
parent = QtGui.QApplication.activeWindow()
location = Path.Preferences.lastPathToolShape()
if os.path.isfile(location):
location = os.path.split(location)[0]
elif not os.path.isdir(location):
location = Path.Preferences.filePath()
fname = QtGui.QFileDialog.getOpenFileName(
parent, translate("CAM_Toolbit", "Select Tool Shape"), location, "*.fcstd"
)
if fname and fname[0]:
if fname != location:
newloc = os.path.dirname(fname[0])
Path.Preferences.setLastPathToolShape(newloc)
return fname[0]
else:
return None
def LoadTool(parent=None):
"""
LoadTool(parent=None) ... Open a file dialog to load a tool from a file.
"""
foo = GetToolFile(parent)
return PathToolBit.Factory.CreateFrom(foo) if foo else foo
def LoadTools(parent=None):
"""
LoadTool(parent=None) ... Open a file dialog to load a tool from a file.
"""
return [PathToolBit.Factory.CreateFrom(foo) for foo in GetToolFiles(parent)]
# Set the factory so all tools are created with UI
PathToolBit.Factory = ToolBitGuiFactory()
PathIconViewProvider.RegisterViewProvider("ToolBit", ViewProvider)

View File

@@ -1,275 +0,0 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@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 QtCore, QtGui
import FreeCADGui
import Path
import Path.Base.Gui.PropertyEditor as PathPropertyEditor
import Path.Base.Gui.Util as PathGuiUtil
import Path.Base.Util as PathUtil
import os
import re
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
class _Delegate(QtGui.QStyledItemDelegate):
"""Handles the creation of an appropriate editing widget for a given property."""
ObjectRole = QtCore.Qt.UserRole + 1
PropertyRole = QtCore.Qt.UserRole + 2
EditorRole = QtCore.Qt.UserRole + 3
def createEditor(self, parent, option, index):
editor = index.data(self.EditorRole)
if editor is None:
obj = index.data(self.ObjectRole)
prp = index.data(self.PropertyRole)
editor = PathPropertyEditor.Editor(obj, prp)
index.model().setData(index, editor, self.EditorRole)
return editor.widget(parent)
def setEditorData(self, widget, index):
# called to update the widget with the current data
index.data(self.EditorRole).setEditorData(widget)
def setModelData(self, widget, model, index):
# called to update the model with the data from the widget
editor = index.data(self.EditorRole)
editor.setModelData(widget)
index.model().setData(
index,
PathUtil.getPropertyValueString(editor.obj, editor.prop),
QtCore.Qt.DisplayRole,
)
class ToolBitEditor(object):
"""UI and controller for editing a ToolBit.
The controller embeds the UI to the parentWidget which has to have a
layout attached to it.
"""
def __init__(self, tool, parentWidget=None, loadBitBody=True):
Path.Log.track()
self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui")
if parentWidget:
self.form.setParent(parentWidget)
parentWidget.layout().addWidget(self.form)
self.tool = tool
self.loadbitbody = loadBitBody
if not tool.BitShape:
self.tool.BitShape = "endmill.fcstd"
if self.loadbitbody:
self.tool.Proxy.loadBitBody(self.tool)
# remove example widgets
layout = self.form.bitParams.layout()
for i in range(layout.rowCount() - 1, -1, -1):
layout.removeRow(i)
# used to track property widgets and editors
self.widgets = []
self.setupTool(self.tool)
self.setupAttributes(self.tool)
def setupTool(self, tool):
Path.Log.track()
# Can't delete and add fields to the form because of dangling references in case of
# a focus change. see https://forum.freecad.org/viewtopic.php?f=10&t=52246#p458583
# Instead we keep widgets once created and use them for new properties, and hide all
# which aren't being needed anymore.
def labelText(name):
return re.sub(r"([A-Z][a-z]+)", r" \1", re.sub(r"([A-Z]+)", r" \1", name))
layout = self.form.bitParams.layout()
ui = FreeCADGui.UiLoader()
# for all properties either assign them to existing labels and editors
# or create additional ones for them if not enough have already been
# created.
usedRows = 0
for nr, name in enumerate(tool.Proxy.toolShapeProperties(tool)):
if nr < len(self.widgets):
Path.Log.debug("reuse row: {} [{}]".format(nr, name))
label, qsb, editor = self.widgets[nr]
label.setText(labelText(name))
editor.attachTo(tool, name)
label.show()
qsb.show()
else:
qsb = ui.createWidget("Gui::QuantitySpinBox")
editor = PathGuiUtil.QuantitySpinBox(qsb, tool, name)
label = QtGui.QLabel(labelText(name))
self.widgets.append((label, qsb, editor))
Path.Log.debug("create row: {} [{}] {}".format(nr, name, type(qsb)))
if hasattr(qsb, "editingFinished"):
qsb.editingFinished.connect(self.updateTool)
if nr >= layout.rowCount():
layout.addRow(label, qsb)
usedRows = usedRows + 1
# hide all rows which aren't being used
Path.Log.track(usedRows, len(self.widgets))
for i in range(usedRows, len(self.widgets)):
label, qsb, editor = self.widgets[i]
label.hide()
qsb.hide()
editor.attachTo(None)
Path.Log.debug(" hide row: {}".format(i))
img = tool.Proxy.getBitThumbnail(tool)
if img:
self.form.image.setPixmap(QtGui.QPixmap(QtGui.QImage.fromData(img)))
else:
self.form.image.setPixmap(QtGui.QPixmap())
def setupAttributes(self, tool):
Path.Log.track()
setup = True
if not hasattr(self, "delegate"):
self.delegate = _Delegate(self.form.attrTree)
self.model = QtGui.QStandardItemModel(self.form.attrTree)
self.model.setHorizontalHeaderLabels(["Property", "Value"])
else:
self.model.removeRows(0, self.model.rowCount())
setup = False
attributes = tool.Proxy.toolGroupsAndProperties(tool, False)
for name in attributes:
group = QtGui.QStandardItem()
group.setData(name, QtCore.Qt.EditRole)
group.setEditable(False)
for prop in attributes[name]:
label = QtGui.QStandardItem()
label.setData(prop, QtCore.Qt.EditRole)
label.setEditable(False)
value = QtGui.QStandardItem()
value.setData(PathUtil.getPropertyValueString(tool, prop), QtCore.Qt.DisplayRole)
value.setData(tool, _Delegate.ObjectRole)
value.setData(prop, _Delegate.PropertyRole)
group.appendRow([label, value])
self.model.appendRow(group)
if setup:
self.form.attrTree.setModel(self.model)
self.form.attrTree.setItemDelegateForColumn(1, self.delegate)
self.form.attrTree.expandAll()
self.form.attrTree.resizeColumnToContents(0)
self.form.attrTree.resizeColumnToContents(1)
# self.form.attrTree.collapseAll()
def accept(self):
Path.Log.track()
self.refresh()
self.tool.Proxy.unloadBitBody(self.tool)
def reject(self):
Path.Log.track()
self.tool.Proxy.unloadBitBody(self.tool)
def updateUI(self):
Path.Log.track()
self.form.toolName.setText(self.tool.Label)
self.form.shapePath.setText(self.tool.BitShape)
for lbl, qsb, editor in self.widgets:
editor.updateSpinBox()
def _updateBitShape(self, shapePath):
# Only need to go through this exercise if the shape actually changed.
if self.tool.BitShape != shapePath:
# Before setting a new bitshape we need to make sure that none of
# editors fires an event and tries to access its old property, which
# might not exist anymore.
for lbl, qsb, editor in self.widgets:
editor.attachTo(self.tool, "File")
self.tool.BitShape = shapePath
self.setupTool(self.tool)
self.form.toolName.setText(self.tool.Label)
if self.tool.BitBody and self.tool.BitBody.ViewObject:
if not self.tool.BitBody.ViewObject.Visibility:
self.tool.BitBody.ViewObject.Visibility = True
self.setupAttributes(self.tool)
return True
return False
def updateShape(self):
Path.Log.track()
shapePath = str(self.form.shapePath.text())
# Only need to go through this exercise if the shape actually changed.
if self._updateBitShape(shapePath):
for lbl, qsb, editor in self.widgets:
editor.updateSpinBox()
def updateTool(self):
Path.Log.track()
label = str(self.form.toolName.text())
shape = str(self.form.shapePath.text())
if self.tool.Label != label:
self.tool.Label = label
self._updateBitShape(shape)
for lbl, qsb, editor in self.widgets:
editor.updateProperty()
self.tool.Proxy._updateBitShape(self.tool)
def refresh(self):
Path.Log.track()
self.form.blockSignals(True)
self.updateTool()
self.updateUI()
self.form.blockSignals(False)
def selectShape(self):
Path.Log.track()
path = self.tool.BitShape
if not path:
path = Path.Preferences.lastPathToolShape()
foo = QtGui.QFileDialog.getOpenFileName(self.form, "Path - Tool Shape", path, "*.fcstd")
if foo and foo[0]:
Path.Preferences.setLastPathToolShape(os.path.dirname(foo[0]))
self.form.shapePath.setText(foo[0])
self.updateShape()
def setupUI(self):
Path.Log.track()
self.updateUI()
self.form.toolName.editingFinished.connect(self.refresh)
self.form.shapePath.editingFinished.connect(self.updateShape)
self.form.shapeSet.clicked.connect(self.selectShape)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 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) *
@@ -20,6 +21,7 @@
# * *
# ***************************************************************************
from lazy_loader.lazy_loader import LazyLoader
from PySide import QtCore, QtGui
from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
@@ -28,11 +30,7 @@ import Path
import Path.Base.Gui.Util as PathGuiUtil
import Path.Base.Util as PathUtil
import Path.Tool.Controller as PathToolController
import Path.Tool.Gui.Bit as PathToolBitGui
import PathGui
# lazily loaded modules
from lazy_loader.lazy_loader import LazyLoader
from Path.Tool.toolbit.ui.selector import ToolBitSelector
Part = LazyLoader("Part", globals(), "Part")
@@ -162,19 +160,30 @@ class CommandPathToolController(object):
def Activated(self):
Path.Log.track()
job = self.selectedJob()
if job:
tool = PathToolBitGui.ToolBitSelector().getTool()
if tool:
toolNr = None
for tc in job.Tools.Group:
if tc.Tool == tool:
toolNr = tc.ToolNumber
break
if not toolNr:
toolNr = max([tc.ToolNumber for tc in job.Tools.Group]) + 1
tc = Create("TC: {}".format(tool.Label), tool, toolNr)
job.Proxy.addToolController(tc)
FreeCAD.ActiveDocument.recompute()
if not job:
return
# Let the user select a toolbit
selector = ToolBitSelector()
if not selector.exec_():
return
tool = selector.get_selected_tool()
if not tool:
return
# Find a tool number
toolNr = None
for tc in job.Tools.Group:
if tc.Tool == tool:
toolNr = tc.ToolNumber
break
if not toolNr:
toolNr = max([tc.ToolNumber for tc in job.Tools.Group]) + 1
# Create the new tool controller with the tool.
tc = Create("TC: {}".format(tool.Label), tool, toolNr)
job.Proxy.addToolController(tc)
FreeCAD.ActiveDocument.recompute()
class ToolControllerEditor(object):

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
import sys
from lazy_loader.lazy_loader import LazyLoader
from . import toolbit
from .assets import DummyAssetSerializer
from .camassets import cam_assets
from .library import Library
from .library.serializers import FCTLSerializer
from .toolbit import ToolBit
from .toolbit.serializers import FCTBSerializer
from .shape import ToolBitShape, ToolBitShapePngIcon, ToolBitShapeSvgIcon
from .machine import Machine
# Register asset classes and serializers.
cam_assets.register_asset(Library, FCTLSerializer)
cam_assets.register_asset(ToolBit, FCTBSerializer)
cam_assets.register_asset(ToolBitShape, DummyAssetSerializer)
cam_assets.register_asset(ToolBitShapePngIcon, DummyAssetSerializer)
cam_assets.register_asset(ToolBitShapeSvgIcon, DummyAssetSerializer)
cam_assets.register_asset(Machine, DummyAssetSerializer)
# For backward compatibility with files saved before the toolbit rename
# This makes the Path.Tool.toolbit.base module available as Path.Tool.Bit.
# Since C++ does not use the standard Python import mechanism and instead
# unpickles existing objects after looking them up in sys.modules, we
# need to update sys.modules here.
sys.modules[__name__ + ".Bit"] = toolbit.models.base
sys.modules[__name__ + ".Gui.Bit"] = LazyLoader(
"Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view"
)
# Define __all__ for explicit public interface
__all__ = [
"ToolBit",
"cam_assets",
]

View File

@@ -0,0 +1,323 @@
# Asset Management Module
This module implements an asset manager that provides methods for storing,
updating, deleting, and receiving assets for the FreeCAD CAM workbench.
## Goals of the asset manager
While currently the AssetManager has no UI yet, the plan is to add one.
The ultimate vision for the asset manager is to provide a unified UI that
can download assets from arbitrary sources, such as online databases,
Git repositories, and also local storage. It should also allow for copying
between these storages, effectively allowing for publishing assets.
Essentially, something similar to what Blender has:
![Blender Asset Manager](docs/blender-assets.jpg)
## What are assets in CAM?
Assets are arbitrary data, such as FreeCAD models, Tools, and many more.
Specifically in the context of CAM, assets are:
- Tool bit libraries
- Tool bits
- Tool bit shape files
- Tool bit shape icons
- Machines
- Fixtures
- Post processors
- ...
**Assets have dependencies:** For example, a ToolBitLibrary requires ToolBits,
and a ToolBit requires a ToolBitShape (which is a FreeCAD model).
## Challenges
In the current codebase, CAM objects are monoliths that handle everything:
in-memory data, serialization, deserialization, storage. They are tightly
coupled to files, and make assumptions about how other objects are stored.
Examples:
- Tool bits have "File" attributes that they use to collect dependencies
such as ToolBit files and shape files.
- It also writes directly to the disk.
- GuiToolBit performs serialization directly in UI functions.
- ToolBits could not be created without an active document.
As the code base grows, separation of concerns becomes more important.
Managing dependencies between asset becomes a hassle if every object tries
to resolve them in their own way.
# Solution
The main effort went into two key areas:
1. **The generic AssetManager:**
- **Manages storage** while existing FreeCAD tool library file structures retained
- **Manages dependencies** including detection of cyclic dependencies, deep vs. shallow fetching
- **Manages threading** for asynchronous storage, while FreeCAD objects are assembled in the main UI thread
- **Defining a generic asset interface** that classes can implement to become "storable"
2. **Refactoring existing CAM objects for clear separation of concerns:**
- **View**: Should handle user interface only. Existing file system access methods were removed.
- **Object Model**: In-memory representation of an object, for example a ToolBit, Icon, or a ToolBitShape. By giving all classes `from_bytes()` and `to_bytes()` methods; the objects no longer need to handle storage themselves.
- **Storage**: Persisting an object to a file system, database system, or API. This is now handled by the AssetManager
- **Serialization** A serialization protocol needs to be defined. This will allow for better import/export mechanisms in the future
FreeCAD is now fully usable with the changes in place. Work remains to be done on the serializers.
## Asset Manager API usage example
```python
import pathlib
from typing import Any, Mapping, List, Type
from Path.Tool.assets import AssetManager, FileStore, AssetUri, Asset
# Define a simple Material class implementing the Asset interface
class Material(Asset):
asset_type: str = "material"
def __init__(self, name: str):
self.name = name
def get_id() -> str:
return self.name.lower().replace(" ", "-")
@classmethod
def dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def from_bytes(cls, data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]]) -> Material:
return cls(data.decode('utf-8'))
def to_bytes(self) -> bytes:
return self.name.encode('utf-8')
manager = AssetManager()
# Register FileStore and the simple asset class
manager.register_store(FileStore("local", pathlib.Path("/tmp/assets")))
manager.register_asset(Material)
# Create and get an asset
asset_uri = manager.add(Material("Copper"))
print(f"Stored with URI: {asset_uri}")
retrieved_asset = manager.get(asset_uri)
print(f"Retrieved: {retrieved_asset}")
```
## The Serializer Protocol
The serializer protocol defines how assets are converted to and from bytes and how their
dependencies are identified. This separation of concerns allows assets to be stored and
retrieved independently of their specific serialization format.
The core components of the protocol are the [`Asset`](src/Mod/CAM/Path/Tool/assets/asset.py:11)
and [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) classes.
- The [`Asset`](src/Mod/CAM/Path/Tool/assets/asset.py:11) class represents an asset object in
memory. It provides methods like [`to_bytes()`](src/Mod/CAM/Path/Tool/assets/asset.py:69)
and [`from_bytes()`](src/Mod/CAM/Path/Tool/assets/asset.py:56) which delegate the actual
serialization and deserialization to an [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8)
instance. It also has an [`extract_dependencies()`](src/Mod/CAM/Path/Tool/assets/asset.py:49)
method that uses the serializer to find dependencies within the raw asset data.
- The [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) is an abstract base
class that defines the interface for serializers. Concrete implementations of
[`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8) are responsible for the
specific logic of converting an asset object to bytes ([`serialize()`](src/Mod/CAM/Path/Tool/assets/serializer.py:21)),
converting bytes back to an asset object ([`deserialize()`](src/Mod/CAM/Path/Tool/assets/serializer.py:27)),
and extracting dependency URIs from the raw byte data
([`extract_dependencies()`](src/Mod/CAM/Path/Tool/assets/serializer.py:15)).
This design allows the AssetManager to work with various asset types and serialization formats
by simply registering the appropriate [`AssetSerializer`](src/Mod/CAM/Path/Tool/assets/serializer.py:8)
for each asset type.
## Class diagram
```mermaid
classDiagram
direction LR
%% -------------- Asset Manager Module --------------
note for AssetManager "AssetUri structure:
&lt;asset_type&gt;:\//&lt;asset_id&gt;[/&lt;version&gt;]<br/>
Examples:
material:\//1234567/1
toolbitshape:\//endmill/1
material:\//aluminium-6012/2"
class AssetManager["AssetManager
<small>Creates, assembles or deletes assets from URIs</small>"] {
stores: Mapping[str, AssetStore] // maps protocol to store
register_store(store: AssetStore, cacheable: bool = False)
register_asset(asset_class: Type[Asset], serializer: Type[AssetSerializer])
get(uri: AssetUri | str, store: str = "local", depth: Optional[int] = None) Any
get_raw(uri: AssetUri | str, store: str = "local") bytes
add(obj: Asset, store: str = "local") AssetUri
add_raw(asset_type: str, asset_id: str, data: bytes, store: str = "local") AssetUri
delete(uri: AssetUri | str, store: str = "local")
is_empty(asset_type: str | None = None, store: str = "local") bool
list_assets(asset_type: str | None = None, limit: int | None = None, offset: int | None = None, store: str = "local") List[AssetUri]
list_versions(uri: AssetUri | str, store: str = "local") List[AssetUri]
get_bulk(uris: Sequence[AssetUri | str], store: str = "local", depth: Optional[int] = None) List[Any]
fetch(asset_type: str | None = None, limit: int | None = None, offset: int | None = None, store: str = "local", depth: Optional[int] = None) List[Asset]
}
class AssetStore["AssetStore
<small>Stores/Retrieves assets as raw bytes</small>"] {
<<abstract>>
async get(uri: AssetUri) bytes
async count_assets(asset_type: str | None = None) int
async delete(uri: AssetUri)
async create(asset_type: str, asset_id: str, data: bytes) AssetUri
async update(uri: AssetUri, data: bytes) AssetUri
async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri]
async list_versions(uri: AssetUri) List[AssetUri]
async is_empty(asset_type: str | None = None) bool
}
AssetStore *-- AssetManager: has many
class FileStore["FileStore
<small>Stores/Retrieves versioned assets as directories/files</small>"] {
__init__(name: str, filepath: pathlib.Path)
set_dir(new_dir: pathlib.Path)
async get(uri: AssetUri) bytes
async delete(uri: AssetUri)
async create(asset_type: str, asset_id: str, data: bytes) AssetUri
async update(uri: AssetUri, data: bytes) AssetUri
async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri]
async list_versions(uri: AssetUri) List[AssetUri]
async is_empty(asset_type: str | None = None) bool
}
FileStore <|-- AssetStore: is
class MemoryStore["MemoryStore
<small>In-memory store, mostly for testing/demonstration</small>"] {
__init__(name: str)
async get(uri: AssetUri) bytes
async delete(uri: AssetUri)
async create(asset_type: str, asset_id: str, data: bytes) AssetUri
async update(uri: AssetUri, data: bytes) AssetUri
async list_assets(asset_type: str | None, limit: int | None, offset: int | None) List[AssetUri]
async list_versions(uri: AssetUri) List[AssetUri]
async is_empty(asset_type: str | None) bool
dump(print: bool) Dict | None
}
MemoryStore <|-- AssetStore: is
class AssetSerializer["AssetSerializer<br/><small>Abstract base class for asset serializers</small>"] {
<<abstract>>
for_class: Type[Asset]
extensions: Tuple[str]
mime_type: str
extract_dependencies(data: bytes) List[AssetUri]
serialize(asset: Asset) bytes
deserialize(data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]]) Asset
}
AssetSerializer *-- AssetManager: has many
Asset --> AssetSerializer: uses
class Asset["Asset<br/><small>Common interface for all asset types</small>"] {
<<abstract>>
asset_type: str // type of the asset type, e.g., toolbit
get_id() str // Returns a unique ID of the asset
to_bytes(serializer: AssetSerializer) bytes
from_bytes(data: bytes, id: str, dependencies: Optional[Mapping[AssetUri, Asset]], serializer: Type[AssetSerializer]) Asset
extract_dependencies(data: bytes, serializer: Type[AssetSerializer]) List[AssetUri] // Extracts dependency URIs from bytes
}
Asset *-- AssetManager: creates
namespace AssetManagerModule {
class AssetManager
class AssetStore
class FileStore
class MemoryStore
class AssetSerializer
class Asset
}
%% -------------- CAM Module (as an example) --------------
class ToolBitShape["ToolBitShape<br/><small>for assets with type toolbitshape</small>"] {
<<Asset>>
asset_type: str = "toolbitshape"
get_id() str // Returns a unique ID
from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) ToolBitShape
to_bytes(obj: ToolBitShape) bytes
dependencies(data: bytes) List[AssetUri]
}
ToolBitShape ..|> Asset: is
class ToolBit["ToolBit<br/><small>for assets with type toolbit</small>"] {
<<Asset>>
asset_type: str = "toolbit"
get_id() str // Returns a unique ID
from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) ToolBit
to_bytes(obj: ToolBit) bytes
dependencies(data: bytes) List[AssetUri]
}
ToolBit ..|> Asset: is
ToolBit --> ToolBitShape: has
namespace CAMModule {
class ToolBitShape
class ToolBit
}
%% -------------- Materials Module (as an example) --------------
class Material["Material<br/><small>for assets with type material</small>"] {
<<Asset>>
asset_type: str = "material"
get_id() str // Returns a unique ID
dependencies(data: bytes) List[AssetUri]
from_bytes(data: bytes, id: str, dependencies: Dict[AssetUri, Asset]) Material
to_bytes(obj: Material) bytes
}
Material ..|> Asset: is
namespace MaterialModule {
class Material
class Material
}
```
# UI Helpers
The `ui` directory contains helper modules for the asset manager's user interface.
- [`filedialog.py`](src/Mod/CAM/Path/Tool/assets/ui/filedialog.py):
Provides file dialogs for importing and exporting assets.
- [`util.py`](src/Mod/CAM/Path/Tool/assets/ui/util.py): Contains general utility
functions used within the asset manager UI.
# What's next
## Shorter term
- Improving the integration of serializers. Ideally the asset manager could help here too:
We can define a common serializer protocol for **all** assets. It could then become the
central point for imports and exports.
## Potential future extensions (longer term)
- Adding a AssetManager UI, to allow for browsing and searching stores for all kinds of
assets (Machines, Fixtures, Libraries, Tools, Shapes, Post Processors, ...)
from all kings of sources (online DB, git repository, etc.).
- Adding a GitStore, to connect to things like the [FreeCAD library](https://github.com/FreeCAD/FreeCAD-library).
- Adding an HttpStore for connectivity to online databases.

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from .asset import Asset
from .manager import AssetManager
from .uri import AssetUri
from .serializer import AssetSerializer, DummyAssetSerializer
from .store.base import AssetStore
from .store.memory import MemoryStore
from .store.filestore import FileStore
__all__ = [
"Asset",
"AssetUri",
"AssetManager",
"AssetSerializer",
"DummyAssetSerializer",
"AssetStore",
"MemoryStore",
"FileStore",
]

View File

@@ -0,0 +1,91 @@
# -*- 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 __future__ import annotations
import abc
from abc import ABC
from typing import Mapping, List, Optional, Type, TYPE_CHECKING
from .uri import AssetUri
if TYPE_CHECKING:
from .serializer import AssetSerializer
class Asset(ABC):
asset_type: str
def __init__(self, *args, **kwargs):
if not hasattr(self, "asset_type"):
raise ValueError("Asset subclasses must define 'asset_type'.")
@property
def label(self) -> str:
return self.__class__.__name__
@abc.abstractmethod
def get_id(self) -> str:
"""Returns the unique ID of an asset object."""
pass
def get_uri(self) -> AssetUri:
return AssetUri.build(asset_type=self.asset_type, asset_id=self.get_id())
@classmethod
def resolve_name(cls, identifier: str) -> AssetUri:
"""
Resolves an identifier (id, name, or URI) to an AssetUri object.
"""
# 1. If the input is a url string, return the Uri object for it.
if AssetUri.is_uri(identifier):
return AssetUri(identifier)
# 2. Construct the Uri using Uri.build() and return it
return AssetUri.build(
asset_type=cls.asset_type,
asset_id=identifier,
)
@classmethod
def get_uri_from_id(cls, asset_id):
return AssetUri.build(cls.asset_type, asset_id=asset_id)
@classmethod
def extract_dependencies(cls, data: bytes, serializer: Type[AssetSerializer]) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
return serializer.extract_dependencies(data)
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
serializer: Type[AssetSerializer],
) -> Asset:
"""
Creates an object from serialized data and resolved dependencies.
If dependencies is None, it indicates a shallow load where dependencies were not resolved.
"""
return serializer.deserialize(data, id, dependencies)
def to_bytes(self, serializer: Type[AssetSerializer]) -> bytes:
"""Serializes an object into bytes."""
return serializer.serialize(self)

View File

@@ -0,0 +1,172 @@
# -*- 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 time
import hashlib
import logging
from collections import OrderedDict
from typing import Any, Dict, Set, NamedTuple, Optional, Tuple
# For type hinting Asset and AssetUri to avoid circular imports
# from typing import TYPE_CHECKING
# if TYPE_CHECKING:
# from .asset import Asset
# from .uri import AssetUri
logger = logging.getLogger(__name__)
class CacheKey(NamedTuple):
store_name: str
asset_uri_str: str
raw_data_hash: int
dependency_signature: Tuple
class CachedAssetEntry(NamedTuple):
asset: Any # Actually type Asset
size_bytes: int # Estimated size of the raw_data
timestamp: float # For LRU, or just use OrderedDict nature
class AssetCache:
def __init__(self, max_size_bytes: int = 100 * 1024 * 1024): # Default 100MB
self.max_size_bytes: int = max_size_bytes
self.current_size_bytes: int = 0
self._cache: Dict[CacheKey, CachedAssetEntry] = {}
self._lru_order: OrderedDict[CacheKey, None] = OrderedDict()
self._cache_dependents_map: Dict[str, Set[CacheKey]] = {}
self._cache_dependencies_map: Dict[CacheKey, Set[str]] = {}
def _evict_lru(self):
while self.current_size_bytes > self.max_size_bytes and self._lru_order:
oldest_key, _ = self._lru_order.popitem(last=False)
if oldest_key in self._cache:
evicted_entry = self._cache.pop(oldest_key)
self.current_size_bytes -= evicted_entry.size_bytes
logger.debug(
f"Cache Evict (LRU): {oldest_key}, "
f"size {evicted_entry.size_bytes}. "
f"New size: {self.current_size_bytes}"
)
self._remove_key_from_dependency_maps(oldest_key)
def _remove_key_from_dependency_maps(self, cache_key_to_remove: CacheKey):
direct_deps_of_removed = self._cache_dependencies_map.pop(cache_key_to_remove, set())
for dep_uri_str in direct_deps_of_removed:
if dep_uri_str in self._cache_dependents_map:
self._cache_dependents_map[dep_uri_str].discard(cache_key_to_remove)
if not self._cache_dependents_map[dep_uri_str]:
del self._cache_dependents_map[dep_uri_str]
def get(self, key: CacheKey) -> Optional[Any]:
if key in self._cache:
self._lru_order.move_to_end(key)
logger.debug(f"Cache HIT: {key}")
return self._cache[key].asset
logger.debug(f"Cache MISS: {key}")
return None
def put(
self,
key: CacheKey,
asset: Any,
raw_data_size_bytes: int,
direct_dependency_uri_strs: Set[str],
):
if key in self._cache:
self._remove_key_from_dependency_maps(key)
self.current_size_bytes -= self._cache[key].size_bytes
del self._cache[key]
self._lru_order.pop(key, None)
if raw_data_size_bytes > self.max_size_bytes:
logger.warning(
f"Asset {key.asset_uri_str} (size {raw_data_size_bytes}) "
f"too large for cache (max {self.max_size_bytes}). Not caching."
)
return
self.current_size_bytes += raw_data_size_bytes
entry = CachedAssetEntry(asset=asset, size_bytes=raw_data_size_bytes, timestamp=time.time())
self._cache[key] = entry
self._lru_order[key] = None
self._lru_order.move_to_end(key)
self._cache_dependencies_map[key] = direct_dependency_uri_strs
for dep_uri_str in direct_dependency_uri_strs:
self._cache_dependents_map.setdefault(dep_uri_str, set()).add(key)
logger.debug(
f"Cache PUT: {key}, size {raw_data_size_bytes}. "
f"Total cache size: {self.current_size_bytes}"
)
self._evict_lru()
def invalidate_for_uri(self, updated_asset_uri_str: str):
keys_to_remove_from_cache: Set[CacheKey] = set()
invalidation_queue: list[str] = [updated_asset_uri_str]
processed_uris_for_invalidation_round: Set[str] = set()
while invalidation_queue:
current_uri_to_check_str = invalidation_queue.pop(0)
if current_uri_to_check_str in processed_uris_for_invalidation_round:
continue
processed_uris_for_invalidation_round.add(current_uri_to_check_str)
for ck in list(self._cache.keys()):
if ck.asset_uri_str == current_uri_to_check_str:
keys_to_remove_from_cache.add(ck)
dependent_cache_keys = self._cache_dependents_map.get(
current_uri_to_check_str, set()
).copy()
for dep_ck in dependent_cache_keys:
if dep_ck not in keys_to_remove_from_cache:
keys_to_remove_from_cache.add(dep_ck)
parent_uri_of_dep_ck = dep_ck.asset_uri_str
if parent_uri_of_dep_ck not in processed_uris_for_invalidation_round:
invalidation_queue.append(parent_uri_of_dep_ck)
for ck_to_remove in keys_to_remove_from_cache:
if ck_to_remove in self._cache:
entry_to_remove = self._cache.pop(ck_to_remove)
self.current_size_bytes -= entry_to_remove.size_bytes
self._lru_order.pop(ck_to_remove, None)
self._remove_key_from_dependency_maps(ck_to_remove)
if keys_to_remove_from_cache:
logger.debug(
f"Cache invalidated for URI '{updated_asset_uri_str}' and "
f"its dependents. Removed {len(keys_to_remove_from_cache)} "
f"entries. New size: {self.current_size_bytes}"
)
def clear(self):
self._cache.clear()
self._lru_order.clear()
self._cache_dependents_map.clear()
self._cache_dependencies_map.clear()
self.current_size_bytes = 0
logger.info("AssetCache cleared.")

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View File

@@ -0,0 +1,768 @@
# -*- 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 logging
import asyncio
import threading
import pathlib
import hashlib
from typing import Dict, Any, Type, Optional, List, Sequence, Union, Set, Mapping, Tuple
from dataclasses import dataclass
from PySide import QtCore, QtGui
from .store.base import AssetStore
from .asset import Asset
from .serializer import AssetSerializer
from .uri import AssetUri
from .cache import AssetCache, CacheKey
logger = logging.getLogger(__name__)
@dataclass
class _AssetConstructionData:
"""Holds raw data and type info needed to construct an asset instance."""
uri: AssetUri
raw_data: bytes
asset_class: Type[Asset]
# Stores AssetConstructionData for dependencies, keyed by their AssetUri
dependencies_data: Optional[Dict[AssetUri, Optional["_AssetConstructionData"]]] = None
class AssetManager:
def __init__(self, cache_max_size_bytes: int = 100 * 1024 * 1024):
self.stores: Dict[str, AssetStore] = {}
self._serializers: List[Tuple[Type[AssetSerializer], Type[Asset]]] = []
self._asset_classes: Dict[str, Type[Asset]] = {}
self.asset_cache = AssetCache(max_size_bytes=cache_max_size_bytes)
self._cacheable_stores: Set[str] = set()
logger.debug(f"AssetManager initialized (Thread: {threading.current_thread().name})")
def register_store(self, store: AssetStore, cacheable: bool = False):
"""Registers an AssetStore with the manager."""
logger.debug(f"Registering store: {store.name}, cacheable: {cacheable}")
self.stores[store.name] = store
if cacheable:
self._cacheable_stores.add(store.name)
def get_serializer_for_class(self, asset_class: Type[Asset]):
for serializer, theasset_class in self._serializers:
if issubclass(asset_class, theasset_class):
return serializer
raise ValueError(f"No serializer found for class {asset_class}")
def register_asset(self, asset_class: Type[Asset], serializer: Type[AssetSerializer]):
"""Registers an Asset class with the manager."""
if not issubclass(asset_class, Asset):
raise TypeError(f"Item '{asset_class.__name__}' must be a subclass of Asset.")
if not issubclass(serializer, AssetSerializer):
raise TypeError(f"Item '{serializer.__name__}' must be a subclass of AssetSerializer.")
self._serializers.append((serializer, asset_class))
asset_type_name = getattr(asset_class, "asset_type", None)
if not isinstance(asset_type_name, str) or not asset_type_name: # Ensure not empty
raise TypeError(
f"Asset class '{asset_class.__name__}' must have a non-empty string 'asset_type' attribute."
)
logger.debug(f"Registering asset type: '{asset_type_name}' -> {asset_class.__name__}")
self._asset_classes[asset_type_name] = asset_class
async def _fetch_asset_construction_data_recursive_async(
self,
uri: AssetUri,
store_name: str,
visited_uris: Set[AssetUri],
depth: Optional[int] = None,
) -> Optional[_AssetConstructionData]:
logger.debug(
f"_fetch_asset_construction_data_recursive_async called {store_name} {uri} {depth}"
)
if uri in visited_uris:
logger.error(f"Cyclic dependency detected for URI: {uri}")
raise RuntimeError(f"Cyclic dependency encountered for URI: {uri}")
# Check arguments
store = self.stores.get(store_name)
if not store:
raise ValueError(f"No store registered for name: {store_name}")
asset_class = self._asset_classes.get(uri.asset_type)
if not asset_class:
raise ValueError(f"No asset class registered for asset type: {asset_class}")
# Fetch the requested asset
try:
raw_data = await store.get(uri)
except FileNotFoundError:
logger.debug(
f"_fetch_asset_construction_data_recursive_async: Asset not found for {uri}"
)
return None # Primary asset not found
if depth == 0:
return _AssetConstructionData(
uri=uri,
raw_data=raw_data,
asset_class=asset_class,
dependencies_data=None, # Indicates that no attempt was made to fetch deps
)
# Extract the list of dependencies (non-recursive)
serializer = self.get_serializer_for_class(asset_class)
dependency_uris = asset_class.extract_dependencies(raw_data, serializer)
# Initialize deps_construction_data_map. Any dependencies mapped to None
# indicate that dependencies were intentionally not fetched.
deps_construction_data: Dict[AssetUri, Optional[_AssetConstructionData]] = {}
for dep_uri in dependency_uris:
visited_uris.add(uri)
try:
dep_data = await self._fetch_asset_construction_data_recursive_async(
dep_uri,
store_name,
visited_uris,
None if depth is None else depth - 1,
)
finally:
visited_uris.remove(uri)
deps_construction_data[dep_uri] = dep_data
logger.debug(
f"ToolBitShape '{uri.asset_id}' dependencies_data: {deps_construction_data is None}"
)
return _AssetConstructionData(
uri=uri,
raw_data=raw_data,
asset_class=asset_class,
dependencies_data=deps_construction_data, # Can be None or Dict
)
def _calculate_cache_key_from_construction_data(
self,
construction_data: _AssetConstructionData,
store_name_for_cache: str,
) -> Optional[CacheKey]:
if not construction_data or not construction_data.raw_data:
return None
if construction_data.dependencies_data is None:
deps_signature_tuple: Tuple = ("shallow_children",)
else:
deps_signature_tuple = tuple(
sorted(str(uri) for uri in construction_data.dependencies_data.keys())
)
raw_data_hash = int(hashlib.sha256(construction_data.raw_data).hexdigest(), 16)
return CacheKey(
store_name=store_name_for_cache,
asset_uri_str=str(construction_data.uri),
raw_data_hash=raw_data_hash,
dependency_signature=deps_signature_tuple,
)
def _build_asset_tree_from_data_sync(
self,
construction_data: Optional[_AssetConstructionData],
store_name_for_cache: str,
) -> Asset | None:
"""
Synchronously and recursively builds an asset instance.
Integrates caching logic.
"""
if not construction_data:
return None
cache_key: Optional[CacheKey] = None
if store_name_for_cache in self._cacheable_stores:
cache_key = self._calculate_cache_key_from_construction_data(
construction_data, store_name_for_cache
)
if cache_key:
cached_asset = self.asset_cache.get(cache_key)
if cached_asset is not None:
return cached_asset
logger.debug(
f"BuildAssetTreeSync: Instantiating '{construction_data.uri}' "
f"of type '{construction_data.asset_class.__name__}'"
)
resolved_dependencies: Optional[Mapping[AssetUri, Any]] = None
if construction_data.dependencies_data is not None:
resolved_dependencies = {}
for (
dep_uri,
dep_data_node,
) in construction_data.dependencies_data.items():
# Assuming dependencies are fetched from the same store context
# for caching purposes. If a dependency *could* be from a
# different store and that store has different cacheability,
# this would need more complex store_name propagation.
# For now, use the parent's store_name_for_cache.
try:
dep = self._build_asset_tree_from_data_sync(dep_data_node, store_name_for_cache)
except Exception as e:
logger.error(
f"Error building dependency '{dep_uri}' for asset '{construction_data.uri}': {e}",
exc_info=True,
)
else:
resolved_dependencies[dep_uri] = dep
asset_class = construction_data.asset_class
serializer = self.get_serializer_for_class(asset_class)
try:
final_asset = asset_class.from_bytes(
construction_data.raw_data,
construction_data.uri.asset_id,
resolved_dependencies,
serializer,
)
except Exception as e:
logger.error(
f"Error instantiating asset '{construction_data.uri}' of type '{asset_class.__name__}': {e}",
exc_info=True,
)
return None
if final_asset is not None and cache_key:
# This check implies store_name_for_cache was in _cacheable_stores
direct_deps_uris_strs: Set[str] = set()
if construction_data.dependencies_data is not None:
direct_deps_uris_strs = {
str(uri) for uri in construction_data.dependencies_data.keys()
}
raw_data_size = len(construction_data.raw_data)
self.asset_cache.put(
cache_key,
final_asset,
raw_data_size,
direct_deps_uris_strs,
)
return final_asset
def get(
self,
uri: Union[AssetUri, str],
store: str = "local",
depth: Optional[int] = None,
) -> Asset:
"""
Retrieves an asset by its URI (synchronous wrapper), to a specified depth.
IMPORTANT: Assumes this method is CALLED ONLY from the main UI thread
if Asset.from_bytes performs UI operations.
Depth None means infinite depth. Depth 0 means only this asset, no dependencies.
"""
# Log entry with thread info for verification
calling_thread_name = threading.current_thread().name
logger.debug(
f"AssetManager.get(uri='{uri}', store='{store}', depth='{depth}') called from thread: {calling_thread_name}"
)
if (
QtGui.QApplication.instance()
and QtCore.QThread.currentThread() is not QtGui.QApplication.instance().thread()
):
logger.warning(
"AssetManager.get() called from a non-main thread! UI in from_bytes may fail!"
)
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
# Step 1: Fetch all data using asyncio.run
try:
logger.debug(
f"Get: Starting asyncio.run for data fetching of '{asset_uri_obj}', depth {depth}."
)
all_construction_data = asyncio.run(
self._fetch_asset_construction_data_recursive_async(
asset_uri_obj, store, set(), depth
)
)
logger.debug(
f"Get: asyncio.run for data fetching of '{asset_uri_obj}', depth {depth} completed."
)
except Exception as e:
logger.error(
f"Get: Error during asyncio.run data fetching for '{asset_uri_obj}': {e}",
exc_info=False,
)
raise # Re-raise the exception from the async part
if all_construction_data is None:
# This means the top-level asset itself was not found by _fetch_...
raise FileNotFoundError(f"Asset '{asset_uri_obj}' not found in store '{store}'.")
# Step 2: Synchronously build the asset tree (and call from_bytes)
# This happens in the current thread (which is assumed to be the main UI thread)
deps_count = 0
found_deps_count = 0
if all_construction_data.dependencies_data is not None:
deps_count = len(all_construction_data.dependencies_data)
found_deps_count = sum(
1
for d in all_construction_data.dependencies_data.values()
if d is not None # Count actual data, not None placeholders
)
logger.debug(
f"Get: Starting synchronous asset tree build for '{asset_uri_obj}' "
f"and {deps_count} dependencies ({found_deps_count} resolved)."
)
final_asset = self._build_asset_tree_from_data_sync(
all_construction_data, store_name_for_cache=store
)
logger.debug(f"Get: Synchronous asset tree build for '{asset_uri_obj}' completed.")
return final_asset
def get_or_none(
self,
uri: Union[AssetUri, str],
store: str = "local",
depth: Optional[int] = None,
) -> Asset | None:
"""
Convenience wrapper for get() that does not raise FileNotFoundError; returns
None instead
"""
try:
return self.get(uri, store, depth)
except FileNotFoundError:
return None
async def get_async(
self,
uri: Union[AssetUri, str],
store: str = "local",
depth: Optional[int] = None,
) -> Optional[Asset]:
"""
Retrieves an asset by its URI (asynchronous), to a specified depth.
NOTE: If Asset.from_bytes does UI work, this method should ideally be awaited
from an asyncio loop that is integrated with the main UI thread (e.g., via QtAsyncio).
If awaited from a plain worker thread's asyncio loop, from_bytes will run on that worker.
"""
calling_thread_name = threading.current_thread().name
logger.debug(
f"AssetManager.get_async(uri='{uri}', store='{store}', depth='{depth}') called from thread: {calling_thread_name}"
)
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
all_construction_data = await self._fetch_asset_construction_data_recursive_async(
asset_uri_obj, store, set(), depth
)
if all_construction_data is None:
# Consistent with get(), if the top-level asset is not found,
# raise FileNotFoundError.
raise FileNotFoundError(
f"Asset '{asset_uri_obj}' not found in store '{store}' (async path)."
)
# return None # Alternative: if Optional[Asset] means asset might not exist
# Instantiation happens in the context of where this get_async was awaited.
logger.debug(
f"get_async: Building asset tree for '{asset_uri_obj}', depth {depth} in current async context."
)
return self._build_asset_tree_from_data_sync(
all_construction_data, store_name_for_cache=store
)
def get_raw(self, uri: Union[AssetUri, str], store: str = "local") -> bytes:
"""Retrieves raw asset data by its URI (synchronous wrapper)."""
logger.debug(
f"AssetManager.get_raw(uri='{uri}', store='{store}') from T:{threading.current_thread().name}"
)
async def _fetch_raw_async():
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
logger.debug(
f"GetRawAsync (internal): Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
try:
selected_store = self.stores[store]
except KeyError:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.get(asset_uri_obj)
try:
return asyncio.run(_fetch_raw_async())
except Exception as e:
logger.error(
f"GetRaw: Error during asyncio.run for '{uri}': {e}",
exc_info=False,
)
raise
async def get_raw_async(self, uri: Union[AssetUri, str], store: str = "local") -> bytes:
"""Retrieves raw asset data by its URI (asynchronous)."""
logger.debug(
f"AssetManager.get_raw_async(uri='{uri}', store='{store}') from T:{threading.current_thread().name}"
)
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
selected_store = self.stores[store]
return await selected_store.get(asset_uri_obj)
def get_bulk(
self,
uris: Sequence[Union[AssetUri, str]],
store: str = "local",
depth: Optional[int] = None,
) -> List[Any]:
"""Retrieves multiple assets by their URIs (synchronous wrapper), to a specified depth."""
logger.debug(
f"AssetManager.get_bulk for {len(uris)} URIs from store '{store}', depth '{depth}'"
)
async def _fetch_all_construction_data_bulk_async():
tasks = [
self._fetch_asset_construction_data_recursive_async(
AssetUri(u) if isinstance(u, str) else u,
store,
set(),
depth,
)
for u in uris
]
# Gather all construction data concurrently
# return_exceptions=True means results list can contain exceptions
return await asyncio.gather(*tasks, return_exceptions=True)
try:
logger.debug("GetBulk: Starting bulk data fetching")
all_construction_data_list = asyncio.run(_fetch_all_construction_data_bulk_async())
logger.debug("GetBulk: bulk data fetching completed")
except Exception as e: # Should ideally not happen if gather returns exceptions
logger.error(
f"GetBulk: Unexpected error during asyncio.run for bulk data: {e}",
exc_info=False,
)
raise
assets = []
for i, data_or_exc in enumerate(all_construction_data_list):
original_uri_input = uris[i]
# Explicitly re-raise exceptions found in the results list
if isinstance(data_or_exc, Exception):
logger.error(
f"GetBulk: Re-raising exception for '{original_uri_input}': {data_or_exc}",
exc_info=False,
)
raise data_or_exc
elif isinstance(data_or_exc, _AssetConstructionData):
# Build asset instance synchronously. Exceptions during build should propagate.
assets.append(
self._build_asset_tree_from_data_sync(data_or_exc, store_name_for_cache=store)
)
elif data_or_exc is None: # From _fetch_... returning None for not found
logger.debug(f"GetBulk: Asset '{original_uri_input}' not found")
assets.append(None)
else: # Should not happen
logger.error(
f"GetBulk: Unexpected item in construction data list for '{original_uri_input}': {type(data_or_exc)}"
)
# Raise an exception for unexpected data types
raise RuntimeError(
f"Unexpected data type for {original_uri_input}: {type(data_or_exc)}"
)
return assets
async def get_bulk_async(
self,
uris: Sequence[Union[AssetUri, str]],
store: str = "local",
depth: Optional[int] = None,
) -> List[Any]:
"""Retrieves multiple assets by their URIs (asynchronous), to a specified depth."""
logger.debug(
f"AssetManager.get_bulk_async for {len(uris)} URIs from store '{store}', depth '{depth}'"
)
tasks = [
self._fetch_asset_construction_data_recursive_async(
AssetUri(u) if isinstance(u, str) else u, store, set(), depth
)
for u in uris
]
all_construction_data_list = await asyncio.gather(*tasks, return_exceptions=True)
assets = []
for i, data_or_exc in enumerate(all_construction_data_list):
if isinstance(data_or_exc, _AssetConstructionData):
assets.append(
self._build_asset_tree_from_data_sync(data_or_exc, store_name_for_cache=store)
)
elif isinstance(data_or_exc, FileNotFoundError) or data_or_exc is None:
assets.append(None)
elif isinstance(data_or_exc, Exception):
assets.append(data_or_exc) # Caller must check
return assets
def fetch(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
store: str = "local",
depth: Optional[int] = None,
) -> List[Asset]:
"""Fetches asset instances based on type, limit, and offset (synchronous), to a specified depth."""
logger.debug(f"Fetch(type='{asset_type}', store='{store}', depth='{depth}')")
asset_uris = self.list_assets(
asset_type, limit, offset, store
) # list_assets doesn't need depth
results = self.get_bulk(asset_uris, store, depth) # Pass depth to get_bulk
# Filter out non-Asset objects (e.g., None for not found, or exceptions if collected)
return [asset for asset in results if isinstance(asset, Asset)]
async def fetch_async(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
store: str = "local",
depth: Optional[int] = None,
) -> List[Asset]:
"""Fetches asset instances based on type, limit, and offset (asynchronous), to a specified depth."""
logger.debug(f"FetchAsync(type='{asset_type}', store='{store}', depth='{depth}')")
asset_uris = await self.list_assets_async(
asset_type, limit, offset, store # list_assets_async doesn't need depth
)
results = await self.get_bulk_async(
asset_uris, store, depth
) # Pass depth to get_bulk_async
return [asset for asset in results if isinstance(asset, Asset)]
def list_assets(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
store: str = "local",
) -> List[AssetUri]:
logger.debug(f"ListAssets(type='{asset_type}', store='{store}')")
return asyncio.run(self.list_assets_async(asset_type, limit, offset, store))
async def list_assets_async(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
store: str = "local",
) -> List[AssetUri]:
logger.debug(f"ListAssetsAsync executing for type='{asset_type}', store='{store}'")
logger.debug(
f"ListAssetsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
try:
selected_store = self.stores[store]
except KeyError:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.list_assets(asset_type, limit, offset)
def count_assets(
self,
asset_type: Optional[str] = None,
store: str = "local",
) -> int:
logger.debug(f"CountAssets(type='{asset_type}', store='{store}')")
return asyncio.run(self.count_assets_async(asset_type, store))
async def count_assets_async(
self,
asset_type: Optional[str] = None,
store: str = "local",
) -> int:
logger.debug(f"CountAssetsAsync executing for type='{asset_type}', store='{store}'")
logger.debug(
f"CountAssetsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
try:
selected_store = self.stores[store]
except KeyError:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.count_assets(asset_type)
def _is_registered_type(self, obj: Asset) -> bool:
"""Helper to extract asset_type, id, and data from an object instance."""
for registered_class_type in self._asset_classes.values():
if isinstance(obj, registered_class_type):
return True
return False
async def add_async(self, obj: Asset, store: str = "local") -> AssetUri:
"""
Adds an asset to the store, either creating a new one or updating an existing one.
Uses obj.get_url() to determine if the asset exists.
"""
logger.debug(f"AddAsync: Adding {type(obj).__name__} to store '{store}'")
uri = obj.get_uri()
if not self._is_registered_type(obj):
logger.warning(f"Asset has unregistered type '{uri.asset_type}' ({type(obj).__name__})")
serializer = self.get_serializer_for_class(obj.__class__)
data = obj.to_bytes(serializer)
return await self.add_raw_async(uri.asset_type, uri.asset_id, data, store)
def add(self, obj: Asset, store: str = "local") -> AssetUri:
"""Synchronous wrapper for adding an asset to the store."""
logger.debug(
f"Add: Adding {type(obj).__name__} to store '{store}' from T:{threading.current_thread().name}"
)
return asyncio.run(self.add_async(obj, store))
async def add_raw_async(
self, asset_type: str, asset_id: str, data: bytes, store: str = "local"
) -> AssetUri:
"""
Adds raw asset data to the store, either creating a new asset or updating an existing one.
"""
logger.debug(f"AddRawAsync: type='{asset_type}', id='{asset_id}', store='{store}'")
if not asset_type or not asset_id:
raise ValueError("asset_type and asset_id must be provided for add_raw.")
if not isinstance(data, bytes):
raise TypeError("Data for add_raw must be bytes.")
selected_store = self.stores.get(store)
if not selected_store:
raise ValueError(f"No store registered for name: {store}")
uri = AssetUri.build(asset_type=asset_type, asset_id=asset_id)
try:
uri = await selected_store.update(uri, data)
logger.debug(f"AddRawAsync: Updated existing asset at {uri}")
except FileNotFoundError:
logger.debug(
f"AddRawAsync: Asset not found, creating new asset with {asset_type} and {asset_id}"
)
uri = await selected_store.create(asset_type, asset_id, data)
if store in self._cacheable_stores:
self.asset_cache.invalidate_for_uri(str(uri)) # Invalidate after add/update
return uri
def add_raw(
self, asset_type: str, asset_id: str, data: bytes, store: str = "local"
) -> AssetUri:
"""Synchronous wrapper for adding raw asset data to the store."""
logger.debug(
f"AddRaw: type='{asset_type}', id='{asset_id}', store='{store}' from T:{threading.current_thread().name}"
)
try:
return asyncio.run(self.add_raw_async(asset_type, asset_id, data, store))
except Exception as e:
logger.error(
f"AddRaw: Error for type='{asset_type}', id='{asset_id}': {e}", exc_info=False
)
raise
def add_file(
self,
asset_type: str,
path: pathlib.Path,
store: str = "local",
asset_id: str | None = None,
) -> AssetUri:
"""
Convenience wrapper around add_raw().
If asset_id is None, the path.stem is used as the id.
"""
return self.add_raw(asset_type, asset_id or path.stem, path.read_bytes(), store=store)
def delete(self, uri: Union[AssetUri, str], store: str = "local") -> None:
logger.debug(f"Delete URI '{uri}' from store '{store}'")
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
async def _do_delete_async():
selected_store = self.stores[store]
await selected_store.delete(asset_uri_obj)
if store in self._cacheable_stores:
self.asset_cache.invalidate_for_uri(str(asset_uri_obj))
asyncio.run(_do_delete_async())
async def delete_async(self, uri: Union[AssetUri, str], store: str = "local") -> None:
logger.debug(f"DeleteAsync URI '{uri}' from store '{store}'")
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
selected_store = self.stores[store]
await selected_store.delete(asset_uri_obj)
if store in self._cacheable_stores:
self.asset_cache.invalidate_for_uri(str(asset_uri_obj))
async def is_empty_async(self, asset_type: Optional[str] = None, store: str = "local") -> bool:
"""Checks if the asset store has any assets of a given type (asynchronous)."""
logger.debug(f"IsEmptyAsync: type='{asset_type}', store='{store}'")
logger.debug(
f"IsEmptyAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
selected_store = self.stores.get(store)
if not selected_store:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.is_empty(asset_type)
def is_empty(self, asset_type: Optional[str] = None, store: str = "local") -> bool:
"""Checks if the asset store has any assets of a given type (synchronous wrapper)."""
logger.debug(
f"IsEmpty: type='{asset_type}', store='{store}' from T:{threading.current_thread().name}"
)
try:
return asyncio.run(self.is_empty_async(asset_type, store))
except Exception as e:
logger.error(
f"IsEmpty: Error for type='{asset_type}', store='{store}': {e}",
exc_info=False,
) # Changed exc_info to False
raise
async def list_versions_async(
self, uri: Union[AssetUri, str], store: str = "local"
) -> List[AssetUri]:
"""Lists available versions for a given asset URI (asynchronous)."""
logger.debug(f"ListVersionsAsync: uri='{uri}', store='{store}'")
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
logger.debug(
f"ListVersionsAsync: Looking up store '{store}'. Available stores: {list(self.stores.keys())}"
)
selected_store = self.stores.get(store)
if not selected_store:
raise ValueError(f"No store registered for name: {store}")
return await selected_store.list_versions(asset_uri_obj)
def list_versions(self, uri: Union[AssetUri, str], store: str = "local") -> List[AssetUri]:
"""Lists available versions for a given asset URI (synchronous wrapper)."""
logger.debug(
f"ListVersions: uri='{uri}', store='{store}' from T:{threading.current_thread().name}"
)
try:
return asyncio.run(self.list_versions_async(uri, store))
except Exception as e:
logger.error(
f"ListVersions: Error for uri='{uri}', store='{store}': {e}",
exc_info=False,
) # Changed exc_info to False
return [] # Return empty list on error to satisfy type hint
def get_registered_asset_types(self) -> List[str]:
"""Returns a list of registered asset type names."""
return list(self._asset_classes.keys())

View File

@@ -0,0 +1,104 @@
# -*- 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 abc
from abc import ABC
from typing import Mapping, List, Optional, Tuple, Type
from .uri import AssetUri
from .asset import Asset
class AssetSerializer(ABC):
for_class: Type[Asset]
extensions: Tuple[str] = tuple()
mime_type: str
can_import: bool = True
can_export: bool = True
@classmethod
@abc.abstractmethod
def get_label(cls) -> str:
pass
@classmethod
@abc.abstractmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
pass
@classmethod
@abc.abstractmethod
def serialize(cls, asset: Asset) -> bytes:
"""Serializes an asset object into bytes."""
pass
@classmethod
@abc.abstractmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> "Asset":
"""
Creates an asset object from serialized data and resolved dependencies.
If dependencies is None, it indicates a shallow load where dependencies
were not resolved.
"""
pass
@classmethod
@abc.abstractmethod
def deep_deserialize(cls, data: bytes) -> Asset:
"""
Like deserialize(), but builds dependencies itself if they are
sufficiently defined in the data.
This method is used for export/import, where some dependencies
may be embedded in the data, while others may not.
"""
pass
class DummyAssetSerializer(AssetSerializer):
"""
A serializer that does nothing. Can be used by simple assets that don't
need a non-native serialization. These type of assets can implement
extract_dependencies(), to_bytes() and from_bytes() methods that ignore
the given serializer.
"""
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def serialize(cls, asset: Asset) -> bytes:
return b""
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> Asset:
raise RuntimeError("DummySerializer.deserialize() was called")

View File

@@ -0,0 +1,166 @@
# -*- 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 abc
from typing import List
from ..uri import AssetUri
class AssetStore(abc.ABC):
"""
Abstract base class for storing and retrieving asset data as raw bytes.
Stores are responsible for handling the low-level interaction with a
specific storage backend (e.g., local filesystem, HTTP server) based
on the URI protocol.
"""
def __init__(self, name: str, *args, **kwargs):
self.name = name
@abc.abstractmethod
async def get(self, uri: AssetUri) -> bytes:
"""
Retrieve the raw byte data for the asset at the given URI.
Args:
uri: The unique identifier for the asset.
Returns:
The raw byte data of the asset.
Raises:
FileNotFoundError: If the asset does not exist at the URI.
# Other store-specific exceptions may be raised.
"""
raise NotImplementedError
@abc.abstractmethod
async def delete(self, uri: AssetUri) -> None:
"""
Delete the asset at the given URI.
Args:
uri: The unique identifier for the asset to delete.
Raises:
FileNotFoundError: If the asset does not exist at the URI.
# Other store-specific exceptions may be raised.
"""
raise NotImplementedError
@abc.abstractmethod
async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri:
"""
Create a new asset in the store with the given data.
The store determines the final URI for the new asset. The
`asset_type` can be used to influence the storage location
or URI structure (e.g., as part of the path).
Args:
asset_type: The type of the asset (e.g., 'material',
'toolbitshape').
asset_id: The unique identifier for the asset.
data: The raw byte data of the asset to create.
Returns:
The URI of the newly created asset.
Raises:
# Store-specific exceptions may be raised (e.g., write errors).
"""
raise NotImplementedError
@abc.abstractmethod
async def update(self, uri: AssetUri, data: bytes) -> AssetUri:
"""
Update the asset at the given URI with new data, creating a new version.
Args:
uri: The unique identifier of the asset to update.
data: The new raw byte data for the asset.
Raises:
FileNotFoundError: If the asset does not exist at the URI.
# Other store-specific exceptions may be raised (e.g., write errors).
"""
raise NotImplementedError
@abc.abstractmethod
async def list_assets(
self, asset_type: str | None = None, limit: int | None = None, offset: int | None = None
) -> List[AssetUri]:
"""
List assets in the store, optionally filtered by asset type and
with pagination. For versioned stores, this lists the latest
version of each asset.
Args:
asset_type: Optional filter for asset type.
limit: Maximum number of assets to return.
offset: Number of assets to skip from the beginning.
Returns:
A list of URIs for the assets.
"""
raise NotImplementedError
@abc.abstractmethod
async def count_assets(self, asset_type: str | None = None) -> int:
"""
Counts assets in the store, optionally filtered by asset type.
Args:
asset_type: Optional filter for asset type.
Returns:
The number of assets.
"""
raise NotImplementedError
@abc.abstractmethod
async def list_versions(self, uri: AssetUri) -> List[AssetUri]:
"""
Lists available version identifiers for a specific asset URI.
Args:
uri: The URI of the asset (version component is ignored).
Returns:
A list of URIs pointing to the specific versions of the asset.
"""
raise NotImplementedError
@abc.abstractmethod
async def is_empty(self, asset_type: str | None = None) -> bool:
"""
Checks if the store contains any assets, optionally filtered by asset
type.
Args:
asset_type: Optional filter for asset type.
Returns:
True if the store is empty (or empty for the given asset type),
False otherwise.
"""
raise NotImplementedError

View File

@@ -0,0 +1,501 @@
# -*- 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 re
import pathlib
from typing import List, Dict, Tuple, Optional, cast
from ..uri import AssetUri
from .base import AssetStore
class FileStore(AssetStore):
"""
Asset store implementation for the local filesystem with optional
versioning.
Maps URIs of the form <asset_type>://<asset_id>[/<version>]
to paths within a base directory.
The mapping to file system paths is configurable depending on the asset
type. Example mapping:
mapping = {
"*": "{asset_type}/{asset_id}/{version}.dat",
"model": "models_dir/{asset_id}-{version}.ml",
"dataset": "data/{asset_id}.csv" # Unversioned (conceptual version "1")
}
Placeholders like {version} are matched greedily (.*), but for compatibility,
versions are expected to be numeric strings for versioned assets.
"""
DEFAULT_MAPPING = {
# Default from original problem doc was "{asset_type}/{asset_id}/{id}/<version>"
# Adjusted to a more common simple case:
"*": "{asset_type}/{asset_id}/{version}"
}
KNOWN_PLACEHOLDERS = {"asset_type", "asset_id", "id", "version"}
def __init__(
self,
name: str,
base_dir: pathlib.Path,
mapping: Optional[Dict[str, str]] = None,
):
super().__init__(name)
self._base_dir = base_dir.resolve()
self._mapping = mapping if mapping is not None else self.DEFAULT_MAPPING.copy()
self._validate_patterns_on_init()
# For _path_to_uri: iterate specific keys before '*' to ensure correct pattern matching
self._sorted_mapping_keys = sorted(self._mapping.keys(), key=lambda k: (k == "*", k))
def _validate_patterns_on_init(self):
if not self._mapping:
raise ValueError("Asset store mapping cannot be empty.")
for asset_type_key, path_format in self._mapping.items():
if not isinstance(path_format, str):
raise TypeError(f"Path format for key '{asset_type_key}' must be a string.")
placeholders_in_format = set(re.findall(r"\{([^}]+)\}", path_format))
for ph_name in placeholders_in_format:
if ph_name not in self.KNOWN_PLACEHOLDERS:
raise ValueError(
f"Unknown placeholder {{{ph_name}}} in pattern: '{path_format}'. Allowed: {self.KNOWN_PLACEHOLDERS}"
)
has_asset_id_ph = "asset_id" in placeholders_in_format or "id" in placeholders_in_format
if not has_asset_id_ph:
raise ValueError(
f"Pattern '{path_format}' for key '{asset_type_key}' must include {{asset_id}} or {{id}}."
)
# CORRECTED LINE: Check for the placeholder name "asset_type" not "{asset_type}"
if asset_type_key == "*" and "asset_type" not in placeholders_in_format:
raise ValueError(
f"Pattern '{path_format}' for wildcard key '*' must include {{asset_type}}."
)
@staticmethod
def _match_path_to_format_string(format_str: str, path_str_posix: str) -> Dict[str, str]:
"""Matches a POSIX-style path string against a format string."""
tokens = re.split(r"\{(.*?)\}", format_str) # format_str uses /
if len(tokens) == 1: # No placeholders
if format_str == path_str_posix:
return {}
raise ValueError(f"Path '{path_str_posix}' does not match pattern '{format_str}'")
keywords = tokens[1::2]
regex_parts = []
for i, literal_part in enumerate(tokens[0::2]):
# Literal parts from format_str (using /) are escaped.
# The path_str_posix is already normalized to /, so direct matching works.
regex_parts.append(re.escape(literal_part))
if i < len(keywords):
regex_parts.append(f"(?P<{keywords[i]}>.*)")
pattern_regex_str = "".join(regex_parts)
match_obj = re.fullmatch(pattern_regex_str, path_str_posix)
if not match_obj:
raise ValueError(
f"Path '{path_str_posix}' does not match format '{format_str}' (regex: '{pattern_regex_str}')"
)
return {kw: match_obj.group(kw) for kw in keywords}
def _get_path_format_for_uri_type(self, uri_asset_type: str) -> str:
if uri_asset_type in self._mapping:
return self._mapping[uri_asset_type]
if "*" in self._mapping:
return self._mapping["*"]
raise ValueError(
f"No mapping pattern for asset_type '{uri_asset_type}' and no '*' fallback."
)
def _path_to_uri(self, file_path: pathlib.Path) -> Optional[AssetUri]:
"""Converts a filesystem path to an AssetUri, if it matches a pattern."""
if not file_path.is_file():
return None
try:
# Convert to relative path object first, then to POSIX string for matching
relative_path_obj = file_path.relative_to(self._base_dir)
relative_path_posix = relative_path_obj.as_posix()
except ValueError:
return None # Path not under base_dir
for asset_type_key in self._sorted_mapping_keys:
path_format_str = self._mapping[asset_type_key] # Pattern uses /
try:
components = FileStore._match_path_to_format_string(
path_format_str, relative_path_posix
)
asset_id = components.get("asset_id", components.get("id"))
if not asset_id:
continue
current_asset_type: str
if "{asset_type}" in path_format_str:
current_asset_type = components.get("asset_type", "")
if not current_asset_type:
continue
else:
current_asset_type = asset_type_key
if current_asset_type == "*":
continue # Invalid state, caught by validation
version_str: str
if "{version}" in path_format_str:
version_str = components.get("version", "")
if not version_str or not version_str.isdigit():
continue
else:
version_str = "1"
return AssetUri.build(
asset_type=current_asset_type,
asset_id=asset_id,
version=version_str,
)
except ValueError: # No match
continue
return None
def set_dir(self, new_dir: pathlib.Path):
"""Sets the base directory for the store."""
self._base_dir = new_dir.resolve()
def _uri_to_path(self, uri: AssetUri) -> pathlib.Path:
"""Converts an AssetUri to a filesystem path using mapping."""
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
format_values: Dict[str, str] = {
"asset_type": uri.asset_type,
"asset_id": uri.asset_id,
"id": uri.asset_id,
}
# Only add 'version' to format_values if the pattern expects it AND uri.version is set.
# uri.version must be a string for .format() (e.g. "1", not None).
if "{version}" in path_format_str:
if uri.version is None:
# This state implies an issue: a versioned pattern is being used
# but the URI hasn't had its version appropriately set (e.g. to "1" for create,
# or resolved from "latest").
raise ValueError(
f"URI version is None for versioned pattern '{path_format_str}'. URI: {uri}"
)
format_values["version"] = uri.version
try:
# Patterns use '/', pathlib handles OS-specific path construction.
resolved_path_str = path_format_str.format(**format_values)
except KeyError as e:
raise ValueError(
f"Pattern '{path_format_str}' placeholder {{{e}}} missing in URI data for {uri}."
)
return self._base_dir / resolved_path_str
async def get(self, uri: AssetUri) -> bytes:
"""Retrieve the raw byte data for the asset at the given URI."""
path_to_read: pathlib.Path
if uri.version == "latest":
query_uri = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
params=uri.params,
)
versions = await self.list_versions(query_uri)
if not versions:
raise FileNotFoundError(f"No versions found for {uri.asset_type}://{uri.asset_id}")
latest_version_uri = versions[-1] # list_versions now returns AssetUri with params
path_to_read = self._uri_to_path(latest_version_uri)
else:
request_uri = uri
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
is_versioned_pattern = "{version}" in path_format_str
if not is_versioned_pattern:
if uri.version is not None and uri.version != "1":
raise FileNotFoundError(
f"Asset type '{uri.asset_type}' is unversioned. "
f"Version '{uri.version}' invalid for URI {uri}. Use '1' or no version."
)
if uri.version is None: # Conceptual "type://id" -> "type://id/1"
request_uri = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version="1",
params=uri.params,
)
elif (
uri.version is None
): # Versioned pattern but URI has version=None (and not "latest")
raise FileNotFoundError(
f"Version required for asset type '{uri.asset_type}' (pattern: '{path_format_str}'). URI: {uri}"
)
path_to_read = self._uri_to_path(request_uri)
try:
with open(path_to_read, mode="rb") as f:
return f.read()
except FileNotFoundError:
raise FileNotFoundError(f"Asset for URI {uri} not found at path {path_to_read}")
except IsADirectoryError:
raise FileNotFoundError(f"Asset URI {uri} resolved to a directory: {path_to_read}")
async def delete(self, uri: AssetUri) -> None:
"""Delete the asset at the given URI."""
paths_to_delete: List[pathlib.Path] = []
parent_dirs_of_deleted_files = set() # To track for cleanup
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
is_versioned_pattern = "{version}" in path_format_str
if uri.version is None: # Delete all versions or the single unversioned file
for path_obj in self._base_dir.rglob("*"):
parsed_uri = self._path_to_uri(path_obj)
if (
parsed_uri
and parsed_uri.asset_type == uri.asset_type
and parsed_uri.asset_id == uri.asset_id
):
paths_to_delete.append(path_obj)
else: # Delete a specific version or an unversioned file (if version is "1")
target_uri_for_path = uri
if not is_versioned_pattern:
if uri.version != "1":
return # Idempotent: non-"1" version of unversioned asset "deleted"
target_uri_for_path = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version="1",
params=uri.params,
)
path = self._uri_to_path(target_uri_for_path)
if path.is_file():
paths_to_delete.append(path)
for p_del in paths_to_delete:
try:
p_del.unlink()
parent_dirs_of_deleted_files.add(p_del.parent)
except FileNotFoundError:
pass
# Clean up empty parent directories, from deepest first
sorted_parents = sorted(
list(parent_dirs_of_deleted_files),
key=lambda p: len(p.parts),
reverse=True,
)
for parent_dir in sorted_parents:
current_cleanup_path = parent_dir
while (
current_cleanup_path.exists()
and current_cleanup_path.is_dir()
and current_cleanup_path != self._base_dir
and current_cleanup_path.is_relative_to(self._base_dir)
and not any(current_cleanup_path.iterdir())
):
try:
current_cleanup_path.rmdir()
current_cleanup_path = current_cleanup_path.parent
except OSError:
break
async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri:
"""Create a new asset in the store with the given data."""
# New assets are conceptually version "1"
uri_to_create = AssetUri.build(asset_type=asset_type, asset_id=asset_id, version="1")
asset_path = self._uri_to_path(uri_to_create)
if asset_path.exists():
# More specific error messages based on what exists
if asset_path.is_file():
raise FileExistsError(f"Asset file already exists at {asset_path}")
if asset_path.is_dir():
raise IsADirectoryError(f"A directory exists at target path {asset_path}")
raise FileExistsError(f"Path {asset_path} already exists (unknown type).")
asset_path.parent.mkdir(parents=True, exist_ok=True)
with open(asset_path, mode="wb") as f:
f.write(data)
return uri_to_create
async def update(self, uri: AssetUri, data: bytes) -> AssetUri:
"""Update the asset at the given URI with new data, creating a new version."""
# Get a Uri without the version number, use it to find all versions.
query_uri = AssetUri.build(
asset_type=uri.asset_type, asset_id=uri.asset_id, params=uri.params
)
existing_versions = await self.list_versions(query_uri)
if not existing_versions:
raise FileNotFoundError(
f"No versions for asset {uri.asset_type}://{uri.asset_id} to update."
)
# Create a Uri for the NEXT version number.
latest_version_uri = existing_versions[-1]
latest_version_num = int(cast(str, latest_version_uri.version))
next_version_str = str(latest_version_num + 1)
next_uri = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version=next_version_str,
params=uri.params,
)
asset_path = self._uri_to_path(next_uri)
# If the file is versioned, then the new version should not yet exist.
# Double check to be sure.
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
is_versioned_pattern = "{version}" in path_format_str
if asset_path.exists() and is_versioned_pattern:
raise FileExistsError(f"Asset path for new version {asset_path} already exists.")
# Done. Write to disk.
asset_path.parent.mkdir(parents=True, exist_ok=True)
with open(asset_path, mode="wb") as f:
f.write(data)
return next_uri
async def list_assets(
self,
asset_type: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> List[AssetUri]:
"""
List assets in the store, optionally filtered by asset type and
with pagination. For versioned stores, this lists the latest
version of each asset.
"""
latest_asset_versions: Dict[Tuple[str, str], str] = {}
for path_obj in self._base_dir.rglob("*"):
parsed_uri = self._path_to_uri(path_obj)
if parsed_uri:
if asset_type is not None and parsed_uri.asset_type != asset_type:
continue
key = (parsed_uri.asset_type, parsed_uri.asset_id)
current_version_str = cast(str, parsed_uri.version) # Is "1" or numeric string
if key not in latest_asset_versions or int(current_version_str) > int(
latest_asset_versions[key]
):
latest_asset_versions[key] = current_version_str
result_uris: List[AssetUri] = [
AssetUri.build(
asset_type=atype, asset_id=aid, version=vstr
) # Params not included in list_assets results
for (atype, aid), vstr in latest_asset_versions.items()
]
result_uris.sort(key=lambda u: (u.asset_type, u.asset_id, int(cast(str, u.version))))
start = offset if offset is not None else 0
end = start + limit if limit is not None else len(result_uris)
return result_uris[start:end]
async def count_assets(self, asset_type: Optional[str] = None) -> int:
"""
Counts assets in the store, optionally filtered by asset type.
"""
unique_assets: set[Tuple[str, str]] = set()
for path_obj in self._base_dir.rglob("*"):
parsed_uri = self._path_to_uri(path_obj)
if parsed_uri:
if asset_type is not None and parsed_uri.asset_type != asset_type:
continue
unique_assets.add((parsed_uri.asset_type, parsed_uri.asset_id))
return len(unique_assets)
async def list_versions(self, uri: AssetUri) -> List[AssetUri]:
"""
Lists available version identifiers for a specific asset URI.
Args:
uri: The URI of the asset (version component is ignored, params preserved).
Returns:
A list of AssetUri objects, sorted by version in ascending order.
"""
if uri.asset_id is None:
raise ValueError(f"Asset ID must be specified for listing versions: {uri}")
path_format_str = self._get_path_format_for_uri_type(uri.asset_type)
is_versioned_pattern = "{version}" in path_format_str
if not is_versioned_pattern:
# Check existence of the single unversioned file
# Conceptual version is "1", params from input URI are preserved
path_check_uri = AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version="1",
params=uri.params,
)
path_to_asset = self._uri_to_path(path_check_uri)
if path_to_asset.is_file():
return [path_check_uri] # Returns URI with version "1" and original params
return []
found_versions_strs: List[str] = []
for path_obj in self._base_dir.rglob("*"):
parsed_uri = self._path_to_uri(path_obj) # This parsed_uri does not have params
if (
parsed_uri
and parsed_uri.asset_type == uri.asset_type
and parsed_uri.asset_id == uri.asset_id
):
# Version from path is guaranteed numeric string by _path_to_uri for versioned patterns
found_versions_strs.append(cast(str, parsed_uri.version))
if not found_versions_strs:
return []
sorted_unique_versions = sorted(list(set(found_versions_strs)), key=int)
return [
AssetUri.build(
asset_type=uri.asset_type,
asset_id=uri.asset_id,
version=v_str,
params=uri.params,
)
for v_str in sorted_unique_versions
]
async def is_empty(self, asset_type: Optional[str] = None) -> bool:
"""
Checks if the store contains any assets, optionally filtered by asset
type.
"""
# Reuses list_assets which iterates files.
# Limit=1 makes it stop after finding the first asset.
assets = await self.list_assets(asset_type=asset_type, limit=1)
return not bool(assets)

View File

@@ -0,0 +1,212 @@
# -*- 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 pprint
from typing import Dict, List, Optional
from ..uri import AssetUri
from .base import AssetStore
class MemoryStore(AssetStore):
"""
An in-memory implementation of the AssetStore.
This store keeps all asset data in memory and is primarily intended for
testing and demonstration purposes. It does not provide persistence.
"""
def __init__(self, name: str, *args, **kwargs):
super().__init__(name, *args, **kwargs)
self._data: Dict[str, Dict[str, Dict[str, bytes]]] = {}
self._versions: Dict[str, Dict[str, List[str]]] = {}
async def get(self, uri: AssetUri) -> bytes:
asset_type = uri.asset_type
asset_id = uri.asset_id
version = uri.version or self._get_latest_version(asset_type, asset_id)
if (
asset_type not in self._data
or asset_id not in self._data[asset_type]
or version not in self._data[asset_type][asset_id]
):
raise FileNotFoundError(f"Asset not found: {uri}")
return self._data[asset_type][asset_id][version]
async def delete(self, uri: AssetUri) -> None:
asset_type = uri.asset_type
asset_id = uri.asset_id
version = uri.version # Capture the version from the URI
if asset_type not in self._data or asset_id not in self._data[asset_type]:
# Deleting non-existent asset should not raise an error
return
if version:
# If a version is specified, try to delete only that version
if version in self._data[asset_type][asset_id]:
del self._data[asset_type][asset_id][version]
# Remove version from the versions list
if (
asset_type in self._versions
and asset_id in self._versions[asset_type]
and version in self._versions[asset_type][asset_id]
):
self._versions[asset_type][asset_id].remove(version)
# If no versions left for this asset_id, clean up
if not self._data[asset_type][asset_id]:
del self._data[asset_type][asset_id]
if asset_type in self._versions and asset_id in self._versions[asset_type]:
del self._versions[asset_type][asset_id]
else:
# If no version is specified, delete the entire asset
del self._data[asset_type][asset_id]
if asset_type in self._versions and asset_id in self._versions[asset_type]:
del self._versions[asset_type][asset_id]
# Clean up empty asset types
if asset_type in self._data and not self._data[asset_type]:
del self._data[asset_type]
if asset_type in self._versions and not self._versions[asset_type]:
del self._versions[asset_type]
async def create(self, asset_type: str, asset_id: str, data: bytes) -> AssetUri:
if asset_type not in self._data:
self._data[asset_type] = {}
self._versions[asset_type] = {}
if asset_id in self._data[asset_type]:
# For simplicity, create overwrites existing in this memory store
# A real store might handle this differently or raise an error
pass
if asset_id not in self._data[asset_type]:
self._data[asset_type][asset_id] = {}
self._versions[asset_type][asset_id] = []
version = "1"
self._data[asset_type][asset_id][version] = data
self._versions[asset_type][asset_id].append(version)
return AssetUri(f"{asset_type}://{asset_id}/{version}")
async def update(self, uri: AssetUri, data: bytes) -> AssetUri:
asset_type = uri.asset_type
asset_id = uri.asset_id
if asset_type not in self._data or asset_id not in self._data[asset_type]:
raise FileNotFoundError(f"Asset not found for update: {uri}")
# Update should create a new version
latest_version = self._get_latest_version(asset_type, asset_id)
version = str(int(latest_version or 0) + 1)
self._data[asset_type][asset_id][version] = data
self._versions[asset_type][asset_id].append(version)
return AssetUri(f"{asset_type}://{asset_id}/{version}")
async def list_assets(
self, asset_type: str | None = None, limit: int | None = None, offset: int | None = None
) -> List[AssetUri]:
all_uris: List[AssetUri] = []
for current_type, assets in self._data.items():
if asset_type is None or current_type == asset_type:
for asset_id in assets:
latest_version = self._get_latest_version(current_type, asset_id)
if latest_version:
all_uris.append(AssetUri(f"{current_type}://{asset_id}/{latest_version}"))
# Apply offset and limit
start = offset if offset is not None else 0
end = start + limit if limit is not None else len(all_uris)
return all_uris[start:end]
async def count_assets(self, asset_type: str | None = None) -> int:
"""
Counts assets in the store, optionally filtered by asset type.
"""
if asset_type is None:
count = 0
for assets_by_id in self._data.values():
count += len(assets_by_id)
return count
else:
if asset_type in self._data:
return len(self._data[asset_type])
return 0
async def list_versions(self, uri: AssetUri) -> List[AssetUri]:
asset_type = uri.asset_type
asset_id = uri.asset_id
if asset_type not in self._versions or asset_id not in self._versions[asset_type]:
return []
version_uris: List[AssetUri] = []
for version in self._versions[asset_type][asset_id]:
version_uris.append(AssetUri(f"{asset_type}://{asset_id}/{version}"))
return version_uris
async def is_empty(self, asset_type: str | None = None) -> bool:
if asset_type is None:
return not bool(self._data)
else:
return asset_type not in self._data or not bool(self._data[asset_type])
def _get_latest_version(self, asset_type: str, asset_id: str) -> Optional[str]:
if (
asset_type in self._versions
and asset_id in self._versions[asset_type]
and self._versions[asset_type][asset_id]
):
return self._versions[asset_type][asset_id][-1]
return None
def dump(self, print: bool = False) -> Dict[str, Dict[str, Dict[str, bytes]]] | None:
"""
Dumps the entire content of the memory store.
Args:
print (bool): If True, pretty-prints the data to the console,
excluding the asset data itself.
Returns:
Dict[str, Dict[str, Dict[str, bytes]]] | None: The stored data as a
dictionary, or None if print is True.
"""
if not print:
return self._data
printable_data = {}
for asset_type, assets in self._data.items():
printable_data[asset_type] = {}
for asset_id, versions in assets.items():
printable_data[asset_type][asset_id] = {}
for version, data_bytes in versions.items():
printable_data[asset_type][asset_id][
version
] = f"<data skipped, {len(data_bytes)} bytes>"
pprint.pprint(printable_data, indent=4)
return self._data

View File

@@ -0,0 +1,6 @@
from .filedialog import AssetOpenDialog, AssetSaveDialog
__all__ = [
"AssetOpenDialog",
"AssetSaveDialog",
]

View File

@@ -0,0 +1,158 @@
# -*- 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
from typing import Optional, Tuple, Type, Iterable
from PySide.QtWidgets import QFileDialog, QMessageBox
from ..serializer import AssetSerializer, Asset
from .util import (
make_import_filters,
make_export_filters,
get_serializer_from_extension,
)
class AssetOpenDialog(QFileDialog):
def __init__(
self,
asset_type: Type[Asset],
serializers: Iterable[Type[AssetSerializer]],
parent=None,
):
super().__init__(parent)
self.asset_type = asset_type
self.serializers = list(serializers)
self.setWindowTitle("Open an asset")
self.setFileMode(QFileDialog.ExistingFile)
filters = make_import_filters(self.serializers)
self.setNameFilters(filters)
if filters:
self.selectNameFilter(filters[0]) # Default to "All supported files"
def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[Asset]:
"""Deserialize the selected file using the appropriate serializer."""
file_extension = file_path.suffix.lower()
serializer_class = get_serializer_from_extension(
self.serializers, file_extension, for_import=True
)
if not serializer_class:
QMessageBox.critical(
self,
"Error",
f"No supported serializer found for file extension '{file_extension}'",
)
return None
try:
raw_data = file_path.read_bytes()
asset = serializer_class.deep_deserialize(raw_data)
if not isinstance(asset, self.asset_type):
raise TypeError(f"Deserialized asset is not of type {self.asset_type.asset_type}")
return asset
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to import asset: {e}")
return None
def exec(self) -> Optional[Tuple[pathlib.Path, Asset]]:
if super().exec_():
filenames = self.selectedFiles()
if filenames:
file_path = pathlib.Path(filenames[0])
asset = self._deserialize_selected_file(file_path)
if asset:
return file_path, asset
return None
class AssetSaveDialog(QFileDialog):
def __init__(
self,
asset_type: Type[Asset],
serializers: Iterable[Type[AssetSerializer]],
parent=None,
):
super().__init__(parent)
self.asset_type = asset_type
self.serializers = list(serializers)
self.setFileMode(QFileDialog.AnyFile)
self.setAcceptMode(QFileDialog.AcceptSave)
self.filters, self.serializer_map = make_export_filters(self.serializers)
self.setNameFilters(self.filters)
if self.filters:
self.selectNameFilter(self.filters[0]) # Default to "Automatic"
self.filterSelected.connect(self.update_default_suffix)
def update_default_suffix(self, filter_str: str):
"""Update the default suffix based on the selected filter."""
if filter_str == "Automatic (*)":
self.setDefaultSuffix("") # No default for Automatic
else:
serializer = self.serializer_map.get(filter_str)
if serializer and serializer.extensions:
self.setDefaultSuffix(serializer.extensions[0])
def _serialize_selected_file(
self,
file_path: pathlib.Path,
asset: Asset,
serializer_class: Type[AssetSerializer],
) -> bool:
"""Serialize and save the asset."""
try:
raw_data = serializer_class.serialize(asset)
file_path.write_bytes(raw_data)
return True
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to export asset: {e}")
return False
def exec(self, asset: Asset) -> Optional[Tuple[pathlib.Path, Type[AssetSerializer]]]:
self.setWindowTitle(f"Save {asset.label}")
if super().exec_():
selected_filter = self.selectedNameFilter()
file_path = pathlib.Path(self.selectedFiles()[0])
if selected_filter == "Automatic (*)":
if not file_path.suffix:
QMessageBox.critical(
self,
"Error",
"Please specify a file extension for automatic serializer selection.",
)
return None
file_extension = file_path.suffix.lower()
serializer_class = get_serializer_from_extension(
self.serializers, file_extension, for_import=False
)
if not serializer_class:
QMessageBox.critical(
self,
"Error",
f"No serializer found for extension '{file_extension}'",
)
return None
else:
serializer_class = self.serializer_map.get(selected_filter)
if not serializer_class:
raise ValueError(f"No serializer found for filter '{selected_filter}'")
if self._serialize_selected_file(file_path, asset, serializer_class):
return file_path, serializer_class
return None

View File

@@ -0,0 +1,128 @@
# -*- 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 tempfile
import FreeCAD
import Path
from PySide import QtGui, QtCore
translate = FreeCAD.Qt.translate
def _is_writable_dir(path: pathlib.Path) -> bool:
"""
Check if a path is a writable directory.
Returns True if writable, False otherwise.
"""
if not path.is_dir():
return False
try:
with tempfile.NamedTemporaryFile(dir=str(path), delete=True):
return True
except (OSError, PermissionError):
return False
class AssetPreferencesPage:
def __init__(self, parent=None):
self.form = QtGui.QToolBox()
self.form.setWindowTitle(translate("CAM_PreferencesAssets", "Assets"))
asset_path_widget = QtGui.QWidget()
main_layout = QtGui.QHBoxLayout(asset_path_widget)
# Create widgets
self.asset_path_label = QtGui.QLabel(translate("CAM_PreferencesAssets", "Asset Directory:"))
self.asset_path_edit = QtGui.QLineEdit()
self.asset_path_note_label = QtGui.QLabel(
translate(
"CAM_PreferencesAssets",
"Note: Select the directory that will contain the "
"Bit/, Shape/, and Library/ subfolders.",
)
)
self.asset_path_note_label.setWordWrap(True)
self.select_path_button = QtGui.QToolButton()
self.select_path_button.setIcon(QtGui.QIcon.fromTheme("folder-open"))
self.select_path_button.clicked.connect(self.selectAssetPath)
self.reset_path_button = QtGui.QPushButton(translate("CAM_PreferencesAssets", "Reset"))
self.reset_path_button.clicked.connect(self.resetAssetPath)
# Set note label font to italic
font = self.asset_path_note_label.font()
font.setItalic(True)
self.asset_path_note_label.setFont(font)
# Layout for asset path section
edit_button_layout = QtGui.QGridLayout()
edit_button_layout.addWidget(self.asset_path_label, 0, 0, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.asset_path_edit, 0, 1, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.select_path_button, 0, 2, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.reset_path_button, 0, 3, QtCore.Qt.AlignVCenter)
edit_button_layout.addWidget(self.asset_path_note_label, 1, 1, 1, 1, QtCore.Qt.AlignTop)
edit_button_layout.addItem(
QtGui.QSpacerItem(0, 0, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding),
2,
0,
1,
4,
)
main_layout.addLayout(edit_button_layout, QtCore.Qt.AlignTop)
self.form.addItem(asset_path_widget, translate("CAM_PreferencesAssets", "Assets"))
def selectAssetPath(self):
# Implement directory selection dialog
path = QtGui.QFileDialog.getExistingDirectory(
self.form,
translate("CAM_PreferencesAssets", "Select Asset Directory"),
self.asset_path_edit.text(),
)
if path:
self.asset_path_edit.setText(str(path))
def resetAssetPath(self):
# Implement resetting path to default
default_path = Path.Preferences.getDefaultAssetPath()
self.asset_path_edit.setText(str(default_path))
def saveSettings(self):
# Check path is writable, then call Path.Preferences.setAssetPath()
asset_path = pathlib.Path(self.asset_path_edit.text())
param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Path")
if param.GetBool("CheckAssetPathWritable", True):
if not _is_writable_dir(asset_path):
QtGui.QMessageBox.warning(
self.form,
translate("CAM_PreferencesAssets", "Warning"),
translate("CAM_PreferencesAssets", "The selected asset path is not writable."),
)
return False
Path.Preferences.setAssetPath(asset_path)
return True
def loadSettings(self):
# use getAssetPath() to initialize UI
asset_path = Path.Preferences.getAssetPath()
self.asset_path_edit.setText(str(asset_path))

View File

@@ -0,0 +1,109 @@
# -*- 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 List, Dict, Optional, Iterable, Type
from ..serializer import AssetSerializer
def make_import_filters(serializers: Iterable[Type[AssetSerializer]]) -> List[str]:
"""
Generates file dialog filters for importing assets.
Args:
serializers: A list of AssetSerializer classes.
Returns:
A list of filter strings, starting with "All supported files".
"""
all_extensions = []
filters = []
for serializer_class in serializers:
if not serializer_class.can_import or not serializer_class.extensions:
continue
all_extensions.extend(serializer_class.extensions)
label = serializer_class.get_label()
extensions = " ".join([f"*{ext}" for ext in serializer_class.extensions])
filters.append(f"{label} ({extensions})")
# Add "All supported files" filter if there are any extensions
if all_extensions:
combined_extensions = " ".join([f"*{ext}" for ext in sorted(list(set(all_extensions)))])
filters.insert(0, f"All supported files ({combined_extensions})")
return filters
def make_export_filters(
serializers: Iterable[Type[AssetSerializer]],
) -> tuple[List[str], Dict[str, Type[AssetSerializer]]]:
"""
Generates file dialog filters for exporting assets and a serializer map.
Args:
serializers: A list of AssetSerializer classes.
Returns:
A tuple of (filters, serializer_map) where filters is a list of filter strings
starting with "Automatic", and serializer_map maps filter strings to serializers.
"""
filters = ["Automatic (*)"]
serializer_map = {}
for serializer_class in serializers:
if not serializer_class.can_export or not serializer_class.extensions:
continue
label = serializer_class.get_label()
extensions = " ".join([f"*{ext}" for ext in serializer_class.extensions])
filter_str = f"{label} ({extensions})"
filters.append(filter_str)
serializer_map[filter_str] = serializer_class
return filters, serializer_map
def get_serializer_from_extension(
serializers: Iterable[Type[AssetSerializer]],
file_extension: str,
for_import: bool | None = None,
) -> Optional[Type[AssetSerializer]]:
"""
Finds a serializer class based on the file extension and import/export capability.
Args:
serializers: A list of AssetSerializer classes.
file_extension: The file extension (without the leading dot).
for_import: None = both, True = import, False = export
Returns:
The matching AssetSerializer class, or None if not found.
"""
for_export = for_import is not True
for_import = for_import is True
for ser in serializers:
if for_import and not ser.can_import:
continue
if for_export and not ser.can_export:
continue
if file_extension in ser.extensions:
return ser
return None

View File

@@ -0,0 +1,119 @@
# -*- 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 __future__ import annotations
import urllib.parse
from typing import Dict, Any, Mapping
class AssetUri:
"""
Represents an asset URI with components.
The URI structure is: <asset_type>://<asset_id>[/version]
"""
def __init__(self, uri_string: str):
# Manually parse the URI string
parts = uri_string.split("://", 1)
if len(parts) != 2:
raise ValueError(f"Invalid URI structure: {uri_string}")
self.asset_type = parts[0]
rest = parts[1]
# Split asset_id, version, and params
path_and_query = rest.split("?", 1)
path_parts = path_and_query[0].split("/")
if not path_parts or not path_parts[0]:
raise ValueError(f"Invalid URI structure: {uri_string}")
self.asset_id = path_parts[0]
self.version = path_parts[1] if len(path_parts) > 1 else None
if len(path_parts) > 2:
raise ValueError(f"Invalid URI path structure: {uri_string}")
self.params: Dict[str, list[str]] = {}
if len(path_and_query) > 1:
self.params = urllib.parse.parse_qs(path_and_query[1])
if not self.asset_type or not self.asset_id:
raise ValueError(f"Invalid URI structure: {uri_string}")
def __str__(self) -> str:
path = f"/{self.version}" if self.version else ""
query = urllib.parse.urlencode(self.params, doseq=True) if self.params else ""
uri_string = urllib.parse.urlunparse((self.asset_type, self.asset_id, path, "", query, ""))
return uri_string
def __repr__(self) -> str:
return f"AssetUri('{str(self)}')"
def __eq__(self, other: Any) -> bool:
if not isinstance(other, AssetUri):
return NotImplemented
return (
self.asset_type == other.asset_type
and self.asset_id == other.asset_id
and self.version == other.version
and self.params == other.params
)
def __hash__(self) -> int:
"""Returns a hash value for the AssetUri."""
return hash((self.asset_type, self.asset_id, self.version, frozenset(self.params.items())))
@classmethod
def is_uri(cls, uri: AssetUri | str) -> bool:
"""Checks if the given string is a valid URI."""
if isinstance(uri, AssetUri):
return True
try:
AssetUri(uri)
except ValueError:
return False
return True
@staticmethod
def build(
asset_type: str,
asset_id: str,
version: str | None = None,
params: Mapping[str, str | list[str]] | None = None,
) -> AssetUri:
"""Builds a Uri object from components."""
uri = AssetUri.__new__(AssetUri) # Create a new instance without calling __init__
uri.asset_type = asset_type
uri.asset_id = asset_id
uri.version = version
uri.params = {}
if params:
for key, value in params.items():
if isinstance(value, list):
uri.params[key] = value
else:
uri.params[key] = [value]
return uri

View File

@@ -0,0 +1,111 @@
# -*- 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 Path
from Path import Preferences
from Path.Preferences import addToolPreferenceObserver
from .assets import AssetManager, FileStore
def ensure_library_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
Ensures the given store is initialized with built-in library
if it is currently empty.
"""
builtin_library_path = Preferences.getBuiltinLibraryPath()
if asset_manager.is_empty("toolbitlibrary", store=store_name):
for path in builtin_library_path.glob("*.fctl"):
asset_manager.add_file("toolbitlibrary", path)
def ensure_toolbit_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
Ensures the given store is initialized with built-in bits
if it is currently empty.
"""
builtin_toolbit_path = Preferences.getBuiltinToolBitPath()
if asset_manager.is_empty("toolbit", store=store_name):
for path in builtin_toolbit_path.glob("*.fctb"):
asset_manager.add_file("toolbit", path)
def ensure_toolbitshape_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
Ensures the given store is initialized with built-in shapes
if it is currently empty.
"""
builtin_shape_path = Preferences.getBuiltinShapePath()
if asset_manager.is_empty("toolbitshape", store=store_name):
for path in builtin_shape_path.glob("*.fcstd"):
asset_manager.add_file("toolbitshape", path)
if asset_manager.is_empty("toolbitshapesvg", store=store_name):
for path in builtin_shape_path.glob("*.svg"):
asset_manager.add_file("toolbitshapesvg", path, asset_id=path.stem + ".svg")
if asset_manager.is_empty("toolbitshapepng", store=store_name):
for path in builtin_shape_path.glob("*.png"):
asset_manager.add_file("toolbitshapepng", path, asset_id=path.stem + ".png")
def ensure_assets_initialized(asset_manager: AssetManager, store="local"):
"""
Ensures the given store is initialized with built-in assets.
"""
ensure_library_assets_initialized(asset_manager, store)
ensure_toolbit_assets_initialized(asset_manager, store)
ensure_toolbitshape_assets_initialized(asset_manager, store)
def _on_asset_path_changed(group, key, value):
Path.Log.info(f"CAM asset directory changed in preferences: {group} {key} {value}")
cam_asset_store.set_dir(Preferences.getAssetPath())
ensure_assets_initialized(cam_assets)
# Set up the local CAM asset storage.
cam_asset_store = FileStore(
name="local",
base_dir=Preferences.getAssetPath(),
mapping={
"toolbitlibrary": "Library/{asset_id}.fctl",
"toolbit": "Bit/{asset_id}.fctb",
"toolbitshape": "Shape/{asset_id}.fcstd",
"toolbitshapesvg": "Shape/{asset_id}", # Asset ID has ".svg" included
"toolbitshapepng": "Shape/{asset_id}", # Asset ID has ".png" included
"machine": "Machine/{asset_id}.fcm",
},
)
# Set up the CAM asset manager.
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
cam_assets = AssetManager()
cam_assets.register_store(cam_asset_store)
try:
ensure_assets_initialized(cam_assets)
except Exception as e:
Path.Log.error(f"Failed to initialize CAM assets in {cam_asset_store._base_dir}: {e}")
else:
Path.Log.debug(f"Using CAM assets in {cam_asset_store._base_dir}")
addToolPreferenceObserver(_on_asset_path_changed)

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from .models.library import Library
__all__ = [
"Library",
]

View File

@@ -0,0 +1,182 @@
# -*- 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 uuid
import pathlib
from typing import Mapping, Union, Optional, List, Dict, cast
import Path
from ...assets import Asset, AssetUri
from ...toolbit import ToolBit
class Library(Asset):
asset_type: str = "toolbitlibrary"
API_VERSION = 1
def __init__(self, label, id=None):
self.id = id if id is not None else str(uuid.uuid4())
self._label = label
self._bits: List[ToolBit] = []
self._bit_nos: Dict[int, ToolBit] = {}
self._bit_urls: Dict[AssetUri, ToolBit] = {}
@property
def label(self) -> str:
return self._label
def get_id(self) -> str:
"""Returns the unique identifier for the Library instance."""
return self.id
@classmethod
def resolve_name(cls, identifier: Union[str, AssetUri, pathlib.Path]) -> AssetUri:
"""
Resolves various forms of library identifiers to a canonical AssetUri string.
Handles direct AssetUri objects, URI strings, asset IDs, or legacy filenames.
Returns the canonical URI string or None if resolution fails.
"""
if isinstance(identifier, AssetUri):
return identifier
if isinstance(identifier, str) and AssetUri.is_uri(identifier):
return AssetUri(identifier)
if isinstance(identifier, pathlib.Path): # Handle direct Path objects (legacy filenames)
identifier = identifier.stem # Use the filename stem as potential ID
if not isinstance(identifier, str):
raise ValueError("Failed to resolve {identifier} to a Uri")
return AssetUri.build(asset_type=Library.asset_type, asset_id=identifier)
def to_dict(self) -> dict:
"""Returns a dictionary representation of the Library in the specified format."""
tools_list = []
for tool_no, tool in self._bit_nos.items():
tools_list.append(
{"nr": tool_no, "path": f"{tool.get_id()}.fctb"} # Tool ID with .fctb extension
)
return {"label": self.label, "tools": tools_list, "version": self.API_VERSION}
@classmethod
def from_dict(
cls,
data_dict: dict,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> "Library":
"""
Creates a Library instance from a dictionary and resolved dependencies.
If dependencies is None, it's a shallow load, and tools are not populated.
"""
library = cls(data_dict.get("label", id or "Unnamed Library"), id=id)
if dependencies is None:
Path.Log.debug(
f"Library.from_dict: Shallow load for library '{library.label}' (id: {id}). Tools not populated."
)
return library # Only process tools if dependencies were resolved
tools_list = data_dict.get("tools", [])
for tool_data in tools_list:
tool_no = tool_data["nr"]
tool_id = pathlib.Path(tool_data["path"]).stem # Extract tool ID
tool_uri = AssetUri(f"toolbit://{tool_id}")
bit = cast(ToolBit, dependencies.get(tool_uri))
if bit:
library.add_bit(bit, bit_no=tool_no)
else:
raise ValueError(f"Tool with id {tool_id} not found in dependencies")
return library
def __str__(self):
return '{} "{}"'.format(self.id, self.label)
def __eq__(self, other):
return self.id == other.id
def __iter__(self):
return self._bits.__iter__()
def get_next_bit_no(self):
bit_nolist = sorted(self._bit_nos, reverse=True)
return bit_nolist[0] + 1 if bit_nolist else 1
def get_bit_no_from_bit(self, bit: ToolBit) -> Optional[int]:
for bit_no, thebit in self._bit_nos.items():
if bit == thebit:
return bit_no
return None
def get_tool_by_uri(self, uri: AssetUri) -> Optional[ToolBit]:
for tool in self._bit_nos.values():
if tool.get_uri() == uri:
return tool
return None
def assign_new_bit_no(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]:
if bit not in self._bits:
return
# If no specific bit_no was requested, assign a new one.
if bit_no is None:
bit_no = self.get_next_bit_no()
elif self._bit_nos.get(bit_no) == bit:
return
# Otherwise, add the bit. Since the requested bit_no may already
# be in use, we need to account for that. In this case, we will
# add the removed bit into a new bit_no.
old_bit = self._bit_nos.pop(bit_no, None)
old_bit_no = self.get_bit_no_from_bit(bit)
if old_bit_no:
del self._bit_nos[old_bit_no]
self._bit_nos[bit_no] = bit
if old_bit:
self.assign_new_bit_no(old_bit)
return bit_no
def add_bit(self, bit: ToolBit, bit_no: Optional[int] = None) -> Optional[int]:
if bit not in self._bits:
self._bits.append(bit)
return self.assign_new_bit_no(bit, bit_no)
def get_bits(self) -> List[ToolBit]:
return self._bits
def has_bit(self, bit: ToolBit) -> bool:
for t in self._bits:
if bit.id == t.id:
return True
return False
def remove_bit(self, bit: ToolBit):
self._bits = [t for t in self._bits if t.id != bit.id]
self._bit_nos = {k: v for (k, v) in self._bit_nos.items() if v.id != bit.id}
def dump(self, summarize: bool = False):
title = 'Library "{}" ({}) (instance {})'.format(self.label, self.id, id(self))
print("-" * len(title))
print(title)
print("-" * len(title))
for bit in self._bits:
print(f"- {bit.label} ({bit.get_id()})")
print()

View File

@@ -0,0 +1,13 @@
from .camotics import CamoticsLibrarySerializer
from .fctl import FCTLSerializer
from .linuxcnc import LinuxCNCSerializer
all_serializers = CamoticsLibrarySerializer, FCTLSerializer, LinuxCNCSerializer
__all__ = [
"CamoticsLibrarySerializer",
"FCTLSerializer",
"LinuxCNCSerializer",
]

View File

@@ -0,0 +1,171 @@
# -*- 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 uuid
import json
from typing import Mapping, List, Optional, Type
import FreeCAD
from ...assets import Asset, AssetUri, AssetSerializer
from ...toolbit import ToolBit
from ...toolbit.mixins import RotaryToolBitMixin
from ...shape import ToolBitShape, ToolBitShapeEndmill
from ..models.library import Library
SHAPEMAP = {
"ballend": "Ballnose",
"endmill": "Cylindrical",
"v-bit": "Conical",
"chamfer": "Snubnose",
}
SHAPEMAP_REVERSE = dict((v, k) for k, v in SHAPEMAP.items())
tooltemplate = {
"units": "metric",
"shape": "Cylindrical",
"length": 10,
"diameter": 3.125,
"description": "",
}
class CamoticsLibrarySerializer(AssetSerializer):
for_class: Type[Asset] = Library
extensions: tuple[str] = (".ctbl",)
mime_type: str = "application/json"
@classmethod
def get_label(cls) -> str:
return FreeCAD.Qt.translate("CAM", "Camotics Tool Library")
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def serialize(cls, asset: Asset) -> bytes:
if not isinstance(asset, Library):
raise TypeError("Asset must be a Library instance")
toollist = {}
for tool_no, tool in asset._bit_nos.items():
assert isinstance(tool, RotaryToolBitMixin)
toolitem = tooltemplate.copy()
diameter_value = tool.get_diameter()
# Ensure diameter is a float, handle Quantity and other types
diameter_serializable = 2.0 # Default value as float
if isinstance(diameter_value, FreeCAD.Units.Quantity):
try:
val_mm = diameter_value.getValueAs("mm")
if val_mm is not None:
diameter_serializable = float(val_mm)
except ValueError:
# Fallback to raw value if unit conversion fails
raw_val = diameter_value.Value if hasattr(diameter_value, "Value") else None
if isinstance(raw_val, (int, float)):
diameter_serializable = float(raw_val)
elif isinstance(diameter_value, (int, float)):
diameter_serializable = float(diameter_value) if diameter_value is not None else 2.0
toolitem["diameter"] = diameter_serializable
toolitem["description"] = tool.label
length_value = tool.get_length()
# Ensure length is a float, handle Quantity and other types
length_serializable = 10.0 # Default value as float
if isinstance(length_value, FreeCAD.Units.Quantity):
try:
val_mm = length_value.getValueAs("mm")
if val_mm is not None:
length_serializable = float(val_mm)
except ValueError:
# Fallback to raw value if unit conversion fails
raw_val = length_value.Value if hasattr(length_value, "Value") else None
if isinstance(raw_val, (int, float)):
length_serializable = float(raw_val)
elif isinstance(length_value, (int, float)):
length_serializable = float(length_value) if length_value is not None else 10.0
toolitem["length"] = length_serializable
toolitem["shape"] = SHAPEMAP.get(tool._tool_bit_shape.name, "Cylindrical")
toollist[str(tool_no)] = toolitem
return json.dumps(toollist, indent=2).encode("utf-8")
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> Library:
try:
data_dict = json.loads(data.decode("utf-8"))
except json.JSONDecodeError as e:
raise ValueError(f"Failed to decode JSON data: {e}") from e
library = Library(id, id=id)
for tool_no_str, toolitem in data_dict.items():
try:
tool_no = int(tool_no_str)
except ValueError:
print(f"Warning: Skipping invalid tool number: {tool_no_str}")
continue
# Find the shape class to use
shape_name_str = SHAPEMAP_REVERSE.get(toolitem.get("shape", "Cylindrical"), "endmill")
shape_class = ToolBitShape.get_subclass_by_name(shape_name_str)
if not shape_class:
print(f"Warning: Unknown shape name '{shape_name_str}', defaulting to endmill")
shape_class = ToolBitShapeEndmill
# Translate parameters to FreeCAD types
params = {}
try:
diameter = float(toolitem.get("diameter", 2))
params["Diameter"] = FreeCAD.Units.Quantity(f"{diameter} mm")
except (ValueError, TypeError):
print(f"Warning: Invalid diameter for tool {tool_no_str}, skipping.")
try:
length = float(toolitem.get("length", 10))
params["Length"] = FreeCAD.Units.Quantity(f"{length} mm")
except (ValueError, TypeError):
print(f"Warning: Invalid length for tool {tool_no_str}, skipping.")
# Create the shape
shape_id = shape_name_str.lower()
tool_bit_shape = shape_class(shape_id, **params)
# Create the toolbit
tool = ToolBit(tool_bit_shape, id=f"camotics_tool_{tool_no_str}")
tool.label = toolitem.get("description", "")
library.add_bit(tool, tool_no)
return library
@classmethod
def deep_deserialize(cls, data: bytes) -> Library:
# TODO: Build tools here
return cls.deserialize(data, str(uuid.uuid4()), {})

View File

@@ -0,0 +1,107 @@
# -*- 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 uuid
import json
from typing import Mapping, List, Optional
import pathlib
import FreeCAD
import Path
from ...assets import Asset, AssetUri, AssetSerializer
from ...toolbit import ToolBit
from ..models.library import Library
class FCTLSerializer(AssetSerializer):
for_class = Library
extensions = (".fctl",)
mime_type = "application/x-freecad-toolbit-library"
@classmethod
def get_label(cls) -> str:
return FreeCAD.Qt.translate("CAM", "FreeCAD Tool Library")
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
data_dict = json.loads(data.decode("utf-8"))
tools_list = data_dict.get("tools", [])
tool_ids = [pathlib.Path(tool["path"]).stem for tool in tools_list]
return [AssetUri(f"toolbit://{tool_id}") for tool_id in tool_ids]
@classmethod
def serialize(cls, asset: Asset) -> bytes:
"""Serializes a Library object into bytes."""
if not isinstance(asset, Library):
raise TypeError(f"Expected Library instance, got {type(asset).__name__}")
attrs = asset.to_dict()
return json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8")
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> Library:
"""
Creates a Library instance from serialized data and resolved
dependencies.
"""
data_dict = json.loads(data.decode("utf-8"))
# The id parameter from the Asset.from_bytes method is the canonical ID
# for the asset being deserialized. We should use this ID for the library
# instance, overriding any 'id' that might be in the data_dict (which
# is from an older version of the format).
library = Library(data_dict.get("label", id or "Unnamed Library"), id=id)
if dependencies is None:
Path.Log.debug(
f"FCTLSerializer.deserialize: Shallow load for library '{library.label}' (id: {id}). Tools not populated."
)
return library # Only process tools if dependencies were resolved
tools_list = data_dict.get("tools", [])
for tool_data in tools_list:
tool_no = tool_data["nr"]
tool_id = pathlib.Path(tool_data["path"]).stem # Extract tool ID
tool_uri = AssetUri(f"toolbit://{tool_id}")
tool = dependencies.get(tool_uri)
if tool:
# Ensure the dependency is a ToolBit instance
if not isinstance(tool, ToolBit):
Path.Log.warning(
f"Dependency for tool '{tool_id}' is not a ToolBit instance. Skipping."
)
continue
library.add_bit(tool, bit_no=tool_no)
else:
# This should not happen if dependencies were resolved correctly,
# but as a safeguard, log a warning and skip the tool.
Path.Log.warning(
f"Tool with id {tool_id} not found in dependencies during deserialization."
)
return library
@classmethod
def deep_deserialize(cls, data: bytes) -> Library:
# TODO: attempt to fetch tools from the asset manager here
return cls.deserialize(data, str(uuid.uuid4()), {})

View File

@@ -0,0 +1,78 @@
# -*- 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 io
from typing import Mapping, List, Optional, Type
import FreeCAD
import Path
from ...assets import Asset, AssetUri, AssetSerializer
from ...toolbit import ToolBit
from ...toolbit.mixins import RotaryToolBitMixin
from ..models.library import Library
class LinuxCNCSerializer(AssetSerializer):
for_class: Type[Asset] = Library
extensions: tuple[str] = (".tbl",)
mime_type: str = "text/plain"
can_import = False
@classmethod
def get_label(cls) -> str:
return FreeCAD.Qt.translate("CAM", "LinuxCNC Tool Table")
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
return []
@classmethod
def serialize(cls, asset: Asset) -> bytes:
if not isinstance(asset, Library):
raise TypeError("Asset must be a Library instance")
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()
pocket = "P" # 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:.1f} ;{bit.label}\n"
output.write(line.encode("ascii", "ignore"))
return output.getvalue()
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> Library:
# LinuxCNC .tbl files do not contain enough information to fully
# reconstruct a Library and its ToolBits.
# Therefore, deserialization is not supported.
raise NotImplementedError("Deserialization is not supported for LinuxCNC .tbl files.")

View File

@@ -0,0 +1,116 @@
# -*- 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 *
# * *
# ***************************************************************************
"""Widget for browsing Tool Library assets with filtering and sorting."""
from typing import cast
from PySide import QtGui
import Path
from ...toolbit.ui.browser import ToolBitBrowserWidget
from ...assets import AssetManager
from ...library import Library
class LibraryBrowserWidget(ToolBitBrowserWidget):
"""
A widget to browse, filter, and select Tool Library assets from the
AssetManager, with sorting and batch insertion, including library selection.
"""
def __init__(
self,
asset_manager: AssetManager,
store: str = "local",
parent=None,
compact=True,
):
self._library_combo = QtGui.QComboBox()
super().__init__(
asset_manager=asset_manager,
store=store,
parent=parent,
tool_no_factory=self.get_tool_no_from_current_library,
compact=compact,
)
# Create the library dropdown and insert it into the top layout
self._top_layout.insertWidget(0, self._library_combo)
self._library_combo.currentIndexChanged.connect(self._on_library_changed)
def refresh(self):
"""Refreshes the library dropdown and fetches all assets."""
self._library_combo.clear()
self._fetch_all_assets()
def _fetch_all_assets(self):
"""Populates the library dropdown with available libraries."""
# Use list_assets("toolbitlibrary") to get URIs
libraries = self._asset_manager.fetch("toolbitlibrary", store=self._store_name, depth=0)
for library in sorted(libraries, key=lambda x: x.label):
self._library_combo.addItem(library.label, userData=library)
if not self._library_combo.count():
return
# Trigger initial load after populating libraries
self._on_library_changed(0)
def get_tool_no_from_current_library(self, toolbit):
"""
Retrieves the tool number for a toolbit based on the currently
selected library.
"""
selected_library = self._library_combo.currentData()
if selected_library is None:
return None
# Use the get_bit_no_from_bit method of the Library object
# This method returns the tool number or None
tool_no = selected_library.get_bit_no_from_bit(toolbit)
return tool_no
def _on_library_changed(self, index):
"""Handles library selection change."""
# Get the selected library from the combo box
selected_library = self._library_combo.currentData()
if not isinstance(selected_library, Library):
self._all_assets = []
return
# Fetch the library from the asset manager
library_uri = selected_library.get_uri()
try:
library = self._asset_manager.get(library_uri, store=self._store_name, depth=1)
# Update the combo box item's user data with the fully fetched library
self._library_combo.setItemData(index, library)
except FileNotFoundError:
Path.Log.error(f"Library {library_uri} not found in store {self._store_name}.")
self._all_assets = []
return
# Update _all_assets based on the selected library
library = cast(Library, library)
self._all_assets = [t for t in library]
self._sort_assets()
self._tool_list_widget.clear_list()
self._scroll_position = 0
self._trigger_fetch() # Display data for the selected library

View File

@@ -24,6 +24,9 @@ from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
import FreeCADGui
import Path
from Path.Tool.library.ui.dock import ToolBitLibraryDock
from Path.Tool.library.ui.editor import LibraryEditor
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
@@ -34,9 +37,9 @@ else:
translate = FreeCAD.Qt.translate
class CommandToolBitSelectorOpen:
class CommandToolBitLibraryDockOpen:
"""
Command to toggle the ToolBitSelector Dock
Command to toggle the ToolBitLibraryDock
"""
def __init__(self):
@@ -52,16 +55,14 @@ class CommandToolBitSelectorOpen:
}
def IsActive(self):
return FreeCAD.ActiveDocument is not None
return True
def Activated(self):
import Path.Tool.Gui.BitLibrary as PathToolBitLibraryGui
dock = PathToolBitLibraryGui.ToolBitSelector()
dock = ToolBitLibraryDock()
dock.open()
class CommandToolBitLibraryOpen:
class CommandLibraryEditorOpen:
"""
Command to open ToolBitLibrary editor.
"""
@@ -80,19 +81,16 @@ class CommandToolBitLibraryOpen:
}
def IsActive(self):
return FreeCAD.ActiveDocument is not None
return True
def Activated(self):
import Path.Tool.Gui.BitLibrary as PathToolBitLibraryGui
library = PathToolBitLibraryGui.ToolBitLibrary()
library = LibraryEditor()
library.open()
if FreeCAD.GuiUp:
FreeCADGui.addCommand("CAM_ToolBitLibraryOpen", CommandToolBitLibraryOpen())
FreeCADGui.addCommand("CAM_ToolBitDock", CommandToolBitSelectorOpen())
FreeCADGui.addCommand("CAM_ToolBitLibraryOpen", CommandLibraryEditorOpen())
FreeCADGui.addCommand("CAM_ToolBitDock", CommandToolBitLibraryDockOpen())
BarList = ["CAM_ToolBitDock"]
MenuList = ["CAM_ToolBitLibraryOpen", "CAM_ToolBitDock"]

View File

@@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 2020 Schildkroet *
# * 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 *
# * *
# ***************************************************************************
"""ToolBit Library Dock Widget."""
import FreeCAD
import FreeCADGui
import Path
import Path.Tool.Gui.Controller as PathToolControllerGui
import PathScripts.PathUtilsGui as PathUtilsGui
from PySide import QtGui, QtCore
from functools import partial
from typing import List, Tuple
from ...assets import AssetUri
from ...camassets import cam_assets, ensure_assets_initialized
from ...toolbit import ToolBit
from .editor import LibraryEditor
from .browser import LibraryBrowserWidget
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
translate = FreeCAD.Qt.translate
class ToolBitLibraryDock(object):
"""Controller for displaying a library and creating ToolControllers"""
def __init__(self):
ensure_assets_initialized(cam_assets)
# Create the main form widget directly
self.form = QtGui.QDockWidget()
self.form.setObjectName("ToolSelector")
self.form.setWindowTitle(translate("CAM_ToolBit", "Tool Selector"))
# Create the browser widget
self.browser_widget = LibraryBrowserWidget(asset_manager=cam_assets)
self._setup_ui()
def _setup_ui(self):
"""Setup the form and load the tooltable data"""
Path.Log.track()
# Create a main widget and layout for the dock
main_widget = QtGui.QWidget()
main_layout = QtGui.QVBoxLayout(main_widget)
# Add the browser widget to the layout
main_layout.addWidget(self.browser_widget)
# Create buttons
self.libraryEditorOpenButton = QtGui.QPushButton(
translate("CAM_ToolBit", "Open Library Editor")
)
self.addToolControllerButton = QtGui.QPushButton(translate("CAM_ToolBit", "Add to Job"))
# Add buttons to a horizontal layout
button_layout = QtGui.QHBoxLayout()
button_layout.addWidget(self.libraryEditorOpenButton)
button_layout.addWidget(self.addToolControllerButton)
# Add the button layout to the main layout
main_layout.addLayout(button_layout)
# Set the main widget as the dock's widget
self.form.setWidget(main_widget)
# Connect signals from the browser widget and buttons
self.browser_widget.toolSelected.connect(self._update_state)
self.browser_widget.itemDoubleClicked.connect(partial(self._add_tool_controller_to_doc))
self.libraryEditorOpenButton.clicked.connect(self._open_editor)
self.addToolControllerButton.clicked.connect(partial(self._add_tool_controller_to_doc))
# Initial state of buttons
self._update_state()
def _update_state(self):
"""Enable button to add tool controller when a tool is selected"""
# Set buttons inactive
self.addToolControllerButton.setEnabled(False)
# Check if any tool is selected in the browser widget
selected = self.browser_widget._tool_list_widget.selectedItems()
if selected and FreeCAD.ActiveDocument:
jobs = len([1 for j in FreeCAD.ActiveDocument.Objects if j.Name[:3] == "Job"]) >= 1
self.addToolControllerButton.setEnabled(len(selected) >= 1 and jobs)
def _open_editor(self):
library = LibraryEditor()
library.open()
# After editing, we might need to refresh the libraries in the browser widget
# Assuming _populate_libraries is the correct method to call
self.browser_widget.refresh()
def _add_tool_to_doc(self) -> List[Tuple[int, ToolBit]]:
"""
Get the selected toolbit assets from the browser widget.
"""
Path.Log.track()
tools = []
selected_toolbits = self.browser_widget.get_selected_bits()
for toolbit in selected_toolbits:
# Need to get the tool number for this toolbit from the currently
# selected library in the browser widget.
toolNr = self.browser_widget.get_tool_no_from_current_library(toolbit)
if toolNr is not None:
toolbit.attach_to_doc(FreeCAD.ActiveDocument)
tools.append((toolNr, toolbit))
else:
Path.Log.warning(
f"Could not get tool number for toolbit {toolbit.get_uri()} in selected library."
)
return tools
def _add_tool_controller_to_doc(self, index=None):
"""
if no jobs, don't do anything, otherwise all TCs for all
selected toolbit assets
"""
Path.Log.track()
jobs = PathUtilsGui.PathUtils.GetJobs()
if len(jobs) == 0:
QtGui.QMessageBox.information(
self.form,
translate("CAM_ToolBit", "No Job Found"),
translate("CAM_ToolBit", "Please create a Job first."),
)
return
elif len(jobs) == 1:
job = jobs[0]
else:
userinput = PathUtilsGui.PathUtilsUserInput()
job = userinput.chooseJob(jobs)
if job is None: # user may have canceled
return
# Get the selected toolbit assets
selected_tools = self._add_tool_to_doc()
for toolNr, toolbit in selected_tools:
tc = PathToolControllerGui.Create(f"TC: {toolbit.label}", toolbit.obj, toolNr)
job.Proxy.addToolController(tc)
FreeCAD.ActiveDocument.recompute()
def open(self, path=None):
"""load library stored in path and bring up ui"""
docs = FreeCADGui.getMainWindow().findChildren(QtGui.QDockWidget)
for doc in docs:
if doc.objectName() == "ToolSelector":
if doc.isVisible():
doc.deleteLater()
return
else:
doc.setVisible(True)
return
mw = FreeCADGui.getMainWindow()
mw.addDockWidget(
QtCore.Qt.RightDockWidgetArea,
self.form,
QtCore.Qt.Orientation.Vertical,
)

View File

@@ -0,0 +1,642 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 2020 Schildkroet *
# * 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 FreeCADGui
import Path
import PySide
from PySide.QtGui import QStandardItem, QStandardItemModel, QPixmap
from PySide.QtCore import Qt
import os
import uuid as UUID
from typing import List, cast
from ...assets import AssetUri
from ...assets.ui import AssetOpenDialog, AssetSaveDialog
from ...camassets import cam_assets, ensure_assets_initialized
from ...shape.ui.shapeselector import ShapeSelector
from ...toolbit import ToolBit
from ...toolbit.serializers import all_serializers as toolbit_serializers
from ...toolbit.ui import ToolBitEditor
from ...library import Library
from ...library.serializers import all_serializers as library_serializers
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
_UuidRole = PySide.QtCore.Qt.UserRole + 1
_PathRole = PySide.QtCore.Qt.UserRole + 2
_LibraryRole = PySide.QtCore.Qt.UserRole + 3
translate = FreeCAD.Qt.translate
class _TableView(PySide.QtGui.QTableView):
"""Subclass of QTableView to support rearrange and copying of ToolBits"""
def __init__(self, parent):
PySide.QtGui.QTableView.__init__(self, parent)
self.setDragEnabled(False)
self.setAcceptDrops(False)
self.setDropIndicatorShown(False)
self.setDragDropMode(PySide.QtGui.QAbstractItemView.DragOnly)
self.setDefaultDropAction(PySide.QtCore.Qt.IgnoreAction)
self.setSortingEnabled(True)
self.setSelectionBehavior(PySide.QtGui.QAbstractItemView.SelectRows)
self.verticalHeader().hide()
def supportedDropActions(self):
return [PySide.QtCore.Qt.CopyAction, PySide.QtCore.Qt.MoveAction]
def _uuidOfRow(self, row):
model = self.toolModel()
return model.data(model.index(row, 0), _UuidRole)
def _rowWithUuid(self, uuid):
model = self.toolModel()
for row in range(model.rowCount()):
if self._uuidOfRow(row) == uuid:
return row
return None
def _copyTool(self, uuid_, dstRow):
model = self.toolModel()
model.insertRow(dstRow)
srcRow = self._rowWithUuid(uuid_)
for col in range(model.columnCount()):
srcItem = model.item(srcRow, col)
model.setData(
model.index(dstRow, col),
srcItem.data(PySide.QtCore.Qt.EditRole),
PySide.QtCore.Qt.EditRole,
)
if col == 0:
model.setData(model.index(dstRow, col), srcItem.data(_PathRole), _PathRole)
# Even a clone of a tool gets its own uuid so it can be identified when
# rearranging the order or inserting/deleting rows
model.setData(model.index(dstRow, col), UUID.uuid4(), _UuidRole)
else:
model.item(dstRow, col).setEditable(False)
def _copyTools(self, uuids, dst):
for i, uuid in enumerate(uuids):
self._copyTool(uuid, dst + i)
def dropEvent(self, event):
"""Handle drop events on the tool table"""
Path.Log.track()
mime = event.mimeData()
data = mime.data("application/x-qstandarditemmodeldatalist")
stream = PySide.QtCore.QDataStream(data)
srcRows = []
while not stream.atEnd():
row = stream.readInt32()
srcRows.append(row)
# get the uuids of all srcRows
model = self.toolModel()
srcUuids = [self._uuidOfRow(row) for row in set(srcRows)]
destRow = self.rowAt(event.pos().y())
self._copyTools(srcUuids, destRow)
if PySide.QtCore.Qt.DropAction.MoveAction == event.proposedAction():
for uuid in srcUuids:
model.removeRow(self._rowWithUuid(uuid))
class ModelFactory:
"""Helper class to generate qtdata models for toolbit libraries"""
@staticmethod
def find_libraries(model) -> QStandardItemModel:
"""
Finds all the fctl files in a location.
Returns a QStandardItemModel.
"""
Path.Log.track()
model.clear()
# Use AssetManager to fetch library assets (depth=0 for shallow fetch)
try:
# Fetch library assets themselves, not their deep dependencies (toolbits).
# depth=0 means "fetch this asset, but not its dependencies"
# The 'fetch' method returns actual Asset objects.
libraries = cast(List[Library], cam_assets.fetch(asset_type="toolbitlibrary", depth=0))
except Exception as e:
Path.Log.error(f"Failed to fetch toolbit libraries: {e}")
return model # Return empty model on error
# Sort by label for consistent ordering, falling back to asset_id if label is missing
def get_sort_key(library):
label = getattr(library, "label", None)
return label if label else library.get_id()
for library in sorted(libraries, key=get_sort_key):
lib_uri_str = str(library.get_uri())
libItem = QStandardItem(library.label or library.get_id())
libItem.setToolTip(f"ID: {library.get_id()}\nURI: {lib_uri_str}")
libItem.setData(lib_uri_str, _LibraryRole) # Store the URI string
libItem.setIcon(QPixmap(":/icons/CAM_ToolTable.svg"))
model.appendRow(libItem)
Path.Log.debug("model rows: {}".format(model.rowCount()))
return model
@staticmethod
def __library_load(library_uri: str, data_model: QStandardItemModel):
Path.Log.track(library_uri)
if library_uri:
# Store the AssetUri string, not just the name
Path.Preferences.setLastToolLibrary(library_uri)
try:
# Load the library asset using AssetManager
loaded_library = cam_assets.get(AssetUri(library_uri), depth=1)
except Exception as e:
Path.Log.error(f"Failed to load library from {library_uri}: {e}")
raise
# Iterate over the loaded ToolBit asset instances
for tool_no, tool_bit in sorted(loaded_library._bit_nos.items()):
data_model.appendRow(
ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri()))
)
@staticmethod
def _generate_tooltip(toolbit: dict) -> str:
"""
Generate an HTML tooltip for a given toolbit dictionary.
Args:
toolbit (dict): A dictionary containing toolbit information.
Returns:
str: An HTML string representing the tooltip.
"""
tooltip = f"<b>Name:</b> {toolbit['name']}<br>"
tooltip += f"<b>Shape File:</b> {toolbit['shape']}<br>"
tooltip += "<b>Parameters:</b><br>"
parameters = toolbit.get("parameter", {})
if parameters:
for key, value in parameters.items():
tooltip += f" <b>{key}:</b> {value}<br>"
else:
tooltip += " No parameters provided.<br>"
attributes = toolbit.get("attribute", {})
if attributes:
tooltip += "<b>Attributes:</b><br>"
for key, value in attributes.items():
tooltip += f" <b>{key}:</b> {value}<br>"
return tooltip
@staticmethod
def _tool_add(nr: int, tool: dict, path: str):
str_shape = os.path.splitext(os.path.basename(tool["shape"]))[0]
tooltip = ModelFactory._generate_tooltip(tool)
tool_nr = QStandardItem()
tool_nr.setData(nr, Qt.EditRole)
tool_nr.setData(path, _PathRole)
tool_nr.setData(UUID.uuid4(), _UuidRole)
tool_nr.setToolTip(tooltip)
tool_name = QStandardItem()
tool_name.setData(tool["name"], Qt.EditRole)
tool_name.setEditable(False)
tool_name.setToolTip(tooltip)
tool_shape = QStandardItem()
tool_shape.setData(str_shape, Qt.EditRole)
tool_shape.setEditable(False)
return [tool_nr, tool_name, tool_shape]
@staticmethod
def library_open(model: QStandardItemModel, library_uri: str) -> QStandardItemModel:
"""
Opens the tools in a library using its AssetUri.
Returns a QStandardItemModel.
"""
Path.Log.track(library_uri)
ModelFactory.__library_load(library_uri, model)
Path.Log.debug("model rows: {}".format(model.rowCount()))
return model
class LibraryEditor(object):
"""LibraryEditor is the controller for
displaying/selecting/creating/editing a collection of ToolBits."""
def __init__(self):
Path.Log.track()
ensure_assets_initialized(cam_assets)
self.factory = ModelFactory()
self.toolModel = PySide.QtGui.QStandardItemModel(0, len(self.columnNames()))
self.listModel = PySide.QtGui.QStandardItemModel()
self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitLibraryEdit.ui")
self.toolTableView = _TableView(self.form.toolTableGroup)
self.form.toolTableGroup.layout().replaceWidget(self.form.toolTable, self.toolTableView)
self.form.toolTable.hide()
self.setupUI()
self.title = self.form.windowTitle()
# Connect signals for tool editing
self.toolTableView.doubleClicked.connect(self.toolEdit)
def toolBitNew(self):
"""Create a new toolbit asset and add it to the current library"""
Path.Log.track()
if not self.current_library:
PySide.QtGui.QMessageBox.warning(
self.form,
translate("CAM_ToolBit", "No Library Loaded"),
translate("CAM_ToolBit", "Load or create a tool library first."),
)
return
# Select the shape for the new toolbit
selector = ShapeSelector()
shape = selector.show()
if shape is None: # user canceled
return
try:
# Find the appropriate ToolBit subclass based on the shape name
tool_bit_classes = {b.SHAPE_CLASS.name: b for b in ToolBit.__subclasses__()}
tool_bit_class = tool_bit_classes.get(shape.name)
if not tool_bit_class:
raise ValueError(f"No ToolBit subclass found for shape '{shape.name}'")
# Create a new ToolBit instance using the subclass constructor
# The constructor will generate a UUID
toolbit = tool_bit_class(shape)
# 1. Save the individual toolbit asset first.
tool_asset_uri = cam_assets.add(toolbit)
Path.Log.debug(f"toolBitNew: Saved tool with URI: {tool_asset_uri}")
# 2. Add the toolbit (which now has a persisted URI) to the current library's model
tool_no = self.current_library.add_bit(toolbit)
Path.Log.debug(
f"toolBitNew: Added toolbit {toolbit.get_id()} (URI: {toolbit.get_uri()}) "
f"to current_library with tool number {tool_no}."
)
# 3. Add the new tool directly to the UI model
new_row_items = ModelFactory._tool_add(
tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit
)
self.toolModel.appendRow(new_row_items)
# 4. Save the library (which now references the saved toolbit)
self._saveCurrentLibrary()
except Exception as e:
Path.Log.error(f"Failed to create or add new toolbit: {e}")
PySide.QtGui.QMessageBox.critical(
self.form,
translate("CAM_ToolBit", "Error Creating Toolbit"),
str(e),
)
raise
def toolBitExisting(self):
"""Add an existing toolbit asset to the current library"""
Path.Log.track()
if not self.current_library:
PySide.QtGui.QMessageBox.warning(
self.form,
translate("CAM_ToolBit", "No Library Loaded"),
translate("CAM_ToolBit", "Load or create a tool library first."),
)
return
# Open the file dialog
dialog = AssetOpenDialog(ToolBit, toolbit_serializers, self.form)
dialog_result = dialog.exec()
if not dialog_result:
return # User canceled or error
file_path, toolbit = dialog_result
toolbit = cast(ToolBit, toolbit)
try:
# Add the existing toolbit to the current library's model
# The add_bit method handles assigning a tool number and returns it.
cam_assets.add(toolbit)
tool_no = self.current_library.add_bit(toolbit)
# Add the new tool directly to the UI model
new_row_items = ModelFactory._tool_add(
tool_no, toolbit.to_dict(), str(toolbit.get_uri()) # URI of the persisted toolbit
)
self.toolModel.appendRow(new_row_items)
# Save the library (which now references the added toolbit)
# Use cam_assets.add directly for internal save on existing toolbit
self._saveCurrentLibrary()
except Exception as e:
Path.Log.error(
f"Failed to add imported toolbit {toolbit.get_id()} "
f"from {file_path} to library: {e}"
)
PySide.QtGui.QMessageBox.critical(
self.form,
translate("CAM_ToolBit", "Error Adding Imported Toolbit"),
str(e),
)
raise
def toolDelete(self):
"""Delete a tool"""
Path.Log.track()
selected_indices = self.toolTableView.selectedIndexes()
if not selected_indices:
return
if not self.current_library:
Path.Log.error("toolDelete: No current_library loaded. Cannot delete tools.")
return
# Collect unique rows to process, as selectedIndexes can return multiple indices per row
selected_rows = sorted(list(set(index.row() for index in selected_indices)), reverse=True)
# Remove the rows from the library model.
for row in selected_rows:
item_tool_nr_or_uri = self.toolModel.item(row, 0) # Column 0 stores _PathRole
tool_uri_string = item_tool_nr_or_uri.data(_PathRole)
tool_uri = AssetUri(tool_uri_string)
bit = self.current_library.get_tool_by_uri(tool_uri)
self.current_library.remove_bit(bit)
self.toolModel.removeRows(row, 1)
Path.Log.info(f"toolDelete: Removed {len(selected_rows)} rows from UI model.")
# Save the library after deleting a tool
self._saveCurrentLibrary()
def toolSelect(self, selected, deselected):
sel = len(self.toolTableView.selectedIndexes()) > 0
self.form.toolDelete.setEnabled(sel)
def tableSelected(self, index):
"""loads the tools for the selected tool table"""
Path.Log.track()
item = index.model().itemFromIndex(index)
library_uri_string = item.data(_LibraryRole)
self._loadSelectedLibraryTools(library_uri_string)
def open(self):
Path.Log.track()
return self.form.exec_()
def toolEdit(self, selected):
"""Edit the selected tool bit asset"""
Path.Log.track()
item = self.toolModel.item(selected.row(), 0)
if selected.column() == 0:
return # Assuming tool number editing is handled directly in the table model
toolbit_uri_string = item.data(_PathRole)
if not toolbit_uri_string:
Path.Log.error("No toolbit URI found for selected item.")
return
toolbit_uri = AssetUri(toolbit_uri_string)
# Load the toolbit asset for editing
try:
bit = cam_assets.get(toolbit_uri)
editor_dialog = ToolBitEditor(bit, self.form) # Create dialog instance
result = editor_dialog.show() # Show as modal dialog
if result == PySide.QtGui.QDialog.Accepted:
# The editor updates the toolbit directly, so we just need to save
cam_assets.add(bit)
Path.Log.info(f"Toolbit {bit.get_id()} saved.")
# Refresh the display and save the library
self._loadSelectedLibraryTools(
self.current_library.get_uri() if self.current_library else None
)
# Save the library after editing a toolbit
self._saveCurrentLibrary()
except Exception as e:
Path.Log.error(f"Failed to load or edit toolbit asset {toolbit_uri_string}: {e}")
PySide.QtGui.QMessageBox.critical(
self.form,
translate("CAM_ToolBit", "Error Editing Toolbit"),
str(e),
)
raise
def libraryNew(self):
"""Create a new tool library asset"""
Path.Log.track()
# Get the desired library name (label) from the user
library_label, ok = PySide.QtGui.QInputDialog.getText(
self.form,
translate("CAM_ToolBit", "New Tool Library"),
translate("CAM_ToolBit", "Enter a name for the new library:"),
)
if not ok or not library_label:
return
# Create a new Library asset instance, UUID will be auto-generated
new_library = Library(library_label)
uri = cam_assets.add(new_library)
Path.Log.info(f"New library created: {uri}")
# Refresh the list of libraries in the UI
self._refreshLibraryListModel()
self._loadSelectedLibraryTools(uri)
# Attempt to select the newly added library in the list
for i in range(self.listModel.rowCount()):
item = self.listModel.item(i)
if item and item.data(_LibraryRole) == str(uri):
curIndex = self.listModel.indexFromItem(item)
self.form.TableList.setCurrentIndex(curIndex)
Path.Log.debug(f"libraryNew: Selected new library '{str(uri)}' in TableList.")
break
def _refreshLibraryListModel(self):
"""Clears and repopulates the self.listModel with available libraries."""
Path.Log.track()
self.listModel.clear()
self.factory.find_libraries(self.listModel)
self.listModel.setHorizontalHeaderLabels(["Library"])
def _saveCurrentLibrary(self):
"""Internal method to save the current tool library asset"""
Path.Log.track()
if not self.current_library:
Path.Log.warning("_saveCurrentLibrary: No library asset loaded to save.")
return
try:
cam_assets.add(self.current_library)
Path.Log.info(
f"_saveCurrentLibrary: Library " f"{self.current_library.get_uri()} saved."
)
except Exception as e:
Path.Log.error(
f"_saveCurrentLibrary: Failed to save library "
f"{self.current_library.get_uri()}: {e}"
)
PySide.QtGui.QMessageBox.critical(
self.form,
translate("CAM_ToolBit", "Error Saving Library"),
str(e),
)
raise
def exportLibrary(self):
"""Export the current tool library asset to a file"""
Path.Log.track()
if not self.current_library:
PySide.QtGui.QMessageBox.warning(
self.form,
translate("CAM_ToolBit", "No Library Loaded"),
translate("CAM_ToolBit", "Load or create a tool library first."),
)
return
dialog = AssetSaveDialog(self.current_library, library_serializers, self.form)
dialog_result = dialog.exec(self.current_library)
if not dialog_result:
return # User canceled or error
file_path, serializer_class = dialog_result
Path.Log.info(
f"Exported library {self.current_library.label} "
f"to {file_path} using serializer {serializer_class.__name__}"
)
def columnNames(self):
return [
"Tn",
translate("CAM_ToolBit", "Tool"),
translate("CAM_ToolBit", "Shape"),
]
def _loadSelectedLibraryTools(self, library_uri: AssetUri | str | None = None):
"""Loads tools for the given library_uri into self.toolModel and selects it in the list."""
Path.Log.track(library_uri)
self.toolModel.clear()
# library_uri is now expected to be a string URI or None when called from setupUI/tableSelected.
# AssetUri object conversion is handled by cam_assets.get() if needed.
self.current_library = None # Reset current_library before loading
if not library_uri:
self.form.setWindowTitle("Tool Library Editor - No Library Selected")
return
# Fetch the library from the asset manager
try:
self.current_library = cam_assets.get(library_uri, depth=1)
except Exception as e:
Path.Log.error(f"Failed to load library asset {library_uri}: {e}")
self.form.setWindowTitle("Tool Library Editor - Error")
return
# Success! Add the tools to the toolModel.
self.toolTableView.setUpdatesEnabled(False)
self.form.setWindowTitle(f"Tool Library Editor - {self.current_library.label}")
for tool_no, tool_bit in sorted(self.current_library._bit_nos.items()):
self.toolModel.appendRow(
ModelFactory._tool_add(tool_no, tool_bit.to_dict(), str(tool_bit.get_uri()))
)
self.toolModel.setHorizontalHeaderLabels(self.columnNames())
self.toolTableView.setUpdatesEnabled(True)
def setupUI(self):
"""Setup the form and load the tool library data"""
Path.Log.track()
self.form.TableList.setModel(self.listModel)
self._refreshLibraryListModel()
self.toolTableView.setModel(self.toolModel)
# Find the last used library.
last_used_lib_identifier = Path.Preferences.getLastToolLibrary()
Path.Log.debug(
f"setupUI: Last used library identifier from prefs: '{last_used_lib_identifier}'"
)
last_used_lib_uri = None
if last_used_lib_identifier:
last_used_lib_uri = Library.resolve_name(last_used_lib_identifier)
# Find it in the list.
index = 0
for i in range(self.listModel.rowCount()):
item = self.listModel.item(i)
if item and item.data(_LibraryRole) == str(last_used_lib_uri):
index = i
break
# Select it.
if index <= self.listModel.rowCount():
item = self.listModel.item(index)
if item: # Should always be true, but...
library_uri_str = item.data(_LibraryRole)
self.form.TableList.setCurrentIndex(self.listModel.index(index, 0))
# Load tools for the selected library.
self._loadSelectedLibraryTools(library_uri_str)
self.toolTableView.resizeColumnsToContents()
self.toolTableView.selectionModel().selectionChanged.connect(self.toolSelect)
self.form.TableList.clicked.connect(self.tableSelected)
self.form.toolAdd.clicked.connect(self.toolBitExisting)
self.form.toolDelete.clicked.connect(self.toolDelete)
self.form.toolCreate.clicked.connect(self.toolBitNew)
self.form.addLibrary.clicked.connect(self.libraryNew)
self.form.exportLibrary.clicked.connect(self.exportLibrary)
self.form.okButton.clicked.connect(self.form.close)
self.toolSelect([], [])

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2025 The FreeCAD team *
# * *
# * 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 *
# * *
# ***************************************************************************

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from .models.machine import Machine
__all__ = [
"Machine",
]

View File

@@ -0,0 +1,434 @@
# -*- 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 uuid
import json
import FreeCAD
from FreeCAD import Base
from typing import Optional, Union, Mapping, List
from ...assets import Asset, AssetUri, AssetSerializer
class Machine(Asset):
"""Represents a machine with various operational parameters."""
asset_type: str = "machine"
API_VERSION = 1
UNIT_CONVERSIONS = {
"hp": 745.7, # hp to W
"in-lbf": 0.112985, # in-lbf to N*m
"inch/min": 25.4, # inch/min to mm/min
"rpm": 1.0 / 60.0, # rpm to 1/s
"kW": 1000.0, # kW to W
"Nm": 1.0, # Nm to N*m
"mm/min": 1.0, # mm/min to mm/min
}
def __init__(
self,
label: str = "Machine",
max_power: Union[int, float, FreeCAD.Units.Quantity] = 2,
min_rpm: Union[int, float, FreeCAD.Units.Quantity] = 3000,
max_rpm: Union[int, float, FreeCAD.Units.Quantity] = 60000,
max_torque: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None,
peak_torque_rpm: Optional[Union[int, float, FreeCAD.Units.Quantity]] = None,
min_feed: Union[int, float, FreeCAD.Units.Quantity] = 1,
max_feed: Union[int, float, FreeCAD.Units.Quantity] = 2000,
id: Optional[str] = None,
) -> None:
"""
Initializes a Machine object.
Args:
label: The label of the machine.
max_power: The maximum power of the machine (kW or Quantity).
min_rpm: The minimum RPM of the machine (RPM or Quantity).
max_rpm: The maximum RPM of the machine (RPM or Quantity).
max_torque: The maximum torque of the machine (Nm or Quantity).
peak_torque_rpm: The RPM at which peak torque is achieved
(RPM or Quantity).
min_feed: The minimum feed rate of the machine
(mm/min or Quantity).
max_feed: The maximum feed rate of the machine
(mm/min or Quantity).
id: The unique identifier of the machine.
"""
self.id = id or str(uuid.uuid1())
self._label = label
# Initialize max_power (W)
if isinstance(max_power, FreeCAD.Units.Quantity):
self._max_power = max_power.getValueAs("W").Value
elif isinstance(max_power, (int, float)):
self._max_power = max_power * self.UNIT_CONVERSIONS["kW"]
else:
self._max_power = 2000.0
# Initialize min_rpm (1/s)
if isinstance(min_rpm, FreeCAD.Units.Quantity):
try:
self._min_rpm = min_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
self._min_rpm = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
elif isinstance(min_rpm, (int, float)):
self._min_rpm = min_rpm * self.UNIT_CONVERSIONS["rpm"]
else:
self._min_rpm = 3000 * self.UNIT_CONVERSIONS["rpm"]
# Initialize max_rpm (1/s)
if isinstance(max_rpm, FreeCAD.Units.Quantity):
try:
self._max_rpm = max_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
self._max_rpm = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
elif isinstance(max_rpm, (int, float)):
self._max_rpm = max_rpm * self.UNIT_CONVERSIONS["rpm"]
else:
self._max_rpm = 60000 * self.UNIT_CONVERSIONS["rpm"]
# Initialize min_feed (mm/min)
if isinstance(min_feed, FreeCAD.Units.Quantity):
self._min_feed = min_feed.getValueAs("mm/min").Value
elif isinstance(min_feed, (int, float)):
self._min_feed = min_feed
else:
self._min_feed = 1.0
# Initialize max_feed (mm/min)
if isinstance(max_feed, FreeCAD.Units.Quantity):
self._max_feed = max_feed.getValueAs("mm/min").Value
elif isinstance(max_feed, (int, float)):
self._max_feed = max_feed
else:
self._max_feed = 2000.0
# Initialize peak_torque_rpm (1/s)
if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity):
try:
self._peak_torque_rpm = peak_torque_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
self._peak_torque_rpm = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
elif isinstance(peak_torque_rpm, (int, float)):
self._peak_torque_rpm = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"]
else:
self._peak_torque_rpm = self._max_rpm / 3
# Initialize max_torque (N*m)
if isinstance(max_torque, FreeCAD.Units.Quantity):
self._max_torque = max_torque.getValueAs("Nm").Value
elif isinstance(max_torque, (int, float)):
self._max_torque = max_torque
else:
# Convert 1/s to rpm
peak_rpm_for_calc = self._peak_torque_rpm * 60
self._max_torque = (
self._max_power * 9.5488 / peak_rpm_for_calc if peak_rpm_for_calc else float("inf")
)
def get_id(self) -> str:
"""Returns the unique identifier for the Machine instance."""
return self.id
def to_dict(self) -> dict:
"""Returns a dictionary representation of the Machine."""
return {
"version": self.API_VERSION,
"id": self.id,
"label": self.label,
"max_power": self._max_power, # W
"min_rpm": self._min_rpm, # 1/s
"max_rpm": self._max_rpm, # 1/s
"max_torque": self._max_torque, # Nm
"peak_torque_rpm": self._peak_torque_rpm, # 1/s
"min_feed": self._min_feed, # mm/min
"max_feed": self._max_feed, # mm/min
}
def to_bytes(self, serializer: AssetSerializer) -> bytes:
"""Serializes the Machine object to bytes using to_dict."""
data_dict = self.to_dict()
json_str = json.dumps(data_dict)
return json_str.encode("utf-8")
@classmethod
def from_dict(cls, data_dict: dict, id: str) -> "Machine":
"""Creates a Machine instance from a dictionary."""
machine = cls(
label=data_dict.get("label", "Machine"),
max_power=data_dict.get("max_power", 2000.0), # W
min_rpm=data_dict.get("min_rpm", 3000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s
max_rpm=data_dict.get("max_rpm", 60000 * cls.UNIT_CONVERSIONS["rpm"]), # 1/s
max_torque=data_dict.get("max_torque", None), # Nm
peak_torque_rpm=data_dict.get("peak_torque_rpm", None), # 1/s
min_feed=data_dict.get("min_feed", 1.0), # mm/min
max_feed=data_dict.get("max_feed", 2000.0), # mm/min
id=id,
)
return machine
@classmethod
def from_bytes(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> "Machine":
"""
Deserializes bytes into a Machine instance using from_dict.
"""
# If dependencies is None, it's fine as Machine doesn't use it.
data_dict = json.loads(data.decode("utf-8"))
return cls.from_dict(data_dict, id)
@classmethod
def dependencies(cls, data: bytes) -> List[AssetUri]:
"""Returns a list of AssetUri dependencies parsed from the serialized data."""
return [] # Machine has no dependencies
@property
def max_power(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._max_power, "W")
@property
def min_rpm(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._min_rpm, "1/s")
@property
def max_rpm(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._max_rpm, "1/s")
@property
def max_torque(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._max_torque, "Nm")
@property
def peak_torque_rpm(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._peak_torque_rpm, "1/s")
@property
def min_feed(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._min_feed, "mm/min")
@property
def max_feed(self) -> FreeCAD.Units.Quantity:
return FreeCAD.Units.Quantity(self._max_feed, "mm/min")
@property
def label(self) -> str:
return self._label
@label.setter
def label(self, label: str) -> None:
self._label = label
def get_min_rpm_value(self) -> float:
"""Helper method to get minimum RPM value for display/testing."""
return self._min_rpm * 60
def get_max_rpm_value(self) -> float:
"""Helper method to get maximum RPM value for display/testing."""
return self._max_rpm * 60
def get_peak_torque_rpm_value(self) -> float:
"""Helper method to get peak torque RPM value for display/testing."""
return self._peak_torque_rpm * 60
def validate(self) -> None:
"""Validates the machine parameters."""
if not self.label:
raise AttributeError("Machine name is required")
if self._peak_torque_rpm > self._max_rpm:
err = ("Peak Torque RPM {ptrpm:.2f} must be less than max RPM " "{max_rpm:.2f}").format(
ptrpm=self._peak_torque_rpm * 60, max_rpm=self._max_rpm * 60
)
raise AttributeError(err)
if self._max_rpm <= self._min_rpm:
raise AttributeError("Max RPM must be larger than min RPM")
if self._max_feed <= self._min_feed:
raise AttributeError("Max feed must be larger than min feed")
def get_torque_at_rpm(self, rpm: Union[int, float, FreeCAD.Units.Quantity]) -> float:
"""
Calculates the torque at a given RPM.
Args:
rpm: The RPM value (int, float, or Quantity).
Returns:
The torque at the given RPM in Nm.
"""
if isinstance(rpm, FreeCAD.Units.Quantity):
try:
rpm_hz = rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
rpm_hz = rpm.Value * self.UNIT_CONVERSIONS["rpm"]
else:
rpm_hz = rpm * self.UNIT_CONVERSIONS["rpm"]
max_torque_nm = self._max_torque
peak_torque_rpm_hz = self._peak_torque_rpm
peak_rpm_for_calc = peak_torque_rpm_hz * 60
rpm_for_calc = rpm_hz * 60
torque_at_current_rpm = (
self._max_power * 9.5488 / rpm_for_calc if rpm_for_calc else float("inf")
)
if rpm_for_calc <= peak_rpm_for_calc:
torque_at_current_rpm = (
max_torque_nm / peak_rpm_for_calc * rpm_for_calc
if peak_rpm_for_calc
else float("inf")
)
return min(max_torque_nm, torque_at_current_rpm)
def set_max_power(self, power: Union[int, float], unit: Optional[str] = None) -> None:
"""Sets the maximum power of the machine."""
unit = unit or "kW"
if unit in self.UNIT_CONVERSIONS:
power_value = power * self.UNIT_CONVERSIONS[unit]
else:
power_value = FreeCAD.Units.Quantity(power, unit).getValueAs("W").Value
self._max_power = power_value
if self._max_power <= 0:
raise AttributeError("Max power must be positive")
def set_min_rpm(self, min_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None:
"""Sets the minimum RPM of the machine."""
if isinstance(min_rpm, FreeCAD.Units.Quantity):
try:
min_rpm_value = min_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
min_rpm_value = min_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
else:
min_rpm_value = min_rpm * self.UNIT_CONVERSIONS["rpm"]
self._min_rpm = min_rpm_value
if self._min_rpm < 0:
raise AttributeError("Min RPM cannot be negative")
if self._min_rpm >= self._max_rpm:
self._max_rpm = min_rpm_value + 1.0 / 60.0
def set_max_rpm(self, max_rpm: Union[int, float, FreeCAD.Units.Quantity]) -> None:
"""Sets the maximum RPM of the machine."""
if isinstance(max_rpm, FreeCAD.Units.Quantity):
try:
max_rpm_value = max_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
max_rpm_value = max_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
else:
max_rpm_value = max_rpm * self.UNIT_CONVERSIONS["rpm"]
self._max_rpm = max_rpm_value
if self._max_rpm <= 0:
raise AttributeError("Max RPM must be positive")
if self._max_rpm <= self._min_rpm:
self._min_rpm = max(0, max_rpm_value - 1.0 / 60.0)
def set_min_feed(
self,
min_feed: Union[int, float, FreeCAD.Units.Quantity],
unit: Optional[str] = None,
) -> None:
"""Sets the minimum feed rate of the machine."""
unit = unit or "mm/min"
if unit in self.UNIT_CONVERSIONS:
min_feed_value = min_feed * self.UNIT_CONVERSIONS[unit]
else:
min_feed_value = FreeCAD.Units.Quantity(min_feed, unit).getValueAs("mm/min").Value
self._min_feed = min_feed_value
if self._min_feed < 0:
raise AttributeError("Min feed cannot be negative")
if self._min_feed >= self._max_feed:
self._max_feed = min_feed_value + 1.0
def set_max_feed(
self,
max_feed: Union[int, float, FreeCAD.Units.Quantity],
unit: Optional[str] = None,
) -> None:
"""Sets the maximum feed rate of the machine."""
unit = unit or "mm/min"
if unit in self.UNIT_CONVERSIONS:
max_feed_value = max_feed * self.UNIT_CONVERSIONS[unit]
else:
max_feed_value = FreeCAD.Units.Quantity(max_feed, unit).getValueAs("mm/min").Value
self._max_feed = max_feed_value
if self._max_feed <= 0:
raise AttributeError("Max feed must be positive")
if self._max_feed <= self._min_feed:
self._min_feed = max(0, max_feed_value - 1.0)
def set_peak_torque_rpm(
self, peak_torque_rpm: Union[int, float, FreeCAD.Units.Quantity]
) -> None:
"""Sets the peak torque RPM of the machine."""
if isinstance(peak_torque_rpm, FreeCAD.Units.Quantity):
try:
peak_torque_rpm_value = peak_torque_rpm.getValueAs("1/s").Value
except (Base.ParserError, ValueError):
peak_torque_rpm_value = peak_torque_rpm.Value * self.UNIT_CONVERSIONS["rpm"]
else:
peak_torque_rpm_value = peak_torque_rpm * self.UNIT_CONVERSIONS["rpm"]
self._peak_torque_rpm = peak_torque_rpm_value
if self._peak_torque_rpm < 0:
raise AttributeError("Peak torque RPM cannot be negative")
def set_max_torque(
self,
max_torque: Union[int, float, FreeCAD.Units.Quantity],
unit: Optional[str] = None,
) -> None:
"""Sets the maximum torque of the machine."""
unit = unit or "Nm"
if unit in self.UNIT_CONVERSIONS:
max_torque_value = max_torque * self.UNIT_CONVERSIONS[unit]
else:
max_torque_value = FreeCAD.Units.Quantity(max_torque, unit).getValueAs("Nm").Value
self._max_torque = max_torque_value
if self._max_torque <= 0:
raise AttributeError("Max torque must be positive")
def dump(self, do_print: bool = True) -> Optional[str]:
"""
Dumps machine information to console or returns it as a string.
Args:
do_print: If True, prints the information to the console.
If False, returns the information as a string.
Returns:
A formatted string containing machine information if do_print is
False, otherwise None.
"""
min_rpm_value = self._min_rpm * 60
max_rpm_value = self._max_rpm * 60
peak_torque_rpm_value = self._peak_torque_rpm * 60
output = ""
output += f"Machine {self.label}:\n"
output += f" Max power: {self._max_power:.2f} W\n"
output += f" RPM: {min_rpm_value:.2f} RPM - {max_rpm_value:.2f} RPM\n"
output += f" Feed: {self.min_feed.UserString} - " f"{self.max_feed.UserString}\n"
output += (
f" Peak torque: {self._max_torque:.2f} Nm at " f"{peak_torque_rpm_value:.2f} RPM\n"
)
output += f" Max_torque: {self._max_torque} Nm\n"
if do_print:
print(output)
return output

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)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# This package aggregates tool bit classes.
# Import the base class and all concrete shape classes
from .models.base import ToolBit
from .models.ballend import ToolBitBallend
from .models.chamfer import ToolBitChamfer
from .models.dovetail import ToolBitDovetail
from .models.drill import ToolBitDrill
from .models.endmill import ToolBitEndmill
from .models.fillet import ToolBitFillet
from .models.probe import ToolBitProbe
from .models.reamer import ToolBitReamer
from .models.slittingsaw import ToolBitSlittingSaw
from .models.tap import ToolBitTap
from .models.threadmill import ToolBitThreadMill
from .models.bullnose import ToolBitBullnose
from .models.vbit import ToolBitVBit
# Define __all__ for explicit public interface
__all__ = [
"ToolBit",
"ToolBitBallend",
"ToolBitChamfer",
"ToolBitDovetail",
"ToolBitDrill",
"ToolBitEndmill",
"ToolBitFillet",
"ToolBitProbe",
"ToolBitReamer",
"ToolBitSlittingSaw",
"ToolBitTap",
"ToolBitThreadMill",
"ToolBitBullnose",
"ToolBitVBit",
]

View File

@@ -0,0 +1,184 @@
# -*- 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
from typing import Any, Dict, List, Optional
class DetachedDocumentObject:
"""
A lightweight class mimicking the property API of a FreeCAD DocumentObject.
This class is used by ToolBit instances when they are not associated
with a real FreeCAD DocumentObject, allowing properties to be stored
and accessed in a detached state.
"""
def __init__(self, label: str = "DetachedObject"):
self.Label: str = label
self.Name: str = label.replace(" ", "_")
self.PropertiesList: List[str] = []
self._properties: Dict[str, Any] = {}
self._property_groups: Dict[str, Optional[str]] = {}
self._property_types: Dict[str, Optional[str]] = {}
self._property_docs: Dict[str, Optional[str]] = {}
self._editor_modes: Dict[str, int] = {}
self._property_enums: Dict[str, List[str]] = {}
def addProperty(
self,
thetype: Optional[str],
name: str,
group: Optional[str],
doc: Optional[str],
) -> None:
"""Mimics FreeCAD DocumentObject.addProperty."""
if name not in self._properties:
self.PropertiesList.append(name)
self._properties[name] = None
self._property_groups[name] = group
self._property_types[name] = thetype
self._property_docs[name] = doc
def getPropertyByName(self, name: str) -> Any:
"""Mimics FreeCAD DocumentObject.getPropertyByName."""
return self._properties.get(name)
def setPropertyByName(self, name: str, value: Any) -> None:
"""Mimics FreeCAD DocumentObject.setPropertyByName."""
self._properties[name] = value
def __setattr__(self, name: str, value: Any) -> None:
"""
Intercept attribute assignment. This is done to behave like
FreeCAD's DocumentObject, which may have any property assigned,
pre-defined or not.
Without this, code linters report an error when trying to set
a property that is not defined in the class.
Handles assignment of enumeration choices (lists/tuples) and
converts string representations of Quantity types to Quantity objects.
"""
if name in ("PropertiesList", "Label", "Name") or name.startswith("_"):
super().__setattr__(name, value)
return
# Handle assignment of enumeration choices (list/tuple)
prop_type = self._property_types.get(name)
if prop_type == "App::PropertyEnumeration" and isinstance(value, (list, tuple)):
self._property_enums[name] = list(value)
assert len(value) > 0, f"Enum property '{name}' must have at least one entry"
self._properties.setdefault(name, value[0])
return
# Attempt to convert string values to Quantity if the property type is Quantity
elif prop_type in [
"App::PropertyQuantity",
"App::PropertyLength",
"App::PropertyArea",
"App::PropertyVolume",
"App::PropertyAngle",
]:
value = FreeCAD.Units.Quantity(value)
# Store the (potentially converted) value
self._properties[name] = value
Path.Log.debug(
f"DetachedDocumentObject: Set property '{name}' to "
f"value {value} (type: {type(value)})"
)
def __getattr__(self, name: str) -> Any:
"""Intercept attribute access."""
if name in self._properties:
return self._properties[name]
# Default behaviour: raise AttributeError
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
def setEditorMode(self, name: str, mode: int) -> None:
"""Stores editor mode settings in detached state."""
self._editor_modes[name] = mode
def getEditorMode(self, name: str) -> int:
"""Stores editor mode settings in detached state."""
return self._editor_modes.get(name, 0) or 0
def getGroupOfProperty(self, name: str) -> Optional[str]:
"""Returns the stored group for a property in detached state."""
return self._property_groups.get(name)
def getTypeIdOfProperty(self, name: str) -> Optional[str]:
"""Returns the stored type string for a property in detached state."""
return self._property_types.get(name)
def getEnumerationsOfProperty(self, name: str) -> List[str]:
"""Returns the stored enumeration list for a property."""
return self._property_enums.get(name, [])
@property
def ExpressionEngine(self) -> List[Any]:
"""Mimics the ExpressionEngine attribute of a real DocumentObject."""
return [] # Return an empty list to satisfy iteration
def copy_to(self, obj: FreeCAD.DocumentObject) -> None:
"""
Copies properties from this detached object to a real DocumentObject.
"""
for prop_name in self.PropertiesList:
if not hasattr(self, prop_name):
continue
prop_value = self.getPropertyByName(prop_name)
prop_type = self._property_types.get(prop_name)
prop_group = self._property_groups.get(prop_name)
prop_doc = self._property_docs.get(prop_name, "")
prop_editor_mode = self._editor_modes.get(prop_name)
# If the property doesn't exist in the target object, add it
if not hasattr(obj, prop_name):
# For enums, addProperty expects "App::PropertyEnumeration"
# The list of choices is set afterwards.
obj.addProperty(prop_type, prop_name, prop_group, prop_doc)
# If it's an enumeration, set its list of choices first
if prop_type == "App::PropertyEnumeration":
enum_choices = self._property_enums.get(prop_name)
assert enum_choices is not None
setattr(obj, prop_name, enum_choices)
# Set the property value and editor mode
try:
if prop_type == "App::PropertyEnumeration":
first_choice = self._property_enums[prop_name][0]
setattr(obj, prop_name, first_choice)
else:
setattr(obj, prop_name, prop_value)
except Exception as e:
Path.Log.error(
f"Error setting property {prop_name} to {prop_value} "
f"(type: {type(prop_value)}, expected type: {prop_type}): {e}"
)
raise
if prop_editor_mode is not None:
obj.setEditorMode(prop_name, prop_editor_mode)

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from .rotary import RotaryToolBitMixin
from .cutting import CuttingToolMixin
__all__ = [
"RotaryToolBitMixin",
"CuttingToolMixin",
]

View File

@@ -0,0 +1,40 @@
# -*- 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 PySide.QtCore import QT_TRANSLATE_NOOP
class CuttingToolMixin:
"""
This is a interface class to indicate that the ToolBit can chip, i.e.
it has a Chipload property.
It is used to determine if the tool bit can be used for chip removal.
"""
def __init__(self, obj, *args, **kwargs):
obj.addProperty(
"App::PropertyLength",
"Chipload",
"Base",
QT_TRANSLATE_NOOP("App::Property", "Chipload per tooth"),
)
obj.Chipload = FreeCAD.Units.Quantity("0.0 mm")

View File

@@ -0,0 +1,60 @@
# -*- 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
class RotaryToolBitMixin:
"""
Mixin class for rotary tool bits.
Provides methods for accessing diameter and length from the shape.
"""
def can_rotate(self) -> bool:
return True
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

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

View File

@@ -0,0 +1,45 @@
# -*- 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
from ...shape import ToolBitShapeBallend
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitBallend(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeBallend
def __init__(self, shape: ToolBitShapeBallend, id: str | None = None):
Path.Log.track(f"ToolBitBallend __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
flutes = self.get_property("Flutes")
cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?")
return FreeCAD.Qt.translate(
"CAM", f"{diameter} {flutes}-flute ballend, {cutting_edge_height} cutting edge"
)

View File

@@ -0,0 +1,810 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 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
import Path.Base.Util as PathUtil
import json
import uuid
import pathlib
from abc import ABC
from itertools import chain
from lazy_loader.lazy_loader import LazyLoader
from typing import Any, List, Optional, Tuple, Type, Union, Mapping, cast
from PySide.QtCore import QT_TRANSLATE_NOOP
import Part
from Path.Base.Generator import toolchange
from ...assets import Asset
from ...camassets import cam_assets
from ...shape import ToolBitShape, ToolBitShapeEndmill, ToolBitShapeIcon
from ..docobject import DetachedDocumentObject
from ..util import to_json, format_value
ToolBitView = LazyLoader("Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view")
PropertyGroupShape = "Shape"
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
class ToolBit(Asset, ABC):
asset_type: str = "toolbit"
SHAPE_CLASS: Type[ToolBitShape] # Abstract class attribute
def __init__(self, tool_bit_shape: ToolBitShape, id: str | None = None):
Path.Log.track("ToolBit __init__ called")
self.id = id if id is not None else str(uuid.uuid4())
self.obj = DetachedDocumentObject()
self.obj.Proxy = self
self._tool_bit_shape: ToolBitShape = tool_bit_shape
self._in_update = False
self._create_base_properties()
self.obj.ToolBitID = self.get_id()
self.obj.ShapeID = tool_bit_shape.get_id()
self.obj.ShapeType = tool_bit_shape.name
self.obj.Label = tool_bit_shape.label or f"New {tool_bit_shape.name}"
# Initialize properties
self._update_tool_properties()
def __eq__(self, other):
"""Compare ToolBit objects based on their unique ID."""
if not isinstance(other, ToolBit):
return False
return self.id == other.id
@staticmethod
def _find_subclass_for_shape(shape: ToolBitShape) -> Type["ToolBit"]:
"""
Finds the appropriate ToolBit subclass for a given ToolBitShape instance.
"""
for subclass in ToolBit.__subclasses__():
if isinstance(shape, subclass.SHAPE_CLASS):
return subclass
raise ValueError(f"No ToolBit subclass found for shape {type(shape).__name__}")
@classmethod
def from_dict(cls, attrs: Mapping, shallow: bool = False) -> "ToolBit":
"""
Creates and populates a ToolBit instance from a dictionary.
"""
# Find the shape ID.
shape_id = pathlib.Path(
str(attrs.get("shape", ""))
).stem # backward compatibility. used to be a filename
if not shape_id:
raise ValueError("ToolBit dictionary is missing 'shape' key")
# Find the shape type.
shape_type = attrs.get("shape-type")
shape_class = None
if shape_type is None:
shape_class = ToolBitShape.get_subclass_by_name(shape_id)
if not shape_class:
Path.Log.error(f'failed to infer shape type from {shape_id}; using "endmill"')
shape_class = ToolBitShapeEndmill
shape_type = shape_class.name
# Try to load the shape, if the asset exists.
tool_bit_shape = None
if not shallow: # Shallow means: skip loading of child assets
shape_asset_uri = ToolBitShape.resolve_name(shape_id)
try:
tool_bit_shape = cast(ToolBitShape, cam_assets.get(shape_asset_uri))
except FileNotFoundError:
pass # Rely on the fallback below
# If it does not exist, create a new instance from scratch.
params = attrs.get("parameter", {})
if tool_bit_shape is None:
if not shape_class:
shape_class = ToolBitShape.get_subclass_by_name(shape_type)
if not shape_class:
raise ValueError(f"failed to get shape class from {shape_id}")
tool_bit_shape = shape_class(shape_id, **params)
# Now that we have a shape, create the toolbit instance.
return cls.from_shape(tool_bit_shape, attrs, id=attrs.get("id"))
@classmethod
def from_shape(cls, tool_bit_shape: ToolBitShape, attrs: Mapping, id: str | None) -> "ToolBit":
selected_toolbit_subclass = cls._find_subclass_for_shape(tool_bit_shape)
toolbit = selected_toolbit_subclass(tool_bit_shape, id=id)
toolbit.label = attrs.get("name") or tool_bit_shape.label
# Get params and attributes.
params = attrs.get("parameter", {})
attr = attrs.get("attribute", {})
# Update parameters; these are stored in the document model object.
for param_name, param_value in params.items():
if hasattr(toolbit.obj, param_name):
PathUtil.setProperty(toolbit.obj, param_name, param_value)
else:
Path.Log.warning(
f" ToolBit {id} Parameter '{param_name}' not found on"
f" {selected_toolbit_subclass.__name__} ({tool_bit_shape})"
f" '{toolbit.obj.Label}'. Skipping."
)
# Update parameters; these are stored in the document model object.
for attr_name, attr_value in attr.items():
if hasattr(toolbit.obj, attr_name):
PathUtil.setProperty(toolbit.obj, attr_name, attr_value)
else:
Path.Log.warning(
f"ToolBit {id} Attribute '{attr_name}' not found on"
f" {selected_toolbit_subclass.__name__} ({tool_bit_shape})"
f" '{toolbit.obj.Label}'. Skipping."
)
return toolbit
@classmethod
def from_shape_id(cls, shape_id: str, label: Optional[str] = None) -> "ToolBit":
"""
Creates and populates a ToolBit instance from a shape ID.
"""
attrs = {"shape": shape_id, "name": label}
return cls.from_dict(attrs)
@classmethod
def from_file(cls, path: Union[str, pathlib.Path]) -> "ToolBit":
"""
Creates and populates a ToolBit instance from a .fctb file.
"""
path = pathlib.Path(path)
with path.open("r") as fp:
attrs_map = json.load(fp)
return cls.from_dict(attrs_map)
@property
def label(self) -> str:
return self.obj.Label
@label.setter
def label(self, label: str):
self.obj.Label = label
def get_shape_name(self) -> str:
"""Returns the shape name of the tool bit."""
return self._tool_bit_shape.name
def set_shape_name(self, name: str):
"""Sets the shape name of the tool bit."""
self._tool_bit_shape.name = name
@property
def summary(self) -> str:
"""
To be overridden by subclasses to provide a better summary
including parameter values. Used as "subtitle" for the tool
in the UI.
Example: "3.2 mm endmill, 4-flute, 8 mm cutting edge"
"""
return self.get_shape_name()
def _create_base_properties(self):
# Create the properties in the Base group.
if not hasattr(self.obj, "ShapeID"):
self.obj.addProperty(
"App::PropertyString",
"ShapeID",
"Base",
QT_TRANSLATE_NOOP(
"App::Property",
"The unique ID of the tool shape (.fcstd)",
),
)
if not hasattr(self.obj, "ShapeType"):
self.obj.addProperty(
"App::PropertyEnumeration",
"ShapeType",
"Base",
QT_TRANSLATE_NOOP(
"App::Property",
"The tool shape type",
),
)
names = [c.name for c in ToolBitShape.__subclasses__()]
self.obj.ShapeType = names
self.obj.ShapeType = ToolBitShapeEndmill.name
if not hasattr(self.obj, "BitBody"):
self.obj.addProperty(
"App::PropertyLink",
"BitBody",
"Base",
QT_TRANSLATE_NOOP(
"App::Property",
"The parametrized body representing the tool bit",
),
)
if not hasattr(self.obj, "ToolBitID"):
self.obj.addProperty(
"App::PropertyString",
"ToolBitID",
"Base",
QT_TRANSLATE_NOOP("App::Property", "The unique ID of the toolbit"),
)
# 0 = read/write, 1 = read only, 2 = hide
self.obj.setEditorMode("ShapeID", 1)
self.obj.setEditorMode("ShapeType", 1)
self.obj.setEditorMode("ToolBitID", 1)
self.obj.setEditorMode("BitBody", 2)
self.obj.setEditorMode("Shape", 2)
# Create the ToolBit properties that are shared by all tool bits
if not hasattr(self.obj, "SpindleDirection"):
self.obj.addProperty(
"App::PropertyEnumeration",
"SpindleDirection",
"Attributes",
QT_TRANSLATE_NOOP("App::Property", "Direction of spindle rotation"),
)
self.obj.SpindleDirection = ["Forward", "Reverse", "None"]
self.obj.SpindleDirection = "Forward" # Default value
if not hasattr(self.obj, "Material"):
self.obj.addProperty(
"App::PropertyEnumeration",
"Material",
"Attributes",
QT_TRANSLATE_NOOP("App::Property", "Tool material"),
)
self.obj.Material = ["HSS", "Carbide"]
self.obj.Material = "HSS" # Default value
def get_id(self) -> str:
"""Returns the unique ID of the tool bit."""
return self.id
def _promote_toolbit(self):
"""
Updates the toolbit properties for backward compatibility.
Ensure obj.ShapeID and obj.ToolBitID are set, handling legacy cases.
"""
Path.Log.track(f"Promoting tool bit {self.obj.Label}")
# Ensure ShapeID is set (handling legacy BitShape/ShapeName)
name = None
if hasattr(self.obj, "ShapeID") and self.obj.ShapeID:
name = self.obj.ShapeID
elif hasattr(self.obj, "ShapeFile") and self.obj.ShapeFile:
name = pathlib.Path(self.obj.ShapeFile).stem
elif hasattr(self.obj, "BitShape") and self.obj.BitShape:
name = pathlib.Path(self.obj.BitShape).stem
elif hasattr(self.obj, "ShapeName") and self.obj.ShapeName:
name = pathlib.Path(self.obj.ShapeName).stem
if name is None:
raise ValueError("ToolBit is missing a shape ID")
uri = ToolBitShape.resolve_name(name)
if uri is None:
raise ValueError(f"Failed to identify ID of ToolBit from '{name}'")
self.obj.ShapeID = uri.asset_id
# Ensure ShapeType is set
thetype = None
if hasattr(self.obj, "ShapeType") and self.obj.ShapeType:
thetype = self.obj.ShapeType
elif hasattr(self.obj, "ShapeFile") and self.obj.ShapeFile:
thetype = pathlib.Path(self.obj.ShapeFile).stem
elif hasattr(self.obj, "BitShape") and self.obj.BitShape:
thetype = pathlib.Path(self.obj.BitShape).stem
elif hasattr(self.obj, "ShapeName") and self.obj.ShapeName:
thetype = pathlib.Path(self.obj.ShapeName).stem
if thetype is None:
raise ValueError("ToolBit is missing a shape type")
shape_class = ToolBitShape.get_subclass_by_name(thetype)
if shape_class is None:
raise ValueError(f"Failed to identify shape of ToolBit from '{thetype}'")
self.obj.ShapeType = shape_class.name
# Ensure ToolBitID is set
if hasattr(self.obj, "File"):
self.id = pathlib.Path(self.obj.File).stem
self.obj.ToolBitID = self.id
Path.Log.debug(f"Set ToolBitID to {self.obj.ToolBitID}")
# Update SpindleDirection:
# Old tools may still have "CCW", "CW", "Off", "None".
# New tools use "None", "Forward", "Reverse".
normalized_direction = old_direction = self.obj.SpindleDirection
if isinstance(old_direction, str):
lower_direction = old_direction.lower()
if lower_direction in ("none", "off"):
normalized_direction = "None"
elif lower_direction in ("cw", "forward"):
normalized_direction = "Forward"
elif lower_direction in ("ccw", "reverse"):
normalized_direction = "Reverse"
self.obj.SpindleDirection = ["Forward", "Reverse", "None"]
self.obj.SpindleDirection = normalized_direction
if old_direction != normalized_direction:
Path.Log.info(
f"Promoted tool bit {self.obj.Label}: SpindleDirection from {old_direction} to {self.obj.SpindleDirection}"
)
# Drop legacy properties.
legacy = "ShapeFile", "File", "BitShape", "ShapeName"
for name in legacy:
if hasattr(self.obj, name):
value = getattr(self.obj, name)
self.obj.removeProperty(name)
Path.Log.debug(f"Removed obsolete property '{name}' ('{value}').")
# Get the schema properties from the current shape
shape_cls = ToolBitShape.get_subclass_by_name(self.obj.ShapeType)
if not shape_cls:
raise ValueError(f"Failed to find shape class named '{self.obj.ShapeType}'")
shape_schema_props = shape_cls.schema().keys()
# Move properties that are part of the shape schema to the "Shape" group
for prop_name in self.obj.PropertiesList:
if (
self.obj.getGroupOfProperty(prop_name) == PropertyGroupShape
or prop_name not in shape_schema_props
):
continue
try:
Path.Log.debug(f"Moving property '{prop_name}' to group '{PropertyGroupShape}'")
# Get property details before removing
prop_type = self.obj.getTypeIdOfProperty(prop_name)
prop_doc = self.obj.getDocumentationOfProperty(prop_name)
prop_value = self.obj.getPropertyByName(prop_name)
# Remove the property
self.obj.removeProperty(prop_name)
# Add the property back to the Shape group
self.obj.addProperty(prop_type, prop_name, PropertyGroupShape, prop_doc)
self._in_update = True # Prevent onChanged from running
PathUtil.setProperty(self.obj, prop_name, prop_value)
Path.Log.info(f"Moved property '{prop_name}' to group '{PropertyGroupShape}'")
except Exception as e:
Path.Log.error(
f"Failed to move property '{prop_name}' to group '{PropertyGroupShape}': {e}"
)
raise
finally:
self._in_update = False
def onDocumentRestored(self, obj):
Path.Log.track(obj.Label)
# Assign self.obj to the restored object
self.obj = obj
self.obj.Proxy = self
if not hasattr(self, "id"):
self.id = str(uuid.uuid4())
Path.Log.debug(
f"Assigned new id {self.id} for ToolBit {obj.Label} during document restore"
)
# Our constructor previously created the base properties in the
# DetachedDocumentObject, which was now replaced.
# So here we need to ensure to set them up in the new (real) DocumentObject
# as well.
self._create_base_properties()
self._promote_toolbit()
# Get the shape instance based on the ShapeType. We try two approaches
# to find the shape and shape class:
# 1. If the asset with the given type exists, use that.
# 2. Otherwise create a new empty instance
shape_uri = ToolBitShape.resolve_name(self.obj.ShapeType)
try:
# Best case: we directly find the shape file in our assets.
self._tool_bit_shape = cast(ToolBitShape, cam_assets.get(shape_uri))
except FileNotFoundError:
# Otherwise, try to at least identify the type of the shape.
shape_class = ToolBitShape.get_subclass_by_name(shape_uri.asset_id)
if not shape_class:
raise ValueError(
"Failed to identify class of ToolBitShape from name "
f"'{self.obj.ShapeType}' (asset id {shape_uri.asset_id})"
)
self._tool_bit_shape = shape_class(shape_uri.asset_id)
# If BitBody exists and is in a different document after document restore,
# it means a shallow copy occurred. We need to re-initialize the visual
# representation and properties to ensure a deep copy of the BitBody
# and its properties.
# Only re-initialize properties from shape if not restoring from file
if self.obj.BitBody and self.obj.BitBody.Document != self.obj.Document:
Path.Log.debug(
f"onDocumeformat_valuentRestored: Re-initializing BitBody for {self.obj.Label} after copy"
)
self._update_visual_representation()
# Ensure the correct ViewProvider is attached during document restore,
# because some legacy fcstd files may still have references to old view
# providers.
if hasattr(self.obj, "ViewObject") and self.obj.ViewObject:
if hasattr(self.obj.ViewObject, "Proxy") and not isinstance(
self.obj.ViewObject.Proxy, ToolBitView.ViewProvider
):
Path.Log.debug(f"onDocumentRestored: Attaching ViewProvider for {self.obj.Label}")
ToolBitView.ViewProvider(self.obj.ViewObject, "ToolBit")
# Copy properties from the restored object to the ToolBitShape.
for name, item in self._tool_bit_shape.schema().items():
if name in self.obj.PropertiesList:
value = self.obj.getPropertyByName(name)
self._tool_bit_shape.set_parameter(name, value)
# Ensure property state is correct after restore.
self._update_tool_properties()
def attach_to_doc(
self, doc: FreeCAD.Document, label: Optional[str] = None
) -> FreeCAD.DocumentObject:
"""
Creates a new FreeCAD DocumentObject in the given document and attaches
this ToolBit instance to it.
"""
label = label or self.label or self._tool_bit_shape.label
tool_doc_obj = doc.addObject("Part::FeaturePython", label)
self.attach_to_obj(tool_doc_obj, label=label)
return tool_doc_obj
def attach_to_obj(self, tool_doc_obj: FreeCAD.DocumentObject, label: Optional[str] = None):
"""
Attaches the ToolBit instance to an existing FreeCAD DocumentObject.
Transfers properties from the internal DetachedDocumentObject to the
tool_doc_obj and updates the visual representation.
"""
if not isinstance(self.obj, DetachedDocumentObject):
Path.Log.warning(
f"ToolBit {self.obj.Label} is already attached to a "
"DocumentObject. Skipping attach_to_obj."
)
return
Path.Log.track(f"Attaching ToolBit to {tool_doc_obj.Label}")
temp_obj = self.obj
self.obj = tool_doc_obj
self.obj.Proxy = self
self._create_base_properties()
# Transfer property values from the detached object to the real object
temp_obj.copy_to(self.obj)
# Ensure label is set
self.obj.Label = label or self.label or self._tool_bit_shape.label
# Update the visual representation now that it's attached
self._update_tool_properties()
self._update_visual_representation()
def onChanged(self, obj, prop):
Path.Log.track(obj.Label, prop)
# Avoid acting during document restore or internal updates
if "Restore" in obj.State:
return
if hasattr(self, "_in_update") and self._in_update:
Path.Log.debug(f"Skipping onChanged for {obj.Label} due to active update.")
return
# We only care about updates that affect the Shape
if obj.getGroupOfProperty(prop) != PropertyGroupShape:
return
self._in_update = True
try:
new_value = obj.getPropertyByName(prop)
Path.Log.debug(
f"Shape parameter '{prop}' changed to {new_value}. "
f"Updating visual representation."
)
self._tool_bit_shape.set_parameter(prop, new_value)
self._update_visual_representation()
finally:
self._in_update = False
def onDelete(self, obj, arg2=None):
Path.Log.track(obj.Label)
self._removeBitBody()
obj.Document.removeObject(obj.Name)
def _removeBitBody(self):
if self.obj.BitBody:
self.obj.BitBody.removeObjectsFromDocument()
self.obj.Document.removeObject(self.obj.BitBody.Name)
self.obj.BitBody = None
def _setupProperty(self, prop, orig):
# extract property parameters and values so it can be copied
val = orig.getPropertyByName(prop)
typ = orig.getTypeIdOfProperty(prop)
grp = orig.getGroupOfProperty(prop)
dsc = orig.getDocumentationOfProperty(prop)
self.obj.addProperty(typ, prop, grp, dsc)
if "App::PropertyEnumeration" == typ:
setattr(self.obj, prop, orig.getEnumerationsOfProperty(prop))
self.obj.setEditorMode(prop, 1)
PathUtil.setProperty(self.obj, prop, val)
def _get_props(self, group: Optional[Union[str, Tuple[str, ...]]] = None) -> List[str]:
"""
Returns a list of property names from the given group(s) for the object.
Returns all groups if the group argument is None.
"""
props_in_group = []
# Use PropertiesList to get all property names
for prop in self.obj.PropertiesList:
prop_group = self.obj.getGroupOfProperty(prop)
if group is None:
props_in_group.append(prop)
elif isinstance(group, str) and prop_group == group:
props_in_group.append(prop)
elif isinstance(group, tuple) and prop_group in group:
props_in_group.append(prop)
return props_in_group
def get_property(self, name: str):
return self.obj.getPropertyByName(name)
def get_property_str(self, name: str, default: str | None = None) -> str | None:
value = self.get_property(name)
return format_value(value) if value else default
def set_property(self, name: str, value: Any):
return self.obj.setPropertyByName(name, value)
def get_property_label_from_name(self, name: str):
return self.obj.getPropertyByName
def get_icon(self) -> Optional[ToolBitShapeIcon]:
"""
Retrieves the thumbnail data for the tool bit shape, as
taken from the explicit SVG or PNG, if the shape has one.
"""
if self._tool_bit_shape:
return self._tool_bit_shape.get_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.
Fallback to the icon from get_icon() (converted to PNG)
"""
if not self._tool_bit_shape:
return None
png_data = self._tool_bit_shape.get_thumbnail()
if png_data:
return png_data
icon = self.get_icon()
if icon:
return icon.get_png()
return None
def _remove_properties(self, group, prop_names):
for name in prop_names:
if hasattr(self.obj, name):
if self.obj.getGroupOfProperty(name) == group:
try:
self.obj.removeProperty(name)
Path.Log.debug(f"Removed property: {group}.{name}")
except Exception as e:
Path.Log.error(f"Failed removing property '{group}.{name}': {e}")
else:
Path.Log.warning(f"'{group}.{name}' failed to remove property, not found")
def _update_tool_properties(self):
"""
Initializes or updates the tool bit's properties based on the current
_tool_bit_shape. Adds/updates shape parameters, removes obsolete shape
parameters, and updates the edit state of them.
Does not handle updating the visual representation.
"""
Path.Log.track(self.obj.Label)
# 1. Add/Update properties for the new shape
for name, item in self._tool_bit_shape.schema().items():
docstring = item[0]
prop_type = item[1]
if not prop_type:
Path.Log.error(
f"No property type for parameter '{name}' in shape "
f"'{self._tool_bit_shape.name}'. Skipping."
)
continue
docstring = self._tool_bit_shape.get_parameter_label(name)
# Add new property
if not hasattr(self.obj, name):
self.obj.addProperty(prop_type, name, "Shape", docstring)
Path.Log.debug(f"Added new shape property: {name}")
# Ensure editor mode is correct
self.obj.setEditorMode(name, 0)
try:
value = self._tool_bit_shape.get_parameter(name)
except KeyError:
continue # Retain existing property value.
# Conditional to avoid unnecessary migration warning when called
# from onDocumentRestored.
if getattr(self.obj, name) != value:
setattr(self.obj, name, value)
# 2. Remove obsolete shape properties
# These are properties currently listed AND in the Shape group,
# but not required by the new shape.
current_shape_prop_names = set(self._get_props("Shape"))
new_shape_param_names = self._tool_bit_shape.schema().keys()
obsolete = current_shape_prop_names - new_shape_param_names
self._remove_properties("Shape", obsolete)
def _update_visual_representation(self):
"""
Updates the visual representation of the tool bit based on the current
_tool_bit_shape. Creates or updates the BitBody and copies its shape
to the main object.
"""
if isinstance(self.obj, DetachedDocumentObject):
return
Path.Log.track(self.obj.Label)
# Remove existing BitBody if it exists
self._removeBitBody()
try:
# Use the shape's make_body method to create the visual representation
body = self._tool_bit_shape.make_body(self.obj.Document)
if not body:
Path.Log.error(
f"Failed to create visual representation for shape "
f"'{self._tool_bit_shape.name}'"
)
return
# Assign the created object to BitBody and copy its shape
self.obj.BitBody = body
self.obj.Shape = self.obj.BitBody.Shape # Copy the evaluated Solid shape
# Hide the visual representation and remove from tree
if hasattr(self.obj.BitBody, "ViewObject") and self.obj.BitBody.ViewObject:
self.obj.BitBody.ViewObject.Visibility = False
self.obj.BitBody.ViewObject.ShowInTree = False
except Exception as e:
Path.Log.error(
f"Failed to create visual representation using make_body for shape"
f" '{self._tool_bit_shape.name}': {e}"
)
raise
def to_dict(self):
"""
Returns a dictionary representation of the tool bit.
Returns:
A dictionary with tool bit properties, JSON-serializable.
"""
Path.Log.track(self.obj.Label)
attrs = {}
attrs["version"] = 2
attrs["name"] = self.obj.Label
attrs["shape"] = self._tool_bit_shape.get_id() + ".fcstd"
attrs["shape-type"] = self._tool_bit_shape.name
attrs["parameter"] = {}
attrs["attribute"] = {}
# Store all shape parameter names and attribute names
param_names = self._tool_bit_shape.get_parameters()
attr_props = self._get_props("Attributes")
property_names = list(chain(param_names, attr_props))
for name in property_names:
value = getattr(self.obj, name, None)
if value is None or isinstance(value, FreeCAD.DocumentObject):
Path.Log.warning(
f"Excluding property '{name}' from serialization "
f"(type {type(value).__name__ if value is not None else 'None'}, value {value})"
)
try:
serialized_value = to_json(value)
attrs["parameter"][name] = serialized_value
except (TypeError, ValueError) as e:
Path.Log.warning(
f"Excluding property '{name}' from serialization "
f"(type {type(value).__name__}, value {value}): {e}"
)
Path.Log.debug(f"to_dict output for {self.obj.Label}: {attrs}")
return attrs
def __getstate__(self):
"""
Prepare the ToolBit for pickling by excluding non-picklable attributes.
Returns:
A dictionary with picklable and JSON-serializable state.
"""
Path.Log.track("ToolBit.__getstate__")
state = {
"id": getattr(self, "id", str(uuid.uuid4())), # Fallback to new UUID
"_in_update": getattr(self, "_in_update", False), # Fallback to False
"_obj_data": self.to_dict(),
}
if not getattr(self, "_tool_bit_shape", None):
return state
# Store minimal shape data to reconstruct _tool_bit_shape
state["_shape_data"] = {
"id": self._tool_bit_shape.get_id(),
"name": self._tool_bit_shape.name,
"parameters": {
name: to_json(getattr(self.obj, name, None))
for name in self._tool_bit_shape.get_parameters()
if not isinstance(getattr(self.obj, name, None), FreeCAD.DocumentObject)
},
}
return state
def get_spindle_direction(self) -> toolchange.SpindleDirection:
# To be safe, never allow non-rotatable shapes (such as probes) to rotate.
if not self.can_rotate():
return toolchange.SpindleDirection.OFF
# Otherwise use power from defined attribute.
if hasattr(self.obj, "SpindleDirection") and self.obj.SpindleDirection is not None:
if self.obj.SpindleDirection.lower() in ("cw", "forward"):
return toolchange.SpindleDirection.CW
else:
return toolchange.SpindleDirection.CCW
# Default to keeping spindle off.
return toolchange.SpindleDirection.OFF
def can_rotate(self) -> bool:
"""
Whether the spindle is allowed to rotate for this kind of ToolBit.
This mostly exists as a safe-hold for probes, which should never rotate.
"""
return True

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 *
# * *
# ***************************************************************************
import FreeCAD
import Path
from ...shape import ToolBitShapeBullnose
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitBullnose(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeBullnose
def __init__(self, tool_bit_shape: ToolBitShapeBullnose, id: str | None = None):
Path.Log.track(f"ToolBitBullnose __init__ called with id: {id}")
super().__init__(tool_bit_shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
flutes = self.get_property("Flutes")
cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?")
flat_radius = self.get_property_str("FlatRadius", "?")
return FreeCAD.Qt.translate(
"CAM",
f"{diameter} {flutes}-flute bullnose, {cutting_edge_height} cutting edge, {flat_radius} flat radius",
)

View File

@@ -0,0 +1,45 @@
# -*- 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
from ...shape import ToolBitShapeChamfer
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitChamfer(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeChamfer
def __init__(self, shape: ToolBitShapeChamfer, id: str | None = None):
Path.Log.track(f"ToolBitChamfer __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
flutes = self.get_property("Flutes")
cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?")
return FreeCAD.Qt.translate(
"CAM", f"{diameter} {cutting_edge_angle} chamfer bit, {flutes}-flute"
)

View File

@@ -0,0 +1,45 @@
# -*- 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
from ...shape import ToolBitShapeDovetail
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitDovetail(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeDovetail
def __init__(self, shape: ToolBitShapeDovetail, id: str | None = None):
Path.Log.track(f"ToolBitDovetail __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?")
flutes = self.get_property("Flutes")
return FreeCAD.Qt.translate(
"CAM", f"{diameter} {cutting_edge_angle} dovetail bit, {flutes}-flute"
)

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 *
# * *
# ***************************************************************************
import FreeCAD
import Path
from ...shape import ToolBitShapeDrill
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitDrill(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeDrill
def __init__(self, shape: ToolBitShapeDrill, id: str | None = None):
Path.Log.track(f"ToolBitDrill __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
tip_angle = self.get_property_str("TipAngle", "?")
flutes = self.get_property("Flutes")
return FreeCAD.Qt.translate("CAM", f"{diameter} drill, {tip_angle} tip, {flutes}-flute")

View File

@@ -0,0 +1,45 @@
# -*- 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
from ...shape import ToolBitShapeEndmill
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitEndmill(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeEndmill
def __init__(self, shape: ToolBitShapeEndmill, id: str | None = None):
Path.Log.track(f"ToolBitEndmill __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
flutes = self.get_property("Flutes")
cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?")
return FreeCAD.Qt.translate(
"CAM", f"{diameter} {flutes}-flute endmill, {cutting_edge_height} cutting edge"
)

View File

@@ -0,0 +1,45 @@
# -*- 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
from ...shape import ToolBitShapeFillet
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitFillet(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeFillet
def __init__(self, shape: ToolBitShapeFillet, id: str | None = None):
Path.Log.track(f"ToolBitFillet __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", "?")
flutes = self.get_property("Flutes")
diameter = self.get_property_str("ShankDiameter", "?")
return FreeCAD.Qt.translate(
"CAM", f"R{radius} fillet bit, {diameter} shank, {flutes}-flute"
)

View File

@@ -0,0 +1,48 @@
# -*- 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
from ...shape import ToolBitShapeProbe
from .base import ToolBit
class ToolBitProbe(ToolBit):
SHAPE_CLASS = ToolBitShapeProbe
def __init__(self, shape: ToolBitShapeProbe, id: str | None = None):
Path.Log.track(f"ToolBitProbe __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
self.obj.SpindleDirection = "None"
self.obj.setEditorMode("SpindleDirection", 2) # Read-only
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
length = self.get_property_str("Length", "?")
shaft_diameter = self.get_property_str("ShaftDiameter", "?")
return FreeCAD.Qt.translate(
"CAM", f"{diameter} probe, {length} length, {shaft_diameter} shaft"
)
def can_rotate(self) -> bool:
return False

View File

@@ -0,0 +1,42 @@
# -*- 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
from ...shape import ToolBitShapeReamer
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitReamer(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeReamer
def __init__(self, shape: ToolBitShapeReamer, id: str | None = None):
Path.Log.track(f"ToolBitReamer __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
cutting_edge_height = self.get_property_str("CuttingEdgeHeight", "?")
return FreeCAD.Qt.translate("CAM", f"{diameter} reamer, {cutting_edge_height} cutting edge")

View File

@@ -0,0 +1,45 @@
# -*- 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
from ...shape import ToolBitShapeSlittingSaw
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitSlittingSaw(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeSlittingSaw
def __init__(self, shape: ToolBitShapeSlittingSaw, id: str | None = None):
Path.Log.track(f"ToolBitSlittingSaw __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
blade_thickness = self.get_property_str("BladeThickness", "?")
flutes = self.get_property("Flutes")
return FreeCAD.Qt.translate(
"CAM", f"{diameter} slitting saw, {blade_thickness} blade, {flutes}-flute"
)

View File

@@ -0,0 +1,45 @@
# -*- 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
from ...shape import ToolBitShapeTap
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitTap(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeTap
def __init__(self, shape: ToolBitShapeTap, id: str | None = None):
Path.Log.track(f"ToolBitTap __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
flutes = self.get_property("Flutes")
cutting_edge_length = self.get_property_str("CuttingEdgeLength", "?")
return FreeCAD.Qt.translate(
"CAM", f"{diameter} tap, {flutes}-flute, {cutting_edge_length} cutting edge"
)

View File

@@ -0,0 +1,45 @@
# -*- 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
from ...shape import ToolBitShapeThreadMill
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitThreadMill(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeThreadMill
def __init__(self, shape: ToolBitShapeThreadMill, id: str | None = None):
Path.Log.track(f"ToolBitThreadMill __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
flutes = self.get_property("Flutes")
cutting_angle = self.get_property_str("cuttingAngle", "?")
return FreeCAD.Qt.translate(
"CAM", f"{diameter} thread mill, {flutes}-flute, {cutting_angle} cutting angle"
)

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 *
# * *
# ***************************************************************************
import FreeCAD
import Path
from ...shape import ToolBitShapeVBit
from ..mixins import RotaryToolBitMixin, CuttingToolMixin
from .base import ToolBit
class ToolBitVBit(ToolBit, CuttingToolMixin, RotaryToolBitMixin):
SHAPE_CLASS = ToolBitShapeVBit
def __init__(self, shape: ToolBitShapeVBit, id: str | None = None):
Path.Log.track(f"ToolBitVBit __init__ called with shape: {shape}, id: {id}")
super().__init__(shape, id=id)
CuttingToolMixin.__init__(self, self.obj)
@property
def summary(self) -> str:
diameter = self.get_property_str("Diameter", "?")
cutting_edge_angle = self.get_property_str("CuttingEdgeAngle", "?")
flutes = self.get_property("Flutes")
return FreeCAD.Qt.translate("CAM", f"{diameter} {cutting_edge_angle} v-bit, {flutes}-flute")

View File

@@ -0,0 +1,12 @@
from .camotics import CamoticsToolBitSerializer
from .fctb import FCTBSerializer
all_serializers = CamoticsToolBitSerializer, FCTBSerializer
__all__ = [
"CamoticsToolBitSerializer",
"FCTBSerializer",
"all_serializers",
]

View File

@@ -0,0 +1,104 @@
# -*- 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 json
from typing import Optional, Mapping
import FreeCAD
import Path
from ...camassets import cam_assets
from ..mixins import RotaryToolBitMixin
from ..models.base import ToolBit
from ...assets.serializer import AssetSerializer
from ...assets.uri import AssetUri
from ...assets.asset import Asset
SHAPEMAP = {
"ballend": "Ballnose",
"endmill": "Cylindrical",
"vbit": "Conical",
"chamfer": "Snubnose",
}
SHAPEMAP_REVERSE = dict((v, k) for k, v in SHAPEMAP.items())
tooltemplate = {
"units": "metric",
"shape": "Cylindrical",
"length": 10,
"diameter": 3,
"description": "",
}
class CamoticsToolBitSerializer(AssetSerializer):
for_class = ToolBit
extensions = tuple() # Camotics does not have tool files; tools are rows in tool tables
mime_type = "application/json"
can_import = False
can_export = False
@classmethod
def get_label(cls) -> str:
return FreeCAD.Qt.translate("CAM", "Camotics Tool")
@classmethod
def extract_dependencies(cls, data: bytes) -> list[AssetUri]:
return []
@classmethod
def serialize(cls, asset: Asset) -> bytes:
assert isinstance(asset, ToolBit)
if not isinstance(asset, RotaryToolBitMixin):
lbl = asset.label
name = asset.get_shape_name()
Path.Log.info(
f"Skipping export of toolbit {lbl} ({name}) because it is not a rotary tool."
)
return b"{}"
toolitem = tooltemplate.copy()
toolitem["diameter"] = asset.get_diameter().Value or 2
toolitem["description"] = asset.label
toolitem["length"] = asset.get_length().Value or 10
toolitem["shape"] = SHAPEMAP.get(asset.get_shape_name(), "Cylindrical")
return json.dumps(toolitem).encode("ascii", "ignore")
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> ToolBit:
# Create an instance of the ToolBitShape class
attrs: dict = json.loads(data.decode("ascii", "ignore"))
shape = cam_assets.get("toolbitshape://endmill")
# Create an instance of the ToolBit class
bit = ToolBit.from_shape_id(shape.get_id())
bit.label = attrs["description"]
if not isinstance(bit, RotaryToolBitMixin):
raise NotImplementedError(
f"Only export of rotary tools is supported ({bit.label} ({bit.id})"
)
bit.set_diameter(FreeCAD.Units.Quantity(float(attrs["diameter"]), "mm"))
bit.set_length(FreeCAD.Units.Quantity(float(attrs["length"]), "mm"))
return bit

View File

@@ -0,0 +1,103 @@
# -*- 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 json
import Path
from typing import Mapping, List, Optional, cast
import FreeCAD
from ...assets import Asset, AssetUri, AssetSerializer
from ...shape import ToolBitShape
from ..models.base import ToolBit
from Path.Base import Util as PathUtil
class FCTBSerializer(AssetSerializer):
for_class = ToolBit
mime_type = "application/x-freecad-toolbit"
extensions = (".fctb",)
@classmethod
def get_label(cls) -> str:
return FreeCAD.Qt.translate("CAM", "FreeCAD Tool")
@classmethod
def extract_dependencies(cls, data: bytes) -> List[AssetUri]:
"""Extracts URIs of dependencies from serialized data."""
Path.Log.info(f"FCTBSerializer.extract_dependencies: raw data = {data!r}")
data_dict = json.loads(data.decode("utf-8"))
shape = data_dict["shape"]
return [ToolBitShape.resolve_name(shape)]
@classmethod
def serialize(cls, asset: Asset) -> bytes:
# Ensure the asset is a ToolBit instance before serializing
if not isinstance(asset, ToolBit):
raise TypeError(f"Expected ToolBit instance, got {type(asset).__name__}")
attrs = asset.to_dict()
return json.dumps(attrs, sort_keys=True, indent=2).encode("utf-8")
@classmethod
def deserialize(
cls,
data: bytes,
id: str,
dependencies: Optional[Mapping[AssetUri, Asset]],
) -> ToolBit:
"""
Creates a ToolBit instance from serialized data and resolved
dependencies.
"""
attrs = json.loads(data.decode("utf-8", "ignore"))
attrs["id"] = id # Ensure id is available for from_dict
if dependencies is None:
# Shallow load: dependencies are not resolved.
# Delegate to from_dict with shallow=True.
return ToolBit.from_dict(attrs, shallow=True)
# Full load: dependencies are resolved.
# Proceed with existing logic to use the resolved shape.
shape_id = attrs.get("shape")
if not shape_id:
Path.Log.warning("ToolBit data is missing 'shape' key, defaulting to 'endmill'")
shape_id = "endmill"
shape_uri = ToolBitShape.resolve_name(shape_id)
shape = dependencies.get(shape_uri)
if shape is None:
raise ValueError(
f"Dependency for shape '{shape_id}' not found by uri {shape_uri}" f" {dependencies}"
)
elif not isinstance(shape, ToolBitShape):
raise ValueError(
f"Dependency for shape '{shape_id}' found by uri {shape_uri} "
f"is not a ToolBitShape instance. {dependencies}"
)
# Find the correct ToolBit subclass for the shape
return ToolBit.from_shape(shape, attrs, id)
@classmethod
def deep_deserialize(cls, data: bytes) -> ToolBit:
attrs_map = json.loads(data)
asset_class = cast(ToolBit, cls.for_class)
return asset_class.from_dict(attrs_map)

View File

@@ -0,0 +1,6 @@
from .editor import ToolBitEditorPanel, ToolBitEditor
__all__ = [
"ToolBitEditor",
"ToolBitEditorPanel",
]

View File

@@ -0,0 +1,263 @@
# -*- 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 *
# * *
# ***************************************************************************
"""Widget for browsing ToolBit assets with filtering and sorting."""
from typing import List, cast
from PySide import QtGui, QtCore
from typing import List, cast
from PySide import QtGui, QtCore
from ...assets import AssetManager, AssetUri
from ...toolbit import ToolBit
from .toollist import ToolBitListWidget, CompactToolBitListWidget, ToolBitUriRole
class ToolBitBrowserWidget(QtGui.QWidget):
"""
A widget to browse, filter, and select ToolBit assets from the
AssetManager, with sorting and batch insertion.
"""
# Signal emitted when a tool is selected in the list
toolSelected = QtCore.Signal(str) # Emits ToolBit URI string
# Signal emitted when a tool is requested for editing (e.g., double-click)
itemDoubleClicked = QtCore.Signal(str) # Emits ToolBit URI string
# Debounce timer for search input
_search_timer_interval = 300 # milliseconds
_batch_size = 20 # Number of items to insert per batch
def __init__(
self,
asset_manager: AssetManager,
store: str = "local",
parent=None,
tool_no_factory=None,
compact=False,
):
super().__init__(parent)
self._asset_manager = asset_manager
self._tool_no_factory = tool_no_factory
self._compact_mode = compact
self._is_fetching = False
self._store_name = store
self._all_assets: List[ToolBit] = [] # Store all fetched assets
self._current_search = "" # Track current search term
self._scroll_position = 0 # Track scroll position
self._sort_key = "tool_no" if tool_no_factory else "label"
# UI Elements
self._search_edit = QtGui.QLineEdit()
self._search_edit.setPlaceholderText("Search tools...")
# Sorting dropdown
self._sort_combo = QtGui.QComboBox()
if self._tool_no_factory:
self._sort_combo.addItem("Sort by Tool Number", "tool_no")
self._sort_combo.addItem("Sort by Label", "label")
self._sort_combo.setCurrentIndex(0)
self._sort_combo.setVisible(self._tool_no_factory is not None) # Hide if no tool_no_factory
# Top layout for search and sort
self._top_layout = QtGui.QHBoxLayout()
self._top_layout.addWidget(self._search_edit, 3) # Give search more space
self._top_layout.addWidget(self._sort_combo, 1)
if self._compact_mode:
self._tool_list_widget = CompactToolBitListWidget(tool_no_factory=self._tool_no_factory)
else:
self._tool_list_widget = ToolBitListWidget(tool_no_factory=self._tool_no_factory)
# Main layout
layout = QtGui.QVBoxLayout(self)
layout.addLayout(self._top_layout)
layout.addWidget(self._tool_list_widget)
# Connections
self._search_timer = QtCore.QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(self._search_timer_interval)
self._search_timer.timeout.connect(self._trigger_fetch)
self._search_edit.textChanged.connect(self._search_timer.start)
self._sort_combo.currentIndexChanged.connect(self._on_sort_changed)
scrollbar = self._tool_list_widget.verticalScrollBar()
scrollbar.valueChanged.connect(self._on_scroll)
self._tool_list_widget.itemDoubleClicked.connect(self._on_item_double_clicked)
self._tool_list_widget.currentItemChanged.connect(self._on_item_selection_changed)
# Note that fetching of assets is done at showEvent(),
# because we need to know the widget size to calculate the number
# of items that need to be fetched.
def showEvent(self, event):
"""Handles the widget show event to trigger initial data fetch."""
super().showEvent(event)
# Fetch all assets the first time the widget is shown
if not self._all_assets and not self._is_fetching:
self._fetch_all_assets()
def _fetch_all_assets(self):
"""Fetches all ToolBit assets and stores them in memory."""
if self._is_fetching:
return
self._is_fetching = True
try:
self._all_assets = cast(
List[ToolBit],
self._asset_manager.fetch(
asset_type="toolbit",
depth=0, # do not fetch dependencies (e.g. shape, icon)
store=self._store_name,
),
)
self._sort_assets()
self._trigger_fetch()
finally:
self._is_fetching = False
def _sort_assets(self):
"""Sorts the in-memory assets based on the current sort key."""
if self._sort_key == "label":
self._all_assets.sort(key=lambda x: x.label.lower())
elif self._sort_key == "tool_no" and self._tool_no_factory:
self._all_assets.sort(
key=lambda x: (self._tool_no_factory(x) or 0) if self._tool_no_factory else 0
)
def _trigger_fetch(self):
"""Initiates a data fetch, clearing the list only if search term changes."""
new_search = self._search_edit.text()
if new_search != self._current_search:
self._current_search = new_search
self._tool_list_widget.clear_list()
self._scroll_position = 0
self._fetch_data()
def _fetch_batch(self, offset):
"""Inserts a batch of filtered assets into the list widget."""
filtered_assets = [
asset
for asset in self._all_assets
if not self._current_search or self._matches_search(asset, self._current_search)
]
end_idx = min(offset + self._batch_size, len(filtered_assets))
for i in range(offset, end_idx):
self._tool_list_widget.add_toolbit(filtered_assets[i])
return end_idx < len(filtered_assets) # Return True if more items remain
def _matches_search(self, toolbit, search_term):
"""Checks if a ToolBit matches the search term."""
search_term = search_term.lower()
return search_term in toolbit.label.lower() or search_term in toolbit.summary.lower()
def _fetch_data(self):
"""Inserts filtered and sorted ToolBit assets into the list widget."""
if self._is_fetching:
return
self._is_fetching = True
try:
# Save current scroll position and selected item
scrollbar = self._tool_list_widget.verticalScrollBar()
self._scroll_position = scrollbar.value()
selected_uri = self._tool_list_widget.get_selected_toolbit_uri()
# Insert initial batches to fill the viewport
offset = self._tool_list_widget.count()
more_items = True
while more_items:
more_items = self._fetch_batch(offset)
offset += self._batch_size
if scrollbar.maximum() != 0:
break
# Apply filter to ensure UI consistency
self._tool_list_widget.apply_filter(self._current_search)
# Restore scroll position and selection
scrollbar.setValue(self._scroll_position)
if selected_uri:
for i in range(self._tool_list_widget.count()):
item = self._tool_list_widget.item(i)
if item.data(ToolBitUriRole) == selected_uri and not item.isHidden():
self._tool_list_widget.setCurrentItem(item)
break
finally:
self._is_fetching = False
def _on_scroll(self, value):
"""Handles scroll events for lazy batch insertion."""
scrollbar = self._tool_list_widget.verticalScrollBar()
is_near_bottom = value >= scrollbar.maximum() - scrollbar.singleStep()
filtered_count = sum(
1
for asset in self._all_assets
if not self._current_search or self._matches_search(asset, self._current_search)
)
more_might_exist = self._tool_list_widget.count() < filtered_count
if is_near_bottom and more_might_exist and not self._is_fetching:
self._fetch_data()
def _on_sort_changed(self):
"""Handles sort order change from the dropdown."""
self._sort_key = self._sort_combo.itemData(self._sort_combo.currentIndex())
self._sort_assets()
self._tool_list_widget.clear_list()
self._scroll_position = 0
self._fetch_data()
def _on_item_double_clicked(self, item):
"""Emits itemDoubleClicked signal when an item is double-clicked."""
uri = item.data(ToolBitUriRole)
if uri:
self.itemDoubleClicked.emit(uri)
def _on_item_selection_changed(self, current_item, previous_item):
"""Emits toolSelected signal when the selection changes."""
uri = None
if current_item:
uri = current_item.data(ToolBitUriRole)
self.toolSelected.emit(uri if current_item else None)
def get_selected_bit_uris(self) -> List[str]:
"""
Returns a list of URIs for the currently selected ToolBit items.
Delegates to the underlying list widget.
"""
return self._tool_list_widget.get_selected_toolbit_uris()
def get_selected_bits(self) -> List[ToolBit]:
"""
Returns a list of selected ToolBit objects.
Retrieves the full ToolBit objects using the asset manager.
"""
selected_bits = []
selected_uris = self.get_selected_bit_uris()
for uri_string in selected_uris:
toolbit = self._asset_manager.get(AssetUri(uri_string))
if toolbit:
selected_bits.append(toolbit)
return selected_bits

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 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) *
@@ -24,9 +25,11 @@ import FreeCAD
import FreeCADGui
import Path
import Path.Tool
import os
from PySide import QtCore
from PySide.QtCore import QT_TRANSLATE_NOOP
from ...toolbit import ToolBit
from ...assets.ui import AssetSaveDialog
from ..serializers import all_serializers as toolbit_serializers
from .file import ToolBitOpenDialog
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
@@ -54,7 +57,9 @@ class CommandToolBitCreate:
return FreeCAD.ActiveDocument is not None
def Activated(self):
obj = Path.Tool.Bit.Factory.Create()
# Create a default endmill tool bit and attach it to a new DocumentObject
toolbit = ToolBit.from_shape_id("endmill.fcstd")
obj = toolbit.attach_to_doc(FreeCAD.ActiveDocument)
obj.ViewObject.Proxy.setCreate(obj.ViewObject)
@@ -81,7 +86,7 @@ class CommandToolBitSave:
def selectedTool(self):
sel = FreeCADGui.Selection.getSelectionEx()
if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.Bit.ToolBit):
if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.ToolBit):
return sel[0].Object
return None
@@ -94,32 +99,15 @@ class CommandToolBitSave:
return False
def Activated(self):
from PySide import QtGui
tool_obj = self.selectedTool()
if not tool_obj:
return
toolbit = tool_obj.Proxy
tool = self.selectedTool()
if tool:
path = None
if not tool.File or self.saveAs:
if tool.File:
fname = tool.File
else:
fname = os.path.join(
Path.Preferences.lastPathToolBit(),
tool.Label + ".fctb",
)
foo = QtGui.QFileDialog.getSaveFileName(
QtGui.QApplication.activeWindow(), "Tool", fname, "*.fctb"
)
if foo:
path = foo[0]
else:
path = tool.File
if path:
if not path.endswith(".fctb"):
path += ".fctb"
tool.Proxy.saveToFile(tool, path)
Path.Preferences.setLastPathToolBit(os.path.dirname(path))
dialog = AssetSaveDialog(ToolBit, toolbit_serializers, FreeCADGui.getMainWindow())
dialog_result = dialog.exec(toolbit)
if not dialog_result:
return
class CommandToolBitLoad:
@@ -141,7 +129,7 @@ class CommandToolBitLoad:
def selectedTool(self):
sel = FreeCADGui.Selection.getSelectionEx()
if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.Bit.ToolBit):
if 1 == len(sel) and isinstance(sel[0].Object.Proxy, Path.Tool.ToolBit):
return sel[0].Object
return None
@@ -149,7 +137,11 @@ class CommandToolBitLoad:
return FreeCAD.ActiveDocument is not None
def Activated(self):
if Path.Tool.Bit.Gui.LoadTools():
dialog = ToolBitOpenDialog(toolbit_serializers, FreeCADGui.getMainWindow())
toolbits = dialog.exec()
for toolbit in toolbits:
toolbit.attach_to_doc(FreeCAD.ActiveDocument)
if toolbits:
FreeCAD.ActiveDocument.recompute()
@@ -165,5 +157,3 @@ CommandList = [
"CAM_ToolBitSave",
"CAM_ToolBitSaveAs",
]
FreeCAD.Console.PrintLog("Loading PathToolBitCmd... done\n")

View File

@@ -0,0 +1,282 @@
# -*- 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 *
# * *
# ***************************************************************************
"""Widget for editing a ToolBit object."""
from functools import partial
import FreeCAD
import FreeCADGui
from PySide import QtGui, QtCore
from ..models.base import ToolBit
from ...shape.ui.shapewidget import ShapeWidget
from ...ui.docobject import DocumentObjectEditorWidget
class ToolBitPropertiesWidget(QtGui.QWidget):
"""
A composite widget for editing the properties and shape of a ToolBit.
"""
# Signal emitted when the toolbit data has been modified
toolBitChanged = QtCore.Signal()
def __init__(self, toolbit: ToolBit | None = None, parent=None, icon: bool = True):
super().__init__(parent)
self._toolbit = None
self._show_shape = icon
# UI Elements
self._label_edit = QtGui.QLineEdit()
self._id_label = QtGui.QLabel() # Read-only ID
self._id_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
self._property_editor = DocumentObjectEditorWidget()
self._property_editor.setSizePolicy(
QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding
)
self._shape_widget = None # Will be created in load_toolbit
# Layout
toolbit_group_box = QtGui.QGroupBox(FreeCAD.Qt.translate("CAM", "Tool Bit"))
form_layout = QtGui.QFormLayout(toolbit_group_box)
form_layout.addRow("Label:", self._label_edit)
form_layout.addRow("ID:", self._id_label)
main_layout = QtGui.QVBoxLayout(self)
main_layout.addWidget(toolbit_group_box)
properties_group_box = QtGui.QGroupBox(FreeCAD.Qt.translate("CAM", "Properties"))
properties_group_box.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding)
properties_layout = QtGui.QVBoxLayout(properties_group_box)
properties_layout.setSpacing(5)
properties_layout.addWidget(self._property_editor)
# Ensure the layout expands horizontally
properties_layout.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
# Set stretch factor to make property editor expand
properties_layout.setStretchFactor(self._property_editor, 1)
main_layout.addWidget(properties_group_box)
# Add stretch before shape widget to push it towards the bottom
main_layout.addStretch(1)
# Layout for centering the shape widget (created later)
self._shape_display_layout = QtGui.QHBoxLayout()
self._shape_display_layout.addStretch(1)
# Placeholder for the widget
self._shape_display_layout.addStretch(1)
main_layout.addLayout(self._shape_display_layout)
# Connections
self._label_edit.editingFinished.connect(self._on_label_changed)
self._property_editor.propertyChanged.connect(self.toolBitChanged)
if toolbit:
self.load_toolbit(toolbit)
def _on_label_changed(self):
"""Update the toolbit's label when the line edit changes."""
if self._toolbit and self._toolbit.obj:
new_label = self._label_edit.text()
if self._toolbit.obj.Label != new_label:
self._toolbit.obj.Label = new_label
self.toolBitChanged.emit()
def load_toolbit(self, toolbit: ToolBit):
"""Load a ToolBit object into the editor."""
self._toolbit = toolbit
if not self._toolbit or not self._toolbit.obj:
# Clear or disable fields if toolbit is invalid
self._label_edit.clear()
self._label_edit.setEnabled(False)
self._id_label.clear()
self._property_editor.setObject(None)
# Clear existing shape widget if any
if self._shape_widget:
self._shape_display_layout.removeWidget(self._shape_widget)
self._shape_widget.deleteLater()
self._shape_widget = None
self.setEnabled(False)
return
self.setEnabled(True)
self._label_edit.setEnabled(True)
self._label_edit.setText(self._toolbit.obj.Label)
self._id_label.setText(self._toolbit.get_id())
# Get properties and suffixes
props_to_show = self._toolbit._get_props(("Shape", "Attributes"))
icon = self._toolbit._tool_bit_shape.get_icon()
suffixes = icon.abbreviations if icon else {}
self._property_editor.setObject(self._toolbit.obj)
self._property_editor.setPropertiesToShow(props_to_show, suffixes)
# Clear old shape widget and create/add new one if shape exists
if self._shape_widget:
self._shape_display_layout.removeWidget(self._shape_widget)
self._shape_widget.deleteLater()
self._shape_widget = None
if self._show_shape and self._toolbit._tool_bit_shape:
self._shape_widget = ShapeWidget(shape=self._toolbit._tool_bit_shape, parent=self)
self._shape_widget.setMinimumSize(200, 150)
# Insert into the middle slot of the HBox layout
self._shape_display_layout.insertWidget(1, self._shape_widget)
def save_toolbit(self):
"""
Applies changes from the editor widgets back to the ToolBit object.
Note: Most changes are applied via signals, but this can be called
for explicit save actions.
"""
# Ensure label is updated if focus is lost without pressing Enter
self._on_label_changed()
# No need to explicitly save the toolbit object itself here,
# as properties were modified directly on toolbit.obj
class ToolBitEditorPanel(QtGui.QWidget):
"""
A widget for editing a ToolBit object, wrapping ToolBitEditorWidget
and providing standard dialog buttons.
"""
# Signals
accepted = QtCore.Signal(ToolBit)
rejected = QtCore.Signal()
toolBitChanged = QtCore.Signal() # Re-emit signal from inner widget
def __init__(self, toolbit: ToolBit | None = None, parent=None):
super().__init__(parent)
# Create the main editor widget
self._editor_widget = ToolBitPropertiesWidget(toolbit, self)
# Create the button box
buttons = QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel
self._button_box = QtGui.QDialogButtonBox(buttons)
# Connect button box signals to custom signals
self._button_box.accepted.connect(self._accepted)
self._button_box.rejected.connect(self.rejected.emit)
# Layout
main_layout = QtGui.QVBoxLayout(self)
main_layout.addWidget(self._editor_widget)
main_layout.addWidget(self._button_box)
# Connect the toolBitChanged signal from the inner widget
self._editor_widget.toolBitChanged.connect(self.toolBitChanged)
def _accepted(self):
self.accepted.emit(self._editor_widget._toolbit)
def load_toolbit(self, toolbit: ToolBit):
"""Load a ToolBit object into the editor."""
self._editor_widget.load_toolbit(toolbit)
def save_toolbit(self):
"""Applies changes from the editor widgets back to the ToolBit object."""
self._editor_widget.save_toolbit()
class ToolBitEditor(QtGui.QWidget):
"""
A widget for editing a ToolBit object, wrapping ToolBitEditorWidget
and providing standard dialog buttons.
"""
# Signals
toolBitChanged = QtCore.Signal() # Re-emit signal from inner widget
def __init__(self, toolbit: ToolBit, parent=None):
super().__init__(parent)
self.form = FreeCADGui.PySideUic.loadUi(":/panels/ToolBitEditor.ui")
self.toolbit = toolbit
# self.tool_no = tool_no
self.default_title = self.form.windowTitle()
# Get first tab from the form, add the shape widget at the top.
tool_tab_layout = self.form.toolTabLayout
widget = ShapeWidget(toolbit._tool_bit_shape)
tool_tab_layout.addWidget(widget)
# Add tool properties editor to the same tab.
props = ToolBitPropertiesWidget(toolbit, self, icon=False)
props.toolBitChanged.connect(self._update)
# props.toolNoChanged.connect(self._on_tool_no_changed)
tool_tab_layout.addWidget(props)
self.form.tabWidget.setCurrentIndex(0)
self.form.tabWidget.currentChanged.connect(self._on_tab_switched)
# Hide second tab (tool notes) for now.
self.form.tabWidget.setTabVisible(1, False)
# Feeds & Speeds
self.feeds_tab_idx = None
"""
TODO: disabled for now.
if tool.supports_feeds_and_speeds():
label = translate('CAM', 'Feeds && Speeds')
self.feeds = FeedsAndSpeedsWidget(db, serializer, tool, parent=self)
self.feeds_tab_idx = self.form.tabWidget.insertTab(1, self.feeds, label)
else:
self.feeds = None
self.feeds_tab_idx = None
self.form.lineEditCoating.setText(toolbit.get_coating())
self.form.lineEditCoating.textChanged.connect(toolbit.set_coating)
self.form.lineEditHardness.setText(toolbit.get_hardness())
self.form.lineEditHardness.textChanged.connect(toolbit.set_hardness)
self.form.lineEditMaterials.setText(toolbit.get_materials())
self.form.lineEditMaterials.textChanged.connect(toolbit.set_materials)
self.form.lineEditSupplier.setText(toolbit.get_supplier())
self.form.lineEditSupplier.textChanged.connect(toolbit.set_supplier)
self.form.plainTextEditNotes.setPlainText(tool.get_notes())
self.form.plainTextEditNotes.textChanged.connect(self._on_notes_changed)
"""
def _update(self):
title = self.default_title
tool_name = self.toolbit.label
if tool_name:
title = "{} - {}".format(tool_name, title)
self.form.setWindowTitle(title)
def _on_tab_switched(self, index):
if index == self.feeds_tab_idx:
self.feeds.update()
def _on_notes_changed(self):
self.toolbit.set_notes(self.form.plainTextEditNotes.toPlainText())
def _on_tool_no_changed(self, value):
self.tool_no = value
def show(self):
return self.form.exec_()

View File

@@ -0,0 +1,81 @@
# -*- 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, List, Type, Iterable
from PySide.QtWidgets import QFileDialog, QMessageBox
from ...assets import AssetSerializer
from ...assets.ui.util import (
make_import_filters,
get_serializer_from_extension,
)
from ..models.base import ToolBit
from ..serializers import all_serializers
class ToolBitOpenDialog(QFileDialog):
def __init__(
self,
serializers: Iterable[Type[AssetSerializer]] | None,
parent=None,
):
super().__init__(parent)
self.serializers = list(serializers) if serializers else all_serializers
self.setWindowTitle("Open ToolBit(s)")
self.setFileMode(QFileDialog.ExistingFiles) # Allow multiple files
filters = make_import_filters(self.serializers)
self.setNameFilters(filters)
if filters:
self.selectNameFilter(filters[0])
def _deserialize_selected_file(self, file_path: pathlib.Path) -> Optional[ToolBit]:
"""Deserialize the selected file using the appropriate serializer."""
file_extension = file_path.suffix.lower()
serializer_class = get_serializer_from_extension(
self.serializers, file_extension, for_import=True
)
if not serializer_class:
QMessageBox.critical(
self,
"Error",
f"No supported serializer found for file extension '{file_extension}'",
)
return None
try:
raw_data = file_path.read_bytes()
toolbit = serializer_class.deep_deserialize(raw_data)
if not isinstance(toolbit, ToolBit):
raise TypeError("Deserialized asset is not of type ToolBit")
return toolbit
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to import toolbit: {e}")
return None
def exec(self) -> List[ToolBit]:
toolbits = []
if super().exec_():
filenames = self.selectedFiles()
for filename in filenames:
file_path = pathlib.Path(filename)
toolbit = self._deserialize_selected_file(file_path)
if toolbit:
toolbits.append(toolbit)
return toolbits

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 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 FreeCADGui
import Path
from Path.Tool.toolbit.ui import ToolBitEditorPanel
class TaskPanel:
"""TaskPanel for the SetupSheet - if it is being edited directly."""
def __init__(self, vobj, deleteOnReject):
Path.Log.track(vobj.Object.Label)
self.vobj = vobj
self.obj = vobj.Object
self.editor = ToolBitEditorPanel(self.obj, self.editor.form)
self.deleteOnReject = deleteOnReject
FreeCAD.ActiveDocument.openTransaction("Edit ToolBit")
def reject(self):
FreeCAD.ActiveDocument.abortTransaction()
self.editor.reject()
FreeCADGui.Control.closeDialog()
if self.deleteOnReject:
FreeCAD.ActiveDocument.openTransaction("Uncreate ToolBit")
self.editor.reject()
FreeCAD.ActiveDocument.removeObject(self.obj.Name)
FreeCAD.ActiveDocument.commitTransaction()
FreeCAD.ActiveDocument.recompute()
def accept(self):
self.editor.accept()
FreeCAD.ActiveDocument.commitTransaction()
FreeCADGui.ActiveDocument.resetEdit()
FreeCADGui.Control.closeDialog()
FreeCAD.ActiveDocument.recompute()
def updateUI(self):
Path.Log.track()
self.editor.updateUI()
def updateModel(self):
self.editor.updateTool()
FreeCAD.ActiveDocument.recompute()
def setupUi(self):
self.editor.setupUI()

View File

@@ -0,0 +1,89 @@
# -*- 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 *
# * *
# ***************************************************************************
"""ToolBit selector dialog."""
from PySide import QtWidgets
import FreeCAD
from ...camassets import cam_assets
from ...toolbit import ToolBit
from .browser import ToolBitBrowserWidget
class ToolBitSelector(QtWidgets.QDialog):
"""
A dialog for selecting ToolBits using the ToolBitBrowserWidget.
"""
def __init__(
self, parent=None, compact=False, button_label=FreeCAD.Qt.translate("CAM", "Add Tool")
):
super().__init__(parent)
self.setMinimumSize(600, 400)
self.setWindowTitle(FreeCAD.Qt.translate("CAM", "Select Tool Bit"))
self._browser_widget = ToolBitBrowserWidget(cam_assets, compact=compact)
# Create OK and Cancel buttons
self._ok_button = QtWidgets.QPushButton(button_label)
self._cancel_button = QtWidgets.QPushButton("Cancel")
# Connect buttons to their actions
self._ok_button.clicked.connect(self.accept)
self._cancel_button.clicked.connect(self.reject)
# Layout setup
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(self._browser_widget)
button_layout = QtWidgets.QHBoxLayout()
button_layout.addStretch()
button_layout.addWidget(self._cancel_button)
button_layout.addWidget(self._ok_button)
layout.addLayout(button_layout)
# Disable OK button initially until a tool is selected
self._ok_button.setEnabled(False)
self._browser_widget.toolSelected.connect(self._on_tool_selected)
self._browser_widget.itemDoubleClicked.connect(self.accept)
self._selected_tool_uri = None
def _on_tool_selected(self, uri):
"""Enables/disables OK button based on selection."""
self._selected_tool_uri = uri
self._ok_button.setEnabled(uri is not None)
def get_selected_tool_uri(self):
"""Returns the URI of the selected tool bit."""
return self._selected_tool_uri
def get_selected_tool(self) -> ToolBit:
"""Returns the selected ToolBit object, or None if none selected."""
uri = self.get_selected_tool_uri()
if uri:
# Assuming ToolBit.from_uri exists and loads the ToolBit object
return cam_assets.get(uri)
return None

View File

@@ -0,0 +1,179 @@
# -*- 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 re
from PySide import QtGui, QtCore
import FreeCAD
from ...shape import ToolBitShape
def isub(text, old, repl_pattern):
pattern = "|".join(re.escape(o) for o in old)
return re.sub("(" + pattern + ")", repl_pattern, text, flags=re.I)
def interpolate_colors(start_color, end_color, ratio):
r = 1.0 - ratio
red = start_color.red() * r + end_color.red() * ratio
green = start_color.green() * r + end_color.green() * ratio
blue = start_color.blue() * r + end_color.blue() * ratio
return QtGui.QColor(int(red), int(green), int(blue))
class TwoLineTableCell(QtGui.QWidget):
def __init__(self, parent=None):
super(TwoLineTableCell, self).__init__(parent)
self.tool_no = ""
self.pocket = ""
self.upper_text = ""
self.lower_text = ""
self.search_highlight = ""
palette = self.palette()
bg_role = self.backgroundRole()
bg_color = palette.color(bg_role)
fg_role = self.foregroundRole()
fg_color = palette.color(fg_role)
self.vbox = QtGui.QVBoxLayout()
self.label_upper = QtGui.QLabel()
self.label_upper.setStyleSheet("margin-top: 8px")
color = interpolate_colors(bg_color, fg_color, 0.8)
style = "margin-bottom: 8px; color: {};".format(color.name())
self.label_lower = QtGui.QLabel()
self.label_lower.setStyleSheet(style)
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)
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.hbox = QtGui.QHBoxLayout()
self.hbox.addWidget(self.label_left, 0)
self.hbox.addWidget(self.icon_widget, 0)
self.hbox.addLayout(self.vbox, 1)
self.hbox.addWidget(self.label_right, 0)
self.setLayout(self.hbox)
def _highlight(self, text):
if not self.search_highlight:
return text
highlight_fmt = r'<font style="background: yellow; color: black">\1</font>'
return isub(text, self.search_highlight.split(" "), highlight_fmt)
def _update(self):
# Handle tool number display
if self.tool_no is not None and self.tool_no != "":
text = self._highlight(str(self.tool_no))
self.label_left.setText(f"<b>{text}</b>")
self.label_left.setVisible(True)
else:
self.label_left.setVisible(False)
text = self._highlight(self.pocket)
lbl = FreeCAD.Qt.translate("CAM_Toolbit", "Pocket")
text = f"{lbl}\n<h3>{text}</h3>" if text else ""
self.label_right.setText(text)
text = self._highlight(self.upper_text)
self.label_upper.setText(f"<big><b>{text}</b></big>")
text = self._highlight(self.lower_text)
self.label_lower.setText(text)
self.label_lower.setText(f"{text}")
def set_tool_no(self, no):
self.tool_no = no
self._update()
def set_pocket(self, pocket):
self.pocket = str(pocket) if pocket else ""
self._update()
def set_upper_text(self, text):
self.upper_text = text
self._update()
def set_lower_text(self, text):
self.lower_text = text
self._update()
def set_icon(self, pixmap):
self.hbox.removeWidget(self.icon_widget)
self.icon_widget = QtGui.QLabel()
self.icon_widget.setPixmap(pixmap)
self.hbox.insertWidget(1, self.icon_widget, 0)
def set_icon_from_shape(self, shape: ToolBitShape):
icon = shape.get_icon()
if not icon:
return
pixmap = icon.get_qpixmap(self.icon_size)
if pixmap:
self.set_icon(pixmap)
def contains_text(self, text):
for term in text.lower().split(" "):
tool_no_str = str(self.tool_no) if self.tool_no is not None else ""
# Check against the raw text content, not the HTML-formatted text
if (
term not in tool_no_str.lower()
and term not in self.upper_text.lower()
and term not in self.lower_text.lower()
):
return False
return True
def highlight(self, text):
self.search_highlight = text
self._update()
class CompactTwoLineTableCell(TwoLineTableCell):
def __init__(self, parent=None):
super(CompactTwoLineTableCell, self).__init__(parent)
# Reduce icon size
ratio = self.devicePixelRatioF()
self.icon_size = QtCore.QSize(32 * ratio, 32 * ratio)
# Reduce margins
self.label_upper.setStyleSheet("margin: 2px 0px 0px 0px; font-size: .8em;")
self.label_lower.setStyleSheet("margin: 0px 0px 2px 0px; font-size: .8em;")
self.vbox.setSpacing(0)
self.hbox.setContentsMargins(0, 0, 0, 0)

View File

@@ -0,0 +1,181 @@
# -*- 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 *
# * *
# ***************************************************************************
"""Widget for displaying a list of ToolBits using TwoLineTableCell."""
from typing import Callable, List
from PySide import QtGui, QtCore
from .tablecell import TwoLineTableCell, CompactTwoLineTableCell
from ..models.base import ToolBit # For type hinting
# Role for storing the ToolBit URI string
ToolBitUriRole = QtCore.Qt.UserRole + 1
class ToolBitListWidget(QtGui.QListWidget):
"""
A QListWidget specialized for displaying ToolBit items using
TwoLineTableCell widgets.
"""
def __init__(self, parent=None, tool_no_factory: Callable | None = None):
super().__init__(parent)
self._tool_no_factory = tool_no_factory
# Optimize view for custom widgets
self.setUniformItemSizes(False) # Allow different heights if needed
self.setAutoScroll(True)
self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
# Consider setting view mode if needed, default is ListMode
# self.setViewMode(QtGui.QListView.ListMode)
# self.setResizeMode(QtGui.QListView.Adjust) # Adjust items on resize
def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None):
"""
Adds a ToolBit to the list.
Args:
toolbit (ToolBit): The ToolBit object to add.
tool_no (int | None): The tool number associated with the ToolBit,
or None if not applicable.
"""
# Use the factory function if provided, otherwise use the passed tool_no
final_tool_no = None
if self._tool_no_factory:
final_tool_no = self._tool_no_factory(toolbit)
elif tool_no is not None:
final_tool_no = tool_no
# Add item to this widget
item = QtGui.QListWidgetItem(self)
cell = TwoLineTableCell(self)
# Populate the cell widget
cell.set_tool_no(final_tool_no)
cell.set_upper_text(toolbit.label)
cell.set_lower_text(toolbit.summary)
# Set the custom widget for the list item
item.setSizeHint(cell.sizeHint())
self.setItemWidget(item, cell)
# Store the ToolBit URI for later retrieval
item.setData(ToolBitUriRole, str(toolbit.get_uri()))
def clear_list(self):
"""Removes all items from the list."""
self.clear()
def apply_filter(self, search_text: str):
"""
Filters the list items based on the search text.
Items are hidden if they don't contain the text in their
tool number, upper text, or lower text.
Also applies highlighting to the visible matching text.
"""
search_text_lower = search_text.lower()
for i in range(self.count()):
item = self.item(i)
cell = self.itemWidget(item)
if isinstance(cell, TwoLineTableCell):
cell.highlight(search_text) # Apply highlighting
# Determine visibility based on content
contains = cell.contains_text(search_text_lower)
item.setHidden(not contains)
else:
# Fallback for items without the expected widget (shouldn't happen)
item_text = item.text().lower() # Basic text search
item.setHidden(search_text_lower not in item_text)
def count_visible_items(self) -> int:
"""
Counts and returns the number of visible items in the list.
"""
visible_count = 0
for i in range(self.count()):
item = self.item(i)
if not item.isHidden():
visible_count += 1
return visible_count
def get_selected_toolbit_uri(self) -> str | None:
"""
Returns the URI string of the currently selected ToolBit item.
Returns None if no item is selected.
"""
currentItem = self.currentItem()
if currentItem:
return currentItem.data(ToolBitUriRole)
return None
def get_selected_toolbit_uris(self) -> List[str]:
"""
Returns a list of URI strings for the currently selected ToolBit items.
Returns an empty list if no item is selected.
"""
selected_uris = []
selected_items = self.selectedItems()
for item in selected_items:
uri = item.data(ToolBitUriRole)
if uri:
selected_uris.append(uri)
return selected_uris
class CompactToolBitListWidget(ToolBitListWidget):
"""
A QListWidget specialized for displaying ToolBit items using
CompactTwoLineTableCell widgets.
"""
def add_toolbit(self, toolbit: ToolBit, tool_no: int | None = None):
"""
Adds a ToolBit to the list using CompactTwoLineTableCell.
Args:
toolbit (ToolBit): The ToolBit object to add.
tool_no (int | None): The tool number associated with the ToolBit,
or None if not applicable.
"""
# Use the factory function if provided, otherwise use the passed tool_no
final_tool_no = None
if self._tool_no_factory:
final_tool_no = self._tool_no_factory(toolbit)
elif tool_no is not None:
final_tool_no = tool_no
item = QtGui.QListWidgetItem(self) # Add item to this widget
cell = CompactTwoLineTableCell(self) # Parent the cell to this widget
# Populate the cell widget
cell.set_tool_no(final_tool_no)
cell.set_upper_text(toolbit.label)
lower_text = toolbit.summary
cell.set_icon_from_shape(toolbit._tool_bit_shape)
cell.set_lower_text(lower_text)
# Set the custom widget for the list item
item.setSizeHint(cell.sizeHint())
self.setItemWidget(item, cell)
# Store the ToolBit URI for later retrieval
item.setData(ToolBitUriRole, str(toolbit.get_uri()))

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2019 sliptonic <shopinthewoods@gmail.com> *
# * 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
import FreeCADGui
import Path
from Path.Base.Gui import IconViewProvider
from Path.Tool.toolbit.ui.panel import TaskPanel
class ViewProvider(object):
"""
ViewProvider for a ToolBit DocumentObject.
It's sole job is to provide an icon and invoke the TaskPanel
on edit.
"""
def __init__(self, vobj, name):
Path.Log.track(name, vobj.Object)
self.panel = None
self.icon = name
self.obj = vobj.Object
self.vobj = vobj
vobj.Proxy = self
def attach(self, vobj):
Path.Log.track(vobj.Object)
self.vobj = vobj
self.obj = vobj.Object
def getIcon(self):
try:
png_data = self.obj.Proxy.get_thumbnail()
except AttributeError: # Proxy not initialized
png_data = None
if png_data:
pixmap = QtGui.QPixmap()
pixmap.loadFromData(png_data, "PNG")
return QtGui.QIcon(pixmap)
return ":/icons/CAM_ToolBit.svg"
def dumps(self):
return None
def loads(self, state):
return None
def onDelete(self, vobj, arg2=None):
Path.Log.track(vobj.Object.Label)
vobj.Object.Proxy.onDelete(vobj.Object)
def getDisplayMode(self, mode):
return "Default"
def _openTaskPanel(self, vobj, deleteOnReject):
Path.Log.track()
self.panel = TaskPanel(vobj, deleteOnReject)
FreeCADGui.Control.closeDialog()
FreeCADGui.Control.showDialog(self.panel)
self.panel.setupUi()
def setCreate(self, vobj):
Path.Log.track()
self._openTaskPanel(vobj, True)
def setEdit(self, vobj, mode=0):
self._openTaskPanel(vobj, False)
return True
def unsetEdit(self, vobj, mode):
FreeCADGui.Control.closeDialog()
self.panel = None
return
def claimChildren(self):
if self.obj.BitBody:
return [self.obj.BitBody]
return []
def doubleClicked(self, vobj):
pass
def setupContextMenu(self, vobj, menu):
# Override the base class method to prevent adding the "Edit" action
# for ToolBit objects.
pass # TODO: call setEdit here once we have a new editor panel
IconViewProvider.RegisterViewProvider("ToolBit", ViewProvider)

View File

@@ -0,0 +1,37 @@
# -*- 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
def to_json(value):
"""Convert a value to JSON format."""
if isinstance(value, FreeCAD.Units.Quantity):
return str(value)
return value
def format_value(value: FreeCAD.Units.Quantity | int | float | None):
if value is None:
return None
elif isinstance(value, FreeCAD.Units.Quantity):
return value.UserString
return str(value)

View File

View File

@@ -0,0 +1,134 @@
# -*- 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 *
# * *
# ***************************************************************************
"""Widget for editing a list of properties of a DocumentObject."""
import re
from PySide import QtGui, QtCore
from .property import BasePropertyEditorWidget
def _get_label_text(prop_name):
"""Generate a human-readable label from a property name."""
# Add space before capital letters (CamelCase splitting)
s1 = re.sub(r"([A-Z][a-z]+)", r" \1", prop_name)
# Add space before sequences of capitals (e.g., ID) followed by lowercase
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", s1)
# Add space before sequences of capitals followed by end of string
s3 = re.sub(r"([A-Z]+)$", r" \1", s2)
# Remove leading/trailing spaces and capitalize
return s3.strip().capitalize()
class DocumentObjectEditorWidget(QtGui.QWidget):
"""
A widget that displays a user friendly form for editing properties of a
FreeCAD DocumentObject.
"""
# Signal emitted when any underlying property value might have changed
propertyChanged = QtCore.Signal()
def __init__(self, obj=None, properties_to_show=None, property_suffixes=None, parent=None):
"""
Initialize the editor widget.
Args:
obj (App.DocumentObject, optional): The object to edit. Defaults to None.
properties_to_show (list[str], optional): List of property names to display.
Defaults to None (shows nothing).
property_suffixes (dict[str, str], optional): Dictionary mapping property names
to suffixes for their labels.
Defaults to None.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self._obj = obj
self._properties_to_show = properties_to_show if properties_to_show else []
self._property_suffixes = property_suffixes if property_suffixes else {}
self._property_editors = {} # Store {prop_name: editor_widget}
self._layout = QtGui.QFormLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setFieldGrowthPolicy(QtGui.QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._populate_form()
def _clear_form(self):
"""Remove all rows from the form layout."""
while self._layout.rowCount() > 0:
self._layout.removeRow(0)
self._property_editors.clear()
def _populate_form(self):
"""Create and add property editors to the form."""
self._clear_form()
if not self._obj:
return
for prop_name in self._properties_to_show:
# Only create an editor if the property exists on the object
if not hasattr(self._obj, prop_name):
continue
editor_widget = BasePropertyEditorWidget.for_property(self._obj, prop_name, self)
label_text = _get_label_text(prop_name)
suffix = self._property_suffixes.get(prop_name)
if suffix:
label_text = f"{label_text} ({suffix}):"
else:
label_text = f"{label_text}:"
label = QtGui.QLabel(label_text)
self._layout.addRow(label, editor_widget)
self._property_editors[prop_name] = editor_widget
# Connect the editor's signal to our own signal
editor_widget.propertyChanged.connect(self.propertyChanged)
def setObject(self, obj):
"""Set or change the DocumentObject being edited."""
if obj != self._obj:
self._obj = obj
# Re-populate might be too slow if only object changes,
# better to just re-attach existing editors.
# self._populate_form()
for prop_name, editor in self._property_editors.items():
editor.attachTo(self._obj, prop_name)
def setPropertiesToShow(self, properties_to_show, property_suffixes=None):
"""Set or change the list of properties to display."""
self._properties_to_show = properties_to_show if properties_to_show else []
self._property_suffixes = property_suffixes if property_suffixes else {}
self._populate_form() # Rebuild the form completely
def updateUI(self):
"""Update all child editor widgets from the object's properties."""
for editor in self._property_editors.values():
editor.updateWidget()
def updateObject(self):
"""Update the object's properties from all child editor widgets."""
# This might not be strictly necessary if signals are connected,
# but can be useful for explicit save actions.
for editor in self._property_editors.values():
editor.updateProperty()

Some files were not shown because too many files have changed in this diff Show More