OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge commits / 881df89

Fix 7 bugs in bidirectional Blender-Godot bridge
Bug 1+3: Embed object names in SHM blob (both directions). Added _write_name_prefix() C++ helper and _read_name_prefix() Python helper. Both regions now write [uint8 name_len][name_bytes][pad to 4B] before transform data. Python reads actual Godot names instead of generating generic "bridge_mesh_N". C++ updates node names on rename. Bug 2: Reverse triangle winding order on import (both C++ and Python). Y↔Z coordinate swap changes handedness, making Blender CCW triangles appear CW in Godot. Swap indices[i+1] and indices[i+2] per triangle. Bug 4: Remove surface_update_vertex_region fast path in C++ import. Always full rebuild via clear_surfaces + add_surface_from_arrays since the fast path requires Godot's internal interleaved vertex format. Bug 5: Detect deleted objects in change_detector.py flush(). Check _name_to_id keys against bpy.data.objects existence and send UPDATE_DELETE entries for objects that no longer exist. Bug 6: Guard stale object removal in scene_import.py full_import(). Only remove stale bridge objects when object_count > 0 to prevent wiping Blender scene when Godot hasn't exported yet. Bug 7: Handle SHM size mismatch in shm_protocol.py open(). Check actual file size via os.fstat() and use min(requested, actual) to avoid mmap ValueError when old .so creates smaller SHM. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
881df89ee7454f37f0909f4f4d1c06206acd041f
by plankatron <fleeky@gmail.com> · 2026-03-03T14:57:11.000Z

Changed files (6) +210 -102

blender_addon/change_detector.py +35 -1
……@@ -151,12 +151,24 @@ class ChangeDetector:
151151 Returns the number of objects written. Call this from the timer,
152152 not from the depsgraph handler.
153153 """
154 if not self._pending or not self._shm.valid:
154 # Detect deleted objects: tracked but no longer in bpy.data.objects
155 deleted_names = []
156 for name in list(self._name_to_id.keys()):
157 if bpy.data.objects.get(name) is None:
158 deleted_names.append(name)
159
160 if not self._pending and not deleted_names:
161 return 0
162 if not self._shm.valid:
155163 return 0
156164
157165 pending = self._pending
158166 self._pending = {}
159167
168 # Add deletions to pending
169 for name in deleted_names:
170 pending[name] = UPDATE_DELETE
171
160172 self._sequence += 1
161173 # Region base for absolute mmap writes
162174 region1_base = self._shm.region_base(1)
……@@ -174,6 +186,28 @@ class ChangeDetector:
174186 if entry_index >= self._max_objects:
175187 break
176188
189 # Handle deletion: object no longer exists
190 if update_type == UPDATE_DELETE:
191 obj_id = self._name_to_id.get(name)
192 if obj_id is None:
193 continue
194 # Write DELETE entry with no blob data
195 self._shm.write_object_entry(
196 index=entry_index,
197 object_id=obj_id,
198 object_type=OBJ_EMPTY,
199 update_type=UPDATE_DELETE,
200 data_offset=0,
201 data_size=0,
202 sequence=self._sequence,
203 sub_type=0,
204 region=1,
205 )
206 written_updates[name] = UPDATE_DELETE
207 entry_index += 1
208 del self._name_to_id[name]
209 continue
210
177211 # Look up the Blender object
178212 obj = bpy.data.objects.get(name)
179213 if obj is None:
blender_addon/scene_import.py +106 -59
……@@ -68,25 +68,29 @@ class SceneImporter:
6868 imported_ids.add(entry["object_id"])
6969 self._import_object(entry)
7070
71 # Scene sync: remove bridge objects not in current Godot scene
72 stale_ids = set(self._id_to_name.keys()) - imported_ids
73 for obj_id in stale_ids:
74 name = self._id_to_name.pop(obj_id, None)
75 if name is None:
76 continue
77 obj = bpy.data.objects.get(name)
78 if obj is not None:
79 data = obj.data
80 bpy.data.objects.remove(obj, do_unlink=True)
81 if data is not None and data.users == 0:
82 if isinstance(data, bpy.types.Mesh):
83 bpy.data.meshes.remove(data)
84 elif isinstance(data, bpy.types.Camera):
85 bpy.data.cameras.remove(data)
86 elif isinstance(data, bpy.types.Light):
87 bpy.data.lights.remove(data)
88 if stale_ids:
89 print(f"BlenderBridge: Removed {len(stale_ids)} stale object(s)")
71 # Scene sync: remove bridge objects not in current Godot scene.
72 # Only do stale removal when Godot actually exported objects —
73 # an empty export (count=0) means Godot hasn't exported yet,
74 # not that all objects were deleted.
75 if header["object_count"] > 0:
76 stale_ids = set(self._id_to_name.keys()) - imported_ids
77 for obj_id in stale_ids:
78 name = self._id_to_name.pop(obj_id, None)
79 if name is None:
80 continue
81 obj = bpy.data.objects.get(name)
82 if obj is not None:
83 data = obj.data
84 bpy.data.objects.remove(obj, do_unlink=True)
85 if data is not None and data.users == 0:
86 if isinstance(data, bpy.types.Mesh):
87 bpy.data.meshes.remove(data)
88 elif isinstance(data, bpy.types.Camera):
89 bpy.data.cameras.remove(data)
90 elif isinstance(data, bpy.types.Light):
91 bpy.data.lights.remove(data)
92 if stale_ids:
93 print(f"BlenderBridge: Removed {len(stale_ids)} stale object(s)")
9094
9195 def poll_updates(self) -> int:
9296 """Check for incremental updates from region 0. Returns number of objects updated."""
……@@ -106,62 +110,92 @@ class SceneImporter:
106110
107111 return updated
108112
113 # ── Name prefix helpers ──────────────────────────────────────────────
114
115 def _read_name_prefix(self, blob_offset: int) -> tuple[str, int]:
116 """Read [uint8 name_len][name_bytes][pad to 4B] from blob.
117
118 Returns (name_string, cursor_after_name).
119 """
120 raw = self._shm.read_blob(blob_offset, 4)
121 name_len = int(raw.view(np.uint8)[0])
122 cursor = blob_offset + 1
123 name = ""
124 if name_len > 0:
125 name_bytes = bytes(self._shm._mm[cursor : cursor + name_len])
126 name = name_bytes.decode("utf-8", errors="replace")
127 cursor += name_len
128 total = 1 + name_len
129 pad = (4 - (total % 4)) % 4
130 cursor += pad
131 return name, cursor
132
109133 # ── Import a single object ───────────────────────────────────────────
110134
111135 def _import_object(self, entry: dict):
112136 obj_id = entry["object_id"]
113137 obj_type = entry["object_type"]
114138
139 # Read name prefix from blob
140 godot_name, transform_offset = self._read_name_prefix(entry["data_offset"])
141
115142 # Check if we already have an object for this ID — reuse it
116143 existing_name = self._id_to_name.get(obj_id)
117144 if existing_name:
118145 existing_obj = bpy.data.objects.get(existing_name)
119146 if existing_obj is not None:
120 # Update transform in place
121 self._apply_transform(existing_obj, entry["data_offset"])
147 # Rename if Godot name changed
148 if godot_name and existing_obj.name != godot_name:
149 existing_obj.name = godot_name
150 self._id_to_name[obj_id] = existing_obj.name
151 # Update transform in place (pass transform_offset, not data_offset)
152 self._apply_transform(existing_obj, transform_offset)
122153 # Update geometry if mesh with vertex data
154 name_prefix_size = transform_offset - entry["data_offset"]
123155 if (
124156 obj_type == OBJ_MESH
125157 and existing_obj.type == "MESH"
126 and entry["data_size"] > TRANSFORM_SIZE + 4
158 and entry["data_size"] > name_prefix_size + TRANSFORM_SIZE + 4
127159 ):
128 self._update_mesh_geometry(existing_obj, entry)
160 self._update_mesh_geometry(existing_obj, entry, transform_offset)
129161 return
130162
131 # No existing object — create a new one
163 # No existing object — create a new one (pass transform_offset for mesh data)
132164 if obj_type == OBJ_MESH:
133 obj = self._import_mesh(entry)
165 obj = self._import_mesh(entry, godot_name, transform_offset)
134166 elif obj_type == OBJ_CAMERA:
135 obj = self._import_camera(entry)
167 obj = self._import_camera(entry, godot_name)
136168 elif obj_type == OBJ_LIGHT:
137 obj = self._import_light(entry)
169 obj = self._import_light(entry, godot_name)
138170 else:
139 obj = self._import_empty(entry)
171 obj = self._import_empty(entry, godot_name)
140172
141173 if obj is not None:
142174 self._id_to_name[obj_id] = obj.name
143 # Apply transform
144 self._apply_transform(obj, entry["data_offset"])
175 # Apply transform (using transform_offset past name prefix)
176 self._apply_transform(obj, transform_offset)
145177
146 def _import_mesh(self, entry: dict):
178 def _import_mesh(self, entry: dict, godot_name: str = "", transform_offset: int = 0):
147179 """Create a Blender mesh from SHM vertex data."""
148 blob_offset = entry["data_offset"]
149180 blob_size = entry["data_size"]
181 obj_name = godot_name or f"bridge_mesh_{entry['object_id']}"
150182
151 if blob_size <= TRANSFORM_SIZE:
183 # Calculate name prefix size to check if there's vertex data
184 name_prefix_size = transform_offset - entry["data_offset"] if transform_offset else 0
185 if blob_size <= name_prefix_size + TRANSFORM_SIZE:
152186 # No vertex data, just transform — create empty mesh
153 mesh = bpy.data.meshes.new(f"bridge_mesh_{entry['object_id']}")
187 mesh = bpy.data.meshes.new(obj_name)
154188 obj = bpy.data.objects.new(mesh.name, mesh)
155189 bpy.context.collection.objects.link(obj)
156190 return obj
157191
158 # Read vertex count (uint32 at transform_offset + TRANSFORM_SIZE)
159 vert_offset = blob_offset + TRANSFORM_SIZE
192 # Read vertex count (uint32 after transform)
193 vert_offset = transform_offset + TRANSFORM_SIZE
160194 vert_count_raw = self._shm.read_blob(vert_offset, 4)
161195 vert_count = int(struct.unpack("<I", vert_count_raw.tobytes())[0])
162196
163197 if vert_count == 0:
164 mesh = bpy.data.meshes.new(f"bridge_mesh_{entry['object_id']}")
198 mesh = bpy.data.meshes.new(obj_name)
165199 obj = bpy.data.objects.new(mesh.name, mesh)
166200 bpy.context.collection.objects.link(obj)
167201 return obj
……@@ -195,27 +229,31 @@ class SceneImporter:
195229 indices_raw = self._shm.read_blob(cursor, idx_count * 4)
196230 indices = np.frombuffer(indices_raw.tobytes(), dtype=np.int32)
197231 for fi in range(0, idx_count - 2, 3):
198 faces.append((int(indices[fi]), int(indices[fi + 1]), int(indices[fi + 2])))
232 # Reverse winding: Y↔Z swap changes handedness
233 faces.append((int(indices[fi]), int(indices[fi + 2]), int(indices[fi + 1])))
199234 else:
200235 for fi in range(0, vert_count - 2, 3):
201 faces.append((fi, fi + 1, fi + 2))
236 # Reverse winding for non-indexed triangles too
237 faces.append((fi, fi + 2, fi + 1))
202238
203 mesh = bpy.data.meshes.new(f"bridge_mesh_{entry['object_id']}")
239 mesh = bpy.data.meshes.new(obj_name)
204240 mesh.from_pydata(verts, [], faces)
205241 mesh.update()
206242 obj = bpy.data.objects.new(mesh.name, mesh)
207243 bpy.context.collection.objects.link(obj)
208244 return obj
209245
210 def _import_camera(self, entry: dict):
246 def _import_camera(self, entry: dict, godot_name: str = ""):
211247 """Create a Blender camera."""
212 cam = bpy.data.cameras.new(f"bridge_camera_{entry['object_id']}")
248 obj_name = godot_name or f"bridge_camera_{entry['object_id']}"
249 cam = bpy.data.cameras.new(obj_name)
213250 obj = bpy.data.objects.new(cam.name, cam)
214251 bpy.context.collection.objects.link(obj)
215252 return obj
216253
217 def _import_light(self, entry: dict):
254 def _import_light(self, entry: dict, godot_name: str = ""):
218255 """Create a Blender light with correct type from sub_type field."""
256 obj_name = godot_name or f"bridge_light_{entry['object_id']}"
219257 sub = entry.get("sub_type", LIGHT_POINT)
220258 if sub == LIGHT_SUN:
221259 bl_type = "SUN"
……@@ -223,18 +261,15 @@ class SceneImporter:
223261 bl_type = "SPOT"
224262 else:
225263 bl_type = "POINT"
226 light = bpy.data.lights.new(
227 f"bridge_light_{entry['object_id']}", bl_type
228 )
264 light = bpy.data.lights.new(obj_name, bl_type)
229265 obj = bpy.data.objects.new(light.name, light)
230266 bpy.context.collection.objects.link(obj)
231267 return obj
232268
233 def _import_empty(self, entry: dict):
269 def _import_empty(self, entry: dict, godot_name: str = ""):
234270 """Create a Blender empty."""
235 obj = bpy.data.objects.new(
236 f"bridge_empty_{entry['object_id']}", None
237 )
271 obj_name = godot_name or f"bridge_empty_{entry['object_id']}"
272 obj = bpy.data.objects.new(obj_name, None)
238273 bpy.context.collection.objects.link(obj)
239274 return obj
240275
……@@ -252,14 +287,18 @@ class SceneImporter:
252287
253288 def _apply_update(self, entry: dict):
254289 obj_id = entry["object_id"]
255 name = self._id_to_name.get(obj_id)
256 if name is None:
290 existing_name = self._id_to_name.get(obj_id)
291
292 # Read name prefix from blob
293 godot_name, transform_offset = self._read_name_prefix(entry["data_offset"])
294
295 if existing_name is None:
257296 # New object — full import
258297 if entry["update_type"] == UPDATE_FULL:
259298 self._import_object(entry)
260299 return
261300
262 obj = bpy.data.objects.get(name)
301 obj = bpy.data.objects.get(existing_name)
263302 if obj is None:
264303 return
265304
……@@ -270,17 +309,25 @@ class SceneImporter:
270309 del self._id_to_name[obj_id]
271310 return
272311
312 # Update name if Godot renamed it
313 if godot_name and obj.name != godot_name:
314 obj.name = godot_name
315 self._id_to_name[obj_id] = obj.name
316
273317 if update_type in (UPDATE_TRANSFORM, UPDATE_FULL):
274 self._apply_transform(obj, entry["data_offset"])
318 self._apply_transform(obj, transform_offset)
275319
276320 if update_type in (UPDATE_VERTEX, UPDATE_FULL):
277 if obj.type == "MESH" and entry["data_size"] > TRANSFORM_SIZE + 4:
278 self._update_mesh_geometry(obj, entry)
321 name_prefix_size = transform_offset - entry["data_offset"]
322 if obj.type == "MESH" and entry["data_size"] > name_prefix_size + TRANSFORM_SIZE + 4:
323 self._update_mesh_geometry(obj, entry, transform_offset)
279324
280 def _update_mesh_geometry(self, obj, entry: dict):
325 def _update_mesh_geometry(self, obj, entry: dict, transform_offset: int = 0):
281326 """Update existing mesh geometry in-place."""
282 blob_offset = entry["data_offset"]
283 vert_offset = blob_offset + TRANSFORM_SIZE
327 if transform_offset == 0:
328 # Legacy: no name prefix
329 transform_offset = entry["data_offset"]
330 vert_offset = transform_offset + TRANSFORM_SIZE
284331
285332 vert_count_raw = self._shm.read_blob(vert_offset, 4)
286333 vert_count = int(struct.unpack("<I", vert_count_raw.tobytes())[0])
blender_addon/shm_protocol.py +14 -1
……@@ -104,11 +104,24 @@ class ShmRegion:
104104 return False
105105
106106 def open(self, name: str, size: int) -> bool:
107 """Open an existing shared memory region (reader — will NOT unlink)."""
107 """Open an existing shared memory region (reader — will NOT unlink).
108
109 If the SHM file is smaller than requested size, uses the actual
110 file size to avoid mmap ValueError.
111 """
108112 self._name = name
109113 path = f"/dev/shm{name}"
110114 try:
111115 self._fd = os.open(path, os.O_RDWR)
116 # Check actual file size to handle version mismatch
117 actual_size = os.fstat(self._fd).st_size
118 if actual_size < size:
119 print(
120 f"BlenderBridge: SHM size mismatch — expected {size}, "
121 f"got {actual_size}. Using actual size. "
122 f"Restart Godot with updated .so to fix."
123 )
124 size = actual_size
112125 self._mm = mmap.mmap(self._fd, size)
113126 self._size = size
114127 self._owner = False
blender_bridge_addon.zip +0 -0
(no content changes — binary or rename)
src/blender_bridge.cpp +54 -41
……@@ -17,7 +17,8 @@
1717using namespace godot;
1818
1919// ── Data layout in blob region ────────────────────────────────────────────────
20// Per object: [4×4 transform (64 bytes)] [optional vertex data]
20// Per object: [name prefix] [4×4 transform (64 bytes)] [optional vertex data]
21// Name prefix: [uint8 name_len] [name_bytes] [padding to 4-byte align]
2122// Vertex data: [uint32 vertex_count] [positions: 3*N floats] [normals: 3*N floats]
2223
2324static constexpr uint32_t TRANSFORM_SIZE = 64; // 4×4 float matrix
……@@ -252,6 +253,24 @@ void BlenderBridge::_export_transform(const Transform3D &xform, float *out) {
252253 out[15] = 1.0f;
253254}
254255
256void BlenderBridge::_write_name_prefix(const String &name, uint8_t *blob_base, uint32_t &blob_offset) {
257 CharString utf8 = name.utf8();
258 uint8_t name_len = (uint8_t)std::min((int)utf8.length(), 255);
259 blob_base[blob_offset] = name_len;
260 blob_offset += 1;
261 if (name_len > 0) {
262 memcpy(blob_base + blob_offset, utf8.get_data(), name_len);
263 blob_offset += name_len;
264 }
265 // Pad to 4-byte alignment
266 uint32_t total = 1 + name_len;
267 uint32_t pad = (4 - (total % 4)) % 4;
268 if (pad > 0) {
269 memset(blob_base + blob_offset, 0, pad);
270 blob_offset += pad;
271 }
272}
273
255274uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob_base, uint32_t &blob_offset) {
256275 ObjectEntry entry = {};
257276 entry.object_id = _next_object_id++;
……@@ -286,7 +305,10 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob
286305 entry.object_type = OBJ_EMPTY;
287306 }
288307
289 // Write transform (always present, first 64 bytes of blob)
308 // Write object name: [uint8 name_len] [name_bytes] [pad to 4-byte alignment]
309 _write_name_prefix(node->get_name(), blob_base, blob_offset);
310
311 // Write transform (always present after name prefix)
290312 float xform_data[16];
291313 _export_transform(node->get_global_transform(), xform_data);
292314
……@@ -581,6 +603,17 @@ void BlenderBridge::import_changes(Node *root) {
581603 // cursor now points past the name to the transform data
582604 uint32_t transform_offset = cursor;
583605
606 // Update name on existing tracked objects if Blender renamed them
607 if (!blender_name.is_empty()) {
608 for (auto &tracked : _tracked_objects) {
609 if (tracked.object_id == entry.object_id && tracked.name != blender_name) {
610 tracked.name = blender_name;
611 target->set_name(blender_name);
612 break;
613 }
614 }
615 }
616
584617 if (entry.update_type == UPDATE_TRANSFORM || entry.update_type == UPDATE_FULL) {
585618 // Read transform from blob (after name prefix)
586619 float xform_data[16];
……@@ -683,6 +716,14 @@ void BlenderBridge::import_changes(Node *root) {
683716 if (idx_count > 0 && cursor + idx_count * 4 <= entry.data_offset + entry.data_size) {
684717 indices.resize(idx_count);
685718 memcpy(indices.ptrw(), base + cursor, idx_count * 4);
719 // Reverse triangle winding order: the Y↔Z axis swap changes
720 // handedness, so Blender's CCW becomes CW in Godot without this fix.
721 int32_t *idx_w = indices.ptrw();
722 for (uint32_t t = 0; t + 2 < idx_count; t += 3) {
723 int32_t tmp = idx_w[t + 1];
724 idx_w[t + 1] = idx_w[t + 2];
725 idx_w[t + 2] = tmp;
726 }
686727 }
687728
688729 Ref<ArrayMesh> arr_mesh = Object::cast_to<ArrayMesh>(mesh_inst->get_mesh().ptr());
……@@ -691,44 +732,10 @@ void BlenderBridge::import_changes(Node *root) {
691732 mesh_inst->set_mesh(arr_mesh);
692733 }
693734
694 // Fast path: same-topology vertex update via surface_update_vertex_region
695 bool same_topology = false;
696 if (entry.update_type == UPDATE_VERTEX && arr_mesh->get_surface_count() > 0) {
697 Array existing = arr_mesh->surface_get_arrays(0);
698 if (existing.size() > Mesh::ARRAY_VERTEX) {
699 PackedVector3Array existing_pos = existing[Mesh::ARRAY_VERTEX];
700 if ((uint32_t)existing_pos.size() == vert_count) {
701 same_topology = true;
702 }
703 }
704 }
705
706 if (same_topology) {
707 // Build vertex buffer in the format expected by surface_update_vertex_region.
708 // For PRIMITIVE_TRIANGLES with ARRAY_VERTEX + ARRAY_NORMAL:
709 // interleaved: [pos.x, pos.y, pos.z, norm.x, norm.y, norm.z] per vertex
710 PackedByteArray vertex_data;
711 if (has_normals && normals.size() == (int)vert_count) {
712 vertex_data.resize(vert_count * 24); // 12 bytes pos + 12 bytes normal
713 uint8_t *vd = vertex_data.ptrw();
714 const Vector3 *p = positions.ptr();
715 const Vector3 *n = normals.ptr();
716 for (uint32_t v = 0; v < vert_count; v++) {
717 float vals[6] = {p[v].x, p[v].y, p[v].z, n[v].x, n[v].y, n[v].z};
718 memcpy(vd + v * 24, vals, 24);
719 }
720 } else {
721 vertex_data.resize(vert_count * 12);
722 uint8_t *vd = vertex_data.ptrw();
723 const Vector3 *p = positions.ptr();
724 for (uint32_t v = 0; v < vert_count; v++) {
725 float vals[3] = {p[v].x, p[v].y, p[v].z};
726 memcpy(vd + v * 12, vals, 12);
727 }
728 }
729 arr_mesh->surface_update_vertex_region(0, 0, vertex_data);
730 } else {
731 // Full rebuild: clear and re-add surface
735 // Always do full rebuild — surface_update_vertex_region requires
736 // matching Godot's internal interleaved vertex format which differs
737 // from our raw float layout, causing buffer overflow errors.
738 {
732739 Array arrays;
733740 arrays.resize(Mesh::ARRAY_MAX);
734741 arrays[Mesh::ARRAY_VERTEX] = positions;
……@@ -949,6 +956,9 @@ void BlenderBridge::_detect_and_push_changes() {
949956 entry.object_type = OBJ_EMPTY;
950957 }
951958
959 // Write name prefix
960 _write_name_prefix(node->get_name(), base, blob_offset);
961
952962 // Write transform
953963 float xform_data[16];
954964 _export_transform(node->get_global_transform(), xform_data);
……@@ -1080,6 +1090,9 @@ void BlenderBridge::_detect_and_push_changes() {
10801090 entry.data_offset = blob_offset;
10811091 entry.sequence = (uint32_t)(_export_sequence + 1);
10821092
1093 // Write name prefix
1094 _write_name_prefix(tracked.name, base, blob_offset);
1095
10831096 float xform_data[16];
10841097 _export_transform(node->get_global_transform(), xform_data);
10851098
……@@ -1096,7 +1109,7 @@ void BlenderBridge::_detect_and_push_changes() {
10961109
10971110 memcpy(base + blob_offset, xform_data, TRANSFORM_SIZE);
10981111 blob_offset += TRANSFORM_SIZE;
1099 entry.data_size = TRANSFORM_SIZE;
1112 entry.data_size = blob_offset - entry.data_offset;
11001113
11011114 write_object_entry(_write_base, changed_count, entry);
11021115 changed_count++;
src/blender_bridge.h +1 -0
……@@ -118,6 +118,7 @@ private:
118118 // ── Export helpers ───────────────────────────────────────────────────
119119 void _collect_exportable_nodes(Node *node, std::vector<Node3D *> &out);
120120 uint32_t _export_node(Node3D *node, uint32_t index, uint8_t *blob_base, uint32_t &blob_offset);
121 static void _write_name_prefix(const String &name, uint8_t *blob_base, uint32_t &blob_offset);
121122
122123 // ── Change detection ────────────────────────────────────────────────
123124 static uint64_t _hash_transform(const Transform3D &xform);