"""Original 12.5 m dieselpunk fighter. Blender 5.2; Cycles GPU. CC0 sources only.
Run: blender -b --python-exit-code 1 --python build_dieselpunk-space-fighter.py
Optional -- --preflight probes GPU baking/export. -- --preview builds fast views.
Authoring axes X=left, -Y=nose, Z=up; glTF conversion is (x,z,-y).
"""
import bpy, bmesh, math, os, sys, json, struct, urllib.request, random, time
import numpy as np
from mathutils import Vector, Matrix
from pathlib import Path
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
SOURCE=TEX/'source'; SOURCE.mkdir(exist_ok=True)
RNG=random.Random(917)
PREVIEW='--preview' in sys.argv
START=time.time()
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene
S.unit_settings.system='METRIC'; S.unit_settings.scale_length=1
S.render.engine='CYCLES'; S.cycles.samples=32; S.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
for backend in ['OPTIX','CUDA']:
    try:
        prefs.compute_device_type=backend; prefs.get_devices()
        if any(d.type!='CPU' for d in prefs.devices): break
    except Exception: pass
for d in prefs.devices: d.use=d.type!='CPU'
S.cycles.device='GPU'; S.cycles.bake_type='EMIT'
S.render.bake.margin=8; S.render.bake.use_clear=True
S.render.image_settings.file_format='PNG'
S.view_settings.view_transform='AgX'; S.view_settings.look='AgX - Medium High Contrast'
S.render.film_transparent=False
print('DEVICE',backend,[(d.name,d.use) for d in prefs.devices],flush=True)

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

def mesh(name,vs,fs,mat=None,smooth=False,uvs=None):
    me=bpy.data.meshes.new(name); me.from_pydata(vs,[],fs); me.update()
    bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces)); bm.to_mesh(me); bm.free()
    o=bpy.data.objects.new(name,me); S.collection.objects.link(o)
    if mat: me.materials.append(mat)
    for p in me.polygons: p.use_smooth=smooth
    uv=me.uv_layers.new(name='UVMap')
    for p in me.polygons:
        n=p.normal; axis=max(range(3),key=lambda i:abs(n[i])); dims=[i for i in range(3) if i!=axis]
        for li in p.loop_indices:
            v=me.vertices[me.loops[li].vertex_index].co
            uv.data[li].uv=(v[dims[0]]*.62,v[dims[1]]*.62)
    return o

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

def cube(name,loc,scale,mat,bev=.025):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.name=name; o.scale=scale
    bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    o.data.materials.append(mat)
    if bev: bevel(o,bev,3)
    return o

def uvworld(o,scale=.62):
    if not o.data.uv_layers: o.data.uv_layers.new(name='UVMap')
    uv=o.data.uv_layers.active
    for p in o.data.polygons:
        n=p.normal; axis=max(range(3),key=lambda i:abs(n[i])); dims=[i for i in range(3) if i!=axis]
        for li in p.loop_indices:
            v=o.matrix_world@o.data.vertices[o.data.loops[li].vertex_index].co
            uv.data[li].uv=(v[dims[0]]*scale,v[dims[1]]*scale)

def beam(name,a,b,width,mat):
    a,b=Vector(a),Vector(b);v=b-a
    o=cube(name,(a+b)/2,(width,width*.72,v.length),mat,width*.16)
    o.rotation_euler=v.to_track_quat('Z','Y').to_euler()
    return o

def ellipsoid(name,loc,scale,mat,seg=24,rings=12):
    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)
    o.data.materials.append(mat)
    for p in o.data.polygons:p.use_smooth=True
    return o

def cylinder(name,a,b,r,mat,verts=20,r2=None):
    a,b=Vector(a),Vector(b); v=b-a
    bpy.ops.mesh.primitive_cone_add(vertices=verts,radius1=r,radius2=r if r2 is None else r2,depth=v.length,location=(a+b)/2)
    o=bpy.context.object; o.name=name; o.rotation_euler=v.to_track_quat('Z','Y').to_euler(); o.data.materials.append(mat)
    for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
    bevel(o,min(.018,r*.14),2)
    return o

def tube(name,pts,r,mat,sides=8):
    # Polyline tube with sampled centreline, shared vertices; end caps.
    vs=[]
    for i,p in enumerate(pts):
        p=Vector(p); tangent=Vector(pts[min(i+1,len(pts)-1)])-Vector(pts[max(0,i-1)])
        tangent.normalize(); ref=Vector((0,0,1)) if abs(tangent.z)<.9 else Vector((1,0,0))
        u=tangent.cross(ref).normalized(); v=tangent.cross(u).normalized()
        vs += [tuple(p+r*(math.cos(j*2*math.pi/sides)*u+math.sin(j*2*math.pi/sides)*v)) for j in range(sides)]
    fs=[]
    for k in range(len(pts)-1):
        for j in range(sides):fs.append((k*sides+j,k*sides+(j+1)%sides,(k+1)*sides+(j+1)%sides,(k+1)*sides+j))
    fs.extend([tuple(reversed(range(sides))),tuple((len(pts)-1)*sides+j for j in range(sides))])
    return mesh(name,vs,fs,mat,True)

def curve_tube(name,control,r,mat,sides=10,steps=6):
    p=[Vector(x) for x in control]; pts=[]
    for i in range(len(p)-1):
        p0=p[max(0,i-1)];p1=p[i];p2=p[i+1];p3=p[min(len(p)-1,i+2)]
        for k in range(steps):
            t=k/steps
            pts.append(tuple(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t)))
    pts.append(tuple(p[-1]));return tube(name,pts,r,mat,sides)

def lathe(name,profile,mat,n=48,cx=0,cz=0):
    vs=[(cx+r*math.cos(j*2*math.pi/n),y,cz+r*math.sin(j*2*math.pi/n)) for y,r in profile for j in range(n)]
    fs=[]
    for i in range(len(profile)-1):
        for j in range(n):fs.append((i*n+j,i*n+(j+1)%n,(i+1)*n+(j+1)%n,(i+1)*n+j))
    o=mesh(name,vs,fs,mat,True)
    uv=o.data.uv_layers.active
    for p in o.data.polygons:
        for li in p.loop_indices:
            k=o.data.loops[li].vertex_index; row=k//n; j=k%n
            # seam-adjusted cylindrical repeat
            js=[o.data.loops[x].vertex_index%n for x in p.loop_indices]
            if max(js)-min(js)>n/2 and j==0:j=n
            uv.data[li].uv=(j/n*4,profile[row][0]*.65)
    return o

def simple(name,col,metal=.0,rough=.4,emission=0,alpha=1):
    m=bpy.data.materials.new(name);m.use_nodes=True;p=m.node_tree.nodes.get('Principled BSDF')
    p.inputs['Base Color'].default_value=(*col,1);p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
    if emission:p.inputs['Emission Color'].default_value=(*col,1);p.inputs['Emission Strength'].default_value=emission
    p.inputs['Alpha'].default_value=alpha
    if alpha<1:m.surface_render_method='DITHERED';m.use_transparency_overlap=False
    return m

if '--preflight' in sys.argv:
    mat=simple('probe',(.4,.5,.6),.5)
    o=cube('probe',(0,0,0),(1,1,1),mat)
    active(o);im=bpy.data.images.new('probe',32,32);n=mat.node_tree.nodes.new('ShaderNodeTexImage');n.image=im;mat.node_tree.nodes.active=n
    bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'});im.filepath_raw=str(ROOT/'preflight.png');im.save()
    bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB',use_selection=True)
    bpy.ops.object.delete();bpy.ops.import_scene.gltf(filepath=str(ROOT/'preflight.glb'))
    assert any(o.type=='MESH' for o in S.objects)
    print('PREFLIGHT_OK',flush=True);sys.exit()

# Material masters: photographed rolled steel and oxidized steel; CC0 Poly Haven.
def download(id,map):
    p=SOURCE/f'{id}_{map}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
        req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0 ForgeAssetBuilder'})
        with urllib.request.urlopen(req,timeout=90) as r:p.write_bytes(r.read())
    return bpy.data.images.load(str(p),check_existing=True)

PH={}
for id in ['metal_plate_02','rusty_metal_02']:
    PH[id]={}
    for map in ['diff','nor_gl','arm']:
        PH[id][map]=download(id,map)
        if map!='diff':PH[id][map].colorspace_settings.name='Non-Color'

MATS={};BIND={};BAKES=[]
def bake_material(name,id,color,metal,rough,size=512,ormsize=256,neutral=False,alpha=1,emission=0):
    print('BAKE',name,flush=True)
    m=simple(name,color,metal,rough,emission,alpha);nt=m.node_tree;n=nt.nodes;l=nt.links;p=n.get('Principled BSDF');out=n.get('Material Output')
    tex=n.new('ShaderNodeTexImage');tex.image=PH[id]['diff']
    norm=n.new('ShaderNodeTexImage');norm.image=PH[id]['nor_gl']
    arm=n.new('ShaderNodeTexImage');arm.image=PH[id]['arm']
    bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs['Color'],bw.inputs[0])
    ramp=n.new('ShaderNodeValToRGB');l.new(bw.outputs[0],ramp.inputs[0])
    # Neutral luminance is deliberately retained in Paint's texture.
    ramp.color_ramp.elements[0].position=.008;ramp.color_ramp.elements[1].position=.22
    dark=.48 if neutral else .36
    ramp.color_ramp.elements[0].color=(*(c*dark for c in color),1)
    ramp.color_ramp.elements[1].color=(*color,1)
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=95;noise.inputs['Detail'].default_value=3.5
    mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.12;l.new(ramp.outputs[0],mix.inputs[1]);l.new(noise.outputs['Fac'],mix.inputs[2])
    if neutral:
        # Weathered neutral paint tile: photographic steel plus baked oily mottling and fine streaks.
        coord=n.new('ShaderNodeTexCoord');stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(7,38,1)
        l.new(coord.outputs['UV'],stretch.inputs[0]);streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=3.2;streak.inputs['Detail'].default_value=4
        l.new(stretch.outputs[0],streak.inputs['Vector']);dirt=n.new('ShaderNodeValToRGB');l.new(streak.outputs['Fac'],dirt.inputs[0])
        dirt.color_ramp.elements[0].position=.32;dirt.color_ramp.elements[0].color=(.37,.37,.37,1);dirt.color_ramp.elements[1].position=.66;dirt.color_ramp.elements[1].color=(1,1,1,1)
        weather=n.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=.23;l.new(mix.outputs[0],weather.inputs[1]);l.new(dirt.outputs[0],weather.inputs[2]);mix=weather
    if name=='Engines':
        coord=n.new('ShaderNodeTexCoord');sp=n.new('ShaderNodeSeparateXYZ');l.new(coord.outputs['UV'],sp.inputs[0])
        thermal=n.new('ShaderNodeValToRGB');l.new(sp.outputs['Y'],thermal.inputs[0]);r=thermal.color_ramp
        r.elements[0].position=0;r.elements[0].color=(1,.62,.14,1);r.elements[1].position=1;r.elements[1].color=(.018,.04,.09,1)
        for at,col in [(.20,(1,.36,.025,1)),(.47,(.52,.09,.009,1)),(.72,(.10,.025,.012,1)),(.88,(.018,.045,.12,1))]:r.elements.new(at).color=col
        mix=thermal
    if name=='Lamps':
        # glTF vertex colours do not affect emission: bake a three-colour lens trim.
        coord=n.new('ShaderNodeTexCoord');sp=n.new('ShaderNodeSeparateXYZ');l.new(coord.outputs['UV'],sp.inputs[0])
        palette=n.new('ShaderNodeValToRGB');l.new(sp.outputs['X'],palette.inputs[0]);r=palette.color_ramp;r.interpolation='CONSTANT'
        r.elements[0].position=0;r.elements[0].color=(1,.025,.012,1)
        r.elements[1].position=2/3;r.elements[1].color=(1,.11,.025,1)
        r.elements.new(1/3).color=(.02,.8,.12,1)
        mix=palette
    l.new(mix.outputs[0],p.inputs['Base Color'])
    nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.035 if neutral else .14;l.new(norm.outputs['Color'],nm.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.10;bump.inputs['Distance'].default_value=.0015
    l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],p.inputs['Normal'])
    sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs['Color'],sep.inputs[0])
    rm=n.new('ShaderNodeMapRange');l.new(sep.outputs['Green'],rm.inputs['Value']);rm.inputs['To Min'].default_value=rough-.09;rm.inputs['To Max'].default_value=rough+.13
    l.new(rm.outputs[0],p.inputs['Roughness'])
    # A real Cycles tile bake is shared by all parts using this material.
    plane=mesh('BakeTile',[(0,0,-20),(1,0,-20),(1,1,-20),(0,1,-20)],[(0,1,2,3)],m)
    uv=plane.data.uv_layers.active
    for i,v in enumerate([(0,0),(1,0),(1,1),(0,1)]):uv.data[i].uv=v
    active(plane);S.cycles.samples=8
    target=n.new('ShaderNodeTexImage');n.active=target
    emit=n.new('ShaderNodeEmission');combine=n.new('ShaderNodeCombineColor')
    l.new(sep.outputs['Red'],combine.inputs['Red']);l.new(rm.outputs[0],combine.inputs['Green']);combine.inputs['Blue'].default_value=metal
    baked={}
    for passname,res in [('BaseColor',size),('Normal',size),('ORM',ormsize)]:
        img=bpy.data.images.new(f'{name}_{passname}',res,res,alpha=False)
        img.colorspace_settings.name='sRGB' if passname=='BaseColor' else 'Non-Color';target.image=img;n.active=target
        if passname=='Normal':
            l.new(p.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
        else:
            l.new(mix.outputs[0] if passname=='BaseColor' else combine.outputs[0],emit.inputs['Color']);l.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
        img.filepath_raw=str(TEX/f'{name}_{passname}.png');img.file_format='PNG';img.save();baked[passname]=img
        BAKES.append({'material':name,'pass':passname,'size':res,'source':id,'engine':'Cycles','device':backend,'samples':8})
    bpy.data.objects.remove(plane,do_unlink=True)
    n.clear();p=n.new('ShaderNodeBsdfPrincipled');out=n.new('ShaderNodeOutputMaterial');l.new(p.outputs[0],out.inputs[0])
    bc=n.new('ShaderNodeTexImage');bc.image=baked['BaseColor'];bc.label='Shared baked tile, neutral for faction paint'
    l.new(bc.outputs['Color'],p.inputs['Base Color'])
    normal=n.new('ShaderNodeTexImage');normal.image=baked['Normal'];nm=n.new('ShaderNodeNormalMap');l.new(normal.outputs['Color'],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
    orm=n.new('ShaderNodeTexImage');orm.image=baked['ORM'];sep=n.new('ShaderNodeSeparateColor');l.new(orm.outputs[0],sep.inputs[0]);l.new(sep.outputs['Green'],p.inputs['Roughness']);l.new(sep.outputs['Blue'],p.inputs['Metallic'])
    # Standard glTF occlusion connector.
    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')
    ao=n.new('ShaderNodeGroup');ao.node_tree=group;l.new(sep.outputs['Red'],ao.inputs['Occlusion'])
    p.inputs['Alpha'].default_value=alpha
    if emission:p.inputs['Emission Color'].default_value=(*color,1);p.inputs['Emission Strength'].default_value=emission
    if name in ('Engines','Lamps'):l.new(bc.outputs['Color'],p.inputs['Emission Color'])
    if name=='Glass':p.inputs['IOR'].default_value=1.46;p.inputs['Coat Weight'].default_value=.65
    MATS[name]=m;BIND[name]=(p,bc)
    return m

Paint=bake_material('Paint','metal_plate_02',(.94,.94,.94),.38,.50,1024,512,True)
Steel=bake_material('Steel','metal_plate_02',(.43,.46,.48),.87,.29,512,512)
Heat=bake_material('Heat','rusty_metal_02',(.30,.24,.21),.78,.38,512,256)
Weapons=bake_material('Weapons','metal_plate_02',(.075,.085,.095),.82,.32,512,256)
Cockpit=bake_material('Cockpit','metal_plate_02',(.048,.055,.052),.22,.66,256,128)
Glass=bake_material('Glass','metal_plate_02',(.12,.22,.24),.15,.19,128,64,alpha=.30)
Engines=bake_material('Engines','metal_plate_02',(1,.24,.035),.15,.36,64,64,emission=2.2)
Lamps=bake_material('Lamps','metal_plate_02',(.9,.3,.10),.1,.28,64,64,emission=1.2)
# Lamps share one baked palette and one emissive material.
S.cycles.samples=32

# Construction: lofted armour shell with an actual open cockpit well.
PROFILE=[(-5.72,1.25,1.22,0),(-5.25,1.30,1.27,0),(-4.1,1.32,1.28,0),(-2.5,1.29,1.27,0),(-1.5,1.27,1.27,0),(0,1.24,1.27,0),(1.5,1.15,1.18,0),(3,.97,1.00,.02),(4.35,.73,.76,.03),(4.8,.61,.66,.03)]
def hullval(y):
    for a,b in zip(PROFILE,PROFILE[1:]):
        if a[0]<=y<=b[0]:
            t=(y-a[0])/(b[0]-a[0]);return tuple(a[k]*(1-t)+b[k]*t for k in range(1,4))
    return PROFILE[0][1:] if y<PROFILE[0][0] else PROFILE[-1][1:]
def hp(y,a,offset=0):
    rx,rz,z=hullval(y);return ((rx+offset)*math.cos(a),y,z+(rz+offset)*math.sin(a))
ys=sorted(set([p[0] for p in PROFILE]+[-1.72,-1.1,0,.75,1.25]+[i*.25 for i in range(-22,20)]))
vs=[hp(y,j*2*math.pi/64) for y in ys for j in range(64)];fs=[]
for k in range(len(ys)-1):
    for j in range(64):
        a=(j+.5)*2*math.pi/64
        if -1.72<=ys[k]<1.25 and math.sin(a)>.64:continue
        fs.append((k*64+j,k*64+(j+1)%64,(k+1)*64+(j+1)%64,(k+1)*64+j))
body=mesh('Riveted monocoque',vs,fs,Paint,True)
# Nose: rolled thick metal inlet lip with a closed inner duct.
lathe('Rolled reactor lip',[(-5.5,1.29),(-5.72,1.29),(-5.85,1.25),(-5.91,1.19),(-5.88,1.12),(-5.72,1.10),(-5.35,1.10)],Steel,64)
lathe('Reactor throat',[(-5.73,1.105),(-5.34,1.09),(-5.18,.92),(-5.12,.45)],Weapons,64)
# Stationary turbine stator: pitched aerofoil vanes behind conical reactor hub.
for j in range(32):
    a=j*math.tau/32
    coords=[]
    for rr,yy,aa in [(.33,-5.65,a), (1.09,-5.49,a+.12),(1.09,-5.30,a+.19),(.37,-5.46,a+.07)]:coords.append((rr*math.cos(aa),yy,rr*math.sin(aa)))
    o=mesh('Reactor stator vane',coords,[(0,1,2,3)],Steel)
    active(o);so=o.modifiers.new('Vane thickness','SOLIDIFY');so.thickness=.026;bpy.ops.object.modifier_apply(modifier=so.name);bevel(o,.012,2)
lathe('Armoured reactor spindle',[(-6.25,.018),(-6.22,.10),(-6.1,.23),(-5.90,.36),(-5.64,.41),(-5.43,.38),(-5.40,.01)],Steel,48)
# Structural hull joints, lap seams, rivets in precise bands.
RIV_V=[];RIV_F=[]
def rivet(pos,normal,r=.025):
    p=Vector(pos);n=Vector(normal).normalized();u=n.cross(Vector((0,1,0)))
    if u.length<.1:u=n.cross(Vector((1,0,0)))
    u.normalize();v=n.cross(u).normalized();base=len(RIV_V)
    for h,rr in [(0,r),(.006,r*.88),(.012,r*.48)]:
        for j in range(6):RIV_V.append(tuple(p+n*h+rr*(math.cos(j*math.tau/6)*u+math.sin(j*math.tau/6)*v)))
    for k in range(2):
        for j in range(6):RIV_F.append((base+k*6+j,base+k*6+(j+1)%6,base+(k+1)*6+(j+1)%6,base+(k+1)*6+j))
    RIV_F.append(tuple(base+12+j for j in range(6)))
for y in [-5.28,-3.85,-2.38,1.40,2.9,4.28]:
    tube('Armour lap joint',[hp(y,j*math.tau/96,.009) for j in range(97)],.008,Weapons,5)
    for j in range(40):
        a=j*math.tau/40
        for dy in [-.065,.065]:rivet(hp(y+dy,a,.012),(math.cos(a),0,math.sin(a)),.017)
for a in [-.58,.20,2.94,3.72,4.8]:
    pts=[hp(y,a,.012) for y in np.linspace(-5.27,4.27,48)]
    tube('Longitudinal armour join',pts,.007,Weapons,5)
    for y in np.arange(-5.1,4.3,.3):rivet(hp(float(y),a+.042,.014),(math.cos(a),0,math.sin(a)),.017)
# Cowling quick release latches.
for side in [-1,1]:
    for y in [-4.9,-3.5,-2.1]:
        rx,rz,z=hullval(y);o=cube('Recessed service latch',(side*(rx+.008),y,.22),(.045,.23,.095),Steel,.02)
        cube('Latch slot',(side*(rx+.034),y,.22),(.012,.12,.018),Weapons,.006)
# Greenhouse cockpit, side-hinged as one moving assembly.
canopy_parts=[]
def cp(o):canopy_parts.append(o);return o
sections=[(-1.72,.86,1.01,.54,1.40),(-1.05,.91,1.05,.66,1.77),(.10,.92,1.05,.67,1.81),(.82,.88,1.03,.63,1.77),(1.25,.82,.99,.58,1.64)]
def csec(s):
    y,wb,zb,wt,zt=s;return [(-wb,y,zb),(-wt,y,zt),(wt,y,zt),(wb,y,zb)]
for a,b in zip(sections,sections[1:]):
    aa,bb=csec(a),csec(b)
    for j in range(3):
        q=[aa[j],aa[j+1],bb[j+1],bb[j]]
        ob=cp(mesh('Armoured laminated glazing',q,[(0,1,2,3)],Glass))
        active(ob);so=ob.modifiers.new('Glass thickness','SOLIDIFY');so.thickness=.022;bpy.ops.object.modifier_apply(modifier=so.name);bevel(ob,.012,2)
        for p,q2 in zip(q,q[1:]+q[:1]):cp(cylinder('Canopy steel glazing bead',p,q2,.029,Steel,10))
for s in sections:
    q=csec(s)
    for a,b in zip(q,q[1:]):cp(beam('Canopy armour arch',a,b,.115,Paint))
for j in range(4):
    for a,b in zip(sections,sections[1:]):cp(beam('Canopy longitudinal rail',csec(a)[j],csec(b)[j],.105,Paint))
# front windshield and rear bulkhead glass.
for s in [sections[0],sections[-1]]:
    q=csec(s);cp(mesh('Windshield end',q,[(0,1,2,3)],Glass))
    cp(beam('Lower glazing rail',q[0],q[3],.10,Paint))
# Hinge barrels and grab handle, connected to canopy.
for y in [-.9,.15,.9]:cp(cylinder('Canopy hinge knuckle',(.90,y-.13,1.04),(.90,y+.13,1.04),.067,Steel,16))
cp(curve_tube('Canopy handle',[(-.94,.45,1.1),(-1.02,.45,1.17),(-1.02,.72,1.17),(-.94,.72,1.1)],.023,Steel))
# Coaming closes edge of hull opening, no floating glass.
for side in [-1,1]:
    curve_tube('Cockpit sill seal',[(side*.87,-1.73,1.0),(side*.95,-1.0,1.02),(side*.95,.6,1.01),(side*.83,1.27,.98)],.060,Weapons,12)
# Closed armour coaming follows the hull opening and supports the hinge.
for side in [-1,1]:
    vv=[]
    for ss in sections:
        y,wb,zb,wt,zt=ss;rx,rz,zc=hullval(y)
        vv.extend([(side*.965,y,zc+rz*math.sqrt(max(.01,1-(.965/rx)**2))-.025),(side*(wb+.04),y,zb-.025)])
    ff=[(i*2,i*2+1,(i+1)*2+1,(i+1)*2) for i in range(len(sections)-1)]
    o=mesh('Armour cockpit coaming',vv,ff,Paint)
    active(o);mod=o.modifiers.new('Coaming plate thickness','SOLIDIFY');mod.thickness=.055;bpy.ops.object.modifier_apply(modifier=mod.name);bevel(o,.012,2)
# Curved armoured windscreen apron seals the forward edge of the cockpit cutout.
vv=[]
for x in np.linspace(-.92,.92,25):
    rx,rz,zc=hullval(-1.86)
    vv.extend([(float(x),-1.87,zc+rz*math.sqrt(1-(x/rx)**2)+.008),(float(x),-1.708,1.045)])
o=mesh('Curved windscreen apron',vv,[(i*2,i*2+1,(i+1)*2+1,(i+1)*2) for i in range(24)],Paint,True)
active(o);mod=o.modifiers.new('Apron thickness','SOLIDIFY');mod.thickness=.035;bpy.ops.object.modifier_apply(modifier=mod.name);bevel(o,.008,2)
# Aft aerodynamic turtle deck.
vs=[]
for y,rx,zz,rz in [(1.20,.82,1.0,.62),(1.6,.79,.95,.57),(2.1,.70,.87,.43),(2.7,.54,.77,.25),(3.3,.28,.68,.09),(3.5,.08,.65,.03)]:
    for j in range(25):
        a=j*math.pi/24;vs.append((rx*math.cos(a),y,zz+rz*math.sin(a)))
fs=[(k*25+j,k*25+j+1,(k+1)*25+j+1,(k+1)*25+j) for k in range(5) for j in range(24)]
mesh('Armoured rear canopy fairing',vs,fs,Paint,True)
# Visible pilot equipment in the actual cockpit cavity.
cube('Cockpit bathtub',(0,-.05,.50),(1.56,2.6,.25),Cockpit,.12)
cube('Pilot seat cushion',(0,.43,.72),(.68,.70,.18),Cockpit,.09)
back=cube('Pilot seat back',(0,.82,1.04),(.72,.18,.74),Cockpit,.09);back.rotation_euler.x=math.radians(12)
cube('Headrest',(0,.83,1.48),(.40,.19,.22),Weapons,.06)
for side in [-1,1]:
    curve_tube('Shoulder harness',[(side*.19,.69,1.4),(side*.20,.62,1.15),(side*.25,.46,.84)],.032,Heat,6)
    cube('Side console',(side*.64,-.25,.81),(.25,1.28,.22),Weapons,.06)
    for y in [-.7,-.45,-.2,.05]:cylinder('Console toggle',(side*.64,y,.92),(side*.64,y,.99),.013,Steel,8)
inst=cube('Instrument panel',(0,-1.07,1.0),(1.3,.15,.49),Weapons,.06);inst.rotation_euler.x=math.radians(-13)
for x,z in [(-.42,1.08),(-.14,1.12),(.18,1.1),(.43,1.06),(-.28,.9),(.12,.9)]:
    cylinder('Instrument bezel',(x,-.962,z),(x,-.93,z),.085,Steel,16)
    cylinder('Dark instrument face',(x,-.926,z),(x,-.916,z),.069,Cockpit,16)
    tube('Gauge needle',[(x-.038,-.909,z-.023),(x+.028,-.909,z+.035)],.005,Steel,5)
curve_tube('Control stick',[(0,-.15,.65),(0,-.30,.89),(0,-.25,1.06)],.033,Weapons)
cube('Stick grip',(0,-.25,1.07),(.16,.08,.06),Cockpit,.025)
for x in [-.26,.26]:cube('Rudder pedal',(x,-.83,.62),(.20,.30,.06),Steel,.025)

# Gull wings: airfoil rings, rounded tips, thicker root fillets.
WSEC=[(1.04,-1.0,1.85,-.28,.27),(1.43,-1.20,1.85,-.20,.25),(2.24,-1.32,1.72,.04,.21),(3.0,-1.23,1.61,.10,.18),(5.95,-.72,1.33,-.20,.11),(6.38,-.58,1.25,-.24,.075),(6.5,-.38,1.07,-.25,.018)]
def wingval(x):
    for a,b in zip(WSEC,WSEC[1:]):
        if a[0]<=x<=b[0]:
            t=(x-a[0])/(b[0]-a[0]);return [a[k]*(1-t)+b[k]*t for k in range(1,5)]
    return list(WSEC[-1][1:])
def wp(x,t,top=True,off=0):
    lead,trail,z,h=wingval(abs(x));yy=lead+(trail-lead)*t
    zz=z+(.1*math.sin(t*math.pi)+(1 if top else -1)*math.sin(math.pi*t)**.6)*h
    return (x,yy,zz+off)
def wing(side):
    vs=[];N=32
    for x,lead,trail,z,h in WSEC:
        for j in range(N):
            a=j*math.tau/N;t=(1-math.cos(a))*.5
            vs.append((side*x,lead+(trail-lead)*t,z+.10*math.sin(t*math.pi)*h+math.sin(a)*h))
    fs=[]
    for k in range(len(WSEC)-1):
        for j in range(N):fs.append((k*N+j,k*N+(j+1)%N,(k+1)*N+(j+1)%N,(k+1)*N+j))
    fs.extend([tuple(reversed(range(N))),tuple((len(WSEC)-1)*N+j for j in range(N))])
    mesh('Port gull wing' if side==1 else 'Starboard gull wing',vs,fs,Paint,True)
    # Panels and the sealed aileron line; no exposed gaps.
    for x in [2.3,3.1,4.6,6.12]:
        pts=[wp(side*x,t,True,.012) for t in np.linspace(.06,.95,18)];tube('Wing panel seam',pts,.007,Weapons,5)
        for t in np.linspace(.12,.9,10):rivet(wp(side*(x+.055),float(t),True,.012),(0,0,1),.018)
    for t in [.14,.79]:
        tube('Wing span lap join',[wp(side*x,t,True,.012) for x in np.linspace(1.45,6.35,45)],.007,Weapons,5)
        for x in np.arange(1.6,6.25,.26):rivet(wp(side*float(x),t+.026,True,.012),(0,0,1),.019)
    # Tip protective leading cap, narrow and smoothly following foil.
    tube('Wingtip steel rub strip',[wp(side*6.385,t,True,.014) for t in np.linspace(.04,.95,22)],.027,Steel,8)
    # Root fairing with attached recess and radiator lips.
    ellipsoid('Blended wing root fillet',(side*1.26,.18,-.15),(.27,1.73,.36),Paint,32,16)
    # Weapons saddle / ammunition feed blister.
    ellipsoid('Ammunition feed fairing',(side*3.0,.10,.06),(.40,1.57,.31),Paint,32,14)
    cube('Radiator intake rim',(side*3,-1.40,.12),(.65,.16,.29),Steel,.09)
    cube('Radiator intake recess',(side*3,-1.492,.12),(.54,.025,.19),Weapons,.06)
    for z in [.05,.11,.17]:cube('Intake louvre',(side*3,-1.514,z),(.48,.035,.017),Steel,.007)
    cube('Gun saddle',(side*3.0,-.04,-.33),(.75,1.25,.30),Weapons,.08)
    # Physical cartridge feed chest, with rounded end and reinforcing ribs.
    lathe('Drum ammunition housing',[(.02,.01),(.06,.25),(.18,.35),(.92,.35),(1.14,.28),(1.27,.09),(1.28,.01)],Weapons,32,side*3.0,-.46)
    for y in [.24,.46,.68,.90]:lathe('Ammo drum reinforcement',[(y-.022,.351),(y-.016,.368),(y+.016,.368),(y+.022,.351)],Steel,24,side*3.0,-.46)
for side in [-1,1]:wing(side)
# Four fixed autocannons, ribbed cooling jackets and recessed bores.
gun_existing=set(S.objects)
MUZZ=[]
for side in [-1,1]:
    for j,x in enumerate([2.78,3.23]):
        x*=side;z=-.48
        cylinder('Cannon breech',(x,.3,z),(x,-1.13,z),.16,Weapons,24)
        cylinder('Barrel receiver',(x,-.9,z),(x,-1.6,z),.13,Steel,24)
        for k in range(9):
            y=-1.02-k*.073;lathe('Receiver cooling rib',[(y-.018,.135),(y-.013,.155),(y+.013,.155),(y+.018,.135)],Weapons,16,x,z)
        cylinder('Autocannon barrel',(x,-1.40,z),(x,-3.60,z),.074,Weapons,20)
        for off in [-.038,.038]:
            cylinder('Barrel stiffening rib',(x+off,-1.65,z+.055),(x+off,-3.37,z+.055),.013,Steel,8)
        lathe('Perforated muzzle brake',[(-3.42,.105),(-3.48,.119),(-3.94,.119),(-3.99,.09),(-3.99,.058),(-3.77,.058)],Steel,24,x,z)
        cylinder('Black bore',(x,-3.775,z),(x,-3.77,z),.058,Weapons,20)
        for y in [-3.55,-3.71,-3.86]:
            for a in [0,math.pi/2,math.pi,3*math.pi/2]:
                p=(x+.118*math.cos(a),y,z+.118*math.sin(a))
                # Inset dark ports and raised circular rims read as drilled side vents.
                n=Vector((math.cos(a),0,math.sin(a)));cylinder('Muzzle vent',Vector(p)-n*.002,Vector(p)+n*.003,.029,Weapons,10)
        MUZZ.append((x,-4.0,z))
# Keep the gun battery compact, with tips aft of the forward exhaust bends.
for ob in set(S.objects)-gun_existing:
    if ob.type!='MESH':continue
    W=ob.matrix_world.copy();I=W.inverted()
    for vert in ob.data.vertices:
        pos=W@vert.co
        if pos.y<-.9:pos.y=-.9+(pos.y+.9)*.86
        vert.co=I@pos
MUZZ=[(x,-.9+(y+.9)*.86,z) for x,y,z in MUZZ]
# Curved exhaust stacks around the reactor, with backed oval manifold plates.
for side in [-1,1]:
    for upper in [True,False]:
        zz=.55 if upper else -.65
        ellipsoid('Exhaust manifold plate',(side*1.23,-3.93,zz),(.12,.97,.28),Weapons,24,12)
        for i in range(3):
            y=-4.59+i*.48
            pts=[(side*1.23,y,zz),(side*1.51,y+.03,zz-.06),(side*1.68,y+.23,zz-.28),(side*1.69,y+.47,zz-.48),(side*1.53,y+.58,zz-.53)]
            if i==2:pts.extend([(side*1.46,y+.94,zz-.53),(side*1.50,y+1.14,zz-.55)])
            curve_tube('Heat scored exhaust stack',pts,.115,Heat,14,6)
            # Trim collars at the welded manifold sockets.
            cylinder('Exhaust root collar',(side*1.25,y,zz),(side*1.40,y+.016,zz-.025),.14,Steel,16)
            p=Vector(pts[-1]);d=(p-Vector(pts[-2])).normalized()
            cylinder('Sooted exhaust mouth',p-d*.014,p+d*.007,.092,Weapons,16)
# Tailfin airfoil extrusion, rounded outline, thick engineering root.
outline=[(3.43,.55),(3.78,1.03),(4.14,1.80),(4.58,2.55),(4.78,2.65),(5.55,2.62),(5.66,2.48),(5.80,.63),(5.48,.34),(4.1,.36)]
def finmesh(name,outline,thick,mat):
    vs=[(s*thick,y,z) for s in [-1,1] for y,z in outline];N=len(outline)
    fs=[tuple(reversed(range(N))),tuple(N+i for i in range(N))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
    return bevel(mesh(name,vs,fs,mat),.065,3)
finmesh('Heavy dorsal tailfin',outline,.14,Paint)
finmesh('Ventral stability keel',[(3.80,-.47),(4.5,-1.35),(5.4,-1.25),(5.56,-.55)],.11,Paint)
for side in [-1,1]:
    curve_tube('Tail rudder perimeter',[(side*.147,5.24,2.53),(side*.147,5.28,2.0),(side*.147,5.43,.75)],.009,Weapons,5)
    for y,z in [(4.48,1.58),(4.78,2.51),(5.05,2.52),(5.3,2.52),(4.22,1.02),(4.55,1.06),(4.92,1.10),(5.35,1.13)]:rivet((side*.153,y,z),(side,0,0),.02)
# Horizontal stabilizers, swept rounded thick airfoils.
for side in [-1,1]:
    secs=[(.58,4.06,5.65,.08,.15),(1.12,4.0,5.69,.08,.13),(2.65,4.40,5.54,.04,.075),(2.87,4.56,5.43,.04,.02)]
    vs=[];N=24
    for x,lead,trail,z,h in secs:
        for j in range(N):
            a=j*math.tau/N;t=(1-math.cos(a))/2;vs.append((side*x,lead+(trail-lead)*t,z+math.sin(a)*h))
    fs=[(k*N+j,k*N+(j+1)%N,(k+1)*N+(j+1)%N,(k+1)*N+j) for k in range(3) for j in range(N)]
    fs.append(tuple(3*N+j for j in range(N)));mesh('Tailplane',vs,fs,Paint,True)
    tube('Elevator seam',[(side*x,5.37,.11) for x in np.linspace(.9,2.65,20)],.008,Weapons,6)
# Triple rocket pack: one upper nozzle, two lower, no central extra engine.
THRUST=[]
for i,(x,z) in enumerate([(0,.51),(-.55,-.40),(.55,-.40)],1):
    ellipsoid('Rocket engine armored nose',(x,4.51,z),(.48,.63,.46),Paint,24,12)
    lathe('Rocket pressure casing',[(4.45,.43),(4.58,.45),(5.39,.45),(5.51,.40)],Heat,40,x,z)
    for y in [4.67,5.32]:
        lathe('Rocket band clamp',[(y-.06,.45),(y-.035,.475),(y+.035,.475),(y+.06,.45)],Steel,32,x,z)
    lathe('Rocket throat',[(5.36,.36),(5.50,.34),(5.63,.38),(5.96,.48),(6.20,.46),(6.25,.43),(6.25,.37),(6.14,.35),(5.73,.22)],Weapons,48,x,z)
    lathe('Nozzle rim',[(6.15,.465),(6.22,.46),(6.25,.43),(6.25,.38)],Steel,48,x,z)
    inc=lathe('Incandescent inner nozzle',[(5.745,.216),(5.90,.267),(6.08,.324),(6.14,.342),(6.20,.357)],Engines,40,x,z)
    for lp in inc.data.loops:
        v=inc.data.vertices[lp.vertex_index].co;inc.data.uv_layers.active.data[lp.index].uv.y=(v.y-5.74)/.51
    core=cylinder('Rocket glowing throat',(x,5.744,z),(x,5.75,z),.215,Engines,32)
    uvworld(core,.10)
    for lp in core.data.loops:
        v=core.matrix_world@core.data.vertices[lp.vertex_index].co;core.data.uv_layers.active.data[lp.index].uv=(.5+(v.x-x)*.10,.045+(v.z-z)*.10)
    for k in range(12):
        a=k*math.tau/12
        r=.40
        curve_tube('Nozzle cooling rib',[(x+r*math.cos(a),5.5,z+r*math.sin(a)),(x+.48*math.cos(a),5.98,z+.48*math.sin(a)),(x+.46*math.cos(a),6.20,z+.46*math.sin(a))],.018,Heat,6,3)
    THRUST.append((x,6.25,z))
# Recessed maintenance bays, belly access plates and tie-downs.
for side in [-1,1]:
    for y in [1.98,3.53]:
        rx,rz,z=hullval(y)
        ellipsoid('Flush inspection hatch',(side*(rx+.006),y,-.25),(.027,.42,.31),Paint,24,12)
        for dy,dz in [(-.29,0),(.29,0),(0,.21),(0,-.21)]:rivet((side*(rx+.035),y+dy,-.25+dz),(side,0,0),.025)
for y in [-2.3,.15,2.3]:cube('Closed belly service door',(0,y,-1.265 if y<1 else -1.09),(.78,.92,.055),Paint,.12)
# Steel paint chips: deterministic small flakes physically conformed to hull and wing skin.
chipv=[];chipf=[]
def chip(p,u,v,size):
    b=len(chipv);N=RNG.choice([4,5,6]);p=Vector(p);u=Vector(u);v=Vector(v)
    for j in range(N):
        a=j*math.tau/N;rr=RNG.uniform(.65,1.15)*size;chipv.append(tuple(p+u*math.cos(a)*rr+v*math.sin(a)*rr*.35))
    chipf.append(tuple(b+j for j in range(N)))
for k in range(360):
    y=RNG.uniform(-5.3,4.25);a=RNG.uniform(0,math.tau)
    if -1.8<y<1.3 and math.sin(a)>.55:continue
    # Sparse longer scratches, plus small edge flakes.
    b=len(chipv);N=RNG.choice([4,5,6]);size=RNG.uniform(.017,.075)
    for j in range(N):
        angle=j*math.tau/N;rr=RNG.uniform(.65,1.15)*size
        chipv.append(hp(y+math.cos(angle)*rr,a+math.sin(angle)*rr*.35/hullval(y)[0],.0014))
    chipf.append(tuple(b+j for j in range(N)))
for side in [-1,1]:
    for k in range(120):
        x=RNG.uniform(1.6,6.3);t=RNG.choice([RNG.uniform(.025,.13),RNG.uniform(.2,.9)])
        b=len(chipv);N=5;size=RNG.uniform(.015,.062)
        for j in range(N):
            a=j*math.tau/N;rr=RNG.uniform(.65,1.15)*size
            chipv.append(wp(side*(x+math.cos(a)*rr),t+math.sin(a)*rr*.14,True,.0014))
        chipf.append(tuple(b+j for j in range(N)))
mesh('Exposed steel paint flakes',chipv,chipf,Steel)
mesh('Raised flush rivets',RIV_V,RIV_F,Steel,True)
# Light lenses sample the baked red / green / amber palette.
def lamp(name,loc,scale,col):
    o=ellipsoid(name,loc,scale,Lamps,16,8);attr=o.data.color_attributes.new(name='LampColor',type='FLOAT_COLOR',domain='CORNER')
    for c in attr.data:c.color=(1,1,1,1)
    center=1/2 if col[1]>.5 else (5/6 if col[1]>.05 else 1/6)
    for loop in o.data.uv_layers.active.data:loop.uv=(center+(loop.uv.x-.5)*.20,loop.uv.y)
    return o
lamp('Port navigation lens',(6.40,-.34,-.20),(.08,.13,.065),(1,.025,.012))
lamp('Starboard navigation lens',(-6.40,-.34,-.20),(.08,.13,.065),(.02,.8,.12))
lamp('Dorsal beacon',(0,2.7,1.07),(.08,.12,.07),(1,.11,.025))

# Consolidate fixed pieces and the moving canopy; transforms remain identity on moving nodes.
def join_at(obs,name,pivot):
    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;o.data.name=name
    # Put geometry into the desired origin without applying a moving-node transform.
    W=o.matrix_world.copy();P=Vector(pivot)
    for v in o.data.vertices:v.co=W@v.co-P
    o.matrix_world=Matrix.Translation(P)
    # Apply all remaining modifiers before final pivot assignment (normally none).
    for m in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=m.name)
    bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.dissolve_degenerate(bm,dist=1e-7,edges=list(bm.edges));bm.to_mesh(o.data);bm.free()
    return o
can=join_at(canopy_parts,'Canopy',(.90,.0,1.04))
fixed=[o for o in S.objects if o.type=='MESH' and o!=can]
hull=join_at(fixed,'Hull',(0,0,0))
can.parent=hull
# Ensure each material primitive has valid neutral colours where Lamps uses vertex colours.
for o in [hull,can]:
    if not o.data.color_attributes.get('LampColor'):
        ca=o.data.color_attributes.new(name='LampColor',type='FLOAT_COLOR',domain='CORNER')
        for d in ca.data:d.color=(1,1,1,1)
    else:
        ca=o.data.color_attributes['LampColor']
        for p in o.data.polygons:
            if o.data.materials[p.material_index]!=Lamps:
                for li in p.loop_indices:
                    color=1.0
                    if o.data.materials[p.material_index]==Paint:
                        pos=o.matrix_world@o.data.vertices[o.data.loops[li].vertex_index].co
                        x,y,z=pos;ax=abs(x)
                        # Grey soot masks preserve faction recolouring; tile detail remains baked.
                        if ax<1.48:
                            plume=math.exp(-((y+1.95)/1.38)**2)*(math.exp(-((z+.03)/.35)**2)+.65*math.exp(-((z+.92)/.30)**2))*min(1,ax/.8)
                            aft=math.exp(-((y-3.5)/1.12)**2)*math.exp(-((z+.12)/.74)**2)
                            color-=min(.57,.68*plume+.57*aft)
                        root=math.exp(-((ax-1.48)/.27)**2)*math.exp(-((y-.15)/1.28)**2)
                        color-=.18*root
                        color=max(.30,color)
                    ca.data[li].color=(color,color,color,1)

def empty(name,p,parent=hull):
    o=bpy.data.objects.new(name,None);S.collection.objects.link(o);o.empty_display_type='ARROWS';o.empty_display_size=.25;o.location=p;o.parent=parent;return o
for i,p in enumerate(THRUST,1):empty(f'Thruster_{i}',p)
for i,p in enumerate(MUZZ,1):empty(f'Muzzle_{i}',p)
for i,x in enumerate([-4.2,4.2],1):empty(f'Hardpoint_{i}',(x,.35,-.38))
empty('Seat_Pilot',(0,.43,.8));empty('Camera_Chase',(0,12.5,5.2));empty('Light_Nav_L',(6.4,-.34,-.2));empty('Light_Nav_R',(-6.4,-.34,-.2));empty('Light_Beacon',(0,2.7,1.07))
EXPORT=[hull,can]+[o for o in S.objects if o.type=='EMPTY']
bpy.context.view_layer.update()
pts=[o.matrix_world@v.co for o in [hull,can] for v in o.data.vertices]
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)]
# The intended exact hull envelope is span 13, length 12.5, height 4.0; rectify tiny bevel bounds.
scale=Vector((13/(hi[0]-lo[0]),12.5/(hi[1]-lo[1]),4/(hi[2]-lo[2])))
for o in [hull,can]:
    for v in o.data.vertices:v.co=[v.co[i]*scale[i] for i in range(3)]
for o in EXPORT:
    if o!=hull:o.location=[o.location[i]*scale[i] for i in range(3)]
bpy.context.view_layer.update()
# Retain silhouette and canopy articulation, collapse subpixel construction triangles.
rawtris=sum(len(o.data.polygons) for o in [hull,can])
if rawtris>85000:
    active(hull);dec=hull.modifiers.new('Game mesh optimization','DECIMATE');dec.ratio=(82000-len(can.data.polygons))/len(hull.data.polygons)
    bpy.ops.object.modifier_apply(modifier=dec.name)
    bm=bmesh.new();bm.from_mesh(hull.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.dissolve_degenerate(bm,dist=1e-7,edges=list(bm.edges));bm.to_mesh(hull.data);bm.free()
# Solidified glazing edge UVs initially have zero area; repair before tangent generation.
for ob in [hull,can]:
    uv=ob.data.uv_layers.active
    for face in ob.data.polygons:
        loops=list(face.loop_indices);coords=[uv.data[i].uv.copy() for i in loops]
        area_uv=abs(sum(coords[k].x*coords[(k+1)%len(coords)].y-coords[(k+1)%len(coords)].x*coords[k].y for k in range(len(coords))))
        if area_uv<1e-10:
            norm=face.normal;u=norm.cross(Vector((0,0,1)))
            if u.length<.1:u=norm.cross(Vector((0,1,0)))
            u.normalize();v=norm.cross(u).normalized()
            for li in loops:
                xyz=ob.data.vertices[ob.data.loops[li].vertex_index].co;uv.data[li].uv=(xyz.dot(u)*.62,xyz.dot(v)*.62)
TRIS=sum(len(p.vertices)-2 for o in [hull,can] for p in o.data.polygons);print('TRIANGLES',TRIS,'from',rawtris,flush=True)
assert 40000<=TRIS<=90000,TRIS
PAINTS=[{'name':'Olive drab','linear_rgb':[.25,.30,.20]},{'name':'Naval grey','linear_rgb':[.34,.40,.44]},{'name':'Desert sand','linear_rgb':[.62,.45,.25]},{'name':'Arctic white','linear_rgb':[.77,.81,.80]},{'name':'Deep navy','linear_rgb':[.045,.085,.145]},{'name':'Crimson','linear_rgb':[.34,.045,.035]}]
# Final tiny decimator shifts are corrected in mesh data and socket translations, not moving transforms.
pts=[o.matrix_world@v.co for o in [hull,can] for v in o.data.vertices]
low=Vector(tuple(min(v[i] for v in pts) for i in range(3)));high=Vector(tuple(max(v[i] for v in pts) for i in range(3)))
correction=Vector((13/(high.x-low.x),12.5/(high.y-low.y),4/(high.z-low.z)))
for ob in [hull,can]:
    for vert in ob.data.vertices:vert.co=[vert.co[i]*correction[i] for i in range(3)]
for ob in EXPORT:
    if ob!=hull:ob.location=[ob.location[i]*correction[i] for i in range(3)]
bpy.context.view_layer.update()
# Export with a neutral base texture; then set the standard glTF baseColorFactor.
active(hull)
for o in EXPORT:o.select_set(True)
GLB=ROOT/'dieselpunk-space-fighter.glb'
bpy.ops.export_scene.gltf(filepath=str(GLB),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True,export_image_format='AUTO')
def patchglb(path):
    raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);tail=bytearray(raw[20+jl:])
    # MikkTSpace can return zero tangents at opposing thin-glass bevels.
    # Repair only those vectors with a stable orthogonal basis; retain all valid UV-derived tangents.
    def attrib(index,width):
        ac=doc['accessors'][index];bv=doc['bufferViews'][ac['bufferView']]
        return np.ndarray((ac['count'],width),dtype='<f4',buffer=tail,offset=8+bv.get('byteOffset',0)+ac.get('byteOffset',0),strides=(bv.get('byteStride',width*4),4))
    fixed_tangents=0
    for me in doc['meshes']:
        for pr in me['primitives']:
            at=pr['attributes']
            if 'TANGENT' not in at:continue
            ta=attrib(at['TANGENT'],4);no=attrib(at['NORMAL'],3)
            for i in range(len(ta)):
                length=float(np.linalg.norm(ta[i,:3]))
                if length<1e-6:
                    ref=np.zeros(3);ref[int(np.argmin(np.abs(no[i])))]=1
                    vec=np.cross(ref,no[i]);vec/=np.linalg.norm(vec);ta[i,:3]=vec;ta[i,3]=1;fixed_tangents+=1
                elif abs(length-1)>1e-5:ta[i,:3]/=length
    print('TANGENT_EDGE_REPAIRS',fixed_tangents,flush=True)
    for m in doc['materials']:
        if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.25,.30,.20,1]
        if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
    s=json.dumps(doc,separators=(',',':')).encode();s+=b' '*((-len(s))%4)
    path.write_bytes(struct.pack('<4sII',b'glTF',2,12+8+len(s)+len(tail))+struct.pack('<I4s',len(s),b'JSON')+s+tail)
    return doc
DOC=patchglb(GLB)
TRIS=sum(DOC['accessors'][p['indices']]['count']//3 for me in DOC['meshes'] for p in me['primitives'])
assert GLB.stat().st_size<=12*1024*1024,GLB.stat().st_size

def glpos(p):return [round(float(p[0]),6),round(float(p[2]),6),round(float(-p[1]),6)]
pts=[o.matrix_world@v.co for o in [hull,can] for v in o.data.vertices];gpts=[glpos(p) for p in pts]
NODES={'units':'metres','coordinate_system':{'up':'+Y','forward':'+Z','left':'+X'},'dimensions':{'length':12.5,'wingspan':13.0,'height':4.0},'bounding_box':{'min':[min(p[i] for p in gpts) for i in range(3)],'max':[max(p[i] for p in gpts) for i in range(3)]},'axes':{'Canopy':[0,0,-1]},'paints':PAINTS,'nodes':{o.name:{'world_position':glpos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'empty'} for o in EXPORT},'moving_parts':{'Canopy':{'pivot':'port longitudinal side hinge','axis_parent':[0,0,-1],'range_degrees':[0,75]}},'weapons':'Four fixed autocannons; no turret or gun articulation. Muzzles are Hull children.','thruster_exhaust_direction':[0,0,-1],'triangles':TRIS,'materials':[m['name'] for m in DOC['materials']],'glb_bytes':GLB.stat().st_size}
(ROOT/'nodes.json').write_text(json.dumps(NODES,indent=2));(ROOT/'bake_manifest.json').write_text(json.dumps(BAKES,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join([
'Original realistic dieselpunk fighter reconstructed from sheet.jpg; 12.5 m length × 13.0 m span × 4.0 m height; fixed four-gun battery, hinged canopy, cockpit and three rocket nozzles.',
f'{TRIS:,} triangles; {GLB.stat().st_size/1048576:.2f} MiB GLB; two mesh nodes and fourteen named sockets.',
'Materials (8): Paint, Steel, Heat, Weapons, Cockpit, Glass, Engines, Lamps; neutral Paint texture × linear olive factor [0.25, 0.30, 0.20].',
'Textures: shared Cycles-baked tiled base/normal 64–1024 px; packed ORM 64–512 px; no exported procedural surface shaders.',
'Poly Haven CC0 ids: metal_plate_02, rusty_metal_02; https://polyhaven.com/a/metal_plate_02 ; https://polyhaven.com/a/rusty_metal_02 ; licence https://polyhaven.com/license .',
'Differences: original nozzle cooling ribs and service hatches; panel and chip placement approximates the drawing; fixed guns, no turret articulation.'
])+'\n')
# Paint render multiplier; standard GLB factor was set explicitly above.
p,bc=BIND['Paint'];deposit=Paint.node_tree.nodes.new('ShaderNodeVertexColor');deposit.layer_name='LampColor'
depmix=Paint.node_tree.nodes.new('ShaderNodeMixRGB');depmix.blend_type='MULTIPLY';depmix.inputs[0].default_value=1;Paint.node_tree.links.new(bc.outputs[0],depmix.inputs[1]);Paint.node_tree.links.new(deposit.outputs['Color'],depmix.inputs[2])
mix=Paint.node_tree.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;Paint.node_tree.links.new(depmix.outputs[0],mix.inputs[1]);Paint.node_tree.links.new(mix.outputs[0],p.inputs['Base Color'])
def setpaint(rgb):mix.inputs[2].default_value=(*rgb,1)
setpaint(PAINTS[0]['linear_rgb'])
# Near-black space world, faint blue vertical gradient and soft white daylight key.
world=bpy.data.worlds.new('Deep space blue');S.world=world;world.use_nodes=True;wn=world.node_tree.nodes;wl=world.node_tree.links;bg=wn.get('Background');bg.inputs['Strength'].default_value=.30
tex=wn.new('ShaderNodeTexCoord');sep=wn.new('ShaderNodeSeparateXYZ');wl.new(tex.outputs['Normal'],sep.inputs[0]);r=wn.new('ShaderNodeValToRGB');r.color_ramp.elements[0].position=.05;r.color_ramp.elements[0].color=(.004,.007,.016,1);r.color_ramp.elements[1].position=.85;r.color_ramp.elements[1].color=(.025,.045,.085,1);wl.new(sep.outputs['Z'],r.inputs[0]);wl.new(r.outputs[0],bg.inputs[0])
lp=wn.new('ShaderNodeLightPath');ambient=wn.new('ShaderNodeBackground');ambient.inputs['Color'].default_value=(.24,.29,.38,1);ambient.inputs['Strength'].default_value=.25
mixw=wn.new('ShaderNodeMixShader');wl.new(lp.outputs['Is Camera Ray'],mixw.inputs[0]);wl.new(ambient.outputs[0],mixw.inputs[1]);wl.new(bg.outputs[0],mixw.inputs[2]);wl.new(mixw.outputs[0],wn.get('World Output').inputs[0])
def area(name,pos,energy,size,col):
    d=bpy.data.lights.new(name,'AREA');o=bpy.data.objects.new(name,d);S.collection.objects.link(o);o.location=pos;o.rotation_euler=(Vector((0,0,0))-o.location).to_track_quat('-Z','Y').to_euler();d.energy=energy;d.shape='DISK';d.size=size;d.color=col
area('Nose softbox',(0,-10,3),450,7,(.86,.91,1));area('White upper-front key',(3,-7,10),2100,7,(1,.96,.90));area('Cool broad fill',(-7,-2,4),900,8,(.57,.73,1));area('Tail rim',(2,8,7),2350,6,(.76,.86,1));area('Low soft bounce',(3,-3,-5),420,7,(.62,.72,1))
camd=bpy.data.cameras.new('RenderCamera');cam=bpy.data.objects.new('RenderCamera',camd);S.collection.objects.link(cam);S.camera=cam
TARGET=Vector((0,-.15,.35));DIRECTION=Vector((1.08,-1.46,.91)).normalized()
# Camera framing computed from all mesh bounds, using projected extents.
def camera_view(direction,ortho=False):
    points=[o.matrix_world@v.co for o in [hull,can] for v in o.data.vertices];ctr=TARGET
    cam.location=ctr+Vector(direction).normalized()*24;cam.rotation_euler=(ctr-cam.location).to_track_quat('-Z','Y').to_euler()
    camd.type='ORTHO' if ortho else 'PERSP';camd.lens=48
    q=cam.rotation_euler.to_quaternion().inverted();pp=[q@(p-ctr) for p in points];aspect=S.render.resolution_x/S.render.resolution_y
    spanx=max(p.x for p in pp)-min(p.x for p in pp);spany=max(p.y for p in pp)-min(p.y for p in pp)
    if ortho:camd.ortho_scale=max(spanx,spany*aspect)*1.15
    else:
        # Long lens gives reference-like industrial product perspective.
        camd.lens=52;hfov=camd.angle_x;vfov=2*math.atan(math.tan(hfov/2)/aspect)
        distance=max(abs(p.x)/math.tan(hfov/2)+p.z for p in pp)
        distance=max(distance,max(abs(p.y)/math.tan(vfov/2)+p.z for p in pp))*1.08
        cam.location=ctr+Vector(direction).normalized()*distance

def render(filename,w=1920,h=1080,samples=32):
    S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=str(ROOT/filename);bpy.ops.render.render(write_still=True)
S.render.resolution_x=1920;S.render.resolution_y=1080;camera_view(DIRECTION)
# Clay is independently rendered, then baked materials restored.
clay=simple('QA clay',(.38,.40,.43),.05,.43)
old={o:[x for x in o.data.materials] for o in [hull,can]}
for o in [hull,can]:
    for i in range(len(o.data.materials)):o.data.materials[i]=clay
render('clay.png',1920,1080,20)
for o in [hull,can]:
    for i,m in enumerate(old[o]):o.data.materials[i]=m
render('final.png',1920,1080,48)
camera_view((1,0,.04),True);render('side.png',1920,1080,24)
camera_view(DIRECTION)
# A 30-degree test rotates only the canopy about its local longitudinal hinge.
pivot0=can.matrix_world.translation.copy();can.rotation_euler.y=math.radians(30);bpy.context.view_layer.update();assert (can.matrix_world.translation-pivot0).length<1e-6
render('canopy_test.png',1280,720,20);can.rotation_euler.y=0;bpy.context.view_layer.update()
# Rear proves all three internal nozzle materials and sockets.
camera_view((1,1.9,.8));render('rear.png',1280,720,24)
# Six matched cameras, rendered in Cycles, assembled into a 3x2 grid without text.
S.render.resolution_x=640;S.render.resolution_y=360;camera_view(DIRECTION)
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,preset in enumerate(PAINTS):
    setpaint(preset['linear_rgb']);fn=f'paint_{i}.png';render(fn,640,360,16)
    im=bpy.data.images.load(str(ROOT/fn));arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4)
    row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640,:]=arr
    bpy.data.images.remove(im);(ROOT/fn).unlink()
im=bpy.data.images.new('Six faction paints',1920,720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
setpaint(PAINTS[0]['linear_rgb']);S.render.resolution_x=1920;S.render.resolution_y=1080;camera_view(DIRECTION)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'source.blend'))
print('COMPLETE',TRIS,'triangles',GLB.stat().st_size,'bytes',round(time.time()-START,1),'seconds',flush=True)
