Brokkr/surfaces/blender/brokkr_bridge/blender_target.py
Meta Vens be0cc3683f Brokkr advertisement: drop Eve surface URIs and eve.*.publish capabilities; Eve is deprecated
Per thing2:ruling:eve-dashboards-quarantined. Removes eveSurfaces from the provider advertisement and its schema, the two surface URIs, the eve.gui/eve.tui publish capabilities, and Eve wording in docs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:14:59 +02:00

782 lines
33 KiB
Python

from __future__ import annotations
import json
import math
import os
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
PROVIDER_ID = "brokkr.blender_editor"
TOOL_KIND = "blender-editor"
DEFAULT_BROKER_URI = "cultmesh://brokkr"
DEFAULT_CULTMESH_CACHE_PATH = "//.brokkr/blender-editor.ccmp"
# The CultLib `packages` directory. The Python runtime is three layered
# packages there (cultcache-py, cultnet-py, cultmesh-py); each one's `src` is
# added to sys.path when they are not installed into Blender's interpreter.
DEFAULT_CULTLIB_PY_SRC = "F:/Projects/CultLib/packages"
CULTLIB_PY_PACKAGES = ("cultcache-py", "cultnet-py", "cultmesh-py")
DEFAULT_DEBUG_MIRROR_ROOT = "//.brokkr/blender-editor-debug"
HOST_SNAPSHOT_SCHEMA = "brokkr.blender.host_snapshot.v0"
COMMAND_INTENT_SCHEMA = "brokkr.blender.command_intent.v0"
COMMAND_RECEIPT_SCHEMA = "brokkr.blender.command_receipt.v0"
SYNC_SESSION_SCHEMA = "brokkr.sync.session.v0"
SYNC_OBJECT_BINDING_SCHEMA = "brokkr.sync.object_binding.v0"
SYNC_VAR_SCHEMA = "brokkr.sync.var.v0"
SYNC_TIMELINE_BINDING_SCHEMA = "brokkr.sync.timeline_binding.v0"
SYNC_RECEIPT_SCHEMA = "brokkr.sync.receipt.v0"
CAPABILITIES = (
"cultcache.mirror.publish",
"cultcache.intent.watch",
"host.status.read",
"scene.tree.read",
"object.graph.read",
"object.transform.write",
"object.create",
"object.delete",
"material.catalog.read",
"material.assign",
"selection.read",
"selection.write",
"command.receipt.publish",
)
class BrokkrBlenderTarget:
"""Blender-owned adapter target for Brokkr mirror documents."""
def __init__(self, bpy_module: Any):
self.bpy = bpy_module
self.last_snapshot: dict[str, Any] | None = None
self.last_receipt: dict[str, Any] | None = None
self.last_sync_receipt: dict[str, Any] | None = None
self.last_auto_drain_error: str = ""
self._node_key: tuple[str, str, str] | None = None
self._node: Any | None = None
self._documents: dict[str, Any] | None = None
self._server_key: tuple[str, str, str, str, int, int, int] | None = None
self._server: Any | None = None
def resolve_cache_path(self, cache_path: str) -> str:
return self.bpy.path.abspath(cache_path or DEFAULT_CULTMESH_CACHE_PATH)
def resolve_debug_mirror_root(self, debug_mirror_root: str) -> str:
return self.bpy.path.abspath(debug_mirror_root or DEFAULT_DEBUG_MIRROR_ROOT)
def publish_snapshot(
self,
context: Any,
cache_path: str,
cultlib_py_src: str,
debug_mirror_root: str = "",
) -> dict[str, Any]:
snapshot = self.capture_snapshot(context)
node, documents = self._open_node(cache_path, cultlib_py_src)
node.database.put(documents["host_snapshot"], "blender/host/current", snapshot)
if debug_mirror_root:
self._write_debug_document(debug_mirror_root, "blender/host/current.json", snapshot)
self.last_snapshot = snapshot
return snapshot
def drain_commands(
self,
context: Any,
cache_path: str,
cultlib_py_src: str,
debug_mirror_root: str = "",
) -> list[dict[str, Any]]:
node, documents = self._open_node(cache_path, cultlib_py_src)
if debug_mirror_root:
self._import_debug_command_files(node, documents["command_intent"], debug_mirror_root)
snapshot = node.database.snapshot()
commands = snapshot.get(COMMAND_INTENT_SCHEMA, {})
receipts: list[dict[str, Any]] = []
for command_key, command in sorted(commands.items()):
receipt = self.execute_command(context, command)
self.publish_receipt(cache_path, cultlib_py_src, receipt, debug_mirror_root)
node.database.delete(documents["command_intent"], command_key)
receipts.append(receipt)
return receipts
def publish_receipt(
self,
cache_path: str,
cultlib_py_src: str,
receipt: dict[str, Any],
debug_mirror_root: str = "",
) -> None:
command_id = receipt.get("commandId") or uuid.uuid4().hex
node, documents = self._open_node(cache_path, cultlib_py_src)
node.database.put(documents["command_receipt"], f"blender/receipts/{command_id}", receipt)
if debug_mirror_root:
self._write_debug_document(debug_mirror_root, f"blender/receipts/{command_id}.json", receipt)
self.last_receipt = receipt
def refresh_sync_receipt(
self,
cache_path: str,
cultlib_py_src: str,
) -> dict[str, Any] | None:
node, _documents = self._open_node(cache_path, cultlib_py_src)
receipts = node.database.snapshot().get(SYNC_RECEIPT_SCHEMA, {})
if not receipts:
self.last_sync_receipt = None
return None
self.last_sync_receipt = max(
receipts.values(),
key=lambda receipt: receipt.get("observedAt", ""),
)
return self.last_sync_receipt
def publish_object_sync(
self,
context: Any,
cache_path: str,
cultlib_py_src: str,
session_id: str,
display_name: str,
unity_object_id: str,
unity_path: str,
sync_transform: bool,
sync_parent: bool,
sync_material: bool,
sync_visibility: bool,
sync_custom_properties: bool,
unity_custom_property_component_type: str = "",
unity_custom_property_path: str = "",
blender_custom_property_path: str = "",
) -> dict[str, Any]:
obj = context.active_object
if obj is None:
raise RuntimeError("Select a Blender object before publishing object sync.")
now = _now()
node, documents = self._open_node(cache_path, cultlib_py_src)
normalized_session_id = session_id or "default"
binding_id = _stable_id("object", normalized_session_id, obj.name, unity_object_id)
session = _sync_session(normalized_session_id, display_name, now)
binding = {
"schema": SYNC_OBJECT_BINDING_SCHEMA,
"bindingId": binding_id,
"sessionId": normalized_session_id,
"displayName": obj.name,
"unityObjectId": unity_object_id,
"unityPath": unity_path,
"blenderObjectName": obj.name,
"blenderCollectionName": obj.users_collection[0].name if obj.users_collection else "",
"enabled": True,
"authority": "blender-to-unity",
"updatedAt": now,
}
node.database.put(documents["sync_session"], f"sync/sessions/{normalized_session_id}", session)
node.database.put(documents["sync_object_binding"], f"sync/bindings/objects/{binding_id}", binding)
self._put_sync_var(node, documents, normalized_session_id, binding_id, "transform", "Transform", "Transform", "location,rotationEuler,scale", "blender-to-unity", sync_transform, "linear", now)
self._put_sync_var(node, documents, normalized_session_id, binding_id, "parent", "Parent", "parentId", "parentName", "blender-to-unity", sync_parent, "step", now)
self._put_sync_var(node, documents, normalized_session_id, binding_id, "active-state", "Active State", "m_IsActive", "visible", "blender-to-unity", sync_visibility, "step", now)
self._put_sync_var(node, documents, normalized_session_id, binding_id, "material", "Material", "Renderer.m_Materials", "materials", "blender-to-unity", sync_material, "step", now)
unity_custom_path = _unity_custom_property_path(
unity_custom_property_component_type,
unity_custom_property_path,
)
blender_custom_path = _blender_custom_property_path(blender_custom_property_path)
self._put_sync_var(node, documents, normalized_session_id, binding_id, "custom-property", "Custom Property", unity_custom_path, blender_custom_path, "blender-to-unity", sync_custom_properties, "step", now)
return binding
def publish_timeline_sync(
self,
context: Any,
cache_path: str,
cultlib_py_src: str,
session_id: str,
display_name: str,
unity_timeline_object_id: str,
unity_cinemachine_object_id: str,
blender_action_name: str,
sync_frame: bool,
sync_camera: bool,
) -> dict[str, Any]:
scene = context.scene
if scene is None:
raise RuntimeError("Open a Blender scene before publishing timeline sync.")
now = _now()
node, documents = self._open_node(cache_path, cultlib_py_src)
normalized_session_id = session_id or "default"
action_name = blender_action_name or _active_action_name(context)
binding_id = _stable_id("timeline", normalized_session_id, scene.name, action_name, unity_timeline_object_id)
session = _sync_session(normalized_session_id, display_name, now)
binding = {
"schema": SYNC_TIMELINE_BINDING_SCHEMA,
"bindingId": binding_id,
"sessionId": normalized_session_id,
"displayName": action_name or scene.name,
"unityTimelineObjectId": unity_timeline_object_id,
"unityCinemachineObjectId": unity_cinemachine_object_id,
"blenderSceneName": scene.name,
"blenderActionName": action_name,
"clockAuthority": "blender",
"frameRate": float(scene.render.fps),
"syncFrame": bool(sync_frame),
"syncCamera": bool(sync_camera),
"enabled": bool(sync_frame or sync_camera),
"updatedAt": now,
}
node.database.put(documents["sync_session"], f"sync/sessions/{normalized_session_id}", session)
node.database.put(documents["sync_timeline_binding"], f"sync/bindings/timelines/{binding_id}", binding)
self._put_sync_var(node, documents, normalized_session_id, binding_id, "timeline-frame", "Timeline Frame", "Timeline.time", "scene.frame_current", "blender-to-unity", sync_frame, "linear", now)
self._put_sync_var(node, documents, normalized_session_id, binding_id, "cinemachine-virtual-camera", "Cinemachine Camera", "CinemachineVirtualCamera", "camera", "blender-to-unity", sync_camera, "linear", now)
return binding
def publish_sync_var(
self,
cache_path: str,
cultlib_py_src: str,
session_id: str,
session_display_name: str,
sync_var_display_name: str,
binding_id: str,
kind: str,
unity_property_path: str,
blender_property_path: str,
authority: str,
enabled: bool,
interpolation: str,
) -> dict[str, Any]:
now = _now()
node, documents = self._open_node(cache_path, cultlib_py_src)
normalized_session_id = session_id or "default"
normalized_kind = kind or "custom-property"
normalized_binding_id = binding_id or _stable_id(
"adhoc-binding",
normalized_session_id,
normalized_kind,
unity_property_path,
blender_property_path,
)
session = _sync_session(normalized_session_id, session_display_name, now)
sync_var_id = _stable_id("var", normalized_session_id, normalized_binding_id, normalized_kind)
sync_var = {
"schema": SYNC_VAR_SCHEMA,
"syncVarId": sync_var_id,
"sessionId": normalized_session_id,
"bindingId": normalized_binding_id,
"displayName": sync_var_display_name or normalized_kind,
"kind": normalized_kind,
"unityPropertyPath": unity_property_path,
"blenderPropertyPath": blender_property_path,
"authority": authority or "blender-to-unity",
"enabled": bool(enabled),
"interpolation": interpolation or "step",
"updatedAt": now,
}
node.database.put(documents["sync_session"], f"sync/sessions/{normalized_session_id}", session)
node.database.put(documents["sync_var"], f"sync/vars/{sync_var_id}", sync_var)
return sync_var
def start_server(
self,
cache_path: str,
cultlib_py_src: str,
host: str,
port: int,
max_snapshot_documents: int,
max_snapshot_bytes: int,
) -> dict[str, Any]:
node, documents = self._open_node(cache_path, cultlib_py_src)
resolved_host = host or "127.0.0.1"
server_key = (
self.resolve_cache_path(cache_path),
str(Path(cultlib_py_src or DEFAULT_CULTLIB_PY_SRC)),
node.runtime_id,
resolved_host,
int(port),
int(max_snapshot_documents),
int(max_snapshot_bytes),
)
if self._server is not None and self._server_key == server_key:
return self.server_status()
self.stop_server()
cultmesh, _cultcache = _load_cultmesh(cultlib_py_src)
verse_catalog = cultmesh.CultMesh.create_verse_catalog()
peer_catalog = cultmesh.CultMesh.create_peer_catalog()
server = cultmesh.CultMesh.serve_node(
node,
verse_catalog=verse_catalog,
peer_catalog=peer_catalog,
host=resolved_host,
port=int(port),
display_name="Brokkr Blender Editor",
max_snapshot_documents=int(max_snapshot_documents),
max_snapshot_bytes=int(max_snapshot_bytes),
)
endpoint = f"cultnet://{resolved_host}:{server.port}"
verse_catalog.upsert(cultmesh.CultMeshVerseDescriptor(
verse_id="brokkr.blender",
display_name="Brokkr Blender",
authority_model="host-owned-editor-mirror",
compatibility=cultmesh.CultMeshVerseCompatibility(
transport_version="cultmesh.v0",
rules_hash="brokkr.blender.v0",
),
discovery_endpoints=(endpoint,),
authority_runtime_ids=(node.runtime_id,),
description="Blender editor mirror served by Brokkr through CultMesh Python.",
))
peer_catalog.upsert(cultmesh.CultMeshPeerCard(
peer_id=node.runtime_id,
verse_id="brokkr.blender",
endpoints=(endpoint,),
roles=("shard-primary", "editor-host", "read-replica"),
shard_ids=("primary",),
region="local",
))
self._server = server
self._server_key = server_key
return self.server_status()
def stop_server(self) -> None:
if self._server is not None:
self._server.stop()
self._server = None
self._server_key = None
def server_status(self) -> dict[str, Any]:
if self._server is None:
return {
"running": False,
"endpoint": "",
"host": "",
"port": 0,
}
return {
"running": True,
"endpoint": f"cultnet://{self._server.host}:{self._server.port}",
"host": self._server.host,
"port": self._server.port,
}
def capture_snapshot(self, context: Any) -> dict[str, Any]:
bpy = self.bpy
selected = [item.name for item in context.selected_objects if item is not None]
active_object = context.active_object.name if context.active_object else ""
active_scene = context.scene.name if context.scene else ""
return {
"schema": HOST_SNAPSHOT_SCHEMA,
"providerId": PROVIDER_ID,
"toolKind": TOOL_KIND,
"projectPath": bpy.data.filepath,
"observedAt": _now(),
"blenderVersion": ".".join(str(part) for part in bpy.app.version),
"activeScene": active_scene,
"activeObjectName": active_object,
"mode": context.mode,
"selectedObjectNames": selected,
"sceneCount": len(bpy.data.scenes),
"objectCount": len(bpy.data.objects),
"collectionCount": len(bpy.data.collections),
"materialCount": len(bpy.data.materials),
"imageCount": len(bpy.data.images),
"capabilities": list(CAPABILITIES),
"scenes": [self._scene_snapshot(scene) for scene in bpy.data.scenes],
"objects": [self._object_snapshot(obj) for obj in bpy.data.objects],
"materials": [self._material_snapshot(material) for material in bpy.data.materials],
"collections": [self._collection_snapshot(collection) for collection in bpy.data.collections],
}
def execute_command(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
action = command.get("action", "")
try:
if action == "createObject":
return self._create_object(context, command)
if action == "deleteObject":
return self._delete_object(context, command)
if action == "setObjectTransform":
return self._set_object_transform(context, command)
if action == "setObjectVisibility":
return self._set_object_visibility(context, command)
if action == "setObjectParent":
return self._set_object_parent(context, command)
if action == "setObjectCustomProperty":
return self._set_object_custom_property(context, command)
if action == "selectObject":
return self._select_object(context, command)
if action == "assignMaterial":
return self._assign_material(context, command)
return self._receipt(command, "failed", f"Unsupported Blender command action: {action}", "")
except Exception as error:
return self._receipt(command, "failed", str(error), "")
def _create_object(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
object_type = command.get("objectType", "EMPTY")
name = command.get("name") or f"Brokkr {object_type.title()}"
location = _vector(command.get("location"), 3, (0.0, 0.0, 0.0))
if object_type == "MESH":
self.bpy.ops.mesh.primitive_cube_add(size=float(command.get("size", 1.0)), location=location)
elif object_type == "LIGHT":
self.bpy.ops.object.light_add(type=command.get("lightType", "POINT"), location=location)
elif object_type == "CAMERA":
self.bpy.ops.object.camera_add(location=location)
else:
self.bpy.ops.object.empty_add(type=command.get("emptyDisplayType", "PLAIN_AXES"), location=location)
obj = context.object
obj.name = name
return self._receipt(command, "accepted", "Blender object created.", obj.name)
def _delete_object(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
obj = self._resolve_object(command)
if obj is None:
return self._receipt(command, "failed", "Target Blender object was not found.", "")
self.bpy.data.objects.remove(obj, do_unlink=True)
return self._receipt(command, "accepted", "Blender object deleted.", command.get("targetObjectName", ""))
def _set_object_transform(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
obj = self._resolve_object(command)
if obj is None:
return self._receipt(command, "failed", "Target Blender object was not found.", "")
if "location" in command:
obj.location = _vector(command["location"], 3, obj.location)
if "rotationEuler" in command:
obj.rotation_euler = _vector(command["rotationEuler"], 3, obj.rotation_euler)
if "scale" in command:
obj.scale = _vector(command["scale"], 3, obj.scale)
return self._receipt(command, "accepted", "Blender object transform updated.", obj.name)
def _set_object_visibility(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
obj = self._resolve_object(command)
if obj is None:
return self._receipt(command, "failed", "Target Blender object was not found.", "")
visible = bool(command.get("visible", True))
obj.hide_viewport = not visible
obj.hide_render = not visible
return self._receipt(command, "accepted", "Blender object visibility updated.", obj.name)
def _set_object_parent(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
obj = self._resolve_object(command)
if obj is None:
return self._receipt(command, "failed", "Target Blender object was not found.", "")
parent_name = command.get("parentObjectName") or command.get("parentName") or ""
parent = self.bpy.data.objects.get(parent_name) if parent_name else None
if parent_name and parent is None:
return self._receipt(command, "failed", f"Parent Blender object was not found: {parent_name}", obj.name)
obj.parent = parent
return self._receipt(command, "accepted", "Blender object parent updated.", obj.name)
def _set_object_custom_property(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
obj = self._resolve_object(command)
if obj is None:
return self._receipt(command, "failed", "Target Blender object was not found.", "")
property_name = command.get("propertyName") or "brokkrValue"
if property_name.startswith("customProperties."):
property_name = property_name[len("customProperties."):]
obj[property_name] = command.get("value")
return self._receipt(command, "accepted", "Blender custom property updated.", obj.name)
def _select_object(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
obj = self._resolve_object(command)
if obj is None:
return self._receipt(command, "failed", "Target Blender object was not found.", "")
if command.get("clearSelection", True):
for existing in context.selected_objects:
existing.select_set(False)
obj.select_set(True)
context.view_layer.objects.active = obj
return self._receipt(command, "accepted", "Blender object selected.", obj.name)
def _assign_material(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
obj = self._resolve_object(command)
if obj is None:
return self._receipt(command, "failed", "Target Blender object was not found.", "")
material_name = command.get("materialName") or "Brokkr Material"
material = self.bpy.data.materials.get(material_name) or self.bpy.data.materials.new(material_name)
if obj.data and hasattr(obj.data, "materials"):
obj.data.materials.append(material)
return self._receipt(command, "accepted", "Blender material assigned.", obj.name)
return self._receipt(command, "failed", "Target object cannot receive materials.", obj.name)
def _resolve_object(self, command: dict[str, Any]) -> Any:
name = command.get("targetObjectName") or command.get("objectName")
return self.bpy.data.objects.get(name) if name else None
def _scene_snapshot(self, scene: Any) -> dict[str, Any]:
return {
"name": scene.name,
"frameCurrent": scene.frame_current,
"frameStart": scene.frame_start,
"frameEnd": scene.frame_end,
"cameraName": scene.camera.name if scene.camera else "",
"renderEngine": scene.render.engine,
"collectionName": scene.collection.name if scene.collection else "",
}
def _object_snapshot(self, obj: Any) -> dict[str, Any]:
data = obj.data if hasattr(obj, "data") else None
return {
"name": obj.name,
"type": obj.type,
"dataName": data.name if data else "",
"libraryPath": obj.library.filepath if obj.library else "",
"location": _to_list(obj.location),
"rotationEuler": _to_list(obj.rotation_euler),
"scale": _to_list(obj.scale),
"visible": bool(obj.visible_get()),
"selected": bool(obj.select_get()),
"parentName": obj.parent.name if obj.parent else "",
"collections": [collection.name for collection in obj.users_collection],
"materials": [slot.material.name for slot in obj.material_slots if slot.material],
"modifiers": [self._modifier_snapshot(modifier) for modifier in obj.modifiers],
"constraints": [constraint.name for constraint in obj.constraints],
"camera": self._camera_snapshot(data) if obj.type == "CAMERA" and data else None,
"mesh": self._mesh_snapshot(data) if obj.type == "MESH" and data else None,
"customProperties": self._custom_properties(obj.items()),
}
def _camera_snapshot(self, camera: Any) -> dict[str, Any]:
return {
"lensMillimeters": float(camera.lens),
"fieldOfViewDegrees": math.degrees(float(camera.angle)),
"clipStart": float(camera.clip_start),
"clipEnd": float(camera.clip_end),
"type": camera.type,
}
def _mesh_snapshot(self, mesh: Any) -> dict[str, Any]:
return {
"vertexCount": len(mesh.vertices),
"edgeCount": len(mesh.edges),
"polygonCount": len(mesh.polygons),
"uvLayerCount": len(mesh.uv_layers),
"shapeKeyCount": len(mesh.shape_keys.key_blocks) if mesh.shape_keys else 0,
}
def _modifier_snapshot(self, modifier: Any) -> dict[str, Any]:
return {
"name": modifier.name,
"type": modifier.type,
"showViewport": modifier.show_viewport,
"showRender": modifier.show_render,
}
def _material_snapshot(self, material: Any) -> dict[str, Any]:
return {
"name": material.name,
"useNodes": material.use_nodes,
"diffuseColor": _to_list(material.diffuse_color),
"libraryPath": material.library.filepath if material.library else "",
}
def _collection_snapshot(self, collection: Any) -> dict[str, Any]:
return {
"name": collection.name,
"objectNames": [obj.name for obj in collection.objects],
"childCollectionNames": [child.name for child in collection.children],
}
def _custom_properties(self, items: Iterable[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in items:
if key.startswith("_"):
continue
result[key] = _jsonable(value)
return result
def _receipt(self, command: dict[str, Any], status: str, message: str, object_name: str) -> dict[str, Any]:
return {
"schema": COMMAND_RECEIPT_SCHEMA,
"commandId": command.get("commandId") or uuid.uuid4().hex,
"action": command.get("action", ""),
"status": status,
"message": message,
"objectName": object_name,
"observedAt": _now(),
}
def _open_node(self, cache_path: str, cultlib_py_src: str) -> tuple[Any, dict[str, Any]]:
path = self.resolve_cache_path(cache_path)
source = str(Path(cultlib_py_src or DEFAULT_CULTLIB_PY_SRC))
runtime_id = PROVIDER_ID
node_key = (path, source, runtime_id)
if self._node is not None and self._documents is not None and self._node_key == node_key:
self._node.database.pull()
return self._node, self._documents
cultmesh, cultcache = _load_cultmesh(cultlib_py_src)
documents = {
"host_snapshot": cultcache.define_document_type(HOST_SNAPSHOT_SCHEMA),
"command_intent": cultcache.define_document_type(COMMAND_INTENT_SCHEMA),
"command_receipt": cultcache.define_document_type(COMMAND_RECEIPT_SCHEMA),
"sync_session": cultcache.define_document_type(SYNC_SESSION_SCHEMA),
"sync_object_binding": cultcache.define_document_type(SYNC_OBJECT_BINDING_SCHEMA),
"sync_var": cultcache.define_document_type(SYNC_VAR_SCHEMA),
"sync_timeline_binding": cultcache.define_document_type(SYNC_TIMELINE_BINDING_SCHEMA),
"sync_receipt": cultcache.define_document_type(SYNC_RECEIPT_SCHEMA),
}
node = cultmesh.CultMesh.start_node(path, runtime_id=runtime_id)
for document in documents.values():
node.database.register_document(document)
node.database.pull()
self._node_key = node_key
self._node = node
self._documents = documents
return node, documents
def _import_debug_command_files(self, node: Any, command_document: Any, debug_mirror_root: str) -> None:
commands_root = Path(self.resolve_debug_mirror_root(debug_mirror_root)) / "blender" / "commands"
for command_path in sorted(commands_root.glob("*.json")):
with command_path.open("r", encoding="utf-8") as handle:
command = json.load(handle)
command_id = command.get("commandId") or command_path.stem
command["schema"] = command.get("schema") or COMMAND_INTENT_SCHEMA
command["commandId"] = command_id
node.database.put(command_document, f"blender/commands/{command_id}", command)
command_path.unlink(missing_ok=True)
def _write_debug_document(self, debug_mirror_root: str, relative_path: str, document: dict[str, Any]) -> None:
path = Path(self.resolve_debug_mirror_root(debug_mirror_root)) / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
json.dump(document, handle, indent=2, sort_keys=True)
def _put_sync_var(
self,
node: Any,
documents: dict[str, Any],
session_id: str,
binding_id: str,
kind: str,
display_name: str,
unity_property_path: str,
blender_property_path: str,
authority: str,
enabled: bool,
interpolation: str,
updated_at: str,
) -> None:
sync_var_id = _stable_id("var", session_id, binding_id, kind)
node.database.put(documents["sync_var"], f"sync/vars/{sync_var_id}", {
"schema": SYNC_VAR_SCHEMA,
"syncVarId": sync_var_id,
"sessionId": session_id,
"bindingId": binding_id,
"displayName": display_name,
"kind": kind,
"unityPropertyPath": unity_property_path,
"blenderPropertyPath": blender_property_path,
"authority": authority,
"enabled": bool(enabled),
"interpolation": interpolation,
"updatedAt": updated_at,
})
def _load_cultmesh(cultlib_py_src: str) -> tuple[Any, Any]:
packages = Path(cultlib_py_src or DEFAULT_CULTLIB_PY_SRC)
for name in CULTLIB_PY_PACKAGES:
source = packages / name / "src"
if source.exists():
source_text = str(source)
if source_text not in sys.path:
sys.path.insert(0, source_text)
try:
import cultmesh_py # type: ignore
import cultcache_py # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"Brokkr Blender target requires CultLib's Python packages. Set CultLib Python Source "
"to the CultLib packages directory or install cultcache-py, cultnet-py and cultmesh-py into Blender's Python."
) from exc
return cultmesh_py, cultcache_py
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _to_list(value: Any) -> list[float]:
return [float(item) for item in value]
def _vector(value: Any, size: int, fallback: Any) -> tuple[float, ...]:
if value is None:
return tuple(float(item) for item in fallback)
result = [float(item) for item in value[:size]]
while len(result) < size:
result.append(0.0)
return tuple(result)
def _jsonable(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
if isinstance(value, float) and not math.isfinite(value):
return None
return value
if isinstance(value, (list, tuple)):
return [_jsonable(item) for item in value]
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
return str(value)
def _sync_session(session_id: str, display_name: str, updated_at: str) -> dict[str, Any]:
return {
"schema": SYNC_SESSION_SCHEMA,
"sessionId": session_id,
"displayName": display_name or "Brokkr Editor Sync",
"owner": "brokkr.creative_tool_broker",
"unityProviderId": "brokkr.unity_editor",
"blenderProviderId": PROVIDER_ID,
"mode": "manual",
"enabled": True,
"createdAt": updated_at,
"updatedAt": updated_at,
}
def _unity_custom_property_path(component_type: str, property_path: str) -> str:
property_path = (property_path or "value").strip()
component_type = (component_type or "").strip()
if not component_type:
return property_path
return f"{component_type}::{property_path}"
def _blender_custom_property_path(property_path: str) -> str:
property_path = (property_path or "value").strip()
if property_path.startswith("customProperties."):
return property_path
return f"customProperties.{property_path}"
def _stable_id(*parts: str) -> str:
return ":".join(str(part or "").replace(" ", "_").replace("/", "_").replace("\\", "_") for part in parts)
def _active_action_name(context: Any) -> str:
obj = context.active_object
if obj is not None and obj.animation_data and obj.animation_data.action:
return obj.animation_data.action.name
return ""