| … | … | @@ -79,9 +79,11 @@ class ChangeDetector: |
| 79 | 79 | self._pending: dict[str, int] = {} |
| 80 | 80 | # Cache of depsgraph for mesh extraction during flush |
| 81 | 81 | self._last_depsgraph = None |
| 82 | | # Feedback loop prevention: skip N depsgraph callbacks after applying |
| 83 | | # incoming transforms from Godot (poll_updates triggers depsgraph_update_post) |
| 84 | | self._skip_frames = 0 |
| 82 | # Feedback loop prevention: per-object suppression after applying |
| 83 | # incoming Godot transforms. Only suppresses echoes for the specific |
| 84 | # objects that Godot sent, allowing modifier-dependent updates through. |
| 85 | # Dict: object_name → remaining ticks |
| 86 | self._suppressed_objects: dict[str, int] = {} |
| 85 | 87 | |
| 86 | 88 | def register(self): |
| 87 | 89 | """Register the depsgraph handler.""" |
| … | … | @@ -100,18 +102,28 @@ class ChangeDetector: |
| 100 | 102 | pass |
| 101 | 103 | self._registered = False |
| 102 | 104 | |
| 103 | | def suppress(self, frames: int = 5): |
| 104 | | """Suppress depsgraph change detection for N timer ticks. |
| 105 | def suppress(self, names: set[str], frames: int = 5): |
| 106 | """Suppress depsgraph echoes for specific objects. |
| 105 | 107 | |
| 106 | | Called after applying incoming Godot transforms to prevent the |
| 107 | | depsgraph echo from being flushed back as a spurious update. |
| 108 | Only the named objects are suppressed. Other objects (e.g. those |
| 109 | with modifiers that depend on the moved object) still get their |
| 110 | updates captured and flushed to Godot. |
| 108 | 111 | """ |
| 109 | | self._skip_frames = max(self._skip_frames, frames) |
| 112 | for name in names: |
| 113 | self._suppressed_objects[name] = max( |
| 114 | self._suppressed_objects.get(name, 0), frames |
| 115 | ) |
| 110 | 116 | |
| 111 | 117 | def unsuppress(self): |
| 112 | | """Clear suppression (called each timer tick to decrement).""" |
| 113 | | if self._skip_frames > 0: |
| 114 | | self._skip_frames -= 1 |
| 118 | """Decrement suppression counters (called each timer tick).""" |
| 119 | expired = [] |
| 120 | for name, ticks in self._suppressed_objects.items(): |
| 121 | if ticks <= 1: |
| 122 | expired.append(name) |
| 123 | else: |
| 124 | self._suppressed_objects[name] = ticks - 1 |
| 125 | for name in expired: |
| 126 | del self._suppressed_objects[name] |
| 115 | 127 | |
| 116 | 128 | def set_id_map(self, id_map: dict[int, str]): |
| 117 | 129 | """Set the object ID mapping from the scene importer (reversed).""" |
| … | … | @@ -136,16 +148,17 @@ class ChangeDetector: |
| 136 | 148 | """Called after every depsgraph evaluation. Accumulates changes.""" |
| 137 | 149 | self._last_depsgraph = depsgraph |
| 138 | 150 | |
| 139 | | # Skip depsgraph changes during suppression (feedback loop prevention) |
| 140 | | if self._skip_frames > 0: |
| 141 | | return |
| 142 | | |
| 143 | 151 | for update in depsgraph.updates: |
| 144 | 152 | obj = update.id |
| 145 | 153 | if not isinstance(obj, bpy.types.Object): |
| 146 | 154 | continue |
| 147 | 155 | |
| 148 | 156 | name = obj.name |
| 157 | |
| 158 | # Skip echoes for objects that Godot just told us about |
| 159 | if name in self._suppressed_objects: |
| 160 | continue |
| 161 | |
| 149 | 162 | existing = self._pending.get(name, -1) |
| 150 | 163 | |
| 151 | 164 | if update.is_updated_geometry: |