diff --git a/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui
index ff76a97264..b68741d6ee 100644
--- a/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui
+++ b/src/Mod/CAM/Gui/Resources/panels/LibraryProperties.ui
@@ -54,7 +54,7 @@
-
-
+
QLayout::SetMinimumSize
@@ -64,18 +64,7 @@
Qt::Horizontal
- QDialogButtonBox::Cancel
-
-
-
- -
-
-
- Edit Library
-
-
-
- ../resources/icons/add-library.svg../resources/icons/add-library.svg
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
@@ -84,38 +73,5 @@
-
-
- buttonBox
- accepted()
- Dialog
- accept()
-
-
- 248
- 254
-
-
- 157
- 274
-
-
-
-
- buttonBox
- rejected()
- Dialog
- reject()
-
-
- 316
- 260
-
-
- 286
- 274
-
-
-
-
+
diff --git a/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui
index 30472ceabe..c53822ac8b 100644
--- a/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui
+++ b/src/Mod/CAM/Gui/Resources/preferences/PathJob.ui
@@ -24,8 +24,8 @@
0
0
- 681
- 370
+ 695
+ 308
@@ -38,37 +38,7 @@
Defaults
- -
-
-
- Path
-
-
-
-
-
-
- Path to look for templates, post processors, tool tables and other external files.
-
-If left empty the macro directory is used.
-
-
-
- -
-
-
- …
-
-
-
- -
-
-
- Template
-
-
-
- -
The default template to be selected when creating a new job.
@@ -79,7 +49,14 @@ If left empty no template will be preselected.
- -
+
-
+
+
+ Template
+
+
+
+ -
…
@@ -129,7 +106,7 @@ If left empty no template will be preselected.
-
- Qt::Vertical
+ Qt::Orientation::Vertical
@@ -146,8 +123,8 @@ If left empty no template will be preselected.
0
0
- 681
- 518
+ 695
+ 480
@@ -167,7 +144,7 @@ If left empty no template will be preselected.
- QFormLayout::AllNonFixedFieldsGrow
+ QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow
-
@@ -280,7 +257,7 @@ See the file save policy below on how to deal with name conflicts.
- QFormLayout::AllNonFixedFieldsGrow
+ QFormLayout::FieldGrowthPolicy::AllNonFixedFieldsGrow
-
@@ -345,7 +322,7 @@ See the file save policy below on how to deal with name conflicts.
-
- Qt::Vertical
+ Qt::Orientation::Vertical
@@ -362,8 +339,8 @@ See the file save policy below on how to deal with name conflicts.
0
0
- 662
- 755
+ 674
+ 619
@@ -410,7 +387,7 @@ See the file save policy below on how to deal with name conflicts.
-
- Qt::Horizontal
+ Qt::Orientation::Horizontal
@@ -537,7 +514,7 @@ See the file save policy below on how to deal with name conflicts.
-
- Qt::Horizontal
+ Qt::Orientation::Horizontal
@@ -579,21 +556,21 @@ See the file save policy below on how to deal with name conflicts.
-
- QAbstractSpinBox::NoButtons
+ QAbstractSpinBox::ButtonSymbols::NoButtons
-
- QAbstractSpinBox::NoButtons
+ QAbstractSpinBox::ButtonSymbols::NoButtons
-
- QAbstractSpinBox::NoButtons
+ QAbstractSpinBox::ButtonSymbols::NoButtons
@@ -622,7 +599,7 @@ See the file save policy below on how to deal with name conflicts.
-
- Qt::Vertical
+ Qt::Orientation::Vertical
@@ -634,52 +611,12 @@ See the file save policy below on how to deal with name conflicts.
-
-
-
- 0
- 0
- 681
- 171
-
-
-
- Tools
-
-
- -
-
-
- 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.
-
-
- Store Absolute Paths
-
-
-
- -
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
-
-
- Qt::Vertical
+ Qt::Orientation::Vertical
@@ -700,8 +637,6 @@ Should multiple tools or tool shapes with the same name exist in different direc
- leDefaultFilePath
- tbDefaultFilePath
leDefaultJobTemplate
tbDefaultJobTemplate
geometryTolerance
diff --git a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py
index 2b98f123b3..2aa5a167e8 100644
--- a/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py
+++ b/src/Mod/CAM/Path/Main/Gui/PreferencesJob.py
@@ -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(
diff --git a/src/Mod/CAM/Path/Preferences.py b/src/Mod/CAM/Path/Preferences.py
index 107fd76fa6..965b896c3c 100644
--- a/src/Mod/CAM/Path/Preferences.py
+++ b/src/Mod/CAM/Path/Preferences.py
@@ -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)
diff --git a/src/Mod/CAM/Path/Tool/assets/manager.py b/src/Mod/CAM/Path/Tool/assets/manager.py
index 5a05fa9e17..072ec3a100 100644
--- a/src/Mod/CAM/Path/Tool/assets/manager.py
+++ b/src/Mod/CAM/Path/Tool/assets/manager.py
@@ -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}"
)
diff --git a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py
index 3c8505c4a6..32ba281114 100644
--- a/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py
+++ b/src/Mod/CAM/Path/Tool/assets/ui/filedialog.py
@@ -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_():
diff --git a/src/Mod/CAM/Path/Tool/camassets.py b/src/Mod/CAM/Path/Tool/camassets.py
index 38f1c6f2a6..8bffbeaec3 100644
--- a/src/Mod/CAM/Path/Tool/camassets.py
+++ b/src/Mod/CAM/Path/Tool/camassets.py
@@ -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,
)
diff --git a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py
index 8b6737bddb..275bfea0fe 100644
--- a/src/Mod/CAM/Path/Tool/library/serializers/fctl.py
+++ b/src/Mod/CAM/Path/Tool/library/serializers/fctl.py
@@ -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)
diff --git a/src/Mod/CAM/Path/Tool/library/ui/browser.py b/src/Mod/CAM/Path/Tool/library/ui/browser.py
index ff1f5f15ce..13d5019eb2 100644
--- a/src/Mod/CAM/Path/Tool/library/ui/browser.py
+++ b/src/Mod/CAM/Path/Tool/library/ui/browser.py
@@ -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
diff --git a/src/Mod/CAM/Path/Tool/library/ui/editor.py b/src/Mod/CAM/Path/Tool/library/ui/editor.py
index bcfd175203..2572a65bc2 100644
--- a/src/Mod/CAM/Path/Tool/library/ui/editor.py
+++ b/src/Mod/CAM/Path/Tool/library/ui/editor.py
@@ -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,
diff --git a/src/Mod/CAM/Path/Tool/library/ui/properties.py b/src/Mod/CAM/Path/Tool/library/ui/properties.py
index 6bab65b4fa..20d1dc4f59 100644
--- a/src/Mod/CAM/Path/Tool/library/ui/properties.py
+++ b/src/Mod/CAM/Path/Tool/library/ui/properties.py
@@ -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()
diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py
index 0722d770a2..ee1efa6959 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/fctb.py
@@ -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
diff --git a/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py
index 2fbbdcef0f..95d679dc4c 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/serializers/yaml.py
@@ -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
diff --git a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py
index 6273f4cb81..4a8f756b00 100644
--- a/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py
+++ b/src/Mod/CAM/Path/Tool/toolbit/ui/browser.py
@@ -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.