"""F-16 style game asset. Blender 5.2; clean scene -> tiled Cycles bakes -> GLB.
Run: blender -b --python-exit-code 1 --python build_f16-fighter.py
Axes in authoring: X aircraft left, -Y nose, Z up. Export glTF +Z nose/+Y up.
Texture source: Poly Haven rusty_metal_03, CC0, downloaded on demand.
"""
import bpy, bmesh, math, os, json, urllib.request, subprocess, struct, random
from pathlib import Path
from mathutils import Vector
from math import sin, cos, pi, sqrt
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(16)
print('BUILD_REVISION shoulder-tangents-v7',flush=True)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for block in list(bpy.data.materials): bpy.data.materials.remove(block)
S=bpy.context.scene
S.unit_settings.system='METRIC'; S.unit_settings.scale_length=1
S.render.engine='CYCLES'; S.cycles.samples=24; S.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 in {'OPTIX','CUDA'}
S.cycles.device='GPU'
print('DEVICES',[(d.name,d.type,d.use) for d in prefs.devices],flush=True)
S.render.image_settings.file_format='PNG'; S.render.image_settings.color_mode='RGBA'
S.view_settings.view_transform='AgX'
# Always derive all outputs anew, source downloads are the only cache.
for kind in ['diff','nor_gl','arm']:
 p=TEX/f'rusty_metal_03_{kind}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/rusty_metal_03/rusty_metal_03_{kind}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
  with urllib.request.urlopen(req,timeout=90) as r: p.write_bytes(r.read())
SRC={k:bpy.data.images.load(str(TEX/f'rusty_metal_03_{k}_1k.jpg'),check_existing=True) for k in ['diff','nor_gl','arm']}
SRC['nor_gl'].colorspace_settings.name='Non-Color'; SRC['arm'].colorspace_settings.name='Non-Color'
M={}; groups={}; asset=[]; sockets=[]
def active(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def material(name, color, rough, metal=0):
 m=bpy.data.materials.new(name); m.use_nodes=True
 p=m.node_tree.nodes.get('Principled BSDF'); p.inputs['Base Color'].default_value=(*color,1); p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal
 M[name]=m;return m
material('Paint',(.40,.42,.45),.6)
material('Metal',(.28,.30,.32),.32,.9)
material('Rubber',(.012,.015,.017),.85)
material('Interior',(.025,.033,.038),.72)
material('Composite',(.20,.23,.25),.58,.05)
material('Glass',(.22,.32,.36),.10,.1)
material('Lamps',(.7,.1,.025),.25,.1)
PAINTS=[{'name':'Air-superiority light grey','linear_rgb':[.40,.42,.45]}, {'name':'Dark grey','linear_rgb':[.16,.18,.205]}, {'name':'Navy blue-grey','linear_rgb':[.13,.19,.25]}, {'name':'Desert sand','linear_rgb':[.55,.40,.24]}, {'name':'Olive','linear_rgb':[.20,.25,.145]}, {'name':'Black','linear_rgb':[.035,.043,.053]}]
# Shared 2 m tile bakes retain texel density instead of stretching an aircraft atlas.
def bake_tile(m):
 name=m.name; nt=m.node_tree;n=nt.nodes;l=nt.links;p=n.get('Principled BSDF');out=n.get('Material Output')
 base=tuple(p.inputs['Base Color'].default_value[:3]);rough=p.inputs['Roughness'].default_value;metal=p.inputs['Metallic'].default_value
 uv=n.new('ShaderNodeTexCoord')
 tex=n.new('ShaderNodeTexImage');tex.image=SRC['diff'];l.new(uv.outputs['UV'],tex.inputs['Vector'])
 bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.015;ramp.color_ramp.elements[1].position=.36
 low=.70 if name=='Paint' else .66;hi=.99 if name=='Paint' else 1.06
 ramp.color_ramp.elements[0].color=(low,low,low,1);ramp.color_ramp.elements[1].color=(hi,hi,hi,1);l.new(bw.outputs[0],ramp.inputs[0])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=7;noise.inputs['Detail'].default_value=4;noise.inputs['Roughness'].default_value=.75;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 nr=n.new('ShaderNodeValToRGB');nr.color_ramp.elements[0].color=(.70,.70,.70,1);nr.color_ramp.elements[1].color=(1,1,1,1);l.new(noise.outputs['Fac'],nr.inputs[0])
 mult=n.new('ShaderNodeMixRGB');mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;l.new(ramp.outputs[0],mult.inputs[1]);l.new(nr.outputs[0],mult.inputs[2])
 tint=n.new('ShaderNodeMixRGB');tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;l.new(mult.outputs[0],tint.inputs[1]);tint.inputs[2].default_value=(*( (1,1,1) if name=='Paint' else base),1)
 normaltex=n.new('ShaderNodeTexImage');normaltex.image=SRC['nor_gl'];l.new(uv.outputs['UV'],normaltex.inputs['Vector'])
 norm=n.new('ShaderNodeNormalMap');norm.inputs['Strength'].default_value=.035 if name=='Paint' else (.008 if name=='Glass' else .12);l.new(normaltex.outputs[0],norm.inputs['Color']);l.new(norm.outputs[0],p.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=SRC['arm'];l.new(uv.outputs['UV'],arm.inputs['Vector']);sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
 rr=n.new('ShaderNodeMath');rr.operation='MULTIPLY_ADD';rr.inputs[1].default_value=.18;rr.inputs[2].default_value=max(.06,rough-.1);l.new(sep.outputs['Green'],rr.inputs[0])
 comb=n.new('ShaderNodeCombineColor');comb.inputs['Red'].default_value=1;comb.inputs['Blue'].default_value=metal;l.new(rr.outputs[0],comb.inputs['Green'])
 if name=='Lamps':
  coord=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],coord.inputs[0])
  palette=n.new('ShaderNodeValToRGB');palette.color_ramp.interpolation='CONSTANT'
  palette.color_ramp.elements[0].position=0;palette.color_ramp.elements[0].color=(.8,.006,.003,1)
  palette.color_ramp.elements[1].position=.333;palette.color_ramp.elements[1].color=(.004,.65,.06,1)
  palette.color_ramp.elements.new(.667).color=(1,.91,.72,1)
  l.new(coord.outputs['X'],palette.inputs[0]);l.new(palette.outputs[0],tint.inputs[2])
 emit=n.new('ShaderNodeEmission');l.new(emit.outputs[0],out.inputs[0])
 bpy.ops.mesh.primitive_plane_add(size=2);plane=bpy.context.object;plane.name='BakeFixture';plane.data.materials.append(m)
 target=n.new('ShaderNodeTexImage')
 baked={}
 for channel,res in [('Base',1024 if name in ['Paint','Metal','Composite','Rubber','Interior'] else 64),('Normal',1024 if name in ['Paint','Metal','Composite'] else 256),('ORM',512 if name in ['Paint','Metal','Composite','Rubber','Interior'] else 64)]:
  img=bpy.data.images.new(f'{name}_{channel}',width=res,height=res,alpha=False)
  if channel!='Base':img.colorspace_settings.name='Non-Color'
  target.image=img;n.active=target
  for node in n:node.select=False
  target.select=True
  if channel=='Normal':
   l.new(p.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL',margin=8)
  else:
   l.new(emit.outputs[0],out.inputs[0]);l.new(tint.outputs[0] if channel=='Base' else comb.outputs[0],emit.inputs[0]);bpy.ops.object.bake(type='EMIT',margin=8)
  ext='jpg' if channel=='Base' else 'png'
  img.filepath_raw=str(TEX/f'{name}_{channel}.{ext}');img.file_format='JPEG' if channel=='Base' else 'PNG';img.save();baked[channel]=img
 bpy.data.objects.remove(plane,do_unlink=True)
 n.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
 al=n.new('ShaderNodeTexImage');al.image=baked['Base'];l.new(al.outputs['Color'],p.inputs['Base Color'])
 if name=='Paint':
  mix=n.new('ShaderNodeMixRGB');mix.name='PaintFactor';mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*PAINTS[0]['linear_rgb'],1);l.new(al.outputs[0],mix.inputs[1]);l.new(mix.outputs[0],p.inputs['Base Color'])
 no=n.new('ShaderNodeTexImage');no.image=baked['Normal'];nm=n.new('ShaderNodeNormalMap');l.new(no.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
 orm=n.new('ShaderNodeTexImage');orm.image=baked['ORM'];sp=n.new('ShaderNodeSeparateColor');l.new(orm.outputs[0],sp.inputs[0]);l.new(sp.outputs['Green'],p.inputs['Roughness']);l.new(sp.outputs['Blue'],p.inputs['Metallic'])
 # Exporter recognizes glTF occlusion via its canonical settings group.
 if 'glTF Material Output' not in bpy.data.node_groups:
  ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 ao=n.new('ShaderNodeGroup');ao.node_tree=bpy.data.node_groups['glTF Material Output'];l.new(sp.outputs['Red'],ao.inputs['Occlusion'])
 if name=='Glass':
  p.inputs['Alpha'].default_value=.38;p.inputs['Roughness'].default_value=.10
  for link in list(p.inputs['Roughness'].links):l.remove(link)
  m.surface_render_method='DITHERED';m.diffuse_color=(*base,.38)
 if name=='Lamps':l.new(al.outputs[0],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=2
 m.diffuse_color=(*base, .38 if name=='Glass' else 1)
 print('BAKED',name,flush=True)

# Mesh utilities: shared vertices, explicit thickness, real tiny chamfers.
def reg(o,group,mat):
 o.data.materials.append(M[mat]);groups.setdefault(group,[]).append(o)
 if mat=='Lamps' and o.data.uv_layers:
  u=.50 if 'navigation' in o.name and o.location.x<0 else (.833 if 'Landing light lens' in o.name else .166)
  for loop in o.data.uv_layers.active.data:loop.uv=(u,.5)
 return o

def mesh(name,v,f,group='Fuselage',mat='Paint',smooth=True,uv=None):
 me=bpy.data.meshes.new(name);me.from_pydata(v,[],f);me.update();o=bpy.data.objects.new(name,me);S.collection.objects.link(o)
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free()
 for p in me.polygons:p.use_smooth=smooth
 u=me.uv_layers.new(name='UVMap')
 for poly in me.polygons:
  axis=max(range(3),key=lambda k:abs(poly.normal[k]));axes=[k for k in range(3) if k!=axis]
  for li in poly.loop_indices:
   vi=me.loops[li].vertex_index;co=me.vertices[vi].co
   u.data[li].uv=uv[vi] if uv else (co[axes[0]]*.5,co[axes[1]]*.5)
 return reg(o,group,mat)

def bevel(o,w=.01,segs=2):
 active(o);mod=o.modifiers.new('Machined edge radius','BEVEL');mod.width=w;mod.segments=segs;mod.limit_method='ANGLE';bpy.ops.object.modifier_apply(modifier=mod.name)
 return o

def cube(name,loc,scale,mat='Paint',group='Fuselage',be=.01):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);reg(o,group,mat)
 if be:bevel(o,be,3)
 return o

def ellipsoid(name,loc,scale,mat='Paint',group='Fuselage',seg=48,rings=24):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);reg(o,group,mat)
 for p in o.data.polygons:p.use_smooth=True
 return o

def rod(name,a,b,r,mat='Metal',group='Fuselage',r2=None,n=24):
 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();active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);reg(o,group,mat);bevel(o,min(r*.13,.008),2)
 for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
 return o

def line(name,pts,r=.004,mat='Composite',group='Fuselage',cyclic=False):
 c=bpy.data.curves.new(name,'CURVE');c.dimensions='3D';c.resolution_u=1;c.bevel_depth=r;c.bevel_resolution=1
 s=c.splines.new('POLY');s.points.add(len(pts)-1)
 for p,co in zip(s.points,pts):p.co=(*co,1)
 s.use_cyclic_u=cyclic;o=bpy.data.objects.new(name,c);S.collection.objects.link(o);active(o);bpy.ops.object.convert(target='MESH');o=bpy.context.object
 return reg(o,group,mat)

def interp(st,t):
 # Catmull-Rom interpolation of cross-section profiles.
 i=0
 while i<len(st)-2 and t>st[i+1][0]:i+=1
 a=st[max(i-1,0)];b=st[i];c=st[i+1];d=st[min(i+2,len(st)-1)];u=(t-b[0])/(c[0]-b[0]);u=max(0,min(1,u))
 return [t]+[.5*((2*b[k])+(-a[k]+c[k])*u+(2*a[k]-5*b[k]+4*c[k]-d[k])*u*u+(-a[k]+3*b[k]-3*c[k]+d[k])*u*u*u) for k in range(1,len(b))]
BODY=[(-9.82,.016,.018,1.93),(-9.4,.20,.18,1.98),(-8.7,.43,.35,2.02),(-7.8,.59,.45,2.03),(-6.6,.66,.49,2.04),(-5.3,.72,.48,2.08),(-3.9,.84,.56,2.03),(-2.3,.90,.62,1.94),(-.5,.83,.66,1.87),(1.1,.76,.66,1.86),(2.6,.70,.62,1.87),(3.9,.64,.56,1.88),(4.65,.56,.48,1.9)]
def bodypoint(y,a,offset=0):
 _,w,h,z=interp(BODY,y)
 # Broad aerodynamic shoulders flow into the wing rather than leaving a tube seam.
 blend=.64*max(0,sin(pi*(y+4.3)/7.9))**1.15 if -4.3<y<3.6 else 0
 flare=blend*math.exp(-(sin(a)/.48)**2)
 return ((w+offset+flare)*cos(a),y,z+(h+offset)*sin(a))
def loft_body():
 v=[];uv=[];f=[];na=96
 ys=sorted(set([BODY[0][0]+(BODY[-1][0]-BODY[0][0])*i/189 for i in range(190)]+[-7.4,-4.44]))
 widths=[(-7.4,.025),(-7.18,.24),(-6.83,.405),(-6.3,.45),(-5.7,.45),(-5.1,.36),(-4.56,.12),(-4.44,.025)]
 for y in ys:
  _,bw,bh,bz=interp(BODY,y)
  alpha=math.acos(min(.99,interp(widths,y)[1]/bw)) if -7.4<=y<=-4.44 else pi/3
  for j in range(na+1):
   if j<=16:a=alpha*j/16
   elif j<=32:a=alpha+(pi-2*alpha)*(j-16)/16
   elif j<=48:a=pi-alpha+alpha*(j-32)/16
   else:a=pi+(j-48)*pi/48
   v.append(bodypoint(y,a));uv.append((y*.5,a/pi*1.2))
 for i in range(len(ys)-1):
  for j in range(na):
   if -7.4<(ys[i]+ys[i+1])/2<-4.44 and 16<=j<32:continue
   k=i*(na+1)+j;f.append((k,k+1,k+na+2,k+na+1))
 f.append(tuple(range(na-1,-1,-1)));f.append(tuple((len(ys)-1)*(na+1)+j for j in range(na)))
 o=mesh('Continuous aerodynamic fuselage',v,f,uv=uv)
 o.data.materials.append(M['Composite'])
 for p in o.data.polygons:
  if p.center.y<-7.85:p.material_index=1
 # A continuous painted sill joins the exact elliptical opening to the bubble seal.
 canopy=[(-7.4,.025,2.42),(-7.18,.24,2.44),(-6.83,.405,2.46),(-6.3,.45,2.48),(-5.7,.45,2.49),(-5.1,.36,2.50),(-4.56,.12,2.50),(-4.44,.025,2.50)]
 for side,j in [(1,16),(-1,32)]:
  vv=[];ff=[]
  for i,y in enumerate(ys):
   if not -7.4<=y<=-4.44:continue
   _,w,z=interp(canopy,y);edge=v[i*(na+1)+j];vv.extend([edge,(side*w,y,z-.012)])
  for i in range(len(vv)//2-1):ff.append((2*i,2*i+1,2*i+3,2*i+2))
  rim=mesh('Continuous cockpit sill',vv,ff);active(rim);sol=rim.modifiers.new('Cockpit sill thickness','SOLIDIFY');sol.thickness=.012;bpy.ops.object.modifier_apply(modifier=sol.name)
 return o

def wingpoint(x,t,side=1,tail=False):
 if not tail:
  q=(x-.82)/(4.82-.82);le=-4.05+4.35*q;te=1.93+.18*q;z=1.93-.08*q;th=.18*(1-q)+.035*q
 else:
  q=(x-.57)/(2.98-.57);le=1.53+2.15*q;te=4.6+.18*q;z=1.73-.20*q;th=.14*(1-q)+.045*q
 # Symmetric airfoil, rounded nose with thin finite trailing edge.
 h=th*(2.7*sqrt(max(t,0))*(1-t)+.025)
 return (side*x,le+(te-le)*t,z+h),h

def wing(name,side,x0,x1,t0,t1,group='Fuselage',tail=False,nx=30,nt=34):
 v=[];uv=[];f=[]
 for layer in [1,-1]:
  for i in range(nx+1):
   x=x0+(x1-x0)*i/nx
   for j in range(nt+1):
    t=t0+(t1-t0)*(1-cos(pi*j/nt))/2;p,h=wingpoint(x,t,side,tail);v.append((p[0],p[1],p[2] if layer==1 else p[2]-2*h));uv.append((x*.5,p[1]*.5))
 count=(nx+1)*(nt+1)
 for layer in range(2):
  for i in range(nx):
   for j in range(nt):
    k=layer*count+i*(nt+1)+j;f.append((k,k+1,k+nt+2,k+nt+1))
 boundary=list(range(nt+1))+[i*(nt+1)+nt for i in range(1,nx+1)]+[nx*(nt+1)+j for j in range(nt-1,-1,-1)]+[i*(nt+1) for i in range(nx-1,0,-1)]
 for a,b in zip(boundary,boundary[1:]+boundary[:1]):f.append((a,b,b+count,a+count))
 return mesh(name,v,f,group,uv=uv)

def foilpoly(name,points,thick=.08,mat='Paint',group='Fuselage'):
 # Convex plate with tapered leading edges and a real bevel.
 v=[(x,y,z+off) for off in [-thick/2,thick/2] for x,y,z in points];n=len(points);f=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]
 f += [(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 return bevel(mesh(name,v,f,group,mat,smooth=False),min(.025,thick*.32),3)

def finpoly(name,pts,thick,group='Fuselage',mat='Paint'):
 v=[(xoff,y,z) for xoff in [-thick/2,thick/2] for y,z in pts];n=len(pts);f=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 return bevel(mesh(name,v,f,group,mat,False),min(.025,thick*.3),3)

def empty(name,loc):
 o=bpy.data.objects.new(name,None);S.collection.objects.link(o);o.location=loc;o.empty_display_size=.18;o.empty_display_type='ARROWS';sockets.append(o);return o

def intake():
 # Continuous rounded lip flowing into the chin nacelle; actual dark duct.
 na=80;sections=[(-5.65,.60,.36,1.27),(-5.57,.65,.40,1.27),(-5.38,.66,.42,1.29),(-4.7,.68,.43,1.30),(-3.0,.68,.44,1.30),(-1.4,.60,.4,1.36),(.7,.42,.33,1.48)]
 def loop(y,w,h,z):
  return [(w*cos(2*pi*j/na),y,z+h*(sin(2*pi*j/na) if sin(2*pi*j/na)<.50 else .50+.20*(sin(2*pi*j/na)-.50))) for j in range(na)]
 v=[]
 for sec in sections:v+=loop(*sec)
 f=[]
 for i in range(len(sections)-1):
  for j in range(na):f.append((i*na+j,i*na+(j+1)%na,(i+1)*na+(j+1)%na,(i+1)*na+j))
 mesh('Chin intake outer fairing',v,f)
 # rolled aluminium lip is painted, with narrow exposed edge.
 ring=loop(-5.659,.599,.36,1.27);line('Rounded intake lip',ring,.028,'Paint',cyclic=True)
 sections=[(-5.651,.566,.326,1.27),(-5.36,.56,.32,1.28),(-4.85,.48,.28,1.34),(-4.30,.28,.20,1.45)]
 v=[]
 for sec in sections:v+=loop(*sec)
 f=[]
 for i in range(len(sections)-1):
  for j in range(na):f.append((i*na+j,i*na+(j+1)%na,(i+1)*na+(j+1)%na,(i+1)*na+j))
 f.append(tuple((len(sections)-1)*na+j for j in range(na)));mesh('Recessed intake duct',v,f,mat='Interior')
 foilpoly('Intake splitter', [(-.56,-5.70,1.66),(.56,-5.70,1.66),(.63,-4.4,1.7),(-.63,-4.4,1.7)],.035)
 rod('Intake central support',(0,-5.43,1.55),(0,-5.43,1.78),.025,'Paint')

def cockpit():
 # A single uninterrupted transparent bubble, only its perimeter is framed.
 stations=[(-7.40,.025,2.42,.025),(-7.18,.24,2.44,.20),(-6.83,.405,2.46,.51),(-6.3,.45,2.48,.66),(-5.7,.45,2.49,.65),(-5.1,.36,2.50,.44),(-4.56,.12,2.50,.10),(-4.44,.025,2.50,.015)]
 v=[];f=[];uv=[];ny=66;na=48
 for i in range(ny):
  y=stations[0][0]+(stations[-1][0]-stations[0][0])*i/(ny-1);_,w,z,h=interp(stations,y)
  for j in range(na+1):
   a=pi*j/na;v.append((w*cos(a),y,z+h*sin(a)));uv.append((i/(ny-1),j/na))
 for i in range(ny-1):
  for j in range(na):
   k=i*(na+1)+j;f.append((k,k+1,k+na+2,k+na+1))
 mesh('Frameless bubble',v,f,'Canopy','Glass',uv=uv)
 for side in [-1,1]:
  pts=[]
  for i in range(90):
   y=-7.4+2.96*i/89;_,w,z,h=interp(stations,y);pts.append((side*w,y,z))
  line('Canopy sill seal',pts,.034,'Rubber','Canopy');line('Painted canopy rim',[(x,y,z-.033) for x,y,z in pts],.028,'Paint','Canopy')
 cube('Cockpit tub',(0,-5.95,2.28),(.76,2.25,.28),'Interior')
 seat=cube('Ejection seat back',(0,-5.62,2.62),(.48,.19,.75),'Interior',be=.05);seat.rotation_euler.x=math.radians(-14)
 cube('Seat cushion',(0,-5.94,2.36),(.47,.52,.15),'Rubber',be=.045)
 cube('Headrest',(0,-5.55,2.90),(.32,.18,.23),'Interior',be=.04)
 for side in [-1,1]:
  line('Harness',[(side*.13,-5.75,2.92),(side*.15,-5.87,2.65),(side*.13,-6.06,2.44)],.022,'Composite')
  cube('Side console',(side*.33,-6.12,2.45),(.13,.75,.17),'Interior')
  for i in range(8):cube('Console switch',(side*.33,-6.40+i*.075,2.545),(.025,.018,.014),'Metal',be=.003)
 panel=cube('Instrument coaming',(0,-6.7,2.58),(.61,.26,.23),'Interior',be=.07)
 for x in [-.19,0,.19]:cube('Recessed instrument screen',(x,-6.545,2.63),(.135,.01,.105),'Glass',be=.008)
 rod('Control stick',(0,-6.13,2.40),(0,-6.16,2.64),.023,'Interior')
 # HUD support and transparent combiner.
 for x in [-.10,.10]:rod('HUD support',(x,-6.79,2.67),(x,-6.83,2.87),.013,'Metal')
 hud=cube('HUD combiner',(0,-6.84,2.84),(.23,.015,.19),'Glass',be=.014);hud.rotation_euler.x=.2
 for side in [-1,1]:rod('Canopy hinge',(side*.28,-4.64,2.49),(side*.28,-4.45,2.49),.033,'Metal','Canopy')
 empty('Seat_Pilot',(0,-5.85,2.44))

def nozzle():
 # Serried individually overlapping afterburner petals and recessed hot section.
 for i in range(24):
  a=2*pi*i/24;da=2*pi/24*.94
  pts=[]
  for y,r in [(3.95,.64),(4.45,.62),(4.9068,.49)]:
   for aa in [a-da/2,a+da/2]:pts.append((r*cos(aa),y,1.90+r*sin(aa)))
  o=mesh('Exhaust petal',pts,[(0,1,3,2),(2,3,5,4)],mat='Metal');active(o);s=o.modifiers.new('Petal gauge','SOLIDIFY');s.thickness=.016;bpy.ops.object.modifier_apply(modifier=s.name);bevel(o,.005,2)
  rod('Nozzle actuator',(.65*cos(a),3.93,1.9+.65*sin(a)),(.65*cos(a),4.32,1.9+.65*sin(a)),.018,'Metal')
 for y,r in [(3.88,.645),(4.01,.65),(4.86,.477)]:line('Exhaust collar',[(r*cos(2*pi*i/96),y,1.9+r*sin(2*pi*i/96)) for i in range(96)],.018,'Metal',cyclic=True)
 v=[];f=[];n=80
 for y,r in [(4.89,.475),(4.65,.44),(3.85,.40)]:
  for i in range(n):v.append((r*cos(2*pi*i/n),y,1.9+r*sin(2*pi*i/n)))
 for k in range(2):
  for i in range(n):f.append((k*n+i,k*n+(i+1)%n,(k+1)*n+(i+1)%n,(k+1)*n+i))
 mesh('Afterburner inner liner',v,f,mat='Interior')
 rod('Dark turbine cavity',(0,3.83,1.9),(0,3.86,1.9),.40,'Interior',n=64)
 for i in range(16):
  a=2*pi*i/16;rod('Flameholder spoke',(.14*cos(a),4.06,1.9+.14*sin(a)),(.39*cos(a+.1),4.06,1.9+.39*sin(a+.1)),.013,'Metal')
 empty('Nozzle',(0,4.93,1.9))

def wheel(name,loc,r,width,group):
 # Profile-lathed tyre and metal hub: axle X, lower profile touches z=0.
 x,y,z=loc;prof=[(-.5*width,.68*r),(-.53*width,.81*r),(-.43*width,.95*r),(-.27*width,r),(.27*width,r),(.43*width,.95*r),(.53*width,.81*r),(.5*width,.68*r)]
 v=[];f=[];n=64
 for dx,rad in prof:
  for j in range(n):a=2*pi*j/n;v.append((x+dx,y+rad*cos(a),z+rad*sin(a)))
 for i in range(len(prof)):
  for j in range(n):f.append((i*n+j,i*n+(j+1)%n,((i+1)%len(prof))*n+(j+1)%n,((i+1)%len(prof))*n+j))
 mesh(name,v,f,group,'Rubber')
 for dx in [-width*.52,width*.52]:
  rod('Wheel rim',(x+dx-.014,y,z),(x+dx+.014,y,z),r*.67,'Metal',group,n=48)
  rod('Recessed hub',(x+dx-.017,y,z),(x+dx+.017,y,z),r*.42,'Interior',group,n=40)
  rod('Axle cap',(x+dx-.020,y,z),(x+dx+.020,y,z),r*.21,'Metal',group,n=32)
  for j in range(8):
   a=2*pi*j/8;rod('Hub bolt',(x+dx-.021,y+r*.31*cos(a),z+r*.31*sin(a)),(x+dx+.021,y+r*.31*cos(a),z+r*.31*sin(a)),.016,'Metal',group,n=8)
 for off in [-.22,0,.22]:
  pts=[(x+width*off,y+(r-.006)*cos(2*pi*j/96),z+(r-.006)*sin(2*pi*j/96)) for j in range(96)]
  line('Tread groove',pts,.004,'Interior',group,True)

def gear():
 for side,label in [(1,'L'),(-1,'R')]:
  g='Gear_'+label;x=side*1.24;r=.39;wheel('Main tyre',(x,0,r),r,.29,g)
  rod('Main axle',(x-side*.28,0,r),(x+side*.15,0,r),.085,'Metal',g)
  rod('Main shock lower',(x-side*.22,0,.43),(side*.91,-.11,1.00),.055,'Metal',g)
  rod('Main shock outer',(side*.91,-.11,.83),(side*.63,-.30,1.62),.085,'Paint',g)
  rod('Drag brace',(side*.67,.65,1.45),(x-side*.23,0,.62),.041,'Metal',g)
  rod('Forward brace',(side*.68,-.72,1.5),(x-side*.23,0,.67),.039,'Paint',g)
  rod('Scissor linkage',(side*.94,-.15,.98),(side*1.15,-.28,.75),.025,'Metal',g)
  rod('Scissor linkage',(side*1.15,-.28,.75),(side*1.09,-.03,.60),.025,'Metal',g)
  line('Hydraulic brake hose',[(side*.66,-.37,1.51),(side*.85,-.29,1.03),(side*1.04,-.20,.65),(x,-.12,.42)],.014,'Rubber',g)
  door=foilpoly('Main gear door',[(side*.88,-.66,1.35),(side*1.46,-.60,.72),(side*1.46,.48,.74),(side*.88,.60,1.36)],.04,group=g)
  for yy in [-.43,.28]:rod('Gear door hinge',(side*.77,yy,1.48),(side*.99,yy,1.23),.035,'Metal',g)
  # Recessed dark well, visibly above each leg.
  cube('Main gear bay',(side*.63,-.05,1.32),(.44,1.37,.04),'Interior')
 g='Gear_Nose';wheel('Nose tyre',(0,-6.37,.265),.265,.17,g)
 for side in [-1,1]:
  rod('Nose fork',(side*.13,-6.37,.265),(side*.13,-6.48,.52),.034,'Paint',g)
  rod('Fork shoulder',(side*.13,-6.48,.52),(0,-6.48,.65),.034,'Metal',g)
 rod('Nose oleo piston',(0,-6.48,.57),(0,-6.48,.91),.044,'Metal',g)
 rod('Nose oleo barrel',(0,-6.48,.88),(0,-6.39,1.59),.065,'Paint',g)
 rod('Nose drag stay',(0,-5.81,1.50),(0,-6.43,.69),.033,'Metal',g)
 line('Nose hydraulic hose',[(.065,-6.41,1.54),(.08,-6.52,.91),(.14,-6.49,.51)],.011,'Rubber',g)
 door=cube('Nose gear front door',(0,-6.73,1.12),(.37,.045,.61),'Paint',g,be=.025)
 cube('Nose bay',(0,-6.22,1.57),(.39,.86,.045),'Interior')
 for side in [-1,1]:
  cube('Nose bay side door',(side*.29,-6.09,1.32),(.035,.84,.44),'Paint',g,be=.015)
 rod('Landing light housing',(0,-6.51,.96),(0,-6.59,.96),.082,'Metal',g)
 rod('Landing light lens',(0,-6.59,.96),(0,-6.60,.96),.068,'Lamps',g)

# Set up rendering before modelling so the clay can be checked first.
def camera(loc,target,ortho=18):
 bpy.ops.object.camera_add(location=loc);c=bpy.context.object;c.rotation_euler=(Vector(target)-c.location).to_track_quat('-Z','Y').to_euler();c.data.type='ORTHO';c.data.ortho_scale=ortho;c.data.clip_end=5000;S.camera=c;return c

def light(name,loc,power,size,target=(0,-2,1)):
 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(target)-o.location).to_track_quat('-Z','Y').to_euler();return o

def setup_stage():
 world=bpy.data.worlds.new('Soft daylight');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.55,.64,.76,1);world.node_tree.nodes['Background'].inputs[1].default_value=.55;S.world=world
 light('Daylight key',(3,-10,14),2200,9);light('Sky fill',(-9,-3,8),1300,11);light('Tail rim',(3,10,10),1900,7)
 bpy.ops.mesh.primitive_plane_add(size=2000,location=(0,0,0));floor=bpy.context.object;floor.name='STUDIO_GROUND';m=bpy.data.materials.new('Studio ground');m.diffuse_color=(.18,.20,.23,1);m.use_nodes=True;m.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.18,.20,.23,1);m.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.83;floor.data.materials.append(m)
 cam=camera((15,-23,12),(0,-2.0,1.8),18.3);S.render.resolution_x=1920;S.render.resolution_y=1080;S.render.resolution_percentage=100
 return cam

def render(file,samples=24):
 S.cycles.samples=samples;S.render.filepath=str(ROOT/file);bpy.ops.render.render(write_still=True);print('RENDERED',file,flush=True)

# ---------- Geometry ----------
loft_body();intake();cockpit();nozzle();gear()
rod('Nose pitot',(0,-10.15,1.93),(0,-9.74,1.93),.008,'Metal',r2=.025)
for side,label in [(1,'L'),(-1,'R')]:
 wing('Wing '+label,side,.82,4.82,0,.715)
 wing('Flap '+label,side,.85,2.48,.721,1,'Flap_'+label,nx=15,nt=18)
 wing('Aileron '+label,side,2.50,4.80,.721,1,'Aileron_'+label,nx=20,nt=18)
 wing('All-moving stabilator '+label,side,.57,2.98,0,1,'Stabilator_'+label,True,nx=22,nt=30)
 # Curved leading edge extension joins the fuselage shoulder into the wing.
 vv=[];ff=[];rootst=[(-6.0,.51,.63,2.075),(-5.45,.58,.79,2.06),(-4.55,.61,1.01,2.06),(-3.55,.64,1.40,2.09),(-2.5,.67,1.47,2.12),(-1.1,.68,1.38,2.12)]
 for ii in range(50):
  yy=-6.0+4.9*ii/49;_,xi,xo,zz=interp(rootst,yy)
  for jj in range(13):
   q=jj/12;xx=xi+(xo-xi)*q;vv.append((side*xx,yy,zz+.10*(1-q)**2-.060*sqrt(q)))
 for ii in range(49):
  for jj in range(12):k=ii*13+jj;ff.append((k,k+1,k+14,k+13))
 ro=mesh('Blended leading-edge root',vv,ff);active(ro);sol=ro.modifiers.new('Root skin thickness','SOLIDIFY');sol.thickness=.025;bpy.ops.object.modifier_apply(modifier=sol.name);bevel(ro,.008,2)
 # Wingtip launch rail, bare for game-attached stores.
 rail=cube('Wingtip missile launch rail',(side*4.91,1.01,1.86),(.14,2.25,.12),'Paint',be=.028)
 cube('Launch rail track',(side*4.91,1.02,1.92),(.065,1.85,.035),'Metal',be=.008)
 for yy in [.30,1.70]:cube('Rail lug',(side*4.91,yy,1.99),(.115,.15,.085),'Metal',be=.012)
 # No weapons: actual per-station hang points with load-bearing pylons.
 for idx,x in enumerate([1.70,2.80]):
  yy=.2+idx*.28
  foilpoly('Pylon '+label+str(idx),[(side*(x-.065),yy-.65,1.74),(side*(x+.065),yy-.65,1.74),(side*(x+.07),yy+.8,1.74),(side*(x-.07),yy+.8,1.74)],.30)
  cube('Pylon foot',(side*x,yy+.1,1.63),(.19,1.20,.10),'Paint',be=.025)
  for ry in [yy-.3,yy+.5]:rod('Pylon sway brace',(side*(x-.17),ry,1.59),(side*(x+.17),ry,1.59),.027,'Metal')
  empty(f'Hardpoint_{label}_{idx+1}',(side*x,yy+.1,1.53))
 empty('Hardpoint_Wingtip_'+label,(side*4.91,1.00,1.93))
 empty('Light_Nav_'+label,(side*4.92,2.07,1.89))
 ellipsoid('Wingtip navigation lens',(side*4.92,2.09,1.9),(.045,.068,.038),'Lamps',seg=24,rings=12)
 # Ventral stabilizing fins, canted outward.
 foilpoly('Ventral fin '+label,[(side*.54,1.25,1.35),(side*.91,1.74,.61),(side*.91,3.03,.80),(side*.54,3.05,1.38)],.055)
 # Geometric wing inspection seams: chord-following and span-following.
 for x in [1.40,2.47,3.65,4.56]:
  pts=[]
  for j in range(24):
   t=.08+.59*j/23;p,h=wingpoint(x,t,side);pts.append((p[0],p[1],p[2]+.003))
  line('Wing panel seam',pts,.003)
 for t in [.12,.43,.708]:
  pts=[]
  for j in range(55):
   x=1.01+3.70*j/54;p,h=wingpoint(x,t,side);pts.append((p[0],p[1],p[2]+.003))
  line('Wing span seam',pts,.003)
 for x in [1.8,3.12,4.05]:
  p,h=wingpoint(x,.53,side);ellipsoid('Flush service cap',(p[0],p[1],p[2]+.006),(.075,.10,.006),'Composite',seg=24,rings=8)
 # Stabilator panel seams follow their own motion.
 for t in [.20,.72]:
  pts=[]
  for j in range(35):
   x=.72+2.10*j/34;p,h=wingpoint(x,t,side,True);pts.append((p[0],p[1],p[2]+.003))
  line('Stabilator seam',pts,.003,group='Stabilator_'+label)
 # Static discharge wicks.
 for x in [3.3,4.3]:
  p,h=wingpoint(x,1,side);rod('Static wick',p,(p[0],p[1]+.18,p[2]),.007,'Interior','Aileron_'+label,n=10)
# Dorsal root and single fin. Rudder split along its true swept hinge.
finpoly('Dorsal spine',[(.2,2.36),(1.4,2.65),(3.00,2.72),(4.32,2.29)],.28)
finpoly('Single swept vertical fin',[(1.10,2.38),(3.16,4.88),(4.00,4.88),(3.36,2.47)],.115)
finpoly('Rudder',[(3.39,2.47),(4.025,4.88),(4.43,4.88),(4.40,2.45)],.082,'Rudder')
cube('Fin tip cap',(0,3.82,4.851),(.13,1.27,.058),'Composite',be=.018)
for side in [-1,1]:
 line('Fin service seam',[(side*.059,1.50,2.53),(side*.059,3.32,4.68),(side*.059,3.95,4.68)],.0035)
 line('Fin lower seam',[(side*.061,1.46,2.64),(side*.061,3.30,2.68)],.004)
 for yy,zz in [(2.12,3.23),(3.24,4.12)]:rod('Fin fastener',(side*.053,yy,zz),(side*.062,yy,zz),.012,'Metal',n=8)
# Fuselage circumferential service seams. Avoid canopy gap.
for y in [-7.85,-7.45,-4.45,-3.65,-2.48,-.9,.55,1.9,3.05,3.85]:
 pts=[bodypoint(y,2*pi*j/120,.003) for j in range(120)]
 line('Fuselage station panel line',pts,.0035,cyclic=True)
for side in [-1,1]:
 for angle in [.12,.64,-.55]:
  a=angle if side==1 else pi-angle
  line('Longitudinal skin seam',[bodypoint(-7.7+11.5*j/140,a,.003) for j in range(141)],.003)
 # Rectangular maintenance hatches on upper shoulders, fasteners along side.
 for y0,L,a0,a1 in [(-4.15,.60,.32,.84),(-2.2,.83,.36,1.02),(.70,.67,.38,1.06),(2.65,.48,.2,.75)]:
  ang=lambda a:a if side==1 else pi-a
  pts=[bodypoint(y0+L*j/15,ang(a0),.007) for j in range(16)]+[bodypoint(y0+L,ang(a0+(a1-a0)*j/12),.007) for j in range(1,13)]+[bodypoint(y0+L-L*j/15,ang(a1),.007) for j in range(1,16)]+[bodypoint(y0,ang(a1-(a1-a0)*j/12),.007) for j in range(1,12)]
  line('Access hatch perimeter',pts,.005,cyclic=True)
  for yy in [y0+.07,y0+L-.07]:
   p=bodypoint(yy,ang(a0+.05),.010);ellipsoid('Captive skin screw',p,(.009,.012,.009),'Metal',seg=12,rings=6)
 # ECS outlet louvers, recessed dark faces with slim painted blades.
 for j in range(7):
  y=-3.1+j*.085;p=bodypoint(y, .06 if side==1 else pi-.06,.01)
  cube('Cooling louver recess',p,(.015,.038,.19),'Interior',be=.007)
  cube('Louver vane',(p[0]+side*.007,p[1]+.017,p[2]),(.020,.012,.17),'Paint',be=.004)
 # Angle of attack probe and forward avionics cheek blister.
 ellipsoid('Avionics cheek',(side*.57,-7.38,2.04),(.06,.21,.055),'Paint',seg=32,rings=12)
 rod('Air data probe',(side*.57,-7.47,2.10),(side*.79,-7.48,2.10),.018,'Metal',r2=.005)
# Flush fastener rows at service seams and along the wing spar covers.
rv=[];rf=[]
def rivet(p,n,r=.005):
 n=Vector(n).normalized();u=n.cross(Vector((0,1,0)))
 if u.length<.1:u=n.cross(Vector((0,0,1)))
 u.normalize();w=n.cross(u);center=Vector(p)+n*.001;idx=len(rv);rv.append(tuple(center))
 for k in range(8):rv.append(tuple(center+r*(u*cos(2*pi*k/8)+w*sin(2*pi*k/8))))
 for k in range(8):rf.append((idx,idx+1+k,idx+1+(k+1)%8))
for yy in [-7.42,-4.42,-3.62,-2.45,-.87,.58,1.93,3.08]:
 for j in range(56):
  a=2*pi*j/56
  if -7.4<yy<-4.4 and sin(a)>.65:continue
  pp=bodypoint(yy,a,.005);rivet(pp,(cos(a),0,sin(a)))
for side in [-1,1]:
 for tt in [.125,.69]:
  for j in range(45):
   xx=1.05+3.65*j/44;pp,hh=wingpoint(xx,tt,side);rivet(pp,(0,0,1),.0045)
mesh('Flush skin fastener rows',rv,rf,mat='Metal',smooth=False)
# Gun port, small top antennas and beacon.
ellipsoid('Recessed gun muzzle',(.67,-5.14,2.14),(.08,.23,.065),'Interior',seg=32,rings=12)
line('Gun port rim',[(.675+.04*cos(2*pi*j/40),-5.14+.21*sin(2*pi*j/40),2.18) for j in range(40)],.011,'Metal',cyclic=True)
finpoly('Dorsal antenna',[(-.7,2.47),(-.53,2.78),(-.35,2.79),(-.42,2.45)],.035,mat='Composite')
finpoly('Belly antenna',[(-2.3,.94),(-2.18,.72),(-1.99,.71),(-2.0,.96)],.035,mat='Composite')
ellipsoid('Beacon lens',(0,.11,2.55),(.065,.1,.04),'Lamps',seg=24,rings=12)
empty('Light_Beacon',(0,.11,2.59));empty('Center_Of_Mass',(0,-.75,1.88));empty('Hardpoint_Centerline',(0,-1.65,.86))
# Join all rigid assemblies, retaining eight-or-fewer shared materials.
PIVOTS={'Fuselage':(0,0,0),'Canopy':(0,-4.5,2.49),'Gear_Nose':(0,-6.39,1.57),'Gear_L':(.63,-.30,1.62),'Gear_R':(-.63,-.30,1.62),'Rudder':(0,3.39,2.47)}
for side,label in [(1,'L'),(-1,'R')]:
 for typ,x in [('Flap',1.65),('Aileron',3.5)]:PIVOTS[typ+'_'+label]=wingpoint(x,.718,side)[0]
 PIVOTS['Stabilator_'+label]=(side*.65,3.07,1.72)
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
 S.cursor.location=PIVOTS.get(name,(0,0,0));bpy.ops.object.origin_set(type='ORIGIN_CURSOR');active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 # Remove numerical degeneracy before export; triangulation is explicit.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.000001);bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=.000001);bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free();asset.append(o)
 print('ASSEMBLY',name,len(o.data.polygons),flush=True)
# Keep true silhouette detail while reducing oversampled tubes and curved skins.
raw_tri=sum(len(o.data.polygons) for o in asset)
if raw_tri>148000:
 ratio=147500/raw_tri
 for o in asset:
  active(o);de=o.modifiers.new('Game mesh optimization','DECIMATE');de.ratio=ratio;de.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=de.name)
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=.000001);bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free()
# Normalize the sub-millimetre bevel/decimation envelope to the exact length.
bpy.context.view_layer.update()
yp=[(o.matrix_world@Vector(v)).y for o in asset for v in o.bound_box]
sy=15.06/(max(yp)-min(yp))
for o in asset:
 o.location.y*=sy
 for v in o.data.vertices:v.co.y*=sy
 o.data.update()
for o in sockets:o.location.y*=sy
# Geometric audit: world bounds are in the actual game convention.
bpy.context.view_layer.update()
def gamepos(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
pts=[o.matrix_world@Vector(v) for o in asset for v in o.bound_box]
lo=[min(p[k] for p in pts) for k in range(3)];hi=[max(p[k] for p in pts) for k in range(3)]
manifest={'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]],'size':[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]]},'nodes':[{'name':o.name,'type':o.type,'world_position':gamepos(o.matrix_world.translation)} for o in asset+sockets],'paints':PAINTS,'hinges':{'Rudder':{'axis':[0,.967,-.255]},'Canopy':{'axis':[1,0,0]},'Aileron_L':{'axis':[1,-.0443,-.339]},'Aileron_R':{'axis':[1,.0443,.339]},'Flap_L':{'axis':[1,-.0443,-.339]},'Flap_R':{'axis':[1,.0443,.339]},'Stabilator_L':{'axis':[1,0,0]},'Stabilator_R':{'axis':[1,0,0]}}}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
cam=setup_stage()
# Clay comes first, before any material bake is trusted.
clay=bpy.data.materials.new('Clay review');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.38,.38,.38,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.62
S.view_layers[0].material_override=clay;render('clay.png',16);S.view_layers[0].material_override=None
# Bake a UV tile for every shader, flattening scan + node detail into portable maps.
for m in M.values():bake_tile(m)
# Save only deliverable meshes and sockets in GLB.
bpy.ops.object.select_all(action='DESELECT')
for o in asset+sockets:o.select_set(True)
bpy.context.view_layer.objects.active=asset[0]
bpy.ops.export_scene.gltf(filepath=str(ROOT/'f16-fighter.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_image_format='AUTO',export_extras=True)
# Pin the paint scalar and blend mode in the serialized standard glTF contract.
# The Multiply shader renders the same value; this also guards exporter revisions.
p=ROOT/'f16-fighter.glb';data=p.read_bytes();n,typ=struct.unpack_from('<II',data,12);g=json.loads(data[20:20+n]);binrest=data[20+n:]
for mat in g.get('materials',[]):
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=[.40,.42,.45,1]
 if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
payload=json.dumps(g,separators=(',',':')).encode();payload+=b' '*((-len(payload))%4);p.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(payload)+len(binrest))+struct.pack('<II',len(payload),0x4E4F534A)+payload+binrest)
tri=sum(len(o.data.polygons) for o in asset);manifest['triangles']=tri;manifest['materials']=list(M);manifest['glb_bytes']=p.stat().st_size;(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
assert 60000<=tri<=160000, tri
assert p.stat().st_size<=12*1024*1024, p.stat().st_size
assert len(M)<=8
assert abs(lo[2])<.0001, lo
assert abs((hi[0]-lo[0])-9.96)<.002
assert abs((hi[2]-lo[2])-4.88)<.002
assert abs((hi[1]-lo[1])-15.06)<.002
print('FIRST_COMPLETE_GLB',tri,p.stat().st_size,'BOUNDS',manifest['bounding_box'],flush=True)
render('final.png',64)
cam.location=(20,-2.4,2.40);target=Vector((0,-2.4,2.40));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=17.0;render('side.png',24)
# Six identical 3/4 views: assemble PNG without external Python packages.
cam.location=(15,-23,12);cam.rotation_euler=(Vector((0,-2.,1.8))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=18.3
S.render.resolution_x=640;S.render.resolution_y=360
pixels=[]
for i,paint in enumerate(PAINTS):
 M['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=(*paint['linear_rgb'],1)
 render(f'paint_{i}.png',12)
 im=bpy.data.images.load(str(ROOT/f'paint_{i}.png'),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 are bottom-up; first palette row is placed on top.
grid=np.concatenate([np.concatenate(pixels[3:6],axis=1),np.concatenate(pixels[0:3],axis=1)],axis=0)
im=bpy.data.images.new('Paint comparison',width=1920,height=720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
for i in range(6):(ROOT/f'paint_{i}.png').unlink()
M['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=(*PAINTS[0]['linear_rgb'],1)
notes=["F-16 Fighting Falcon style single-engine fighter, gear down, articulated controls and game sockets; +Z nose, +Y up, metres.",f"Triangles: {tri:,}; GLB: {p.stat().st_size/1048576:.2f} MiB; dimensions: {hi[1]-lo[1]:.3f} m long x {hi[0]-lo[0]:.3f} m span x {hi[2]-lo[2]:.3f} m high.","Materials (7): Paint, Metal, Rubber, Interior, Composite, Glass (BLEND), Lamps; neutral Paint tile multiplied by editable linear factor.","Cycles GPU baked shared tiling sets: base/normal at most 1024 px; packed ORM at most 512 px; no unique full-aircraft atlas.","Poly Haven ID: rusty_metal_03; CC0 https://polyhaven.com/a/rusty_metal_03 ; licence https://polyhaven.com/license ; source maps downloaded from dl.polyhaven.org.","Differences: unarmed launch rails and pylons for game stores; no faction markings; simplified cockpit instrumentation, maintenance layout and internal engine; reference camouflage replaced by contract recolourable neutral paint."]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
# Portability capture from the actual serialized file, separate from authoring scene.
for obj in asset+sockets:bpy.data.objects.remove(obj,do_unlink=True)
# Avoid colliding with Blender 5.2 importer’s richer canonical settings group.
if 'glTF Material Output' in bpy.data.node_groups:
 bpy.data.node_groups['glTF Material Output'].name='Authoring baked occlusion settings'
bpy.ops.import_scene.gltf(filepath=str(ROOT/'f16-fighter.glb'))
S.render.resolution_x=1920;S.render.resolution_y=1080
render('roundtrip.png',32)
print('COMPLETE',flush=True)
