From 0844241216e389256645e82887af00a8b723b5a9 Mon Sep 17 00:00:00 2001 From: sliptonic Date: Wed, 10 Sep 2025 11:05:55 -0500 Subject: [PATCH] fixes fix duplicate toolbits add tools to 'all tools' context menus and deletion /CamAssets/Tool/ directory structure Assets and preferences --- .../Gui/Resources/panels/LibraryProperties.ui | 50 +------- .../CAM/Gui/Resources/preferences/PathJob.ui | 115 ++++-------------- src/Mod/CAM/Path/Main/Gui/PreferencesJob.py | 16 +-- src/Mod/CAM/Path/Preferences.py | 36 ++++-- src/Mod/CAM/Path/Tool/assets/manager.py | 100 +++++++++++++++ src/Mod/CAM/Path/Tool/assets/ui/filedialog.py | 37 +++++- src/Mod/CAM/Path/Tool/camassets.py | 22 +++- .../CAM/Path/Tool/library/serializers/fctl.py | 79 +++++++++++- src/Mod/CAM/Path/Tool/library/ui/browser.py | 69 ++++++++--- src/Mod/CAM/Path/Tool/library/ui/editor.py | 79 ++++++++---- .../CAM/Path/Tool/library/ui/properties.py | 24 +++- .../CAM/Path/Tool/toolbit/serializers/fctb.py | 18 +-- .../CAM/Path/Tool/toolbit/serializers/yaml.py | 17 ++- src/Mod/CAM/Path/Tool/toolbit/ui/browser.py | 62 +++++++++- 14 files changed, 482 insertions(+), 242 deletions(-) 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.