CAM: Replace complete tool management (PR 21425)
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
323
src/Mod/CAM/Path/Tool/assets/README.md
Normal file
323
src/Mod/CAM/Path/Tool/assets/README.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
<asset_type>:\//<asset_id>[/<version>]<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.
|
||||
19
src/Mod/CAM/Path/Tool/assets/__init__.py
Normal file
19
src/Mod/CAM/Path/Tool/assets/__init__.py
Normal 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",
|
||||
]
|
||||
91
src/Mod/CAM/Path/Tool/assets/asset.py
Normal file
91
src/Mod/CAM/Path/Tool/assets/asset.py
Normal 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)
|
||||
172
src/Mod/CAM/Path/Tool/assets/cache.py
Normal file
172
src/Mod/CAM/Path/Tool/assets/cache.py
Normal 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.")
|
||||
BIN
src/Mod/CAM/Path/Tool/assets/docs/blender-assets.jpg
Normal file
BIN
src/Mod/CAM/Path/Tool/assets/docs/blender-assets.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
768
src/Mod/CAM/Path/Tool/assets/manager.py
Normal file
768
src/Mod/CAM/Path/Tool/assets/manager.py
Normal 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())
|
||||
104
src/Mod/CAM/Path/Tool/assets/serializer.py
Normal file
104
src/Mod/CAM/Path/Tool/assets/serializer.py
Normal 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")
|
||||
0
src/Mod/CAM/Path/Tool/assets/store/__init__.py
Normal file
0
src/Mod/CAM/Path/Tool/assets/store/__init__.py
Normal file
166
src/Mod/CAM/Path/Tool/assets/store/base.py
Normal file
166
src/Mod/CAM/Path/Tool/assets/store/base.py
Normal 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
|
||||
501
src/Mod/CAM/Path/Tool/assets/store/filestore.py
Normal file
501
src/Mod/CAM/Path/Tool/assets/store/filestore.py
Normal 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)
|
||||
212
src/Mod/CAM/Path/Tool/assets/store/memory.py
Normal file
212
src/Mod/CAM/Path/Tool/assets/store/memory.py
Normal 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
|
||||
6
src/Mod/CAM/Path/Tool/assets/ui/__init__.py
Normal file
6
src/Mod/CAM/Path/Tool/assets/ui/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .filedialog import AssetOpenDialog, AssetSaveDialog
|
||||
|
||||
__all__ = [
|
||||
"AssetOpenDialog",
|
||||
"AssetSaveDialog",
|
||||
]
|
||||
158
src/Mod/CAM/Path/Tool/assets/ui/filedialog.py
Normal file
158
src/Mod/CAM/Path/Tool/assets/ui/filedialog.py
Normal 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
|
||||
128
src/Mod/CAM/Path/Tool/assets/ui/preferences.py
Normal file
128
src/Mod/CAM/Path/Tool/assets/ui/preferences.py
Normal 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))
|
||||
109
src/Mod/CAM/Path/Tool/assets/ui/util.py
Normal file
109
src/Mod/CAM/Path/Tool/assets/ui/util.py
Normal 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
|
||||
119
src/Mod/CAM/Path/Tool/assets/uri.py
Normal file
119
src/Mod/CAM/Path/Tool/assets/uri.py
Normal 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
|
||||
111
src/Mod/CAM/Path/Tool/camassets.py
Normal file
111
src/Mod/CAM/Path/Tool/camassets.py
Normal 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)
|
||||
6
src/Mod/CAM/Path/Tool/library/__init__.py
Normal file
6
src/Mod/CAM/Path/Tool/library/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .models.library import Library
|
||||
|
||||
__all__ = [
|
||||
"Library",
|
||||
]
|
||||
0
src/Mod/CAM/Path/Tool/library/models/__init__.py
Normal file
0
src/Mod/CAM/Path/Tool/library/models/__init__.py
Normal file
182
src/Mod/CAM/Path/Tool/library/models/library.py
Normal file
182
src/Mod/CAM/Path/Tool/library/models/library.py
Normal 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()
|
||||
13
src/Mod/CAM/Path/Tool/library/serializers/__init__.py
Normal file
13
src/Mod/CAM/Path/Tool/library/serializers/__init__.py
Normal 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",
|
||||
]
|
||||
171
src/Mod/CAM/Path/Tool/library/serializers/camotics.py
Normal file
171
src/Mod/CAM/Path/Tool/library/serializers/camotics.py
Normal 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()), {})
|
||||
107
src/Mod/CAM/Path/Tool/library/serializers/fctl.py
Normal file
107
src/Mod/CAM/Path/Tool/library/serializers/fctl.py
Normal 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()), {})
|
||||
78
src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py
Normal file
78
src/Mod/CAM/Path/Tool/library/serializers/linuxcnc.py
Normal 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.")
|
||||
0
src/Mod/CAM/Path/Tool/library/ui/__init__.py
Normal file
0
src/Mod/CAM/Path/Tool/library/ui/__init__.py
Normal file
116
src/Mod/CAM/Path/Tool/library/ui/browser.py
Normal file
116
src/Mod/CAM/Path/Tool/library/ui/browser.py
Normal 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
|
||||
@@ -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"]
|
||||
190
src/Mod/CAM/Path/Tool/library/ui/dock.py
Normal file
190
src/Mod/CAM/Path/Tool/library/ui/dock.py
Normal 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,
|
||||
)
|
||||
642
src/Mod/CAM/Path/Tool/library/ui/editor.py
Normal file
642
src/Mod/CAM/Path/Tool/library/ui/editor.py
Normal 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([], [])
|
||||
21
src/Mod/CAM/Path/Tool/library/util.py
Normal file
21
src/Mod/CAM/Path/Tool/library/util.py
Normal 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 *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
6
src/Mod/CAM/Path/Tool/machine/__init__.py
Normal file
6
src/Mod/CAM/Path/Tool/machine/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .models.machine import Machine
|
||||
|
||||
__all__ = [
|
||||
"Machine",
|
||||
]
|
||||
0
src/Mod/CAM/Path/Tool/machine/models/__init__.py
Normal file
0
src/Mod/CAM/Path/Tool/machine/models/__init__.py
Normal file
434
src/Mod/CAM/Path/Tool/machine/models/machine.py
Normal file
434
src/Mod/CAM/Path/Tool/machine/models/machine.py
Normal 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
|
||||
48
src/Mod/CAM/Path/Tool/shape/__init__.py
Normal file
48
src/Mod/CAM/Path/Tool/shape/__init__.py
Normal 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",
|
||||
]
|
||||
189
src/Mod/CAM/Path/Tool/shape/doc.py
Normal file
189
src/Mod/CAM/Path/Tool/shape/doc.py
Normal 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"
|
||||
)
|
||||
1
src/Mod/CAM/Path/Tool/shape/models/__init__.py
Normal file
1
src/Mod/CAM/Path/Tool/shape/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
59
src/Mod/CAM/Path/Tool/shape/models/ballend.py
Normal file
59
src/Mod/CAM/Path/Tool/shape/models/ballend.py
Normal 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")
|
||||
630
src/Mod/CAM/Path/Tool/shape/models/base.py
Normal file
630
src/Mod/CAM/Path/Tool/shape/models/base.py
Normal 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
|
||||
63
src/Mod/CAM/Path/Tool/shape/models/bullnose.py
Normal file
63
src/Mod/CAM/Path/Tool/shape/models/bullnose.py
Normal 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")
|
||||
63
src/Mod/CAM/Path/Tool/shape/models/chamfer.py
Normal file
63
src/Mod/CAM/Path/Tool/shape/models/chamfer.py
Normal 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")
|
||||
75
src/Mod/CAM/Path/Tool/shape/models/dovetail.py
Normal file
75
src/Mod/CAM/Path/Tool/shape/models/dovetail.py
Normal 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")
|
||||
55
src/Mod/CAM/Path/Tool/shape/models/drill.py
Normal file
55
src/Mod/CAM/Path/Tool/shape/models/drill.py
Normal 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")
|
||||
59
src/Mod/CAM/Path/Tool/shape/models/endmill.py
Normal file
59
src/Mod/CAM/Path/Tool/shape/models/endmill.py
Normal 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")
|
||||
63
src/Mod/CAM/Path/Tool/shape/models/fillet.py
Normal file
63
src/Mod/CAM/Path/Tool/shape/models/fillet.py
Normal 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")
|
||||
296
src/Mod/CAM/Path/Tool/shape/models/icon.py
Normal file
296
src/Mod/CAM/Path/Tool/shape/models/icon.py
Normal 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
|
||||
51
src/Mod/CAM/Path/Tool/shape/models/probe.py
Normal file
51
src/Mod/CAM/Path/Tool/shape/models/probe.py
Normal 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")
|
||||
55
src/Mod/CAM/Path/Tool/shape/models/reamer.py
Normal file
55
src/Mod/CAM/Path/Tool/shape/models/reamer.py
Normal 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")
|
||||
67
src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py
Normal file
67
src/Mod/CAM/Path/Tool/shape/models/slittingsaw.py
Normal 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")
|
||||
63
src/Mod/CAM/Path/Tool/shape/models/tap.py
Normal file
63
src/Mod/CAM/Path/Tool/shape/models/tap.py
Normal 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")
|
||||
71
src/Mod/CAM/Path/Tool/shape/models/threadmill.py
Normal file
71
src/Mod/CAM/Path/Tool/shape/models/threadmill.py
Normal 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")
|
||||
67
src/Mod/CAM/Path/Tool/shape/models/vbit.py
Normal file
67
src/Mod/CAM/Path/Tool/shape/models/vbit.py
Normal 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")
|
||||
0
src/Mod/CAM/Path/Tool/shape/ui/__init__.py
Normal file
0
src/Mod/CAM/Path/Tool/shape/ui/__init__.py
Normal file
216
src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py
Normal file
216
src/Mod/CAM/Path/Tool/shape/ui/flowlayout.py
Normal 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
|
||||
47
src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py
Normal file
47
src/Mod/CAM/Path/Tool/shape/ui/shapebutton.py
Normal 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))
|
||||
80
src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py
Normal file
80
src/Mod/CAM/Path/Tool/shape/ui/shapeselector.py
Normal 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
|
||||
43
src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py
Normal file
43
src/Mod/CAM/Path/Tool/shape/ui/shapewidget.py
Normal 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)
|
||||
114
src/Mod/CAM/Path/Tool/shape/util.py
Normal file
114
src/Mod/CAM/Path/Tool/shape/util.py
Normal 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)
|
||||
36
src/Mod/CAM/Path/Tool/toolbit/__init__.py
Normal file
36
src/Mod/CAM/Path/Tool/toolbit/__init__.py
Normal 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",
|
||||
]
|
||||
184
src/Mod/CAM/Path/Tool/toolbit/docobject.py
Normal file
184
src/Mod/CAM/Path/Tool/toolbit/docobject.py
Normal 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)
|
||||
9
src/Mod/CAM/Path/Tool/toolbit/mixins/__init__.py
Normal file
9
src/Mod/CAM/Path/Tool/toolbit/mixins/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .rotary import RotaryToolBitMixin
|
||||
from .cutting import CuttingToolMixin
|
||||
|
||||
__all__ = [
|
||||
"RotaryToolBitMixin",
|
||||
"CuttingToolMixin",
|
||||
]
|
||||
40
src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py
Normal file
40
src/Mod/CAM/Path/Tool/toolbit/mixins/cutting.py
Normal 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")
|
||||
60
src/Mod/CAM/Path/Tool/toolbit/mixins/rotary.py
Normal file
60
src/Mod/CAM/Path/Tool/toolbit/mixins/rotary.py
Normal 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
|
||||
1
src/Mod/CAM/Path/Tool/toolbit/models/__init__.py
Normal file
1
src/Mod/CAM/Path/Tool/toolbit/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
45
src/Mod/CAM/Path/Tool/toolbit/models/ballend.py
Normal file
45
src/Mod/CAM/Path/Tool/toolbit/models/ballend.py
Normal 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"
|
||||
)
|
||||
810
src/Mod/CAM/Path/Tool/toolbit/models/base.py
Normal file
810
src/Mod/CAM/Path/Tool/toolbit/models/base.py
Normal 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
|
||||
47
src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py
Normal file
47
src/Mod/CAM/Path/Tool/toolbit/models/bullnose.py
Normal 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",
|
||||
)
|
||||
45
src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py
Normal file
45
src/Mod/CAM/Path/Tool/toolbit/models/chamfer.py
Normal 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"
|
||||
)
|
||||
45
src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py
Normal file
45
src/Mod/CAM/Path/Tool/toolbit/models/dovetail.py
Normal 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"
|
||||
)
|
||||
43
src/Mod/CAM/Path/Tool/toolbit/models/drill.py
Normal file
43
src/Mod/CAM/Path/Tool/toolbit/models/drill.py
Normal 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")
|
||||
45
src/Mod/CAM/Path/Tool/toolbit/models/endmill.py
Normal file
45
src/Mod/CAM/Path/Tool/toolbit/models/endmill.py
Normal 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"
|
||||
)
|
||||
45
src/Mod/CAM/Path/Tool/toolbit/models/fillet.py
Normal file
45
src/Mod/CAM/Path/Tool/toolbit/models/fillet.py
Normal 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"
|
||||
)
|
||||
48
src/Mod/CAM/Path/Tool/toolbit/models/probe.py
Normal file
48
src/Mod/CAM/Path/Tool/toolbit/models/probe.py
Normal 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
|
||||
42
src/Mod/CAM/Path/Tool/toolbit/models/reamer.py
Normal file
42
src/Mod/CAM/Path/Tool/toolbit/models/reamer.py
Normal 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")
|
||||
45
src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py
Normal file
45
src/Mod/CAM/Path/Tool/toolbit/models/slittingsaw.py
Normal 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"
|
||||
)
|
||||
45
src/Mod/CAM/Path/Tool/toolbit/models/tap.py
Normal file
45
src/Mod/CAM/Path/Tool/toolbit/models/tap.py
Normal 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"
|
||||
)
|
||||
45
src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py
Normal file
45
src/Mod/CAM/Path/Tool/toolbit/models/threadmill.py
Normal 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"
|
||||
)
|
||||
43
src/Mod/CAM/Path/Tool/toolbit/models/vbit.py
Normal file
43
src/Mod/CAM/Path/Tool/toolbit/models/vbit.py
Normal 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")
|
||||
12
src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py
Normal file
12
src/Mod/CAM/Path/Tool/toolbit/serializers/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from .camotics import CamoticsToolBitSerializer
|
||||
from .fctb import FCTBSerializer
|
||||
|
||||
|
||||
all_serializers = CamoticsToolBitSerializer, FCTBSerializer
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CamoticsToolBitSerializer",
|
||||
"FCTBSerializer",
|
||||
"all_serializers",
|
||||
]
|
||||
104
src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py
Normal file
104
src/Mod/CAM/Path/Tool/toolbit/serializers/camotics.py
Normal 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
|
||||
103
src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py
Normal file
103
src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py
Normal 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)
|
||||
6
src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py
Normal file
6
src/Mod/CAM/Path/Tool/toolbit/ui/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .editor import ToolBitEditorPanel, ToolBitEditor
|
||||
|
||||
__all__ = [
|
||||
"ToolBitEditor",
|
||||
"ToolBitEditorPanel",
|
||||
]
|
||||
263
src/Mod/CAM/Path/Tool/toolbit/ui/browser.py
Normal file
263
src/Mod/CAM/Path/Tool/toolbit/ui/browser.py
Normal 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
|
||||
@@ -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")
|
||||
282
src/Mod/CAM/Path/Tool/toolbit/ui/editor.py
Normal file
282
src/Mod/CAM/Path/Tool/toolbit/ui/editor.py
Normal 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_()
|
||||
81
src/Mod/CAM/Path/Tool/toolbit/ui/file.py
Normal file
81
src/Mod/CAM/Path/Tool/toolbit/ui/file.py
Normal 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
|
||||
69
src/Mod/CAM/Path/Tool/toolbit/ui/panel.py
Normal file
69
src/Mod/CAM/Path/Tool/toolbit/ui/panel.py
Normal 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()
|
||||
89
src/Mod/CAM/Path/Tool/toolbit/ui/selector.py
Normal file
89
src/Mod/CAM/Path/Tool/toolbit/ui/selector.py
Normal 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
|
||||
179
src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py
Normal file
179
src/Mod/CAM/Path/Tool/toolbit/ui/tablecell.py
Normal 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)
|
||||
181
src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py
Normal file
181
src/Mod/CAM/Path/Tool/toolbit/ui/toollist.py
Normal 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()))
|
||||
109
src/Mod/CAM/Path/Tool/toolbit/ui/view.py
Normal file
109
src/Mod/CAM/Path/Tool/toolbit/ui/view.py
Normal 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)
|
||||
37
src/Mod/CAM/Path/Tool/toolbit/util.py
Normal file
37
src/Mod/CAM/Path/Tool/toolbit/util.py
Normal 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)
|
||||
0
src/Mod/CAM/Path/Tool/ui/__init__.py
Normal file
0
src/Mod/CAM/Path/Tool/ui/__init__.py
Normal file
134
src/Mod/CAM/Path/Tool/ui/docobject.py
Normal file
134
src/Mod/CAM/Path/Tool/ui/docobject.py
Normal 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
Reference in New Issue
Block a user