import bpy, bmesh, math, os, json, urllib.request, struct, sys
import numpy as np
from mathutils import Vector
from math import sin,cos,pi,sqrt
ROOT=os.path.dirname(os.path.abspath(__file__))
os.makedirs(os.path.join(ROOT,'textures'),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
pref=bpy.context.preferences.addons['cycles'].preferences
pref.compute_device_type='CUDA';pref.get_devices()
for d in pref.devices:d.use=d.type!='CPU'
S.cycles.device='GPU';S.cycles.use_denoising=True
S.render.image_settings.file_format='PNG';S.view_settings.view_transform='AgX'
S.render.threads_mode='FIXED';S.render.threads=8
M={};groups={};pivots={}
PAINTS=[('Dark grey',[.22,.24,.24]),('Air-superiority light grey',[.48,.53,.56]),('Navy blue-grey',[.12,.18,.23]),('Desert sand',[.52,.39,.24]),('Olive',[.17,.20,.105]),('Black',[.035,.042,.046])]

def download(id,map):
 p=os.path.join(ROOT,'textures',f'{id}_{map}_1k.jpg')
 if not os.path.exists(p):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
  urllib.request.urlretrieve(url,p)
 return bpy.data.images.load(p,check_existing=True)
source={id:{k:download(id,k) for k in ['diff','nor_gl','arm']} for id in ['metal_plate','rubberized_track']}

def bake_material(name,color,rough,metal,id='metal_plate'):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;ns=nt.nodes;ln=nt.links;b=ns.get('Principled BSDF')
 b.inputs['Base Color'].default_value=(*color,1);b.inputs['Roughness'].default_value=rough;b.inputs['Metallic'].default_value=metal
 tex=ns.new('ShaderNodeTexImage');tex.image=source[id]['diff']
 bw=ns.new('ShaderNodeRGBToBW');ln.new(tex.outputs['Color'],bw.inputs[0])
 ramp=ns.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.05;ramp.color_ramp.elements[1].position=.8
 for e,f in zip(ramp.color_ramp.elements,[.74,1.0]):e.color=tuple(c*f for c in color)+(1,)
 ln.new(bw.outputs[0],ramp.inputs[0]);ln.new(ramp.outputs[0],b.inputs['Base Color'])
 uvn=ns.new('ShaderNodeTexCoord');stretch=ns.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(1.1,16,1);ln.new(uvn.outputs['UV'],stretch.inputs[0])
 weather=ns.new('ShaderNodeTexNoise');weather.inputs['Scale'].default_value=5;weather.inputs['Detail'].default_value=4;weather.inputs['Roughness'].default_value=.75;ln.new(stretch.outputs[0],weather.inputs[0])
 wr=ns.new('ShaderNodeValToRGB');wr.color_ramp.elements[0].position=.28;wr.color_ramp.elements[0].color=(.51,.51,.51,1);wr.color_ramp.elements[1].position=.72;wr.color_ramp.elements[1].color=(1,1,1,1);ln.new(weather.outputs['Fac'],wr.inputs[0])
 mix=ns.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.48 if name=='Paint' else .24;ln.new(ramp.outputs[0],mix.inputs[1]);ln.new(wr.outputs[0],mix.inputs[2]);ln.new(mix.outputs[0],b.inputs['Base Color'])
 noise=ns.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=95;noise.inputs['Detail'].default_value=3
 bump=ns.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.19;bump.inputs['Distance'].default_value=.0025 if name=='Paint' else .006
 ln.new(noise.outputs['Fac'],bump.inputs['Height'])
 norm=ns.new('ShaderNodeTexImage');norm.image=source[id]['nor_gl'];norm.image.colorspace_settings.name='Non-Color'
 normal=ns.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.035 if name=='Paint' else .28
 ln.new(norm.outputs['Color'],normal.inputs['Color']);ln.new(normal.outputs['Normal'],bump.inputs['Normal']);ln.new(bump.outputs['Normal'],b.inputs['Normal'])
 arm=ns.new('ShaderNodeTexImage');arm.image=source[id]['arm'];arm.image.colorspace_settings.name='Non-Color'
 sep=ns.new('ShaderNodeSeparateColor');ln.new(arm.outputs['Color'],sep.inputs[0])
 mr=ns.new('ShaderNodeMapRange');mr.inputs['To Min'].default_value=rough-.09;mr.inputs['To Max'].default_value=min(.98,rough+.08);ln.new(sep.outputs[1],mr.inputs['Value']);ln.new(mr.outputs[0],b.inputs['Roughness'])
 bpy.ops.mesh.primitive_plane_add(size=2);o=bpy.context.object;o.data.materials.append(m)
 target=ns.new('ShaderNodeTexImage');maps={}
 for kind,size in [('base',1024),('normal',1024),('orm',512)]:
  im=bpy.data.images.new(name+'_'+kind,size,size,alpha=False);im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color';target.image=im;ns.active=target
  if kind=='base':bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'},margin=8)
  elif kind=='normal':bpy.ops.object.bake(type='NORMAL',margin=8)
  else:
   comb=ns.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1;comb.inputs[2].default_value=metal;ln.new(mr.outputs[0],comb.inputs[1])
   em=ns.new('ShaderNodeEmission');ln.new(comb.outputs[0],em.inputs['Color']);ln.new(em.outputs[0],ns.get('Material Output').inputs['Surface']);bpy.ops.object.bake(type='EMIT',margin=8)
  ext='png' if kind=='orm' else 'jpg';im.filepath_raw=os.path.join(ROOT,'textures',name+'_'+kind+'.'+ext);im.file_format='PNG' if kind=='orm' else 'JPEG';S.render.image_settings.quality=92;im.save();maps[kind]=im
  if kind=='normal' and name in ['Glass','Interior','Seams','Lamps']:
   maps[kind]=bpy.data.images.get('Metal_normal')
 bpy.data.objects.remove(o,do_unlink=True)
 ns.clear();out=ns.new('ShaderNodeOutputMaterial');b=ns.new('ShaderNodeBsdfPrincipled');ln.new(b.outputs[0],out.inputs[0])
 bc=ns.new('ShaderNodeTexImage');bc.image=maps['base'];ln.new(bc.outputs[0],b.inputs['Base Color'])
 no=ns.new('ShaderNodeTexImage');no.image=maps['normal'];nm=ns.new('ShaderNodeNormalMap');ln.new(no.outputs[0],nm.inputs[1]);ln.new(nm.outputs[0],b.inputs['Normal'])
 ar=ns.new('ShaderNodeTexImage');ar.image=maps['orm'];sp=ns.new('ShaderNodeSeparateColor');ln.new(ar.outputs[0],sp.inputs[0]);ln.new(sp.outputs[1],b.inputs['Roughness']);ln.new(sp.outputs[2],b.inputs['Metallic'])
 if name=='Paint':
  tint=ns.new('ShaderNodeMixRGB');tint.name='PaintTint';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;tint.inputs[2].default_value=(*PAINTS[0][1],1);ln.new(bc.outputs[0],tint.inputs[1]);ln.new(tint.outputs[0],b.inputs['Base Color'])
 if name=='Glass':
  nt.links.remove(b.inputs['Normal'].links[0]);nt.links.remove(b.inputs['Roughness'].links[0]);b.inputs['Normal'].default_value=(0,0,0)
  b.inputs['Alpha'].default_value=.52;b.inputs['Roughness'].default_value=.14;b.inputs['Metallic'].default_value=.25;m.surface_render_method='DITHERED'
 if name=='Lamps':
  b.inputs['Emission Color'].default_value=(1,.18,.035,1);b.inputs['Emission Strength'].default_value=3
 M[name]=m
 print('BAKED',name,flush=True)

# A small shared tile, not a unique 48 m atlas. Neutral Paint is tinted only at runtime.
for args in [('Paint',(1,1,1),.63,.12),('Metal',(.40,.43,.46),.38,.8),('Rubber',(.045,.047,.05),.86,0,'rubberized_track'),('Interior',(.075,.085,.085),.8,.08),('Glass',(.12,.19,.22),.2,.15),('Lamps',(.8,.26,.1),.24,.1),('Seams',(.065,.075,.08),.65,.3)]:bake_material(*args)

def reg(o,g,mat='Paint'):
 o.data.materials.append(M[mat]);groups.setdefault(g,[]).append(o)
 for p in o.data.polygons:p.use_smooth=True
 return o

def mesh(name,v,f,g='Fuselage',mat='Paint',uv=None):
 me=bpy.data.meshes.new(name);me.from_pydata(v,[],f);me.update();o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);reg(o,g,mat)
 layer=me.uv_layers.new(name='UVMap')
 for p in me.polygons:
  n=p.normal;axis=max(range(3),key=lambda a:abs(n[a]));axes=[a for a in range(3) if a!=axis]
  for li in p.loop_indices:
   co=me.vertices[me.loops[li].vertex_index].co
   layer.data[li].uv=uv[me.loops[li].vertex_index] if uv else (co[axes[0]]/2.3,co[axes[1]]/2.3)
 return o

def bevel(o,w=.025,seg=3):
 m=o.modifiers.new('Machined edge radii','BEVEL');m.width=w;m.segments=seg
 bpy.context.view_layer.objects.active=o
 bpy.ops.object.modifier_apply(modifier=m.name)
 return o

def box(name,loc,dim,g='Fuselage',mat='Paint',w=.03):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.dimensions=dim;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);reg(o,g,mat);bevel(o,w)
 return o

def sphere(name,loc,scale,g='Fuselage',mat='Paint',seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,radius=1,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);return reg(o,g,mat)

def rod(name,a,b,r,g='Fuselage',mat='Metal',verts=16):
 a,b=Vector(a),Vector(b);d=b-a;bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=r,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();reg(o,g,mat);bevel(o,min(.012,r*.16),2);return o

def line(name,pts,r=.009,g='Fuselage',mat='Seams'):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=1;cu.bevel_depth=r;cu.bevel_resolution=1;s=cu.splines.new('POLY');s.points.add(len(pts)-1)
 for p,co in zip(s.points,pts):p.co=(*co,1)
 ob=bpy.data.objects.new(name,cu);bpy.context.collection.objects.link(ob);bpy.context.view_layer.objects.active=ob;ob.select_set(True);bpy.ops.object.convert(target='MESH');ob=bpy.context.object;ob.select_set(False);return reg(ob,g,mat)

# Smooth fuselage profiles: y (tail positive), half-width, half-height, vertical center.
profiles=[(-24.25,.035,.055,3.35),(-24.02,.43,.43,3.46),(-23.6,.84,.74,3.54),(-23.0,1.13,.94,3.61),(-22.6,1.25,1.03,3.65),(-22,1.19,1.10,3.74),(-21,1.50,1.64,3.94),(-20,1.70,1.75,3.92),(-18,1.79,1.75,3.92),(-14,1.8,1.76,3.94),(-7,1.80,1.76,3.94),(3,1.74,1.71,3.96),(10,1.52,1.48,4.08),(16,1.30,1.20,4.45),(21,.95,.94,4.65),(24,.80,.85,4.70),(24.25,.77,.78,4.70)]
def prof(y):
 for a,b in zip(profiles,profiles[1:]):
  if a[0]<=y<=b[0]:
   i=profiles.index(a);prev=profiles[max(0,i-1)];nex=profiles[min(len(profiles)-1,i+2)];span=b[0]-a[0];t=(y-a[0])/span
   return tuple((2*t**3-3*t*t+1)*a[k]+(t**3-2*t*t+t)*(b[k]-prev[k])/(b[0]-prev[0])*span+(-2*t**3+3*t*t)*b[k]+(t**3-t*t)*(nex[k]-a[k])/(nex[0]-a[0])*span for k in range(1,4))
 return profiles[-1][1:]
def skin(y,t,offset=0):
 rx,rz,z=prof(y);return ((rx+offset)*sin(t),y,z+(rz+offset)*cos(t))
ys=sorted(set([round(-24.25+i*48.5/194,5) for i in range(195)]+[-22,-21,-20,-19,-18.0,-6.7,2.6]))
N=96;v=[];uv=[]
for y in ys:
 for j in range(N):
  t=2*pi*j/N;v.append(skin(y,t));uv.append((t*1.78/2.3,y/2.3))
f=[]
for i in range(len(ys)-1):
 for j in range(N):
  theta=2*pi*(j+.5)/N;theta=min(theta,2*pi-theta)
  if -22<=ys[i] and ys[i+1]<=-18 and theta<1.0472:continue
  if -6.7<=ys[i] and ys[i+1]<=2.6 and abs(2*pi*(j+.5)/N-pi)<.655:continue
  f.append((i*N+j,i*N+(j+1)%N,(i+1)*N+(j+1)%N,(i+1)*N+j))
f += [tuple(range(N-1,-1,-1)),tuple((len(ys)-1)*N+j for j in range(N))]
mesh('Lofted pressure hull',v,f,uv=uv)
# Paned cockpit follows the cut-out, with actual framing, interior and seats.
for a,b in [(-22,-21),(-21,-20),(-20,-19),(-19,-18)]:
 for ta,tb in [(-pi/3,-pi/6),(-pi/6,0),(0,pi/6),(pi/6,pi/3)]:
  vs=[skin(a,ta,.015),skin(a,tb,.015),skin(b,tb,.015),skin(b,ta,.015)]
  glazed=(a==-22 or (a<-19 and abs((ta+tb)/2)>pi/6))
  fs=[(0,1,2,3)]
  if not glazed:
   vs=[skin(a+(b-a)*i/8,ta+(tb-ta)*j/8,.015) for i in range(9) for j in range(9)]
   fs=[(i*9+j,i*9+j+1,(i+1)*9+j+1,(i+1)*9+j) for i in range(8) for j in range(8)]
  o=mesh('Cockpit laminated pane' if glazed else 'Cockpit roof skin',vs,fs,'Canopy' if glazed else 'Fuselage','Glass' if glazed else 'Paint');sol=o.modifiers.new('Laminated glazing','SOLIDIFY');sol.thickness=.024;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=sol.name)
for y in [-22,-21]:line('Cockpit transverse frame',[skin(y,-pi/3+i*pi/48,.035) for i in range(33)],.033,'Fuselage','Paint')
for y in [-20,-19]:
 for side in [-1,1]:line('Cockpit side mullion',[skin(y,side*(pi/6+i*pi/96),.035) for i in range(17)],.03,'Fuselage','Paint')
for t in [-pi/3,-pi/6,0,pi/6,pi/3]:
 length=1 if t==0 else 3
 line('Cockpit longitudinal frame',[skin(-22+i/10,t,.04) for i in range(length*10+1)],.03,'Fuselage','Paint')
pivots['Canopy']=(0,-18,5.6)
box('Cockpit floor',(0,-19.85,4.1),(2.5,4.0,.12),mat='Interior')
box('Instrument coaming',(0,-21.2,4.53),(2.3,.53,.27),mat='Interior',w=.09)
for x in [-.65,.65]:
 box('Crew seat cushion',(x,-19.9,4.46),(.54,.56,.17),mat='Interior',w=.08)
 box('Crew seat back',(x,-19.58,4.84),(.58,.17,.72),mat='Interior',w=.08)
 rod('Control column',(x,-20.5,4.2),(x,-20.5,4.76),.045,mat='Metal')
 for z in [4.61,4.75]:box('Unmarked instrument',(x,-21.0,z),(.35,.035,.09),mat='Glass',w=.02)
# Skin seams: circumferential manufacturing joints and sparse access outlines.
for y in [-17,-13,-9,-4,1,6,11,16,20,22.5]:line('Circumferential skin joint',[skin(y,2*pi*j/96,.008) for j in range(97)],.008)
for t in [1.17,1.95,4.33,5.11]:line('Longitudinal skin joint',[skin(-18+i*.5,t,.008) for i in range(81)],.006)
for side in [-1,1]:
 for y in [-17,-13,9,15,20]:
  t=side*pi/2;pts=[skin(y+dy,t+dt,.012) for dy,dt in [(-.42,-.17),(.42,-.17),(.42,.17),(-.42,.17),(-.42,-.17)]];line('Flush service hatch',pts,.012)
  for yy in [y-.35,y+.35]:sphere('Quarter-turn fastener',skin(yy,t-.12,.025),(.025,.025,.025),mat='Metal',seg=8,rings=4)
# Airfoil surfaces use closed NACA sections, sweep, taper, camber and anhedral.
def wingpoint(side,x,t,kind='wing',bottom=False):
 if kind=='wing':
  lead=-7.0+.64*x;chord=10.5-.275*x;z=5.28-.040*x-.0014*x*x;thick=.105-.0015*x
 elif kind=='tail':lead=14.2+.49*x;chord=6.5-.47*x;z=4.65+.065*x;thick=.075
 yt=5*thick*(.2969*sqrt(max(t,0))-.126*t-.3516*t*t+.2843*t**3-.1036*t**4)*chord
 return (side*x,lead+t*chord,z+(.018*sin(pi*t)*chord)+(-yt if bottom else yt))
def foil(name,side,x0,x1,t0,t1,g,kind='wing',nx=36,nt=26):
 v=[]
 ts=[t0+(t1-t0)*(.5-.5*cos(pi*j/nt)) for j in range(nt+1)]
 for i in range(nx+1):
  x=x0+(x1-x0)*i/nx
  for bottom in [False,True]:
   for t in ts:v.append(wingpoint(side,x,t,kind,bottom))
 stride=2*(nt+1);f=[]
 for i in range(nx):
  for b in range(2):
   for j in range(nt):
    a=i*stride+b*(nt+1)+j;face=(a,a+1,a+stride+1,a+stride);f.append(face if b==0 else tuple(reversed(face)))
  for j in [0,nt]:
   a=i*stride+j;bb=a+nt+1;f.append((a,a+stride,bb+stride,bb))
 for i in [0,nx]:
  for j in range(nt):a=i*stride+j;bb=a+nt+1;f.append((a,bb,bb+1,a+1))
 o=mesh(name,v,f,g);return o
for side,label in [(1,'L'),(-1,'R')]:
 foil('Continuous swept wing',side,1.05,28.2,0,.72,'Fuselage',nx=46,nt=30)
 foil('Root trailing fairing',side,1.05,3.0,.72,1,'Fuselage',nx=6,nt=10)
 foil('Fowler flap',side,3.02,16.0,.724,1,'Flap_'+label,nx=25,nt=12)
 foil('Fixed trailing strip',side,16.02,18.0,.72,1,'Fuselage',nx=5,nt=10)
 foil('Outboard aileron',side,18.02,27.4,.724,1,'Aileron_'+label,nx=24,nt=12)
 foil('Wingtip trailing cap',side,27.42,28.2,.72,1,'Fuselage',nx=4,nt=10)
 pivots['Flap_'+label]=wingpoint(side,3.02,.724);pivots['Aileron_'+label]=wingpoint(side,18.02,.724)
 foil('Swept tailplane',side,.65,7.8,0,.70,'Fuselage','tail',nx=23,nt=18)
 foil('Elevator',side,.68,7.8,.708,1,'Elevator_'+label,'tail',nx=23,nt=12);pivots['Elevator_'+label]=wingpoint(side,.68,.708,'tail')
 for x in [3,6,9,12,15,18,21,24,27]:
  pts=[wingpoint(side,x,.05+.65*j/30) for j in range(31)];pts=[(a,b,c+.006) for a,b,c in pts];line('Wing rib seam',pts,.007)
 for t in [.18,.53,.71]:line('Wing span panel seam',[(a,b,c+.012) for a,b,c in [wingpoint(side,1.7+i*.5,t) for i in range(53)]],.008)
 for x in [5,10,14]:
  q=wingpoint(side,x,.84);sphere('Flap track fairing',(q[0],q[1]-.25,q[2]-.35),(.16,1.25,.22))
 for x in [4,7,10,13,16,19,22,25]:
  for t in [.19,.54]:
   a,b,c=wingpoint(side,x,t);sphere('Wing flush fastener',(a,b,c+.02),(.022,.022,.012),mat='Metal',seg=8,rings=4)
# Tall swept vertical fin with separate rudder.
def finpos(h,t,side):
 z=4.5+7.9*h;le=12.8+5.95*h;ch=7.9-4.95*h;th=(.23*(1-h)+.075)*sin(pi*t)**.6
 return (side*th,le+t*ch,z)
def fin(name,t0,t1,g):
 v=[];nh=30;nt=18
 for side in [-1,1]:
  for i in range(nh+1):
   for j in range(nt+1):v.append(finpos(i/nh,t0+(t1-t0)*j/nt,side))
 n=(nh+1)*(nt+1);f=[]
 for s in range(2):
  for i in range(nh):
   for j in range(nt):a=s*n+i*(nt+1)+j;f.append((a,a+1,a+nt+2,a+nt+1))
 for i in range(nh):
  for j in [0,nt]:a=i*(nt+1)+j;b=a+nt+1;f.append((a,b,b+n,a+n))
 for i in [0,nh]:
  for j in range(nt):a=i*(nt+1)+j;f.append((a,a+n,a+n+1,a+1))
 mesh(name,v,f,g)
fin('Vertical stabilizer',0,.74,'Fuselage');fin('Rudder',.748,1,'Rudder');pivots['Rudder']=finpos(0,.748,0)
for side in [-1,1]:
 for h in [.2,.4,.6,.8]:line('Fin panel joint',[finpos(h,.04+j*.69/30,side) for j in range(31)],.008)
 sphere('Fin dielectric blister',(side*.23,19.4,8.7),(.13,.8,.18))
fv=[]
for yy,rx,rz,zz in [(10.8,.03,.18,5.4),(11.5,.15,.38,5.4),(12.7,.24,.70,5.4),(14.2,.30,1.15,5.4)]:
 for j in range(24):fv.append((rx*sin(2*pi*j/24),yy,zz+rz*cos(2*pi*j/24)))
ff=[(i*24+j,i*24+(j+1)%24,(i+1)*24+(j+1)%24,(i+1)*24+j) for i in range(3) for j in range(24)]
mesh('Fin dorsal root fairing',fv,ff)
# Engine nacelles: open annular lips, dark inlet ducts, fan blades, exhaust cones.
def tube(name,x,y,z,sections,g='Fuselage',mat='Paint',n=64):
 v=[];uv=[]
 for yy,r in sections:
  for j in range(n):
   t=2*pi*j/n;v.append((x+r*sin(t),y+yy,z+r*cos(t)));uv.append((j/n*2,yy/2.3))
 f=[]
 for i in range(len(sections)-1):
  for j in range(n):a=i*n+j;b=i*n+(j+1)%n;f.append((a,b,b+n,a+n))
 return mesh(name,v,f,g,mat,uv)
engine=0
sockets=[]
for side,label in [(1,'L'),(-1,'R')]:
 for pod,x,front in [(1,8.7,-3.8),(2,17.0,1.5)]:
  top=wingpoint(side,x,.2)[2];z=top-1.7
  # shaped swept support pylon with real thickness
  pv=[(side*(x+dx),yy,zz) for dx in [-.14,.14] for yy,zz in [(front+1.0,z+.5),(front+3.8,z+.5),(-7+.64*x+3.0,top-.05),(-7+.64*x+.4,top-.05)]]
  o=mesh('Engine swept pylon',pv,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)]);bevel(o,.06)
  for dx in [-.73,.73]:
   engine+=1;xx=side*(x+dx)
   tube('Engine cowling',xx,front,z,[(0,.57),(.06,.64),(.17,.68),(.4,.70),(1.0,.71),(2.4,.68),(3.3,.59),(3.75,.49)],n=80)
   tube('Rolled intake lip',xx,front,z,[(.2,.58),(.02,.56),(-.035,.59),(-.02,.63),(.06,.67),(.17,.68)],mat='Metal')
   tube('Intake deep duct',xx,front,z,[(.02,.56),(.45,.55),(1.0,.49)],mat='Interior')
   sphere('Fan spinner',(xx,front+.68,z),(.16,.38,.16),mat='Metal')
   for k in range(24):
    t=k*2*pi/24;pts=[]
    for r,a in [(.15,t),(.49,t+.08),(.5,t+.19),(.17,t+.21)]:pts.append((xx+r*sin(a),front+.83+.08*(r/.5),z+r*cos(a)))
    mesh('Compressor blade',pts,[(0,1,2,3)],mat='Metal')
   tube('Exhaust heatshield',xx,front,z,[(3.45,.55),(3.9,.49),(4.13,.46),(4.13,.40),(3.8,.40)],mat='Metal')
   tube('Exhaust cavity',xx,front,z,[(3.8,.40),(3.2,.28)],mat='Interior')
   sphere('Exhaust centerbody',(xx,front+3.8,z),(.17,.48,.17),mat='Metal')
   for yy in [.65,2.5,3.35]:
    rr=.712 if yy<2.6 else .59;line('Nacelle service band',[(xx+rr*sin(j*2*pi/64),front+yy,z+rr*cos(j*2*pi/64)) for j in range(65)],.012)
   sockets.append(('Engine_'+str(engine),(xx,front+4.14,z)))
  sockets.append(('Hardpoint_EnginePod_'+label+str(pod),(side*x,front+2.8,top-.2)))
 # outboard streamlined outrigger fairing
 x=23.2;q=wingpoint(side,x,.52)
 sphere('Outrigger fairing',(q[0],q[1],q[2]-.32),(.32,1.65,.35))
 g='Gear_Outrigger_'+label;pivots[g]=(q[0],q[1],q[2]-.3)
 rod('Outrigger oleo',(q[0],q[1],q[2]-.3),(q[0],q[1]+.3,.42),.07,g)
 rod('Outrigger trailing brace',(q[0],q[1]-.65,q[2]-.2),(q[0],q[1]+.22,.9),.045,g)
 sockets.append(('Hardpoint_'+label,(side*5.5,-1.0,4.7)))
 sockets.append(('Light_Nav_'+label,wingpoint(side,28.05,.30)))
 sphere('Wingtip navigation lens',wingpoint(side,28.1,.3),(.075,.17,.05),mat='Lamps')
 # Fuel tank bulge under outer wing, distinct slender shape.
 q=wingpoint(side,24.0,.42);sphere('External wing tank',(q[0],q[1],q[2]-.38),(.41,2.5,.4))

# Tires are lathed with rounded shoulders, grooves, recessed hubs and bolts.
def wheel(g,x,y,z,r=.77,width=.37):
 profile=[(-width*.5,.50*r),(-width*.56,.76*r),(-width*.48,.91*r),(-width*.29,.986*r),(0,r),(width*.29,.986*r),(width*.48,.91*r),(width*.56,.76*r),(width*.5,.50*r)]
 v=[];n=64
 for ax,rr in profile:
  for j in range(n):t=j*2*pi/n;v.append((x+ax,y+rr*sin(t),z+rr*cos(t)))
 f=[]
 for i in range(len(profile)-1):
  for j in range(n):a=i*n+j;b=i*n+(j+1)%n;f.append((a,b,b+n,a+n))
 mesh('Pneumatic tire',v,f,g,'Rubber')
 for ax in [-.12,-.04,.04,.12]:
  line('Tread circumferential groove',[(x+ax,y+(r-.012)*sin(j*2*pi/80),z+(r-.012)*cos(j*2*pi/80)) for j in range(81)],.009,g,'Interior')
 for s in [-1,1]:
  rod('Recessed alloy hub',(x+s*width*.44,y,z),(x+s*width*.53,y,z),r*.49,g,'Metal',48)
  rod('Axle cap',(x+s*width*.53,y,z),(x+s*width*.61,y,z),r*.18,g,'Metal',32)
  for k in range(8):
   t=k*pi/4;rod('Wheel nut',(x+s*width*.535,y+r*.32*sin(t),z+r*.32*cos(t)),(x+s*width*.57,y+r*.32*sin(t),z+r*.32*cos(t)),.028,g,'Metal',8)
for side,label in [(1,'L'),(-1,'R')]:
 for front,y in [(True,-10.4),(False,5.1)]:
  g=('Gear_Nose' if side==1 else 'Gear_Front_R') if front else 'Gear_'+label
  x=side*.99;pivots[g]=(x,y,2.75)
  rod('Main oleo upper',(x,y,2.65),(x,y,1.27),.17,g,'Paint',24)
  rod('Main polished piston',(x,y,1.7),(x,y,.78),.105,g,'Metal',24)
  rod('Main bogie beam',(x,y-.55,.78),(x,y+.55,.78),.13,g,'Metal',24)
  rod('Drag brace',(x,y-1.0,2.3),(x,y,1.0),.075,g,'Metal')
  for yy in [y-.52,y+.52]:
   for xx in [x-.24,x+.24]:wheel(g,xx,yy,.76,.76,.31)
  dv=[(side*(1.64+dx),y+dy,zz) for dx in [-.045,.045] for dy,zz in [(-1.25,2.5),(1.25,2.5),(.7,1.25),(-.52,.97)]]
  door=mesh('Contoured main gear door',dv,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],g);bevel(door,.035)
  for yy in [y-.9,y+.9]:rod('Door actuator',(side*1.43,yy,2.53),(side*1.66,yy,1.9),.035,g)
for side,label in [(1,'L'),(-1,'R')]:
 q=wingpoint(side,23.2,.52);wheel('Gear_Outrigger_'+label,q[0],q[1]+.3,.42,.42,.22)
# Empty enclosed bay with ceiling, sidewalls, ribs and a real lower hull opening.
box('Bomb bay ceiling',(0,-2.05,3.5),(2.30,9.3,.09),mat='Interior')
for x in [-1.13,1.13]:
 box('Bomb bay sidewall',(x,-2.05,2.83),(.08,9.3,1.34),mat='Interior')
 rod('Bay longitudinal rail',(x*.8,-6.5,3.30),(x*.8,2.4,3.30),.055,mat='Metal')
for y in [-6.7,2.6]:box('Bomb bay end bulkhead',(0,y,2.83),(2.3,.09,1.34),mat='Interior')
for y in [-5,-2,1]:box('Bomb bay frame rib',(0,y,3.42),(2.24,.09,.14),mat='Metal')
for side in [-1,1]:line('Bay opening coaming',[skin(-6.7+i*9.3/40,pi+side*.655,.04) for i in range(41)],.045,mat='Paint')
# Closed curved bomb-bay doors, separate hinges, matching the lower hull.
for side,label in [(1,'L'),(-1,'R')]:
 g='BayDoor_'+label;v=[];ny=32;nt=12
 for i in range(ny+1):
  y=-6.7+i*9.3/ny
  for j in range(nt+1):t=pi+side*(.015+j*.64/nt);v.append(skin(y,t,.018))
 f=[]
 for i in range(ny):
  for j in range(nt):a=i*(nt+1)+j;f.append((a,a+1,a+nt+2,a+nt+1))
 o=mesh(g,v,f,g);mod=o.modifiers.new('Door skin thickness','SOLIDIFY');mod.thickness=.04;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name);pivots[g]=skin(-6.7,pi+side*.655,.018)
 for y in [-5.8,-2.0,1.6]:rod('Bay door hinge',skin(y-.13,pi+side*.655,.05),skin(y+.13,pi+side*.655,.05),.045,g)
# Small aerials, radome blisters and airframe fittings.
for y in [-16,-4,10,14]:
 rx,rz,z=prof(y);o=box('Blade aerial',(0,y,z+rz+.18),(.07,.45,.4),w=.025)
for side in [-1,1]:
 sphere('Nose cheek sensor',(side*.96,-22.6,3.14),(.16,.69,.14))
 sphere('Aft electronics fairing',(side*1.49,8.1,3.85),(.18,.55,.26))
 rod('Nose pitot',(side*.81,-22.8,3.24),(side*.81,-23.47,3.2),.019)
for x in [-.42,.42]:rod('Tail discharge tube',(x,23.55,4.48),(x,24.24,4.48),.085)
sphere('Beacon lens',(0,-5,5.82),(.10,.15,.075),mat='Lamps')
sockets += [('Seat_Pilot',(.65,-19.9,4.6)),('Seat_Copilot',(-.65,-19.9,4.6)),('Seat_Navigator',(.55,-17.5,3.15)),('Center_Of_Mass',(0,-1.5,4.1)),('Light_Beacon',(0,-5,5.82))]

# Small recessed ventilation banks and flush fasteners establish full-size sheet metal.
for side in [-1,1]:
 for cy in [-15.2,18.4,20.6]:
  for j in range(7):
   y=cy+j*.11;t=side*1.40
   a=skin(y,t-.075,.014);bb=skin(y,t+.075,.014);line('Recessed vent slot',[a,bb],.023,mat='Interior')
 for y in [-16,-11,-6,-1,4,9,14,19]:
  for j in range(10):
   t=side*(.75+j*.13);co=skin(y+.04,t,.013);sphere('Flush skin rivet',co,(.017,.017,.017),mat='Metal',seg=6,rings=4)
# Join by articulated assembly; retain all authored origins in world coordinates.
assets=[]
for name,obs in groups.items():
 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=name
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 S.cursor.location=pivots.get(name,(0,0,0));bpy.ops.object.origin_set(type='ORIGIN_CURSOR');assets.append(o)
 # Weld exact airfoil leading/trailing edges and remove collapsed cap faces.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.00001);bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=.00001);bm.to_mesh(o.data);bm.free()
 count=sum(len(p.vertices)-2 for p in o.data.polygons)
 if count>3000:
  md=o.modifiers.new('Game tessellation','DECIMATE');md.ratio=.49 if name=='Fuselage' else .39;bpy.ops.object.modifier_apply(modifier=md.name)
 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-10]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free()
 for vert in o.data.vertices:
  wp=o.matrix_world@vert.co
  if wp.z<0:wp.z=0;vert.co=o.matrix_world.inverted()@wp
 # Recalculate all outward normals on connected surfaces.
 bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.mesh.normals_make_consistent(inside=False);bpy.ops.object.mode_set(mode='OBJECT')
for name,pos in sockets:
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=pos;o.empty_display_size=.25;assets.append(o)
bpy.context.view_layer.update()
tri=sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in assets if o.type=='MESH')
pts=[o.matrix_world@Vector(c) for o in assets 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)]
def yup(p):return [round(float(p[0]),5),round(float(p[2]),5),round(float(-p[1]),5)]
manifest={'units':'metres','axes':'+Z nose, +Y up, +X aircraft left','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,'position':yup(o.matrix_world.translation)} for o in assets],'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS]}
with open(os.path.join(ROOT,'nodes.json'),'w') as f:json.dump(manifest,f,indent=2)
# Export only game objects. Bake graph is portable, tint lives in glTF factor.
paint=M['Paint'];nt=paint.node_tree;b=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');tex=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image.name=='Paint_base');nt.links.new(tex.outputs[0],b.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in assets:o.select_set(True)
path=os.path.join(ROOT,'b52-bomber.glb')
bpy.ops.export_scene.gltf(filepath=path,export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO')
# Set tint explicitly: no exporter-version-dependent node recognition.
data=open(path,'rb').read();jl,jt=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+jl]);rest=data[20+jl:]
for mat in doc['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
 if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
open(path,'wb').write(struct.pack('<III',0x46546c67,2,12+8+len(js)+len(rest))+struct.pack('<II',len(js),0x4e4f534a)+js+rest)
nt.links.new(nt.nodes['PaintTint'].outputs[0],b.inputs['Base Color'])
print('EXPORT_OK',tri,os.path.getsize(path),manifest['bounding_box'],flush=True)
with open(os.path.join(ROOT,'NOTES.md'),'w') as f:
 f.write('B-52-style bomber, metric gear-down asset with eight engines, cockpit, bay doors and articulated controls.\n')
 f.write(f'Triangles: {tri:,}; GLB: {os.path.getsize(path)/1048576:.2f} MiB.\n')
 f.write('Materials: Paint, Metal, Rubber, Interior, Glass (BLEND), Lamps, Seams.\n')
 f.write('Textures: shared Cycles-baked 1024 px base/normal tiles and 512 px ORM; Paint is neutral with runtime tint.\n')
 f.write('Poly Haven CC0: metal_plate, rubberized_track; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/rubberized_track .\n')
 f.write('Differences: generic unmarked aircraft; simplified cockpit and undercarriage mechanisms; bomb bay closed, no internal weapons.\n')
# Soft daylight studio, all excluded from asset export.
world=bpy.data.worlds.new('Soft daylight');S.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.8,1);world.node_tree.nodes['Background'].inputs[1].default_value=.65
bpy.ops.mesh.primitive_plane_add(size=2000,location=(0,0,-.045));ground=bpy.context.object;ground.name='Render floor'
gm=bpy.data.materials.new('Render floor');gm.diffuse_color=(.32,.35,.38,1);ground.data.materials.append(gm)
def area(name,loc,power,size):
 bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.name=name;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.rotation_euler=(Vector((0,0,2))-o.location).to_track_quat('-Z','Y').to_euler()
area('Daylight key',(5,-28,46),16000,30);area('Sky fill',(-30,8,30),10000,25)
bpy.ops.object.light_add(type='SUN',location=(0,0,40));sun=bpy.context.object;sun.rotation_euler=(.4,-.35,-.45);sun.data.energy=1.6;sun.data.angle=.22
bpy.ops.object.camera_add();cam=bpy.context.object;S.camera=cam;cam.data.type='ORTHO';cam.data.lens=50

def view(loc,target,scale):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale

def hero_view():
 view((64,-77,43),(0,0,3.0),71)
 rot=cam.rotation_euler.to_matrix();inv=rot.transposed()
 points=[inv@(o.matrix_world@v.co-cam.location) for o in assets if o.type=='MESH' for v in o.data.vertices]
 xmin=min(v.x for v in points);xmax=max(v.x for v in points);ymin=min(v.y for v in points);ymax=max(v.y for v in points)
 cam.location+=rot@Vector(((xmin+xmax)/2,(ymin+ymax)/2,0))
 cam.data.ortho_scale=max(xmax-xmin,(ymax-ymin)*1920/1080)*1.13

def render(name,w=1920,h=1080,samples=24):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=os.path.join(ROOT,name);bpy.ops.render.render(write_still=True)
hero_view()
# Clay override uses exactly the final camera.
clay=bpy.data.materials.new('Clay preview');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.28,.28,.28,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.7;S.view_layers[0].material_override=clay
render('clay.png',1920,1080,16);S.view_layers[0].material_override=None
render('final.png')
view((75,0,5.5),(0,0,5.5),57);render('side.png',1920,1080,20)
hero_view()
# Six identical camera views; no additional image source or external dependency.
frames=[]
for name,col in PAINTS:
 nt.nodes['PaintTint'].inputs[2].default_value=(*col,1);render('_paint.png',640,360,12)
 im=bpy.data.images.load(os.path.join(ROOT,'_paint.png'),check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);frames.append(arr);bpy.data.images.remove(im)
out=np.zeros((720,1920,4),dtype=np.float32)
for i,arr in enumerate(frames):out[(1-i//3)*360:(2-i//3)*360,(i%3)*640:(i%3+1)*640]=arr
im=bpy.data.images.new('Paint variants',1920,720,alpha=True);im.pixels.foreach_set(out.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save();os.remove(os.path.join(ROOT,'_paint.png'))
nt.nodes['PaintTint'].inputs[2].default_value=(*PAINTS[0][1],1)
if '--qa-close' in sys.argv:
 view((12,-31,10),(0,-19.5,3.8),13);render('close.png',1280,960,32)
print('COMPLETE',flush=True)
