"""E-3 style AWACS. Clean headless Blender 5.2 build, CC0 tile bakes and glTF.
Run: blender -b --python-exit-code 1 --python build_e3-awacs.py
Authoring axes: X left, -Y nose, Z up; glTF converts to X left/Y up/Z nose.
"""
import bpy, bmesh, math, os, sys, json, urllib.request, struct, random
from mathutils import Vector
from math import sin, cos, pi, sqrt
import numpy as np
ROOT=os.path.dirname(os.path.abspath(__file__))
TEX=os.path.join(ROOT,'textures');os.makedirs(TEX,exist_ok=True)
random.seed(33)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES'
sc.cycles.samples=24;sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
sc.cycles.device='GPU';sc.render.bake.margin=8
sc.world=bpy.data.worlds.new('Daylight');sc.world.use_nodes=True
sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.74,.86,1)
sc.world.node_tree.nodes['Background'].inputs[1].default_value=.55
sc.view_settings.view_transform='AgX';sc.view_settings.look='AgX - Medium High Contrast';sc.view_settings.exposure=.35
GROUPS={};MATS={};PIVOTS={};HINGES={}
PAINTS=[('Air-superiority light grey',[.55,.57,.58]),('Dark grey',[.19,.215,.235]),('Navy blue-grey',[.105,.17,.235]),('Desert sand',[.52,.395,.245]),('Olive',[.205,.245,.125]),('Black',[.035,.042,.048])]

def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def node(nt,typ):return nt.nodes.new(typ)
def link(nt,a,b):nt.links.new(a,b)
def val(nt,op,a,b):
 n=node(nt,'ShaderNodeMath');n.operation=op
 for i,v in enumerate((a,b)):
  if isinstance(v,(int,float)):n.inputs[i].default_value=v
  else:link(nt,v,n.inputs[i])
 return n.outputs[0]
def ramp(nt,v,low,high):return val(nt,'ADD',val(nt,'MULTIPLY',v,high-low),low)

def fetch(id,map):
 path=os.path.join(TEX,id+'_'+map+'_1k.jpg')
 if not os.path.exists(path):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
  print('DOWNLOAD',url,flush=True);urllib.request.urlretrieve(url,path)
 return bpy.data.images.load(path,check_existing=True)

# Bake reusable surface tiles. A 3m Paint tile preserves density on the 46m hull.
def make_material(name,color,rough,metal,res=512,source='metal_plate',panel=False):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
 out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');link(nt,bs.outputs[0],out.inputs['Surface'])
 uv=node(nt,'ShaderNodeTexCoord')
 diff=node(nt,'ShaderNodeTexImage');diff.image=fetch(source,'diff');link(nt,uv.outputs['UV'],diff.inputs['Vector'])
 bw=node(nt,'ShaderNodeRGBToBW');link(nt,diff.outputs['Color'],bw.inputs[0])
 noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=8;noise.inputs['Detail'].default_value=3;link(nt,uv.outputs['UV'],noise.inputs['Vector'])
 detail=val(nt,'MULTIPLY',ramp(nt,bw.outputs[0],.58,1),ramp(nt,noise.outputs['Fac'],.88,1))
 seam=None
 if panel:
  sep=node(nt,'ShaderNodeSeparateXYZ');link(nt,uv.outputs['UV'],sep.inputs[0])
  u,v=sep.outputs[0],sep.outputs[1]
  seam=val(nt,'MAXIMUM',val(nt,'LESS_THAN',u,.0025),val(nt,'LESS_THAN',v,.0025))
  # Narrow rivet bands; dots at 18 fasteners per three-metre panel.
  fu=val(nt,'FRACT',val(nt,'MULTIPLY',u,18),0)
  fv=val(nt,'FRACT',val(nt,'MULTIPLY',v,18),0)
  dots=val(nt,'MAXIMUM',val(nt,'MULTIPLY',val(nt,'LESS_THAN',u,.012),val(nt,'LESS_THAN',fv,.035)),val(nt,'MULTIPLY',val(nt,'LESS_THAN',v,.012),val(nt,'LESS_THAN',fu,.035)))
  detail=val(nt,'MULTIPLY',detail,ramp(nt,val(nt,'MAXIMUM',seam,dots),1,.47))
 # Neutral photographic surface, with broad directional rain variation.
 mapping=node(nt,'ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(28,2,1);link(nt,uv.outputs['UV'],mapping.inputs[0])
 streak=node(nt,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=3;link(nt,mapping.outputs[0],streak.inputs['Vector'])
 detail=val(nt,'MULTIPLY',detail,ramp(nt,streak.outputs['Fac'],.82,1))
 if panel:
  # Fine streaks wash away from panel edges, baked into the neutral tile.
  grime=val(nt,'MULTIPLY',val(nt,'POWER',val(nt,'SUBTRACT',1,u),9),val(nt,'POWER',streak.outputs['Fac'],2))
  detail=val(nt,'MULTIPLY',detail,ramp(nt,grime,1,.28))
 mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[1].default_value=(*color,1);link(nt,detail,mix.inputs[2])
 if name=='Lamps':
  # Three baked trim swatches: port red, landing white, starboard green.
  uvsep=node(nt,'ShaderNodeSeparateXYZ');link(nt,uv.outputs['UV'],uvsep.inputs[0])
  c1=node(nt,'ShaderNodeMixRGB');c1.inputs[1].default_value=(1,1,.92,1);c1.inputs[2].default_value=(.75,.006,.003,1);link(nt,val(nt,'LESS_THAN',uvsep.outputs[0],.333),c1.inputs[0])
  mix=node(nt,'ShaderNodeMixRGB');link(nt,c1.outputs[0],mix.inputs[1]);mix.inputs[2].default_value=(.004,.6,.018,1);link(nt,val(nt,'GREATER_THAN',uvsep.outputs[0],.667),mix.inputs[0])
 link(nt,mix.outputs[0],bs.inputs['Base Color']);bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 nor=node(nt,'ShaderNodeTexImage');nor.image=fetch(source,'nor_gl');nor.image.colorspace_settings.name='Non-Color';link(nt,uv.outputs['UV'],nor.inputs[0])
 nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.004 if name in ['Glass','Lamps'] else (.10 if panel else .18);link(nt,nor.outputs['Color'],nm.inputs['Color'])
 bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=0 if name in ['Glass','Lamps'] else .15;bump.inputs['Distance'].default_value=.006
 link(nt,detail,bump.inputs['Height']);link(nt,nm.outputs['Normal'],bump.inputs['Normal']);link(nt,bump.outputs[0],bs.inputs['Normal'])
 arm=node(nt,'ShaderNodeTexImage');arm.image=fetch(source,'arm');arm.image.colorspace_settings.name='Non-Color';link(nt,uv.outputs['UV'],arm.inputs[0])
 sep_arm=node(nt,'ShaderNodeSeparateColor');link(nt,arm.outputs[0],sep_arm.inputs[0])
 roughsock=ramp(nt,sep_arm.outputs[1],max(.08,rough-.08),min(1,rough+.08))
 link(nt,roughsock,bs.inputs['Roughness'])
 bpy.ops.mesh.primitive_plane_add(size=2);o=bpy.context.object;o.name='BakeTile';o.data.materials.append(m)
 target=node(nt,'ShaderNodeTexImage');nt.nodes.active=target
 baked={}
 emit=node(nt,'ShaderNodeEmission')
 for passname,size in [('Base',res),('Normal',res),('ORM',min(res,512))]:
  im=bpy.data.images.new(name+'_'+passname,size,size,alpha=False)
  if passname!='Base':im.colorspace_settings.name='Non-Color'
  target.image=im;nt.nodes.active=target
  if passname=='Normal':
   link(nt,bs.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
  else:
   if passname=='Base':link(nt,mix.outputs[0],emit.inputs[0])
   else:
    pack=node(nt,'ShaderNodeCombineColor');pack.inputs[0].default_value=1;link(nt,roughsock,pack.inputs[1]);pack.inputs[2].default_value=metal;link(nt,pack.outputs[0],emit.inputs[0])
   link(nt,emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=os.path.join(TEX,name+'_'+passname+'.png');im.file_format='PNG';im.save();baked[passname]=im
 bpy.data.objects.remove(o,do_unlink=True)
 nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');link(nt,bs.outputs[0],out.inputs['Surface'])
 bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 texs={}
 for key,im in baked.items():
  t=node(nt,'ShaderNodeTexImage');t.image=im;t.label=key;texs[key]=t
 if name=='Paint':
  mult=node(nt,'ShaderNodeMixRGB');mult.name='PaintFactor';mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;link(nt,texs['Base'].outputs[0],mult.inputs[1]);mult.inputs[2].default_value=(*PAINTS[0][1],1);link(nt,mult.outputs[0],bs.inputs['Base Color'])
 else:link(nt,texs['Base'].outputs[0],bs.inputs['Base Color'])
 nn=node(nt,'ShaderNodeNormalMap');link(nt,texs['Normal'].outputs[0],nn.inputs['Color']);link(nt,nn.outputs[0],bs.inputs['Normal'])
 sep=node(nt,'ShaderNodeSeparateColor');link(nt,texs['ORM'].outputs[0],sep.inputs[0]);link(nt,sep.outputs[1],bs.inputs['Roughness']);link(nt,sep.outputs[2],bs.inputs['Metallic'])
 # glTF's occlusion socket convention.
 group=bpy.data.node_groups.get('glTF Material Output')
 if not group:
  group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=node(nt,'ShaderNodeGroup');gn.node_tree=group;link(nt,sep.outputs[0],gn.inputs['Occlusion'])
 if name=='Glass':
  bs.inputs['Alpha'].default_value=.82;bs.inputs['Transmission Weight'].default_value=0;bs.inputs['IOR'].default_value=1.46;m.surface_render_method='BLENDED'
 if name=='Lamps':
  link(nt,texs['Base'].outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=2
 MATS[name]=m;print('BAKED',name,flush=True)
 return m

# Geometry builders. Each rigid/moving assembly is merged once at the end.
def register(o,name,mat='Paint',group='Fuselage',smooth=True):
 o.name=name;o.data.materials.append(MATS[mat]);GROUPS.setdefault(group,[]).append(o)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 return o

def mesh(name,verts,faces,mat='Paint',group='Fuselage',uv=None,smooth=True):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 ob=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(ob)
 layer=me.uv_layers.new(name='UVMap')
 for p in me.polygons:
  for li in p.loop_indices:
   vi=me.loops[li].vertex_index;v=me.vertices[vi].co
   if uv:co=uv[vi]
   else:
    n=p.normal;axis=max(range(3),key=lambda a:abs(n[a]));ids=[a for a in range(3) if a!=axis];co=(v[ids[0]]/3,v[ids[1]]/3)
   layer.data[li].uv=co
 return register(ob,name,mat,group,smooth)

def bevel(o,w=.03,seg=3):
 active(o);m=o.modifiers.new('Machined soft edges','BEVEL');m.width=w;m.segments=seg;bpy.ops.object.modifier_apply(modifier=m.name);return o

def box(name,loc,dim,mat='Paint',group='Fuselage',bev=.03):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.dimensions=dim;active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);register(o,name,mat,group,False)
 if bev:bevel(o,bev,3)
 return o

def sphere(name,loc,scale,mat='Paint',group='Fuselage',seg=48,rings=24):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,radius=1,location=loc);o=bpy.context.object;o.scale=scale;active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if mat=='Lamps':
  u=.5 if 'Landing' in name else (.83 if 'Wingtip' in name and loc[0]<0 else .16)
  for loop in o.data.uv_layers.active.data:loop.uv=(u,.5)
 return register(o,name,mat,group)

def rod(name,a,b,r,mat='Metal',group='Fuselage',n=20,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.rotation_euler=d.to_track_quat('Z','Y').to_euler();register(o,name,mat,group);return bevel(o,min(r*.18,.015),2)

def tube(name,points,r=.012,mat='Interior',group='Fuselage',closed=False):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.bevel_depth=r;cu.bevel_resolution=1 if r>=.023 else 0;cu.resolution_u=1
 points=list(points)
 if closed and (Vector(points[0])-Vector(points[-1])).length<.000001:points=points[:-1]
 sp=cu.splines.new('POLY');sp.points.add(len(points)-1)
 for p,co in zip(sp.points,points):p.co=(*co,1)
 sp.use_cyclic_u=closed;o=bpy.data.objects.new(name,cu);bpy.context.collection.objects.link(o);active(o);bpy.ops.object.convert(target='MESH');return register(bpy.context.object,name,mat,group)

def lathe_y(name,profile,x=0,z=0,mat='Metal',group='Fuselage',n=96):
 verts=[];uv=[]
 for y,r in profile:
  for j in range(n+1):
   a=2*pi*j/n;verts.append((x+r*cos(a),y,z+r*sin(a)));uv.append((j/n*2*pi*max(r,.2)/3,y/3))
 faces=[]
 for k in range(len(profile)-1):
  for j in range(n):
   a=k*(n+1)+j;faces.append((a,a+1,a+n+2,a+n+1))
 return mesh(name,verts,faces,mat,group,uv)

ST=[(-27,.10,3.13),(-26.98,.19,3.13),(-26.90,.32,3.14),(-26.8,.43,3.15),(-26.1,.88,3.20),(-25.2,1.2,3.36),(-24.3,1.5,3.52),(-23.1,1.74,3.62),(-21,1.87,3.65),(-17,1.88,3.65),(6,1.88,3.65),(9,1.8,3.7),(11.5,1.5,3.85),(14,.99,4.05),(17,.46,4.3),(19.6,.025,4.55)]
def profile(y):
 # Monotone Hermite interpolation removes the faceted station shoulders.
 for k,(a,b) in enumerate(zip(ST,ST[1:])):
  if a[0]<=y<=b[0]:
   h=b[0]-a[0];t=(y-a[0])/h;values=[]
   for c in [1,2]:
    d=(b[c]-a[c])/h
    dp=(a[c]-ST[k-1][c])/(a[0]-ST[k-1][0]) if k else d
    dn=(ST[k+2][c]-b[c])/(ST[k+2][0]-b[0]) if k+2<len(ST) else d
    m0=2*d*dp/(d+dp) if d*dp>0 else 0
    m1=2*d*dn/(d+dn) if d*dn>0 else 0
    values.append((2*t**3-3*t*t+1)*a[c]+(t**3-2*t*t+t)*h*m0+(-2*t**3+3*t*t)*b[c]+(t**3-t*t)*h*m1)
   return values
 return ST[-1][1:]
def skin(y,a,offset=0):
 r,z=profile(y);return ((r+offset)*sin(a),y,z+(r+offset)*cos(a))

def fuselage():
 ys=sorted(set([round(-27+i*46.6/200,6) for i in range(201)]+[s[0] for s in ST]+[-25.2,-24.4,-24.3,-23.98,-23.9,-23.58,-23.5,-23.4,-22.55]))
 angles=sorted(set([i*2*pi/128 for i in range(129)]+[.24,.41,.48,1.12,.70,1.17,2*pi-.24,2*pi-.41,2*pi-.48,2*pi-1.12,2*pi-.70,2*pi-1.17]))
 verts=[];uv=[]
 for y in ys:
  for a in angles:verts.append(skin(y,a));uv.append(((y+27)/3,a*1.88/3))
 n=len(angles);faces=[];glassfaces=[]
 for k in range(len(ys)-1):
  y=(ys[k]+ys[k+1])/2
  for j in range(n-1):
   a=(angles[j]+angles[j+1])/2;aa=min(a,2*pi-a)
   win=(-25.2<y<-24.4 and .48<aa<1.12) or ((-24.3<y<-23.5 or -23.4<y<-22.55) and .70<aa<1.17) or ((-24.3<y<-23.98 or -23.9<y<-23.58) and .24<aa<.41)
   ids=(k*n+j,k*n+j+1,(k+1)*n+j+1,(k+1)*n+j)
   (glassfaces if win else faces).append(ids)
 faces.append(tuple(range(n-1)));faces.append(tuple((len(ys)-1)*n+i for i in range(n-1)))
 mesh('Monocoque fuselage',verts,faces)
 mesh('Cockpit glazing',verts,glassfaces,'Glass')
 for s in [-1,1]:
  for a,b,lo,hi in [(-25.2,-24.4,.48,1.12),(-24.3,-23.5,.70,1.17),(-23.4,-22.55,.70,1.17),(-24.3,-23.98,.24,.41),(-23.9,-23.58,.24,.41)]:
   pts=[skin(y,s*t,.02) for y,t in [(a,lo),(a,hi),(b,hi),(b,lo)]]
   tube('Window structural frame',pts,.015 if hi<.45 else .027,'Paint',closed=True)
  # Windscreen wiper arm, connected to its mounting boss.
  rod('Wiper',skin(-24.45,s*1.06,.05),skin(-25.0,s*.63,.05),.016,'Interior')
 # Actual interior visible through transparent cockpit panes.
 box('Cockpit floor',(0,-23.25,3.4),(2.45,3.6,.12),'Interior')
 box('Instrument coaming',(0,-24.3,4.05),(1.95,.47,.27),'Interior',bev=.1)
 for s in [-1,1]:
  box('Pilot seat cushion',(s*.52,-22.95,3.75),(.6,.67,.22),'Interior',bev=.10)
  b=box('Pilot seat back',(s*.52,-22.65,4.15),(.61,.18,.86),'Interior',bev=.10);b.rotation_euler.x=-.12
  rod('Control column',(s*.5,-23.55,3.5),(s*.5,-23.7,4.15),.035,'Interior')
  tube('Control yoke',[(s*.5-.19,-23.7,4.27),(s*.5-.16,-23.7,4.1),(s*.5+.16,-23.7,4.1),(s*.5+.19,-23.7,4.27)],.025,'Interior')
  for j in range(4):box('Instrument bezel',(s*.48+(j%2)*.18-.09,-24.05,4.18+(j//2)*.15),(.13,.03,.10),'Glass',bev=.015)
 # Under-nose avionics blister joins the lower nose skin.
 sphere('Nose chin fairing',(0,-24.25,2.14),(.61,1.12,.30),'Paint',seg=48,rings=20)
 # Radome nose demarcation and longitudinal construction details.
 for y in [-25.7,-21.1,-17.1,-12,-7,5.8,9,13.8,17.5]:
  tube('Fuselage lap seam',[skin(y,a,.007) for a in np.linspace(0,2*pi,129)],.006,'Seals',closed=True)
 # Cabin access doors, rounded outline, latch and small window.
 for s in [-1,1]:
  for y in [-20.8,9.6]:
   aa1,aa2=.78,1.78;w=.48;pts=[]
   # rounded rectangle in unrolled fuselage coordinates
   for cy,ca,base in [(y-w+.08,aa1+.06,pi),(y+w-.08,aa1+.06,1.5*pi),(y+w-.08,aa2-.06,0),(y-w+.08,aa2-.06,.5*pi)]:
    for th in np.linspace(base,base+pi/2,7):pts.append(skin(cy+.08*cos(th),s*(ca+.06*sin(th)),.022))
   tube('Door perimeter',pts,.014,'Metal',closed=True)
   # upper inset and silver grab handle
   sphere('Door viewing port',skin(y,s*.99,.028),(.025,.105,.145),'Glass',seg=24,rings=12)
   p=skin(y+.24,s*1.39,.045);rod('Door handle',(p[0],p[1]-.09,p[2]),(p[0],p[1]+.09,p[2]),.021,'Metal')
   for dy in [-.40,.40]:sphere('Door hinge',skin(y+dy,s*1.16,.025),(.055,.05,.08),'Metal',seg=16,rings=8)
  # Forward cheek electronics fairing with slotted cooling outlet.
  sphere('Forward avionics cheek',(s*1.80,-17.6,3.7),(.29,2.2,.40),'Paint',seg=48,rings=16)
  for j in range(11):
   box('Cheek cooling louver',(s*2.065,-18.8+j*.20,3.7),(.035,.068,.18),'Seals',bev=.02)
  for y in [-11.8,-6.9]:
   p=skin(y,s*1.14,.015);box('Emergency hatch trim',p,(.035,.48,.77),'Metal',bev=.05)
   box('Emergency hatch panel',(p[0]+s*.023,p[1],p[2]),(.035,.40,.69),'Paint',bev=.06)
  # belly fairing around wing box
  sphere('Wing root body fairing',(s*1.4,-.8,2.4),(.94,6.2,.70),'Paint',seg=64,rings=24)
 # Dorsal and ventral blade antennae; each grounded in the skin.
 for y,h in [(-21,.35),(-13.3,.28),(-10,.22),(-7,.21),(10,.28)]:
  r,z=profile(y);verts=[(-.045,y-.24,z+r-.03),(.045,y-.24,z+r-.03),(-.035,y+.18,z+r-.03),(.035,y+.18,z+r-.03),(-.015,y+.22,z+r+h),(.015,y+.22,z+r+h)]
  bevel(mesh('Dorsal blade antenna',verts,[(0,1,3,2),(0,2,4),(1,5,3),(0,4,5,1),(2,3,5,4)]),.015,2)
 for y in [-18,-14,-9,6]:
  r,z=profile(y);rod('Belly antenna',(0,y,z-r+.05),(0,y+.15,z-r-.32),.035,'Paint',r2=.013)

WING=[(0,-8.1,4.4,2.95),(3,-6.8,4.5,3.03),(7,-4.7,4.9,3.26),(13.7,-.45,6.45,3.69),(21.3,5.95,8.45,4.15),(22.2,7.15,8.75,4.24)]
TAIL=[(0,10.5,18.1,4.7),(2,11.7,18.35,4.92),(8,16.5,19.15,5.55),(8.4,17.5,19.2,5.62)]
FIN=[(4.6,9.8,18.9,0),(6.1,12.5,19.1,0),(9.8,15.4,19.35,0),(12.6,17,19.58,0)]
def interp(st,x):
 for a,b in zip(st,st[1:]):
  if a[0]<=x<=b[0]:
   t=(x-a[0])/(b[0]-a[0]);return [a[i]*(1-t)+b[i]*t for i in range(1,4)]
 return list(st[-1][1:])
def foilpoint(st,x,t,upper=True,sgn=1,vertical=False):
 le,te,z=interp(st,x);ch=te-le
 thick=(.105 if st==WING else .085)*ch*(.2969*sqrt(max(t,0))-.126*t-.3516*t*t+.2843*t**3-.1036*t**4)*5
 thick=max(.012,thick)
 camber=.012*ch*sin(pi*t)
 h=camber+(thick if upper else -thick)
 return (h,le+ch*t,x) if vertical else (sgn*x,le+ch*t,z+h)
def hingepoint(st,x,t,sgn=1,vertical=False):
 return tuple((Vector(foilpoint(st,x,t,True,sgn,vertical))+Vector(foilpoint(st,x,t,False,sgn,vertical)))*.5)
def foil(name,st,x0,x1,t0,t1,sgn=1,group='Fuselage',vertical=False,nx=22,nt=24):
 xs=sorted(set(np.linspace(x0,x1,nx+1).tolist()+[s[0] for s in st if x0<s[0]<x1]));ts=[t0+(t1-t0)*(.5-.5*cos(pi*j/nt)) for j in range(nt+1)]
 vs=[];uv=[];faces=[];n=len(ts);N=len(xs)*n
 for up in [True,False]:
  for x in xs:
   for t in ts:
    v=foilpoint(st,x,t,up,sgn,vertical);vs.append(v);uv.append((v[1]/3,(v[2] if vertical else v[0])/3))
 for side in [0,N]:
  for i in range(len(xs)-1):
   for j in range(n-1):a=side+i*n+j;faces.append((a,a+1,a+n+1,a+n))
 for i in range(len(xs)-1):
  for j in [0,n-1]:a=i*n+j;faces.append((a,a+n,a+n+N,a+N))
 for i in [0,len(xs)-1]:
  for j in range(n-1):a=i*n+j;faces.append((a,a+1,a+1+N,a+N))
 return mesh(name,vs,faces,'Paint',group,uv)

def wings():
 for s,side in [(1,'L'),(-1,'R')]:
  lead=foil('Wing leading structure',WING,0,22.2,0,.735,s);lead.data.materials.append(MATS['Metal'])
  for face in lead.data.polygons:
   c=sum((lead.data.vertices[i].co for i in face.vertices),Vector())/len(face.vertices);le,te,z=interp(WING,abs(c.x))
   if abs(c.x)>2 and (c.y-le)/(te-le)<.028:face.material_index=1
  for a,b,group in [(0,3,'Fuselage'),(3.03,13.65,'Flap_'+side),(13.65,14.2,'Fuselage'),(14.23,21.27,'Aileron_'+side),(21.3,22.2,'Fuselage')]:
   foil(group,WING,a,b,.74,1,s,group,nx=20,nt=12)
   if group!='Fuselage':
    PIVOTS[group]=hingepoint(WING,a,.74,s);HINGES[group]=[PIVOTS[group],hingepoint(WING,b,.74,s)]
  # leading edge polished erosion strips and panel seams.
  for a,b in [(2,6.9),(7.1,13.6),(13.8,21.2)]:
   pts=[foilpoint(WING,x,.016,True,s) for x in np.linspace(a,b,32)];tube('Leading edge erosion strip',pts,.026,'Metal')
  for x in [3.1,5,7,9.5,12,14.2,16.5,19,21.3]:
   pts=[Vector(foilpoint(WING,x,t,True,s))+Vector((0,0,.006)) for t in np.linspace(.09,.72,22)];tube('Wing chord panel seam',pts,.0055,'Seals')
  for t in [.28,.58]:tube('Wing spar seam',[Vector(foilpoint(WING,x,t,True,s))+Vector((0,0,.006)) for x in np.linspace(2,21.3,80)],.005,'Seals')
  for x in [4.3,7.8,11.6]:
   le,te,z=interp(WING,x);sphere('Flap track canoe',(s*x,te-.1,z-.23),(.14,.91,.20),'Paint',seg=32,rings=16)
  for x in [5,8,11]:
   le,te,z=interp(WING,x);box('Upper spoiler panel',(s*x,le+(te-le)*.64,z+.17),(1.8,.56,.045),'Paint',bev=.025)
  # Stabilizer and independently hinged elevator.
  foil('Horizontal stabilizer',TAIL,0,8.4,0,.70,s,nx=18)
  foil('Elevator',TAIL,.9,8.36,.708,1,s,'Elevator_'+side,nx=20,nt=12)
  foil('Tail root trailing structure',TAIL,0,.9,.70,1,s,nx=4,nt=10)
  PIVOTS['Elevator_'+side]=hingepoint(TAIL,.9,.708,s);HINGES['Elevator_'+side]=[PIVOTS['Elevator_'+side],hingepoint(TAIL,8.36,.708,s)]
  for x in [2,4,6]:tube('Tail panel joint',[foilpoint(TAIL,x,t,True,s) for t in np.linspace(.10,.68,20)],.006,'Seals')
  # static discharge wicks, physically attached at trailing edges
  for x in [18.8,20,21.2]:
   p=Vector(foilpoint(WING,x,.998,True,s));rod('Static wick',p,p+Vector((0,.24,0)),.009,'Interior',n=8)
  sphere('Wingtip light',(s*22.05,8.10,4.30),(.12,.18,.065),'Lamps',seg=24,rings=12)
 foil('Vertical fin',FIN,4.6,12.6,0,.71,vertical=True,nx=28)
 foil('Rudder',FIN,5.25,12.58,.716,1,group='Rudder',vertical=True,nx=28,nt=12)
 foil('Fin root trailing structure',FIN,4.6,5.25,.71,1,vertical=True,nx=4,nt=10)
 PIVOTS['Rudder']=hingepoint(FIN,5.25,.716,vertical=True);HINGES['Rudder']=[PIVOTS['Rudder'],hingepoint(FIN,12.58,.716,vertical=True)]
 for z in [6.4,8.1,10.1,11.9]:
  tube('Fin construction joint',[foilpoint(FIN,z,t,True,vertical=True) for t in np.linspace(.13,.70,24)],.006,'Seals')
 rod('Fin tip antenna',(0,17.2,12.53),(0,15.75,12.53),.027,'Paint',r2=.012)

# Four TF33-style narrow turbofan nacelles, open intake and exhaust.
def engines():
 sockets=[]
 for s in [1,-1]:
  for xx,front in [(6.6,-9.0),(12.35,-5.5)]:
   x=s*xx;z=2.15 if xx<8 else 2.40;rear=front+4.8
   # Outer cowl continues into the rolled intake lip and inner duct.
   pro=[(front+4.8,.49),(front+4.25,.64),(front+3.45,.73),(front+1.2,.78),(front+.30,.77),(front+.06,.735),(front,.67),(front+.045,.605),(front+.23,.59),(front+.78,.575)]
   cowl=lathe_y('Engine cowling',pro,x,z,'Paint',n=96);cowl.data.materials.append(MATS['Metal'])
   for face in cowl.data.polygons:
    cy=sum(cowl.data.vertices[i].co.y for i in face.vertices)/len(face.vertices)
    if cy>front+3.0 or front+.30<cy<front+1.2:face.material_index=1
   lathe_y('Polished inlet lip',[(front+.30,.774),(front+.06,.738),(front-.005,.670),(front+.045,.601),(front+.21,.595)],x,z,'Metal',n=96)
   lathe_y('Intake interior',[(front+.19,.595),(front+.75,.57),(front+1.15,.53)],x,z,'Interior',n=96)
   lathe_y('Exhaust nozzle',[(rear-.9,.60),(rear-.25,.52),(rear,.47),(rear+.03,.40),(rear-.18,.37),(rear-.7,.36)],x,z,'Metal',n=96)
   lathe_y('Exhaust shadow',[(rear-.2,.37),(rear-.9,.30),(rear-1.1,.06)],x,z,'Interior',n=64)
   sphere('Exhaust center cone',(x,rear-.55,z),(.20,.8,.20),'Metal',seg=32,rings=16)
   # Recessed spinner and 30 curved fan vanes.
   sphere('Fan spinner',(x,front+.81,z),(.175,.30,.175),'Metal',seg=40,rings=20)
   for j in range(30):
    a=j*2*pi/30;verts=[]
    for r,tw in [(.17,0),(.33,.10),(.55,.22)]:
     for da in [0,.09]:verts.append((x+r*cos(a+tw+da),front+.87+.16*r,z+r*sin(a+tw+da)))
    ob=mesh('Fan blade',verts,[(0,1,3,2),(2,3,5,4)],'Metal');sol=ob.modifiers.new('Blade thickness','SOLIDIFY');sol.thickness=.008;active(ob);bpy.ops.object.modifier_apply(modifier=sol.name)
   # Pylon is a tapered airfoil shape connected to wing and nacelle.
   le,te,wz=interp(WING,xx)
   vv=[(x-.10,front+1,z+.57),(x+.10,front+1,z+.57),(x-.15,front+3.9,z+.5),(x+.15,front+3.9,z+.5),(x-.12,le+.6,wz+.10),(x+.12,le+.6,wz+.10),(x-.13,le+3.4,wz+.05),(x+.13,le+3.4,wz+.05)]
   bevel(mesh('Engine pylon',vv,[(0,1,3,2),(4,6,7,5),(0,4,5,1),(2,3,7,6),(0,2,6,4),(1,5,7,3)]),.045,3)
   for yy in [front+.63,front+1.25,front+3.45]:
    rr=.78 if yy<front+3 else .733
    tube('Nacelle ring seam',[(x+rr*cos(a),yy,z+rr*sin(a)) for a in np.linspace(0,2*pi,97)],.007,'Seals',closed=True)
   for yy in [front+1.3,front+3.4]:
    for a in np.linspace(0,2*pi,12,endpoint=False):sphere('Cowling fastener',(x+.782*cos(a),yy,z+.782*sin(a)),(.012,.018,.012),'Metal',seg=8,rings=4)
   sockets.append(((x,rear+.03,z),(x,le+2,wz-.05)))
 return sockets

def radar():
 cy=2.9;cz=10.05
 # Two visibly separate slab struts, airfoil plan, splayed roots.
 for s in [-1,1]:
  vs=[(s*1.12-.12,1.5,5.00),(s*1.12+.12,1.5,5.00),(s*1.12-.17,4.35,5.05),(s*1.12+.17,4.35,5.05),(s*.83-.08,2.00,9.7),(s*.83+.08,2.00,9.7),(s*.83-.09,3.67,9.7),(s*.83+.09,3.67,9.7)]
  bevel(mesh('Radar support strut',vs,[(0,1,3,2),(4,6,7,5),(0,4,5,1),(2,3,7,6),(0,2,6,4),(1,5,7,3)]),.06,4)
  sphere('Support root fairing',(s*1.03,2.88,5.25),(.40,1.9,.32),'Paint',seg=40,rings=16)
  for y in [1.8,4.0]:sphere('Support fixing',(s*1.17,y,5.48),(.055,.055,.035),'Metal',seg=12,rings=8)
 rod('Radar bearing',(0,cy,9.3),(0,cy,9.9),.75,'Metal',n=64)
 # Oblate lens with a continuous light central strip like the reference.
 vs=[];uv=[];faces=[];N=160;R=44
 for i in range(R+1):
  t=pi*(i+.002)/(R+.004);rr=4.55*sin(t);zz=cz+.70*cos(t)
  for j in range(N+1):
   a=2*pi*j/N;vs.append((rr*cos(a),cy+rr*sin(a),zz));uv.append((rr*cos(a)/3,rr*sin(a)/3))
 for i in range(R):
  for j in range(N):a=i*(N+1)+j;faces.append((a,a+1,a+N+2,a+N+1))
 ob=mesh('Rotodome lens',vs,faces,'Radome','Rotodome',uv);ob.data.materials.append(MATS['Paint'])
 bm=bmesh.new();bm.from_mesh(ob.data)
 for yy in [cy-.82,cy+.82]:
  bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),dist=.000001,plane_co=(0,yy,0),plane_no=(0,1,0))
 bm.to_mesh(ob.data);bm.free()
 for p in ob.data.polygons:
  center=sum((ob.data.vertices[i].co for i in p.vertices),Vector())/len(p.vertices)
  if abs(center.y-cy)<.82001:p.material_index=1
 tube('Rotodome equator seal',[(4.535*cos(a),cy+4.535*sin(a),cz) for a in np.linspace(0,2*pi,161)],.015,'Seals','Rotodome',True)
 PIVOTS['Rotodome']=(0,cy,cz)

# Real compressed tyres, bogies, oleos, torque links and hydraulic hoses.
def wheel(x,y,r,width,group):
 z=r
 # Lathe about X: closed toroidal cross-section, softly crowned tread.
 v=[];uv=[];f=[];N=64;K=12
 for i in range(K):
  t=2*pi*i/K;xx=x+width*.5*cos(t);rr=r*.73+r*.27*sin(t)
  for j in range(N+1):
   a=2*pi*j/N;v.append((xx,y+rr*cos(a),max(.0,z+rr*sin(a))));uv.append((i/K,j/N*3))
 for i in range(K):
  for j in range(N):a=i*(N+1)+j;b=((i+1)%K)*(N+1)+j;f.append((a,a+1,b+1,b))
 mesh('Tyre',v,f,'Rubber',group,uv)
 rod('Wheel hub',(x-width*.53,y,z),(x+width*.53,y,z),r*.43,'Metal',group,n=48)
 for s in [-1,1]:
  rod('Hub recess',(x+s*width*.54,y,z),(x+s*width*.56,y,z),r*.29,'Interior',group,n=32)
  rod('Axle cap',(x+s*width*.565,y,z),(x+s*width*.60,y,z),r*.16,'Metal',group,n=32)
  for j in range(8):
   a=j*2*pi/8;rod('Wheel hub bolt',(x+s*width*.535,y+r*.34*cos(a),z+r*.34*sin(a)),(x+s*width*.56,y+r*.34*cos(a),z+r*.34*sin(a)),.021,'Metal',group,n=8)
 # longitudinal circumferential tyre grooves
 for dx in [-.09,0,.09]:
  if abs(dx)<width*.45:
   rr=r*.73+r*.27*sqrt(1-(2*dx/width)**2)-.005
   tube('Tyre tread groove',[(x+dx,y+rr*cos(a),z+rr*sin(a)) for a in np.linspace(0,2*pi,65)],.004,'Seals',group,True)

def gear():
 for s,side in [(1,'L'),(-1,'R')]:
  g='Gear_'+side;x=s*2.6;PIVOTS[g]=(s*1.8,0,2.75)
  box('Main wheel well',(s*2.1,0,2.35),(1.4,2.5,.17),'Interior')
  rod('Main oleo',(x,0,.75),(s*2.25,0,2.72),.13,'Metal',g,n=32)
  rod('Main oleo sleeve',(s*2.39,0,1.5),(s*2.25,0,2.72),.195,'Paint',g,n=32)
  rod('Bogie beam',(x,-.83,.64),(x,.83,.64),.16,'Metal',g,n=24)
  for yy in [-.78,.78]:
   rod('Bogie axle',(x-.52,yy,.57),(x+.52,yy,.57),.12,'Metal',g)
   for dx in [-.38,.38]:wheel(x+dx,yy,.57,.34,g)
  rod('Main diagonal brace',(s*1.45,-1.25,2.5),(x,0,1.0),.08,'Metal',g)
  rod('Main torque upper',(x,-.14,1.58),(x,-.43,1.18),.055,'Metal',g)
  rod('Main torque lower',(x,-.43,1.18),(x,-.15,.92),.055,'Metal',g)
  tube('Brake hydraulic line',[(s*2.25+.15,0,2.66),(x+.18,.1,1.5),(x+.19,.17,.8),(x+.28,-.74,.61)],.019,'Rubber',g)
  b=box('Main gear door',(s*3.03,.1,1.95),(.08,1.58,.81),'Paint',g,bev=.065);b.rotation_euler.y=s*.14
  for yy in [-.4,.6]:rod('Gear door stay',(x,yy,1.6),(s*3.03,yy,1.9),.04,'Metal',g)
  box('Door inner stiffener',(s*2.97,.1,1.95),(.07,1.36,.09),'Metal',g,bev=.02)
 g='Gear_Nose';PIVOTS[g]=(0,-21.5,2.15)
 box('Nose gear recess',(0,-21.5,1.95),(.8,1.4,.15),'Interior')
 rod('Nose oleo',(0,-21.5,.43),(0,-21.72,2.18),.09,'Metal',g,n=32)
 rod('Nose oleo sleeve',(0,-21.63,1.27),(0,-21.72,2.18),.14,'Paint',g,n=32)
 rod('Nose axle',(-.40,-21.5,.43),(.40,-21.5,.43),.1,'Metal',g)
 for x in [-.26,.26]:wheel(x,-21.5,.43,.23,g)
 rod('Nose drag brace',(0,-20.8,1.94),(0,-21.51,.85),.07,'Metal',g)
 tube('Nose brake hose',[(.13,-21.74,2.0),(.16,-21.65,1.3),(.16,-21.55,.63),(.25,-21.5,.45)],.017,'Rubber',g)
 for s in [-1,1]:
  b=box('Nose wheel bay door',(s*.43,-21.42,1.5),(.055,1.18,.46),'Paint',g,bev=.035);b.rotation_euler.y=s*.22
 sphere('Landing light',(0,-21.8,1.15),(.11,.08,.11),'Lamps',g,seg=24,rings=12)

# All materials use aligned PH maps and are baked into small reusable PBR sets.
make_material('Paint',(1,1,1),.58,0,1024,panel=True)
make_material('Metal',(.48,.50,.52),.29,.82,512)
make_material('Rubber',(.029,.032,.035),.86,0,512,'rubber_tiles')
make_material('Radome',(.028,.031,.035),.58,0,512)
make_material('Interior',(.047,.056,.061),.78,0,256)
make_material('Seals',(.18,.19,.19),.8,0,128)
make_material('Glass',(.06,.095,.11),.10,.05,128)
make_material('Lamps',(1,.35,.11),.25,.12,64)
print('BUILD GEOMETRY',flush=True)
fuselage();wings();engine_sockets=engines();radar();gear()
sphere('Upper beacon',(0,-6,5.64),(.13,.13,.085),'Lamps',seg=24,rings=12)
sphere('Lower beacon',(0,4.5,1.68),(.13,.13,.075),'Lamps',seg=24,rings=12)

# Merge each required assembly, bake transforms, preserve correct hinge origins.
ASSEMBLIES=[]
for name,objects in GROUPS.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.select_set(True)
 bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 sc.cursor.location=PIVOTS.get(name,(0,0,0));bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 # Triangulation also makes the delivered triangle budget deterministic.
 tr=o.modifiers.new('Export triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tr.name)
 # Remove unused construction vertices (e.g. cockpit loft's cut-out copy).
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.delete(bm,geom=[v for v in bm.verts if not v.link_faces],context='VERTS');bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=.000001);bm.to_mesh(o.data);bm.free()
 if name in HINGES:o['hinge_axis_world_blender']=list((Vector(HINGES[name][1])-Vector(HINGES[name][0])).normalized())
 ASSEMBLIES.append(o)

# Only simplify if details together exceed the requested real-time budget.
raw_tri=sum(len(o.data.polygons) for o in ASSEMBLIES)
if raw_tri>195000:
 protected=sum(len(o.data.polygons) for o in ASSEMBLIES if o.name=='Rotodome')
 for o in ASSEMBLIES:
  if o.name=='Rotodome':continue # preserve the precision-cut stripe boundaries
  active(o);dec=o.modifiers.new('Game weight budget','DECIMATE');dec.ratio=(193000-protected)/(raw_tri-protected);dec.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=dec.name)

def empty(name,pos):
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=pos;o.empty_display_size=.25;ASSEMBLIES.append(o);return o
empty('Seat_Pilot',(.52,-22.95,3.77));empty('Seat_Copilot',(-.52,-22.95,3.77));empty('Seat_Mission_Operator',(1,-13,3.7));empty('Center_Of_Mass',(0,-1.3,3.55))
for i,(ex,py) in enumerate(engine_sockets,1):empty('Engine_'+str(i),ex);empty('Hardpoint_Engine_'+str(i),py)
empty('Light_Nav_L',(22.05,8.10,4.30));empty('Light_Nav_R',(-22.05,8.10,4.30));empty('Light_Beacon',(0,-6,5.64))

bpy.context.view_layer.update()
pts=[o.matrix_world@v.co for o in ASSEMBLIES if o.type=='MESH' for v in o.data.vertices]
lo=Vector([min(v[i] for v in pts) for i in range(3)]);hi=Vector([max(v[i] for v in pts) for i in range(3)])
def game(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
tris=sum(len(o.data.polygons) for o in ASSEMBLIES if o.type=='MESH')
metadata={'asset':'e3-awacs','units':'metres','axes':{'up':'+Y','forward':'+Z','left':'+X'},'origin':'ground beneath main bogie axle midpoint','bounding_box':{'min':[lo.x,lo.z,-hi.y],'max':[hi.x,hi.z,-lo.y],'size':[hi.x-lo.x,hi.z-lo.z,hi.y-lo.y]},'triangle_count':tris,'nodes':[{'name':o.name,'position':game(o.matrix_world.translation),'type':o.type,**({'hinge_line':[game(Vector(v)) for v in HINGES[o.name]]} if o.name in HINGES else {})} for o in ASSEMBLIES],'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'rotodome':{'axis':[0,1,0],'diameter_m':9.1},'materials':list(MATS),'source_textures':[{'id':i,'license':'CC0-1.0','url':'https://polyhaven.com/a/'+i} for i in ['metal_plate','rubber_tiles']]}
with open(os.path.join(ROOT,'nodes.json'),'w') as f:json.dump(metadata,f,indent=2)
# Export selected meshes and gameplay sockets only.
bpy.ops.object.select_all(action='DESELECT')
for o in ASSEMBLIES:o.select_set(True)
filepath=os.path.join(ROOT,'e3-awacs.glb')
bpy.ops.export_scene.gltf(filepath=filepath,export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_extras=True,export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_image_format='AUTO')
# Explicitly preserve the runtime paint multiplier, independent of exporter node heuristics.
def enforce_contract(path):
 data=open(path,'rb').read();jl,jt=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+jl]);binary=data[20+jl:]
 for m in doc['materials']:
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1.0]
  if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
  m.get('extensions',{}).pop('KHR_materials_clearcoat',None)
 encoded=json.dumps(doc,separators=(',',':')).encode();encoded+=b' '*((-len(encoded))%4)
 open(path,'wb').write(struct.pack('<III',0x46546c67,2,20+len(encoded)+len(binary))+struct.pack('<II',len(encoded),0x4e4f534a)+encoded+binary)
enforce_contract(filepath)
print('EXPORT_COMPLETE',tris,os.path.getsize(filepath),metadata['bounding_box'],flush=True)
with open(os.path.join(ROOT,'NOTES.md'),'w') as f:
 f.write('E-3 Sentry-style AWACS; metre-scale, gear down, articulated controls and rotodome, faction-neutral.\n')
 f.write(f'{tris:,} triangles; GLB {os.path.getsize(filepath)/1048576:.2f} MiB; +Z nose, +Y up, +X left.\n')
 f.write('8 materials: Paint, Metal, Rubber, Radome, Interior, Seals, Glass (BLEND), Lamps; no clearcoat.\n')
 f.write('Cycles-baked tiled base/normal maps 64–1024 px; packed ORM 64–512 px; Paint tile 3 m, neutral detail × linear colour factor.\n')
 f.write('Poly Haven CC0: metal_plate, rubber_tiles (https://polyhaven.com/license); diff/nor_gl/arm maps; no other image sources.\n')
 f.write('Reference differences: reconstructed E-3-style geometry rather than certified plans; simplified mission cabin; no markings, text, insignia or logos.\n')

# Studio soft daylight. Camera fits the projected asset bounds, avoiding cropped tips.
bpy.ops.mesh.primitive_plane_add(size=2000,location=(0,0,-.018));ground=bpy.context.object;ground.name='Render ground'
gm=bpy.data.materials.new('Studio floor');gm.diffuse_color=(.39,.43,.46,1);gm.use_nodes=True;gm.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.39,.43,.46,1);gm.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.9;ground.data.materials.append(gm)
ld=bpy.data.lights.new('Large soft daylight','AREA');light=bpy.data.objects.new('Large soft daylight',ld);bpy.context.collection.objects.link(light);light.location=(12,-18,35);ld.energy=6500;ld.shape='DISK';ld.size=22;light.rotation_euler=(Vector((0,-2,2))-light.location).to_track_quat('-Z','Y').to_euler()
ld=bpy.data.lights.new('Sun','SUN');li=bpy.data.objects.new('Sun',ld);bpy.context.collection.objects.link(li);li.rotation_euler=(.45,-.38,-.5);ld.energy=2.0;ld.angle=.22
camdata=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camdata);bpy.context.collection.objects.link(cam);sc.camera=cam;camdata.type='ORTHO';camdata.lens=50

def camera(direction,aspect):
 center=(lo+hi)/2;center.z=4.5
 cam.location=center+Vector(direction).normalized()*90;cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();bpy.context.view_layer.update()
 inv=cam.matrix_world.inverted();pp=[inv@p for p in pts];xmin=min(p.x for p in pp);xmax=max(p.x for p in pp);ymin=min(p.y for p in pp);ymax=max(p.y for p in pp)
 # Blender ortho_scale is horizontal extent at landscape aspect.
 camdata.ortho_scale=max(xmax-xmin,(ymax-ymin)*aspect)*1.14
 shift=cam.rotation_euler.to_matrix()@Vector(((xmin+xmax)/2,(ymin+ymax)/2,0));cam.location+=shift

def render(name,w,h,samples=24):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.render.resolution_percentage=100;sc.cycles.samples=samples;sc.render.image_settings.file_format='PNG';sc.render.filepath=os.path.join(ROOT,name);bpy.ops.render.render(write_still=True)

camera((1,-1.30,.67),1920/1080)
render('final.png',1920,1080,32)
# Clay shares the final camera; material override applies uniformly to the asset.
clay=bpy.data.materials.new('QA clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.42,.42,.42,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.65
sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,20);sc.view_layers[0].material_override=None
camera((1,0,0),1920/1080);render('side.png',1920,1080,24)
camera((1,-1.30,.67),16/9)
# Build the 3x2 proof sheet from actual six renders, in linear image space.
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,(name,col) in enumerate(PAINTS):
 MATS['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=(*col,1)
 render('paint_'+str(i)+'.png',640,360,12)
 im=bpy.data.images.load(os.path.join(ROOT,'paint_'+str(i)+'.png'),check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4)
 row=1-i//3;column=i%3;canvas[row*360:(row+1)*360,column*640:(column+1)*640,:]=a;bpy.data.images.remove(im)
MATS['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=(*PAINTS[0][1],1)
im=bpy.data.images.new('Six paint proof',1920,720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
for i in range(6):os.unlink(os.path.join(ROOT,'paint_'+str(i)+'.png'))
# Save a recovery scene remotely, not part of the required delivery package.
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT,'e3-awacs.blend'))
print('BUILD_AND_RENDER_COMPLETE',flush=True)
