From 871ab6df64f7c1abf04a16d2434a0bf69d6418f5 Mon Sep 17 00:00:00 2001 From: Billy Huddleston Date: Mon, 3 Nov 2025 15:33:33 -0500 Subject: [PATCH] CAM: Fix infinite recompute loop when ToolBit properties use expressions A bug in the ToolBit model caused an infinite recompute loop and UI freeze when properties such as Diameter, Flutes, or CuttingEdgeHeight were set to expressions. The visual representation update was being triggered during document recompute, which could recursively trigger further recomputes. This fix defers visual updates by queuing them and processing only after the document recompute completes, using a document observer. The observer is cleaned up after use and on object deletion, preventing memory leaks and repeated recompute cycles. src/Mod/CAM/Path/Tool/toolbit/models/base.py: - ToolBitRecomputeObserver: Document observer class that triggers queued visual updates after recompute completes via slotRecomputedDocument. - _queue_visual_update: Queues a visual update to be processed after document recompute. - _setup_recompute_observer: Registers the document observer for recompute completion. - _process_queued_visual_update: Processes the queued visual update and cleans up the observer. - onChanged: Now queues visual updates instead of calling them directly. - onDelete: Cleans up any pending document observer before object removal. --- src/Mod/CAM/Path/Tool/toolbit/models/base.py | 57 +++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Mod/CAM/Path/Tool/toolbit/models/base.py b/src/Mod/CAM/Path/Tool/toolbit/models/base.py index 7214c92072..1ebba66863 100644 --- a/src/Mod/CAM/Path/Tool/toolbit/models/base.py +++ b/src/Mod/CAM/Path/Tool/toolbit/models/base.py @@ -44,6 +44,24 @@ from ..util import to_json, format_value ToolBitView = LazyLoader("Path.Tool.toolbit.ui.view", globals(), "Path.Tool.toolbit.ui.view") +class ToolBitRecomputeObserver: + """Document observer that triggers queued visual updates after recompute completes.""" + + def __init__(self, toolbit_proxy): + self.toolbit_proxy = toolbit_proxy + + def slotRecomputedDocument(self, doc): + """Called when document recompute is finished.""" + # Only process updates for the correct document + if doc != self.toolbit_proxy.obj.Document: + return + + # Process any queued visual updates + if self.toolbit_proxy and hasattr(self.toolbit_proxy, "_process_queued_visual_update"): + Path.Log.debug("Document recompute finished, processing queued visual update") + self.toolbit_proxy._process_queued_visual_update() + + PropertyGroupShape = "Shape" if False: @@ -559,15 +577,19 @@ class ToolBit(Asset, ABC): new_value = obj.getPropertyByName(prop) Path.Log.debug( f"Shape parameter '{prop}' changed to {new_value}. " - f"Updating visual representation." + f"Queuing visual representation update." ) self._tool_bit_shape.set_parameter(prop, new_value) - self._update_visual_representation() + self._queue_visual_update() finally: self._in_update = False def onDelete(self, obj, arg2=None): Path.Log.track(obj.Label) + # Clean up any pending observer + if hasattr(self, "_recompute_observer"): + FreeCAD.removeDocumentObserver(self._recompute_observer) + del self._recompute_observer self._removeBitBody() obj.Document.removeObject(obj.Name) @@ -761,6 +783,37 @@ class ToolBit(Asset, ABC): if material_value in ("HSS", "Carbide") and self.obj.Material != material_value: PathUtil.setProperty(self.obj, "Material", material_value) + def _queue_visual_update(self): + """Queue a visual update to be processed after document recompute is complete.""" + if not hasattr(self, "_visual_update_queued"): + self._visual_update_queued = False + + if not self._visual_update_queued: + self._visual_update_queued = True + Path.Log.debug(f"Queuing visual update for {self.obj.Label}") + + # Set up a document observer to process the update after recompute + self._setup_recompute_observer() + + def _setup_recompute_observer(self): + """Set up a document observer to process queued visual updates after recompute.""" + if not hasattr(self, "_recompute_observer"): + Path.Log.debug(f"Setting up recompute observer for {self.obj.Label}") + self._recompute_observer = ToolBitRecomputeObserver(self) + FreeCAD.addDocumentObserver(self._recompute_observer) + + def _process_queued_visual_update(self): + """Process the queued visual update.""" + if hasattr(self, "_visual_update_queued") and self._visual_update_queued: + self._visual_update_queued = False + Path.Log.debug(f"Processing queued visual update for {self.obj.Label}") + self._update_visual_representation() + + # Clean up the observer + if hasattr(self, "_recompute_observer"): + FreeCAD.removeDocumentObserver(self._recompute_observer) + del self._recompute_observer + def _update_visual_representation(self): """ Updates the visual representation of the tool bit based on the current