Communication with Blender via Python RPC

My current 3D graphics setup revolves around Blender, ZBrush and, less often, Marmoset Toolbag 3. Transferring the meshes between ZBrush and Blender is the constant dance of exporting and importing (yes, I know about GoB but it’s not good enough for me). I strongly dislike this back and forth. I feel it adds a lot of friction, throws me off and wastes a lot of time. That makes me experiment with inter process communication. This is one of those experiments.

This experiment uses Python’s XMLRPC module. It allows for remote execution of server side defined methods. That means it’s a great way to expose Blender’s Python API to another process. This setup will work even if the client process isn’t running on the same machine as the server (Blender).

Good place to start is to go through the Blender’s side, server script:

import bpy

import threading
from xmlrpc.server import SimpleXMLRPCServer

HOST = "127.0.0.1"
PORT = 8000


def launch_server():
  server = SimpleXMLRPCServer((HOST, PORT))
  server.register_function(list_objects)
  server.register_function(import_obj)
  server.serve_forever()

def server_start():
  t = threading.Thread(target=launch_server)
  t.daemon = True
  t.start()
  
def list_objects():
  return bpy.data.objects.keys()
  
def import_obj(path:str):
  status = bpy.ops.import_scene.obj(filepath=path)
  return "OK"

The server gets started as a daemonized thread. Two functions get registered for the SimpleXMLRPCServer: list_objects and import_obj. A client which connects to this server will be able to execute those methods. One of the Python’s side implementation quirk is that the registered functions have to return something serializeable. list_objects() returns a list of objects in the scene, using a tuple of strings, import_obj return an OK string.

If you’d like to try this script, save it in the blender_install_dir/2.90/python/lib/funzone/rpc_server.py file (be sure to create an empty __init__.py file in the funzone folder) and start the server from Blender’s scripting tab, with:

import funzone.rpc_server as rpc_s
rpc_s.server_start()

At this point, the server runs in Blender’s background thread. Time to take a look at a possible client’s implementation:

import xmlrpc.client

HOST = "127.0.0.1"
PORT = 8000

client = None


def start():
  global client
  client = xmlrpc.client.ServerProxy(f"http://{HOST}:{PORT}")

Client’s implementation is very basic. That’s because at this point in time I’m still experimenting and I’m not putting everything in a script but rather using it in the Python’s REPL.

>>> import rpc_client as rpc_c
>>> rpc_c.start()
>>> rpc_c.client.list_objects()
>>> rpc_c.client.import_obj(r"C:\Users\mc\Desktop\test.obj")

This is an example in which I connect a client to the server and try executing both RPC functions. I don’t want to brag… but it actually works ;)

I don’t have a great use case yet. I was thinking about using a file browser, maybe nnn to quickly import meshes into Blender. nnn is a pretty niche, command line file browser and chances are you have no experience using it.

Update for 2020-10-25 <

I have written a prototype of a nnn plugin for quick mesh importing. nnn doesn’t support Windows but you can still run it, using the Linux WSL subsystem.

file_path=$2/$1
file_dir=$2
file=$1

PY_SCRIPT="
import xmlrpc.client
from pathlib import Path

HOST = '127.0.0.1'
PORT = 8000

with xmlrpc.client.ServerProxy(f'http://127.0.0.1:8000') as proxy:
  # Very specific process of converting the file path. Since this is a nnn plugin
  # it runs on the Linux (WSL) side. The path will be in a Unix format. Didn't find a way
  # to use pathlib for path conversion to Windows format.
  target_file = Path('$file_dir', '$file')
  _target_file = str(target_file).split('/')
  # When writing this script the split list started with ['', 'mnt', ...].
  # Drop those two to build a proper Windows path.
  _target_file = _target_file[2:]
  # The 0th element should be the drive letter. Append ':' to the drive.
  _target_file[0] = _target_file[0] + ':'
  # Char 92 is a backslash but since this script is just a bash string it's safer to avoid
  # using it explicitly. Using chr(92) is a workaround.
  target_file = chr(92).join(_target_file)
  print(target_file)
  proxy.import_obj(target_file)
"

if test -f $file_path; then
 echo "$PY_SCRIPT" | python3
fi

Here is a video of this setup in action.