"""Import Godot scene from shared memory into Blender.
Reads SHM region 0 (Godot→Blender) written by BlenderBridge::export_scene()
and reconstructs objects in the current Blender scene.
Coordinate conversion (already applied by C++ side):
Godot Y-up (x,y,z) → Blender Z-up (x,-z,y) — positions are pre-converted.
Transform matrix is in Blender convention (row-major 4×4 in blob).
"""
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,
)
# bpy is only available inside Blender
try:
import bpy
import mathutils
except ImportError:
bpy = None
mathutils = None
class SceneImporter:
"""Imports scene data from SHM region 0 (Godot→Blender) into Blender."""
def __init__(self, shm: ShmRegion, max_objects: int = 256):
self._shm = shm
self._max_objects = max_objects
# Map object_id → Blender object name
self._id_to_name: dict[int, str] = {}
self._last_sequence = 0
def full_import(self):
"""Import all objects from Godot, syncing the Blender scene.
Creates/updates objects from the SHM data and removes any stale
bridge objects that are no longer in the Godot scene.
"""
header = self._shm.read_header(region=0)
if header["magic"] != b"BGOD":
raise ValueError(f"Bad magic: {header['magic']}")
self._last_sequence = header["sequence"]
# Track which object IDs appear in this import
imported_ids = set()
for i in range(header["object_count"]):
entry = self._shm.read_object_entry(i, region=0)
imported_ids.add(entry["object_id"])
self._import_object(entry)
# Scene sync: remove bridge objects not in current Godot scene.
# Only do stale removal when Godot actually exported objects —
# an empty export (count=0) means Godot hasn't exported yet,
# not that all objects were deleted.
if header["object_count"] > 0:
stale_ids = set(self._id_to_name.keys()) - imported_ids
for obj_id in stale_ids:
name = self._id_to_name.pop(obj_id, None)
if name is None:
continue
obj = bpy.data.objects.get(name)
if obj is not None:
data = obj.data
bpy.data.objects.remove(obj, do_unlink=True)
if data is not None and data.users == 0:
if isinstance(data, bpy.types.Mesh):
bpy.data.meshes.remove(data)
elif isinstance(data, bpy.types.Camera):
bpy.data.cameras.remove(data)
elif isinstance(data, bpy.types.Light):
bpy.data.lights.remove(data)
if stale_ids:
print(f"BlenderBridge: Removed {len(stale_ids)} stale object(s)")
def poll_updates(self) -> set[str]:
"""Check for incremental updates from region 0.
Returns set of Blender object names that were updated (for
per-object suppression of depsgraph echoes).
"""
header = self._shm.read_header(region=0)
# No direction check needed — region 0 is always Godot→Blender
if header["sequence"] <= self._last_sequence:
return set()
self._last_sequence = header["sequence"]
updated_names: set[str] = set()
for i in range(header["object_count"]):
entry = self._shm.read_object_entry(i, region=0)
obj_id = entry["object_id"]
name = self._id_to_name.get(obj_id)
if name:
updated_names.add(name)
self._apply_update(entry)
return updated_names
# ── Name prefix helpers ──────────────────────────────────────────────
def _read_name_prefix(self, blob_offset: int) -> tuple[str, int]:
"""Read [uint8 name_len][name_bytes][pad to 4B] from blob.
Returns (name_string, cursor_after_name).
"""
raw = self._shm.read_blob(blob_offset, 4)
name_len = int(raw.view(np.uint8)[0])
cursor = blob_offset + 1
name = ""
if name_len > 0:
name_bytes = bytes(self._shm._mm[cursor : cursor + name_len])
name = name_bytes.decode("utf-8", errors="replace")
cursor += name_len
total = 1 + name_len
pad = (4 - (total % 4)) % 4
cursor += pad
return name, cursor
# ── Import a single object ───────────────────────────────────────────
def _import_object(self, entry: dict):
obj_id = entry["object_id"]
obj_type = entry["object_type"]
# Read name prefix from blob
godot_name, transform_offset = self._read_name_prefix(entry["data_offset"])
# Check if we already have an object for this ID — reuse it
existing_name = self._id_to_name.get(obj_id)
if existing_name:
existing_obj = bpy.data.objects.get(existing_name)
if existing_obj is not None:
# Rename if Godot name changed
if godot_name and existing_obj.name != godot_name:
existing_obj.name = godot_name
self._id_to_name[obj_id] = existing_obj.name
# Update transform in place (pass transform_offset, not data_offset)
self._apply_transform(existing_obj, transform_offset)
# Update geometry if mesh with vertex data
name_prefix_size = transform_offset - entry["data_offset"]
if (
obj_type == OBJ_MESH
and existing_obj.type == "MESH"
and entry["data_size"] > name_prefix_size + TRANSFORM_SIZE + 4
):
self._update_mesh_geometry(existing_obj, entry, transform_offset)
return
# No existing object — create a new one (pass transform_offset for mesh data)
if obj_type == OBJ_MESH:
obj = self._import_mesh(entry, godot_name, transform_offset)
elif obj_type == OBJ_CAMERA:
obj = self._import_camera(entry, godot_name)
elif obj_type == OBJ_LIGHT:
obj = self._import_light(entry, godot_name)
else:
obj = self._import_empty(entry, godot_name)
if obj is not None:
self._id_to_name[obj_id] = obj.name
# Apply transform (using transform_offset past name prefix)
self._apply_transform(obj, transform_offset)
def _import_mesh(self, entry: dict, godot_name: str = "", transform_offset: int = 0):
"""Create a Blender mesh from SHM vertex data."""
blob_size = entry["data_size"]
obj_name = godot_name or f"bridge_mesh_{entry['object_id']}"
# Calculate name prefix size to check if there's vertex data
name_prefix_size = transform_offset - entry["data_offset"] if transform_offset else 0
if blob_size <= name_prefix_size + TRANSFORM_SIZE:
# No vertex data, just transform — create empty mesh
mesh = bpy.data.meshes.new(obj_name)
obj = bpy.data.objects.new(mesh.name, mesh)
bpy.context.collection.objects.link(obj)
return obj
# Read vertex count (uint32 after transform)
vert_offset = transform_offset + TRANSFORM_SIZE
vert_count_raw = self._shm.read_blob(vert_offset, 4)
vert_count = int(struct.unpack("<I", vert_count_raw.tobytes())[0])
if vert_count == 0:
mesh = bpy.data.meshes.new(obj_name)
obj = bpy.data.objects.new(mesh.name, mesh)
bpy.context.collection.objects.link(obj)
return obj
# Read positions (already in Blender Z-up convention from C++ export)
pos_offset = vert_offset + 4
positions = self._shm.read_blob(pos_offset, vert_count * 3 * 4)
positions = positions.reshape(-1, 3)
cursor = pos_offset + vert_count * 3 * 4
# Read has_normals flag
normals = None
normals_flag_raw = self._shm.read_blob(cursor, 4)
has_normals = int(struct.unpack("<I", normals_flag_raw.tobytes())[0])
cursor += 4
if has_normals:
normals = self._shm.read_blob(cursor, vert_count * 3 * 4)
normals = normals.reshape(-1, 3)
cursor += vert_count * 3 * 4
# Read index count
idx_count_raw = self._shm.read_blob(cursor, 4)
idx_count = int(struct.unpack("<I", idx_count_raw.tobytes())[0])
cursor += 4
# Build faces from index buffer or raw triangle list
verts = [tuple(v) for v in positions]
faces = []
if idx_count > 0:
indices_raw = self._shm.read_blob(cursor, idx_count * 4)
indices = np.frombuffer(indices_raw.tobytes(), dtype=np.int32)
for fi in range(0, idx_count - 2, 3):
# Reverse winding: Y↔Z swap changes handedness
faces.append((int(indices[fi]), int(indices[fi + 2]), int(indices[fi + 1])))
cursor += idx_count * 4
else:
for fi in range(0, vert_count - 2, 3):
# Reverse winding for non-indexed triangles too
faces.append((fi, fi + 2, fi + 1))
# Read per-triangle smooth flags (after indices)
smooth_flags = None
data_end = entry["data_offset"] + entry["data_size"]
if cursor + 4 <= data_end:
smooth_count = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0]
cursor += 4
if smooth_count > 0 and cursor + smooth_count <= data_end:
smooth_flags = np.frombuffer(
bytes(self._shm._mm[cursor:cursor + smooth_count]),
dtype=np.uint8,
)
cursor += smooth_count
# Read UV coordinates (after smooth flags)
uv_data = None
if cursor + 4 <= data_end:
uv_count = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0]
cursor += 4
if uv_count > 0 and cursor + uv_count * 8 <= data_end:
uv_data = np.frombuffer(
bytes(self._shm._mm[cursor:cursor + uv_count * 8]),
dtype=np.float32,
).reshape(-1, 2).copy()
cursor += uv_count * 8
# Reverse UV winding to match reversed face winding (swap indices 1↔2 per triangle)
for ti in range(0, len(uv_data) - 2, 3):
uv_data[ti + 1], uv_data[ti + 2] = uv_data[ti + 2].copy(), uv_data[ti + 1].copy()
mesh = bpy.data.meshes.new(obj_name)
mesh.from_pydata(verts, [], faces)
mesh.update()
# Apply per-face smooth/flat shading
if smooth_flags is not None and len(smooth_flags) == len(mesh.polygons):
mesh.polygons.foreach_set("use_smooth", smooth_flags.tolist())
mesh.update()
# Apply UVs
if uv_data is not None and len(mesh.loops) > 0:
uv_layer = mesh.uv_layers.new()
# UVs are per-loop in Blender; our data is per-loop-triangle-corner
# which maps directly to mesh loops for triangle-only meshes
if len(uv_data) == len(mesh.loops):
uv_flat = uv_data.flatten()
uv_layer.data.foreach_set("uv", uv_flat.tolist())
mesh.update()
# Read and apply material data (44 mat + 4 tex_id = 48 bytes)
if cursor + 4 <= data_end:
has_material = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0]
cursor += 4
if has_material and cursor + 48 <= data_end:
mat_data = np.frombuffer(
bytes(self._shm._mm[cursor:cursor + 44]),
dtype=np.float32,
)
cursor += 44
cursor += 4 # skip texture_id (used by Godot import_changes only)
self._apply_material_to_mesh(mesh, mat_data)
obj = bpy.data.objects.new(mesh.name, mesh)
bpy.context.collection.objects.link(obj)
return obj
def _apply_material_to_mesh(self, mesh, mat_data):
"""Create a Principled BSDF material from material data array."""
mat = bpy.data.materials.new(name=mesh.name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf is not None:
bsdf.inputs["Base Color"].default_value = (
float(mat_data[0]), float(mat_data[1]),
float(mat_data[2]), float(mat_data[3]),
)
bsdf.inputs["Roughness"].default_value = float(mat_data[4])
bsdf.inputs["Metallic"].default_value = float(mat_data[5])
bsdf.inputs["Emission Color"].default_value = (
float(mat_data[6]), float(mat_data[7]),
float(mat_data[8]), float(mat_data[9]),
)
bsdf.inputs["Emission Strength"].default_value = float(mat_data[10])
if len(mesh.materials) == 0:
mesh.materials.append(mat)
else:
mesh.materials[0] = mat
def _import_camera(self, entry: dict, godot_name: str = ""):
"""Create a Blender camera."""
obj_name = godot_name or f"bridge_camera_{entry['object_id']}"
cam = bpy.data.cameras.new(obj_name)
obj = bpy.data.objects.new(cam.name, cam)
bpy.context.collection.objects.link(obj)
return obj
def _import_light(self, entry: dict, godot_name: str = ""):
"""Create a Blender light with correct type from sub_type field."""
obj_name = godot_name or f"bridge_light_{entry['object_id']}"
sub = entry.get("sub_type", LIGHT_POINT)
if sub == LIGHT_SUN:
bl_type = "SUN"
elif sub == LIGHT_SPOT:
bl_type = "SPOT"
else:
bl_type = "POINT"
light = bpy.data.lights.new(obj_name, bl_type)
obj = bpy.data.objects.new(light.name, light)
bpy.context.collection.objects.link(obj)
return obj
def _import_empty(self, entry: dict, godot_name: str = ""):
"""Create a Blender empty."""
obj_name = godot_name or f"bridge_empty_{entry['object_id']}"
obj = bpy.data.objects.new(obj_name, None)
bpy.context.collection.objects.link(obj)
return obj
# ── Transform handling ───────────────────────────────────────────────
def _apply_transform(self, obj, blob_offset: int):
"""Read 4×4 matrix from blob and apply to Blender object."""
raw = self._shm.read_blob(blob_offset, TRANSFORM_SIZE)
# C++ writes row-major 4×4 float matrix
mat = np.array(raw, dtype=np.float32).reshape(4, 4)
# Blender's Matrix is column-major internally but constructed row-major
obj.matrix_world = mathutils.Matrix(mat.tolist())
# ── Incremental updates ──────────────────────────────────────────────
def _apply_update(self, entry: dict):
obj_id = entry["object_id"]
existing_name = self._id_to_name.get(obj_id)
# Read name prefix from blob
godot_name, transform_offset = self._read_name_prefix(entry["data_offset"])
if existing_name is None:
# New object — full import
if entry["update_type"] == UPDATE_FULL:
self._import_object(entry)
return
obj = bpy.data.objects.get(existing_name)
if obj is None:
return
update_type = entry["update_type"]
if update_type == UPDATE_DELETE:
bpy.data.objects.remove(obj, do_unlink=True)
del self._id_to_name[obj_id]
return
# Update name if Godot renamed it
if godot_name and obj.name != godot_name:
obj.name = godot_name
self._id_to_name[obj_id] = obj.name
if update_type in (UPDATE_TRANSFORM, UPDATE_FULL):
self._apply_transform(obj, transform_offset)
if update_type in (UPDATE_VERTEX, UPDATE_FULL):
name_prefix_size = transform_offset - entry["data_offset"]
if obj.type == "MESH" and entry["data_size"] > name_prefix_size + TRANSFORM_SIZE + 4:
self._update_mesh_geometry(obj, entry, transform_offset)
def _update_mesh_geometry(self, obj, entry: dict, transform_offset: int = 0):
"""Update existing mesh geometry in-place."""
if transform_offset == 0:
# Legacy: no name prefix
transform_offset = entry["data_offset"]
vert_offset = transform_offset + TRANSFORM_SIZE
vert_count_raw = self._shm.read_blob(vert_offset, 4)
vert_count = int(struct.unpack("<I", vert_count_raw.tobytes())[0])
if vert_count == 0:
return
pos_offset = vert_offset + 4
positions = self._shm.read_blob(pos_offset, vert_count * 3 * 4)
positions = positions.reshape(-1, 3)
cursor = pos_offset + vert_count * 3 * 4
# Skip normals flag + normals data
normals_flag_raw = self._shm.read_blob(cursor, 4)
has_normals = int(struct.unpack("<I", normals_flag_raw.tobytes())[0])
cursor += 4
if has_normals:
cursor += vert_count * 3 * 4
# Read index count and indices
idx_count_raw = self._shm.read_blob(cursor, 4)
idx_count = int(struct.unpack("<I", idx_count_raw.tobytes())[0])
cursor += 4
indices = None
if idx_count > 0:
indices_raw = self._shm.read_blob(cursor, idx_count * 4)
indices = np.frombuffer(indices_raw.tobytes(), dtype=np.int32)
cursor += idx_count * 4
# Read per-triangle smooth flags (after indices)
smooth_flags = None
data_end = entry["data_offset"] + entry["data_size"]
if cursor + 4 <= data_end:
smooth_count = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0]
cursor += 4
if smooth_count > 0 and cursor + smooth_count <= data_end:
smooth_flags = np.frombuffer(
bytes(self._shm._mm[cursor:cursor + smooth_count]),
dtype=np.uint8,
)
cursor += smooth_count
# Read UV coordinates (after smooth flags)
uv_data = None
if cursor + 4 <= data_end:
uv_count = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0]
cursor += 4
if uv_count > 0 and cursor + uv_count * 8 <= data_end:
uv_data = np.frombuffer(
bytes(self._shm._mm[cursor:cursor + uv_count * 8]),
dtype=np.float32,
).reshape(-1, 2).copy()
cursor += uv_count * 8
# Reverse UV winding to match reversed face winding
for ti in range(0, len(uv_data) - 2, 3):
uv_data[ti + 1], uv_data[ti + 2] = uv_data[ti + 2].copy(), uv_data[ti + 1].copy()
mesh = obj.data
if len(mesh.vertices) == vert_count:
flat = positions.flatten()
mesh.vertices.foreach_set("co", flat)
mesh.update()
else:
verts = [tuple(v) for v in positions]
faces = []
if indices is not None:
for fi in range(0, idx_count - 2, 3):
# Reverse winding: Y↔Z swap changes handedness
faces.append((int(indices[fi]), int(indices[fi + 2]), int(indices[fi + 1])))
else:
for fi in range(0, vert_count - 2, 3):
faces.append((fi, fi + 2, fi + 1))
mesh.clear_geometry()
mesh.from_pydata(verts, [], faces)
mesh.update()
# Apply per-face smooth/flat shading
if smooth_flags is not None and len(smooth_flags) == len(mesh.polygons):
mesh.polygons.foreach_set("use_smooth", smooth_flags.tolist())
mesh.update()
# Apply UVs
if uv_data is not None and len(mesh.loops) > 0:
if not mesh.uv_layers:
mesh.uv_layers.new()
uv_layer = mesh.uv_layers.active
if len(uv_data) == len(mesh.loops):
uv_flat = uv_data.flatten()
uv_layer.data.foreach_set("uv", uv_flat.tolist())
mesh.update()
# Read and apply material data (44 mat + 4 tex_id = 48 bytes)
if cursor + 4 <= data_end:
has_material = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0]
cursor += 4
if has_material and cursor + 48 <= data_end:
mat_data = np.frombuffer(
bytes(self._shm._mm[cursor:cursor + 44]),
dtype=np.float32,
)
cursor += 44
cursor += 4 # skip texture_id (used by Godot import_changes only)
self._apply_material_to_mesh(mesh, mat_data)