"""Rafale-style game asset. Blender 5.2: blender -b --python-exit-code 1 --python build_rafale-fighter.py
Clean deterministic build; downloads CC0 Poly Haven maps, Cycles GPU bakes shared tiles,
exports uncompressed portable GLB and all requested views. Coordinates in Blender: X left, Y aft, Z up.
"""
import bpy, math, os, json, urllib.request, random, struct
import numpy as np
from mathutils import Vector
from math import sin,cos,pi
ROOT=os.path.dirname(os.path.abspath(__file__))
CACHE=os.path.join(ROOT,'textures');os.makedirs(CACHE,exist_ok=True)
random.seed(23)
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials):bpy.data.materials.remove(m)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES';sc.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'
sc.cycles.device='GPU';sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG';sc.render.film_transparent=False
sc.view_settings.view_transform='AgX'
sc.view_settings.exposure=-.2
GROUPS={};PIVOTS={};SOCKETS={};MATS={}
def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def mesh(name,verts,faces,mat='Paint',group='Fuselage',smooth=True):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(name,me);sc.collection.objects.link(o)
 if mat:o.data.materials.append(MATS[mat])
 for p in me.polygons:p.use_smooth=smooth
 GROUPS.setdefault(group,[]).append(o)
 return o

def apply(o):
 active(o)
 for mod in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=mod.name)
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)

def bevel(o,w=.012,seg=3):
 b=o.modifiers.new('manufactured radii','BEVEL');b.width=w;b.segments=seg
 apply(o)
 return o

def box(name,loc,size,mat='Paint',group='Fuselage',r=.018):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.scale=size;apply(o);o.data.materials.append(MATS[mat]);GROUPS.setdefault(group,[]).append(o);bevel(o,r,3)
 return o

def uv_project(o,scale=1.4):
 me=o.data
 uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers.active
 for p in me.polygons:
  axis=max(range(3),key=lambda i:abs(p.normal[i]));axes=[i for i in range(3) if i!=axis]
  for li in p.loop_indices:
   v=me.vertices[me.loops[li].vertex_index].co;uv.data[li].uv=(v[axes[0]]/scale,v[axes[1]]/scale)

def tube(name,pts,r=.015,mat='Metal',group='Fuselage',sides=8):
 verts=[];faces=[]
 for k,p in enumerate(pts):
  p=Vector(p);d=Vector(pts[min(k+1,len(pts)-1)])-Vector(pts[max(0,k-1)])
  d.normalize();u=d.cross(Vector((0,0,1)))
  if u.length<.01:u=d.cross(Vector((0,1,0)))
  u.normalize();v=d.cross(u).normalized()
  for j in range(sides):verts.append(p+r*(u*cos(j*2*pi/sides)+v*sin(j*2*pi/sides)))
 for k in range(len(pts)-1):
  for j in range(sides):a=k*sides+j;b=k*sides+(j+1)%sides;faces.append((a,b,b+sides,a+sides))
 faces.extend([tuple(reversed(range(sides))),tuple((len(pts)-1)*sides+j for j in range(sides))])
 return mesh(name,verts,faces,mat,group)

def cyl(name,a,b,r,mat='Metal',group='Fuselage',n=32,r2=None):
 a,b=Vector(a),Vector(b);d=b-a
 bpy.ops.mesh.primitive_cone_add(vertices=n,radius1=r,radius2=r if r2 is None else r2,depth=d.length,location=(a+b)/2)
 o=bpy.context.object;o.name=name;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();apply(o);o.data.materials.append(MATS[mat]);GROUPS.setdefault(group,[]).append(o)
 for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
 bevel(o,min(.008,r*.08),2);return o

def sphere(name,loc,scale,mat='Metal',group='Fuselage',seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;apply(o);o.data.materials.append(MATS[mat]);GROUPS.setdefault(group,[]).append(o)
 for p in o.data.polygons:p.use_smooth=True
 return o

def torus(name,loc,major,minor,mat='Rubber',group='Fuselage',axis='X',n=64,m=12):
 bpy.ops.mesh.primitive_torus_add(major_radius=major,minor_radius=minor,major_segments=n,minor_segments=m,location=loc)
 o=bpy.context.object;o.name=name
 if axis=='X':o.rotation_euler[1]=pi/2
 elif axis=='Y':o.rotation_euler[0]=pi/2
 apply(o);o.data.materials.append(MATS[mat]);GROUPS.setdefault(group,[]).append(o)
 for p in o.data.polygons:p.use_smooth=True
 return o

def empty(name,p):SOCKETS[name]=p

# Photographic tile inputs. These are the only external image sources.
def getmap(asset,kind):
 path=os.path.join(CACHE,asset+'_'+kind+'.jpg')
 if not os.path.exists(path):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
  urllib.request.urlretrieve(url,path)
 im=bpy.data.images.load(path,check_existing=True)
 if kind!='diff':im.colorspace_settings.name='Non-Color'
 return im
sources={a:{k:getmap(a,k) for k in ('diff','nor_gl','arm')} for a in ('metal_plate','rubberized_track')}

def bake_material(name,color,metal,rough,source='metal_plate',res=512,glass=False,emissive=False):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();nodes=nt.nodes;links=nt.links
 out=nodes.new('ShaderNodeOutputMaterial');bs=nodes.new('ShaderNodeBsdfPrincipled');links.new(bs.outputs['BSDF'],out.inputs['Surface'])
 bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 tc=nodes.new('ShaderNodeTexCoord');photo=nodes.new('ShaderNodeTexImage');photo.image=sources[source]['diff'];links.new(tc.outputs['UV'],photo.inputs['Vector'])
 bw=nodes.new('ShaderNodeRGBToBW');links.new(photo.outputs['Color'],bw.inputs[0])
 ramp=nodes.new('ShaderNodeValToRGB');links.new(bw.outputs[0],ramp.inputs[0])
 lo=.44 if name=='Paint' else .62
 ramp.color_ramp.elements[0].color=tuple(c*lo for c in color)+(1,);ramp.color_ramp.elements[1].color=tuple(color)+(1,)
 noise=nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=12;noise.inputs['Detail'].default_value=3;links.new(tc.outputs['UV'],noise.inputs['Vector'])
 mix=nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.20;links.new(ramp.outputs['Color'],mix.inputs[1]);links.new(noise.outputs['Fac'],mix.inputs[2]);links.new(mix.outputs[0],bs.inputs['Base Color'])
 norm=nodes.new('ShaderNodeTexImage');norm.image=sources[source]['nor_gl'];links.new(tc.outputs['UV'],norm.inputs['Vector']);nm=nodes.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.065 if name=='Paint' else .18;links.new(norm.outputs['Color'],nm.inputs['Color']);links.new(nm.outputs[0],bs.inputs['Normal'])
 arm=nodes.new('ShaderNodeTexImage');arm.image=sources[source]['arm'];links.new(tc.outputs['UV'],arm.inputs['Vector']);sep=nodes.new('ShaderNodeSeparateColor');links.new(arm.outputs['Color'],sep.inputs[0])
 rr=nodes.new('ShaderNodeMapRange');rr.inputs['To Min'].default_value=max(.04,rough-.16);rr.inputs['To Max'].default_value=min(1,rough+.13);links.new(sep.outputs['Green'],rr.inputs['Value']);links.new(rr.outputs[0],bs.inputs['Roughness'])
 bpy.ops.mesh.primitive_plane_add(size=1);o=bpy.context.object;o.name='bake_tile';o.data.materials.append(m)
 sc.cycles.samples=4;sc.render.bake.margin=4;sc.render.bake.use_clear=True
 target=nodes.new('ShaderNodeTexImage')
 baked={}
 def dobake(key,size,typ):
  im=bpy.data.images.new(name+'_'+key,width=size,height=size,alpha=False)
  if key!='BaseColor':im.colorspace_settings.name='Non-Color'
  target.image=im;nodes.active=target;active(o)
  if typ=='DIFFUSE':bpy.ops.object.bake(type=typ,pass_filter={'COLOR'})
  else:bpy.ops.object.bake(type=typ)
  im.filepath_raw=os.path.join(CACHE,name+'_'+key+'.png');im.file_format='PNG';im.save();baked[key]=im
 dobake('BaseColor',res,'DIFFUSE');dobake('Normal',res,'NORMAL')
 emit=nodes.new('ShaderNodeEmission');combine=nodes.new('ShaderNodeCombineColor');combine.inputs['Red'].default_value=1;links.new(rr.outputs[0],combine.inputs['Green']);combine.inputs['Blue'].default_value=metal;links.new(combine.outputs[0],emit.inputs['Color']);links.new(emit.outputs[0],out.inputs['Surface'])
 dobake('ORM',min(512,res),'EMIT')
 bpy.data.objects.remove(o,do_unlink=True)
 nt.nodes.clear();out=nodes.new('ShaderNodeOutputMaterial');bs=nodes.new('ShaderNodeBsdfPrincipled');links.new(bs.outputs[0],out.inputs[0])
 bc=nodes.new('ShaderNodeTexImage');bc.image=baked['BaseColor'];links.new(bc.outputs['Color'],bs.inputs['Base Color'])
 normal=nodes.new('ShaderNodeTexImage');normal.image=baked['Normal'];nm=nodes.new('ShaderNodeNormalMap');links.new(normal.outputs[0],nm.inputs['Color']);links.new(nm.outputs[0],bs.inputs['Normal'])
 orm=nodes.new('ShaderNodeTexImage');orm.image=baked['ORM'];sep=nodes.new('ShaderNodeSeparateColor');links.new(orm.outputs[0],sep.inputs[0]);links.new(sep.outputs['Green'],bs.inputs['Roughness']);links.new(sep.outputs['Blue'],bs.inputs['Metallic'])
 # Recognised glTF occlusion group, packed in the same image as roughness and metal.
 ng=bpy.data.node_groups.get('glTF Material Output')
 if not ng:
  ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=nodes.new('ShaderNodeGroup');gn.node_tree=ng;links.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 if glass:
  m.surface_render_method='BLENDED';bs.inputs['Alpha'].default_value=.48;bs.inputs['Roughness'].default_value=.16;bs.inputs['Metallic'].default_value=.08
  for l in list(bs.inputs['Roughness'].links)+list(bs.inputs['Metallic'].links):links.remove(l)
 if emissive:
  links.new(bc.outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=2
 m.diffuse_color=tuple(color)+(1,);MATS[name]=m
 print('BAKED',name,flush=True)
 return m

bake_material('Paint',(0.94,.94,.94),.05,.56,res=1024)
bake_material('Metal',(.48,.51,.54),.85,.43)
bake_material('Exhaust',(.15,.13,.115),.82,.47)
bake_material('Rubber',(.032,.035,.038),0,.79,'rubberized_track')
bake_material('Interior',(.052,.061,.066),.1,.67)
bake_material('Glass',(.20,.28,.30),.05,.16,res=256,glass=True)
bake_material('Lamps',(.68,.77,.68),.2,.2,res=128,emissive=True)
paint=MATS['Paint'];pbs=next(n for n in paint.node_tree.nodes if n.type=='BSDF_PRINCIPLED');ptex=next(n for n in paint.node_tree.nodes if n.type=='TEX_IMAGE' and 'BaseColor' in n.image.name)
mult=paint.node_tree.nodes.new('ShaderNodeMixRGB');mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;mult.inputs[2].default_value=(.18,.20,.22,1);paint.node_tree.links.new(ptex.outputs[0],mult.inputs[1]);paint.node_tree.links.new(mult.outputs[0],pbs.inputs['Base Color'])

# Smoothly interpolated station loft. Fuselage dimensions are in metres.
stations=[(-10.8,.004,.006,1.91),(-10.3,.17,.12,1.94),(-9.5,.37,.27,1.99),(-8.65,.51,.39,2.06),(-7.7,.60,.47,2.13),(-6.6,.66,.51,2.13),(-5.5,.71,.56,2.08),(-4.4,.86,.59,2.04),(-3.1,1.00,.61,2.04),(-1.5,1.08,.61,2.04),(0,1.13,.59,2.03),(1.6,1.14,.55,2.02),(3.4,1.10,.50,2.01),(3.95,1.02,.46,2.0)]
def station(y):
 for k in range(len(stations)-1):
  if y<=stations[k+1][0]:break
 a=stations[k];b=stations[k+1];t=max(0,min(1,(y-a[0])/(b[0]-a[0])))
 vals=[]
 for j in (1,2,3):
  p=stations[max(0,k-1)][j];q=a[j];r=b[j];s=stations[min(len(stations)-1,k+2)][j]
  vals.append(.5*((2*q)+(-p+r)*t+(2*p-5*q+4*r-s)*t*t+(-p+3*q-3*r+s)*t*t*t))
 return vals
verts=[];faces=[];N=80;K=150
for i in range(K+1):
 y=-10.8+(14.75*i/K);w,h,z=station(y)
 for j in range(N):a=j*2*pi/N;verts.append((w*cos(a),y,z+h*sin(a)))
for i in range(K):
 for j in range(N):a=i*N+j;b=i*N+(j+1)%N;faces.append((a,b,b+N,a+N))
faces.extend([tuple(reversed(range(N))),tuple(K*N+j for j in range(N))])
fuse=mesh('continuous pressure skin',verts,faces)
# Remove hidden top cockpit patch to expose the cockpit through glazing.
import bmesh
bm=bmesh.new();bm.from_mesh(fuse.data)
kill=[]
for f in bm.faces:
 c=f.calc_center_median()
 if (-8.15<c.y<-4.85 and c.z>2.40 and abs(c.x)<.46) or (-5.19<c.y<-3.35 and 1.47<c.z<2.31 and abs(c.x)>.51):kill.append(f)
bmesh.ops.delete(bm,geom=kill,context='FACES');bm.to_mesh(fuse.data);bm.free()

# Fuselage seams follow the curved skin, not straight floating strips.
for y in [-9.22,-8.55,-4.48,-3.30,-1.95,-.35,1.2,2.70,3.45]:
 w,h,z=station(y);pts=[]
 for j in range(97):
  a=2*pi*j/96;pts.append(((w+.0018)*cos(a),y,z+(h+.0018)*sin(a)))
 tube('skin circumferential joint',pts,.0032,'Interior',sides=5)
 for j in range(0,96,4):
  a=2*pi*j/96
  if -8.2<y<-4.8 and sin(a)>.5:continue
  sphere('flush fastener',((w+.003)*cos(a),y+.033,z+(h+.003)*sin(a)),(.009,.009,.009),'Metal',seg=8,rings=4)
for a in [0,pi,pi/4,3*pi/4,5*pi/4,7*pi/4]:
 pts=[]
 for k in range(101):
  y=-4.4+k*8.25/100;w,h,z=station(y);pts.append(((w+.002)*cos(a),y,z+(h+.002)*sin(a)))
 tube('longitudinal skin joint',pts,.0028,'Interior',sides=5)
# Dorsal spine, blended along upper fuselage.
def ell_loft(name,sections,mat='Paint',group='Fuselage',n=48,caps=True):
 vv=[];ff=[]
 for y,cx,cz,rx,rz in sections:
  for j in range(n):a=2*pi*j/n;vv.append((cx+rx*cos(a),y,cz+rz*sin(a)))
 for k in range(len(sections)-1):
  for j in range(n):a=k*n+j;b=k*n+(j+1)%n;ff.append((a,b,b+n,a+n))
 if caps:ff.extend([tuple(reversed(range(n))),tuple((len(sections)-1)*n+j for j in range(n))])
 o=mesh(name,vv,ff,mat,group)
 if caps:
  for p in o.data.polygons[-2:]:p.use_smooth=False
 return o
ell_loft('dorsal spine',[(-5.0,0,2.54,.4,.18),(-4.0,0,2.56,.36,.15),(-2,0,2.57,.28,.12),(0,0,2.54,.25,.13),(2.6,0,2.47,.18,.09)])

# Airfoils sampled chordwise with a rounded leading edge and a finite trailing edge.
def wing_surface(name,sign,sections,group='Fuselage',start=0,end=1):
 # sections: x,leading y,trailing y,z, thickness. Closed shell, correct physical edges.
 vv=[];ff=[];C=24
 for x,le,te,z,th in sections:
  for side in (1,-1):
   for j in range(C+1):
    t=start+(end-start)*(.5-.5*cos(pi*j/C))
    shape=max(0,(.2969*math.sqrt(t)-.126*t-.3516*t*t+.2843*t**3-.1015*t**4))*5
    vv.append((sign*x,le+(te-le)*t,z+side*(th*shape+.004)))
 R=2*(C+1)
 for k in range(len(sections)-1):
  for side in range(2):
   for j in range(C):
    a=k*R+side*(C+1)+j;b=a+1;ff.append((a,b,b+R,a+R))
  for j in (0,C):
   a=k*R+j;b=k*R+C+1+j;ff.append((a,b,b+R,a+R))
 for k in (0,len(sections)-1):ff.append(tuple(k*R+j for j in list(range(C+1))+list(reversed(range(C+1,R)))))
 o=mesh(name,vv,ff,'Paint',group)
 # recalc avoids reflection differences between mirrored sides
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
 return o
wingsecs=[(.72,-4.2,3.22,2.00,.13),(1.05,-3.82,3.25,2.03,.125),(1.75,-2.8,3.28,2.05,.105),(2.7,-1.36,3.3,2.065,.085),(3.75,.15,3.32,2.08,.065),(4.65,1.35,3.35,2.10,.048),(5.26,2.10,3.37,2.12,.035)]
for sg,lr in [(1,'L'),(-1,'R')]:
 wing_surface('delta main wing '+lr,sg,wingsecs,end=.77)
 wing_surface('inner elevon '+lr,sg,wingsecs[:4],group='Elevon_Inner_'+lr,start=.771)
 wing_surface('outer elevon '+lr,sg,wingsecs[3:],group='Elevon_'+lr,start=.771)
 PIVOTS['Elevon_Inner_'+lr]=(sg*.72,-4.2+(3.22+4.2)*.771,2.0)
 PIVOTS['Elevon_'+lr]=(sg*2.7,-1.36+4.66*.771,2.065)
 # Wing seams on the top and corresponding rows of recessed rivets.
 for idx in [1,2,4,5]:
  x,le,te,z,th=wingsecs[idx]
  pts=[]
  for j in range(36):
   t=.09+.64*j/35;shape=5*(.2969*math.sqrt(t)-.126*t-.3516*t*t+.2843*t**3-.1015*t**4)
   pts.append((sg*x,le+(te-le)*t,z+th*shape+.006))
  tube('wing rib panel joint',pts,.003,'Interior',sides=5)
 for frac in [.15,.60]:
  pts=[]
  for x,le,te,z,th in wingsecs:
   shape=5*(.2969*math.sqrt(frac)-.126*frac-.3516*frac**2+.2843*frac**3-.1015*frac**4)
   pts.append((sg*x,le+(te-le)*frac,z+th*shape+.006))
  tube('wing spar panel joint',pts,.003,'Interior',sides=5)
  for a,b in zip(pts,pts[1:]):
   for j in range(1,8):
    p=Vector(a).lerp(Vector(b),j/8);p.y+=.032;sphere('wing flush screw',p,(.008,.008,.004),'Metal',seg=8,rings=4)
 # slender wingtip launch rails, no missiles
 ell_loft('wingtip launch rail',[(1.63,sg*5.30,2.13,.018,.018),(1.85,sg*5.30,2.13,.07,.065),(3.72,sg*5.30,2.13,.10,.065),(3.94,sg*5.30,2.13,.02,.018)])
 empty('Hardpoint_Wingtip_'+lr,(sg*5.30,2.55,2.08));empty('Light_Nav_'+lr,(sg*5.38,2.55,2.13))
 sphere('navigation lens', (sg*5.375,2.5,2.14),(.025,.10,.035),'Lamps',seg=16,rings=8)
 # Close-coupled all-moving canards, just above and ahead of intake lips.
 cansecs=[(.59,-5.45,-3.80,2.66,.053),(1.0,-5.28,-3.72,2.69,.048),(1.65,-4.60,-3.66,2.72,.037),(2.28,-3.93,-3.62,2.74,.023)]
 wing_surface('canard '+lr,sg,cansecs,'Canard_'+lr);PIVOTS['Canard_'+lr]=(sg*.68,-4.2,2.68)
 cyl('canard trunnion',(sg*.63,-4.2,2.66),(sg*.95,-4.2,2.68),.10,'Metal')
 # Underwing pylons follow airfoil; three hardpoints on each wing.
 for k,x in enumerate((1.55,2.7,3.7)):
  y=.6+k*.48;z=2.0
  vv=[(sg*(x-.04),y-.62,z),(sg*(x+.04),y-.62,z),(sg*(x-.04),y+.72,z),(sg*(x+.04),y+.72,z),(sg*(x-.035),y-.2,z-.28),(sg*(x+.035),y-.2,z-.28),(sg*(x-.035),y+.57,z-.28),(sg*(x+.035),y+.57,z-.28)]
  bevel(mesh('stores attachment pylon',vv,[(0,2,3,1),(0,1,5,4),(2,6,7,3),(4,5,7,6),(0,4,6,2),(1,3,7,5)],smooth=False),.015)
  empty('Hardpoint_'+lr+'_'+str(k+1),(sg*x,y+.15,z-.29))

# Flush wing access caps and engraved fastener rings, all carried by the shared materials.
for sg in [-1,1]:
 for xx,yy in [(2.15,.45),(3.4,1.58),(4.25,2.39)]:
  def wingtop(x,y):
   for k in range(len(wingsecs)-1):
    if x<=wingsecs[k+1][0]:break
   a,b=wingsecs[k],wingsecs[k+1];f=(x-a[0])/(b[0]-a[0]);le=a[1]+(b[1]-a[1])*f;te=a[2]+(b[2]-a[2])*f;z=a[3]+(b[3]-a[3])*f;th=a[4]+(b[4]-a[4])*f
   t=(y-le)/(te-le);shape=5*(.2969*math.sqrt(t)-.126*t-.3516*t*t+.2843*t**3-.1015*t**4)
   return z+th*shape+.006
  rr=.088;pts=[]
  for j in range(33):
   a=j*2*pi/32;x=xx+rr*cos(a);y=yy+rr*sin(a);pts.append((sg*x,y,wingtop(x,y)))
  tube('flush wing access cap',pts,.0025,'Interior',sides=5)
  for j in range(4):
   a=j*pi/2;x=xx+.105*cos(a);y=yy+.105*sin(a);sphere('access cap screw',(sg*x,y,wingtop(x,y)),(.008,.008,.003),'Metal',seg=8,rings=4)

# Fin is a tapered airfoil in the vertical plane, swept back with an integral dorsal fillet.
finsecs=[(2.46,-.15,3.17,.19),(2.85,.70,3.30,.16),(3.45,1.40,3.55,.125),(4.4,2.40,4.02,.08),(5.19,3.35,4.40,.048)]
def fin(name,start,end,group):
 vv=[];ff=[];C=20
 for z,le,te,w in finsecs:
  for side in (1,-1):
   for j in range(C+1):
    t=start+(end-start)*j/C;shape=sin(pi*t)**.65
    vv.append((side*(w*shape+.006),le+(te-le)*t,z))
 R=2*(C+1)
 for k in range(len(finsecs)-1):
  for side in range(2):
   for j in range(C):a=k*R+side*(C+1)+j;ff.append((a,a+1,a+1+R,a+R))
  for j in [0,C]:a=k*R+j;b=a+C+1;ff.append((a,b,b+R,a+R))
 for k in [0,len(finsecs)-1]:ff.append(tuple(k*R+j for j in list(range(C+1))+list(reversed(range(C+1,R)))))
 o=mesh(name,vv,ff,'Paint',group);bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
fin('single swept fin',0,.72,'Fuselage');fin('rudder',.724,1,'Rudder');PIVOTS['Rudder']=(0,-.15+(.724*3.32),2.46)
ell_loft('fin cap electronic fairing',[(3.20,0,5.22,.018,.035),(3.35,0,5.22,.07,.12),(4.31,0,5.22,.07,.10),(4.47,0,5.22,.02,.035)],n=32)
for sg in (-1,1):
 tube('fin access line',[(sg*.16,.94,2.85),(sg*.105,1.69,3.43),(sg*.06,3.22,4.93)],.003,'Interior',sides=5)
 box('fin aerial', (sg*.061,3.13,4.65),(.10,.20,.025),r=.013)

# Intakes: full hollow lips, inner duct, dark recessed fan face. Oval with a flatter inner wall.
for sg,lr in [(1,'L'),(-1,'R')]:
 vv=[];ff=[];n=64
 rings=[(-5.21,.86,1.91,.39,.39),(-5.10,.87,1.91,.42,.40),(-4.72,.94,1.93,.44,.41),(-3.75,1.02,1.94,.41,.39),(-2.70,1.00,1.97,.30,.32)]
 for y,x,z,rx,rz in rings:
  for j in range(n):a=2*pi*j/n;vv.append((sg*(x+rx*cos(a)),y,z+rz*sin(a)))
 for k in range(len(rings)-1):
  for j in range(n):a=k*n+j;b=k*n+(j+1)%n;ff.append((a,b,b+n,a+n))
 o=mesh('intake outer fairing '+lr,vv,ff)
 # Closed rolled lip has a dark inner bevel, pale paint on its leading surface.
 pts=[(sg*(.86+.39*cos(j*2*pi/64)),-5.216,1.91+.39*sin(j*2*pi/64)) for j in range(65)]
 tube('rolled intake lip',pts,.026,'Paint',sides=10)
 ell_loft('intake dark duct',[(-5.195,sg*.86,1.91,.354,.352),(-4.96,sg*.90,1.92,.34,.34),(-4.18,sg*.94,1.94,.28,.28)],'Interior',n=64,caps=False)
 # lip-adjacent boundary-layer splitter plate
 box('intake splitter', (sg*.54,-4.99,1.98),(.06,.62,.63),r=.055)
 # Metallic outer nacelle panels / exhaust sleeves.
 ell_loft('rear engine casing',[(2.66,sg*.56,1.99,.52,.48),(3.54,sg*.56,1.99,.52,.48),(3.93,sg*.56,1.99,.49,.46)],'Metal',n=64)
 # hollow nozzle, petals taper into throat
 for j in range(24):
  a=j*2*pi/24;b=(j+1)*2*pi/24-.009
  vv=[]
  for y,r in [(3.88,.486),(4.15,.459),(4.47,.411),(4.47,.372),(3.94,.425)]:
   for ang in [a,(a+b)/2,b]:vv.append((sg*.56+r*cos(ang),y,1.99+r*sin(ang)))
  ff=[]
  for k in range(4):
   for q in range(2):u=k*3+q;ff.append((u,u+1,u+4,u+3))
  ff.extend([(0,3,6,9,12),(2,14,11,8,5)])
  bevel(mesh('overlapping nozzle petal',vv,ff,'Exhaust'),.003,2)
 torus('nozzle collar',(sg*.56,3.88,1.99),.48,.020,'Metal',axis='Y')
 ell_loft('exhaust dark throat',[(4.46,sg*.56,1.99,.37,.37),(3.60,sg*.56,1.99,.30,.30)],'Interior',n=64,caps=False)
 cyl('turbine centre',(sg*.56,3.55,1.99),(sg*.56,3.84,1.99),.11,'Exhaust',r2=.035)
 for j in range(20):
  a=j*2*pi/20;tube('turbine vane',[(sg*.56+.11*cos(a),3.57,1.99+.11*sin(a)),(sg*.56+.29*cos(a+.27),3.62,1.99+.29*sin(a+.27))],.022,'Exhaust',sides=5)
 empty('Nozzle_'+lr,(sg*.56,4.47,1.99))

# Cockpit tub, visible seat, instrument hood, controls and canopy seal.
box('cockpit tub',(0,-6.38,2.25),(.86,2.96,.35),'Interior',r=.16)
box('pilot seat cushion',(0,-6.03,2.51),(.53,.58,.17),'Interior',r=.07)
seat=box('ejection seat back',(0,-5.90,2.70),(.55,.18,.64),'Interior',r=.06);seat.rotation_euler[0]=-.16;apply(seat)
box('ejection head box',(0,-5.83,2.98),(.37,.23,.25),'Rubber',r=.055)
for sg in [-1,1]:
 tube('seat harness',[(sg*.16,-6.0,2.94),(sg*.16,-5.97,2.68),(sg*.23,-6.16,2.59)],.028,'Metal',sides=8)
 box('cockpit side console',(sg*.34,-6.42,2.53),(.14,1.52,.18),'Interior',r=.035)
 for k in range(9):sphere('console button',(sg*.34,-6.98+k*.12,2.635),(.018,.026,.009),'Metal',seg=8,rings=4)
box('instrument coaming',(0,-7.2,2.70),(.75,.46,.20),'Interior',r=.09)
for x in [-.21,0,.21]:
 panel=box('unmarked multifunction screen',(x,-6.99,2.72),(.16,.025,.12),'Glass',r=.012)
tube('control stick',[(0,-6.55,2.43),(0,-6.54,2.73),(0,-6.48,2.78)],.028,'Interior',sides=10)
box('HUD projector',(0,-7.2,2.85),(.15,.22,.08),'Metal',r=.015)
hud=box('HUD glass',(0,-7.18,2.99),(.26,.014,.22),'Glass',r=.01);hud.rotation_euler[0]=-.18;apply(hud)
empty('Seat_Pilot',(0,-6.08,2.64))
# canopy full arched shell split at front windscreen bow; all glass and bows in hinged group.
canopy=[(-8.27,.09,2.41,.035),(-7.91,.28,2.47,.22),(-7.40,.43,2.53,.49),(-6.91,.49,2.56,.61),(-6.32,.49,2.58,.63),(-5.75,.45,2.60,.54),(-5.16,.35,2.59,.36),(-4.69,.19,2.57,.13)]
# Dense interpolation makes the bubble silhouette smooth.
def caninterp(y):
 for k in range(len(canopy)-1):
  if y<=canopy[k+1][0]:break
 a=canopy[k];b=canopy[k+1];t=max(0,min(1,(y-a[0])/(b[0]-a[0])));vals=[]
 for j in [1,2,3]:
  p=canopy[max(0,k-1)][j];q=a[j];r=b[j];s=canopy[min(len(canopy)-1,k+2)][j]
  vals.append(.5*(2*q+(-p+r)*t+(2*p-5*q+4*r-s)*t*t+(-p+3*q-3*r+s)*t**3))
 return vals
vv=[];ff=[];C=48;K=70
for i in range(K+1):
 y=-8.27+3.58*i/K;w,z,h=caninterp(y)
 for j in range(C+1):a=pi*j/C;vv.append((w*cos(a),y,z+h*sin(a)))
for i in range(K):
 for j in range(C):a=i*(C+1)+j;ff.append((a,a+1,a+C+2,a+C+1))
mesh('bubble glazing',vv,ff,'Glass','Canopy')
for y in [-7.40,-5.03]:
 w,z,h=caninterp(y);pts=[(w*cos(pi*j/48),y,z+h*sin(pi*j/48)) for j in range(49)];tube('canopy structural bow',pts,.022,'Paint','Canopy',10)
for sg in [-1,1]:
 pts=[]
 for i in range(71):
  y=-8.27+3.58*i/70;w,z,h=caninterp(y);pts.append((sg*w,y,z))
 tube('canopy pressure seal',pts,.025,'Rubber','Canopy',10)
 tube('canopy lower rail',[(p[0],p[1],p[2]-.027) for p in pts],.024,'Paint','Canopy',10)
# Fixed curved cockpit sill closes the space between the shell and the pressure fuselage.
for sg in [-1,1]:
 vv=[];ff=[]
 for i in range(71):
  y=-8.27+3.58*i/70;w,z,h=caninterp(y);fw,fh,fz=station(y)
  low=fz+fh*math.sqrt(max(0,1-(w/fw)**2))
  vv.extend([(sg*(w+.007),y,z-.014),(sg*(w+.009),y,low-.012),(sg*(w-.015),y,low-.010),(sg*(w-.015),y,z-.014)])
 for k in range(70):
  for j in range(4):a=k*4+j;b=k*4+(j+1)%4;ff.append((a,b,b+4,a+4))
 ff.extend([(3,2,1,0),(280,281,282,283)])
 mesh('formed cockpit sill',vv,ff)
PIVOTS['Canopy']=(0,-4.69,2.59)
# Fixed starboard refuelling probe with bent rigid tube and nozzle.
probe=[(-.46,-7.75,2.48),(-.76,-8.10,2.70),(-.97,-8.48,2.99),(-1.02,-8.79,3.01),(-1.02,-9.0,2.98)]
tube('fixed refuelling probe',probe,.049,'Paint',sides=20)
cyl('probe receiver tip',(-1.02,-8.91,2.985),(-1.02,-9.18,2.97),.065,'Metal',n=24,r2=.038)
sphere('probe mount',(-.47,-7.75,2.48),(.12,.20,.10),'Metal')
# Small fittings, service hatches and cooling slots.
for sg in (-1,1):
 for y in [-3.9,-2.6,-.9,.7,2.1]:
  def skin(yy,a,offset=.002):
   w,h,z=station(yy);return (sg*(w+offset)*cos(a),yy,z+(h+offset)*sin(a))
  path=[]
  # Rounded rectangular seam built directly on the hull surface.
  for j in range(65):
   a=j*2*pi/64;dy=.28*math.copysign(abs(cos(a))**.28,cos(a));da=.23*math.copysign(abs(sin(a))**.28,sin(a))
   path.append(skin(y+dy,.47+da))
  tube('conformal service hatch seam',path,.0026,'Interior',sides=5)
  for dy in [-.21,.21]:
   for da in [-.17,.17]:sphere('flush hatch screw',skin(y+dy,.47+da,.004),(.007,.007,.007),'Metal',seg=8,rings=4)
  tube('recessed service latch',[skin(y-.045,.46,.004),skin(y+.045,.46,.004)],.007,'Interior',sides=6)
 for j in range(9):
  y=-4.55+j*.09;w,h,z=station(y);box('cooling louvre',(sg*.35,y,2.674),(.19,.028,.016),'Interior',r=.006)
 # dorsal chaff panel and aerials
wing_surface('dorsal blade aerial',1,[(0,-3.1,-2.65,2.70,.018),(.02,-2.91,-2.60,2.99,.01)])
box('ventral aerial',(0,-6.52,1.54),(.045,.24,.22),'Paint',r=.017)
empty('Center_Of_Mass',(0,-.75,2.0));empty('Light_Beacon',(0,-2.24,2.74));sphere('beacon lens',(0,-2.24,2.73),(.07,.09,.035),'Lamps',seg=20,rings=10)
empty('Hardpoint_Centerline',(0,-1.35,1.43))

# Detailed articulated undercarriage: complete down assemblies with open doors, torque links and hydraulic hoses.
def gear(lr,x,y,r,nose=False):
 group='Gear_'+lr;z=r;PIVOTS[group]=(x,y,1.59)
 if nose:
  # Twin nose tyres sharing an axle.
  wheelxs=[x-.125,x+.125];width=.075
 else:wheelxs=[x];width=.15
 for wx in wheelxs:
  # rounded tyre shoulders, recessed metal hubs and visible circumferential tread grooves
  torus('tyre',(wx,y,z),r*.76,r*.24,'Rubber',group,n=64,m=16)
  cyl('wheel hub',(wx-width*.99,y,z),(wx+width*.99,y,z),r*.51,'Metal',group,n=40)
  for side in (-1,1):
   torus('hub rim',(wx+side*width,y,z),r*.45,.017,'Metal',group,n=40,m=8)
   cyl('axle cap',(wx+side*width,y,z),(wx+side*(width+.018),y,z),r*.18,'Exhaust',group,n=24)
   for j in range(8):
    a=j*pi/4;sphere('hub bolt',(wx+side*(width+.014),y+r*.32*cos(a),z+r*.32*sin(a)),(.008,.012,.012),'Metal',group,seg=8,rings=4)
  for offset in [-.065,.065] if not nose else [-.03,.03]:
   rr=r*.76+math.sqrt((r*.24)**2-offset**2)
   torus('tread groove',(wx+offset,y,z),rr,.002,'Interior',group,n=64,m=5)
 cyl('wheel axle',(x-.22,y,z),(x+.22,y,z),.066,'Metal',group)
 forkx=x-.20 if x>=0 else x+.20
 cyl('lower oleo',(forkx,y,z),(forkx,y-.08,1.10),.060,'Metal',group)
 cyl('upper oleo',(forkx,y-.08,.91),(x,y+.06,1.67),.085,'Paint',group)
 cyl('polished piston',(forkx,y-.06,.65),(forkx,y-.08,1.01),.046,'Metal',group)
 cyl('drag brace',(x,y+.59,1.54),(forkx,y,.69),.038,'Metal',group)
 tube('torque scissor',[(forkx,y-.11,1.0),(forkx,y-.30,.78),(forkx,y-.08,.60)],.029,'Metal',group,10)
 tube('flex hydraulic hose',[(x+.09,y+.04,1.55),(forkx+.09,y-.17,1.05),(forkx+.10,y-.21,.67),(x,y-.11,z)],.012,'Rubber',group,8)
 for zz in [.70,1.03,1.37]:cyl('oleo collar',(forkx,y-.07,zz-.025),(forkx,y-.07,zz+.025),.084,'Metal',group)
 if nose:
  for sg in [-1,1]:
   door=box('nose gear bay door',(sg*.31,y+.12,1.26),(.065,1.07,.47),'Paint',group,r=.025)
  box('nose wheel well',(0,y+.16,1.57),(.50,1.12,.05),'Interior',r=.04)
  sphere('taxi landing lamp',(forkx,y-.11,1.15),(.065,.04,.055),'Lamps',group,seg=20,rings=12)
 else:
  sg=1 if x>0 else -1
  door=box('main gear outer door',(x+sg*.30,y+.02,.98),(.055,.62,.64),'Paint',group,r=.035);door.rotation_euler[1]=sg*.16;apply(door)
  for dz in [-.22,.20]:box('door inner stiffener',(x+sg*.25,y+.02,.98+dz),(.032,.58,.025),'Metal',group,r=.007)
  box('main gear bay',(x*.78,y+.15,1.51),(.62,1.05,.045),'Interior',r=.05)
gear('Nose',0,-5.45,.265,True);gear('L',1.12,0,.43);gear('R',-1.12,0,.43)

# Match the reference's shorter engine tail and fin extending behind the exhausts.
# All aft fixed geometry, sockets and moving wing pieces share the same station remap.
fin_names=('single swept fin','rudder','fin cap electronic fairing','fin access line','fin aerial')
for group,parts in GROUPS.items():
 for o in parts:
  if any(o.name.startswith(n) for n in fin_names):continue
  mw=o.matrix_world.copy();inv=mw.inverted()
  for v in o.data.vertices:
   p=mw@v.co
   if p.y>0:p.y*=.78
   v.co=inv@p
for table in (PIVOTS,SOCKETS):
 for name,p in list(table.items()):
  if name!='Rudder' and p[1]>0:table[name]=(p[0],p[1]*.78,p[2])

# Finish objects, consistent tiled UVs, join rigid groups and set exact hinge origins.
objects=[]
for name,parts in GROUPS.items():
 for o in parts:
  apply(o);uv_project(o,1.55 if any(m.name=='Paint' for m in o.data.materials) else .6)
 active(parts[0])
 for o in parts:o.select_set(True)
 bpy.ops.object.join();o=bpy.context.object;o.name=name
 # Recalculate all closed shell winding and remove zero-area construction remnants.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces)
 bmesh.ops.triangulate(bm,faces=list(bm.faces))
 bad=[f for f in bm.faces if f.calc_area()<1e-10]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free()
 sc.cursor.location=PIVOTS.get(name,(0,0,0));bpy.ops.object.origin_set(type='ORIGIN_CURSOR');objects.append(o)
for name,p in SOCKETS.items():
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=p;o.empty_display_size=.12;objects.append(o)
# Source uses Z-up; glTF export conversion maps (x,y,z) -> (x,z,-y).
bpy.context.view_layer.update()
def game(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
pts=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
tri=sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in objects if o.type=='MESH')
paints=[{'name':'Dark grey','linear_rgb':[.18,.20,.22]},{'name':'Air-superiority light grey','linear_rgb':[.44,.49,.54]},{'name':'Navy blue-grey','linear_rgb':[.105,.16,.215]},{'name':'Desert sand','linear_rgb':[.45,.34,.21]},{'name':'Olive','linear_rgb':[.16,.19,.105]},{'name':'Black','linear_rgb':[.028,.034,.041]}]
metadata={'units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]],'dimensions':[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]]},'triangles':tri,'nodes':[{'name':o.name,'type':o.type,'world_position':game(o.matrix_world.translation)} for o in objects],'paints':paints,'hinges':{k:game(v) for k,v in PIVOTS.items()},'paint_contract':'Paint baseColorTexture neutral; baseColorFactor is linear RGB. No national markings or weapons.'}
metadata['hinge_axes']={'Canopy':[1,0,0],'Rudder':[0,0.908,-0.419],'Canard_L':[1,0,0],'Canard_R':[1,0,0],'Elevon_L':[.963,.010,-.269],'Elevon_R':[-.963,.010,-.269],'Elevon_Inner_L':[.963,.01,-.269],'Elevon_Inner_R':[-.963,.01,-.269],'Gear_Nose':[1,0,0],'Gear_L':[0,0,1],'Gear_R':[0,0,1]}
with open(os.path.join(ROOT,'nodes.json'),'w') as f:json.dump(metadata,f,indent=2)
# Export uses direct image graph; write the tint into the serialized baseColorFactor for exact interoperability.
paint.node_tree.links.new(ptex.outputs[0],pbs.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in objects:o.select_set(True)
glb=os.path.join(ROOT,'rafale-fighter.glb')
bpy.ops.export_scene.gltf(filepath=glb,export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_image_format='AUTO')
raw=open(glb,'rb').read();jsonlen,jtype=struct.unpack_from('<II',raw,12);doc=json.loads(raw[20:20+jsonlen]);binary=raw[20+jsonlen:]
for m in doc.get('materials',[]):
 if m['name']=='Paint':m.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=[.18,.20,.22,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
open(glb,'wb').write(struct.pack('<4sII',b'glTF',2,12+8+len(js)+len(binary))+struct.pack('<II',len(js),0x4e4f534a)+js+binary)
paint.node_tree.links.new(mult.outputs[0],pbs.inputs['Base Color'])
print('ASSET_EXPORTED',tri,os.path.getsize(glb),metadata['bounding_box'],flush=True)
with open(os.path.join(ROOT,'NOTES.md'),'w') as f:
 f.write('Rafale-style twin-engine delta fighter, gear down, articulated surfaces and cockpit; metres, +Z nose, +Y up.\n')
 f.write(f'{tri:,} triangles; GLB {os.path.getsize(glb)/1048576:.2f} MiB; separate rigid assemblies and game sockets.\n')
 f.write('7 materials: Paint, Metal, Exhaust, Rubber, Interior, Glass (BLEND), Lamps; matte neutral-detail recolourable paint.\n')
 f.write('Cycles GPU baked shared tiles: Paint colour/normal 1024, ORM 512; other maps 512 or smaller.\n')
 f.write('Poly Haven CC0: metal_plate, rubberized_track; https://polyhaven.com/license ; downloaded diff, nor_gl, arm maps.\n')
 f.write('Reference differences: no insignia/text by contract; simplified cockpit instruments, intake internals and maintenance panel layout.\n')
# Studio daylight. Lighting and floor exist only after asset export.
world=bpy.data.worlds.new('soft daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.68,.75,.85,1);world.node_tree.nodes['Background'].inputs[1].default_value=.55
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.009));floor=bpy.context.object;floor.name='render ground';fm=bpy.data.materials.new('render ground');fm.diffuse_color=(.25,.28,.31,1);floor.data.materials.append(fm)
def light(name,loc,power,size,color):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;d.color=color;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,-2,1.5))-o.location).to_track_quat('-Z','Y').to_euler()
light('broad sky key',(5,-9,14),2100,9,(1,.95,.88));light('cool fill',(-9,-3,8),1350,10,(.75,.85,1));light('tail rim',(3,8,11),2200,8,(.88,.94,1))
ld=bpy.data.lights.new('sun','SUN');ld.energy=1.3;ld.angle=.20;ob=bpy.data.objects.new('sun',ld);sc.collection.objects.link(ob);ob.rotation_euler=(.35,-.5,-.4)
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam
cam.data.type='ORTHO';cam.data.ortho_scale=18.8
hero=(15,-23,12);target=(0,-2.55,2.0)
def camera(loc,targ,scale):cam.location=loc;cam.rotation_euler=(Vector(targ)-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale
camera(hero,target,18.8)
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=32
sc.render.filepath=os.path.join(ROOT,'final.png');bpy.ops.render.render(write_still=True)
camera((22,-3.1,2.72),(0,-3.1,2.62),17.2);sc.render.filepath=os.path.join(ROOT,'side.png');bpy.ops.render.render(write_still=True)
camera(hero,target,18.8)
clay=bpy.data.materials.new('render clay');clay.use_nodes=True;cb=clay.node_tree.nodes.get('Principled BSDF');cb.inputs['Base Color'].default_value=(.38,.39,.40,1);cb.inputs['Roughness'].default_value=.68
saved={}
for o in objects:
 if o.type=='MESH':saved[o.name]=(list(o.data.materials),[p.material_index for p in o.data.polygons]);o.data.materials.clear();o.data.materials.append(clay)
sc.render.filepath=os.path.join(ROOT,'clay.png');bpy.ops.render.render(write_still=True)
for o in objects:
 if o.type=='MESH':
  o.data.materials.clear()
  for m in saved[o.name][0]:o.data.materials.append(m)
  for p,mi in zip(o.data.polygons,saved[o.name][1]):p.material_index=mi
# Material slot indices survive assignment above.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
pixels=[]
for i,p in enumerate(paints):
 mult.inputs[2].default_value=tuple(p['linear_rgb'])+(1,);sc.render.filepath=os.path.join(CACHE,f'paint_{i}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(sc.render.filepath,check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);pixels.append(arr);bpy.data.images.remove(im)
# Blender pixels run bottom up: first row is placed at the top.
grid=np.zeros((720,1920,4),dtype=np.float32)
for i,a in enumerate(pixels):row=1-i//3;col=i%3;grid[row*360:(row+1)*360,col*640:(col+1)*640]=a
im=bpy.data.images.new('paint comparison',1920,720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
mult.inputs[2].default_value=(.18,.20,.22,1)
print('BUILD_COMPLETE',flush=True)
