|
import json |
|
import os |
|
import shutil |
|
import site |
|
import subprocess |
|
import sys |
|
from pathlib import Path |
|
|
|
__version__ = "0.1.0" |
|
|
|
OK = 0 |
|
|
|
|
|
class WillNotBlend(Exception): |
|
pass |
|
|
|
|
|
# these run in the host environmment |
|
|
|
|
|
def blender_bin(): |
|
blender_path = shutil.which("blender") |
|
if blender_path is None: |
|
raise WillNotBlend("couldn't even find blender") |
|
return Path(blender_path) |
|
|
|
|
|
def blender_version(force_bin=None): |
|
return ( |
|
subprocess.check_output([force_bin or blender_bin(), "--version"]) |
|
.decode("utf-8") |
|
.splitlines()[0] |
|
) |
|
|
|
|
|
def install_kernelspecs(): |
|
install_kernelspec() |
|
|
|
if shutil.which("xvfb-run"): |
|
install_kernelspec(xvfb=True) |
|
|
|
|
|
def install_kernelspec(xvfb=False): |
|
import pkg_resources |
|
|
|
kernel_path = Path(sys.prefix) / "share" / "jupyter" / "kernels" / "blender" |
|
|
|
blender_from_env = Path(os.environ.get("IPYBLENDER_BLENDER_BIN", blender_bin())) |
|
|
|
assert blender_from_env.exists(), f"blender not found at {blender_from_env}" |
|
|
|
display_name = blender_version(blender_from_env) |
|
|
|
argv = [sys.executable, "-m", "ipyblender", "-f", "{connection_file}"] |
|
|
|
if xvfb: |
|
kernel_path = ( |
|
Path(sys.prefix) / "share" / "jupyter" / "kernels" / "blender-xvfb" |
|
) |
|
display_name += " (xvfb)" |
|
argv = ["xvfb-run", "-a", *argv] |
|
|
|
kernel_path.exists() or kernel_path.mkdir(parents=True) |
|
|
|
spec_path = kernel_path / "kernel.json" |
|
|
|
spec = { |
|
"argv": list(map(str, argv)), |
|
"display_name": display_name, |
|
"language": "python", |
|
"env": { |
|
"IPYBLENDER_PYTHON_SITE_PACKAGES": site.getsitepackages()[0], |
|
"IPYBLENDER_BLENDER": str(blender_from_env), |
|
}, |
|
} |
|
|
|
path_vars = ["LD_LIBRARY_PATH", "LD_LINK_PATH", "LD_PATH"] |
|
|
|
for path_var in path_vars: |
|
if path_var in os.environ: |
|
spec["env"][path_var] = os.environ[path_var] |
|
|
|
if path_var == "LD_LIBRARY_PATH" and "CONDA_PREFIX" in os.environ: |
|
spec["env"][path_var] = os.path.pathsep.join( |
|
[ |
|
os.path.join(os.environ["CONDA_PREFIX"], "lib"), |
|
spec["env"][path_var], |
|
] |
|
) |
|
|
|
spec_path.write_text(json.dumps(spec, indent=2)) |
|
|
|
for res in ["32", "64"]: |
|
fname = f"logo-{res}x{res}.png" |
|
src = Path(pkg_resources.resource_filename("ipyblender", fname)) |
|
|
|
if src.exists(): |
|
dest = kernel_path / fname |
|
shutil.copy2(src, dest) |
|
|
|
return OK |
|
|
|
|
|
# these run in the blender environment |
|
|
|
|
|
def initialize_blender_loop(): |
|
import bpy |
|
import asyncio |
|
import heapq |
|
import socket |
|
import subprocess |
|
import time |
|
import os |
|
import sys |
|
|
|
def _run_once(self): |
|
"""Run one full iteration of the event loop. |
|
This calls all currently ready callbacks, polls for I/O, |
|
schedules the resulting callbacks, and finally schedules |
|
'call_later' callbacks. |
|
This is copied verbatim from the standard library code, with |
|
only one little change, namely the default timeout value. |
|
""" |
|
# Remove delayed calls that were cancelled from head of queue. |
|
while self._scheduled and self._scheduled[0]._cancelled: |
|
heapq.heappop(self._scheduled) |
|
|
|
# Set default timeout for call to "select" API. In the original |
|
# standard library code this timeout is 0, meaning select with block |
|
# until anything happens. Can't have that with foreign event loops! |
|
timeout = 1.0 / 100.0 |
|
if self._ready: |
|
timeout = 0 |
|
elif self._scheduled: |
|
# Compute the desired timeout. |
|
when = self._scheduled[0]._when |
|
deadline = max(0, when - self.time()) |
|
if timeout is None: |
|
timeout = deadline |
|
else: |
|
timeout = min(timeout, deadline) |
|
event_list = self._selector.select(timeout) |
|
self._process_events(event_list) |
|
|
|
# Handle 'later' callbacks that are ready. |
|
end_time = self.time() + self._clock_resolution |
|
while self._scheduled: |
|
handle = self._scheduled[0] |
|
if handle._when >= end_time: |
|
break |
|
handle = heapq.heappop(self._scheduled) |
|
self._ready.append(handle) |
|
|
|
# This is the only place where callbacks are actually *called*. |
|
# All other places just add them to ready. |
|
# Note: We run all currently scheduled callbacks, but not any |
|
# callbacks scheduled by callbacks run this time around -- |
|
# they will be run the next time (after another I/O poll). |
|
# Use an idiom that is threadsafe without using locks. |
|
ntodo = len(self._ready) |
|
for i in range(ntodo): |
|
handle = self._ready.popleft() |
|
if not handle._cancelled: |
|
handle._run() |
|
handle = None # Needed to break cycles when an exception |
|
|
|
class AsyncioBridgeOperator(bpy.types.Operator): |
|
"""Operator which runs its self from a timer""" |
|
|
|
bl_idname = "bpy.start_asyncio_bridge" |
|
bl_label = "Start Asyncio Modal Operator" |
|
|
|
def __init__(self): |
|
super().__init__() |
|
|
|
def __del__(self): |
|
pass |
|
|
|
def modal(self, context, event): |
|
if event.type == "TIMER": |
|
_run_once(self.loop) |
|
else: |
|
for listener_id, listener in self.listeners.items(): |
|
fire, catch = listener.check_event(event) |
|
if fire: |
|
listener.flag.set() |
|
# In the case of firing an event, it is important to |
|
# quit the listener processing in this loop iteration. |
|
# This assures that only one asyncio.Event flag is |
|
# set per iteration. |
|
if catch: |
|
return {"RUNNING_MODAL"} |
|
else: |
|
return {"PASS_THROUGH"} |
|
|
|
return {"PASS_THROUGH"} |
|
|
|
def execute(self, context): |
|
self.types = {} |
|
self.listeners = {} |
|
self.listener_id = 0 |
|
self.loop = asyncio.get_event_loop() |
|
self.loop.operator = self |
|
wm = context.window_manager |
|
wm.modal_handler_add(self) |
|
self._timer = wm.event_timer_add(0.005, window=context.window) |
|
return {"RUNNING_MODAL"} |
|
|
|
def invoke(self, context, event): |
|
self.execute(context) |
|
return {"RUNNING_MODAL"} |
|
|
|
def cancel(self, context): |
|
wm = context.window_manager |
|
wm.event_timer_remove(self._timer) |
|
|
|
def add_listener(self, listener): |
|
self.listeners[self.listener_id] = listener |
|
listener.id = self.listener_id |
|
self.listener_id += 1 |
|
|
|
def remove_listener(self, listener): |
|
del self.listeners[listener.id] |
|
|
|
def register(): |
|
try: |
|
bpy.utils.register_class(AsyncioBridgeOperator) |
|
except: |
|
pass |
|
|
|
def get_event_loop(): |
|
register() |
|
loop = asyncio.get_event_loop() |
|
if not hasattr(loop, "operator") or loop.operator is None: |
|
bpy.ops.bpy.start_asyncio_bridge("INVOKE_DEFAULT") |
|
return loop |
|
|
|
def unregister(): |
|
bpy.utils.unregister_class(AsyncioBridgeOperator) |
|
|
|
return get_event_loop() |
|
|
|
|
|
async def start_kernel_app(): |
|
from tornado import ioloop |
|
from ipykernel.kernelapp import IPKernelApp |
|
|
|
sys.argv = sys.argv[sys.argv.index("-f") - 1 :] |
|
|
|
app = IPKernelApp.instance() |
|
|
|
app.initialize() |
|
|
|
if app.poller is not None: |
|
app.poller.start() |
|
|
|
app.kernel.start() |
|
|
|
app.io_loop = ioloop.IOLoop.current() |
|
|
|
|
|
def launch_kernel(): |
|
IPYBLENDER_PYTHON_SITE_PACKAGES = os.environ.get("IPYBLENDER_PYTHON_SITE_PACKAGES") |
|
|
|
if IPYBLENDER_PYTHON_SITE_PACKAGES is not None: |
|
sys.path = [IPYBLENDER_PYTHON_SITE_PACKAGES] + list(sys.path) |
|
|
|
loop = initialize_blender_loop() |
|
loop.create_task(start_kernel_app()) |
|
|
|
|
|
if __name__ == "__main__": |
|
if int(os.environ.get("IPYBLENDER_IN_BLENDER", "0")) > 0: |
|
launch_kernel() |
|
elif "IPYBLENDER_BLENDER" in os.environ: |
|
env = dict(**os.environ) |
|
env["IPYBLENDER_IN_BLENDER"] = "1" |
|
args = [os.environ["IPYBLENDER_BLENDER"], "-P", __file__, "--", *sys.argv] |
|
|
|
sys.exit(subprocess.call(args, env=env)) |