fix duplicate toolbits
add tools to 'all tools'
context menus and deletion
/CamAssets/Tool/  directory structure
Assets and preferences
This commit is contained in:
sliptonic
2025-09-10 11:05:55 -05:00
committed by Billy
parent a7774a5100
commit 0844241216
14 changed files with 482 additions and 242 deletions

View File

@@ -54,7 +54,7 @@
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="1,0">
<layout class="QHBoxLayout" name="horizontalLayout" stretch="1">
<property name="sizeConstraint">
<enum>QLayout::SetMinimumSize</enum>
</property>
@@ -64,18 +64,7 @@
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Cancel</set>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButtonSave">
<property name="text">
<string>Edit Library</string>
</property>
<property name="icon">
<iconset>
<normaloff>../resources/icons/add-library.svg</normaloff>../resources/icons/add-library.svg</iconset>
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
@@ -84,38 +73,5 @@
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>Dialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
<connections/>
</ui>

View File

@@ -24,8 +24,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>681</width>
<height>370</height>
<width>695</width>
<height>308</height>
</rect>
</property>
<attribute name="label">
@@ -38,37 +38,7 @@
<string>Defaults</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Path</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="leDefaultFilePath">
<property name="toolTip">
<string>Path to look for templates, post processors, tool tables and other external files.
If left empty the macro directory is used.</string>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QToolButton" name="tbDefaultFilePath">
<property name="text">
<string notr="true">…</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Template</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="leDefaultJobTemplate">
<property name="toolTip">
<string>The default template to be selected when creating a new job.
@@ -79,7 +49,14 @@ If left empty no template will be preselected.</string>
</property>
</widget>
</item>
<item row="1" column="3">
<item row="0" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Template</string>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="QToolButton" name="tbDefaultJobTemplate">
<property name="text">
<string notr="true">…</string>
@@ -129,7 +106,7 @@ If left empty no template will be preselected.</string>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@@ -146,8 +123,8 @@ If left empty no template will be preselected.</string>
<rect>
<x>0</x>
<y>0</y>
<width>681</width>
<height>518</height>
<width>695</width>
<height>480</height>
</rect>
</property>
<attribute name="label">
@@ -167,7 +144,7 @@ If left empty no template will be preselected.</string>
</property>
<layout class="QFormLayout" name="formLayout_2">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
<enum>QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow</enum>
</property>
<item row="1" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_3">
@@ -280,7 +257,7 @@ See the file save policy below on how to deal with name conflicts.</string>
</property>
<layout class="QFormLayout" name="formLayout">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
<enum>QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_3">
@@ -345,7 +322,7 @@ See the file save policy below on how to deal with name conflicts.</string>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@@ -362,8 +339,8 @@ See the file save policy below on how to deal with name conflicts.</string>
<rect>
<x>0</x>
<y>0</y>
<width>662</width>
<height>755</height>
<width>674</width>
<height>619</height>
</rect>
</property>
<attribute name="label">
@@ -410,7 +387,7 @@ See the file save policy below on how to deal with name conflicts.</string>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@@ -537,7 +514,7 @@ See the file save policy below on how to deal with name conflicts.</string>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@@ -579,21 +556,21 @@ See the file save policy below on how to deal with name conflicts.</string>
<item row="3" column="2">
<widget class="QDoubleSpinBox" name="stockAxisX">
<property name="buttonSymbols">
<enum>QAbstractSpinBox::NoButtons</enum>
<enum>QAbstractSpinBox::ButtonSymbols::NoButtons</enum>
</property>
</widget>
</item>
<item row="3" column="3">
<widget class="QDoubleSpinBox" name="stockAxisY">
<property name="buttonSymbols">
<enum>QAbstractSpinBox::NoButtons</enum>
<enum>QAbstractSpinBox::ButtonSymbols::NoButtons</enum>
</property>
</widget>
</item>
<item row="3" column="4">
<widget class="QDoubleSpinBox" name="stockAxisZ">
<property name="buttonSymbols">
<enum>QAbstractSpinBox::NoButtons</enum>
<enum>QAbstractSpinBox::ButtonSymbols::NoButtons</enum>
</property>
</widget>
</item>
@@ -622,7 +599,7 @@ See the file save policy below on how to deal with name conflicts.</string>
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@@ -634,52 +611,12 @@ See the file save policy below on how to deal with name conflicts.</string>
</item>
</layout>
</widget>
<widget class="QWidget" name="page_4">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>681</width>
<height>171</height>
</rect>
</property>
<attribute name="label">
<string>Tools</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QCheckBox" name="toolsAbsolutePaths">
<property name="toolTip">
<string>References to tool bits and their shapes can either be stored with an absolute path or with a relative path to the search path.
Generally it is recommended to use relative paths due to their flexibility and robustness to layout changes.
Should multiple tools or tool shapes with the same name exist in different directories it can be required to use absolute paths.</string>
</property>
<property name="text">
<string>Store Absolute Paths</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_4">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<spacer name="verticalSpacer_5">
<property name="orientation">
<enum>Qt::Vertical</enum>
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
@@ -700,8 +637,6 @@ Should multiple tools or tool shapes with the same name exist in different direc
</customwidget>
</customwidgets>
<tabstops>
<tabstop>leDefaultFilePath</tabstop>
<tabstop>tbDefaultFilePath</tabstop>
<tabstop>leDefaultJobTemplate</tabstop>
<tabstop>tbDefaultJobTemplate</tabstop>
<tabstop>geometryTolerance</tabstop>

View File

@@ -45,11 +45,10 @@ class JobPreferencesPage:
self.processor = {}
def saveSettings(self):
filePath = self.form.leDefaultFilePath.text()
jobTemplate = self.form.leDefaultJobTemplate.text()
geometryTolerance = Units.Quantity(self.form.geometryTolerance.text())
curveAccuracy = Units.Quantity(self.form.curveAccuracy.text())
Path.Preferences.setJobDefaults(filePath, jobTemplate, geometryTolerance, curveAccuracy)
Path.Preferences.setJobDefaults(jobTemplate, geometryTolerance, curveAccuracy)
if curveAccuracy:
Path.Area.setDefaultParams(Accuracy=curveAccuracy)
@@ -146,7 +145,6 @@ class JobPreferencesPage:
)
def loadSettings(self):
self.form.leDefaultFilePath.setText(Path.Preferences.defaultFilePath())
self.form.leDefaultJobTemplate.setText(Path.Preferences.defaultJobTemplate())
blacklist = Path.Preferences.postProcessorBlacklist()
@@ -175,7 +173,6 @@ class JobPreferencesPage:
self.form.leOutputFile.setText(Path.Preferences.defaultOutputFile())
self.selectComboEntry(self.form.cboOutputPolicy, Path.Preferences.defaultOutputPolicy())
self.form.tbDefaultFilePath.clicked.connect(self.browseDefaultFilePath)
self.form.tbDefaultJobTemplate.clicked.connect(self.browseDefaultJobTemplate)
self.form.postProcessorList.itemEntered.connect(self.setProcessorListTooltip)
self.form.postProcessorList.itemChanged.connect(self.verifyAndUpdateDefaultPostProcessor)
@@ -311,7 +308,8 @@ class JobPreferencesPage:
self.form.defaultPostProcessorArgs.setToolTip(self.postProcessorArgsDefaultTooltip)
def bestGuessForFilePath(self):
path = self.form.leDefaultFilePath.text()
path = Path.Preferences.defaultFilePath()
if not path:
path = Path.Preferences.filePath()
return path
@@ -326,14 +324,6 @@ class JobPreferencesPage:
if foo:
self.form.leDefaultJobTemplate.setText(foo)
def browseDefaultFilePath(self):
path = self.bestGuessForFilePath()
foo = QtGui.QFileDialog.getExistingDirectory(
QtGui.QApplication.activeWindow(), "Path - External File Directory", path
)
if foo:
self.form.leDefaultFilePath.setText(foo)
def browseOutputFile(self):
path = self.form.leOutputFile.text()
foo = QtGui.QFileDialog.getExistingDirectory(

View File

@@ -124,22 +124,38 @@ def getDefaultAssetPath() -> Path:
def getAssetPath() -> pathlib.Path:
pref = tool_preferences()
# Check if we have a CamAssets path already set
cam_assets_path = pref.GetString(ToolPath, "")
if cam_assets_path:
return pathlib.Path(cam_assets_path)
# Migration: Check for legacy DefaultFilePath and use it for CamAssets
legacy_path = defaultFilePath()
if legacy_path:
legacy_path_obj = pathlib.Path(legacy_path)
if legacy_path_obj.exists() and legacy_path_obj.is_dir():
# Migrate: Set the legacy path as the new CamAssets path
setAssetPath(legacy_path_obj)
return legacy_path_obj
# Fallback to default if no legacy path found
default = getDefaultAssetPath()
path = pref.GetString(ToolPath, str(default))
return pathlib.Path(path or default)
return pathlib.Path(default)
def setAssetPath(path: pathlib.Path):
assert path.is_dir(), f"Cannot put a non-initialized asset directory into preferences: {path}"
if str(path) == str(getAssetPath()):
return
pref = tool_preferences()
current_path = pref.GetString(ToolPath, "")
if str(path) == current_path:
return
pref.SetString(ToolPath, str(path))
_emit_change(ToolGroup, ToolPath, path)
def getToolBitPath() -> pathlib.Path:
return getAssetPath() / "Bit"
return getAssetPath() / "Tools" / "Bit"
def getLastToolLibrary() -> Optional[str]:
@@ -212,7 +228,7 @@ def defaultFilePath():
def filePath():
path = defaultFilePath()
if not path:
path = macroFilePath()
path = getAssetPath()
return path
@@ -248,13 +264,9 @@ def defaultJobTemplate():
return ""
def setJobDefaults(fileName, jobTemplate, geometryTolerance, curveAccuracy):
Path.Log.track(
"(%s='%s', %s, %s, %s)"
% (DefaultFilePath, fileName, jobTemplate, geometryTolerance, curveAccuracy)
)
def setJobDefaults(jobTemplate, geometryTolerance, curveAccuracy):
Path.Log.track("(%s, %s, %s)" % (jobTemplate, geometryTolerance, curveAccuracy))
pref = preferences()
pref.SetString(DefaultFilePath, fileName)
pref.SetString(DefaultJobTemplate, jobTemplate)
pref.SetFloat(GeometryTolerance, geometryTolerance)
pref.SetFloat(LibAreaCurveAccuracy, curveAccuracy)

View File

@@ -107,6 +107,12 @@ class AssetManager:
visited_uris: Set[AssetUri],
depth: Optional[int] = None,
) -> Optional[_AssetConstructionData]:
# Log library fetch details
if uri.asset_type == "library":
logger.info(
f"LIBRARY FETCH: Loading library '{uri.asset_id}' with depth={depth} from stores {store_names}"
)
logger.debug(
f"_fetch_asset_construction_data_recursive_async called {store_names} {uri} {depth}"
)
@@ -126,29 +132,59 @@ class AssetManager:
# Fetch the requested asset, trying each store in order
raw_data = None
found_store_name = None
# Log toolbit search details
if uri.asset_type == "toolbit":
logger.info(
f"TOOLBIT SEARCH: Looking for toolbit '{uri.asset_id}' in stores: {store_names}"
)
for current_store_name in store_names:
store = self.stores.get(current_store_name)
if not store:
logger.warning(f"Store '{current_store_name}' not registered. Skipping.")
continue
# Log store search path for toolbits
if uri.asset_type == "toolbit":
store_path = getattr(store, "base_path", "unknown")
logger.info(
f"TOOLBIT SEARCH: Checking store '{current_store_name}' at path: {store_path}"
)
try:
raw_data = await store.get(uri)
found_store_name = current_store_name
if uri.asset_type == "toolbit":
logger.info(
f"TOOLBIT FOUND: '{uri.asset_id}' found in store '{found_store_name}'"
)
logger.debug(
f"_fetch_asset_construction_data_recursive_async: Asset {uri} found in store {found_store_name}"
)
break # Asset found, no need to check other stores
except FileNotFoundError:
if uri.asset_type == "toolbit":
logger.info(
f"TOOLBIT SEARCH: '{uri.asset_id}' NOT found in store '{current_store_name}'"
)
logger.debug(
f"_fetch_asset_construction_data_recursive_async: Asset {uri} not found in store {current_store_name}"
)
continue # Try next store
if raw_data is None or not found_store_name:
if uri.asset_type == "toolbit":
logger.warning(
f"TOOLBIT NOT FOUND: '{uri.asset_id}' not found in any of the stores: {store_names}"
)
return None # Asset not found in any store
if depth == 0:
if uri.asset_type == "library":
logger.warning(
f"LIBRARY SHALLOW: Library '{uri.asset_id}' loaded with depth=0 - no dependencies will be resolved"
)
return _AssetConstructionData(
store=found_store_name,
uri=uri,
@@ -241,10 +277,23 @@ class AssetManager:
resolved_dependencies: Optional[Mapping[AssetUri, Any]] = None
if construction_data.dependencies_data is not None:
resolved_dependencies = {}
# Log dependency resolution for libraries
if construction_data.uri.asset_type == "library":
logger.info(
f"LIBRARY DEPS: Resolving {len(construction_data.dependencies_data)} dependencies for library '{construction_data.uri.asset_id}'"
)
for (
dep_uri,
dep_data_node,
) in construction_data.dependencies_data.items():
# Log toolbit dependency resolution
if dep_uri.asset_type == "toolbit":
logger.info(
f"TOOLBIT DEP: Resolving dependency '{dep_uri.asset_id}' for library '{construction_data.uri.asset_id}'"
)
# 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,
@@ -252,7 +301,18 @@ class AssetManager:
# For now, use the parent's store_name_for_cache.
try:
dep = self._build_asset_tree_from_data_sync(dep_data_node)
if dep_uri.asset_type == "toolbit":
if dep:
logger.info(
f"TOOLBIT DEP: Successfully resolved '{dep_uri.asset_id}' -> {type(dep).__name__}"
)
else:
logger.warning(
f"TOOLBIT DEP: Dependency '{dep_uri.asset_id}' resolved to None"
)
except Exception as e:
if dep_uri.asset_type == "toolbit":
logger.error(f"TOOLBIT DEP: Error resolving '{dep_uri.asset_id}': {e}")
logger.error(
f"Error building dependency '{dep_uri}' for asset '{construction_data.uri}': {e}",
exc_info=True,
@@ -260,9 +320,31 @@ class AssetManager:
else:
resolved_dependencies[dep_uri] = dep
# Log final dependency count for libraries
if construction_data.uri.asset_type == "library":
toolbit_deps = [
uri for uri in resolved_dependencies.keys() if uri.asset_type == "toolbit"
]
logger.info(
f"LIBRARY DEPS: Resolved {len(resolved_dependencies)} total dependencies ({len(toolbit_deps)} toolbits) for library '{construction_data.uri.asset_id}'"
)
else:
# Log when dependencies_data is None
if construction_data.uri.asset_type == "library":
logger.warning(
f"LIBRARY NO DEPS: Library '{construction_data.uri.asset_id}' has dependencies_data=None - was loaded with depth=0"
)
asset_class = construction_data.asset_class
serializer = self.get_serializer_for_class(asset_class)
try:
# Log library instantiation with dependency info
if construction_data.uri.asset_type == "library":
dep_count = len(resolved_dependencies) if resolved_dependencies else 0
logger.info(
f"LIBRARY INSTANTIATE: Creating library '{construction_data.uri.asset_id}' with {dep_count} dependencies"
)
final_asset = asset_class.from_bytes(
construction_data.raw_data,
construction_data.uri.asset_id,
@@ -307,6 +389,24 @@ class AssetManager:
# Log entry with thread info for verification
calling_thread_name = threading.current_thread().name
stores_list = [store] if isinstance(store, str) else store
# Log all asset get requests
asset_uri_obj = AssetUri(uri) if isinstance(uri, str) else uri
if asset_uri_obj.asset_type == "library":
logger.info(
f"LIBRARY GET: Request for library '{asset_uri_obj.asset_id}' with depth={depth}"
)
elif asset_uri_obj.asset_type == "toolbit":
logger.info(
f"TOOLBIT GET: Direct request for toolbit '{asset_uri_obj.asset_id}' with depth={depth} from stores {stores_list}"
)
# Add stack trace to see who's calling this
import traceback
stack = traceback.format_stack()
caller_info = "".join(stack[-3:-1]) # Get the 2 frames before this one
logger.info(f"TOOLBIT GET CALLER:\n{caller_info}")
logger.debug(
f"AssetManager.get(uri='{uri}', stores='{stores_list}', depth='{depth}') called from thread: {calling_thread_name}"
)

View File

@@ -29,6 +29,7 @@ from .util import (
make_export_filters,
get_serializer_from_extension,
)
import Path.Preferences as Preferences
class AssetOpenDialog(QFileDialog):
@@ -40,7 +41,11 @@ class AssetOpenDialog(QFileDialog):
parent=None,
):
super().__init__(parent)
self.setDirectory(pathlib.Path.home().as_posix())
# Set default directory based on asset type
default_dir = self._get_default_directory(asset_class)
self.setDirectory(default_dir.as_posix())
self.asset_class = asset_class
self.asset_manager = asset_manager
self.serializers = list(serializers)
@@ -70,7 +75,7 @@ class AssetOpenDialog(QFileDialog):
raw_data = file_path.read_bytes()
dependencies = serializer_class.extract_dependencies(raw_data)
for dependency_uri in dependencies:
if not self.asset_manager.exists(dependency_uri):
if not self.asset_manager.exists(dependency_uri, store=["local", "builtin"]):
QMessageBox.critical(
self,
"Error",
@@ -101,6 +106,24 @@ class AssetOpenDialog(QFileDialog):
return file_path, asset
return None
def _get_default_directory(self, asset_class: Type[Asset]) -> pathlib.Path:
"""Get the appropriate default directory based on asset type."""
try:
asset_path = Preferences.getAssetPath()
# Check asset type to determine subdirectory
asset_type = getattr(asset_class, "asset_type", None)
if asset_type == "toolbit":
return asset_path / "Tool" / "Bit"
elif asset_type == "library" or asset_type == "toolbitlibrary":
return asset_path / "Tool" / "Library"
else:
# Default to asset path root for unknown types
return asset_path
except Exception:
# Fallback to home directory if anything goes wrong
return pathlib.Path.home()
class AssetSaveDialog(QFileDialog):
def __init__(
@@ -110,7 +133,10 @@ class AssetSaveDialog(QFileDialog):
parent=None,
):
super().__init__(parent)
self.setDirectory(pathlib.Path.home().as_posix())
# Set default directory based on asset type
default_dir = self._get_default_directory(asset_class)
self.setDirectory(default_dir.as_posix())
self.asset_class = asset_class
self.serializers = list(serializers)
self.setFileMode(QFileDialog.AnyFile)
@@ -145,6 +171,11 @@ class AssetSaveDialog(QFileDialog):
QMessageBox.critical(self, "Error", f"Failed to export asset: {e}")
return False
def _get_default_directory(self, asset_class: Type[Asset]) -> pathlib.Path:
"""Get the appropriate default directory based on asset type."""
# For exports, default to home directory instead of CAM assets path
return pathlib.Path.home()
def exec_(self, asset: Asset) -> Optional[Tuple[pathlib.Path, Type[AssetSerializer]]]:
self.setWindowTitle(f"Save {asset.label or self.asset_class.asset_type}")
if super().exec_():

View File

@@ -131,13 +131,13 @@ def ensure_toolbitshape_assets_present(asset_manager: AssetManager, store_name:
def ensure_toolbitshape_assets_initialized(asset_manager: AssetManager, store_name: str = "local"):
"""
Copies an example shape to the given store if it is currently empty.
Ensures the toolbitshape directory structure exists without adding any files.
"""
builtin_shape_path = Preferences.getBuiltinShapePath()
from pathlib import Path
if asset_manager.is_empty("toolbitshape", store=store_name):
path = builtin_shape_path / "endmill.fcstd"
asset_manager.add_file("toolbitshape", path, store=store_name, asset_id="example")
# Get the shape directory path and ensure it exists
shape_path = Preferences.getAssetPath() / "Tools" / "Shape"
shape_path.mkdir(parents=True, exist_ok=True)
def ensure_assets_initialized(asset_manager: AssetManager, store="local"):
@@ -157,6 +157,16 @@ def _on_asset_path_changed(group, key, value):
# Set up the local CAM asset storage.
asset_mapping = {
"toolbitlibrary": "Tools/Library/{asset_id}.fctl",
"toolbit": "Tools/Bit/{asset_id}.fctb",
"toolbitshape": "Tools/Shape/{asset_id}.fcstd",
"toolbitshapesvg": "Tools/Shape/{asset_id}", # Asset ID has ".svg" included
"toolbitshapepng": "Tools/Shape/{asset_id}", # Asset ID has ".png" included
"machine": "Machine/{asset_id}.fcm",
}
# Separate mapping for builtin assets (maintains original structure)
builtin_asset_mapping = {
"toolbitlibrary": "Library/{asset_id}.fctl",
"toolbit": "Bit/{asset_id}.fctb",
"toolbitshape": "Shape/{asset_id}.fcstd",
@@ -174,7 +184,7 @@ user_asset_store = FileStore(
builtin_asset_store = FileStore(
name="builtin",
base_dir=Preferences.getBuiltinAssetPath(),
mapping=asset_mapping,
mapping=builtin_asset_mapping,
)

View File

@@ -35,6 +35,9 @@ class FCTLSerializer(AssetSerializer):
extensions = (".fctl",)
mime_type = "application/x-freecad-toolbit-library"
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
@classmethod
def get_label(cls) -> str:
return FreeCAD.Qt.translate("CAM", "FreeCAD Tool Library")
@@ -66,6 +69,7 @@ class FCTLSerializer(AssetSerializer):
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
@@ -103,9 +107,80 @@ class FCTLSerializer(AssetSerializer):
Path.Log.warning(
f"Tool with id {tool_id} not found in dependencies during deserialization."
)
# Create a placeholder toolbit with the original ID to preserve library structure
from ...toolbit.models.custom import ToolBitCustom
from ...shape.models.custom import ToolBitShapeCustom
placeholder_shape = ToolBitShapeCustom(tool_id)
placeholder_toolbit = ToolBitCustom(placeholder_shape, id=tool_id)
placeholder_toolbit.label = f"Missing Tool ({tool_id})"
library.add_bit(placeholder_toolbit, bit_no=tool_no)
Path.Log.info(f"Created placeholder toolbit with original ID {tool_id}")
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()), {})
"""Deep deserialize a library by fetching all toolbit dependencies."""
import uuid
from ...camassets import cam_assets
# Generate a unique ID for this library instance
library_id = str(uuid.uuid4())
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Starting deep deserialization for library id='{library_id}'"
)
# Extract dependency URIs from the library data
dependency_uris = cls.extract_dependencies(data)
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Found {len(dependency_uris)} toolbit dependencies: {[uri.asset_id for uri in dependency_uris]}"
)
# Fetch all toolbit dependencies
resolved_dependencies = {}
for dep_uri in dependency_uris:
try:
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Fetching toolbit '{dep_uri.asset_id}' from stores ['local', 'builtin']"
)
# Check if toolbit exists in each store individually for debugging
exists_local = cam_assets.exists(dep_uri, store="local")
exists_builtin = cam_assets.exists(dep_uri, store="builtin")
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Toolbit '{dep_uri.asset_id}' exists - local: {exists_local}, builtin: {exists_builtin}"
)
toolbit = cam_assets.get(dep_uri, store=["local", "builtin"], depth=0)
resolved_dependencies[dep_uri] = toolbit
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Successfully fetched toolbit '{dep_uri.asset_id}'"
)
except Exception as e:
Path.Log.warning(
f"FCTL DEEP_DESERIALIZE: Failed to fetch toolbit '{dep_uri.asset_id}': {e}"
)
# Try to get more detailed error information
try:
# Check what's actually in the stores
local_toolbits = cam_assets.list_assets("toolbit", store="local")
local_ids = [uri.asset_id for uri in local_toolbits]
Path.Log.info(
f"FCTL DEBUG: Local store has {len(local_ids)} toolbits: {local_ids[:10]}{'...' if len(local_ids) > 10 else ''}"
)
if dep_uri.asset_id in local_ids:
Path.Log.warning(
f"FCTL DEBUG: Toolbit '{dep_uri.asset_id}' IS in local store list but get() failed!"
)
except Exception as list_error:
Path.Log.error(f"FCTL DEBUG: Failed to list local toolbits: {list_error}")
Path.Log.info(
f"FCTL DEEP_DESERIALIZE: Resolved {len(resolved_dependencies)} of {len(dependency_uris)} dependencies"
)
# Now deserialize with the resolved dependencies
return cls.deserialize(data, library_id, resolved_dependencies)

View File

@@ -284,6 +284,7 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
selected_items = self._tool_list_widget.selectedItems()
has_selection = bool(selected_items)
has_library = self.current_library is not None
# Add actions in the desired order
edit_action = context_menu.addAction("Edit", self._on_edit_requested)
@@ -310,13 +311,17 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
context_menu.addSeparator()
action = context_menu.addAction(
"Remove from Library", self._on_remove_from_library_requested
)
action.setShortcut(QtGui.QKeySequence.Delete)
# Only show "Remove from Library" when viewing a specific library
if has_library:
action = context_menu.addAction(
"Remove from Library", self._on_remove_from_library_requested
)
action.setShortcut(QtGui.QKeySequence.Delete)
action = context_menu.addAction("Delete from disk", self._on_delete_requested)
action.setShortcut(QtGui.QKeySequence("Shift+Delete"))
# Only show "Delete from disk" when viewing 'all tools' (no library selected)
if not has_library:
action = context_menu.addAction("Delete from disk", self._on_delete_requested)
action.setShortcut(QtGui.QKeySequence("Shift+Delete"))
# Execute the menu
context_menu.exec_(self._tool_list_widget.mapToGlobal(position))
@@ -443,14 +448,29 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
toolbit_data_bytes = toolbit_yaml_str.encode("utf-8")
toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None)
# Assign a new tool id and a label
toolbit.set_id()
self._asset_manager.add(toolbit) # Save the new toolbit to disk
# Add the bit to the current library
added_toolbit = current_library.add_bit(toolbit)
# Get the original toolbit ID from the deserialized data
original_id = toolbit.id
Path.Log.info(f"COPY PASTE: Attempting to paste toolbit with original_id={original_id}")
# Check if toolbit already exists in asset manager
toolbit_uri = toolbit.get_uri()
existing_toolbit = None
try:
existing_toolbit = self._asset_manager.get(
toolbit_uri, store=["local", "builtin"], depth=0
)
Path.Log.info(f"COPY PASTE: Found existing toolbit {original_id}, using reference")
except FileNotFoundError:
# Toolbit doesn't exist, save it as new
Path.Log.info(f"COPY PASTE: Toolbit {original_id} not found, creating new one")
self._asset_manager.add(toolbit)
existing_toolbit = toolbit
# Add the existing or new toolbit to the current library
added_toolbit = current_library.add_bit(existing_toolbit)
if added_toolbit:
new_uris.add(str(toolbit.get_uri()))
new_uris.add(str(existing_toolbit.get_uri()))
if new_uris:
self._asset_manager.add(current_library) # Save the modified library
@@ -485,16 +505,25 @@ class LibraryBrowserWidget(ToolBitBrowserWidget):
toolbit_data_bytes = toolbit_yaml_str.encode("utf-8")
toolbit = YamlToolBitSerializer.deserialize(toolbit_data_bytes, dependencies=None)
source_library.remove_bit(toolbit)
# Remove it from the old library, add it to the new library
source_library.remove_bit(toolbit)
added_toolbit = current_library.add_bit(toolbit)
if added_toolbit:
new_uris.add(str(toolbit.get_uri()))
# Get the original toolbit ID and find the existing toolbit
original_id = toolbit.id
Path.Log.info(f"CUT PASTE: Moving toolbit with original_id={original_id}")
# The toolbit itself does not change, so we don't need to save it.
# It is only the reference in the library that changes.
toolbit_uri = toolbit.get_uri()
try:
existing_toolbit = self._asset_manager.get(
toolbit_uri, store=["local", "builtin"], depth=0
)
Path.Log.info(f"CUT PASTE: Found existing toolbit {original_id}, using reference")
# Remove from source library, add to target library
source_library.remove_bit(existing_toolbit)
added_toolbit = current_library.add_bit(existing_toolbit)
if added_toolbit:
new_uris.add(str(existing_toolbit.get_uri()))
except FileNotFoundError:
Path.Log.warning(f"CUT PASTE: Toolbit {original_id} not found in asset manager")
if new_uris:
# Save the modified libraries

View File

@@ -350,7 +350,9 @@ class LibraryEditor(QWidget):
self.form.renameLibraryButton.setEnabled(library_selected)
self.form.exportLibraryButton.setEnabled(library_selected)
self.form.importLibraryButton.setEnabled(True)
self.form.addToolBitButton.setEnabled(library_selected)
self.form.addToolBitButton.setEnabled(
True
) # Always enabled - can create standalone toolbits
# TODO: self.form.exportToolBitButton.setEnabled(toolbit_selected)
def _save_library(self):
@@ -475,17 +477,9 @@ class LibraryEditor(QWidget):
self._update_button_states()
def _on_add_toolbit_requested(self):
"""Handles request to add a new toolbit to the current library."""
"""Handles request to add a new toolbit to the current library or create standalone."""
Path.Log.debug("_on_add_toolbit_requested: Called.")
current_library = self.browser.get_current_library()
if not current_library:
Path.Log.warning("Cannot add toolbit: No library selected.")
QMessageBox.warning(
self,
FreeCAD.Qt.translate("CAM", "Warning"),
FreeCAD.Qt.translate("CAM", "Please select a library first."),
)
return
# Select the shape for the new toolbit
selector = ShapeSelector()
@@ -508,15 +502,19 @@ class LibraryEditor(QWidget):
tool_asset_uri = cam_assets.add(new_toolbit)
Path.Log.debug(f"_on_add_toolbit_requested: Saved tool with URI: {tool_asset_uri}")
# Add the toolbit to the current library
toolno = current_library.add_bit(new_toolbit)
Path.Log.debug(
f"_on_add_toolbit_requested: Added toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()}) "
f"to current_library with number {toolno}."
)
# Save the library
cam_assets.add(current_library)
# Add the toolbit to the current library if one is selected
if current_library:
toolno = current_library.add_bit(new_toolbit)
Path.Log.debug(
f"_on_add_toolbit_requested: Added toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()}) "
f"to current_library with number {toolno}."
)
# Save the library
cam_assets.add(current_library)
else:
Path.Log.debug(
f"_on_add_toolbit_requested: Created standalone toolbit {new_toolbit.get_id()} (URI: {new_toolbit.get_uri()})"
)
except Exception as e:
Path.Log.error(f"Failed to create or add new toolbit: {e}")
@@ -552,17 +550,50 @@ class LibraryEditor(QWidget):
return
file_path, toolbit = cast(Tuple[pathlib.Path, ToolBit], response)
# Add the imported toolbit to the current library
added_toolbit = current_library.add_bit(toolbit)
# Debug logging for imported toolbit
Path.Log.info(
f"IMPORT TOOLBIT: file_path={file_path}, toolbit.id={toolbit.id}, toolbit.label={toolbit.label}"
)
import traceback
stack = traceback.format_stack()
caller_info = "".join(stack[-3:-1])
Path.Log.info(f"IMPORT TOOLBIT CALLER:\n{caller_info}")
# Check if toolbit already exists in asset manager
toolbit_uri = toolbit.get_uri()
Path.Log.info(f"IMPORT CHECK: toolbit_uri={toolbit_uri}")
existing_toolbit = None
try:
existing_toolbit = cam_assets.get(toolbit_uri, store=["local", "builtin"], depth=0)
Path.Log.info(
f"IMPORT CHECK: Toolbit {toolbit.id} already exists, using existing reference"
)
Path.Log.info(
f"IMPORT CHECK: existing_toolbit.id={existing_toolbit.id}, existing_toolbit.label={existing_toolbit.label}"
)
except FileNotFoundError:
# Toolbit doesn't exist, save it as new
Path.Log.info(f"IMPORT CHECK: Toolbit {toolbit.id} is new, saving to disk")
new_uri = cam_assets.add(toolbit)
Path.Log.info(f"IMPORT CHECK: Toolbit saved with new URI: {new_uri}")
existing_toolbit = toolbit
# Add the toolbit (existing or new) to the current library
Path.Log.info(
f"IMPORT ADD: Adding toolbit {existing_toolbit.id} to library {current_library.label}"
)
added_toolbit = current_library.add_bit(existing_toolbit)
if added_toolbit:
cam_assets.add(toolbit) # Save the imported toolbit to disk
Path.Log.info(f"IMPORT ADD: Successfully added toolbit to library")
cam_assets.add(current_library) # Save the modified library
self.browser.refresh()
self.browser.select_by_uri([str(toolbit.get_uri())])
self.browser.select_by_uri([str(existing_toolbit.get_uri())])
self._update_button_states()
else:
Path.Log.warning(f"IMPORT ADD: Failed to add toolbit {existing_toolbit.id} to library")
Path.Log.warning(
f"Failed to import toolbit from {file_path} to library {current_library.label}."
f"IMPORT FAILED: Failed to import toolbit from {file_path} to library {current_library.label}."
)
QMessageBox.warning(
self,

View File

@@ -42,20 +42,32 @@ class LibraryPropertyDialog(QtWidgets.QDialog):
self.form.lineEditLibraryName.setText(self.library.label)
self.update_window_title()
if new:
label = FreeCAD.Qt.translate("CAM", "Create Library")
self.form.pushButtonSave.setText(label)
self.form.buttonBox.accepted.connect(self.accept)
self.form.buttonBox.accepted.connect(self.save_properties)
self.form.buttonBox.rejected.connect(self.reject)
self.form.pushButtonSave.clicked.connect(self.save_properties)
# Connect text changed signal to update window title
self.form.lineEditLibraryName.textChanged.connect(self.update_window_title)
# Make the OK button the default so Enter key works
ok_button = self.form.buttonBox.button(QtWidgets.QDialogButtonBox.Ok)
cancel_button = self.form.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel)
if cancel_button:
cancel_button.setDefault(False)
cancel_button.setAutoDefault(False)
if ok_button:
ok_button.setDefault(True)
ok_button.setAutoDefault(True)
ok_button.setFocus() # Also set focus to the OK button
# Set minimum width for the dialog
self.setMinimumWidth(450)
# Set focus to the text input so user can start typing immediately
self.form.lineEditLibraryName.setFocus()
self.form.lineEditLibraryName.selectAll() # Select all text for easy replacement
def update_window_title(self):
# Update title based on current text in the line edit
current_name = self.form.lineEditLibraryName.text()

View File

@@ -28,7 +28,7 @@ from ...shape import ToolBitShape
from ..models.base import ToolBit
if False:
if True:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
else:
@@ -100,15 +100,19 @@ class FCTBSerializer(AssetSerializer):
f"is not a ToolBitShape instance. {dependencies}"
)
# Find the correct ToolBit subclass for the shape
Path.Log.debug(
f"FCTBSerializer.deserialize: shape = {shape!r}, id = {id!r},"
f" params = {shape.get_parameters()}, attrs = {attrs!r}"
)
return ToolBit.from_shape(shape, attrs, id)
@classmethod
def deep_deserialize(cls, data: bytes) -> ToolBit:
"""Deep deserialize preserving the original toolbit ID."""
attrs_map = json.loads(data)
original_id = attrs_map.get("id")
asset_class = cast(ToolBit, cls.for_class)
return asset_class.from_dict(attrs_map)
toolbit = asset_class.from_dict(attrs_map)
if original_id:
toolbit.id = original_id # Preserve the original ID
return toolbit

View File

@@ -20,7 +20,7 @@
# * *
# ***************************************************************************
import yaml
from typing import List, Optional, Mapping, Type
from typing import List, Optional, Mapping, Type, cast
from ...assets.serializer import AssetSerializer
from ...assets.uri import AssetUri
from ...shape import ToolBitShape
@@ -81,8 +81,13 @@ class YamlToolBitSerializer(AssetSerializer):
@classmethod
def deep_deserialize(cls, data: bytes) -> ToolBit:
"""
Like deserialize(), but builds dependencies itself if they are
sufficiently defined in the data.
"""
raise NotImplementedError
"""Deep deserialize preserving the original toolbit ID."""
data_dict = yaml.safe_load(data)
if not isinstance(data_dict, dict):
raise ValueError("Invalid YAML data for ToolBit")
original_id = data_dict.get("id") # Extract the original ID
toolbit = ToolBit.from_dict(data_dict)
if original_id:
toolbit.id = original_id # Preserve the original ID
return toolbit

View File

@@ -395,7 +395,7 @@ class ToolBitBrowserWidget(QtGui.QWidget):
self._to_clipboard(uris, mode="copy")
def _on_delete_requested(self):
"""Deletes selected toolbits."""
"""Deletes selected toolbits and removes them from all libraries."""
Path.Log.debug("ToolBitBrowserWidget._on_delete_requested: Function entered.")
uris = self.get_selected_bit_uris()
if not uris:
@@ -406,7 +406,10 @@ class ToolBitBrowserWidget(QtGui.QWidget):
reply = QMessageBox.question(
self,
FreeCAD.Qt.translate("CAM", "Confirm Deletion"),
FreeCAD.Qt.translate("CAM", "Are you sure you want to delete the selected toolbit(s)?"),
FreeCAD.Qt.translate(
"CAM",
"Are you sure you want to delete the selected toolbit(s)? This is not reversible. The toolbits will be removed from disk and from all libraries that contain them.",
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
@@ -415,19 +418,66 @@ class ToolBitBrowserWidget(QtGui.QWidget):
return
deleted_count = 0
libraries_modified = [] # Use list instead of set since Library objects aren't hashable
for uri_string in uris:
try:
# Delete the toolbit using the asset manager
self._asset_manager.delete(AssetUri(uri_string))
toolbit_uri = AssetUri(uri_string)
# First, remove the toolbit from all libraries that contain it
libraries_to_update = self._find_libraries_containing_toolbit(toolbit_uri)
for library in libraries_to_update:
library.remove_bit_by_uri(uri_string)
if library not in libraries_modified: # Avoid duplicates
libraries_modified.append(library)
Path.Log.info(
f"Removed toolbit {toolbit_uri.asset_id} from library {library.label}"
)
# Then delete the toolbit file from disk
self._asset_manager.delete(toolbit_uri)
deleted_count += 1
Path.Log.info(f"Deleted toolbit file {uri_string}")
except Exception as e:
Path.Log.error(f"Failed to delete toolbit {uri_string}: {e}")
# Optionally show a message box to the user
# Save all modified libraries
for library in libraries_modified:
try:
self._asset_manager.add(library)
Path.Log.info(f"Saved updated library {library.label}")
except Exception as e:
Path.Log.error(f"Failed to save library {library.label}: {e}")
if deleted_count > 0:
Path.Log.info(f"Deleted {deleted_count} toolbit(s).")
Path.Log.info(
f"Deleted {deleted_count} toolbit(s) and updated {len(libraries_modified)} libraries."
)
self.refresh()
def _find_libraries_containing_toolbit(self, toolbit_uri: AssetUri) -> List:
"""Find all libraries that contain the specified toolbit."""
from ...library.models.library import Library
libraries_with_toolbit = []
try:
# Get all libraries from the asset manager
all_libraries = self._asset_manager.fetch("toolbitlibrary", store="local", depth=1)
for library in all_libraries:
if isinstance(library, Library):
# Check if this library contains the toolbit
for toolbit in library:
if toolbit.get_uri() == toolbit_uri:
libraries_with_toolbit.append(library)
break
except Exception as e:
Path.Log.error(f"Error finding libraries containing toolbit {toolbit_uri}: {e}")
return libraries_with_toolbit
def get_selected_bit_uris(self) -> List[str]:
"""
Returns a list of URIs for the currently selected ToolBit items.