| … | … | @@ -199,6 +199,18 @@ class ChangeDetector: |
| 199 | 199 | self._pending[name] = UPDATE_FULL |
| 200 | 200 | # else: already VERTEX or FULL, keep it |
| 201 | 201 | |
| 202 | # Material/shading change: needs geometry re-export to include |
| 203 | # updated material data in the blob |
| 204 | if update.is_updated_shading and obj.type == "MESH": |
| 205 | existing = self._pending.get(name, -1) |
| 206 | if existing == -1: |
| 207 | if name not in self._name_to_id: |
| 208 | self._pending[name] = UPDATE_FULL |
| 209 | else: |
| 210 | self._pending[name] = UPDATE_VERTEX |
| 211 | elif existing == UPDATE_TRANSFORM: |
| 212 | self._pending[name] = UPDATE_VERTEX |
| 213 | |
| 202 | 214 | if update.is_updated_transform: |
| 203 | 215 | # Hash-based jitter reduction: skip if transform unchanged |
| 204 | 216 | new_hash = self._hash_transform(obj.matrix_world) |
| … | … | @@ -213,6 +225,65 @@ class ChangeDetector: |
| 213 | 225 | self._pending[name] = UPDATE_TRANSFORM |
| 214 | 226 | # else: don't downgrade from VERTEX/FULL to TRANSFORM |
| 215 | 227 | |
| 228 | # -- Geometry nodes instance realization -------------------------------- |
| 229 | |
| 230 | @staticmethod |
| 231 | def _realize_instances(obj, depsgraph): |
| 232 | """Collect geometry nodes instances and combine into a single Blender mesh. |
| 233 | |
| 234 | When geometry nodes use Instance on Points (or similar), to_mesh() |
| 235 | returns empty because instances aren't realized. This method iterates |
| 236 | depsgraph.object_instances, collects all instances parented to `obj`, |
| 237 | transforms their vertices into obj's local space, and creates a |
| 238 | combined mesh. |
| 239 | """ |
| 240 | parent_inv = obj.matrix_world.inverted() |
| 241 | all_verts = [] |
| 242 | all_faces = [] |
| 243 | vert_offset = 0 |
| 244 | |
| 245 | for inst in depsgraph.object_instances: |
| 246 | if not inst.is_instance: |
| 247 | continue |
| 248 | if inst.parent is None or inst.parent.original != obj: |
| 249 | continue |
| 250 | |
| 251 | inst_obj = inst.object |
| 252 | inst_mesh = inst_obj.to_mesh() |
| 253 | if inst_mesh is None or len(inst_mesh.vertices) == 0: |
| 254 | if inst_mesh is not None: |
| 255 | inst_obj.to_mesh_clear() |
| 256 | continue |
| 257 | |
| 258 | inst_mesh.calc_loop_triangles() |
| 259 | if len(inst_mesh.loop_triangles) == 0: |
| 260 | inst_obj.to_mesh_clear() |
| 261 | continue |
| 262 | |
| 263 | # Transform instance verts to parent local space |
| 264 | local_mat = parent_inv @ inst.matrix_world |
| 265 | for v in inst_mesh.vertices: |
| 266 | pos = local_mat @ v.co |
| 267 | all_verts.append((pos.x, pos.y, pos.z)) |
| 268 | |
| 269 | for tri in inst_mesh.loop_triangles: |
| 270 | all_faces.append(( |
| 271 | tri.vertices[0] + vert_offset, |
| 272 | tri.vertices[1] + vert_offset, |
| 273 | tri.vertices[2] + vert_offset, |
| 274 | )) |
| 275 | |
| 276 | vert_offset += len(inst_mesh.vertices) |
| 277 | inst_obj.to_mesh_clear() |
| 278 | |
| 279 | if not all_verts: |
| 280 | return None |
| 281 | |
| 282 | combined = bpy.data.meshes.new("_bridge_realized_tmp") |
| 283 | combined.from_pydata(all_verts, [], all_faces) |
| 284 | combined.update() |
| 285 | return combined |
| 286 | |
| 216 | 287 | # -- Flush accumulated changes to SHM ---------------------------------- |
| 217 | 288 | |
| 218 | 289 | def flush(self) -> int: |
| … | … | @@ -327,6 +398,23 @@ class ChangeDetector: |
| 327 | 398 | else: |
| 328 | 399 | mesh = obj.data |
| 329 | 400 | eval_obj = None |
| 401 | realized_mesh = None # Track standalone mesh that needs bpy.data.meshes.remove() |
| 402 | |
| 403 | if mesh is not None: |
| 404 | # Geometry nodes with instances (Instance on Points etc): |
| 405 | # to_mesh() doesn't realize instances. Collect them from |
| 406 | # depsgraph and combine into a single mesh. |
| 407 | mesh.calc_loop_triangles() |
| 408 | if ( |
| 409 | len(mesh.loop_triangles) == 0 |
| 410 | and depsgraph is not None |
| 411 | and any(m.type == "NODES" for m in obj.modifiers) |
| 412 | ): |
| 413 | if eval_obj is not None: |
| 414 | eval_obj.to_mesh_clear() |
| 415 | mesh = self._realize_instances(obj, depsgraph) |
| 416 | realized_mesh = mesh # Remember for cleanup |
| 417 | eval_obj = None # mesh is standalone, no clear needed |
| 330 | 418 | |
| 331 | 419 | if mesh is not None: |
| 332 | 420 | # Geometry hash check: skip re-export if evaluated mesh |
| … | … | @@ -341,6 +429,9 @@ class ChangeDetector: |
| 341 | 429 | # Mesh unchanged — downgrade to transform-only or skip |
| 342 | 430 | if eval_obj is not None: |
| 343 | 431 | eval_obj.to_mesh_clear() |
| 432 | if realized_mesh is not None: |
| 433 | bpy.data.meshes.remove(realized_mesh) |
| 434 | realized_mesh = None |
| 344 | 435 | if update_type == UPDATE_VERTEX: |
| 345 | 436 | # Only geometry was flagged but unchanged — skip entirely |
| 346 | 437 | blob_abs_offset = region1_base + data_rel_start |
| … | … | @@ -464,6 +555,9 @@ class ChangeDetector: |
| 464 | 555 | |
| 465 | 556 | if eval_obj is not None: |
| 466 | 557 | eval_obj.to_mesh_clear() |
| 558 | if realized_mesh is not None: |
| 559 | bpy.data.meshes.remove(realized_mesh) |
| 560 | realized_mesh = None |
| 467 | 561 | |
| 468 | 562 | # Write material data (for meshes, after vertex/UV data) |
| 469 | 563 | if obj.type == "MESH" and obj.active_material is not None: |