"""Detect Blender scene changes and export to shared memory.
Uses depsgraph_update_post handler to detect transform and geometry changes,
accumulates them in memory, and flushes to SHM region 1 (Blender->Godot)
on timer tick to prevent rapid depsgraph fires from overwriting data.
"""
import struct
import numpy as np
from .shm_protocol import (
LIGHT_POINT,
LIGHT_SPOT,
LIGHT_SUN,
OBJ_CAMERA,
OBJ_EMPTY,
OBJ_LIGHT,
OBJ_MESH,
TRANSFORM_SIZE,
UPDATE_DELETE,
UPDATE_FULL,
UPDATE_TRANSFORM,
UPDATE_VERTEX,
ShmRegion,
)
try:
import bpy
import mathutils
except ImportError:
bpy = None
mathutils = None
def _blender_type_to_obj_type(obj) -> int:
"""Map Blender object type to protocol ObjectType."""
if obj.type == "MESH":
return OBJ_MESH
elif obj.type == "CAMERA":
return OBJ_CAMERA
elif obj.type in ("LIGHT", "LAMP"):
return OBJ_LIGHT
else:
return OBJ_EMPTY
def _blender_light_sub_type(obj) -> int:
"""Map Blender light type to protocol LightSubType."""
if obj.type not in ("LIGHT", "LAMP"):
return 0
light_type = obj.data.type
if light_type == "SUN":
return LIGHT_SUN
elif light_type == "SPOT":
return LIGHT_SPOT
return LIGHT_POINT
class ChangeDetector:
"""Detects Blender scene changes and exports to SHM region 1.
Changes are accumulated in memory during depsgraph_update_post callbacks,
then flushed to SHM atomically when flush() is called from the timer.
This prevents rapid depsgraph fires from overwriting UPDATE_FULL data
with UPDATE_TRANSFORM before Godot reads it.
"""
# Minimum bytes per mesh vertex (positions + normals + UVs + overhead)
# 3 pos floats + 3 normal floats + 2 UV floats = 8 floats × 4 bytes = 32 bytes
# plus count headers (5 × 4 = 20 bytes overhead per mesh) + name + transform
_BYTES_PER_VERTEX = 32
_MESH_OVERHEAD = 256 # name + transform + headers + material + alignment
def __init__(self, shm: ShmRegion, max_objects: int = 256):
self._shm = shm
self._max_objects = max_objects
self._sequence = 0
# Map Blender object name -> object_id
self._name_to_id: dict[str, int] = {}
self._next_id = 1
self._registered = False
# Pending changes accumulated between flushes.
# Key: object name, Value: (update_type, depsgraph ref for mesh extraction)
self._pending: dict[str, int] = {}
# Cache of depsgraph for mesh extraction during flush
self._last_depsgraph = None
# Feedback loop prevention: per-object suppression after applying
# incoming Godot transforms. Only suppresses echoes for the specific
# objects that Godot sent, allowing modifier-dependent updates through.
# Dict: object_name → remaining ticks
self._suppressed_objects: dict[str, int] = {}
# Transform hash cache for jitter reduction
self._transform_hashes: dict[str, int] = {}
# Geometry hash cache: prevents re-flushing unchanged geometry
# (geometry nodes fire is_updated_geometry every frame even when output is identical)
self._geometry_hashes: dict[str, int] = {}
# Dirty images: image names that were edited (texture paint, image editor)
# Drained by the timer for re-baking via TileBaker
self._dirty_images: set[str] = {}
# Known texture image names (set by timer from baker._texture_ids)
self._tracked_images: set[str] = set()
# Shading update flag: set when any material parameter changes
self._had_shading_update = False
# Callback for notifying Godot of SHM resize: fn(new_size, new_region_size)
self._resize_callback = None
# Texture ID lookup: image_name → int tex_id (shared ref from TileBaker)
self._texture_ids: dict[str, int] = {}
def register(self):
"""Register the depsgraph handler."""
if self._registered:
return
bpy.app.handlers.depsgraph_update_post.append(self._on_depsgraph_update)
self._registered = True
def unregister(self):
"""Unregister the depsgraph handler."""
if not self._registered:
return
try:
bpy.app.handlers.depsgraph_update_post.remove(self._on_depsgraph_update)
except ValueError:
pass
self._registered = False
def suppress(self, names: set[str], frames: int = 5):
"""Suppress depsgraph echoes for specific objects.
Only the named objects are suppressed. Other objects (e.g. those
with modifiers that depend on the moved object) still get their
updates captured and flushed to Godot.
Pre-caches transform and geometry hashes so that when suppression
expires, the objects won't be falsely detected as "changed".
"""
for name in names:
self._suppressed_objects[name] = max(
self._suppressed_objects.get(name, 0), frames
)
# Pre-cache hashes to prevent false detection after unsuppress
obj = bpy.data.objects.get(name) if bpy else None
if obj is not None:
self._transform_hashes[name] = self._hash_transform(obj.matrix_world)
if obj.type == "MESH" and obj.data is not None:
verts = obj.data.vertices
if len(verts) > 0:
coords = np.empty(len(verts) * 3, dtype=np.float32)
verts.foreach_get("co", coords)
self._geometry_hashes[name] = hash(coords.tobytes())
def unsuppress(self):
"""Decrement suppression counters (called each timer tick)."""
expired = []
for name, ticks in self._suppressed_objects.items():
if ticks <= 1:
expired.append(name)
else:
self._suppressed_objects[name] = ticks - 1
for name in expired:
del self._suppressed_objects[name]
def set_id_map(self, id_map: dict[int, str]):
"""Merge object ID mapping from scene importer (additive, not replace)."""
for obj_id, name in id_map.items():
if name not in self._name_to_id:
self._name_to_id[name] = obj_id
if self._name_to_id:
self._next_id = max(self._name_to_id.values()) + 1
def get_or_assign_id(self, name: str) -> int:
"""Get existing ID or assign a new one for a Blender object."""
if name not in self._name_to_id:
self._name_to_id[name] = self._next_id
self._next_id += 1
return self._name_to_id[name]
def get_id_map(self) -> dict[int, str]:
"""Return the current ID map as {object_id: name} for syncing with SceneImporter."""
return {obj_id: name for name, obj_id in self._name_to_id.items()}
def set_tracked_images(self, image_names: set[str]):
"""Set which image names should trigger re-bake on edit."""
self._tracked_images = image_names
def drain_dirty_images(self) -> set[str]:
"""Return and clear the set of dirty image names."""
dirty = self._dirty_images
self._dirty_images = set()
return dirty
def had_shading_update(self) -> bool:
"""Return and clear the shading update flag (set when material parameters change)."""
val = self._had_shading_update
self._had_shading_update = False
return val
def invalidate_meshes_with_image(self, image_name: str):
"""Force re-flush of meshes whose Base Color uses the given image.
Called after texture registration so the material blob gets the correct
tex_id (first flush had tex_id=0 because registration was pending).
"""
if bpy is None:
return
for obj in bpy.data.objects:
if obj.type != "MESH" or obj.active_material is None:
continue
mat = obj.active_material
if not mat.use_nodes or not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type != "BSDF_PRINCIPLED":
continue
bc = node.inputs.get("Base Color")
if bc is None or not bc.is_linked:
continue
for link in bc.links:
if (link.from_node.type == "TEX_IMAGE" and
link.from_node.image and
link.from_node.image.name == image_name):
# Clear geometry hash → forces UPDATE_VERTEX re-flush
self._geometry_hashes.pop(obj.name, None)
self._pending[obj.name] = UPDATE_VERTEX
break
def set_resize_callback(self, callback):
"""Set a callback for SHM resize notifications: callback(new_size, new_region_size)."""
self._resize_callback = callback
@staticmethod
def _hash_transform(mat) -> int:
"""FNV-1a hash of 4×4 matrix floats for change detection."""
h = 14695981039346656037
for row in mat:
for val in row:
bits = struct.pack("<f", val)
for b in bits:
h ^= b
h = (h * 1099511628211) & 0xFFFFFFFFFFFFFFFF
return h
# -- Depsgraph handler (accumulate only, no SHM writes) ----------------
def _on_depsgraph_update(self, scene, depsgraph):
"""Called after every depsgraph evaluation. Accumulates changes."""
self._last_depsgraph = depsgraph
for update in depsgraph.updates:
obj = update.id
# Detect image data changes (texture paint, image editor)
if isinstance(obj, bpy.types.Image):
if obj.name in self._tracked_images:
self._dirty_images.add(obj.name)
continue
# Detect material parameter changes (procedural texture re-bake)
if isinstance(obj, bpy.types.Material):
if update.is_updated_shading:
self._had_shading_update = True
continue
if not isinstance(obj, bpy.types.Object):
continue
name = obj.name
# Skip echoes for objects that Godot just told us about,
# but still cache the transform hash so unsuppress doesn't
# trigger false change detection
if name in self._suppressed_objects:
if update.is_updated_transform:
self._transform_hashes[name] = self._hash_transform(obj.matrix_world)
continue
existing = self._pending.get(name, -1)
# Detect geometry changes: explicit flag OR geometry nodes modifier
# with any update (geometry nodes often fire is_updated_transform
# instead of is_updated_geometry when inputs change)
is_geo_change = update.is_updated_geometry
if not is_geo_change and update.is_updated_transform:
if obj.type == "MESH" and any(
m.type == "NODES" for m in obj.modifiers
):
is_geo_change = True
if is_geo_change:
# Geometry change: upgrade to at least VERTEX, or FULL if new
if existing == -1:
if name not in self._name_to_id:
self._pending[name] = UPDATE_FULL
else:
self._pending[name] = UPDATE_VERTEX
elif existing == UPDATE_TRANSFORM:
self._pending[name] = UPDATE_FULL
# else: already VERTEX or FULL, keep it
# Material/shading change: needs geometry re-export to include
# updated material data in the blob
if update.is_updated_shading and obj.type == "MESH":
existing = self._pending.get(name, -1)
if existing == -1:
if name not in self._name_to_id:
self._pending[name] = UPDATE_FULL
else:
self._pending[name] = UPDATE_VERTEX
elif existing == UPDATE_TRANSFORM:
self._pending[name] = UPDATE_VERTEX
if update.is_updated_transform:
# Hash-based jitter reduction: skip if transform unchanged
new_hash = self._hash_transform(obj.matrix_world)
if new_hash == self._transform_hashes.get(name, -1):
continue
self._transform_hashes[name] = new_hash
if existing == -1:
if name not in self._name_to_id:
self._pending[name] = UPDATE_FULL
else:
self._pending[name] = UPDATE_TRANSFORM
# else: don't downgrade from VERTEX/FULL to TRANSFORM
# -- Geometry nodes instance realization --------------------------------
@staticmethod
def _realize_instances(obj, depsgraph):
"""Collect geometry nodes instances and combine into a single Blender mesh.
When geometry nodes use Instance on Points (or similar), to_mesh()
returns empty because instances aren't realized. This method iterates
depsgraph.object_instances, collects all instances parented to `obj`,
transforms their vertices into obj's local space, and creates a
combined mesh.
"""
parent_inv = obj.matrix_world.inverted()
all_verts = []
all_faces = []
vert_offset = 0
for inst in depsgraph.object_instances:
if not inst.is_instance:
continue
if inst.parent is None or inst.parent.original != obj:
continue
inst_obj = inst.object
inst_mesh = inst_obj.to_mesh()
if inst_mesh is None or len(inst_mesh.vertices) == 0:
if inst_mesh is not None:
inst_obj.to_mesh_clear()
continue
inst_mesh.calc_loop_triangles()
if len(inst_mesh.loop_triangles) == 0:
inst_obj.to_mesh_clear()
continue
# Transform instance verts to parent local space
local_mat = parent_inv @ inst.matrix_world
for v in inst_mesh.vertices:
pos = local_mat @ v.co
all_verts.append((pos.x, pos.y, pos.z))
for tri in inst_mesh.loop_triangles:
# Reverse winding (swap vertices 1↔2) so from_pydata()
# produces outward-facing normals. loop_triangles.vertices
# uses the internal loop order which, when fed to from_pydata
# as a new standalone mesh, creates inward-facing faces.
all_faces.append((
tri.vertices[0] + vert_offset,
tri.vertices[2] + vert_offset,
tri.vertices[1] + vert_offset,
))
vert_offset += len(inst_mesh.vertices)
inst_obj.to_mesh_clear()
if not all_verts:
return None
combined = bpy.data.meshes.new("_bridge_realized_tmp")
combined.from_pydata(all_verts, [], all_faces)
combined.update()
return combined
# -- SHM auto-resize ---------------------------------------------------
def _ensure_region_capacity(self, pending: dict):
"""Estimate blob size for pending objects and grow SHM if needed."""
from .shm_protocol import HEADER_SIZE, ENTRY_SIZE, REGION_SIZE
estimated_blob = 0
for name, update_type in pending.items():
if update_type == UPDATE_DELETE:
estimated_blob += 64 # small entry
continue
obj = bpy.data.objects.get(name)
if obj is None:
continue
estimated_blob += self._MESH_OVERHEAD
if update_type in (UPDATE_VERTEX, UPDATE_FULL) and obj.type == "MESH":
mesh = obj.data
if mesh is not None:
tri_count = len(mesh.loop_triangles) if mesh.loop_triangles else len(mesh.polygons) * 2
vert_count = tri_count * 3
estimated_blob += vert_count * self._BYTES_PER_VERTEX
table_size = HEADER_SIZE + self._max_objects * ENTRY_SIZE
needed_region = table_size + estimated_blob
current_region = self._shm._region_size
if needed_region <= current_region:
return # fits fine
# Round up to next power-of-2 MB boundary (min 16 MB)
new_region = max(16 * 1024 * 1024, current_region)
while new_region < needed_region:
new_region *= 2
# Tile ring stays 8 MB
tile_ring_size = 8 * 1024 * 1024
new_shm_size = new_region * 2 + tile_ring_size
print(f"BlenderBridge: Region overflow — resizing SHM "
f"{self._shm.size // (1024*1024)} MB → {new_shm_size // (1024*1024)} MB "
f"(region {current_region // (1024*1024)} → {new_region // (1024*1024)} MB, "
f"estimated blob {estimated_blob // 1024} KB)")
if self._shm.resize(new_shm_size, new_region):
# Notify Godot to remap
if self._resize_callback:
self._resize_callback(new_shm_size, new_region)
print(f"BlenderBridge: SHM resize successful")
else:
print(f"BlenderBridge: SHM resize FAILED — large objects may not transfer")
# -- Flush accumulated changes to SHM ----------------------------------
def flush(self) -> int:
"""Write all pending changes to SHM region 1 atomically.
Returns the number of objects written. Call this from the timer,
not from the depsgraph handler.
"""
# Detect deleted objects: tracked but no longer in bpy.data.objects
deleted_names = []
for name in list(self._name_to_id.keys()):
if bpy.data.objects.get(name) is None:
deleted_names.append(name)
if not self._pending and not deleted_names:
return 0
if not self._shm.valid:
return 0
pending = self._pending
self._pending = {}
# Add deletions to pending
for name in deleted_names:
pending[name] = UPDATE_DELETE
# Estimate blob size and resize SHM if needed
self._ensure_region_capacity(pending)
self._sequence += 1
# Region base for absolute mmap writes
region1_base = self._shm.region_base(1)
from .shm_protocol import HEADER_SIZE, ENTRY_SIZE
blob_rel_start = HEADER_SIZE + self._max_objects * ENTRY_SIZE
blob_rel_offset = blob_rel_start
blob_abs_offset = region1_base + blob_rel_start
depsgraph = self._last_depsgraph
entry_index = 0
written_updates: dict[str, int] = {}
for name, update_type in pending.items():
if entry_index >= self._max_objects:
break
# Handle deletion: object no longer exists
if update_type == UPDATE_DELETE:
obj_id = self._name_to_id.get(name)
if obj_id is None:
continue
# Write DELETE entry with no blob data
self._shm.write_object_entry(
index=entry_index,
object_id=obj_id,
object_type=OBJ_EMPTY,
update_type=UPDATE_DELETE,
data_offset=0,
data_size=0,
sequence=self._sequence,
sub_type=0,
region=1,
)
written_updates[name] = UPDATE_DELETE
entry_index += 1
del self._name_to_id[name]
self._transform_hashes.pop(name, None)
self._geometry_hashes.pop(name, None)
continue
# Look up the Blender object
obj = bpy.data.objects.get(name)
if obj is None:
continue
is_new = name not in self._name_to_id
obj_id = self.get_or_assign_id(name)
# New mesh objects always get FULL
if is_new and obj.type == "MESH":
update_type = UPDATE_FULL
obj_type = _blender_type_to_obj_type(obj)
sub_type = _blender_light_sub_type(obj)
data_rel_start = blob_rel_offset
# Write object name (length-prefixed, padded to 4-byte alignment)
name_bytes = name.encode("utf-8")[:255]
name_len = len(name_bytes)
self._shm.write_blob(blob_abs_offset, struct.pack("<B", name_len))
blob_abs_offset += 1
blob_rel_offset += 1
self._shm.write_blob(blob_abs_offset, name_bytes)
blob_abs_offset += name_len
blob_rel_offset += name_len
# Pad to 4-byte alignment
pad = (4 - ((1 + name_len) % 4)) % 4
if pad > 0:
self._shm.write_blob(blob_abs_offset, b"\x00" * pad)
blob_abs_offset += pad
blob_rel_offset += pad
# Write transform (always) and update hash cache
mat = np.array(obj.matrix_world, dtype=np.float32).flatten()
self._shm.write_blob_array(blob_abs_offset, mat)
blob_abs_offset += TRANSFORM_SIZE
blob_rel_offset += TRANSFORM_SIZE
self._transform_hashes[name] = self._hash_transform(obj.matrix_world)
# Write vertex data for geometry updates
if update_type in (UPDATE_VERTEX, UPDATE_FULL) and obj.type == "MESH":
if depsgraph is not None:
eval_obj = obj.evaluated_get(depsgraph)
mesh = eval_obj.to_mesh()
else:
mesh = obj.data
eval_obj = None
realized_mesh = None # Track standalone mesh that needs bpy.data.meshes.remove()
if mesh is not None:
# Geometry nodes with instances (Instance on Points etc):
# to_mesh() doesn't realize instances. Collect them from
# depsgraph and combine into a single mesh.
mesh.calc_loop_triangles()
if (
len(mesh.loop_triangles) == 0
and depsgraph is not None
and any(m.type == "NODES" for m in obj.modifiers)
):
if eval_obj is not None:
eval_obj.to_mesh_clear()
mesh = self._realize_instances(obj, depsgraph)
realized_mesh = mesh # Remember for cleanup
eval_obj = None # mesh is standalone, no clear needed
if mesh is not None:
# Geometry hash check: skip re-export if evaluated mesh
# is identical (geometry nodes fire is_updated_geometry
# every frame even when output doesn't change)
all_verts_hash = np.empty(
len(mesh.vertices) * 3, dtype=np.float32
)
mesh.vertices.foreach_get("co", all_verts_hash)
geo_hash = hash(all_verts_hash.tobytes())
if geo_hash == self._geometry_hashes.get(name):
# Mesh unchanged — downgrade to transform-only or skip
if eval_obj is not None:
eval_obj.to_mesh_clear()
if realized_mesh is not None:
bpy.data.meshes.remove(realized_mesh)
realized_mesh = None
if update_type == UPDATE_VERTEX:
# Only geometry was flagged but unchanged — skip entirely
blob_abs_offset = region1_base + data_rel_start
blob_rel_offset = data_rel_start
continue
# UPDATE_FULL: still need to write (new object), but skip geometry
update_type = UPDATE_TRANSFORM
if eval_obj is not None:
eval_obj = None
mesh = None
else:
self._geometry_hashes[name] = geo_hash
if mesh is not None:
mesh.calc_loop_triangles()
tri_count = len(mesh.loop_triangles)
if tri_count > 0:
# Export per-loop-triangle-corner data so modifier
# normals are correct (weighted normal, geometry
# nodes, auto-smooth, etc.)
out_vert_count = tri_count * 3
# Read all vertex positions
all_coords = np.empty(
len(mesh.vertices) * 3, dtype=np.float32
)
mesh.vertices.foreach_get("co", all_coords)
all_coords = all_coords.reshape(-1, 3)
# Read loop triangle vertex + loop indices
tri_vert_idx = np.empty(out_vert_count, dtype=np.int32)
mesh.loop_triangles.foreach_get("vertices", tri_vert_idx)
tri_loop_idx = np.empty(out_vert_count, dtype=np.int32)
mesh.loop_triangles.foreach_get("loops", tri_loop_idx)
# Read all loop normals (per face corner — includes
# weighted normals, auto-smooth, geometry nodes, etc.)
all_loop_normals = np.empty(
len(mesh.loops) * 3, dtype=np.float32
)
mesh.corner_normals.foreach_get(
"vector", all_loop_normals
)
all_loop_normals = all_loop_normals.reshape(-1, 3)
# Build per-corner positions and normals
out_positions = all_coords[tri_vert_idx].flatten()
out_normals = all_loop_normals[tri_loop_idx].flatten()
# Write vertex count
self._shm.write_blob(
blob_abs_offset,
struct.pack("<I", out_vert_count),
)
blob_abs_offset += 4
blob_rel_offset += 4
# Write positions
written = self._shm.write_blob_array(
blob_abs_offset, out_positions
)
blob_abs_offset += written
blob_rel_offset += written
# Write normals (always present with per-corner data)
self._shm.write_blob(
blob_abs_offset, struct.pack("<I", 1)
)
blob_abs_offset += 4
blob_rel_offset += 4
written = self._shm.write_blob_array(
blob_abs_offset, out_normals
)
blob_abs_offset += written
blob_rel_offset += written
# No index buffer (per-corner data is sequential)
self._shm.write_blob(
blob_abs_offset, struct.pack("<I", 0)
)
blob_abs_offset += 4
blob_rel_offset += 4
# No smooth flags (normals already encode shading)
self._shm.write_blob(
blob_abs_offset, struct.pack("<I", 0)
)
blob_abs_offset += 4
blob_rel_offset += 4
# Write UV coordinates (float32×2 per vertex)
uv_layer = mesh.uv_layers.active
if uv_layer is not None:
all_uvs = np.empty(
len(mesh.loops) * 2, dtype=np.float32
)
uv_layer.data.foreach_get("uv", all_uvs)
all_uvs = all_uvs.reshape(-1, 2)
# Index by tri_loop_idx for per-corner data
out_uvs = all_uvs[tri_loop_idx].flatten()
self._shm.write_blob(
blob_abs_offset,
struct.pack("<I", out_vert_count),
)
blob_abs_offset += 4
blob_rel_offset += 4
written = self._shm.write_blob_array(
blob_abs_offset, out_uvs
)
blob_abs_offset += written
blob_rel_offset += written
else:
# No UVs — write zero count
self._shm.write_blob(
blob_abs_offset, struct.pack("<I", 0)
)
blob_abs_offset += 4
blob_rel_offset += 4
else:
# Empty mesh — write vert_count=0 so C++ side
# knows to skip geometry (protocol consistency)
self._shm.write_blob(
blob_abs_offset, struct.pack("<I", 0)
)
blob_abs_offset += 4
blob_rel_offset += 4
if eval_obj is not None:
eval_obj.to_mesh_clear()
if realized_mesh is not None:
bpy.data.meshes.remove(realized_mesh)
realized_mesh = None
# Write material data (for meshes, after vertex/UV data)
if obj.type == "MESH" and obj.active_material is not None:
mat = obj.active_material
has_mat = 0
if mat.use_nodes and mat.node_tree:
bsdf = None
for node in mat.node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
bsdf = node
break
if bsdf is not None:
has_mat = 1
self._shm.write_blob(
blob_abs_offset, struct.pack("<I", 1)
)
blob_abs_offset += 4
blob_rel_offset += 4
base_color = bsdf.inputs["Base Color"].default_value
roughness = bsdf.inputs["Roughness"].default_value
metallic = bsdf.inputs["Metallic"].default_value
emission_color = bsdf.inputs["Emission Color"].default_value
emission_strength = bsdf.inputs["Emission Strength"].default_value
mat_data = np.array([
base_color[0], base_color[1], base_color[2], base_color[3],
roughness,
metallic,
emission_color[0], emission_color[1], emission_color[2], emission_color[3],
emission_strength,
], dtype=np.float32)
written = self._shm.write_blob_array(blob_abs_offset, mat_data)
blob_abs_offset += written
blob_rel_offset += written
# Write texture_id: look up image texture on Base Color input
tex_id = 0
base_color_input = bsdf.inputs.get("Base Color")
if base_color_input and base_color_input.is_linked:
for link in base_color_input.links:
if link.from_node.type == "TEX_IMAGE" and link.from_node.image:
tex_id = self._texture_ids.get(link.from_node.image.name, 0)
break
self._shm.write_blob(blob_abs_offset, struct.pack("<I", tex_id))
blob_abs_offset += 4
blob_rel_offset += 4
if not has_mat:
self._shm.write_blob(
blob_abs_offset, struct.pack("<I", 0)
)
blob_abs_offset += 4
blob_rel_offset += 4
elif obj.type == "MESH":
# No material assigned
self._shm.write_blob(
blob_abs_offset, struct.pack("<I", 0)
)
blob_abs_offset += 4
blob_rel_offset += 4
data_rel_size = blob_rel_offset - data_rel_start
# Write object entry to region 1
self._shm.write_object_entry(
index=entry_index,
object_id=obj_id,
object_type=obj_type,
update_type=update_type,
data_offset=data_rel_start,
data_size=data_rel_size,
sequence=self._sequence,
sub_type=sub_type,
region=1,
)
written_updates[name] = update_type
entry_index += 1
# Update header in region 1
if entry_index > 0:
self._shm.write_header(
region=1,
direction=1,
sequence=self._sequence,
object_count=entry_index,
)
details = ", ".join(
"{}(id={} t={})".format(n, self._name_to_id.get(n, "?"), ut)
for n, ut in written_updates.items()
)
print(f"BlenderBridge: Flushed {entry_index} change(s) seq={self._sequence} [{details}]")
return entry_index