OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / addons / blender_bridge / plugin.gd

249 lines · gd ref:
@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