@tool
extends EditorPlugin
## The bridge, owned by the EDITOR rather than by a scene.
##
## ⛔ WHY THIS EXISTS. As a node inside a scene, the bridge stops the instant the
## editor opens another one: EditorNode::_set_current_scene_nocheck does
##
## scene_root->remove_child(old_scene)
##
## and a node outside the tree gets no _process. So "open the bound scene and
## sync into it" killed the very thing doing the syncing. A plugin lives in the
## editor's own tree and outlives every scene switch.
##
## The consequence is that "the scene" is no longer the one the bridge was
## exported from -- it is whichever scene is being edited, which is what
## follow_edited_scene makes the C++ side read.
var bridge: Node = null
var _dock: VBoxContainer = null
var _label: Label = null
var _blend_edit: LineEdit = null
var _socket := "/tmp/blender_bridge.sock"
## OPEN IN BLENDER — the Godot -> Blender direction (2026-10-04). The bridge could
## follow a .blend into a .tscn but not start from a scene: nothing knew where
## Blender was or made the file. Both are EDITOR SETTINGS (user: "both the binary
## path and the blender file path should be configurable in the editor"); each scene
## may also name its own .blend, kept on its root as metadata/bb_blend_path — the
## mirror of godot_scene_path on the Blender side.
const SETTING_BINARY := "blender_bridge/blender_binary"
const SETTING_BLEND_DIR := "blender_bridge/blend_dir"
const DEFAULT_BINARY := "/home/fleeky/3d-prog/blender/blender-5.2.0-linux-x64/blender"
const DEFAULT_BLEND_DIR := "/storage/projects/3d/procedural-terrain"
const META_BLEND := "bb_blend_path"
const ADDON_MODULE := "bl_ext.user_default.blender_godot_bridge"
func _enter_tree() -> void:
if not ClassDB.class_exists("BlenderBridge"):
push_error("Blender Bridge: the GDExtension is not loaded, so there is "
+ "nothing to run. Check bin/blender_bridge.gdextension.")
return
bridge = ClassDB.instantiate("BlenderBridge")
bridge.name = "BlenderBridge"
# Imports land in the scene the user is looking at, not in a scene this
# plugin owns -- the plugin owns no scene.
bridge.follow_edited_scene = true
add_child(bridge)
# ⚠ A PRIVATE SOCKET WHEN ASKED. The default is the one every Blender looks
# for, so an editor opened to test something would quietly capture whatever
# Blender session the user had running. BLENDER_BRIDGE_SOCKET keeps a test
# instance out of the way; unset, the behaviour is unchanged.
var sock := OS.get_environment("BLENDER_BRIDGE_SOCKET")
if sock.is_empty():
bridge.start_server()
else:
bridge.set_shm_name(OS.get_environment("BLENDER_BRIDGE_SHM"))
bridge.start_server(sock)
_socket = sock
print("[BB] plugin on a private socket: ", sock)
_ensure_settings()
# ⚠ ON, as it was when the bridge lived in a scene. In a Blender-led session
# structure only flows one way, but the CORRECTIONS still come back -- a
# transform, a parameter delta -- and with this off the log says
# "outgoing push SKIPPED: auto_sync is OFF" and nothing returns.
bridge.auto_sync = true
print("[BB] plugin up; the bridge now survives scene switches")
# ⚠ DEFERRED. Added during _enter_tree the control goes in before the
# editor restores its saved dock layout, and the restore drops it.
_build_dock.call_deferred()
## A dock, because the two things worth saying -- WHICH scene is the target and
## WHICH way this session runs -- have nowhere else to live. The bridge is not
## in a scene any more, so the inspector cannot show them.
func _build_dock() -> void:
_dock = VBoxContainer.new()
_dock.name = "Blender Bridge"
_label = Label.new()
_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_dock.add_child(_label)
var to_blender := Button.new()
to_blender.text = "Sync this scene → Blender"
to_blender.tooltip_text = ("Godot leads: the scene being edited becomes the source "
+ "and is pushed into the open .blend, which is expected to be empty.")
to_blender.pressed.connect(_on_sync_to_blender)
_dock.add_child(to_blender)
# THIS scene's .blend: the default is <blend_dir>/<scene name>.blend; editing the
# field stores it on the scene (metadata/bb_blend_path).
var row := HBoxContainer.new()
var bl := Label.new()
bl.text = ".blend"
row.add_child(bl)
_blend_edit = LineEdit.new()
_blend_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_blend_edit.tooltip_text = "This scene's .blend (stored on the scene). Default: <%s>/<scene>.blend" % SETTING_BLEND_DIR
_blend_edit.text_submitted.connect(func(t: String) -> void: _set_scene_blend(t))
row.add_child(_blend_edit)
_dock.add_child(row)
var open := Button.new()
open.text = "Open in Blender"
open.tooltip_text = ("Start Blender (Editor Settings > %s) on this scene's .blend, creating "
+ "it if needed. Whichever of the two files changed LAST leads the session.") % SETTING_BINARY
open.pressed.connect(_on_open_in_blender)
_dock.add_child(open)
var target := Button.new()
target.text = "Make this scene the sync target"
target.tooltip_text = ("Blender keeps leading; this only says WHICH open scene "
+ "its objects land in.")
target.pressed.connect(_on_set_target)
_dock.add_child(target)
add_control_to_dock(DOCK_SLOT_RIGHT_UL, _dock)
# The .blend field shows the EDITED scene's file, so it follows every tab switch.
scene_changed.connect(func(_root: Node) -> void: _refresh())
_refresh()
func _on_sync_to_blender() -> void:
var why: String = bridge.sync_scene_to_blender()
if why != "":
push_warning("Blender Bridge: " + why)
_refresh()
func _on_set_target() -> void:
var root := EditorInterface.get_edited_scene_root()
if root == null or root.scene_file_path == "":
push_warning("Blender Bridge: save the scene first — an unsaved scene has no path to bind to")
return
bridge.sync_target = root.scene_file_path
_refresh()
func _refresh() -> void:
if _label == null or bridge == null:
return
var t: String = bridge.sync_target
_label.text = ("target: %s\ndirection: %s\nblender: %s"
% [t if t != "" else "(none — whatever tab is in front)",
"Blender leads" if bridge.sync_direction == 0 else "Godot leads",
_setting(SETTING_BINARY, DEFAULT_BINARY)])
var root := EditorInterface.get_edited_scene_root()
if _blend_edit != null:
_blend_edit.text = blend_path_for(root) if root != null else ""
# ---- Open in Blender -------------------------------------------------------------
func _ensure_settings() -> void:
var es := EditorInterface.get_editor_settings()
for spec: Array in [[SETTING_BINARY, DEFAULT_BINARY, PROPERTY_HINT_GLOBAL_FILE],
[SETTING_BLEND_DIR, DEFAULT_BLEND_DIR, PROPERTY_HINT_GLOBAL_DIR]]:
if not es.has_setting(spec[0]):
es.set_setting(spec[0], spec[1])
es.set_initial_value(spec[0], spec[1], false)
es.add_property_info({"name": spec[0], "type": TYPE_STRING, "hint": spec[2]})
func _setting(name: String, fallback: String) -> String:
var es := EditorInterface.get_editor_settings()
return String(es.get_setting(name)) if es.has_setting(name) else fallback
## The .blend a scene pairs with: its own metadata/bb_blend_path, else
## <blend_dir>/<scene file name>.blend.
func blend_path_for(root: Node) -> String:
if root == null:
return ""
if root.has_meta(META_BLEND) and String(root.get_meta(META_BLEND)) != "":
return String(root.get_meta(META_BLEND))
var base := root.scene_file_path.get_file().get_basename() if root.scene_file_path != "" else String(root.name)
return _setting(SETTING_BLEND_DIR, DEFAULT_BLEND_DIR).path_join(base + ".blend")
func _set_scene_blend(path: String) -> void:
var root := EditorInterface.get_edited_scene_root()
if root == null:
return
root.set_meta(META_BLEND, path.strip_edges())
EditorInterface.mark_scene_as_unsaved()
_refresh()
## Start Blender on the edited scene's .blend. WHICHEVER FILE CHANGED LAST LEADS:
## no .blend, or the scene newer than it -> Godot leads (the bridge pushes the scene
## into that Blender when it binds, expect_godot_led); the .blend newer -> Blender
## leads, as a Blender-started session always has. Returns "" or why not.
func open_in_blender(root: Node = null) -> String:
if root == null:
root = EditorInterface.get_edited_scene_root()
if root == null or root.scene_file_path == "":
return "save the scene first — an unsaved scene has no path to pair with"
var binary := _setting(SETTING_BINARY, DEFAULT_BINARY)
if not FileAccess.file_exists(binary):
return "no Blender at %s — set Editor Settings > %s" % [binary, SETTING_BINARY]
var blend := blend_path_for(root)
var scene_abs := ProjectSettings.globalize_path(root.scene_file_path)
var new_file := not FileAccess.file_exists(blend)
var godot_leads := new_file or FileAccess.get_modified_time(scene_abs) > FileAccess.get_modified_time(blend)
if godot_leads:
bridge.expect_godot_led(root.scene_file_path, root)
var expr := ("import bpy; bpy.ops.preferences.addon_enable(module=%s); import %s as bb; "
+ "bb.godot_launch(%s, %s, %s, %s, %s)") % [
JSON.stringify(ADDON_MODULE), ADDON_MODULE, JSON.stringify(_socket),
JSON.stringify(String(bridge.get_shm_name())), JSON.stringify(root.scene_file_path),
JSON.stringify(blend), "True" if new_file else "False"]
var args: PackedStringArray = []
var extra := OS.get_environment("BLENDER_BRIDGE_EXTRA_ARGS") # a gate: "--background"
if extra != "":
args.append_array(extra.split(" ", false))
if not new_file:
args.append(blend)
args.append_array(["--python-expr", expr])
var pid := OS.create_process(binary, args)
if pid <= 0:
return "could not start %s" % binary
print("[BB] Open in Blender: %s (%s, %s leads) pid %d" % [blend, "new file" if new_file else "existing",
"Godot" if godot_leads else "Blender", pid])
return ""
func _on_open_in_blender() -> void:
var why := open_in_blender()
if why != "":
push_warning("Blender Bridge: " + why)
_refresh()
func _exit_tree() -> void:
if _dock != null:
remove_control_from_docks(_dock)
_dock.queue_free()
_dock = null
if bridge != null:
bridge.stop_server()
bridge.queue_free()
bridge = null
## The bridge itself, for a script or the console:
## EditorInterface.get_editor_plugin("Blender Bridge") # 4.x has no such
## getter, so reach it the plain way instead:
## get_tree().root.find_child("BlenderBridge", true, false)
func get_bridge() -> Node:
return bridge