"""North American conventional bus. Clean Blender 5.2 build, Cycles GPU bake + export.
Run: blender -b --python-exit-code 1 --python build_school-bus.py
All photographic sources CC0 Poly Haven; no external non-CC0 images.
"""
import bpy, bmesh, math, os, json, urllib.request, struct
from mathutils import Vector, Matrix
from math import sin,cos,pi
from pathlib import Path
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT)
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
s=bpy.context.scene;s.unit_settings.system='METRIC';s.render.engine='CYCLES';s.cycles.samples=24
prefs=bpy.context.preferences.addons['cycles'].preferences;prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
s.cycles.device='GPU';s.cycles.use_denoising=True
s.render.image_settings.file_format='PNG';s.render.film_transparent=False
s.view_settings.view_transform='AgX';s.view_settings.look='AgX - Medium High Contrast';s.view_settings.exposure=-.35
PH=['metal_plate','rubber_tiles','fabric_leather_02']
for ident in PH:
 for mp in ['diff','nor_gl','arm']:
  p=TEX/f'{ident}_{mp}_1k.jpg'
  if not p.exists():
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{mp}_1k.jpg'
   print('DOWNLOAD',url,flush=True);urllib.request.urlretrieve(url,p)

def mat(name,col,rough=.4,metal=0):
 m=bpy.data.materials.new(name);m.use_nodes=True;p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*col,1);p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal;return m
M={}
# Each reusable surface is baked from a photographic source + node detail to a tiled set.
configs=[('Paint',(0.72,.72,.72),.28,0,'metal_plate',.12),('Rubber',(.023,.027,.029),.78,0,'rubber_tiles',.35),('Metal',(.44,.47,.49),.26,.85,'metal_plate',.24),('Interior',(.045,.073,.078),.67,0,'fabric_leather_02',.42),('DarkMetal',(.044,.048,.051),.4,.65,'metal_plate',.3),('Red',(.48,.009,.012),.3,0,'metal_plate',.09),('Lamps',(.8,.8,.8),.19,.12,'metal_plate',.06)]
bpy.ops.mesh.primitive_plane_add(size=2);tile=bpy.context.object;tile.name='BakeTile'
for name,col,rough,metal,ident,variation in configs:
 print('BAKE',name,flush=True)
 m=mat(name,col,rough,metal);M[name]=m;tile.data.materials.clear();tile.data.materials.append(m)
 nt=m.node_tree;n=nt.nodes;l=nt.links;p=n.get('Principled BSDF');out=n.get('Material Output')
 uv=n.new('ShaderNodeTexCoord')
 tx=n.new('ShaderNodeTexImage');tx.image=bpy.data.images.load(str(TEX/f'{ident}_diff_1k.jpg'));l.new(uv.outputs['UV'],tx.inputs['Vector'])
 bw=n.new('ShaderNodeRGBToBW');l.new(tx.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=(*(c*(1-variation) for c in col),1);ramp.color_ramp.elements[1].color=(*col,1);l.new(bw.outputs[0],ramp.inputs[0]);l.new(ramp.outputs[0],p.inputs['Base Color'])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=150;noise.inputs['Detail'].default_value=2;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 normal=n.new('ShaderNodeTexImage');normal.image=bpy.data.images.load(str(TEX/f'{ident}_nor_gl_1k.jpg'));normal.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],normal.inputs[0])
 norm=n.new('ShaderNodeNormalMap');norm.inputs['Strength'].default_value=.015 if name in ['Paint','Red','Lamps'] else .22;l.new(normal.outputs['Color'],norm.inputs['Color'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.16;bump.inputs['Distance'].default_value=.001 if name=='Paint' else .003;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(norm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],p.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=bpy.data.images.load(str(TEX/f'{ident}_arm_1k.jpg'));arm.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],arm.inputs[0])
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
 rm=n.new('ShaderNodeMath');rm.operation='MULTIPLY_ADD';rm.inputs[1].default_value=.16;rm.inputs[2].default_value=rough-.08;l.new(sep.outputs[1],rm.inputs[0]);l.new(rm.outputs[0],p.inputs['Roughness'])
 if name=='Lamps':
  separate=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],separate.inputs[0]);r=n.new('ShaderNodeValToRGB');r.color_ramp.interpolation='CONSTANT';r.color_ramp.elements.remove(r.color_ramp.elements[1]);r.color_ramp.elements[0].color=(.8,.68,.42,1);r.color_ramp.elements.new(.333).color=(.6,.008,.004,1);r.color_ramp.elements.new(.666).color=(1,.22,.005,1);l.new(separate.outputs['X'],r.inputs[0]);l.new(r.outputs[0],p.inputs['Base Color'])
 if name=='Paint':
  stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(12,.6,1);l.new(uv.outputs['UV'],stretch.inputs[0])
  grime=n.new('ShaderNodeTexNoise');grime.inputs['Scale'].default_value=5;grime.inputs['Detail'].default_value=3;l.new(stretch.outputs[0],grime.inputs['Vector'])
  gr=n.new('ShaderNodeValToRGB');gr.color_ramp.elements[0].position=.25;gr.color_ramp.elements[0].color=(.68,.68,.68,1);gr.color_ramp.elements[1].position=.64;gr.color_ramp.elements[1].color=(1,1,1,1);l.new(grime.outputs['Fac'],gr.inputs[0])
  dirt=n.new('ShaderNodeMixRGB');dirt.blend_type='MULTIPLY';dirt.inputs[0].default_value=.22;l.new(p.inputs['Base Color'].links[0].from_socket,dirt.inputs[1]);l.new(gr.outputs[0],dirt.inputs[2]);l.new(dirt.outputs[0],p.inputs['Base Color'])
 base_socket=p.inputs['Base Color'].links[0].from_socket
 baked={}
 for kind,res in [('base',1024),('normal',512),('orm',512)]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color'
  target=n.new('ShaderNodeTexImage');target.image=im;n.active=target
  s.cycles.samples=8;s.render.bake.margin=8
  if kind=='base':bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
  elif kind=='normal':bpy.ops.object.bake(type='NORMAL')
  else:
   combine=n.new('ShaderNodeCombineColor');combine.inputs[0].default_value=1;combine.inputs[2].default_value=metal;l.new(rm.outputs[0],combine.inputs[1]);em=n.new('ShaderNodeEmission');l.new(combine.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT');l.new(p.outputs[0],out.inputs['Surface'])
  im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();baked[kind]=im
 n.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
 for kind,im in baked.items():
  t=n.new('ShaderNodeTexImage');t.image=im
  if kind=='base':l.new(t.outputs['Color'],p.inputs['Base Color']);base_node=t
  elif kind=='normal':nm=n.new('ShaderNodeNormalMap');l.new(t.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
  else:
   sep=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],p.inputs['Roughness']);l.new(sep.outputs[2],p.inputs['Metallic'])
 if name=='Paint':
  mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.95,.55,.01,1);l.new(base_node.outputs[0],mix.inputs[1]);l.new(mix.outputs[0],p.inputs['Base Color']);p.inputs['Coat Weight'].default_value=1;p.inputs['Coat Roughness'].default_value=.05
 if name=='Lamps':l.new(base_node.outputs[0],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=.22
bpy.data.objects.remove(tile,do_unlink=True)
M['Glass']=mat('Glass',(.075,.12,.14),.12,.08)
p=M['Glass'].node_tree.nodes.get('Principled BSDF');p.inputs['Alpha'].default_value=.40;p.inputs['Transmission Weight'].default_value=.15
M['Glass'].surface_render_method='BLENDED';M['Glass'].use_backface_culling=True
parts={};group='Body'
def add(o,ma,tag=None):
 o.data.materials.append(M[ma]);parts.setdefault(tag or group,[]).append(o);return o
def apply(o):
 bpy.context.view_layer.objects.active=o;o.select_set(True)
 for mod in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=mod.name)
 o.select_set(False)
def bevel(o,w=.015,seg=3):
 b=o.modifiers.new('Manufactured edge radius','BEVEL');b.width=w;b.segments=seg
 b=o.modifiers.new('Face normals','WEIGHTED_NORMAL');b.keep_sharp=True
 return o
def box(name,loc,dim,ma='Paint',b=.012,tag=None):
 dx,dy,dz=[v/2 for v in dim]
 vs=[(-dx,-dy,-dz),(-dx,-dy,dz),(-dx,dy,-dz),(-dx,dy,dz),(dx,-dy,-dz),(dx,-dy,dz),(dx,dy,-dz),(dx,dy,dz)]
 fs=[(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3),(0,2,3,1),(4,5,7,6)]
 me=bpy.data.meshes.new(name);me.from_pydata(vs,[],fs);me.update();o=bpy.data.objects.new(name,me);s.collection.objects.link(o);o.location=loc;add(o,ma,tag)
 bpy.context.view_layer.objects.active=o
 if b:bevel(o,b,3)
 return o

def mesh(name,vs,fs,ma='Paint',b=0,tag=None,smooth=False):
 me=bpy.data.meshes.new(name);me.from_pydata(vs,[],fs);me.update();o=bpy.data.objects.new(name,me);s.collection.objects.link(o);add(o,ma,tag)
 # Explicitly recalculate winding for solid custom meshes.
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 if b:bevel(o,b,3)
 for p in me.polygons:p.use_smooth=smooth
 return o
def cyl(name,loc,r,depth,ma='DarkMetal',axis='Z',verts=32,tag=None,b=.003):
 vs=[]
 for z in [-depth/2,depth/2]:
  for i in range(verts):
   a=2*pi*i/verts;x,y=r*cos(a),r*sin(a)
   vs.append((z,y,-x) if axis=='X' else ((x,-z,y) if axis=='Y' else (x,y,z)))
 fs=[tuple(reversed(range(verts))),tuple(range(verts,verts*2))]+[(i,(i+1)%verts,(i+1)%verts+verts,i+verts) for i in range(verts)]
 me=bpy.data.meshes.new(name);me.from_pydata(vs,[],fs);me.update();o=bpy.data.objects.new(name,me);s.collection.objects.link(o);o.location=loc;add(o,ma,tag)
 for p in me.polygons:p.use_smooth=len(p.vertices)==4
 if b:bevel(o,b,2)
 return o

def rod(name,a,b,r=.012,ma='Metal',tag=None):
 a=Vector(a);b=Vector(b);o=cyl(name,(a+b)/2,r,(b-a).length,ma,tag=tag,verts=12,b=0);o.rotation_euler=(b-a).to_track_quat('Z','Y').to_euler();return o
def torus(name,loc,major,minor,ma='Rubber',axis='X',tag=None,N=64,n=12):
 bpy.ops.mesh.primitive_torus_add(major_radius=major,minor_radius=minor,major_segments=N,minor_segments=n,location=loc);o=bpy.context.object;o.name=name
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 add(o,ma,tag)
 for p in o.data.polygons:p.use_smooth=True
 return o
def panel(name,poly,x0,x1,ma='Paint'):
 v=[(x,y,z) for x in [x0,x1] for y,z in poly];n=len(poly);f=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)];return mesh(name,v,f,ma,.008)
def arch(name,side,y,r1,r2,z=.54):
 vs=[];N=48
 for x in [side*(.80 if 'Front' in name else 1.145),side*1.225]:
  for r in [r1,r2]:
   for i in range(N+1):
    a=pi*i/N;vs.append((x,y+r*cos(a),z+r*sin(a)))
 fs=[];k=N+1
 for i in range(N):
  fs.extend([(i,i+1,k+i+1,k+i),(2*k+i,3*k+i,3*k+i+1,2*k+i+1),(i,2*k+i,2*k+i+1,i+1),(k+i,k+i+1,3*k+i+1,3*k+i)])
 fs.extend([(0,k,3*k,2*k),(N,2*k+N,3*k+N,k+N)])
 return mesh(name,vs,fs,'Paint',.012,smooth=True)
# Chassis and cabin floor.
box('Floor',(0,1.75,.81),(2.30,9.3,.15),'DarkMetal',.03)
for x in [-.70,.70]:box('Chassis rail',(x,1,.57),(.16,10.1,.20),'DarkMetal',.018)
for y in [-3,3]:
 cyl('Axle',(0,y,.53),.095,2.15,'DarkMetal','X')
 box('Differential',(0,y,.53),(.48,.38,.3),'DarkMetal',.11)
 for x in [-.72,.72]:
  for z in [.64,.665,.69]:box('Leaf spring',(x,y,z),(.075,1.18,.018),'DarkMetal',.007)
box('Rear bumper',(0,6.39,.68),(2.44,.16,.32),'Rubber',.045)
box('Front bumper',(0,-4.45,.66),(2.38,.16,.30),'DarkMetal',.045)
# Rounded barrel roof, solid shell with open interior.
prof=[(1.22*sin(-pi/2+pi*i/32),2.84+.35*cos(-pi/2+pi*i/32)) for i in range(33)]
vs=[];roof_y=[-2.85,-2.80,-2.40,-1.0,.5,2.,3.5,5.,6.,6.34,6.39]
section=prof+[(x,z-.055) for x,z in reversed(prof)];k=len(section)
for y in roof_y:
 for x,z in section:vs.append((x,y,z))
fs=[tuple(reversed(range(k))),tuple(range((len(roof_y)-1)*k,len(roof_y)*k))]
for j in range(len(roof_y)-1):
 for i in range(k):fs.append((j*k+i,j*k+(i+1)%k,(j+1)*k+(i+1)%k,(j+1)*k+i))
roof=mesh('Formed roof',vs,fs,'Paint',0,smooth=True)
# Solid stamped front and rear destination headers follow the roof crown.
for ya,yb in [(-2.88,-2.78),(6.31,6.40)]:
 contour=prof+[(1.22,2.78),(-1.22,2.78)]
 vv=[(x,y,z) for y in [ya,yb] for x,z in contour];nn=len(contour)
 ff=[tuple(reversed(range(nn))),tuple(range(nn,2*nn))]+[(i,(i+1)%nn,(i+1)%nn+nn,i+nn) for i in range(nn)]
 mesh('Curved lamp header',vv,ff,'Paint',.009)
for y in [-2.7,-1.1,.6,2.3,4,5.7,6.25]:
 for a,b in zip(prof,prof[1:]):rod('Roof seam',(a[0],y,a[1]+.003),(b[0],y,b[1]+.003),.004,'Paint')
for y in [.1,4.2]:box('Roof emergency hatch',(0,y,3.18),(.65,.63,.04),'Paint',.045)
# Sides: lower skin has actual wheel cutouts, no hidden rectangular slab through wheels.
for side in [-1,1]:
 x=side*1.18
 poly=[(-2.84,1.20),(-2.70,1.13),(-2.50,.98),(-2.34,.76),(2.34,.76)]
 # arch crossing lower shell at rear axle
 for i in range(33):
  a=pi-pi*i/32;poly.append((3+.67*cos(a),.54+.67*sin(a)))
 poly.extend([(3.67,.76),(6.38,.76),(6.38,1.86),(-2.84,1.86)])
 # right door opening excluded at front
 if side==-1:
  poly=poly[3:];poly[0]=(-1.41,.76);poly[-1]=(-1.41,1.86)
 panel('Side steel skirt',poly,x-side*.033,x+side*.033)
 box('Window sill',(x,1.78,1.87),(.075,9.25,.095),'Paint',.016)
 box('Window header',(x,1.78,2.85),(.085,9.25,.09),'Paint',.012)
 # ten split sash passenger windows
 start=-1.41;pitch=.765
 for j in range(10):
  y=start+pitch*j+pitch/2;w=pitch-.087
  box('Window rubber gasket',(x,y,2.37),(.045,w+.028,.885),'Rubber',.025)
  # actual glass is outboard of rubber frame; open center gasket achieved with four borders below
  parts['Body'].pop();o=bpy.context.object;bpy.data.objects.remove(o,do_unlink=True)
  for yy in [y-w/2,y+w/2]:box('Sash side',(x,yy,2.37),(.065,.035,.91),'DarkMetal',.008)
  for zz in [1.93,2.80]:box('Sash top bottom',(x,y,zz),(.065,w,.032),'DarkMetal',.007)
  for zz in [2.155,2.585]:box('Window glass',(x,y,zz),(.009,w-.036,.405),'Glass',0)
  box('Sliding sash divider',(x+side*.014,y,2.365),(.06,w,.036),'Metal',.006)
  box('Body window pillar',(x, start+j*pitch,2.37),(.085,.065,.92),'Paint',.009)
  box('Window latch',(x-side*.04,y,2.37),(.03,.10,.045),'Rubber',.007)
 box('Rear corner post',(x,6.30,2.37),(.105,.17,1.05),'Paint',.02)
 # ribbed black rub rails, stop for entrance door on passenger side
 for z,h in [(1.83,.09),(1.47,.075),(1.23,.12)]:
  y0=-1.42 if side==-1 else -2.83
  box('Rub rail',(side*1.224,(y0+6.36)/2,z),(.041,6.36-y0,h),'Rubber',.012)
  for dz in [-h*.25,h*.25]:box('Rail raised rib',(side*1.247,(y0+6.36)/2,z+dz),(.009,6.36-y0,.013),'DarkMetal',.005)
 for y0,y1 in [(-1.40 if side==-1 else -2.31,2.31),(3.69,6.33)]:box('Lower rub rail',(side*1.218,(y0+y1)/2,.79),(.039,y1-y0,.07),'Rubber',.012)
 arch('Rear wheel arch',side,3,.585,.687)
 box('Mud flap',(side*1.04,3.64,.36),(.30,.042,.48),'Rubber',.015)
 # panels, vertical seams and hundreds of rivets
 for y in [-1.8,-.3,1.2,2.3,3.7,5.05,6.28]:
  box('Panel joint',(side*1.219,y,1.015),(.004,.008,.35),'DarkMetal',.001)
  for z in [.86,.99,1.12,1.59,1.72,2.91]:cyl('Body rivet',(side*1.226,y,z),.008,.006,'Metal','X',12,b=.001)
 for y in [-1.1,1,4.5,5.9]:
  for z in [1.60,1.74]:
   for off in [-.23,0,.23]:cyl('Panel rivet',(side*1.226,y+off,z),.006,.006,'Paint','X',10,b=0)
 box('Luggage hatch',(side*1.223,-.98,1.0),(.016,.73,.33),'Paint',.025)
 box('Hatch latch',(side*1.241,-.77,1.03),(.016,.105,.027),'Metal',.006)
# Front bulkhead with double windshield and roof brow.
box('Front cowl',(0,-2.80,1.69),(2.36,.14,.35),'Paint',.06)
for x in [-1.145,0,1.145]:box('Windshield pillar',(x,-2.814,2.26),(.065,.12,1.1),'Paint' if x else 'Rubber',.015)
for z in [1.75,2.79]:box('Windscreen seal',(0,-2.89,z),(2.27,.05,.043),'Rubber',.012)
for x in [-.573,.573]:box('Windshield',(x,-2.884,2.28),(1.08,.009,1.0),'Glass',0)
for x in [-.64,.64]:
 rod('Wiper arm',(x,-2.915,1.78),(x-.12,-2.918,2.23),.010,'DarkMetal');rod('Wiper blade',(x-.2,-2.925,2.03),(x+.04,-2.925,2.55),.016,'Rubber')
# Driver side cab window and passenger folding door.
box('Driver cab side',(1.18,-2.14,2.37),(.016,1.23,.89),'Glass',0)
for y in [-2.81,-1.46]:box('Cab post',(1.18,y,2.36),(.09,.08,1.01),'Paint',.012)
box('Driver sash',(1.21,-2.14,2.35),(.025,1.26,.028),'Metal',.006)
for j,y in enumerate([-2.15,-1.68]):
 tag=f'Door_{j+1}'
 for yy in [y-.22,y+.22]:box('Door leaf stile',(-1.19,yy,1.80),(.07,.045,2.03),'Paint',.012,tag)
 for z in [.79,1.91,2.80]:box('Door leaf rail',(-1.19,y,z),(.07,.43,.05),'Paint',.01,tag)
 for z,h in [(1.35,1.05),(2.355,.82)]:
  box('Door glass',(-1.19,y,z),(.009,.36,h),'Glass',0,tag)
  for yy in [y-.19,y+.19]:box('Door seal',(-1.19,yy,z),(.04,.018,h),'Rubber',.005,tag)
 for z in [.95,1.75,2.65]:cyl('Door hinge',(-1.23,y-.223,z),.018,.10,'Metal','Z',16,tag)
for z,x in [(.45,-1.03),(.62,-.84),(.79,-.66)]:box('Entry step',(x,-1.91,z),(.42,.88,.07),'Rubber',.012)
# Fixed quarter light ahead of the folding entrance.
box('Entrance quarter glass',(-1.18,-2.60,2.35),(.009,.33,.89),'Glass',0)
for y in [-2.82,-2.38]:box('Entrance front jamb',(-1.18,y,2.30),(.08,.065,1.03),'Paint',.012)
# Rear wall with framed emergency egress.
for x in [-.82,.82]:box('Rear lower wall',(x,6.345,1.20),(.70,.09,.90),'Paint',.025)
box('Rear upper header',(0,6.345,2.81),(2.34,.09,.13),'Paint',.025)
for x in [-1.15,-.47,.47,1.15]:box('Rear window pillar',(x,6.345,2.28),(.09,.09,1.03),'Paint',.015)
box('Rear belt panel',(0,6.345,1.80),(2.34,.09,.24),'Paint',.02)
for x in [-.82,.82]:box('Rear side glass',(x,6.398,2.35),(.55,.009,.77),'Glass',0)
for x in [-.425,.425]:box('Emergency door edge',(x,6.423,1.80),(.038,.032,1.94),'Rubber',.01)
for z,h in [(.94,.26),(1.71,.49),(2.77,.08)]:box('Emergency door panel',(0,6.423,z),(.82,.035,h),'Paint',.015)
for x in [-.365,.365]:box('Emergency door stile',(x,6.423,1.83),(.07,.035,1.85),'Paint',.01)
for z,h in [(2.35,.75),(1.25,.35)]:box('Rear door glass',(0,6.442,z),(.64,.008,h),'Glass',0)
box('Rear door handle',(.25,6.45,1.69),(.14,.03,.035),'Metal',.008)
for z in [1.14,1.91,2.6]:box('Emergency hinge',(-.44,6.45,z),(.08,.035,.07),'Metal',.01)
# Sculpted bonnet, original broad inset grille (no manufacturer grille/signature).
# loft rounded rectangular cross sections over length, top slopes towards nose
rings=[(-4.34,.81,1.12,1.60),(-4.25,.88,1.09,1.67),(-3.92,.91,1.05,1.75),(-3.22,.92,1.07,1.84),(-2.84,.96,1.16,1.88)]
vs=[];hood_n=21
for y,w,low,high in rings:
 profile=[(-w,low)]+[(w*sin(-pi/2+pi*i/18),high-.24+.24*cos(-pi/2+pi*i/18)) for i in range(19)]+[(w,low)]
 for x,z in profile:vs.append((x,y,z))
fs=[tuple(reversed(range(hood_n))),tuple(range((len(rings)-1)*hood_n,len(rings)*hood_n))]
for j in range(len(rings)-1):
 for i in range(hood_n):fs.append((j*hood_n+i,j*hood_n+(i+1)%hood_n,(j+1)*hood_n+(i+1)%hood_n,(j+1)*hood_n+i))
hood=mesh('Sculpted engine hood',vs,fs,'Paint',.035,smooth=True)
for side in [-1,1]:box('Cab quarter panel',(side*1.075,-2.63,1.48),(.29,.43,.76),'Paint',.045)
for side in [-1,1]:
 arch('Front swept fender',side,-3,.59,.72)
 # fender forward cheek joins hood to lamps
 box('Front fender cheek',(side*1.025,-4.05,1.02),(.36,.52,.48),'Paint',.065)
 rod('Hood shut line',(side*.932,-3.84,1.66),(side*.97,-2.86,1.80),.006,'Rubber')
 box('Hood latch',(side*.965,-2.98,1.44),(.045,.08,.16),'Rubber',.012)
 for z in [1.40,1.49]:cyl('Latch bolt',(side*.992,-2.98,z),.012,.012,'Metal','X',12)
 # side hood louvers
 for y in [-3.90,-3.81,-3.72,-3.63]:box('Hood vent',(side*.914,y,1.46),(.009,.034,.19),'DarkMetal',.014)
box('Grille surround',(0,-4.345,1.20),(1.47,.075,.81),'Paint',.10)
box('Grille recess',(0,-4.39,1.20),(1.29,.027,.64),'Rubber',.065)
for x in [i*.069 for i in range(-8,9)]:box('Grille bar',(x,-4.412,1.20),(.027,.033,.55),'Metal',.013)
for z in [.94,1.46]:box('Grille top trim',(0,-4.415,z),(1.14,.025,.024),'Metal',.011)
# Lamp UV palette tagging, resolved after modifiers below.
def lamp(name,loc,r,axis='Y',color='white',depth=.035):
 o=cyl(name,loc,r,depth,'Lamps',axis,32,b=.007);o['lamp_uv']={'white':.16,'red':.5,'amber':.83}[color];return o
for side in [-1,1]:
 x=side*.99
 box('Headlamp housing',(x,-4.325,1.04),(.36,.095,.23),'DarkMetal',.04)
 o=box('Headlight lens',(x,-4.38,1.065),(.28,.025,.13),'Lamps',.025);o['lamp_uv']=.16
 o=box('Front turn signal',(x,-4.38,.948),(.28,.027,.045),'Lamps',.014);o['lamp_uv']=.83
 for xx in [-.91,-.68,.68,.91]:
  if side==-1:break
  cyl('Flasher housing',(xx,-2.86,2.98),.108,.08,'Rubber','Y',32)
  lamp('Front roof flasher',(xx,-2.91,2.98),.082,color='red' if abs(xx)>.8 else 'amber')
  cyl('Rear flasher housing',(xx,6.39,2.98),.105,.05,'Rubber','Y',32)
  lamp('Rear roof flasher',(xx,6.43,2.98),.080,color='red' if abs(xx)>.8 else 'amber')
 for z,col in [(1.60,'red'),(1.37,'amber'),(.98,'red')]:
  cyl('Tail light rubber',(side*.96,6.407,z),.080,.035,'Rubber','Y')
  lamp('Tail light',(side*.96,6.435,z),.063,color=col)
 for y in [-1.54,5.99]:lamp('Side marker',(side*1.255,y,1.63),.035,'X','amber' if y<0 else 'red',.012)
for x in [-.29,0,.29]:lamp('Roof marker',(x,-2.76,3.164),.031,'Y','amber',.025)
# Mirrors on triangulated stays, with inset reflective glass.
for side in [-1,1]:
 rod('Mirror stay',(side*1.16,-2.79,2.54),(side*1.42,-3.0,2.53),.019,'DarkMetal')
 rod('Mirror brace',(side*1.17,-2.76,1.96),(side*1.42,-3.0,2.11),.014,'DarkMetal')
 box('Rearview mirror',(side*1.43,-3.01,2.33),(.15,.13,.52),'Rubber',.06)
 box('Rearview face',(side*1.431,-2.934,2.33),(.11,.014,.44),'Metal',.035)
 rod('Crossview stalk',(side*1.04,-4.05,1.19),(side*1.37,-4.31,1.94),.016,'DarkMetal')
 rod('Crossview brace',(side*.91,-3.63,1.54),(side*1.37,-4.31,1.91),.012,'DarkMetal')
 box('Crossview housing',(side*1.37,-4.31,2.0),(.22,.08,.20),'Rubber',.07)
 box('Crossview lens',(side*1.37,-4.357,2.0),(.17,.016,.15),'Metal',.04)
# Text-free octagonal stop-sign arm, folded against left side.
box('Stop arm mount',(1.265,-1.57,1.78),(.085,.16,.37),'DarkMetal',.02)
rod('Stop arm linkage',(1.28,-1.58,1.77),(1.31,-1.21,1.80),.021,'Metal')
for rad,x,ma in [(.285,1.322,'Metal'),(.254,1.339,'Red')]:
 cyl('Octagonal stop arm',(x,-1.25,1.85),rad,.022,ma,'X',8,b=.008)
for z in [1.64,2.06]:lamp('Stop arm lamp',(1.359,-1.25,z),.028,'X','red',.012)
# Interior: seat frames, padded vinyl benches and piping, aisle, cab.
box('Interior aisle',(0,1.95,.908),(.57,8.68,.023),'Rubber',.005)
for y in [-1.28+i*.78 for i in range(10)]:
 for side in [-1,1]:
  x=side*.78
  box('Passenger cushion',(x,y,1.25),(.79,.48,.13),'Interior',.075)
  back=box('Passenger seat back',(x,y+.245,1.62),(.80,.12,.69),'Interior',.085);back.rotation_euler[0]=-.06
  for xx in [x-.29,x+.29]:rod('Seat leg',(xx,y,.90),(xx,y,1.20),.024,'DarkMetal')
  rod('Bench back handle',(x-.32,y+.23,1.90),(x+.32,y+.23,1.90),.019,'DarkMetal')
for side in [-1,1]:
 rod('Ceiling grab rail',(side*.61,-1.65,2.69),(side*.61,5.96,2.69),.021,'Metal')
 for y in [-1.5,1.4,4.5,5.8]:rod('Rail ceiling bracket',(side*.61,y,2.7),(side*.61,y,3.13),.015,'Metal')
box('Dashboard',(0,-2.56,1.64),(2.18,.40,.20),'Rubber',.065)
box('Driver instrument binnacle',(.60,-2.46,1.81),(.55,.25,.20),'DarkMetal',.06)
for x in [.43,.58,.73]:cyl('Instrument bezel',(x,-2.315,1.84),.045,.018,'Metal','Y',24)
for x in [.43,.58,.73]:cyl('Instrument glass',(x,-2.298,1.84),.036,.008,'Rubber','Y',24)
box('Driver cushion',(.66,-1.99,1.26),(.57,.51,.16),'Interior',.07)
box('Driver backrest',(.66,-1.69,1.65),(.58,.14,.72),'Interior',.08)
cyl('Seat pedestal',(.66,-1.97,1.06),.13,.30,'DarkMetal')
rod('Steering column',(.64,-2.36,1.33),(.64,-2.16,1.79),.036,'DarkMetal')
steer=(.64,-2.16,1.79)
o=torus('Steering rim',steer,.205,.021,'Rubber',axis='Z',tag='SteeringWheel',N=48,n=12);o.rotation_euler[0]=.50
for a in [0,2*pi/3,4*pi/3]:rod('Steering spoke',steer,(steer[0]+.18*cos(a),steer[1]+.18*sin(a)*cos(.5),steer[2]+.18*sin(a)*sin(.5)),.015,'DarkMetal','SteeringWheel')
cyl('Steering hub',steer,.056,.07,'Rubber',tag='SteeringWheel')
rod('Entry rail',(-.63,-2.48,.91),(-.63,-2.48,1.73),.025,'Metal');rod('Entry rail grip',(-.63,-2.48,1.73),(-.63,-1.85,1.73),.025,'Metal')
# Fuel tank and exhaust.
box('Fuel tank',(.66,.2,.57),(.65,1.14,.31),'DarkMetal',.065)
for y in [-.22,.62]:box('Tank strap',(.66,y,.406),(.61,.045,.02),'Metal',.005)
rod('Exhaust pipe',(-.63,2.1,.46),(-.63,6.1,.46),.047,'DarkMetal')
rod('Exhaust outlet',(-.63,6.1,.46),(-.91,6.41,.44),.047,'DarkMetal')
# Tyres as lathed cross sections, tread channels and block geometry. Rear duals share node.
def tyre(tag,x,y):
 # profile radius vs axial displacement, wheel axis X; radius .54 => ground contact
 profile=[(-.145,.31),(-.173,.37),(-.176,.45),(-.155,.505),(-.115,.534),(-.078,.54),(.078,.54),(.115,.534),(.155,.505),(.176,.45),(.173,.37),(.145,.31)]
 vs=[];N=80
 for dx,r in profile:
  for j in range(N):a=2*pi*j/N;vs.append((x+dx,y+r*sin(a),.54+r*cos(a)))
 fs=[]
 for k in range(len(profile)):
  for j in range(N):fs.append((k*N+j,k*N+(j+1)%N,((k+1)%len(profile))*N+(j+1)%N,((k+1)%len(profile))*N+j))
 mesh('Tyre carcass',vs,fs,'Rubber',tag=tag,smooth=True)
 # circumferential recessed grooves implied by separated tread ribs
 for dx in [-.104,-.052,0,.052,.104]:torus('Tread rib',(x+dx,y,.54),.532,.009,'Rubber',tag=tag,N=80,n=6)
 for j in range(64):
  a=2*pi*j/64
  for row in [-1,1]:
   o=box('Tread shoulder block',(x+row*.128,y+.518*sin(a),.54+.518*cos(a)),(.032,.041,.024),'Rubber',.004,tag);o.rotation_euler[0]=-a
 # steel dish + concentric rim beads on outer side
 side=1 if x>0 else -1;outer=x+side*.177
 cyl('Wheel rim',(x,y,.54),.319,.30,'DarkMetal','X',64,tag,b=.015)
 cyl('Wheel dish',(outer,y,.54),.266,.022,'DarkMetal','X',64,tag,b=.012)
 torus('Rim lip',(outer,y,.54),.303,.018,'Metal',tag=tag,N=64,n=10)
 torus('Sidewall molding',(outer+side*.001,y,.54),.439,.004,'Rubber',tag=tag,N=80,n=6)
 cyl('Wheel hub',(outer+side*.035,y,.54),.115,.10,'DarkMetal','X',48,tag,b=.014)
 for j in range(10):
  a=2*pi*j/10
  cyl('Lug nut',(outer+side*.042,y+.152*sin(a),.54+.152*cos(a)),.019,.031,'Metal','X',6,tag,b=.002)
  cyl('Rim ventilation recess',(outer+side*.014,y+.231*sin(a),.54+.231*cos(a)),.035,.006,'Rubber','X',16,tag,b=.003)
 cyl('Brake rotor',(x-side*.15,y,.54),.265,.025,'Metal','X',48,tag)
for tag,x,y in [('Wheel_FL',1.02,-3),('Wheel_FR',-1.02,-3),('Wheel_RL',1.02,3),('Wheel_RR',-1.02,3)]:
 tyre(tag,x,y)
 if y>0:tyre(tag,x-(.32 if x>0 else -.32),y)
print('GEOMETRY CREATED',sum(len(v) for v in parts.values()),flush=True)
# Snapshot evaluated bevels in one dependency-graph pass before modifying the scene.
bpy.context.view_layer.update();dg=bpy.context.evaluated_depsgraph_get()
evaluated=[]
for obs in parts.values():
 for o in obs:
  me=bpy.data.meshes.new_from_object(o.evaluated_get(dg),preserve_all_data_layers=True,depsgraph=dg)
  evaluated.append((o,me,o.matrix_world.copy()))
for o,me,mw in evaluated:
 o.modifiers.clear();o.data=me;me.transform(mw);o.matrix_world=Matrix.Identity(4)
print('MODIFIERS APPLIED',flush=True)
# World-projected tiled UVs. No procedural shaders remain.
preserved=[]
for group,obs in parts.items():
 for o in obs:
  uv=o.data.uv_layers.new(name='UVMap') if not o.data.uv_layers else o.data.uv_layers.active
  for p in o.data.polygons:
   ax=max(range(3),key=lambda i:abs(p.normal[i]));dims=[i for i in range(3) if i!=ax]
   for li in p.loop_indices:
    co=o.matrix_world@o.data.vertices[o.data.loops[li].vertex_index].co
    if 'lamp_uv' in o:uv.data[li].uv=(o['lamp_uv']+(co[dims[0]]%1)*.035,.3+(co[dims[1]]%1)*.3)
    else:uv.data[li].uv=(co[dims[0]]*.74,co[dims[1]]*.74)
 if group=='Body':
  preserved=[o for o in obs if o.name.startswith(('Formed roof','Sculpted engine hood','Front wheel arch','Rear wheel arch','Front fender cheek'))]
  obs=[o for o in obs if o not in preserved]
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=group
 # triangulate explicitly, remove true zero-area faces only
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bad=[f for f in bm.faces if f.calc_area()<1e-12]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free()
 origin=(0,0,0)
 if group.startswith('Wheel'):origin=((1.02 if group[-1]=='L' else -1.02),(-3 if group[6]=='F' else 3),.54)
 if group=='SteeringWheel':origin=steer
 if group=='Door_1':origin=(-1.19,-2.37,.79)
 if group=='Door_2':origin=(-1.19,-1.90,.79)
 s.cursor.location=origin;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');o.select_set(False)
for o in preserved:
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free()
protected_tri=sum(len(o.data.polygons) for o in preserved)
asset=[o for o in s.objects if o.type=='MESH' and o not in preserved]
raw_tri=sum(len(o.data.polygons) for o in asset)
if raw_tri>128000:
 ratio=(100000-protected_tri)/raw_tri
 for o in asset:
  d=o.modifiers.new('Game triangle budget','DECIMATE');d.ratio=ratio;d.use_collapse_triangulate=True;apply(o)
 print('Triangle budget',raw_tri,'reduced to',sum(len(o.data.polygons) for o in asset),flush=True)
for o in asset:
 if o.name.startswith('Wheel'):
  zmin=min(v.co.z for v in o.data.vertices);fac=.54/abs(zmin)
  for v in o.data.vertices:v.co.y*=fac;v.co.z*=fac
# Preserve continuous manufactured curves through the budget reduction.
bpy.ops.object.select_all(action='DESELECT')
body=bpy.data.objects['Body'];body.select_set(True)
for o in preserved:o.select_set(True)
bpy.context.view_layer.objects.active=body;bpy.ops.object.join();body.select_set(False)
sockets={'Seat_Driver':(.66,-1.99,1.34),'Door_Front':(-1.25,-1.91,.45),'Exhaust':(-.91,6.41,.44),'Light_Head_L':(.99,-4.40,1.065),'Light_Head_R':(-.99,-4.40,1.065),'Light_Tail_L':(.96,6.46,1.60),'Light_Tail_R':(-.96,6.46,1.60)}
for name,loc in sockets.items():o=bpy.data.objects.new(name,None);s.collection.objects.link(o);o.location=loc;asset.append(o)
def game(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
pts=[o.matrix_world@Vector(v) for o in asset if o.type=='MESH' for v in o.bound_box]
lo=[min(game(v)[i] for v in pts) for i in range(3)];hi=[max(game(v)[i] for v in pts) for i in range(3)]
paints=[{'name':'School bus yellow','linear_rgb':[.95,.55,.01]},{'name':'Transport white','linear_rgb':[.80,.83,.82]},{'name':'Forest green','linear_rgb':[.035,.15,.095]},{'name':'Navy blue','linear_rgb':[.026,.072,.19]},{'name':'Service red','linear_rgb':[.48,.025,.018]},{'name':'Warm ivory','linear_rgb':[.64,.52,.33]}]
tri=sum(len(o.data.polygons) for o in asset if o.type=='MESH')
manifest={'coordinate_system':'glTF Y up; +Z forward; +X vehicle left; axle midpoint ground origin','nodes':[{'name':o.name,'world_position':game(o.location),'type':'mesh' if o.type=='MESH' else 'empty'} for o in asset],'bounding_box':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'nominal_dimensions_m':[2.44,3.20,11.0],'wheelbase_m':6,'triangles':tri,'paints':paints}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
# Identical material normals are shared by reference before export.
import hashlib
seen_images={}
for material in M.values():
 for node in material.node_tree.nodes:
  if node.type=='TEX_IMAGE' and node.image:
   key=hashlib.sha256(bytes(Path(node.image.filepath_raw).read_bytes())).hexdigest()
   if key in seen_images:node.image=seen_images[key]
   else:seen_images[key]=node.image
# Export only asset nodes, then normalize the glTF Paint factor explicitly (Blender supports multiply).
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'school-bus.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT')
pth=ROOT/'school-bus.glb';data=pth.read_bytes();jl,jt=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+jl]);binary=data[20+jl:]
for ma in doc['materials']:
 if ma['name']=='Paint':ma['pbrMetallicRoughness']['baseColorFactor']=[.95,.55,.01,1];ma.setdefault('extensions',{})['KHR_materials_clearcoat']={'clearcoatFactor':1,'clearcoatRoughnessFactor':.05}
 if ma['name']=='Glass':ma['alphaMode']='BLEND';ma['doubleSided']=False
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);pth.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+binary)
print('EXPORT COMPLETE',tri,'triangles',pth.stat().st_size,'bytes',flush=True)
(ROOT/'NOTES.md').write_text('\n'.join(['Conventional 11 m school bus; articulated wheels, folding entrance leaves, visible cabin and text-free stop arm.',f'Triangles: {tri:,}; wheelbase 6.0 m; nominal shell width 2.44 m; mirrors project beyond body.','Materials: Paint, Rubber, Metal, Interior, DarkMetal, Red, Lamps, Glass (8).','Baked Cycles base: 1024 px; normal/ORM: 512 px; shared tiled sets; GLB '+str(round(pth.stat().st_size/1048576,2))+' MiB.','Poly Haven CC0 ids: metal_plate, rubber_tiles, fabric_leather_02; https://polyhaven.com/license ; sources https://polyhaven.com/a/<id>.','Differences: original grille and lamps, no logos or readable text; simplified chassis and cabin fittings; no manufacturer-specific badging.'])+'\n')
# Studio only: excluded from GLB.
ground=mat('Studio ground',(.19,.215,.235),.82)
bpy.ops.mesh.primitive_plane_add(size=200);o=bpy.context.object;o.name='StudioGround';o.data.materials.append(ground);o.location.z=-.013
world=bpy.data.worlds.new('Soft daylight');s.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.70,.80,1,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
for name,loc,power,size in [('Key',(1,-5,10),2200,8),('Fill',(-7,-2,6),1400,7),('Rim',(4,7,8),2300,6)]:
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);s.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,1,1.4))-o.location).to_track_quat('-Z','Y').to_euler()
camd=bpy.data.cameras.new('Presentation');cam=bpy.data.objects.new('Presentation',camd);s.collection.objects.link(cam);s.camera=cam
cam.location=(13.8,-16.5,7.3);target=Vector((0,.85,1.40));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=14.0
s.render.resolution_x=1920;s.render.resolution_y=1080;s.render.resolution_percentage=100;s.cycles.samples=32
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'school-bus.blend'))
def render(file):s.render.filepath=str(ROOT/file);bpy.ops.render.render(write_still=True)
render('final.png')
clay=mat('Clay',(.34,.36,.38),.60)
s.view_layers[0].material_override=clay;render('clay.png');s.view_layers[0].material_override=None
cam.location=(-19,1,3.2);cam.rotation_euler=(Vector((0,1,1.6))-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=13.6;render('side.png')
cam.location=(13.8,-16.5,7.3);cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=14
s.render.resolution_x=640;s.render.resolution_y=360;s.cycles.samples=12
mix=next(n for n in M['Paint'].node_tree.nodes if n.type=='MIX_RGB')
for i,c in enumerate(paints):mix.inputs[2].default_value=(*c['linear_rgb'],1);render(f'paint_{i}.png')
mix.inputs[2].default_value=(.95,.55,.01,1)
# Assemble PNG grid using Blender image buffers (no external packages).
import numpy as np
canvas=np.ones((720,1920,4),dtype=np.float32)
for i in range(6):
 im=bpy.data.images.load(str(ROOT/f'paint_{i}.png'));arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=arr
im=bpy.data.images.new('Paint variants',width=1920,height=720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
print('ALL DELIVERABLES COMPLETE',flush=True)
# Verify the delivered GLB by importing it into the same presentation lighting.
for o in asset:bpy.data.objects.remove(o,do_unlink=True)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'school-bus.glb'))
s.render.resolution_x=1280;s.render.resolution_y=720;s.cycles.samples=24
render('roundtrip.png')
print('EXPORTED GLB ROUNDTRIP COMPLETE',flush=True)
