"""Betworld Blender bridge. Install this trusted add-on; uploaded files are data only."""
bl_info={'name':'Betworld Worlds','author':'Betworld','version':(1,2,0),'blender':(4,2,0),'location':'3D View > Sidebar > Betworld','category':'Import-Export'}
import bpy,json,math,tempfile,urllib.request,urllib.parse,uuid,base64
from pathlib import Path
from mathutils import Matrix,Vector
SERVER='https://betworld.gg'

def call(context,tool,args=None):
 token=context.window_manager.betworld_token.strip()
 if len(token)!=64 or any(c not in '0123456789abcdef' for c in token):raise ValueError('Paste a valid world credential from Betworld.')
 body={'jsonrpc':'2.0','id':str(uuid.uuid4()),'method':'tools/call','params':{'name':tool,'arguments':args or {},'_meta':{'io.modelcontextprotocol/protocolVersion':'2026-07-28','io.modelcontextprotocol/clientInfo':{'name':'betworld-blender','version':'1.0.0'},'io.modelcontextprotocol/clientCapabilities':{}}}}
 req=urllib.request.Request(SERVER+'/api/worlds/ai/mcp',data=json.dumps(body).encode(),method='POST',headers={'Content-Type':'application/json','Accept':'application/json, text/event-stream','Authorization':'Bearer '+token,'MCP-Protocol-Version':'2026-07-28','Mcp-Method':'tools/call','Mcp-Name':tool})
 try:
  with urllib.request.urlopen(req,timeout=40) as response:raw=response.read(140_000_001)
 except urllib.error.HTTPError as error:
  message=error.read(4096).decode('utf8','replace');raise ValueError('Betworld rejected the request: '+message) from None
 if len(raw)>140_000_000:raise ValueError('Response exceeds the world budget.')
 # Legacy-compatible servers may frame a completed result in SSE.
 if raw.startswith(b'event:') or raw.startswith(b'data:'):
  events=[line[5:].strip() for line in raw.splitlines() if line.startswith(b'data:')]
  raw=events[-1] if events else raw
 result=json.loads(raw)
 if 'error' in result:raise ValueError(result['error'].get('message','MCP request failed.'))
 result=result['result']
 if result.get('isError'):raise ValueError(result['content'][0]['text'])
 return result.get('structuredContent') or json.loads(result['content'][0]['text'])

def fetch_glb(url,directory,key):
 parsed=urllib.parse.urlsplit(url);allowed=urllib.parse.urlsplit(SERVER)
 if (parsed.scheme,parsed.netloc)!=(allowed.scheme,allowed.netloc):raise ValueError('Only Betworld asset downloads are accepted.')
 target=Path(directory)/(key+'.glb')
 # URLs are server-provided; never evaluate names, extras, scripts or Blender files.
 with urllib.request.urlopen(url,timeout=40) as response:data=response.read(100_000_001)
 if len(data)>100_000_000 or data[:4]!=b'glTF':raise ValueError('Invalid GLB download.')
 target.write_bytes(data);return str(target)

def import_nodes(path):
 before=set(bpy.data.objects);bpy.ops.import_scene.gltf(filepath=path);return set(bpy.data.objects)-before

def clone_tree(source,collection,parent=None):
 clone=source.copy();collection.objects.link(clone);clone.parent=parent
 for child in source.children:clone_tree(child,collection,clone)
 return clone

def download_world(context):
 data=call(context,'world_download');new=bpy.data.scenes.new('Betworld · '+data['name']);context.window.scene=new
 world=bpy.data.collections.new('World entities');new.collection.children.link(world)
 new['betworld_id']=data['worldId'];new['betworld_revision']=data['revision'];new['betworld_floor']=data['document']['floor'];new['betworld_walls']=data['document']['walls'];new['betworld_skins']=json.dumps(data['document'].get('skins',[]));new['betworld_environment']=json.dumps({k:data['document'][k] for k in ('buildSize','terrain','background') if k in data['document']})
 with tempfile.TemporaryDirectory(prefix='betworld-') as directory:
  sources={};temporary=set()
  for key in [key for key in ['props','games','building'] if key in data['sources']]:
   nodes=import_nodes(fetch_glb(data['sources'][key],directory,key));temporary.update(nodes)
   for o in nodes:
    if o.parent not in nodes:sources[o.name.split('.')[0]]=o
  shell=import_nodes(fetch_glb(data['sources']['shell'],directory,'shell'))
  for o in shell:o.hide_select=True;o['betworld_shell']=True
  uploads={a['hash']:a for a in data['assets']}
  for asset in set(e['asset'] for e in data['document']['entities']):
   native=data.get('catalog',{}).get('props',{}).get(asset,{})
   if not native.get('path','').startswith('/assets/props/gmod/'):continue
   from urllib.parse import urljoin
   nodes=import_nodes(fetch_glb(urljoin(data['sources']['props'],native['path']),directory,asset));temporary.update(nodes)
   holder=bpy.data.objects.new(asset,None);new.collection.objects.link(holder);temporary.add(holder)
   for o in nodes:
    if o.parent not in nodes:o.parent=holder
   sources[asset]=holder
  for asset in set(e['asset'] for e in data['document']['entities'] if e['asset'].startswith('upload:')):
   nodes=import_nodes(fetch_glb(uploads[asset[7:]]['url'],directory,asset[7:]));temporary.update(nodes)
   root=bpy.data.objects.new(asset,None);new.collection.objects.link(root);temporary.add(root)
   for o in nodes:
    if o.parent not in nodes:o.parent=root
   context.view_layer.update()
   points=[o.matrix_world@Vector(p) for o in nodes if o.type=='MESH' for p in o.bound_box]
   if not points:raise ValueError('Uploaded prop has no geometry.')
   low=Vector(tuple(min(p[i] for p in points) for i in range(3)));high=Vector(tuple(max(p[i] for p in points) for i in range(3)));extent=max(high-low)
   offset=Vector(((low.x+high.x)/2,(low.y+high.y)/2,low.z))
   for child in root.children:child.location-=offset
   root.scale=(1/extent,)*3;sources[asset]=root
  builtins={s['id']:s for s in data.get('catalog',{}).get('gameSkins',[])}
  if any(e.get('skin') in builtins for e in data['document']['entities']):
   nodes=import_nodes(fetch_glb(data['sources']['constructGames'],directory,'construct-games'));temporary.update(nodes)
   for o in nodes:
    if o.parent not in nodes and o.get('gameKey'):sources['construct-'+o['gameKey']]=o
  skins={s['id']:s for s in data['document'].get('skins',[])};skin_sources={}
  used=set(e.get('skin') for e in data['document']['entities'])
  hashes=set(h for skin in skins.values() if skin['id'] in used for h in [skin['body']]+[b['asset'] for b in skin['bindings']])
  contracts={}
  for game in set(skin['game'] for skin in skins.values() if skin['id'] in used):contracts[game]=call(context,'game_skin_contract',{'game':game})
  for h in hashes:
   nodes=import_nodes(fetch_glb(uploads[h]['url'],directory,'skin-'+h));temporary.update(nodes)
   holder=bpy.data.objects.new('skin-source-'+h,None);new.collection.objects.link(holder);temporary.add(holder)
   for o in nodes:
    if o.parent not in nodes:o.parent=holder
   skin_sources[h]=holder
  for entity in data['document']['entities']:

   asset=entity['asset'];source=sources.get(entity['skin'] if entity.get('skin') in builtins else asset)
   if not source:raise ValueError('Missing asset '+asset)
   anchor=bpy.data.objects.new(entity['id'],None);world.objects.link(anchor);anchor['betworld_entity']=json.dumps(entity);anchor.empty_display_type='CUBE';anchor.empty_display_size=.3;anchor.color=tuple(int(entity['color'][i:i+2],16)/255 for i in [1,3,5])+(1,)
   model=clone_tree(source,world,anchor)
   if entity['kind']=='game':model.scale*=.63 if asset=='crash' else 1.05 if asset=='roulette' else data['gameScale']
   if entity.get('skin') and entity['skin'] not in builtins:apply_skin_preview(context,anchor,model,skins[entity['skin']],contracts[asset],skin_sources,world)
   x,y,z=entity['position'];anchor.location=(x,-z,y);anchor.rotation_euler.z=entity['yaw'];sx,sy,sz=entity['scale'];anchor.scale=(sx,sz,sy)
  for o in temporary:bpy.data.objects.remove(o,do_unlink=True)
 new.world=bpy.data.worlds.new('Betworld preview');new.world.color=(.25,.25,.25)
 context.view_layer.update()

def upload_world(context):
 scene=context.scene
 if 'betworld_id' not in scene:raise ValueError('Download your world first.')
 entities=[]
 for o in scene.objects:
  if 'betworld_entity' not in o:continue
  e=json.loads(o['betworld_entity']);loc,rotation,scale=o.matrix_world.decompose();angles=rotation.to_euler('XYZ')
  if abs(angles.x)>.0001 or abs(angles.y)>.0001:raise ValueError('World objects rotate around the vertical axis only.')
  e['color']='#'+''.join(format(round(max(0,min(1,c))*255),'02x') for c in o.color[:3]);e['position']=[round(loc.x,5),round(loc.z,5),round(-loc.y,5)];e['yaw']=angles.z;e['scale']=[round(scale.x,5),round(scale.z,5),round(scale.y,5)];entities.append(e)
 result=call(context,'world_upload',{'revision':scene['betworld_revision'],'document':{'version':1,'floor':scene['betworld_floor'],'walls':scene['betworld_walls'],'entities':entities,'skins':json.loads(scene.get('betworld_skins','[]')),**json.loads(scene.get('betworld_environment','{}'))}})
 scene['betworld_revision']=result['revision']

def upload_prop(context):
 selected=[o for o in context.selected_objects if o.type=='MESH']
 if not selected:raise ValueError('Select the mesh objects for one static prop.')
 if any(o.type=='ARMATURE' or o.animation_data for o in selected):raise ValueError('Props must be static.')
 with tempfile.TemporaryDirectory(prefix='betworld-prop-') as directory:
  path=str(Path(directory)/'prop.glb')
  bpy.ops.export_scene.gltf(filepath=path,export_format='GLB',use_selection=True,use_active_scene=True,export_animations=False,export_skins=False,export_extras=False,export_cameras=False,export_lights=False)
  data=Path(path).read_bytes()
  if len(data)>4_000_000:raise ValueError('Each prop must be under 4 MB. Reduce textures or geometry.')
  result=call(context,'asset_upload',{'name':selected[0].name[:80],'glbBase64':base64.b64encode(data).decode('ascii')})
 context.view_layer.update()
 points=[o.matrix_world@Vector(p) for o in selected for p in o.bound_box]
 low=Vector(tuple(min(p[i] for p in points) for i in range(3)));high=Vector(tuple(max(p[i] for p in points) for i in range(3)))
 center=Vector(((low.x+high.x)/2,(low.y+high.y)/2,low.z));extent=max(high-low)
 anchor=bpy.data.objects.new('prop-'+str(uuid.uuid4()),None);context.scene.collection.objects.link(anchor);anchor.location=center;anchor.scale=(extent,)*3
 context.view_layer.update()
 for o in selected:
  transform=o.matrix_world.copy();o.parent=anchor;o.matrix_world=transform
 entity={'id':anchor.name,'kind':'prop','asset':'upload:'+result['hash'],'position':[center.x,center.z,-center.y],'yaw':0,'scale':[extent]*3,'color':'#ffffff'}
 anchor['betworld_entity']=json.dumps(entity);anchor.color=(1,1,1,1)
 context.scene['betworld_last_asset']='upload:'+result['hash']
 return result


def tree(root):
 return [root]+[c for child in root.children for c in tree(child)]

def fit_skin_mesh(context,source,parent,low,high,collection):
 holder=bpy.data.objects.new('Custom skin mesh',None);collection.objects.link(holder)
 clone_tree(source,collection,holder);context.view_layer.update()
 nodes=tree(holder);points=[o.matrix_world@Vector(p) for o in nodes if o.type=='MESH' for p in o.bound_box]
 if not points:raise ValueError('Skin mesh has no geometry.')
 a=Vector(tuple(min(p[i] for p in points) for i in range(3)));b=Vector(tuple(max(p[i] for p in points) for i in range(3)))
 low,high=Vector(low),Vector(high);size=b-a;dest=high-low
 holder.scale=tuple(dest[i]/size[i] if size[i]>1e-8 else 1 for i in range(3))
 holder.location=(low+high)/2-Vector(tuple((a[i]+b[i])/2*holder.scale[i] for i in range(3)));holder.parent=parent
 # These are preview meshes only. Uploaded extras never become Blender behavior.
 for o in nodes:
  for key in list(o.keys()):del o[key]
 return holder

def apply_skin_preview(context,anchor,model,skin,contract,sources,collection):
 native=tree(model);bindings={b['slot']:b['asset'] for b in skin['bindings']};targets={}
 def functional(o):
  p=str(o.get('referencePart',''))
  return any(k in o for k in ['liveAction','sharedSeatAction','buttonLabelFor','livePart']) or p in ['top','screen','coin','result_display','bet_display'] or p.startswith(('slider_','tick_','peg_','bucket_'))
 for o in native:
  keep=functional(o);parent=o.parent
  while parent and parent!=anchor:keep=keep or functional(parent);parent=parent.parent
  if o.type=='MESH' and not keep:o.hide_render=True;o.hide_set(True)
 claimed=set()
 for slot in contract['controls']:
  selector=slot['selector'];candidates=[]
  for o in native:
   seat=o.get('sharedSeatAction',{});action=o.get('liveAction') or seat.get('type')
   if o not in claimed and o.type=='MESH' and action==selector['action'] and o.get('liveIndex')==selector.get('index') and seat.get('seat')==selector.get('seat'):candidates.append(o)
  if not candidates:raise ValueError('Native template does not match skin socket '+slot['slot'])
  o=candidates[0];claimed.add(o);target='control.'+slot['slot'];targets[target]=[o];h=bindings.get(target)
  if h:
   low=tuple(min(p[i] for p in o.bound_box) for i in range(3));high=tuple(max(p[i] for p in o.bound_box) for i in range(3))
   fit_skin_mesh(context,sources[h],o,low,high,collection);o.hide_render=True;o.hide_set(True)
 for slot in contract['parts']:
  h=bindings.get('part.'+slot['slot'])
  if h:
   for o in native:
    if o.type=='MESH' and o.get('referencePart')==slot['referencePart']:
     low=tuple(min(p[i] for p in o.bound_box) for i in range(3));high=tuple(max(p[i] for p in o.bound_box) for i in range(3))
     fit_skin_mesh(context,sources[h],o,low,high,collection);o.hide_render=True;o.hide_set(True)
 box=contract['bodyBounds'];lo,hi=box['min'],box['max'];body=fit_skin_mesh(context,sources[skin['body']],anchor,(lo[0],-hi[2],lo[1]),(hi[0],-lo[2],hi[1]),collection)
 if skin['version']==2:
  targets['body']=[body]
  def display(o):
   part=str(o.get('referencePart',''))
   return not any(k in o for k in ['liveAction','sharedSeatAction','buttonLabelFor']) and ('livePart' in o or part in ['top','screen','coin','result_display','bet_display','multiplier_screen'] or part.startswith(('slider_','tick_','peg_','bucket_')) or part.startswith('tile_') and part.endswith('_number'))
  targets['display']=[o for o in native if display(o) and not any(display(p) for p in parent_chain(o,model))]
  context.view_layer.update()
  def base_name(name):return name.rsplit('.',1)[0] if name.rsplit('.',1)[-1].isdigit() else name
  for label in native:
   if not label.get('buttonLabelFor'):continue
   candidates=[('control.'+c['slot'],targets['control.'+c['slot']][0]) for c in contract['controls'] if base_name(str(label['buttonLabelFor']))==base_name(c['selector']['name'])]
   if candidates:
    target,_=min(candidates,key=lambda item:(item[1].matrix_world.translation-label.matrix_world.translation).length_squared);targets[target].append(label)
  frames={o:o.parent.matrix_world.copy() for nodes in targets.values() for o in nodes if o.parent}
  for placement in skin.get('placements',[]):
   target=placement['target'];pivot=(0,0,0)
   bounds=box if target=='body' else next((c['bounds'] for c in contract['controls'] if 'control.'+c['slot']==target),None)
   if bounds:pivot=tuple((bounds['min'][i]+bounds['max'][i])/2 for i in range(3))
   x,y,z=placement['rotation'];delta=Matrix.Translation(Vector(placement['position'])+Vector(pivot))@Matrix.Rotation(x,4,'X')@Matrix.Rotation(y,4,'Y')@Matrix.Rotation(z,4,'Z')@Matrix.Diagonal((*placement['scale'],1))@Matrix.Translation(-Vector(pivot))
   axes=Matrix(((1,0,0,0),(0,0,-1,0),(0,1,0,0),(0,0,0,1)));world_delta=anchor.matrix_world@axes@delta@axes.inverted()@anchor.matrix_world.inverted()
   for o in targets.get(target,[]):
    parent=o.parent;local=o.matrix_local.copy();original=frames[o]
    wrapper=bpy.data.objects.new('Skin placement '+target,None);collection.objects.link(wrapper);wrapper.parent=parent;wrapper.matrix_local=original.inverted()@world_delta@original
    o.parent=wrapper;o.matrix_parent_inverse=Matrix.Identity(4);o.matrix_basis=local
  context.view_layer.update()

def parent_chain(obj,stop):
 parent=obj.parent
 while parent and parent!=stop:
  yield parent;parent=parent.parent


def upload_skin_mesh(context):
 selected=[o for o in context.selected_objects if o.type=='MESH']
 if not selected:raise ValueError('Select the static body or one button mesh.')
 if any(o.animation_data or o.find_armature() for o in selected):raise ValueError('Skin assets must be static.')
 with tempfile.TemporaryDirectory(prefix='betworld-skin-') as directory:
  path=str(Path(directory)/'skin.glb')
  bpy.ops.export_scene.gltf(filepath=path,export_format='GLB',use_selection=True,use_active_scene=True,export_animations=False,export_skins=False,export_extras=False,export_cameras=False,export_lights=False)
  data=Path(path).read_bytes()
  if len(data)>4_000_000:raise ValueError('Each skin mesh GLB must be at most 4 MB.')
  result=call(context,'asset_upload',{'name':selected[0].name[:80],'glbBase64':base64.b64encode(data).decode('ascii')})
 context.scene['betworld_last_skin_asset']=result['hash'];return result

def save_game_skin(context,skin,entity_ids):
 scene=context.scene
 if 'betworld_revision' not in scene:raise ValueError('Download the world first.')
 result=call(context,'game_skin_save',{'revision':scene['betworld_revision'],'skin':skin,'entityIds':entity_ids})
 scene['betworld_revision']=result['revision'];skins=json.loads(scene.get('betworld_skins','[]'));scene['betworld_skins']=json.dumps([s for s in skins if s['id']!=skin['id']]+[skin])
 for o in scene.objects:
  if 'betworld_entity' in o:
   e=json.loads(o['betworld_entity'])
   if e['id'] in entity_ids:e['skin']=skin['id'];o['betworld_entity']=json.dumps(e)
 return result

class BETWORLD_OT_skin_mesh(bpy.types.Operator):
 bl_idname='betworld.upload_skin_mesh';bl_label='Upload selected skin mesh'
 def execute(self,context):
  try:r=upload_skin_mesh(context);self.report({'INFO'},'Skin asset uploaded: '+r['hash']);return {'FINISHED'}
  except Exception as e:self.report({'ERROR'},str(e));return {'CANCELLED'}
class BETWORLD_OT_save_skin(bpy.types.Operator):
 bl_idname='betworld.save_game_skin';bl_label='Save skin JSON to selected stations'
 def execute(self,context):
  try:
   text=bpy.data.texts.get('betworld-skin.json')
   if not text:raise ValueError('Create a Text datablock named betworld-skin.json containing the manifest.')
   skin=json.loads(text.as_string());ids=[json.loads(o['betworld_entity'])['id'] for o in context.selected_objects if 'betworld_entity' in o]
   save_game_skin(context,skin,ids);self.report({'INFO'},'Skin saved to draft. Download again to preview.');return {'FINISHED'}
  except Exception as e:self.report({'ERROR'},str(e));return {'CANCELLED'}

class BETWORLD_OT_download(bpy.types.Operator):
 bl_idname='betworld.download_world';bl_label='Download world + assets';bl_options={'REGISTER','UNDO'}
 def execute(self,context):
  try:download_world(context);self.report({'INFO'},'Downloaded into a new scene. Existing scenes were preserved.');return {'FINISHED'}
  except Exception as e:self.report({'ERROR'},str(e));return {'CANCELLED'}
class BETWORLD_OT_upload(bpy.types.Operator):
 bl_idname='betworld.upload_world';bl_label='Upload world draft'
 def execute(self,context):
  try:upload_world(context);self.report({'INFO'},'Draft uploaded. Review the draft status and publish on Betworld.');return {'FINISHED'}
  except Exception as e:self.report({'ERROR'},str(e));return {'CANCELLED'}
class BETWORLD_OT_prop(bpy.types.Operator):
 bl_idname='betworld.upload_prop';bl_label='Upload selected prop'
 def execute(self,context):
  try:r=upload_prop(context);self.report({'INFO'},'Prop added to your library. Upload the world draft to save this placement.');return {'FINISHED'}
  except Exception as e:self.report({'ERROR'},str(e));return {'CANCELLED'}
class BETWORLD_PT_worlds(bpy.types.Panel):
 bl_label='Betworld Worlds';bl_idname='BETWORLD_PT_worlds';bl_space_type='VIEW_3D';bl_region_type='UI';bl_category='Betworld'
 def draw(self,context):
  layout=self.layout;layout.label(text='Paste your world credential:');layout.prop(context.window_manager,'betworld_token',text='Credential');layout.operator('betworld.download_world');layout.operator('betworld.upload_world');layout.operator('betworld.upload_prop');layout.operator('betworld.upload_skin_mesh');layout.operator('betworld.save_game_skin');layout.label(text='Drafts only. Publish on betworld.gg.')
  if 'betworld_id' in context.scene:layout.label(text='Revision '+str(context.scene['betworld_revision']))
  if context.active_object and 'betworld_entity' in context.active_object:layout.prop(context.active_object,'color',text='Surface color')
classes=[BETWORLD_OT_skin_mesh,BETWORLD_OT_save_skin,BETWORLD_OT_download,BETWORLD_OT_upload,BETWORLD_OT_prop,BETWORLD_PT_worlds]
def register():
 for cls in classes:bpy.utils.register_class(cls)
 bpy.types.WindowManager.betworld_token=bpy.props.StringProperty(name='World credential',subtype='PASSWORD',options={'SKIP_SAVE'})
def unregister():
 del bpy.types.WindowManager.betworld_token
 for cls in reversed(classes):bpy.utils.unregister_class(cls)
if __name__=='__main__':register()
