Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
437 lines
26 KiB
Python
437 lines
26 KiB
Python
"""Long-lived embedded bpy kernel with sequential, explicit commit commands."""
|
|
import ast
|
|
import code
|
|
import contextlib
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import traceback
|
|
import types
|
|
import uuid
|
|
import webbrowser
|
|
from urllib.parse import urlsplit
|
|
import zipfile
|
|
import bpy
|
|
|
|
TOOLS=Path(__file__).resolve().parent
|
|
sys.path.insert(0,str(TOOLS))
|
|
from native_resources import install_resources, save_portable
|
|
from server_operator_properties import validated_operator_properties
|
|
from native_preferences import load_preferences, save_preferences
|
|
ROOT=Path(sys.argv[sys.argv.index('--')+1]).resolve()
|
|
REQUEST=json.loads((ROOT/'request.json').read_text())
|
|
CONSOLE_MODULE=types.ModuleType('__main__')
|
|
NAMESPACE=CONSOLE_MODULE.__dict__
|
|
NAMESPACE.update(__builtins__=sys.modules['builtins'],bpy=bpy,C=bpy.context,D=bpy.data)
|
|
exec('from mathutils import *\nfrom math import *',NAMESPACE,NAMESPACE)
|
|
sequence=0;desktop_io=None;last_input=time.monotonic()
|
|
preserve_layout=REQUEST.get('desktop',{}).get('preserveLayout',False)
|
|
browser_operators=set();last_desktop_state=0;saving_bridge=False;menu_save_sequence=0;startup_stamp=None;bridge_error=''
|
|
extension_catalog_cache={}
|
|
|
|
def publish(path,data):
|
|
tmp=path.with_suffix('.tmp');tmp.write_text(json.dumps(data,ensure_ascii=False));tmp.replace(path)
|
|
|
|
class BrowserBridge(webbrowser.BaseBrowser):
|
|
def open(self,url,new=0,autoraise=True):
|
|
parsed=urlsplit(url)
|
|
if parsed.scheme not in {'http','https'} or not parsed.netloc or len(url)>8192:
|
|
raise RuntimeError('SESSION_BROWSER_URL_INVALID')
|
|
directory=ROOT/'browser-links';directory.mkdir(exist_ok=True)
|
|
publish(directory/(uuid.uuid4().hex+'.json'),{'url':url})
|
|
return True
|
|
|
|
class OutputBudgetExceeded(RuntimeError):
|
|
pass
|
|
|
|
class Output:
|
|
def __init__(self):self.parts=[];self.bytes=0
|
|
def write(self,value):
|
|
size=len(value.encode());self.bytes+=size
|
|
if self.bytes>1024*1024:raise OutputBudgetExceeded('PYTHON_OUTPUT_BUDGET')
|
|
self.parts.append(value);return len(value)
|
|
def flush(self):pass
|
|
|
|
class SessionConsole(code.InteractiveConsole):
|
|
error=None
|
|
def showtraceback(self):
|
|
self.error=sys.exc_info()[1]
|
|
if isinstance(self.error,OutputBudgetExceeded):raise self.error
|
|
super().showtraceback()
|
|
def showsyntaxerror(self,filename=None):
|
|
self.error=sys.exc_info()[1]
|
|
super().showsyntaxerror(filename)
|
|
|
|
CONSOLE=SessionConsole(locals=NAMESPACE,filename='<blender_console>')
|
|
|
|
def execute_console(source,output):
|
|
# Match _console_python: persistent locals, standard displayhook and recoverable Python errors.
|
|
CONSOLE.error=None
|
|
bpy.context.preferences.edit.use_global_undo=True
|
|
try:bpy.ops.ed.undo_push(message='Web Blender Console')
|
|
except RuntimeError:pass
|
|
previous_main=sys.modules.get('__main__');previous_stdin=sys.stdin
|
|
try:
|
|
sys.modules['__main__']=CONSOLE_MODULE;sys.stdin=None
|
|
with contextlib.redirect_stdout(output),contextlib.redirect_stderr(output):
|
|
try:
|
|
tree=ast.parse(source,filename='<blender_console>',mode='exec')
|
|
compiled=compile(ast.Interactive(body=tree.body),'<blender_console>','single')
|
|
except (SyntaxError,OverflowError,ValueError):
|
|
CONSOLE.showsyntaxerror('<blender_console>')
|
|
else:
|
|
try:CONSOLE.runcode(compiled)
|
|
except SystemExit as error:
|
|
CONSOLE.error=error;print(repr(error));CONSOLE.resetbuffer()
|
|
finally:
|
|
sys.stdin=previous_stdin
|
|
if previous_main is None:sys.modules.pop('__main__',None)
|
|
else:sys.modules['__main__']=previous_main
|
|
try:bpy.ops.ed.undo_push(message='Web Blender Console Result')
|
|
except RuntimeError:pass
|
|
return None if CONSOLE.error is None else type(CONSOLE.error).__name__+': '+str(CONSOLE.error)
|
|
|
|
def context():
|
|
source=REQUEST['context'];window=bpy.context.window or next(iter(bpy.context.window_manager.windows),None)
|
|
if window is None:raise RuntimeError('SESSION_WINDOW_REQUIRED')
|
|
area_type=source.get('areaType','VIEW_3D')
|
|
if preserve_layout:
|
|
area=bpy.context.area or max(window.screen.areas,key=lambda a:a.width*a.height)
|
|
region=next((r for r in area.regions if r.type=='WINDOW'),None)
|
|
return {'window':window,'area':area,**({'region':region} if region else {}),'scene':window.scene,'view_layer':window.view_layer}
|
|
area=next((a for a in window.screen.areas if a.type==area_type),None)
|
|
if area is None:
|
|
area=max(window.screen.areas,key=lambda a:a.width*a.height);area.type=area_type
|
|
region=next((r for r in area.regions if r.type=='WINDOW'),None)
|
|
if region is None:raise RuntimeError('SESSION_REGION_REQUIRED')
|
|
return {'window':window,'area':area,'region':region,'scene':window.scene,'view_layer':window.view_layer}
|
|
|
|
def modal_running():
|
|
# Python Window.modal_operators reflects the actual window-manager modal stack.
|
|
return any(len(window.modal_operators)>0 for window in bpy.context.window_manager.windows)
|
|
|
|
def snapshot_result(command,output):
|
|
global saving_bridge
|
|
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
|
|
file=ROOT/f'result-{command["sequence"]:08d}.blend'
|
|
with bpy.context.temp_override(**context()):
|
|
if bpy.context.object and bpy.context.object.mode=='EDIT':bpy.context.object.update_from_editmode()
|
|
saving_bridge=True
|
|
try:save_portable(ROOT,file)
|
|
finally:saving_bridge=False
|
|
size=file.stat().st_size
|
|
if size>256*1024*1024:raise RuntimeError('SESSION_BLEND_BUDGET')
|
|
artifacts=[{'id':file.name,'name':'session.blend','mime':'application/x-blender','bytes':size,'sha256':hashlib.sha256(file.read_bytes()).hexdigest()}]
|
|
resources=ROOT/'resources'
|
|
if resources.exists():
|
|
archive=ROOT/f'resources-{command["sequence"]:08d}.zip';total=0
|
|
with zipfile.ZipFile(archive,'w',zipfile.ZIP_STORED) as bundle:
|
|
for src in sorted(resources.rglob('*')):
|
|
if src.is_symlink():raise RuntimeError('SESSION_RESOURCE_SYMLINK')
|
|
if src.is_file():
|
|
total+=src.stat().st_size
|
|
if total>256*1024*1024:raise RuntimeError('SESSION_RESOURCE_BUDGET')
|
|
bundle.write(src,str(src.relative_to(resources)))
|
|
artifacts.append({'id':archive.name,'name':'resources.zip','mime':'application/zip','bytes':archive.stat().st_size,'sha256':hashlib.sha256(archive.read_bytes()).hexdigest()})
|
|
return {'status':'SUCCEEDED','output':''.join(output.parts),'artifacts':artifacts,'mainReceipt':{'command':command['kind'],'sequence':command['sequence'],'activeObject':bpy.context.object.name if bpy.context.object else None,'objects':[{'name':obj.name,'location':[float(v) for v in obj.location]} for obj in sorted(bpy.data.objects,key=lambda item:item.name)]}}
|
|
|
|
def browser_directory(relative='',filename=None):
|
|
directory=(ROOT/'resources'/'user'/relative).resolve()
|
|
if not directory.is_relative_to((ROOT/'resources'/'user').resolve()) or not directory.is_dir():
|
|
raise RuntimeError('SESSION_FILE_DIRECTORY_INVALID')
|
|
found=False
|
|
for window in bpy.context.window_manager.windows:
|
|
for area in window.screen.areas:
|
|
if area.type=='FILE_BROWSER' and area.spaces.active.params:
|
|
area.spaces.active.params.directory=str(directory).encode()
|
|
if filename is not None:area.spaces.active.params.filename=filename
|
|
area.tag_redraw();found=True
|
|
if not found:raise RuntimeError('SESSION_OPEN_FILE_MENU_FIRST')
|
|
|
|
def extension_hosts():
|
|
hosts=set()
|
|
settings=getattr(bpy.context.preferences,'extensions',None)
|
|
for repo in getattr(settings,'repos',[]):
|
|
if not getattr(repo,'enabled',True):continue
|
|
remote=getattr(repo,'remote_url','');parsed=urlsplit(remote)
|
|
if parsed.scheme!='https' or not parsed.hostname:continue
|
|
hosts.add(parsed.hostname.lower())
|
|
directory=getattr(repo,'directory','')
|
|
if not directory:continue
|
|
file=Path(directory)/'.blender_ext'/'index.json'
|
|
try:
|
|
info=file.stat()
|
|
if info.st_size>8*1024*1024:continue
|
|
previous=extension_catalog_cache.get(str(file))
|
|
if previous and previous[0]==info.st_mtime_ns:hosts.update(previous[1]);continue
|
|
catalog=json.loads(file.read_text());archives=set()
|
|
for package in catalog.get('data',[]):
|
|
target=urlsplit(package.get('archive_url',''))
|
|
if target.scheme=='https' and target.hostname:archives.add(target.hostname.lower())
|
|
extension_catalog_cache[str(file)]=(info.st_mtime_ns,archives);hosts.update(archives)
|
|
except (OSError,ValueError,AttributeError,TypeError):continue
|
|
publish(ROOT/'extension-hosts.json',{'hosts':sorted(hosts)[:128]})
|
|
|
|
def desktop_state():
|
|
global browser_operators,last_desktop_state
|
|
if not preserve_layout:return
|
|
current=set()
|
|
for window in bpy.context.window_manager.windows:
|
|
for area in window.screen.areas:
|
|
if area.type=='FILE_BROWSER':
|
|
space=area.spaces.active
|
|
if space.params and space.active_operator:
|
|
key=space.active_operator.as_pointer();current.add(key)
|
|
if key not in browser_operators and space.active_operator.bl_idname!='WM_OT_recover_auto_save':
|
|
space.params.directory=str(ROOT/'resources'/'user').encode();area.tag_redraw()
|
|
browser_operators=current
|
|
if time.monotonic()-last_desktop_state>=1:
|
|
extension_hosts()
|
|
window=bpy.context.window or next(iter(bpy.context.window_manager.windows),None)
|
|
publish(ROOT/'desktop-state.json',{'dirty':bpy.data.is_dirty,'filepath':bpy.data.filepath,
|
|
'workspace':window.workspace.name if window else '', 'fileBrowser':bool(current),'savedSequence':menu_save_sequence,
|
|
**({'error':bridge_error} if bridge_error else {})})
|
|
last_desktop_state=time.monotonic()
|
|
|
|
def prepare_saved_startup():
|
|
global startup_stamp,bridge_error
|
|
file=ROOT/'config'/'startup.blend'
|
|
if not file.is_file() or file.stat().st_mtime_ns==startup_stamp:return
|
|
try:
|
|
resources=ROOT/'resources'
|
|
source_files=list(resources.rglob('*'));total=0
|
|
for source in source_files:
|
|
if source.is_symlink():raise RuntimeError('STARTUP_RESOURCE_SYMLINK')
|
|
if source.is_file():total+=source.stat().st_size
|
|
if total>256*1024*1024 or len(source_files)>4096:raise RuntimeError('STARTUP_RESOURCE_BUDGET')
|
|
if total:
|
|
target=Path(os.environ['BLENDER_USER_DATAFILES'])/'startup-assets'/uuid.uuid4().hex
|
|
target.parent.mkdir(parents=True,exist_ok=True);shutil.copytree(resources,target)
|
|
with (ROOT/'startup-resources.log').open('w') as log:
|
|
result=subprocess.run([bpy.app.binary_path,'--background','--factory-startup','--disable-autoexec',
|
|
'--python-exit-code','1','--python',str(TOOLS/'native_startup_resources.py'),'--',str(file),str(ROOT),str(target)],
|
|
stdout=log,stderr=subprocess.STDOUT,timeout=120)
|
|
if result.returncode:raise RuntimeError('STARTUP_RESOURCE_REWRITE_FAILED')
|
|
startup_stamp=file.stat().st_mtime_ns
|
|
publish(ROOT/'startup-ready.json',{'sha256':hashlib.sha256(file.read_bytes()).hexdigest()})
|
|
bridge_error=''
|
|
except BaseException as error:bridge_error=str(error)
|
|
|
|
@bpy.app.handlers.persistent
|
|
def saved_from_menu(*_):
|
|
global menu_save_sequence,saving_bridge,bridge_error
|
|
if not preserve_layout or saving_bridge:return
|
|
prepare_saved_startup()
|
|
source=Path(bpy.data.filepath)
|
|
if not source.is_file():return
|
|
menu_save_sequence+=1
|
|
if not source.resolve().is_relative_to((ROOT/'resources'/'user').resolve()):
|
|
target=ROOT/'resources'/'user'/'downloads'/source.name
|
|
target.parent.mkdir(parents=True,exist_ok=True)
|
|
if source.stat().st_size<=256*1024*1024:shutil.copyfile(source,target)
|
|
key=REQUEST.get('desktop',{}).get('recoveryKey')
|
|
if key:
|
|
try:
|
|
directory=Path(os.environ['BLENDER_USER_DATAFILES'])/'project-saves'/key
|
|
destination=directory/uuid.uuid4().hex;destination.mkdir(parents=True)
|
|
files=list((ROOT/'resources').rglob('*'));total=0
|
|
for file in files:
|
|
if file.is_symlink():raise RuntimeError('DESKTOP_SAVE_RESOURCE_SYMLINK')
|
|
if file.is_file():
|
|
total+=file.stat().st_size
|
|
if total>256*1024*1024 or len(files)>4096:raise RuntimeError('DESKTOP_SAVE_RESOURCE_BUDGET')
|
|
target=destination/file.relative_to(ROOT/'resources');target.parent.mkdir(parents=True,exist_ok=True);shutil.copyfile(file,target)
|
|
main='project.blend'
|
|
while (destination/main).exists():main='_'+main
|
|
saving_bridge=True
|
|
try:save_portable(ROOT,destination/main)
|
|
finally:saving_bridge=False
|
|
if (destination/main).stat().st_size>256*1024*1024:raise RuntimeError('DESKTOP_SAVE_MAIN_BUDGET')
|
|
publish(directory/'latest.json',{'directory':destination.name,'main':main,'savedAt':int(time.time()*1000),
|
|
'sha256':hashlib.sha256((destination/main).read_bytes()).hexdigest()})
|
|
old=sorted((item for item in directory.iterdir() if item.is_dir()),key=lambda item:item.stat().st_mtime,reverse=True)
|
|
for item in old[3:]:shutil.rmtree(item)
|
|
bridge_error=''
|
|
except BaseException as error:bridge_error=str(error)
|
|
|
|
def tick():
|
|
global sequence,last_input
|
|
if desktop_io is not None and desktop_io.poll() is not None:
|
|
publish(ROOT/'session-ready.json',{'status':'FAILED','error':'SESSION_DESKTOP_IO_EXITED'})
|
|
return None
|
|
try:desktop_state()
|
|
except Exception as error:publish(ROOT/'desktop-state.json',{'error':str(error),'dirty':True,'filepath':'','workspace':''})
|
|
file=ROOT/'commands'/f'{sequence+1:08d}.json'
|
|
if not file.exists():return 0.05
|
|
command=json.loads(file.read_text())
|
|
if command.get('afterInput',0):
|
|
ack=ROOT/'input-ack.json'
|
|
if not ack.exists():return 0.05
|
|
applied=json.loads(ack.read_text())
|
|
if applied['sequence']<command['afterInput'] or time.time()-applied['at']<0.1:return 0.05
|
|
file.unlink();sequence+=1;last_input=time.monotonic()
|
|
response={'sequence':sequence,'sessionId':REQUEST['jobId'],'requestSha256':REQUEST['requestSha256']}
|
|
output=Output()
|
|
try:
|
|
kind=command['kind']
|
|
if kind=='read-main':
|
|
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
|
|
with bpy.context.temp_override(**context()):
|
|
receipt={'command':kind,'sequence':command['sequence'],'activeObject':bpy.context.object.name if bpy.context.object else None,'objects':[{'name':obj.name,'location':[float(v) for v in obj.location]} for obj in sorted(bpy.data.objects,key=lambda item:item.name)]}
|
|
observed={'version':list(bpy.app.version),'objects':len(bpy.data.objects),'workspace':str(ROOT),'filepath':bpy.data.filepath,'filepathExists':os.path.isfile(bpy.data.filepath),'dirty':bpy.data.is_dirty,'packedImages':{image.name:bool(image.packed_file) for image in bpy.data.images}}
|
|
observed['images']=[{'name':image.name,'filepath':image.filepath,'absolute':bpy.path.abspath(image.filepath,library=image.library) if image.filepath else '', 'exists':bool(image.filepath) and os.path.isfile(bpy.path.abspath(image.filepath,library=image.library)),'packed':bool(image.packed_file)} for image in sorted(bpy.data.images,key=lambda item:item.name) if image.source=='FILE']
|
|
response.update(status='SUCCEEDED',output=json.dumps(observed),artifacts=[],mainReceipt=receipt)
|
|
elif kind=='python':
|
|
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
|
|
with bpy.context.temp_override(**context()):
|
|
python_error=execute_console(command['source'],output)
|
|
response.update(snapshot_result(command,output))
|
|
if python_error:response.update(status='PYTHON_ERROR',error=python_error)
|
|
elif kind in ('undo','redo'):
|
|
with bpy.context.temp_override(**context()):result=getattr(bpy.ops.ed,kind)()
|
|
if 'CANCELLED' in result:raise RuntimeError(f'SESSION_{kind.upper()}_CANCELLED')
|
|
response.update(snapshot_result(command,output));response['output']=','.join(sorted(result))
|
|
elif kind=='invoke':
|
|
with bpy.context.temp_override(**context()):
|
|
family,name=command['operator'].split('.',1);target=getattr(getattr(bpy.ops,family),name)
|
|
if not target.poll():raise RuntimeError('SESSION_OPERATOR_POLL_FAILED')
|
|
properties=validated_operator_properties(target,command.get('properties',{}))
|
|
result=target('INVOKE_DEFAULT',**properties)
|
|
if 'CANCELLED' in result:raise RuntimeError('SESSION_OPERATOR_CANCELLED')
|
|
response.update(status='SUCCEEDED',operatorState=sorted(result),output=','.join(sorted(result)))
|
|
elif kind=='commit':response.update(snapshot_result(command,output))
|
|
elif kind=='download-project':
|
|
result=snapshot_result(command,output)
|
|
blend=ROOT/result['artifacts'][0]['id']
|
|
archive=ROOT/f'project-{sequence:08d}.zip'
|
|
with zipfile.ZipFile(archive,'w',zipfile.ZIP_DEFLATED) as bundle:
|
|
main_name='project.blend'
|
|
while (ROOT/'resources'/main_name).exists():main_name='_'+main_name
|
|
bundle.write(blend,main_name)
|
|
for item in result['artifacts']:
|
|
if item['mime']=='application/zip':
|
|
with zipfile.ZipFile(ROOT/item['id']) as resources:
|
|
for name in resources.namelist():bundle.writestr(name,resources.read(name))
|
|
if archive.stat().st_size>256*1024*1024:raise RuntimeError('SESSION_PACKAGE_BUDGET')
|
|
result['artifacts'].append({'id':archive.name,'name':'Blender-project.zip','mime':'application/zip',
|
|
'bytes':archive.stat().st_size,'sha256':hashlib.sha256(archive.read_bytes()).hexdigest()})
|
|
response.update(result)
|
|
elif kind=='clipboard':
|
|
bpy.context.window_manager.clipboard=command['text']
|
|
response.update(status='SUCCEEDED',output='')
|
|
elif kind=='clipboard-read':
|
|
text=bpy.context.window_manager.clipboard
|
|
if len(text.encode())>65536:raise RuntimeError('SESSION_CLIPBOARD_BUDGET')
|
|
response.update(status='SUCCEEDED',output=text)
|
|
elif kind=='file-browser':
|
|
browser_directory(command['path'],command.get('filename'))
|
|
response.update(status='SUCCEEDED',output='')
|
|
elif kind=='open-blend':
|
|
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
|
|
file=(ROOT/'resources'/'user'/command['path']).resolve()
|
|
if not file.is_relative_to((ROOT/'resources'/'user').resolve()):raise RuntimeError('SESSION_FILE_PATH')
|
|
state=bpy.ops.wm.open_mainfile(filepath=str(file),load_ui=True,use_scripts=False)
|
|
if 'FINISHED' not in state:raise RuntimeError('SESSION_OPEN_FAILED')
|
|
response.update(status='SUCCEEDED',output=file.name)
|
|
elif kind=='preferences-save':
|
|
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
|
|
file=save_preferences(ROOT,sequence)
|
|
response.update(status='SUCCEEDED',output='Preferences saved for future Web Blender sessions',
|
|
artifacts=[{'id':file.name,'name':'userpref.blend','mime':'application/x-blender-preferences',
|
|
'bytes':file.stat().st_size,'sha256':hashlib.sha256(file.read_bytes()).hexdigest()}])
|
|
elif kind=='editor':
|
|
if modal_running():raise RuntimeError('SESSION_MODAL_ACTIVE_FINISH_OR_CANCEL_FIRST')
|
|
area=context()['area']
|
|
area.type=command['areaType']
|
|
REQUEST['context']['areaType']=command['areaType']
|
|
response.update(status='SUCCEEDED',output='Editor: '+area.type)
|
|
elif kind=='cancel-modal':
|
|
publish(ROOT/'events'/f'cancel-{time.time_ns()}.json',[{'type':'key','code':'Escape','down':True},{'type':'key','code':'Escape','down':False}])
|
|
response.update(status='SUCCEEDED',output='Escape sent')
|
|
else:raise RuntimeError('SESSION_COMMAND_UNKNOWN')
|
|
except BaseException as error:
|
|
response.update(status='FAILED',error=str(error),output=''.join(output.parts)+'\n'+traceback.format_exc(limit=8))
|
|
publish(ROOT/'responses'/f'{sequence:08d}.json',response)
|
|
return 0.05
|
|
|
|
def tablet_tick():
|
|
file=ROOT/'pen-input.json'
|
|
if not file.exists():return 0.008
|
|
request=json.loads(file.read_text());file.unlink()
|
|
result={'id':request['id'],'handled':[]}
|
|
try:
|
|
target=getattr(bpy.context.window_manager,'web_pointer_event',None)
|
|
if target is None:raise RuntimeError('NATIVE_TABLET_API_NOT_BUILT')
|
|
for sample in request['samples']:
|
|
result['handled'].append(target(x=sample['x'],y=sample['y'],button=sample['button'],
|
|
action={'move':0,'press':1,'release':2}[sample['action']],pressure=sample['pressure'],
|
|
tilt_x=sample['tiltX'],tilt_y=sample['tiltY'],eraser=sample['eraser']))
|
|
except BaseException as error:result['error']=str(error)
|
|
publish(ROOT/'pen-output.json',result)
|
|
return 0.008
|
|
|
|
def start():
|
|
global desktop_io,startup_stamp
|
|
try:
|
|
load_preferences(ROOT)
|
|
startup=ROOT/'config'/'startup.blend'
|
|
startup_stamp=startup.stat().st_mtime_ns if startup.is_file() else None
|
|
if preserve_layout and REQUEST.get('desktop',{}).get('useSavedStartup') and not (ROOT/'desktop-recovery.json').exists():
|
|
bpy.ops.wm.read_homefile('EXEC_DEFAULT')
|
|
else:bpy.ops.wm.open_mainfile(filepath=str(ROOT/'source.blend'),load_ui=preserve_layout,use_scripts=False)
|
|
resource_report=install_resources(ROOT)
|
|
if resource_report['missing'] and not preserve_layout:raise RuntimeError('SESSION_RESOURCES_MISSING: '+', '.join(resource_report['missing'][:32]))
|
|
bpy.app.timers.register(tablet_tick,first_interval=0.008,persistent=True)
|
|
if preserve_layout:
|
|
webbrowser.register('web-blender',None,BrowserBridge(),preferred=True)
|
|
bpy.app.handlers.save_post.append(saved_from_menu)
|
|
desktop_io=subprocess.Popen(['/usr/bin/python3',str(TOOLS/'native_desktop_io.py'),str(ROOT)])
|
|
publish(ROOT/'session-ready.json',{'status':'READY','blenderVersion':bpy.app.version_string,'resources':resource_report,'inputCapabilities':{'pen':hasattr(bpy.context.window_manager,'web_pointer_event'),'logicalKeys':True,'horizontalWheel':True}})
|
|
bpy.app.timers.register(tick,first_interval=0.1,persistent=True)
|
|
return None
|
|
ctx=context();settings=REQUEST['context']
|
|
with bpy.context.temp_override(**ctx):
|
|
if settings.get('sceneName'):
|
|
scene=bpy.data.scenes.get(settings['sceneName'])
|
|
if scene is None:raise RuntimeError('SESSION_SCENE_NOT_FOUND')
|
|
ctx['window'].scene=scene
|
|
ctx=context()
|
|
with bpy.context.temp_override(**ctx):
|
|
active=settings.get('activeObject');names=settings.get('selectedObjects',[])
|
|
for obj in ctx['view_layer'].objects:obj.select_set(obj.name in names or obj.name==active)
|
|
if active:
|
|
obj=ctx['view_layer'].objects.get(active)
|
|
if obj is None:raise RuntimeError('SESSION_ACTIVE_OBJECT_NOT_FOUND')
|
|
ctx['view_layer'].objects.active=obj
|
|
selection=settings.get('meshSelection')
|
|
if selection:
|
|
obj=bpy.context.object
|
|
if not obj or obj.type!='MESH':raise RuntimeError('SESSION_MESH_REQUIRED')
|
|
if obj.mode!='OBJECT':bpy.ops.object.mode_set(mode='OBJECT')
|
|
groups={'VERT':obj.data.vertices,'EDGE':obj.data.edges,'FACE':obj.data.polygons};elements=groups[selection['domain']]
|
|
for group in groups.values():
|
|
for element in group:element.select=False
|
|
for index in selection['indices']:
|
|
if index>=len(elements):raise RuntimeError('SESSION_SELECTION_RANGE')
|
|
elements[index].select=True
|
|
bpy.context.tool_settings.mesh_select_mode=tuple(selection['domain']==d for d in ('VERT','EDGE','FACE'))
|
|
mode=settings.get('mode','OBJECT')
|
|
if bpy.context.object and bpy.context.object.mode!=mode:bpy.ops.object.mode_set(mode=mode)
|
|
if ctx['area'].type=='NODE_EDITOR':
|
|
ctx['area'].ui_type=settings.get('nodeTreeType','GeometryNodeTree')
|
|
tree=bpy.data.node_groups.get(settings.get('nodeTreeName',''))
|
|
if tree:ctx['area'].spaces.active.pin=True;ctx['area'].spaces.active.node_tree=tree
|
|
desktop_io=subprocess.Popen(['/usr/bin/python3',str(TOOLS/'native_desktop_io.py'),str(ROOT)])
|
|
publish(ROOT/'session-ready.json',{'status':'READY','blenderVersion':bpy.app.version_string,'resources':resource_report,'inputCapabilities':{'pen':hasattr(bpy.context.window_manager,'web_pointer_event'),'logicalKeys':True,'horizontalWheel':True}})
|
|
bpy.app.timers.register(tick,first_interval=0.1,persistent=True)
|
|
except BaseException as error:publish(ROOT/'session-ready.json',{'status':'FAILED','error':str(error),'detail':traceback.format_exc(limit=6)})
|
|
return None
|
|
|
|
bpy.app.timers.register(start,first_interval=0.5,persistent=True)
|