OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge / src / blender_bridge.cpp

1722 lines · cpp ref:
#include "blender_bridge.h"
#include "virtual_texture.h"

#include <algorithm>
#include <cstdio>
#include <string>

#include <godot_cpp/classes/camera3d.hpp>
#include <godot_cpp/classes/dir_access.hpp>
#include <godot_cpp/classes/directional_light3d.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/light3d.hpp>
#include <godot_cpp/classes/omni_light3d.hpp>
#include <godot_cpp/classes/packed_scene.hpp>
#include <godot_cpp/classes/resource_loader.hpp>
#include <godot_cpp/classes/resource_saver.hpp>
#include <godot_cpp/classes/scene_tree.hpp>
#include <godot_cpp/classes/spot_light3d.hpp>
#include <godot_cpp/classes/window.hpp>
#include <godot_cpp/variant/utility_functions.hpp>

using namespace godot;

// ── Data layout in blob region ────────────────────────────────────────────────
// Per object: [name prefix] [4×4 transform (64 bytes)] [optional vertex data]
// Name prefix: [uint8 name_len] [name_bytes] [padding to 4-byte align]
// Vertex data: [uint32 vert_count] [float32×3 × vert_count positions]
//              [uint32 has_normals] [float32×3 × vert_count normals]
//              [uint32 idx_count] [int32 × idx_count indices]
//              [uint32 smooth_count] [uint8 × smooth_count flags]  (0=flat, 1=smooth)
//              [uint32 uv_count] [float32×2 × uv_count uvs]
//              [uint32 has_material] [44 bytes material data if has_material]

static constexpr uint32_t TRANSFORM_SIZE = 64; // 4×4 float matrix

BlenderBridge::BlenderBridge() {
}

BlenderBridge::~BlenderBridge() {
	stop_server();
}

// ── Bind methods ──────────────────────────────────────────────────────────────

void BlenderBridge::_bind_methods() {
	// Server lifecycle
	ClassDB::bind_method(D_METHOD("start_server", "socket_path"), &BlenderBridge::start_server, DEFVAL("/tmp/blender_bridge.sock"));
	ClassDB::bind_method(D_METHOD("stop_server"), &BlenderBridge::stop_server);
	ClassDB::bind_method(D_METHOD("is_connected"), &BlenderBridge::is_connected);
	ClassDB::bind_method(D_METHOD("get_client_count"), &BlenderBridge::get_client_count);

	// Scene export/import
	ClassDB::bind_method(D_METHOD("export_scene", "root"), &BlenderBridge::export_scene);
	ClassDB::bind_method(D_METHOD("get_export_info"), &BlenderBridge::get_export_info);
	ClassDB::bind_method(D_METHOD("import_changes", "root"), &BlenderBridge::import_changes);

	// Properties
	ClassDB::bind_method(D_METHOD("set_shm_name", "name"), &BlenderBridge::set_shm_name);
	ClassDB::bind_method(D_METHOD("get_shm_name"), &BlenderBridge::get_shm_name);
	ADD_PROPERTY(PropertyInfo(Variant::STRING, "shm_name"), "set_shm_name", "get_shm_name");

	ClassDB::bind_method(D_METHOD("set_shm_size", "size"), &BlenderBridge::set_shm_size);
	ClassDB::bind_method(D_METHOD("get_shm_size"), &BlenderBridge::get_shm_size);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "shm_size"), "set_shm_size", "get_shm_size");

	ClassDB::bind_method(D_METHOD("set_max_objects", "count"), &BlenderBridge::set_max_objects);
	ClassDB::bind_method(D_METHOD("get_max_objects"), &BlenderBridge::get_max_objects);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "max_objects"), "set_max_objects", "get_max_objects");

	ClassDB::bind_method(D_METHOD("set_auto_sync", "enabled"), &BlenderBridge::set_auto_sync);
	ClassDB::bind_method(D_METHOD("get_auto_sync"), &BlenderBridge::get_auto_sync);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "auto_sync"), "set_auto_sync", "get_auto_sync");

	// VirtualTexture integration
	ClassDB::bind_method(D_METHOD("set_virtual_texture_path", "path"), &BlenderBridge::set_virtual_texture_path);
	ClassDB::bind_method(D_METHOD("get_virtual_texture_path"), &BlenderBridge::get_virtual_texture_path);
	ADD_PROPERTY(PropertyInfo(Variant::NODE_PATH, "virtual_texture_path"), "set_virtual_texture_path", "get_virtual_texture_path");

	ClassDB::bind_method(D_METHOD("notify_texture_dirty", "texture_id", "tile_x", "tile_y", "w", "h", "mip"), &BlenderBridge::notify_texture_dirty);
	ClassDB::bind_method(D_METHOD("finalize_scene", "root", "save_dir"), &BlenderBridge::finalize_scene);

	// Signals
	ADD_SIGNAL(MethodInfo("client_connected", PropertyInfo(Variant::INT, "client_id")));
	ADD_SIGNAL(MethodInfo("client_disconnected", PropertyInfo(Variant::INT, "client_id")));
	ADD_SIGNAL(MethodInfo("scene_exported", PropertyInfo(Variant::INT, "object_count")));
	ADD_SIGNAL(MethodInfo("changes_imported", PropertyInfo(Variant::INT, "object_count")));
	ADD_SIGNAL(MethodInfo("texture_dirty",
			PropertyInfo(Variant::INT, "texture_id"),
			PropertyInfo(Variant::INT, "tile_x"),
			PropertyInfo(Variant::INT, "tile_y"),
			PropertyInfo(Variant::INT, "w"),
			PropertyInfo(Variant::INT, "h"),
			PropertyInfo(Variant::INT, "mip")));
}

// ── Properties ────────────────────────────────────────────────────────────────

void BlenderBridge::set_shm_name(const String &name) { _shm_name = name; }
String BlenderBridge::get_shm_name() const { return _shm_name; }
void BlenderBridge::set_shm_size(int size) { _shm_size = size; }
int BlenderBridge::get_shm_size() const { return _shm_size; }
void BlenderBridge::set_max_objects(int count) { _max_objects = count; }
int BlenderBridge::get_max_objects() const { return _max_objects; }
void BlenderBridge::set_auto_sync(bool enabled) { _auto_sync = enabled; }
bool BlenderBridge::get_auto_sync() const { return _auto_sync; }
void BlenderBridge::set_virtual_texture_path(const NodePath &path) { _virtual_texture_path = path; }
NodePath BlenderBridge::get_virtual_texture_path() const { return _virtual_texture_path; }

// ── VirtualTexture integration ────────────────────────────────────────────────

void BlenderBridge::notify_texture_dirty(int texture_id, int tile_x, int tile_y, int w, int h, int mip) {
	// Forward to VirtualTexture node if configured
	if (!_virtual_texture_path.is_empty()) {
		Node *vt_node = get_node_or_null(_virtual_texture_path);
		VirtualTexture *vt = Object::cast_to<VirtualTexture>(vt_node);
		if (vt) {
			vt->invalidate_region(texture_id, tile_x, tile_y, w, h, mip);
		}
	}

	emit_signal("texture_dirty", texture_id, tile_x, tile_y, w, h, mip);
}

// ── Finalize (save textures + scene to disk) ──────────────────────────────────

Error BlenderBridge::finalize_scene(Node *root, const String &save_dir) {
	if (_virtual_texture_path.is_empty())
		return ERR_UNCONFIGURED;

	Node *vt_node = get_node_or_null(_virtual_texture_path);
	VirtualTexture *vt = Object::cast_to<VirtualTexture>(vt_node);
	if (!vt)
		return ERR_UNCONFIGURED;

	// Step 1: Save each reconstructed texture as .png
	TypedArray<Dictionary> textures = vt->get_all_textures();
	HashMap<int, String> tex_paths; // svt_id → saved .png path

	for (int i = 0; i < textures.size(); i++) {
		Dictionary tex = textures[i];
		int tex_id = tex["id"];
		String tex_name = tex["name"];
		bool has_image = tex["has_image"];

		if (!has_image)
			continue;

		Ref<Image> img = vt->get_reconstructed_image(tex_id);
		if (img.is_null())
			continue;

		// Sanitize name for filesystem
		String safe_name = tex_name.replace(" ", "_").replace("/", "_");
		String png_path = save_dir.path_join(safe_name + ".png");
		Error err = img->save_png(png_path);
		if (err == OK) {
			tex_paths[tex_id] = png_path;
			UtilityFunctions::print("Finalize: Saved ", png_path);
		}
	}

	// Step 2: Convert SVT ShaderMaterials to StandardMaterial3D + ImageTexture
	Ref<Shader> svt_shader = vt->get_svt_shader();
	std::vector<Node *> stack;
	stack.push_back(root);
	while (!stack.empty()) {
		Node *node = stack.back();
		stack.pop_back();

		MeshInstance3D *mesh_inst = Object::cast_to<MeshInstance3D>(node);
		if (mesh_inst) {
			for (int s = 0; s < mesh_inst->get_surface_override_material_count(); s++) {
				Ref<Material> mat = mesh_inst->get_surface_override_material(s);
				Ref<ShaderMaterial> shader_mat = mat;
				if (shader_mat.is_valid() && svt_shader.is_valid() && shader_mat->get_shader() == svt_shader) {
					// Extract material properties from SVT shader params
					Color albedo = shader_mat->get_shader_parameter("albedo_color");
					float rough = shader_mat->get_shader_parameter("roughness");
					float metal = shader_mat->get_shader_parameter("metallic");

					Ref<StandardMaterial3D> std_mat;
					std_mat.instantiate();
					std_mat->set_albedo(albedo);
					std_mat->set_roughness(rough);
					std_mat->set_metallic(metal);

					// Set first available texture as albedo
					for (const KeyValue<int, String> &pair : tex_paths) {
						Ref<Texture2D> loaded = ResourceLoader::get_singleton()->load(pair.value);
						if (loaded.is_valid()) {
							std_mat->set_texture(StandardMaterial3D::TEXTURE_ALBEDO, loaded);
							break;
						}
					}

					mesh_inst->set_surface_override_material(s, std_mat);
				}
			}
		}

		for (int c = 0; c < node->get_child_count(); c++) {
			stack.push_back(node->get_child(c));
		}
	}

	// Step 3: Save the scene as .tscn
	Ref<PackedScene> packed;
	packed.instantiate();
	packed->pack(root);
	String scene_path = save_dir.path_join("scene.tscn");
	Error err = ResourceSaver::get_singleton()->save(packed, scene_path);
	if (err == OK) {
		UtilityFunctions::print("Finalize: Saved scene to ", scene_path);
	}

	return err;
}

// ── Server lifecycle ──────────────────────────────────────────────────────────

Error BlenderBridge::start_server(const String &socket_path) {
	if (_server_running) {
		return ERR_ALREADY_IN_USE;
	}

	// Create shared memory
	CharString shm_cs = _shm_name.utf8();
	if (!_shm.create(shm_cs.get_data(), _shm_size)) {
		UtilityFunctions::printerr("BlenderBridge: Failed to create shared memory: ", _shm_name);
		return ERR_CANT_CREATE;
	}

	// Set up split-region pointers
	_region_size = REGION_SIZE;
	_write_base = _shm.ptr();                    // Region 0: Godot writes
	_read_base = region_base(_shm.ptr(), 1, _region_size);  // Region 1: Blender writes

	// Initialize BOTH region headers
	BridgeHeader hdr = {};
	hdr.magic[0] = 'B';
	hdr.magic[1] = 'G';
	hdr.magic[2] = 'O';
	hdr.magic[3] = 'D';
	hdr.version = 1;
	hdr.direction = 0;
	hdr.sequence = 0;
	hdr.object_count = 0;
	hdr.frame_number = 0;
	write_header(_write_base, hdr);
	write_header(_read_base, hdr);

	// Start UDS server
	CharString sock_cs = socket_path.utf8();
	if (!_uds.start(sock_cs.get_data())) {
		UtilityFunctions::printerr("BlenderBridge: Failed to start UDS server: ", socket_path);
		_shm.destroy();
		return ERR_CANT_CREATE;
	}

	_server_running = true;
	_export_sequence = 0;
	_last_import_sequence = 0;
	_tracked_objects.clear();
	_known_instance_ids.clear();
	_next_object_id = 1;

	UtilityFunctions::print("BlenderBridge v0.18.2: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")");
	return OK;
}

void BlenderBridge::stop_server() {
	if (!_server_running)
		return;

	_uds.stop();
	_shm.destroy();
	_server_running = false;
	_tracked_objects.clear();
	_known_instance_ids.clear();
	_write_base = nullptr;
	_read_base = nullptr;
	_region_size = 0;

	UtilityFunctions::print("BlenderBridge: Server stopped");
}

bool BlenderBridge::is_connected() const {
	return _server_running && _uds.client_count() > 0;
}

int BlenderBridge::get_client_count() const {
	return _uds.client_count();
}

// ── Scene export (Godot → shared memory) ──────────────────────────────────────

void BlenderBridge::_collect_exportable_nodes(Node *node, std::vector<Node3D *> &out) {
	Node3D *n3d = Object::cast_to<Node3D>(node);
	if (n3d) {
		// Include MeshInstance3D, Camera3D, Light3D
		if (Object::cast_to<MeshInstance3D>(n3d) ||
				Object::cast_to<Camera3D>(n3d) ||
				Object::cast_to<Light3D>(n3d)) {
			out.push_back(n3d);
		}
	}

	for (int i = 0; i < node->get_child_count(); i++) {
		_collect_exportable_nodes(node->get_child(i), out);
	}
}

void BlenderBridge::_export_transform(const Transform3D &xform, float *out) {
	// Godot Transform3D → 4×4 column-major matrix in Blender coordinate space
	// Godot: Y-up (x, y, z) → Blender: Z-up (x, -z, y)
	//
	// Conversion matrix M (applied to both basis columns and origin):
	//   [1,  0,  0]
	//   [0,  0, -1]
	//   [0,  1,  0]
	//
	// Result = M * GodotBasis * M^-1 (for basis), M * origin (for translation)

	const Basis &b = xform.basis;
	const Vector3 &o = xform.origin;

	// Blender expects row-major 4×4 with translation in row 3 (column 3 when transposed).
	// Matrix() constructor takes rows, so we write standard row-major:
	//   [basis_col0.x  basis_col1.x  basis_col2.x  origin.x]
	//   [basis_col0.y  basis_col1.y  basis_col2.y  origin.y]
	//   [basis_col0.z  basis_col1.z  basis_col2.z  origin.z]
	//   [0             0             0             1        ]
	//
	// With coordinate conversion M applied: M * col, M * origin

	// M * B * M^-1 (similarity transform, NOT transposed)
	// Row 0 (Blender X)
	out[0] = b[0][0];
	out[1] = -b[0][2];
	out[2] = b[0][1];
	out[3] = o.x;

	// Row 1 (Blender Y = -Godot Z)
	out[4] = -b[2][0];
	out[5] = b[2][2];
	out[6] = -b[2][1];
	out[7] = -o.z;

	// Row 2 (Blender Z = Godot Y)
	out[8] = b[1][0];
	out[9] = -b[1][2];
	out[10] = b[1][1];
	out[11] = o.y;

	// Row 3
	out[12] = 0.0f;
	out[13] = 0.0f;
	out[14] = 0.0f;
	out[15] = 1.0f;
}

void BlenderBridge::_write_name_prefix(const String &name, uint8_t *blob_base, uint32_t &blob_offset) {
	CharString utf8 = name.utf8();
	uint8_t name_len = (uint8_t)std::min((int)utf8.length(), 255);
	blob_base[blob_offset] = name_len;
	blob_offset += 1;
	if (name_len > 0) {
		memcpy(blob_base + blob_offset, utf8.get_data(), name_len);
		blob_offset += name_len;
	}
	// Pad to 4-byte alignment
	uint32_t total = 1 + name_len;
	uint32_t pad = (4 - (total % 4)) % 4;
	if (pad > 0) {
		memset(blob_base + blob_offset, 0, pad);
		blob_offset += pad;
	}
}

uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob_base, uint32_t &blob_offset) {
	ObjectEntry entry = {};
	entry.object_id = _next_object_id++;
	entry.update_type = UPDATE_FULL;
	entry.data_offset = blob_offset;
	entry.sub_type = 0;

	// Determine type and subtype
	MeshInstance3D *mesh_inst = Object::cast_to<MeshInstance3D>(node);
	Camera3D *camera = Object::cast_to<Camera3D>(node);
	Light3D *light = Object::cast_to<Light3D>(node);
	bool needs_look_compensation = false;

	if (mesh_inst) {
		entry.object_type = OBJ_MESH;
	} else if (camera) {
		entry.object_type = OBJ_CAMERA;
		needs_look_compensation = true;
	} else if (light) {
		entry.object_type = OBJ_LIGHT;
		// Detect specific light type
		if (Object::cast_to<DirectionalLight3D>(node)) {
			entry.sub_type = LIGHT_SUN;
			needs_look_compensation = true;
		} else if (Object::cast_to<SpotLight3D>(node)) {
			entry.sub_type = LIGHT_SPOT;
			needs_look_compensation = true;
		} else {
			entry.sub_type = LIGHT_POINT;
		}
	} else {
		entry.object_type = OBJ_EMPTY;
	}

	// Write object name: [uint8 name_len] [name_bytes] [pad to 4-byte alignment]
	_write_name_prefix(node->get_name(), blob_base, blob_offset);

	// Write transform (always present after name prefix)
	float xform_data[16];
	_export_transform(node->get_global_transform(), xform_data);

	// Camera/directional/spot lights: post-multiply by R_x(90°) to compensate
	// for the -Z_local look direction convention across coordinate systems.
	// Without this, cameras/lights point in the wrong direction in Blender.
	if (needs_look_compensation) {
		// R_x(90°): new col1 = old col2, new col2 = -old col1
		float old1[3] = {xform_data[1], xform_data[5], xform_data[9]};
		float old2[3] = {xform_data[2], xform_data[6], xform_data[10]};
		xform_data[1] = old2[0]; xform_data[5] = old2[1]; xform_data[9] = old2[2];
		xform_data[2] = -old1[0]; xform_data[6] = -old1[1]; xform_data[10] = -old1[2];
	}

	memcpy(blob_base + blob_offset, xform_data, TRANSFORM_SIZE);
	blob_offset += TRANSFORM_SIZE;

	// Write vertex data for meshes
	if (mesh_inst && mesh_inst->get_mesh().is_valid()) {
		Ref<Mesh> mesh = mesh_inst->get_mesh();
		if (mesh->get_surface_count() > 0) {
			Array arrays = mesh->surface_get_arrays(0);
			if (arrays.size() > Mesh::ARRAY_VERTEX) {
				PackedVector3Array positions = arrays[Mesh::ARRAY_VERTEX];
				uint32_t vert_count = positions.size();

				// Write vertex count
				memcpy(blob_base + blob_offset, &vert_count, 4);
				blob_offset += 4;

				// Write positions (converted to Blender coords)
				const Vector3 *pos_r = positions.ptr();
				for (uint32_t v = 0; v < vert_count; v++) {
					float in[3] = { pos_r[v].x, pos_r[v].y, pos_r[v].z };
					float out[3];
					_godot_to_blender_vec3(in, out);
					memcpy(blob_base + blob_offset, out, 12);
					blob_offset += 12;
				}

				// Write normals if present
				bool has_normals = arrays.size() > Mesh::ARRAY_NORMAL && arrays[Mesh::ARRAY_NORMAL].get_type() != Variant::NIL;
				// Write has_normals flag (uint32)
				uint32_t normals_flag = has_normals ? 1 : 0;
				memcpy(blob_base + blob_offset, &normals_flag, 4);
				blob_offset += 4;

				if (has_normals) {
					PackedVector3Array normals = arrays[Mesh::ARRAY_NORMAL];
					const Vector3 *norm_r = normals.ptr();
					for (uint32_t v = 0; v < vert_count; v++) {
						float in[3] = { norm_r[v].x, norm_r[v].y, norm_r[v].z };
						float out[3];
						_godot_to_blender_vec3(in, out);
						memcpy(blob_base + blob_offset, out, 12);
						blob_offset += 12;
					}
				}

				// Write index buffer if present
				bool has_indices = arrays.size() > Mesh::ARRAY_INDEX && arrays[Mesh::ARRAY_INDEX].get_type() != Variant::NIL;
				uint32_t idx_count = 0;
				if (has_indices) {
					PackedInt32Array indices = arrays[Mesh::ARRAY_INDEX];
					idx_count = indices.size();
				}
				memcpy(blob_base + blob_offset, &idx_count, 4);
				blob_offset += 4;

				if (idx_count > 0) {
					PackedInt32Array indices = arrays[Mesh::ARRAY_INDEX];
					const int32_t *idx_r = indices.ptr();
					memcpy(blob_base + blob_offset, idx_r, idx_count * 4);
					blob_offset += idx_count * 4;
				}

				// Write per-triangle smooth flags (0=flat, 1=smooth)
				uint32_t tri_count = idx_count / 3;
				memcpy(blob_base + blob_offset, &tri_count, 4);
				blob_offset += 4;

				if (tri_count > 0) {
					if (has_normals && has_indices) {
						PackedVector3Array norm_arr = arrays[Mesh::ARRAY_NORMAL];
						const Vector3 *nr = norm_arr.ptr();
						PackedInt32Array idx_arr = arrays[Mesh::ARRAY_INDEX];
						const int32_t *ir = idx_arr.ptr();

						for (uint32_t t = 0; t < tri_count; t++) {
							int32_t i0 = ir[t * 3], i1 = ir[t * 3 + 1], i2 = ir[t * 3 + 2];
							Vector3 fn = (pos_r[i1] - pos_r[i0]).cross(pos_r[i2] - pos_r[i0]);
							float len_sq = fn.length_squared();

							uint8_t is_smooth = 1;
							if (len_sq > 1e-12f) {
								fn /= Math::sqrt(len_sq);
								if (nr[i0].dot(fn) > 0.999f &&
										nr[i1].dot(fn) > 0.999f &&
										nr[i2].dot(fn) > 0.999f) {
									is_smooth = 0;
								}
							}
							blob_base[blob_offset++] = is_smooth;
						}
					} else {
						// No normals or no indices — assume all smooth
						memset(blob_base + blob_offset, 1, tri_count);
						blob_offset += tri_count;
					}
				}
			}

			// Write UV coordinates (float32×2 per vertex)
			bool has_uvs = arrays.size() > Mesh::ARRAY_TEX_UV && arrays[Mesh::ARRAY_TEX_UV].get_type() != Variant::NIL;
			uint32_t uv_count = 0;
			if (has_uvs) {
				PackedVector2Array uvs = arrays[Mesh::ARRAY_TEX_UV];
				uv_count = uvs.size();
			}
			memcpy(blob_base + blob_offset, &uv_count, 4);
			blob_offset += 4;

			if (uv_count > 0) {
				PackedVector2Array uvs = arrays[Mesh::ARRAY_TEX_UV];
				const Vector2 *uv_r = uvs.ptr();
				for (uint32_t v = 0; v < uv_count; v++) {
					float uv[2] = { uv_r[v].x, uv_r[v].y };
					memcpy(blob_base + blob_offset, uv, 8);
					blob_offset += 8;
				}
			}
		}
	}

	// Write material data (only for meshes)
	if (entry.object_type == OBJ_MESH && mesh_inst) {
		Ref<Material> mat_ref = mesh_inst->get_surface_override_material(0);
		if (!mat_ref.is_valid() && mesh_inst->get_mesh().is_valid() && mesh_inst->get_mesh()->get_surface_count() > 0) {
			mat_ref = mesh_inst->get_mesh()->surface_get_material(0);
		}
		Ref<StandardMaterial3D> std_mat = Object::cast_to<StandardMaterial3D>(mat_ref.ptr());
		if (std_mat.is_valid()) {
			uint32_t has_material = 1;
			memcpy(blob_base + blob_offset, &has_material, 4);
			blob_offset += 4;
			Color albedo = std_mat->get_albedo();
			float mat_data[11];
			mat_data[0] = albedo.r; mat_data[1] = albedo.g; mat_data[2] = albedo.b; mat_data[3] = albedo.a;
			mat_data[4] = std_mat->get_roughness();
			mat_data[5] = std_mat->get_metallic();
			Color emission = std_mat->get_emission();
			mat_data[6] = emission.r; mat_data[7] = emission.g; mat_data[8] = emission.b; mat_data[9] = emission.a;
			mat_data[10] = std_mat->get_emission_energy_multiplier();
			memcpy(blob_base + blob_offset, mat_data, 44);
			blob_offset += 44;
			uint32_t tex_id = 0; // Godot-side meshes don't carry bridge texture IDs
			memcpy(blob_base + blob_offset, &tex_id, 4);
			blob_offset += 4;
		} else {
			uint32_t has_material = 0;
			memcpy(blob_base + blob_offset, &has_material, 4);
			blob_offset += 4;
		}
	} else if (entry.object_type == OBJ_MESH) {
		uint32_t has_material = 0;
		memcpy(blob_base + blob_offset, &has_material, 4);
		blob_offset += 4;
	}

	entry.data_size = blob_offset - entry.data_offset;
	entry.sequence = _export_sequence;
	write_object_entry(_write_base, index, entry);

	// Track the object with initial transform hash
	TrackedObject tracked;
	tracked.object_id = entry.object_id;
	tracked.godot_instance_id = node->get_instance_id();
	tracked.name = node->get_name();
	tracked.type = (ObjectType)entry.object_type;
	tracked.transform_hash = _hash_transform(node->get_global_transform());
	tracked.from_blender = false;
	_tracked_objects.push_back(tracked);

	return entry.object_id;
}

Error BlenderBridge::export_scene(Node *root) {
	if (!_server_running || !_shm.valid()) {
		return ERR_UNCONFIGURED;
	}

	// Collect exportable nodes
	std::vector<Node3D *> nodes;
	_collect_exportable_nodes(root, nodes);

	if (nodes.size() > (size_t)_max_objects) {
		UtilityFunctions::printerr("BlenderBridge: Too many objects (", (int)nodes.size(), " > ", _max_objects, ")");
		return ERR_OUT_OF_MEMORY;
	}

	// Clear only Godot-origin tracked objects (preserve Blender-origin ones)
	_tracked_objects.erase(
		std::remove_if(_tracked_objects.begin(), _tracked_objects.end(),
			[](const TrackedObject &t) { return !t.from_blender; }),
		_tracked_objects.end());
	_next_object_id = 1;
	// Ensure IDs don't collide with Blender-origin objects
	for (const auto &t : _tracked_objects) {
		if (t.object_id >= _next_object_id) {
			_next_object_id = t.object_id + 1;
		}
	}
	_export_sequence++;
	_export_root_id = root->get_instance_id();
	_last_export_node_count = (int)nodes.size();

	// Blob data starts after header + object table — within region 0
	uint32_t blob_start = (uint32_t)data_blob_offset(_max_objects);
	uint32_t blob_offset = blob_start;
	uint8_t *base = static_cast<uint8_t *>(_write_base);

	// Export each node
	for (size_t i = 0; i < nodes.size(); i++) {
		_export_node(nodes[i], (uint32_t)i, base, blob_offset);
	}

	// Populate _known_instance_ids for incremental change detection
	_known_instance_ids.clear();
	for (const auto &t : _tracked_objects) {
		_known_instance_ids.insert(t.godot_instance_id);
	}

	// Update header in region 0 (Godot→Blender)
	BridgeHeader hdr = read_header(_write_base);
	hdr.direction = 0; // godot→blender (unused in split-region, kept for compat)
	hdr.sequence = _export_sequence;
	hdr.object_count = (uint32_t)nodes.size();
	hdr.frame_number++;
	write_header(_write_base, hdr);

	// Signal Blender via UDS
	if (_uds.client_count() > 0) {
		const char *msg = "scene_exported";
		_uds.broadcast(msg, (uint32_t)strlen(msg));
	}

	emit_signal("scene_exported", (int)nodes.size());
	return OK;
}

Dictionary BlenderBridge::get_export_info() const {
	Dictionary info;

	if (!_shm.valid()) {
		info["object_count"] = 0;
		return info;
	}

	BridgeHeader hdr = read_header(_write_base);
	info["object_count"] = (int)hdr.object_count;
	info["sequence"] = (int)hdr.sequence;
	info["direction"] = (int)hdr.direction;

	Array objects;
	for (const auto &tracked : _tracked_objects) {
		Dictionary obj;
		obj["object_id"] = (int)tracked.object_id;
		obj["name"] = tracked.name;
		obj["type"] = (int)tracked.type;
		objects.push_back(obj);
	}
	info["objects"] = objects;

	return info;
}

// ── Scene import (shared memory → Godot) ──────────────────────────────────────

void BlenderBridge::import_changes(Node *root) {
	if (!_shm.valid() || !_read_base)
		return;

	// Read from region 1 (Blender→Godot) — no direction check needed
	BridgeHeader hdr = read_header(_read_base);

	// Only process if new data
	if (hdr.sequence <= _last_import_sequence)
		return;

	_last_import_sequence = hdr.sequence;
	uint8_t *base = static_cast<uint8_t *>(_read_base);
	int imported = 0;

	UtilityFunctions::print("BlenderBridge: import_changes seq=", (int)hdr.sequence,
		" count=", (int)hdr.object_count, " tracked=", (int)_tracked_objects.size());

	for (uint32_t i = 0; i < hdr.object_count; i++) {
		ObjectEntry entry = read_object_entry(_read_base, i);

		// Read name from blob: [uint8 name_len] [name_bytes] [padding to 4-byte align]
		uint32_t cursor = entry.data_offset;
		String blender_name;
		if (entry.data_size > 0) {
			uint8_t name_len = base[cursor];
			cursor += 1;
			if (name_len > 0 && cursor + name_len <= entry.data_offset + entry.data_size) {
				blender_name = String::utf8(reinterpret_cast<const char *>(base + cursor), name_len);
				cursor += name_len;
			}
			// Skip padding to 4-byte alignment
			uint32_t total = 1 + name_len;
			uint32_t pad = (4 - (total % 4)) % 4;
			cursor += pad;
		}

		// Find matching tracked object
		Node3D *target = nullptr;
		for (const auto &tracked : _tracked_objects) {
			if (tracked.object_id == entry.object_id) {
				Object *obj = ObjectDB::get_instance(tracked.godot_instance_id);
				target = Object::cast_to<Node3D>(obj);
				break;
			}
		}

		if (!target) {
			// Unknown object — Blender created it. Create a new node in Godot.
			UtilityFunctions::print("BlenderBridge: NEW obj id=", (int)entry.object_id,
				" name=", blender_name,
				" type=", (int)entry.object_type, " update=", (int)entry.update_type,
				" size=", (int)entry.data_size);
			if (entry.update_type == UPDATE_DELETE)
				continue;

			Node *export_root = Object::cast_to<Node>(ObjectDB::get_instance(_export_root_id));
			if (!export_root)
				export_root = root;

			Node3D *new_node = nullptr;
			if (entry.object_type == OBJ_MESH) {
				MeshInstance3D *mi = memnew(MeshInstance3D);
				Ref<ArrayMesh> mesh;
				mesh.instantiate();
				mi->set_mesh(mesh);
				new_node = mi;
			} else if (entry.object_type == OBJ_CAMERA) {
				new_node = memnew(Camera3D);
			} else if (entry.object_type == OBJ_LIGHT) {
				if (entry.sub_type == LIGHT_SUN) {
					new_node = memnew(DirectionalLight3D);
				} else if (entry.sub_type == LIGHT_SPOT) {
					new_node = memnew(SpotLight3D);
				} else {
					new_node = memnew(OmniLight3D);
				}
			} else {
				new_node = memnew(Node3D);
			}

			// Use actual Blender name, fallback to "blender_obj_N"
			String name = blender_name.is_empty()
				? String("blender_obj_") + String::num_int64(entry.object_id)
				: blender_name;
			new_node->set_name(name);
			export_root->add_child(new_node);
			new_node->set_owner(export_root);

			TrackedObject tracked;
			tracked.object_id = entry.object_id;
			tracked.godot_instance_id = new_node->get_instance_id();
			tracked.name = name;
			tracked.type = (ObjectType)entry.object_type;
			tracked.transform_hash = 0;
			tracked.from_blender = true;
			_tracked_objects.push_back(tracked);
			_known_instance_ids.insert(new_node->get_instance_id());
			_last_export_node_count++;

			target = new_node;
		}

		// cursor now points past the name to the transform data
		uint32_t transform_offset = cursor;

		// Update name on existing tracked objects if Blender renamed them
		if (!blender_name.is_empty()) {
			for (auto &tracked : _tracked_objects) {
				if (tracked.object_id == entry.object_id && tracked.name != blender_name) {
					tracked.name = blender_name;
					target->set_name(blender_name);
					break;
				}
			}
		}

		if (entry.update_type == UPDATE_TRANSFORM || entry.update_type == UPDATE_FULL) {
			// Read transform from blob (after name prefix)
			float xform_data[16];
			memcpy(xform_data, base + transform_offset, TRANSFORM_SIZE);

			// Reverse of _export_transform: M^-1 * B' * M
			// where B' is the Blender-space 4x4 from SHM
			Transform3D xform;
			Basis &b = xform.basis;
			Vector3 &o = xform.origin;

			b[0][0] = xform_data[0];
			b[0][1] = xform_data[2];
			b[0][2] = -xform_data[1];

			b[1][0] = xform_data[8];
			b[1][1] = xform_data[10];
			b[1][2] = -xform_data[9];

			b[2][0] = -xform_data[4];
			b[2][1] = -xform_data[6];
			b[2][2] = xform_data[5];

			o.x = xform_data[3];
			o.y = xform_data[11];
			o.z = -xform_data[7];

			// Camera/directional/spot lights: post-multiply by R_x(-90°)
			// to undo the look-direction compensation applied during export.
			bool needs_look_compensation = (entry.object_type == OBJ_CAMERA) ||
				(entry.object_type == OBJ_LIGHT && entry.sub_type != LIGHT_POINT);
			if (needs_look_compensation) {
				// R_x(-90°): new col1 = -old col2, new col2 = old col1
				real_t old_col1[3] = {b[0][1], b[1][1], b[2][1]};
				real_t old_col2[3] = {b[0][2], b[1][2], b[2][2]};
				b[0][1] = -old_col2[0]; b[1][1] = -old_col2[1]; b[2][1] = -old_col2[2];
				b[0][2] = old_col1[0]; b[1][2] = old_col1[1]; b[2][2] = old_col1[2];
			}

			target->set_global_transform(xform);

			// Update tracked hash so _detect_and_push_changes won't echo this back
			for (auto &tracked : _tracked_objects) {
				if (tracked.object_id == entry.object_id) {
					tracked.transform_hash = _hash_transform(xform);
					break;
				}
			}
		}

		if (entry.update_type == UPDATE_VERTEX || entry.update_type == UPDATE_FULL) {
			MeshInstance3D *mesh_inst = Object::cast_to<MeshInstance3D>(target);
			if (mesh_inst && entry.data_size > TRANSFORM_SIZE + 4) {
				uint32_t cursor = transform_offset + TRANSFORM_SIZE;
				uint32_t data_end = entry.data_offset + entry.data_size;
				uint32_t vert_count;
				memcpy(&vert_count, base + cursor, 4);
				cursor += 4;

				// Bounds check: ensure position data fits within the entry
				if (vert_count == 0) {
					// Empty mesh — no geometry data in blob, skip to material
				} else if (cursor + vert_count * 12 > data_end) {
					UtilityFunctions::printerr("BlenderBridge: vert_count out of bounds: ", (int)vert_count);
					continue;
				} else {

				PackedVector3Array positions;
				positions.resize(vert_count);
				Vector3 *pos_w = positions.ptrw();
				for (uint32_t v = 0; v < vert_count; v++) {
					float in[3];
					memcpy(in, base + cursor, 12);
					cursor += 12;
					pos_w[v].x = in[0];
					pos_w[v].y = in[2];
					pos_w[v].z = -in[1];
				}

				// Read has_normals flag
				PackedVector3Array normals;
				uint32_t has_normals = 0;
				if (cursor + 4 <= data_end) {
					memcpy(&has_normals, base + cursor, 4);
					cursor += 4;
				}

				if (has_normals && cursor + vert_count * 12 <= data_end) {
					normals.resize(vert_count);
					Vector3 *norm_w = normals.ptrw();
					for (uint32_t v = 0; v < vert_count; v++) {
						float in[3];
						memcpy(in, base + cursor, 12);
						cursor += 12;
						norm_w[v].x = in[0];
						norm_w[v].y = in[2];
						norm_w[v].z = -in[1];
					}
				}

				// Read index buffer
				PackedInt32Array indices;
				uint32_t idx_count = 0;
				if (cursor + 4 <= data_end) {
					memcpy(&idx_count, base + cursor, 4);
					cursor += 4;
				}

				if (idx_count > 0 && cursor + idx_count * 4 <= data_end) {
					indices.resize(idx_count);
					memcpy(indices.ptrw(), base + cursor, idx_count * 4);
					cursor += idx_count * 4;
					// Reverse triangle winding order: the Y↔Z axis swap changes
					// handedness, so Blender's CCW becomes CW in Godot without this fix.
					int32_t *idx_w = indices.ptrw();
					for (uint32_t t = 0; t + 2 < idx_count; t += 3) {
						int32_t tmp = idx_w[t + 1];
						idx_w[t + 1] = idx_w[t + 2];
						idx_w[t + 2] = tmp;
					}
				} else if (idx_count == 0 && vert_count >= 3) {
					// Per-corner data (no index buffer): reverse winding by
					// swapping vertices 1 and 2 of each triangle in-place.
					Vector3 *pw = positions.ptrw();
					Vector3 *nw = normals.size() > 0 ? normals.ptrw() : nullptr;
					for (uint32_t t = 0; t + 2 < vert_count; t += 3) {
						Vector3 tmp = pw[t + 1];
						pw[t + 1] = pw[t + 2];
						pw[t + 2] = tmp;
						if (nw) {
							tmp = nw[t + 1];
							nw[t + 1] = nw[t + 2];
							nw[t + 2] = tmp;
						}
					}
				}

				// Read per-triangle smooth flags (after indices)
				uint32_t smooth_count = 0;
				if (cursor + 4 <= entry.data_offset + entry.data_size) {
					memcpy(&smooth_count, base + cursor, 4);
					cursor += 4;
				}

				std::vector<uint8_t> smooth_flags;
				if (smooth_count > 0 && cursor + smooth_count <= entry.data_offset + entry.data_size) {
					smooth_flags.resize(smooth_count);
					memcpy(smooth_flags.data(), base + cursor, smooth_count);
					cursor += smooth_count;
				}


				// Read UV coordinates (after smooth flags)
				PackedVector2Array uvs;
				uint32_t uv_count = 0;
				if (cursor + 4 <= data_end) {
					memcpy(&uv_count, base + cursor, 4);
					cursor += 4;
				}

				if (uv_count > 0 && cursor + uv_count * 8 <= data_end) {
					uvs.resize(uv_count);
					Vector2 *uv_w = uvs.ptrw();
					for (uint32_t v = 0; v < uv_count; v++) {
						float uv[2];
						memcpy(uv, base + cursor, 8);
						cursor += 8;
						uv_w[v].x = uv[0];
						uv_w[v].y = uv[1];
					}

					// Reverse UV winding for per-corner data (same as positions/normals)
					if (idx_count == 0 && uv_count >= 3) {
						Vector2 *uvw = uvs.ptrw();
						for (uint32_t t = 0; t + 2 < uv_count; t += 3) {
							Vector2 tmp = uvw[t + 1];
							uvw[t + 1] = uvw[t + 2];
							uvw[t + 2] = tmp;
						}
					}
				}

				// Handle flat shading: duplicate vertices with face normals
				if (!smooth_flags.empty() && normals.size() > 0 && indices.size() > 0) {
					uint32_t tri_count = indices.size() / 3;
					bool has_flat = false;
					for (uint32_t t = 0; t < tri_count && t < smooth_count; t++) {
						if (smooth_flags[t] == 0) { has_flat = true; break; }
					}

					if (has_flat) {
						PackedVector3Array new_positions = positions;
						PackedVector3Array new_normals = normals;
						PackedVector2Array new_uvs = uvs;
						PackedInt32Array new_indices;
						const Vector3 *orig_pos = positions.ptr();
						const Vector2 *orig_uv = uvs.size() > 0 ? uvs.ptr() : nullptr;

						for (uint32_t t = 0; t < tri_count; t++) {
							int32_t i0 = indices[t * 3];
							int32_t i1 = indices[t * 3 + 1];
							int32_t i2 = indices[t * 3 + 2];

							if (t < smooth_count && smooth_flags[t] == 0) {
								// Flat: duplicate vertices with face normal
								Vector3 p0 = orig_pos[i0];
								Vector3 p1 = orig_pos[i1];
								Vector3 p2 = orig_pos[i2];
								Vector3 face_n = (p2 - p0).cross(p1 - p0).normalized();

								int32_t base_idx = new_positions.size();
								new_positions.push_back(p0);
								new_positions.push_back(p1);
								new_positions.push_back(p2);
								new_normals.push_back(face_n);
								new_normals.push_back(face_n);
								new_normals.push_back(face_n);

								if (orig_uv) {
									new_uvs.push_back(orig_uv[i0]);
									new_uvs.push_back(orig_uv[i1]);
									new_uvs.push_back(orig_uv[i2]);
								}

								new_indices.push_back(base_idx);
								new_indices.push_back(base_idx + 1);
								new_indices.push_back(base_idx + 2);
							} else {
								// Smooth: keep original indices
								new_indices.push_back(i0);
								new_indices.push_back(i1);
								new_indices.push_back(i2);
							}
						}

						positions = new_positions;
						normals = new_normals;
						uvs = new_uvs;
						indices = new_indices;
					}
				}

				Ref<ArrayMesh> arr_mesh = Object::cast_to<ArrayMesh>(mesh_inst->get_mesh().ptr());
				if (!arr_mesh.is_valid()) {
					arr_mesh.instantiate();
					mesh_inst->set_mesh(arr_mesh);
				}

				// Always do full rebuild — surface_update_vertex_region requires
				// matching Godot's internal interleaved vertex format which differs
				// from our raw float layout, causing buffer overflow errors.
				{
					Array arrays;
					arrays.resize(Mesh::ARRAY_MAX);
					arrays[Mesh::ARRAY_VERTEX] = positions;
					if (normals.size() > 0) {
						arrays[Mesh::ARRAY_NORMAL] = normals;
					}
					if (indices.size() > 0) {
						arrays[Mesh::ARRAY_INDEX] = indices;
					}
					if (uvs.size() > 0) {
						arrays[Mesh::ARRAY_TEX_UV] = uvs;
					}

					arr_mesh->clear_surfaces();
					arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);

					// Read material data (after UV data): 44 bytes PBR + 4 bytes tex_id
					uint32_t has_material = 0;
					if (cursor + 4 <= data_end) {
						memcpy(&has_material, base + cursor, 4);
						cursor += 4;
					}
					if (has_material && cursor + 48 <= data_end) {
						float mat_data[11];
						memcpy(mat_data, base + cursor, 44);
						cursor += 44;
						uint32_t tex_id = 0;
						memcpy(&tex_id, base + cursor, 4);
						cursor += 4;

						Color albedo(mat_data[0], mat_data[1], mat_data[2], mat_data[3]);
						float roughness_val = mat_data[4];
						float metallic_val = mat_data[5];

						// StandardMaterial3D with Blender PBR properties
						Ref<StandardMaterial3D> std_mat;
						std_mat.instantiate();
						std_mat->set_albedo(albedo);
						std_mat->set_roughness(roughness_val);
						std_mat->set_metallic(metallic_val);
						Color emission(mat_data[6], mat_data[7], mat_data[8], mat_data[9]);
						if (emission.r > 0.0f || emission.g > 0.0f || emission.b > 0.0f) {
							std_mat->set_feature(BaseMaterial3D::FEATURE_EMISSION, true);
							std_mat->set_emission(emission);
							std_mat->set_emission_energy_multiplier(mat_data[10]);
						}

						// Apply image texture if available
						if (tex_id > 0 && _image_textures.has(tex_id)) {
							std_mat->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, _image_textures[tex_id]);
						}

						mesh_inst->set_surface_override_material(0, std_mat);
					}

				}
				} // end else (vert_count > 0)
			}
		}

		if (entry.update_type == UPDATE_DELETE) {
			// Remove from tracking before freeing
			if (target) {
				uint64_t del_id = target->get_instance_id();
				_known_instance_ids.erase(del_id);
				target->queue_free();
			}
			_tracked_objects.erase(
				std::remove_if(_tracked_objects.begin(), _tracked_objects.end(),
					[&](const TrackedObject &t) { return t.object_id == entry.object_id; }),
				_tracked_objects.end());
			_last_export_node_count--;
		}

		imported++;
	}

	if (imported > 0) {
		emit_signal("changes_imported", imported);
	}
}

// ── Frame processing ──────────────────────────────────────────────────────────

void BlenderBridge::_process(double delta) {
	if (!_server_running)
		return;

	// Poll UDS for new connections and messages
	_uds.poll();

	// Process incoming UDS messages
	while (_uds.has_messages()) {
		UdsMessage msg = _uds.next_message();
		_handle_uds_message(msg);
	}

	// Import Blender changes when notified via UDS
	if (_import_pending && _shm.valid()) {
		_import_pending = false;
		Node *root = get_tree() ? get_tree()->get_root() : nullptr;
		if (root) {
			import_changes(root);
			_import_cooldown = 10; // Skip change detection for 10 frames after import
		}
	}

	// Auto-sync: push outgoing Godot changes (skip during import cooldown)
	if (_import_cooldown > 0) {
		_import_cooldown--;
	} else if (_auto_sync && _shm.valid()) {
		_detect_and_push_changes();
	}
}

// ── UDS message handling ──────────────────────────────────────────────────────

void BlenderBridge::_handle_uds_message(const UdsMessage &msg) {
	if (msg.size < 1)
		return;

	// Messages are simple text-based signaling:
	// "hello" — handshake
	// "request_texture:name,width,height" — Blender asks Godot to register a texture with SVT
	// "texture_dirty:tex_id,tile_x,tile_y,w,h,mip" — texture invalidation
	// "tile_ready:tex_id,tile_x,tile_y,mip,shm_offset,size" — tile data ready in shm
	// "finalize" — save textures as .png and scene as .tscn

	std::string text(reinterpret_cast<const char *>(msg.data.data()), msg.size);

	if (text == "updated") {
		// Blender flushed changes to region 1 — import on next frame
		_import_pending = true;
		return;
	}

	if (text.substr(0, 5) == "hello") {
		_last_import_sequence = 0; // Reset for new connection

		// Clean up stale from_blender tracked objects (will be re-sent)
		_tracked_objects.erase(
			std::remove_if(_tracked_objects.begin(), _tracked_objects.end(),
				[&](const TrackedObject &t) {
					if (t.from_blender) {
						_known_instance_ids.erase(t.godot_instance_id);
						Object *obj = ObjectDB::get_instance(t.godot_instance_id);
						Node *n = Object::cast_to<Node>(obj);
						if (n) n->queue_free();
						return true;
					}
					return false;
				}),
			_tracked_objects.end());

		const char *ack = "connected";
		_uds.send_to(msg.client_id, ack, (uint32_t)strlen(ack));

		// Notify the new client that scene data is already in SHM
		if (!_tracked_objects.empty()) {
			const char *notify = "scene_exported";
			_uds.send_to(msg.client_id, notify, (uint32_t)strlen(notify));
		}
		return;
	}

	if (text.substr(0, 16) == "request_texture:") {
		// Parse: request_texture:name,width,height
		std::string payload = text.substr(16);
		std::string tex_name;
		int img_w = 0, img_h = 0;
		size_t comma1 = payload.find(',');
		if (comma1 != std::string::npos) {
			tex_name = payload.substr(0, comma1);
			size_t comma2 = payload.find(',', comma1 + 1);
			if (comma2 != std::string::npos) {
				img_w = std::atoi(payload.substr(comma1 + 1, comma2 - comma1 - 1).c_str());
				img_h = std::atoi(payload.substr(comma2 + 1).c_str());
			}
		} else {
			tex_name = payload; // backward compat: no dimensions
		}

		if (!tex_name.empty() && !_virtual_texture_path.is_empty()) {
			Node *vt_node = get_node_or_null(_virtual_texture_path);
			VirtualTexture *vt = Object::cast_to<VirtualTexture>(vt_node);
			if (vt) {
				String gd_name = String::utf8(tex_name.c_str(), (int)tex_name.size());
				int svt_id = vt->register_texture(gd_name, img_w, img_h);
				String reply = "texture_registered:" + gd_name + "," + String::num_int64(svt_id);
				CharString reply_utf8 = reply.utf8();
				_uds.send_to(msg.client_id, reply_utf8.get_data(), (uint32_t)reply_utf8.length());
			}
		}
		return;
	}

	if (text.substr(0, 12) == "shm_resized:") {
		// Blender grew the SHM: shm_resized:new_size,new_region_size
		size_t new_size = 0, new_region_size = 0;
		if (sscanf(text.c_str() + 12, "%zu,%zu", &new_size, &new_region_size) == 2) {
			if (_shm.resize(new_size)) {
				_shm_size = (int)new_size;
				_region_size = new_region_size;
				_write_base = _shm.ptr();
				_read_base = region_base(_shm.ptr(), 1, _region_size);
				UtilityFunctions::print("BlenderBridge: SHM resized to ", (int)new_size,
					" bytes (region=", (int)new_region_size, ")");
			} else {
				UtilityFunctions::printerr("BlenderBridge: SHM resize failed!");
			}
		}
		return;
	}

	if (text == "finalize") {
		// Blender requests finalization — save textures + scene
		Object *root_obj = ObjectDB::get_instance(_export_root_id);
		Node *root = Object::cast_to<Node>(root_obj);
		if (root) {
			String save_dir = "res://blender_bridge_export/";
			Ref<DirAccess> dir = DirAccess::open("res://");
			if (dir.is_valid()) {
				dir->make_dir_recursive("blender_bridge_export");
			}
			Error err = finalize_scene(root, save_dir);
			const char *reply = (err == OK) ? "finalized" : "finalize_error";
			_uds.send_to(msg.client_id, reply, (uint32_t)strlen(reply));
		} else {
			const char *reply = "finalize_error";
			_uds.send_to(msg.client_id, reply, (uint32_t)strlen(reply));
		}
		return;
	}

	if (text.substr(0, 14) == "texture_dirty:") {
		// Parse: texture_dirty:tex_id,tile_x,tile_y,w,h,mip
		int tex_id, tx, ty, w, h, mip;
		if (sscanf(text.c_str() + 14, "%d,%d,%d,%d,%d,%d", &tex_id, &tx, &ty, &w, &h, &mip) == 6) {
			notify_texture_dirty(tex_id, tx, ty, w, h, mip);
		}
		return;
	}

	if (text.substr(0, 11) == "tile_ready:") {
		// Parse: tile_ready:tex_id,tile_x,tile_y,mip,shm_offset,size
		int tex_id, tx, ty, mip;
		uint32_t offset, size;
		if (sscanf(text.c_str() + 11, "%d,%d,%d,%d,%u,%u", &tex_id, &tx, &ty, &mip, &offset, &size) == 6) {
			// Read tile data from shared memory and upload to VirtualTexture
			if (_shm.valid() && !_virtual_texture_path.is_empty()) {
				Node *vt_node = get_node_or_null(_virtual_texture_path);
				VirtualTexture *vt = Object::cast_to<VirtualTexture>(vt_node);
				if (vt) {
					uint8_t *base = static_cast<uint8_t *>(_shm.ptr());
					PackedByteArray tile_data;
					tile_data.resize(size);
					memcpy(tile_data.ptrw(), base + offset, size);
					vt->upload_tile(tex_id, tx, ty, mip, tile_data);
				}
			}
		}
		return;
	}

	if (text.substr(0, 11) == "image_file:") {
		// Parse: image_file:tex_id,w,h,filepath
		// Blender saved a full image as raw RGBA8 and sent the path
		size_t c1 = text.find(',', 11);
		size_t c2 = (c1 != std::string::npos) ? text.find(',', c1 + 1) : std::string::npos;
		size_t c3 = (c2 != std::string::npos) ? text.find(',', c2 + 1) : std::string::npos;
		if (c3 != std::string::npos) {
			int tex_id = std::atoi(text.substr(11, c1 - 11).c_str());
			int w = std::atoi(text.substr(c1 + 1, c2 - c1 - 1).c_str());
			int h = std::atoi(text.substr(c2 + 1, c3 - c2 - 1).c_str());
			std::string filepath = text.substr(c3 + 1);

			// Read raw RGBA8 file
			String gd_path = String::utf8(filepath.c_str(), (int)filepath.size());
			Ref<FileAccess> f = FileAccess::open(gd_path, FileAccess::READ);
			if (f.is_valid()) {
				int64_t expected = (int64_t)w * h * 4;
				PackedByteArray data = f->get_buffer(expected);
				if (data.size() == expected) {
					Ref<Image> img = Image::create_from_data(w, h, false, Image::FORMAT_RGBA8, data);
					Ref<ImageTexture> tex = ImageTexture::create_from_image(img);
					_image_textures[tex_id] = tex;
					UtilityFunctions::print("BlenderBridge: Loaded image texture id=", tex_id,
						" (", w, "x", h, ") from ", gd_path);
				}
			}
		}
		return;
	}
}

// ── Change detection ──────────────────────────────────────────────────────────

uint64_t BlenderBridge::_hash_transform(const Transform3D &xform) {
	// FNV-1a hash of the 12 floats in Basis + Origin
	uint64_t hash = 14695981039346656037ULL;
	auto hash_float = [&](float f) {
		uint32_t bits;
		memcpy(&bits, &f, 4);
		hash ^= bits;
		hash *= 1099511628211ULL;
	};

	const Basis &b = xform.basis;
	hash_float(b[0][0]);
	hash_float(b[0][1]);
	hash_float(b[0][2]);
	hash_float(b[1][0]);
	hash_float(b[1][1]);
	hash_float(b[1][2]);
	hash_float(b[2][0]);
	hash_float(b[2][1]);
	hash_float(b[2][2]);
	hash_float(xform.origin.x);
	hash_float(xform.origin.y);
	hash_float(xform.origin.z);

	return hash;
}

void BlenderBridge::_detect_and_push_changes() {
	if (!_shm.valid() || !_write_base)
		return;

	Object *root_obj = ObjectDB::get_instance(_export_root_id);
	Node *export_root = Object::cast_to<Node>(root_obj);
	if (!export_root)
		return;

	uint8_t *base = static_cast<uint8_t *>(_write_base);
	uint32_t blob_start = (uint32_t)data_blob_offset(_max_objects);
	uint32_t blob_offset = blob_start;
	uint32_t changed_count = 0;

	// ── Detect new/removed Godot nodes (incremental, no full re-export) ──

	std::vector<Node3D *> current_nodes;
	_collect_exportable_nodes(export_root, current_nodes);
	std::set<uint64_t> current_ids;
	for (auto *n : current_nodes) {
		current_ids.insert(n->get_instance_id());
	}

	// New nodes: in scene but not tracked
	for (auto *node : current_nodes) {
		uint64_t nid = node->get_instance_id();
		if (_known_instance_ids.count(nid) > 0)
			continue;

		// New Godot node — export as UPDATE_FULL
		uint32_t obj_id = _next_object_id++;

		ObjectEntry entry = {};
		entry.object_id = obj_id;
		entry.update_type = UPDATE_FULL;
		entry.data_offset = blob_offset;
		entry.sub_type = 0;

		MeshInstance3D *mesh_inst = Object::cast_to<MeshInstance3D>(node);
		Camera3D *camera = Object::cast_to<Camera3D>(node);
		Light3D *light = Object::cast_to<Light3D>(node);
		bool needs_look_compensation = false;

		if (mesh_inst) {
			entry.object_type = OBJ_MESH;
		} else if (camera) {
			entry.object_type = OBJ_CAMERA;
			needs_look_compensation = true;
		} else if (light) {
			entry.object_type = OBJ_LIGHT;
			if (Object::cast_to<DirectionalLight3D>(node)) {
				entry.sub_type = LIGHT_SUN;
				needs_look_compensation = true;
			} else if (Object::cast_to<SpotLight3D>(node)) {
				entry.sub_type = LIGHT_SPOT;
				needs_look_compensation = true;
			} else {
				entry.sub_type = LIGHT_POINT;
			}
		} else {
			entry.object_type = OBJ_EMPTY;
		}

		// Write name prefix
		_write_name_prefix(node->get_name(), base, blob_offset);

		// Write transform
		float xform_data[16];
		_export_transform(node->get_global_transform(), xform_data);
		if (needs_look_compensation) {
			float old1[3] = {xform_data[1], xform_data[5], xform_data[9]};
			float old2[3] = {xform_data[2], xform_data[6], xform_data[10]};
			xform_data[1] = old2[0]; xform_data[5] = old2[1]; xform_data[9] = old2[2];
			xform_data[2] = -old1[0]; xform_data[6] = -old1[1]; xform_data[10] = -old1[2];
		}
		memcpy(base + blob_offset, xform_data, TRANSFORM_SIZE);
		blob_offset += TRANSFORM_SIZE;

		// Write vertex data for meshes
		if (mesh_inst && mesh_inst->get_mesh().is_valid()) {
			Ref<Mesh> mesh = mesh_inst->get_mesh();
			if (mesh->get_surface_count() > 0) {
				Array arrays = mesh->surface_get_arrays(0);
				if (arrays.size() > Mesh::ARRAY_VERTEX) {
					PackedVector3Array positions = arrays[Mesh::ARRAY_VERTEX];
					uint32_t vert_count = positions.size();
					memcpy(base + blob_offset, &vert_count, 4);
					blob_offset += 4;

					const Vector3 *pos_r = positions.ptr();
					for (uint32_t v = 0; v < vert_count; v++) {
						float in[3] = { pos_r[v].x, pos_r[v].y, pos_r[v].z };
						float out[3];
						_godot_to_blender_vec3(in, out);
						memcpy(base + blob_offset, out, 12);
						blob_offset += 12;
					}

					bool has_normals = arrays.size() > Mesh::ARRAY_NORMAL && arrays[Mesh::ARRAY_NORMAL].get_type() != Variant::NIL;
					uint32_t normals_flag = has_normals ? 1 : 0;
					memcpy(base + blob_offset, &normals_flag, 4);
					blob_offset += 4;

					if (has_normals) {
						PackedVector3Array normals = arrays[Mesh::ARRAY_NORMAL];
						const Vector3 *norm_r = normals.ptr();
						for (uint32_t v = 0; v < vert_count; v++) {
							float in[3] = { norm_r[v].x, norm_r[v].y, norm_r[v].z };
							float out[3];
							_godot_to_blender_vec3(in, out);
							memcpy(base + blob_offset, out, 12);
							blob_offset += 12;
						}
					}

					bool has_indices = arrays.size() > Mesh::ARRAY_INDEX && arrays[Mesh::ARRAY_INDEX].get_type() != Variant::NIL;
					uint32_t idx_count = 0;
					if (has_indices) {
						PackedInt32Array indices = arrays[Mesh::ARRAY_INDEX];
						idx_count = indices.size();
					}
					memcpy(base + blob_offset, &idx_count, 4);
					blob_offset += 4;

					if (idx_count > 0) {
						PackedInt32Array indices = arrays[Mesh::ARRAY_INDEX];
						const int32_t *idx_r = indices.ptr();
						memcpy(base + blob_offset, idx_r, idx_count * 4);
						blob_offset += idx_count * 4;
					}

					// Write per-triangle smooth flags (0=flat, 1=smooth)
					uint32_t tri_count = idx_count / 3;
					memcpy(base + blob_offset, &tri_count, 4);
					blob_offset += 4;

					if (tri_count > 0) {
						if (has_normals && has_indices) {
							PackedVector3Array norm_arr = arrays[Mesh::ARRAY_NORMAL];
							const Vector3 *nr = norm_arr.ptr();
							PackedInt32Array idx_arr = arrays[Mesh::ARRAY_INDEX];
							const int32_t *ir = idx_arr.ptr();

							for (uint32_t t = 0; t < tri_count; t++) {
								int32_t i0 = ir[t * 3], i1 = ir[t * 3 + 1], i2 = ir[t * 3 + 2];
								Vector3 fn = (pos_r[i1] - pos_r[i0]).cross(pos_r[i2] - pos_r[i0]);
								float len_sq = fn.length_squared();

								uint8_t is_smooth = 1;
								if (len_sq > 1e-12f) {
									fn /= Math::sqrt(len_sq);
									if (nr[i0].dot(fn) > 0.999f &&
											nr[i1].dot(fn) > 0.999f &&
											nr[i2].dot(fn) > 0.999f) {
										is_smooth = 0;
									}
								}
								base[blob_offset++] = is_smooth;
							}
						} else {
							memset(base + blob_offset, 1, tri_count);
							blob_offset += tri_count;
						}
					}

					// Write UV coordinates (float32×2 per vertex)
					bool has_uvs = arrays.size() > Mesh::ARRAY_TEX_UV && arrays[Mesh::ARRAY_TEX_UV].get_type() != Variant::NIL;
					uint32_t uv_count = 0;
					if (has_uvs) {
						PackedVector2Array uvs = arrays[Mesh::ARRAY_TEX_UV];
						uv_count = uvs.size();
					}
					memcpy(base + blob_offset, &uv_count, 4);
					blob_offset += 4;

					if (uv_count > 0) {
						PackedVector2Array uvs = arrays[Mesh::ARRAY_TEX_UV];
						const Vector2 *uv_r = uvs.ptr();
						for (uint32_t v = 0; v < uv_count; v++) {
							float uv[2] = { uv_r[v].x, uv_r[v].y };
							memcpy(base + blob_offset, uv, 8);
							blob_offset += 8;
						}
					}
				}
			}
		}


		// Write material data (only for meshes): 44 mat + 4 tex_id
		if (entry.object_type == OBJ_MESH && mesh_inst) {
			Ref<Material> mat_ref = mesh_inst->get_surface_override_material(0);
			if (!mat_ref.is_valid() && mesh_inst->get_mesh().is_valid() && mesh_inst->get_mesh()->get_surface_count() > 0) {
				mat_ref = mesh_inst->get_mesh()->surface_get_material(0);
			}
			Ref<StandardMaterial3D> std_mat = Object::cast_to<StandardMaterial3D>(mat_ref.ptr());
			if (std_mat.is_valid()) {
				uint32_t has_material = 1;
				memcpy(base + blob_offset, &has_material, 4);
				blob_offset += 4;
				Color albedo = std_mat->get_albedo();
				float mat_data[11];
				mat_data[0] = albedo.r; mat_data[1] = albedo.g; mat_data[2] = albedo.b; mat_data[3] = albedo.a;
				mat_data[4] = std_mat->get_roughness();
				mat_data[5] = std_mat->get_metallic();
				Color emission = std_mat->get_emission();
				mat_data[6] = emission.r; mat_data[7] = emission.g; mat_data[8] = emission.b; mat_data[9] = emission.a;
				mat_data[10] = std_mat->get_emission_energy_multiplier();
				memcpy(base + blob_offset, mat_data, 44);
				blob_offset += 44;
				uint32_t tex_id = 0;
				memcpy(base + blob_offset, &tex_id, 4);
				blob_offset += 4;
			} else {
				uint32_t has_material = 0;
				memcpy(base + blob_offset, &has_material, 4);
				blob_offset += 4;
			}
		} else if (entry.object_type == OBJ_MESH) {
			uint32_t has_material = 0;
			memcpy(base + blob_offset, &has_material, 4);
			blob_offset += 4;
		}

		entry.data_size = blob_offset - entry.data_offset;
		entry.sequence = (uint32_t)(_export_sequence + 1);
		write_object_entry(_write_base, changed_count, entry);
		changed_count++;

		// Track the new node
		TrackedObject tracked;
		tracked.object_id = obj_id;
		tracked.godot_instance_id = nid;
		tracked.name = node->get_name();
		tracked.type = (ObjectType)entry.object_type;
		tracked.transform_hash = _hash_transform(node->get_global_transform());
		tracked.from_blender = false;
		_tracked_objects.push_back(tracked);
	}

	// Removed nodes: tracked but not in scene
	for (auto it = _tracked_objects.begin(); it != _tracked_objects.end(); ) {
		if (it->from_blender) {
			++it;
			continue; // Don't delete Blender-origin objects from Godot side
		}
		if (current_ids.count(it->godot_instance_id) == 0) {
			// Node was removed from Godot — send DELETE
			ObjectEntry entry = {};
			entry.object_id = it->object_id;
			entry.object_type = it->type;
			entry.update_type = UPDATE_DELETE;
			entry.data_offset = 0;
			entry.data_size = 0;
			entry.sequence = (uint32_t)(_export_sequence + 1);
			write_object_entry(_write_base, changed_count, entry);
			changed_count++;

			_known_instance_ids.erase(it->godot_instance_id);
			it = _tracked_objects.erase(it);
		} else {
			++it;
		}
	}

	// ── Detect transform changes on existing tracked objects ──

	for (auto &tracked : _tracked_objects) {
		Object *obj = ObjectDB::get_instance(tracked.godot_instance_id);
		Node3D *node = Object::cast_to<Node3D>(obj);
		if (!node)
			continue;

		uint64_t new_hash = _hash_transform(node->get_global_transform());
		if (new_hash == tracked.transform_hash)
			continue;

		// Transform changed — write delta to region 0
		tracked.transform_hash = new_hash;

		ObjectEntry entry = {};
		entry.object_id = tracked.object_id;
		entry.object_type = tracked.type;
		entry.update_type = UPDATE_TRANSFORM;
		entry.data_offset = blob_offset;
		entry.sequence = (uint32_t)(_export_sequence + 1);

		// Write name prefix
		_write_name_prefix(tracked.name, base, blob_offset);

		float xform_data[16];
		_export_transform(node->get_global_transform(), xform_data);

		// Camera/directional/spot lights: apply look-direction compensation
		bool needs_look_compensation = (tracked.type == OBJ_CAMERA) ||
			(tracked.type == OBJ_LIGHT && Object::cast_to<DirectionalLight3D>(node)) ||
			(tracked.type == OBJ_LIGHT && Object::cast_to<SpotLight3D>(node));
		if (needs_look_compensation) {
			float old1[3] = {xform_data[1], xform_data[5], xform_data[9]};
			float old2[3] = {xform_data[2], xform_data[6], xform_data[10]};
			xform_data[1] = old2[0]; xform_data[5] = old2[1]; xform_data[9] = old2[2];
			xform_data[2] = -old1[0]; xform_data[6] = -old1[1]; xform_data[10] = -old1[2];
		}

		memcpy(base + blob_offset, xform_data, TRANSFORM_SIZE);
		blob_offset += TRANSFORM_SIZE;
		entry.data_size = blob_offset - entry.data_offset;

		write_object_entry(_write_base, changed_count, entry);
		changed_count++;
	}

	// Update _known_instance_ids
	_known_instance_ids = current_ids;
	_last_export_node_count = (int)current_nodes.size();

	if (changed_count > 0) {
		_export_sequence++;

		BridgeHeader hdr = read_header(_write_base);
		hdr.direction = 0;
		hdr.sequence = _export_sequence;
		hdr.object_count = changed_count;
		hdr.frame_number++;
		write_header(_write_base, hdr);

		// Signal Blender
		if (_uds.client_count() > 0) {
			const char *msg = "updated";
			_uds.broadcast(msg, (uint32_t)strlen(msg));
		}
	}
}