| … | … | @@ -68,25 +68,29 @@ class SceneImporter: |
| 68 | 68 | imported_ids.add(entry["object_id"]) |
| 69 | 69 | self._import_object(entry) |
| 70 | 70 | |
| 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)") |
| 90 | 94 | |
| 91 | 95 | def poll_updates(self) -> int: |
| 92 | 96 | """Check for incremental updates from region 0. Returns number of objects updated.""" |
| … | … | @@ -106,62 +110,92 @@ class SceneImporter: |
| 106 | 110 | |
| 107 | 111 | return updated |
| 108 | 112 | |
| 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 | |
| 109 | 133 | # ── Import a single object ─────────────────────────────────────────── |
| 110 | 134 | |
| 111 | 135 | def _import_object(self, entry: dict): |
| 112 | 136 | obj_id = entry["object_id"] |
| 113 | 137 | obj_type = entry["object_type"] |
| 114 | 138 | |
| 139 | # Read name prefix from blob |
| 140 | godot_name, transform_offset = self._read_name_prefix(entry["data_offset"]) |
| 141 | |
| 115 | 142 | # Check if we already have an object for this ID — reuse it |
| 116 | 143 | existing_name = self._id_to_name.get(obj_id) |
| 117 | 144 | if existing_name: |
| 118 | 145 | existing_obj = bpy.data.objects.get(existing_name) |
| 119 | 146 | 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) |
| 122 | 153 | # Update geometry if mesh with vertex data |
| 154 | name_prefix_size = transform_offset - entry["data_offset"] |
| 123 | 155 | if ( |
| 124 | 156 | obj_type == OBJ_MESH |
| 125 | 157 | and existing_obj.type == "MESH" |
| 126 | | and entry["data_size"] > TRANSFORM_SIZE + 4 |
| 158 | and entry["data_size"] > name_prefix_size + TRANSFORM_SIZE + 4 |
| 127 | 159 | ): |
| 128 | | self._update_mesh_geometry(existing_obj, entry) |
| 160 | self._update_mesh_geometry(existing_obj, entry, transform_offset) |
| 129 | 161 | return |
| 130 | 162 | |
| 131 | | # No existing object — create a new one |
| 163 | # No existing object — create a new one (pass transform_offset for mesh data) |
| 132 | 164 | if obj_type == OBJ_MESH: |
| 133 | | obj = self._import_mesh(entry) |
| 165 | obj = self._import_mesh(entry, godot_name, transform_offset) |
| 134 | 166 | elif obj_type == OBJ_CAMERA: |
| 135 | | obj = self._import_camera(entry) |
| 167 | obj = self._import_camera(entry, godot_name) |
| 136 | 168 | elif obj_type == OBJ_LIGHT: |
| 137 | | obj = self._import_light(entry) |
| 169 | obj = self._import_light(entry, godot_name) |
| 138 | 170 | else: |
| 139 | | obj = self._import_empty(entry) |
| 171 | obj = self._import_empty(entry, godot_name) |
| 140 | 172 | |
| 141 | 173 | if obj is not None: |
| 142 | 174 | 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) |
| 145 | 177 | |
| 146 | | def _import_mesh(self, entry: dict): |
| 178 | def _import_mesh(self, entry: dict, godot_name: str = "", transform_offset: int = 0): |
| 147 | 179 | """Create a Blender mesh from SHM vertex data.""" |
| 148 | | blob_offset = entry["data_offset"] |
| 149 | 180 | blob_size = entry["data_size"] |
| 181 | obj_name = godot_name or f"bridge_mesh_{entry['object_id']}" |
| 150 | 182 | |
| 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: |
| 152 | 186 | # 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) |
| 154 | 188 | obj = bpy.data.objects.new(mesh.name, mesh) |
| 155 | 189 | bpy.context.collection.objects.link(obj) |
| 156 | 190 | return obj |
| 157 | 191 | |
| 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 |
| 160 | 194 | vert_count_raw = self._shm.read_blob(vert_offset, 4) |
| 161 | 195 | vert_count = int(struct.unpack("<I", vert_count_raw.tobytes())[0]) |
| 162 | 196 | |
| 163 | 197 | if vert_count == 0: |
| 164 | | mesh = bpy.data.meshes.new(f"bridge_mesh_{entry['object_id']}") |
| 198 | mesh = bpy.data.meshes.new(obj_name) |
| 165 | 199 | obj = bpy.data.objects.new(mesh.name, mesh) |
| 166 | 200 | bpy.context.collection.objects.link(obj) |
| 167 | 201 | return obj |
| … | … | @@ -195,27 +229,31 @@ class SceneImporter: |
| 195 | 229 | indices_raw = self._shm.read_blob(cursor, idx_count * 4) |
| 196 | 230 | indices = np.frombuffer(indices_raw.tobytes(), dtype=np.int32) |
| 197 | 231 | 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]))) |
| 199 | 234 | else: |
| 200 | 235 | 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)) |
| 202 | 238 | |
| 203 | | mesh = bpy.data.meshes.new(f"bridge_mesh_{entry['object_id']}") |
| 239 | mesh = bpy.data.meshes.new(obj_name) |
| 204 | 240 | mesh.from_pydata(verts, [], faces) |
| 205 | 241 | mesh.update() |
| 206 | 242 | obj = bpy.data.objects.new(mesh.name, mesh) |
| 207 | 243 | bpy.context.collection.objects.link(obj) |
| 208 | 244 | return obj |
| 209 | 245 | |
| 210 | | def _import_camera(self, entry: dict): |
| 246 | def _import_camera(self, entry: dict, godot_name: str = ""): |
| 211 | 247 | """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) |
| 213 | 250 | obj = bpy.data.objects.new(cam.name, cam) |
| 214 | 251 | bpy.context.collection.objects.link(obj) |
| 215 | 252 | return obj |
| 216 | 253 | |
| 217 | | def _import_light(self, entry: dict): |
| 254 | def _import_light(self, entry: dict, godot_name: str = ""): |
| 218 | 255 | """Create a Blender light with correct type from sub_type field.""" |
| 256 | obj_name = godot_name or f"bridge_light_{entry['object_id']}" |
| 219 | 257 | sub = entry.get("sub_type", LIGHT_POINT) |
| 220 | 258 | if sub == LIGHT_SUN: |
| 221 | 259 | bl_type = "SUN" |
| … | … | @@ -223,18 +261,15 @@ class SceneImporter: |
| 223 | 261 | bl_type = "SPOT" |
| 224 | 262 | else: |
| 225 | 263 | 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) |
| 229 | 265 | obj = bpy.data.objects.new(light.name, light) |
| 230 | 266 | bpy.context.collection.objects.link(obj) |
| 231 | 267 | return obj |
| 232 | 268 | |
| 233 | | def _import_empty(self, entry: dict): |
| 269 | def _import_empty(self, entry: dict, godot_name: str = ""): |
| 234 | 270 | """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) |
| 238 | 273 | bpy.context.collection.objects.link(obj) |
| 239 | 274 | return obj |
| 240 | 275 | |
| … | … | @@ -252,14 +287,18 @@ class SceneImporter: |
| 252 | 287 | |
| 253 | 288 | def _apply_update(self, entry: dict): |
| 254 | 289 | 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: |
| 257 | 296 | # New object — full import |
| 258 | 297 | if entry["update_type"] == UPDATE_FULL: |
| 259 | 298 | self._import_object(entry) |
| 260 | 299 | return |
| 261 | 300 | |
| 262 | | obj = bpy.data.objects.get(name) |
| 301 | obj = bpy.data.objects.get(existing_name) |
| 263 | 302 | if obj is None: |
| 264 | 303 | return |
| 265 | 304 | |
| … | … | @@ -270,17 +309,25 @@ class SceneImporter: |
| 270 | 309 | del self._id_to_name[obj_id] |
| 271 | 310 | return |
| 272 | 311 | |
| 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 | |
| 273 | 317 | if update_type in (UPDATE_TRANSFORM, UPDATE_FULL): |
| 274 | | self._apply_transform(obj, entry["data_offset"]) |
| 318 | self._apply_transform(obj, transform_offset) |
| 275 | 319 | |
| 276 | 320 | 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) |
| 279 | 324 | |
| 280 | | def _update_mesh_geometry(self, obj, entry: dict): |
| 325 | def _update_mesh_geometry(self, obj, entry: dict, transform_offset: int = 0): |
| 281 | 326 | """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 |
| 284 | 331 | |
| 285 | 332 | vert_count_raw = self._shm.read_blob(vert_offset, 4) |
| 286 | 333 | vert_count = int(struct.unpack("<I", vert_count_raw.tobytes())[0]) |