| … | … | @@ -55,6 +55,8 @@ def _poll_timer(): |
| 55 | 55 | scene = bpy.context.scene |
| 56 | 56 | if hasattr(scene, "bridge_clear_on_import") and scene.bridge_clear_on_import: |
| 57 | 57 | _clear_bridge_objects() |
| 58 | # Reset importer state so IDs don't conflict | |
| 59 | _importer._id_to_name.clear() | |
| 58 | 60 | _importer.full_import() |
| 59 | 61 | if _detector is not None: |
| 60 | 62 | _detector.set_id_map(_importer._id_to_name) |
| … | … | @@ -192,8 +194,20 @@ class BRIDGE_OT_import_scene(bpy.types.Operator): |
| 192 | 194 | def _clear_bridge_objects(): |
| 193 | 195 | """Remove all objects previously imported by the bridge.""" |
| 194 | 196 | to_remove = [obj for obj in bpy.data.objects if obj.name.startswith("bridge_")] |
| 197 | if to_remove: | |
| 198 | print(f"BlenderBridge: Clearing {len(to_remove)} bridge objects") | |
| 195 | 199 | for obj in to_remove: |
| 200 | # Store data reference before removing object | |
| 201 | data = obj.data | |
| 196 | 202 | bpy.data.objects.remove(obj, do_unlink=True) |
| 203 | # Clean up orphaned data blocks (meshes, cameras, lights) | |
| 204 | if data is not None and data.users == 0: | |
| 205 | if isinstance(data, bpy.types.Mesh): | |
| 206 | bpy.data.meshes.remove(data) | |
| 207 | elif isinstance(data, bpy.types.Camera): | |
| 208 | bpy.data.cameras.remove(data) | |
| 209 | elif isinstance(data, bpy.types.Light): | |
| 210 | bpy.data.lights.remove(data) | |
| 197 | 211 | |
| 198 | 212 | |
| 199 | 213 | # ── UI Panel ───────────────────────────────────────────────────────────────── |
| … | … | @@ -54,6 +54,8 @@ class ChangeDetector: |
| 54 | 54 | self._registered = False |
| 55 | 55 | # Suppress change detection during imports (prevent feedback loop) |
| 56 | 56 | self._suppressed = False |
| 57 | # Skip N depsgraph callbacks after unsuppress to absorb echo | |
| 58 | self._skip_frames = 0 | |
| 57 | 59 | |
| 58 | 60 | def register(self): |
| 59 | 61 | """Register the depsgraph handler.""" |
| … | … | @@ -92,14 +94,25 @@ class ChangeDetector: |
| 92 | 94 | self._suppressed = True |
| 93 | 95 | |
| 94 | 96 | def unsuppress(self): |
| 95 | """Re-enable change detection after import.""" | |
| 97 | """Re-enable change detection after import. | |
| 98 | ||
| 99 | Skips the next 5 depsgraph callbacks to absorb the echo from | |
| 100 | the import — Blender's depsgraph fires asynchronously after | |
| 101 | objects are modified, and those updates would loop back. | |
| 102 | """ | |
| 96 | 103 | self._suppressed = False |
| 104 | self._skip_frames = 5 | |
| 97 | 105 | |
| 98 | 106 | def _on_depsgraph_update(self, scene, depsgraph): |
| 99 | 107 | """Called after every depsgraph evaluation.""" |
| 100 | 108 | if self._suppressed: |
| 101 | 109 | return |
| 102 | 110 | |
| 111 | # Skip N callbacks after unsuppress to absorb depsgraph echo | |
| 112 | if self._skip_frames > 0: | |
| 113 | self._skip_frames -= 1 | |
| 114 | return | |
| 115 | ||
| 103 | 116 | # Skip while any object is in edit mode — accessing mesh data crashes |
| 104 | 117 | if bpy.context.mode != "OBJECT": |
| 105 | 118 | return |
| … | … | @@ -377,6 +377,9 @@ Error BlenderBridge::export_scene(Node *root) { |
| 377 | 377 | hdr.frame_number++; |
| 378 | 378 | write_header(_shm.ptr(), hdr); |
| 379 | 379 | |
| 380 | // Cooldown: ignore Blender's depsgraph echo for 500ms | |
| 381 | _import_cooldown = 0.5; | |
| 382 | ||
| 380 | 383 | // Signal Blender via UDS |
| 381 | 384 | if (_uds.client_count() > 0) { |
| 382 | 385 | const char *msg = "scene_exported"; |
| … | … | @@ -419,6 +422,11 @@ void BlenderBridge::import_changes(Node *root) { |
| 419 | 422 | if (!_shm.valid()) |
| 420 | 423 | return; |
| 421 | 424 | |
| 425 | // Skip during cooldown — prevents feedback loop from Blender's | |
| 426 | // depsgraph echo after we just exported | |
| 427 | if (_import_cooldown > 0.0) | |
| 428 | return; | |
| 429 | ||
| 422 | 430 | BridgeHeader hdr = read_header(_shm.ptr()); |
| 423 | 431 | |
| 424 | 432 | // Only process blender→godot updates |
| … | … | @@ -586,6 +594,11 @@ void BlenderBridge::_process(double delta) { |
| 586 | 594 | _handle_uds_message(msg); |
| 587 | 595 | } |
| 588 | 596 | |
| 597 | // Tick down import cooldown | |
| 598 | if (_import_cooldown > 0.0) { | |
| 599 | _import_cooldown -= delta; | |
| 600 | } | |
| 601 | ||
| 589 | 602 | // Auto-sync: check for incoming changes and push outgoing changes |
| 590 | 603 | if (_auto_sync && _shm.valid()) { |
| 591 | 604 | Node *root = get_tree() ? get_tree()->get_root() : nullptr; |
| … | … | @@ -731,6 +744,9 @@ void BlenderBridge::_detect_and_push_changes() { |
| 731 | 744 | hdr.frame_number++; |
| 732 | 745 | write_header(_shm.ptr(), hdr); |
| 733 | 746 | |
| 747 | // Cooldown: ignore Blender's depsgraph echo for 200ms | |
| 748 | _import_cooldown = 0.2; | |
| 749 | ||
| 734 | 750 | // Signal Blender |
| 735 | 751 | if (_uds.client_count() > 0) { |
| 736 | 752 | const char *msg = "transform_update"; |
| … | … | @@ -86,6 +86,11 @@ private: |
| 86 | 86 | uint64_t _export_sequence = 0; |
| 87 | 87 | uint64_t _last_import_sequence = 0; |
| 88 | 88 | |
| 89 | // ── Feedback loop prevention ──────────────────────────────────────── | |
| 90 | // After export, ignore incoming changes for a brief period to prevent | |
| 91 | // Blender's depsgraph echo from corrupting the Godot scene. | |
| 92 | double _import_cooldown = 0.0; | |
| 93 | ||
| 89 | 94 | // ── Object ID mapping ─────────────────────────────────────────────── |
| 90 | 95 | struct TrackedObject { |
| 91 | 96 | uint32_t object_id; |