#!/usr/bin/env python3
"""B-2 inspired 52.4 m flying wing. Blender 5.2 clean-scene generator.
Run: blender -b --python-exit-code 1 --python build_b2-bomber.py
Only photographic inputs: CC0 Poly Haven metal_plate and rubber_tiles.
Geometry is authored Z-up, nose -Y; glTF conversion produces Y-up, nose +Z.
"""
import bpy, bmesh, math, os, json, sys, time, urllib.request, subprocess, struct, zlib
from pathlib import Path
from mathutils import Vector
from math import sin,cos,pi,exp,sqrt
import numpy as np
ROOT=Path(__file__).resolve().parent
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
START=time.time()
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials): bpy.data.materials.remove(m)
sc=bpy.context.scene
sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
sc.render.engine='CYCLES'; sc.cycles.samples=32; sc.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()
        for d in prefs.devices: d.use=d.type!='CPU'
        if any(d.use for d in prefs.devices): break
    except Exception: pass
sc.cycles.device='GPU'; sc.render.image_settings.file_format='PNG'
sc.render.film_transparent=False
sc.world.color=(.5,.5,.5)
sc.view_settings.view_transform='AgX'
sc.render.bake.margin=8
print('DEVICE',prefs.compute_device_type,flush=True)
GROUPS={}; MATERIALS={}; BAKE_LOG=[]

def fetch(asset,mapname):
    p=CACHE/f'{asset}_{mapname}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mapname}_1k.jpg'
        subprocess.run(['curl','--fail','-L','--retry','2','-A','Mozilla/5.0','-sS',url,'-o',str(p)],check=True)
    return bpy.data.images.load(str(p),check_existing=True)

def mat(name,col,rough=.7,metal=0):
    m=bpy.data.materials.new(name); m.use_nodes=True
    b=m.node_tree.nodes.get('Principled BSDF'); b.inputs['Base Color'].default_value=(*col,1)
    b.inputs['Roughness'].default_value=rough; b.inputs['Metallic'].default_value=metal
    MATERIALS[name]=m; return m

def bake_tile(name,asset,col,rough,metal,res=512):
    m=mat(name,col,rough,metal); n=m.node_tree.nodes; l=m.node_tree.links
    b=n.get('Principled BSDF'); out=n.get('Material Output')
    uv=n.new('ShaderNodeTexCoord')
    diff=n.new('ShaderNodeTexImage'); diff.image=fetch(asset,'diff'); l.new(uv.outputs['UV'],diff.inputs['Vector'])
    arm=n.new('ShaderNodeTexImage'); arm.image=fetch(asset,'arm'); arm.image.colorspace_settings.name='Non-Color'; l.new(uv.outputs['UV'],arm.inputs['Vector'])
    nor=n.new('ShaderNodeTexImage'); nor.image=fetch(asset,'nor_gl'); nor.image.colorspace_settings.name='Non-Color'; l.new(uv.outputs['UV'],nor.inputs['Vector'])
    bw=n.new('ShaderNodeRGBToBW'); l.new(diff.outputs['Color'],bw.inputs[0])
    ramp=n.new('ShaderNodeValToRGB'); l.new(bw.outputs[0],ramp.inputs[0]); ramp.color_ramp.elements[0].position=.05; ramp.color_ramp.elements[1].position=.85
    if name=='Paint': lo=(.61,.61,.61,1); hi=(1,1,1,1)
    else: lo=tuple(v*.65 for v in col)+(1,); hi=tuple(v*1.18 for v in col)+(1,)
    ramp.color_ramp.elements[0].color=lo; ramp.color_ramp.elements[1].color=hi
    noise=n.new('ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=2.1 if name=='Paint' else 35; noise.inputs['Detail'].default_value=3; l.new(uv.outputs['UV'],noise.inputs['Vector'])
    if name=='Paint':
        # A torus embedding gives mathematically periodic macro weathering at tile edges.
        noise.noise_dimensions='4D';split=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],split.inputs[0]);trig=[]
        for axis in ['X','Y']:
            angle=n.new('ShaderNodeMath');angle.operation='MULTIPLY';angle.inputs[1].default_value=2*pi;l.new(split.outputs[axis],angle.inputs[0])
            for operation in ['COSINE','SINE']:
                op=n.new('ShaderNodeMath');op.operation=operation;l.new(angle.outputs[0],op.inputs[0]);trig.append(op.outputs[0])
        xyz=n.new('ShaderNodeCombineXYZ')
        for i in range(3):l.new(trig[i],xyz.inputs[i])
        l.new(xyz.outputs[0],noise.inputs['Vector']);l.new(trig[3],noise.inputs['W'])
    mix=n.new('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=.35 if name=='Paint' else .08; l.new(ramp.outputs[0],mix.inputs[1]); l.new(noise.outputs['Fac'],mix.inputs[2]); l.new(mix.outputs[0],b.inputs['Base Color'])
    if name=='Paint':
        contrast=n.new('ShaderNodeValToRGB');contrast.color_ramp.elements[0].position=.32;contrast.color_ramp.elements[0].color=(.40,.40,.40,1);contrast.color_ramp.elements[1].position=.68;contrast.color_ramp.elements[1].color=(1,1,1,1);l.new(noise.outputs['Fac'],contrast.inputs[0]);l.new(contrast.outputs[0],mix.inputs[2])
    sep=n.new('ShaderNodeSeparateColor'); l.new(arm.outputs['Color'],sep.inputs[0])
    rm=n.new('ShaderNodeMath'); rm.operation='MULTIPLY_ADD'; rm.inputs[1].default_value=.22; rm.inputs[2].default_value=rough-.11; l.new(sep.outputs['Green'],rm.inputs[0]); l.new(rm.outputs[0],b.inputs['Roughness'])
    normal=n.new('ShaderNodeNormalMap'); normal.inputs['Strength'].default_value=.13 if name=='Paint' else .4; l.new(nor.outputs['Color'],normal.inputs['Color']); l.new(normal.outputs[0],b.inputs['Normal'])
    bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,-100)); plane=bpy.context.object; plane.data.materials.append(m)
    sc.cycles.samples=8
    images={}
    emission=n.new('ShaderNodeEmission'); l.new(emission.outputs[0],out.inputs['Surface'])
    for kind,size in [('base',res),('normal',res),('orm',min(512,res))]:
        im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=(kind=='base' and name=='Grime'))
        im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color'
        target=n.new('ShaderNodeTexImage'); target.image=im; n.active=target
        if kind=='base':
            l.new(mix.outputs[0],emission.inputs['Color']); bpy.ops.object.bake(type='EMIT')
        elif kind=='normal':
            l.new(b.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='NORMAL'); l.new(emission.outputs[0],out.inputs['Surface'])
        else:
            comb=n.new('ShaderNodeCombineColor'); comb.inputs['Red'].default_value=1; comb.inputs['Blue'].default_value=metal; l.new(rm.outputs[0],comb.inputs['Green']); l.new(comb.outputs[0],emission.inputs['Color']); bpy.ops.object.bake(type='EMIT')
        im.filepath_raw=str(CACHE/f'{name}_{kind}.png'); im.file_format='PNG'; im.save(); images[kind]=im
    bpy.data.objects.remove(plane,do_unlink=True)
    n.clear(); out=n.new('ShaderNodeOutputMaterial'); b=n.new('ShaderNodeBsdfPrincipled'); l.new(b.outputs[0],out.inputs[0])
    for kind,im in images.items():
        t=n.new('ShaderNodeTexImage'); t.image=im
        if kind=='base': l.new(t.outputs['Color'],b.inputs['Base Color'])
        elif kind=='normal':
            no=n.new('ShaderNodeNormalMap'); l.new(t.outputs['Color'],no.inputs['Color']); l.new(no.outputs[0],b.inputs['Normal'])
        else:
            se=n.new('ShaderNodeSeparateColor'); l.new(t.outputs[0],se.inputs[0]); l.new(se.outputs['Green'],b.inputs['Roughness']); l.new(se.outputs['Blue'],b.inputs['Metallic'])
            # Standard glTF occlusion semantic.
            gn=bpy.data.node_groups.get('glTF Material Output')
            if gn is None:
                gn=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); gn.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
            g=n.new('ShaderNodeGroup'); g.node_tree=gn; l.new(se.outputs['Red'],g.inputs['Occlusion'])
    BAKE_LOG.append({'material':name,'source':asset,'passes':['Cycles EMIT base colour','Cycles tangent NORMAL','Cycles EMIT packed ORM'],'samples':8,'device':prefs.compute_device_type,'sizes':[res,res,min(512,res)]})
    print('BAKED',name,round(time.time()-START,1),flush=True)
    return m

# Small execution probe exercises actual GPU bake and glTF exporter before full asset.
Paint=bake_tile('Paint','metal_plate',(1,1,1),.69,0,1024)
Metal=bake_tile('Metal','metal_plate',(.34,.36,.37),.36,.8,512)
Rubber=bake_tile('Rubber','rubber_tiles',(.033,.035,.038),.83,0,512)
Interior=bake_tile('Interior','metal_plate',(.033,.040,.046),.69,.12,512)
# Broad weathering uses a separately baked, soft transparent dirt tile: neutral grime,
# not a second paint material. Every source remains the same CC0 photographic plate.
Grime=bake_tile('Grime','metal_plate',(.030,.032,.034),.91,0,512)
def bake_grime_opacity():
    m=bpy.data.materials.new('_Opacity bake');m.use_nodes=True;n=m.node_tree.nodes;l=m.node_tree.links;n.clear()
    out=n.new('ShaderNodeOutputMaterial');em=n.new('ShaderNodeEmission');l.new(em.outputs[0],out.inputs[0])
    uv=n.new('ShaderNodeTexCoord');sep=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],sep.inputs[0])
    def mathnode(op,a,b=None):
        q=n.new('ShaderNodeMath');q.operation=op
        if isinstance(a,(int,float)):q.inputs[0].default_value=a
        else:l.new(a,q.inputs[0])
        if b is not None:
            if isinstance(b,(int,float)):q.inputs[1].default_value=b
            else:l.new(b,q.inputs[1])
        return q.outputs[0]
    x=sep.outputs['X'];y=sep.outputs['Y'];edge=mathnode('MULTIPLY',mathnode('MULTIPLY',x,mathnode('SUBTRACT',1,x)),mathnode('MULTIPLY',y,mathnode('SUBTRACT',1,y)))
    edge=mathnode('POWER',mathnode('MULTIPLY',edge,16),1.5)
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=5;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs[0])
    tex=n.new('ShaderNodeTexImage');tex.image=fetch('metal_plate','diff');l.new(uv.outputs['UV'],tex.inputs[0]);bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs[0],bw.inputs[0])
    opacity=mathnode('MULTIPLY',mathnode('MULTIPLY',edge,noise.outputs['Fac']),mathnode('MULTIPLY_ADD',bw.outputs[0],.70))
    # For multiply_add the third default input is .5, preserving a low contrast photographic mask.
    l.new(opacity,em.inputs['Color'])
    image=bpy.data.images.new('_Baked grime opacity',width=512,height=512,alpha=False);image.colorspace_settings.name='Non-Color';target=n.new('ShaderNodeTexImage');target.image=image;n.active=target
    bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,-100));plane=bpy.context.object;plane.data.materials.append(m);sc.cycles.samples=8;bpy.ops.object.bake(type='EMIT')
    base=bpy.data.images.get('Grime_base');pixels=np.array(base.pixels[:],dtype=np.float32).reshape(-1,4);mask=np.array(image.pixels[:],dtype=np.float32).reshape(-1,4)[:,0];pixels[:,3]=np.clip(mask*.75,0,.58);base.pixels.foreach_set(pixels.ravel());base.save()
    bpy.data.objects.remove(plane,do_unlink=True);bpy.data.materials.remove(m);bpy.data.images.remove(image)
    nodes=Grime.node_tree.nodes;links=Grime.node_tree.links;bs=nodes.get('Principled BSDF');bn=next(q for q in nodes if q.type=='TEX_IMAGE' and q.image==base);links.new(bn.outputs['Alpha'],bs.inputs['Alpha']);Grime.surface_render_method='DITHERED'
    BAKE_LOG[-1]['passes'].append('Cycles EMIT soft opacity mask')
bake_grime_opacity()
Glass=mat('Glass',(.065,.10,.125),.19,.05)
gb=Glass.node_tree.nodes.get('Principled BSDF'); gb.inputs['Alpha'].default_value=.65; gb.inputs['Transmission Weight'].default_value=.12; gb.inputs['IOR'].default_value=1.46
Glass.surface_render_method='BLENDED'; Glass.diffuse_color=(.065,.10,.125,.65)
Lamps=mat('Lamps',(1,.8,.6),.22,.0); lb=Lamps.node_tree.nodes.get('Principled BSDF'); lb.inputs['Emission Color'].default_value=(1,.65,.3,1); lb.inputs['Emission Strength'].default_value=3

# Helpers: all object meshes carry real metre UVs (3m painted tile, .5m trim).
def add_group(o,group): GROUPS.setdefault(group,[]).append(o); return o

def mesh(name,verts,faces,material=Paint,group='Fuselage',smooth=True,uvscale=3):
    me=bpy.data.meshes.new(name); me.from_pydata(verts,[],faces); me.update()
    o=bpy.data.objects.new(name,me); sc.collection.objects.link(o); me.materials.append(material)
    for p in me.polygons:p.use_smooth=smooth
    uv=me.uv_layers.new(name='UVMap')
    for p in me.polygons:
        a=max(range(3),key=lambda i:abs(p.normal[i])); axes=[i for i in range(3) if i!=a]
        for li in p.loop_indices:
            co=me.vertices[me.loops[li].vertex_index].co; uv.data[li].uv=(co[axes[0]]/uvscale,co[axes[1]]/uvscale)
    return add_group(o,group)

def bevel(o,w=.03,s=3):
    bpy.context.view_layer.objects.active=o; o.select_set(True)
    mod=o.modifiers.new('Manufactured edge radii','BEVEL'); mod.width=w; mod.segments=s
    bpy.ops.object.modifier_apply(modifier=mod.name); o.select_set(False)
    return o

def cube(name,loc,scale,material=Paint,group='Fuselage',bev=.03):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.name=name; o.dimensions=scale; bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    o.data.materials.append(material); bevel(o,bev,3)
    # box projected UV
    uv=o.data.uv_layers.active
    for p in o.data.polygons:
        a=max(range(3),key=lambda i:abs(p.normal[i])); axes=[i for i in range(3) if i!=a]
        for li in p.loop_indices:
            co=o.data.vertices[o.data.loops[li].vertex_index].co; uv.data[li].uv=(co[axes[0]],co[axes[1]])
    return add_group(o,group)

def cylinder(name,a,b,r,material=Metal,group='Fuselage',vertices=20):
    d=Vector(b)-Vector(a); center=(Vector(a)+Vector(b))/2
    bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=r,depth=d.length,location=center)
    o=bpy.context.object; o.name=name; o.rotation_euler=d.to_track_quat('Z','Y').to_euler(); bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    o.data.materials.append(material); bevel(o,min(.018,r*.15),2)
    for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
    return add_group(o,group)

def curve(name,points,r,material=Interior,group='Fuselage'):
    cu=bpy.data.curves.new(name,'CURVE'); cu.dimensions='3D'; cu.resolution_u=1; cu.bevel_depth=r; cu.bevel_resolution=1
    sp=cu.splines.new('POLY'); sp.points.add(len(points)-1)
    for p,c in zip(sp.points,points):p.co=(*c,1)
    o=bpy.data.objects.new(name,cu); sc.collection.objects.link(o); cu.materials.append(material)
    bpy.context.view_layer.objects.active=o; o.select_set(True); bpy.ops.object.convert(target='MESH'); o.select_set(False)
    return add_group(o,group)

def torus(name,loc,major,minor,material,group,rotation=(0,pi/2,0),majseg=48,minseg=12):
    bpy.ops.mesh.primitive_torus_add(major_segments=majseg,minor_segments=minseg,location=loc,rotation=rotation,major_radius=major,minor_radius=minor)
    o=bpy.context.object; o.name=name; o.data.materials.append(material)
    for p in o.data.polygons:p.use_smooth=True
    return add_group(o,group)

def empty(name,loc):
    o=bpy.data.objects.new(name,None); sc.collection.objects.link(o); o.location=loc; o.empty_display_size=.3; return o

# Swept, continuous flying-wing airfoil with a 10-point re-entrant trailing edge.
TX=[0,4.25,6.8,13.4,21.8,26.2]; TY=[9.2,6.3,8.25,3.8,9.2,6.4]
def trailing(x): return float(np.interp(abs(x),TX,TY))
def leading(x): return -11.8+18.2*abs(x)/26.2

def surface(x,y,top=True):
    a=abs(x); le=leading(x); te=trailing(x); t=max(0,min(1,(y-le)/(te-le)))
    # Near-elliptical leading-edge radius, long reflexed tail, gentle spanwise taper.
    thick=(.10+1.04*exp(-(a/8.5)**1.65)+.13*(1-a/26.2))*max(0,sin(pi*t))**.72
    mean=3.12-.12*(a/26.2)**1.6+.09*sin(pi*t)
    if top:
        cockpit=.78*exp(-(x/1.8)**4)*exp(-((y+5.0)/3.35)**4)
        return mean+thick+cockpit
    return mean-1.0*thick

xs=sorted(set([round(float(x),6) for x in np.linspace(-26.2,26.2,201)]+[s*x for x in TX+[17.8,24.6] for s in [-1,1]]))
nt=104; wingverts=[]
for layer in [True,False]:
    for x in xs:
        for j in range(nt+1):
            t=j/nt; y=leading(x)+(trailing(x)-leading(x))*t
            wingverts.append((x,y,surface(x,y,layer)))
N=len(xs)*(nt+1); faces_by={}
def face_group(x,t,layer):
    suffix='L' if x>0 else 'R'
    if abs(x)>=17.8 and abs(x)<24.6 and t>=.725: return ('DragRudder_' if layer==0 else 'DragRudderLower_')+suffix
    if abs(x)>=6.8 and abs(x)<17.8 and t>=.825: return 'Elevon_'+suffix
    return 'Fuselage'
for layer in range(2):
    for i in range(len(xs)-1):
        for j in range(nt):
            x=(xs[i]+xs[i+1])/2; t=(j+.5)/nt; y=leading(x)+(trailing(x)-leading(x))*t
            if layer==0 and abs(x)<1.98 and -8.60<y<-4.30: continue
            ids=[layer*N+i*(nt+1)+j,layer*N+(i+1)*(nt+1)+j,layer*N+(i+1)*(nt+1)+j+1,layer*N+i*(nt+1)+j+1]
            if layer==1:ids.reverse()
            faces_by.setdefault(face_group(x,t,layer),[]).append(ids)
# real thickness at edge, avoid collapsed tip edges; leading/trailing closed tiny-radius ribbons.
for i in range(len(xs)-1):
    for j in [0,nt]:
        a=i*(nt+1)+j;b=(i+1)*(nt+1)+j
        # top/bottom exact meet at rim; do not emit zero-area quads.
for group,faces in faces_by.items():
    if group.startswith('Elevon'):
        # Close every hinge and end cut with the actual airfoil thickness.
        edges={}
        for f in faces:
            for a,b in zip(f,f[1:]+f[:1]):
                key=tuple(sorted((a,b)));edges[key]=edges.get(key,0)+1
        present=set(v for f in faces for v in f)
        caps=[]
        for (a,b),count in edges.items():
            if count==1 and a<N and b<N and a+N in present and b+N in present:
                caps.append((b,a,a+N,b+N))
        faces.extend(caps)
    ids=sorted(set(i for f in faces for i in f)); lookup={a:i for i,a in enumerate(ids)}
    ob=mesh(group+'_airfoil',[wingverts[i] for i in ids],[[lookup[i] for i in f] for f in faces],Paint,group)
    # Weld the coincident leading/trailing vertices wherever a rigid assembly includes both skins.
    bm=bmesh.new(); bm.from_mesh(ob.data); bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.00002); bm.to_mesh(ob.data); bm.free()
    if group.startswith('DragRudder'):
        solid=ob.modifiers.new('Split drag rudder real skin thickness','SOLIDIFY');solid.thickness=.026;solid.offset=-1
        bpy.context.view_layer.objects.active=ob;bpy.ops.object.modifier_apply(modifier=solid.name)
print('WING',sum(len(o.data.polygons) for arr in GROUPS.values() for o in arr),flush=True)

# Cockpit glazing is part of the smooth continuous nose envelope, with flush frames.
# Each pane uses the same analytic blended surface as the fuselage, avoiding floating boxes.
window_polys=[]
for sgn in [-1,1]:
    window_polys.append([(sgn*x,y) for x,y in [(.11,-8.18),(1.18,-7.76),(1.18,-6.14),(.11,-6.50)]])
    window_polys.append([(sgn*x,y) for x,y in [(1.30,-7.64),(1.76,-6.94),(1.76,-4.73),(1.30,-5.0)]])
def inside_poly(x,y,poly):
    inside=False
    for (ax,ay),(bx,by) in zip(poly,poly[1:]+poly[:1]):
        if (ay>y)!=(by>y) and x<(bx-ax)*(y-ay)/(by-ay)+ax:inside=not inside
    return inside
pv=[];pf=[];glassfaces=[];nx,ny=88,92
for j in range(ny+1):
    y=-8.90+4.90*j/ny
    for i in range(nx+1):
        x=-2.20+4.40*i/nx;pv.append((x,y,surface(x,y)+.014))
for j in range(ny):
    for i in range(nx):
        a=j*(nx+1)+i;pf.append((a,a+1,a+nx+2,a+nx+1))
        x=-2.20+4.40*(i+.5)/nx;y=-8.90+4.90*(j+.5)/ny
        glassfaces.append(any(inside_poly(x,y,poly) for poly in window_polys))
canopy=mesh('Continuous cockpit shell',pv,pf,Paint,'Canopy');canopy.data.materials.append(Glass)
for poly,isglass in zip(canopy.data.polygons,glassfaces):poly.material_index=int(isglass)
for k,poly in enumerate(window_polys):
    points=[]
    for a,b in zip(poly,poly[1:]+poly[:1]):
        for j in range(24):
            t=j/24;x=a[0]*(1-t)+b[0]*t;y=a[1]*(1-t)+b[1]*t
            points.append((x,y,surface(x,y)+.022))
    points.append(points[0]);curve('Flush windshield structural mullion',points,.045,Paint,'Canopy')
# Small aft hinge knuckles lie on the actual shell seam.
for x in [-.8,.8]:
    z=surface(x,-4.15)+.028;cylinder('Canopy hinge',(x-.15,-4.15,z),(x+.15,-4.15,z),.036,Metal,'Canopy')
# Rear opaque deck covers glass perimeter with a smooth roof continuation.
cube('Cockpit sill',(0,-6.25,3.88),(2.75,3.4,.16),Interior,'Fuselage',.10)
for s in [-1,1]:
    seat=cube('Ejection seat cushion',(s*.58,-5.50,4.12),(.55,.63,.14),Rubber,'Fuselage',.08)
    seat=cube('Ejection seat back',(s*.58,-5.15,4.43),(.52,.15,.63),Interior,'Fuselage',.06);seat.rotation_euler.x=.13
    cube('Head restraint',(s*.58,-5.15,4.79),(.33,.22,.23),Rubber,'Fuselage',.06)
    cylinder('Control column',(s*.58,-5.91,4.05),(s*.58,-6.04,4.40),.035,Metal)
    torus('Control grip',(s*.58,-6.10,4.43),.12,.026,Rubber,'Fuselage',(pi/2,0,0),24,8)
    empty('Seat_Pilot' if s==1 else 'Seat_Copilot',(s*.58,-5.50,4.19))
    for j in range(3):cube('Multifunction display',(s*(.30+j*.27),-6.88,4.36),(.22,.05,.19),Glass,'Fuselage',.015)
cube('Instrument coaming',(0,-6.85,4.20),(2.1,.48,.25),Interior,'Fuselage',.08)

# Buried engine intake shells. A thick rounded cowl edge, dark duct and internal vanes.
for s in [-1,1]:
    cx=s*4.5
    profile=[(float(x),.58*(.5+.5*cos(pi*x/1.43))**.68) for x in np.linspace(-1.43,1.43,41)]
    ys=np.linspace(-4.65,5.15,38)
    vs=[]
    for y in ys:
        t=(y+4.65)/9.8;taper=1-.30*t*t;lift=(1-t*t)**1.35
        for px,pz in profile:
            x=cx+px*taper;vs.append((x,y,surface(x,y)+pz*lift+.007))
    fs=[]
    for k in range(len(ys)-1):
        for j in range(len(profile)-1):a=k*len(profile)+j;fs.append((a,a+1,a+1+len(profile),a+len(profile)))
    ob=mesh('Smooth engine dorsal fairing',vs,fs,Paint);sol=ob.modifiers.new('Cowl wall thickness','SOLIDIFY');sol.thickness=.035;sol.offset=-1;bpy.context.view_layer.objects.active=ob;bpy.ops.object.modifier_apply(modifier=sol.name)
    edge=[(cx+px,-4.655,surface(cx+px,-4.65)+pz+.007) for px,pz in profile]
    curve('Rounded intake lip',edge,.038,Paint)
    # duct bottom, black back wall, engine division web; no exterior-mounted engines.
    floorz=surface(cx,-4.6)+.02
    floorvs=[];floorfs=[]
    for j in range(9):
        yy=-4.58+.93*j/8
        for i in range(21):
            xx=cx-1.31+2.62*i/20;floorvs.append((xx,yy,surface(xx,yy)+.015))
    for j in range(8):
        for i in range(20):
            a=j*21+i;floorfs.append((a,a+1,a+22,a+21))
    mesh('Conformal S intake dark throat',floorvs,floorfs,Interior)

    # Fit the light trap to the inside cowl arch; rectangular corners would poke out.
    yy=-3.72;t=(yy+4.65)/9.8;taper=1-.30*t*t;lift=(1-t*t)**1.35
    arch=[(cx+px*taper,yy,surface(cx+px*taper,yy)+max(0,pz*lift-.035)) for px,pz in profile]
    arch.extend([(cx+1.41*taper,yy,surface(cx+1.41*taper,yy)-.09),(cx-1.41*taper,yy,surface(cx-1.41*taper,yy)-.09)])
    mesh('Fitted dark intake termination',arch,[tuple(range(len(arch)))],Interior,smooth=False)
    cylinder('Engine inlet divider',(cx,-4.53,floorz+.02),(cx,-4.53,floorz+.49),.035,Metal)
    for j in range(5):
        xx=cx-1+j*.5
        curve('Duct guide vane',[(xx,-4.34,surface(xx,-4.34)+.025),(xx,-3.72,surface(xx,-3.72)+.11)],.018,Interior)
    # The flat B-2 exhaust is a shallow conformal thermal trough, never a raised box.
    end=min(trailing(cx+dx) for dx in np.linspace(-1.22,1.22,30))-.14
    start=end-1.85;vv=[];ff=[];nx,ny=20,18
    for j in range(ny+1):
        yy=start+(end-start)*j/ny
        for i in range(nx+1):
            xx=cx-1.22+2.44*i/nx;vv.append((xx,yy,surface(xx,yy)+.022))
    for j in range(ny):
        for i in range(nx):a=j*(nx+1)+i;ff.append((a,a+1,a+nx+2,a+nx+1))
    mesh('Conformal exhaust thermal trough',vv,ff,Metal)
    z=surface(cx,end)+.029
    cube('Buried exhaust mouth',(cx,end-.05,z),(2.18,.20,.055),Interior,bev=.018)
    for j in range(9):
        xx=cx-1.03+j*.257;points=[]
        for k in range(16):
            yy=start+.08+(end-start-.16)*k/15;points.append((xx,yy,surface(xx,yy)+.033))
        curve('Exhaust cooling rib',points,.008,Metal)
    empty('Nozzle_L' if s>0 else 'Nozzle_R',(cx,end+.05,z))

# Fine RAM panel boundaries: shallow flush sealant, retained sparingly against clean skin.
def surf_line(name,xy,rad=.008,matl=Paint,group='Fuselage',top=True):
    pts=[]
    for a,b in zip(xy,xy[1:]):
        for j in range(12):
            t=j/12; x=a[0]*(1-t)+b[0]*t;y=a[1]*(1-t)+b[1]*t
            pts.append((x,y,surface(x,y,top)+(.026 if top else -.026)))
    x,y=xy[-1];pts.append((x,y,surface(x,y,top)+(.026 if top else -.026)))
    return curve(name,pts,rad*.65,matl,group)
for s in [-1,1]:
    # leading edge RAM strip, swept spar, access covers, trailing surface hinge
    pts=[(s*x,leading(x)+.42) for x in [2.3,5,9,14,19,24.7]];surf_line('Leading edge panel seam',pts,.011)
    surf_line('Outer leading edge inner seam',[(s*x,leading(x)+1.28) for x in [7.3,11,16,21,24]],.007)
    for a,b in [(7.3,12.8),(14.1,20.8)]:
        xy=[(s*a,leading(a)+2.0),(s*b,leading(b)+2.0),(s*b,trailing(b)-.75),(s*a,trailing(a)-.85),(s*a,leading(a)+2.0)]
        surf_line('Swept access panel gasket',xy,.008)
        xy2=[(x-s*.09,y+.08) for x,y in xy];surf_line('Flush RAM panel inner edge',xy2,.004,Paint)
    for x in [7.0,10.3,14.0,17.8,21.1,24.2]:
        y1=leading(x)+.48;y2=trailing(x)-.65
        surf_line('Span station seam',[(s*x,y1),(s*x,y2)],.0045)
    for x in [6.9,9,11.4,14,16.8]:
        y=leading(x)+(trailing(x)-leading(x))*.825
        surf_line('Elevon hinge seam',[(s*x,y),(s*(x+.65),leading(x+.65)+(trailing(x+.65)-leading(x+.65))*.825)],.013)
    # Small service panels and flush screw heads on the centrebody.
    for y in [-3.6,-.8,2.0,4.2]:
        x=s*2.35;xy=[(x-.27,y-.36),(x+.27,y-.36),(x+.27,y+.36),(x-.27,y+.36),(x-.27,y-.36)];surf_line('Avionics access cover',xy,.007)
        for dx,dy in [(-.20,-.29),(.20,-.29),(-.20,.29),(.20,.29)]:
            xx=x+dx;yy=y+dy;z=surface(xx,yy)+.012
            cylinder('Flush panel fastener',(xx,yy,z-.006),(xx,yy,z+.005),.018,Metal,vertices=8)
    # Bomb bay shut, with real thickness and separate longitudinal hinge.
    x=s*1.55;y=.75;group='BayDoor_L' if s>0 else 'BayDoor_R'
    vs=[];fs=[];nx,ny=8,32
    for j in range(ny+1):
        yy=y-3.25+6.50*j/ny
        for i in range(nx+1):
            xx=x-1.225+2.45*i/nx;vs.append((xx,yy,surface(xx,yy,False)-.030))
    for j in range(ny):
        for i in range(nx):
            a=j*(nx+1)+i;fs.append((a,a+nx+1,a+nx+2,a+1))
    door=mesh('Conformal weapons bay door',vs,fs,Paint,group)
    bpy.context.view_layer.objects.active=door
    sol=door.modifiers.new('Door real thickness','SOLIDIFY');sol.thickness=.055;sol.offset=-1
    bpy.ops.object.modifier_apply(modifier=sol.name);bevel(door,.012,2)
    hx=x+s*1.21
    for dy in [-2.65,0,2.65]:
        ya=y+dy-.20;yb=y+dy+.20
        cylinder('Weapons bay door hinge',(hx,ya,surface(hx,ya,False)-.030),(hx,yb,surface(hx,yb,False)-.030),.025,Metal,group)


# Low-contrast aircraft-scale wash, exhaust heat staining and leading-edge dirt.
# Soft alpha edges are baked; meshes are conformal and raised 18 mm to avoid z fighting.
def grime_patch(name,cx,cy,sx,sy,angle=0):
    vs=[];fs=[];nx,ny=20,10
    for j in range(ny+1):
        for i in range(nx+1):
            u=(i/nx-.5)*sx;v=(j/ny-.5)*sy;x=cx+u*cos(angle)-v*sin(angle);y=cy+u*sin(angle)+v*cos(angle)
            # Stay within the actual planform even close to the swept leading edge.
            y=min(trailing(x)-.15,max(leading(x)+.15,y));vs.append((x,y,surface(x,y)+.019))
    for j in range(ny):
        for i in range(nx):a=j*(nx+1)+i;fs.append((a,a+1,a+nx+2,a+nx+1))
    o=mesh(name,vs,fs,Grime)
    uv=o.data.uv_layers.active
    for poly in o.data.polygons:
        for li in poly.loop_indices:
            vi=o.data.loops[li].vertex_index;uv.data[li].uv=((vi%(nx+1))/nx,(vi//(nx+1))/ny)
for sgn in [-1,1]:
    for x,y,sx,sy in [(9.8,-.1,7.0,3.0),(17.9,4.1,8.1,2.2),(3.0,2.0,2.6,5.8),(22.7,6.2,3.5,2.0)]:
        grime_patch('Soft accumulated surface weathering',sgn*x,y,sx,sy,sgn*.14)
    grime_patch('Exhaust heat staining',sgn*4.5,5.8,2.7,2.5)
# Landing gear, actual telescopic oleos, torque links, retraction braces, hoses and four-wheel bogies.
def wheel(group,x,y,z,r,w):
    # Crowned tyre cross-section: revolved profile with tread channels at physical scale.
    seg=64; prof=[(-w*.5,.60*r),(-w*.53,.76*r),(-w*.45,.92*r),(-w*.30,.987*r),(-w*.15,r),(0,r),(.15*w,r),(.30*w,.987*r),(.45*w,.92*r),(.53*w,.76*r),(.5*w,.60*r)]
    vs=[(x+ax,y+rr*sin(2*pi*j/seg),z+rr*cos(2*pi*j/seg)) for ax,rr in prof for j in range(seg)]
    fs=[]
    for k in range(len(prof)-1):
        for j in range(seg):fs.append((k*seg+j,k*seg+(j+1)%seg,(k+1)*seg+(j+1)%seg,(k+1)*seg+j))
    mesh('Pressure tyre',vs,fs,Rubber,group,True,.5)
    for off in [-.19*w,0,.19*w]:torus('Circumferential tread groove',(x+off,y,z),r-.016,.009,Interior,group,majseg=64,minseg=6)
    cylinder('Wheel alloy hub',(x-w*.54,y,z),(x+w*.54,y,z),r*.59,Metal,group,40)
    for s in [-1,1]:
        torus('Wheel rim lip',(x+s*w*.55,y,z),r*.52,.032,Metal,group,majseg=40,minseg=8)
        cylinder('Wheel bearing cap',(x+s*w*.55,y,z),(x+s*w*.58,y,z),r*.19,Metal,group,24)
        for j in range(8):
            yy=y+r*.37*sin(2*pi*j/8);zz=z+r*.37*cos(2*pi*j/8)
            cylinder('Hub brake ventilation',(x+s*w*.545,yy,zz),(x+s*w*.568,yy,zz),r*.065,Interior,group,10)
for s in [-1,1]:
    group='Gear_L' if s>0 else 'Gear_R';x=s*5.9
    # wheel radius 0.66 => exact ground contact at zero
    for dy in [-.77,.77]:
        for dx in [-.37,.37]:wheel(group,x+dx,dy,.66,.66,.35)
        cylinder('Bogie axle',(x-.67,dy,.66),(x+.67,dy,.66),.095,Metal,group)
    cylinder('Bogie beam',(x,-1.05,.72),(x,1.05,.72),.125,Metal,group)
    cylinder('Chrome piston',(x,0,.74),(x,0,1.85),.115,Metal,group)
    cylinder('Main oleo housing',(x,0,1.55),(x,0,2.88),.19,Paint,group)
    for zz in [1.6,1.78,2.65]:torus('Oleo collar',(x,0,zz),.19,.035,Metal,group,(0,0,0),24,8)
    cylinder('Drag brace',(x,0,1.15),(x,.95,2.73),.082,Metal,group)
    cylinder('Side retraction brace',(x,0,1.75),(x+s*.95,.2,2.87),.078,Metal,group)
    for a,b in [((x,-.19,1.0),(x,-.52,1.40)),((x,-.52,1.40),(x,-.21,1.77))]:cylinder('Torque scissors',a,b,.047,Metal,group)
    curve('Hydraulic brake hose',[(x+.20,.04,2.7),(x+.28,-.14,2.0),(x+.27,-.22,1.3),(x+.30,.0,.88)],.026,Rubber,group)
    # Outer main door hangs from the wing with a trapezoidal sloping lower edge.
    xx=x+s*.86
    vv=[(xx,-1.45,2.98),(xx,1.35,2.80),(xx,1.05,.95),(xx,-1.20,1.05)]
    ob=mesh('Main gear door skin',vv,[(0,1,2,3)],Paint,group,False);sol=ob.modifiers.new('Door thickness','SOLIDIFY');sol.thickness=.085;bpy.context.view_layer.objects.active=ob;bpy.ops.object.modifier_apply(modifier=sol.name);bevel(ob,.035,3)
    for yy in [-.9,.6]:cylinder('Door actuator',(x,yy,2.6),(xx,yy,1.9),.047,Metal,group)
    curve('Gear door perimeter rib',[(xx-s*.07,y,z) for _,y,z in vv]+[(xx-s*.07,vv[0][1],vv[0][2])],.035,Metal,group)
    cube('Main wheel well',(x,0,2.80),(2.05,3.1,.10),Interior,bev=.06)
# nose twin wheel gear
group='Gear_Nose'; y=-8.25
for x in [-.22,.22]:wheel(group,x,y,.43,.43,.24)
cylinder('Nose axle',(-.42,y,.43),(.42,y,.43),.072,Metal,group)
cylinder('Nose oleo piston',(0,y,.45),(0,y,1.42),.072,Metal,group)
cylinder('Nose oleo body',(0,y,1.15),(0,y+.12,2.74),.115,Paint,group)
cylinder('Nose rear drag link',(0,y,1.02),(0,y+1.0,2.70),.057,Metal,group)
for a,b in [((0,y-.12,.64),(0,y-.32,1.02)),((0,y-.32,1.02),(0,y-.13,1.35))]:cylinder('Nose torque link',a,b,.032,Metal,group)
curve('Nose brake line',[(.12,y+.10,2.65),(.15,y-.10,1.8),(.13,y-.15,.62)],.018,Rubber,group)
for s in [-1,1]:
    xx=s*.46;vv=[(xx,y-.40,2.77),(xx,y+.44,2.68),(xx+s*.12,y+.25,1.27),(xx+s*.12,y-.30,1.43)]
    o=mesh('Tapered nose gear door',vv,[(0,1,2,3)],Paint,group,False);solid=o.modifiers.new('Nose door thickness','SOLIDIFY');solid.thickness=.055;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=solid.name);bevel(o,.026,3)
    cylinder('Nose door actuator',(s*.20,y+.25,2.55),(s*.46,y+.25,2.13),.025,Metal,group)
cube('Nose wheel well',(0,y+.22,2.80),(.92,1.8,.10),Interior,bev=.08)
cube('Landing light',(0,y-.17,1.45),(.18,.08,.16),Lamps,group,.05)

# Flush sensors and tiny navigation lights; no livery, insignia, serials or labels.
for s in [-1,1]:
    x=s*25.50;y=leading(x)+.26;z=surface(x,y)+.015
    cube('Wingtip navigation lens',(x,y,z),(.14,.28,.065),Lamps,'Fuselage',.025)
    empty('Light_Nav_L' if s>0 else 'Light_Nav_R',(x,y,z+.05))
    cylinder('Flush nose air data sensor',(s*2.0,-8.25,surface(s*2,-8.25)),(s*2.0,-8.25,surface(s*2,-8.25)+.025),.07,Metal,vertices=20)
empty('Light_Beacon',(0,.8,surface(0,.8)+.05));empty('Center_Of_Mass',(0,.3,3.2))
# Internal weapon stations: no pylons fitted to the stealth airframe.
for s in [-1,1]:
    for j,y in enumerate([-1.0,2.2]):empty('Hardpoint_Bay_'+('L' if s>0 else 'R')+'_'+str(j+1),(s*1.55,y,2.6))

# Join fixed and articulated assemblies, preserve hinge origins, apply all transforms.
asset=[]
hinges={'Fuselage':(0,0,0),'Canopy':(0,-4.15,surface(.8,-4.15)+.028),'Gear_Nose':(0,-8.1,2.8),'Gear_L':(5.9,0,2.8),'Gear_R':(-5.9,0,2.8),'BayDoor_L':(2.76,.75,surface(2.76,.75,False)-.030),'BayDoor_R':(-2.76,.75,surface(-2.76,.75,False)-.030)}
for s in [-1,1]:
    suff='L' if s>0 else 'R'
    for prefix,x,t in [('Elevon_',11.5,86/104),('DragRudder_',21.0,75/104),('DragRudderLower_',21.0,75/104)]:
        y=leading(x)+(trailing(x)-leading(x))*t;hinges[prefix+suff]=(s*x,y,surface(x,y,prefix!='DragRudderLower_'))
for name,obs in GROUPS.items():
    bpy.ops.object.select_all(action='DESELECT')
    for ob in obs:ob.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)
    sc.cursor.location=hinges.get(name,(0,0,0));bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    # Collapse redundant coplanar samples while retaining authored curved profiles.
    if len(o.data.polygons)>1000:
        dec=o.modifiers.new('Game mesh density','DECIMATE');dec.ratio=.72;dec.use_collapse_triangulate=True
        bpy.ops.object.modifier_apply(modifier=dec.name)
    # Triangulate deterministically and remove zero-area faces as an export gate.
    bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.dissolve_degenerate(bm,dist=.00001,edges=list(bm.edges));bmesh.ops.triangulate(bm,faces=list(bm.faces));bad=[f for f in bm.faces if f.calc_area()<1e-8];bmesh.ops.delete(bm,geom=bad,context='FACES');bm.to_mesh(o.data);bm.free()
    asset.append(o)
# Set 5.2m exact upper bound through coherent vertical scaling; ground tyres remain grounded.
allpts=[o.matrix_world@v.co for o in asset for v in o.data.vertices]
zmin=min(v.z for v in allpts);zmax=max(v.z for v in allpts); zfactor=5.2/(zmax-zmin)
for o in asset:
    origin=o.location.copy();o.location.z=(o.location.z-zmin)*zfactor
    for v in o.data.vertices:v.co.z*=zfactor
for o in sc.objects:
    if o.type=='EMPTY':o.location.z=(o.location.z-zmin)*zfactor
bpy.context.view_layer.update()
bpy.context.view_layer.objects.active=bpy.data.objects['Fuselage'];sc.cursor.location=(0,0,0);bpy.ops.object.select_all(action='DESELECT');bpy.data.objects['Fuselage'].select_set(True);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
bpy.context.view_layer.update()
allpts=[o.matrix_world@v.co for o in asset for v in o.data.vertices]
lo=[min(p[i] for p in allpts) for i in range(3)];hi=[max(p[i] for p in allpts) for i in range(3)]
TRIS=sum(len(o.data.polygons) for o in asset)
print('ASSET',TRIS,'triangles',len(asset),'meshes','bounds',lo,hi,flush=True)
assert 100000<=TRIS<=200000,TRIS

PAINTS=[{'name':'Dark grey','linear_rgb':[.12,.12,.13]},{'name':'Air-superiority light grey','linear_rgb':[.38,.41,.44]},{'name':'Navy blue-grey','linear_rgb':[.10,.145,.19]},{'name':'Desert sand','linear_rgb':[.40,.31,.20]},{'name':'Olive','linear_rgb':[.15,.18,.105]},{'name':'Black','linear_rgb':[.035,.037,.043]}]
# Convert Blender positions to glTF coordinates (x,z,-y).
def gamepos(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
manifest={'asset':'b2-bomber.glb','units':'metres','axes':{'up':'+Y','nose':'+Z','left':'+X'},'origin':'Ground plane below the main landing gear axles','bounds':{'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':TRIS,'materials':list(MATERIALS),'nodes':[], 'paints':PAINTS,'bakes':BAKE_LOG,'provenance':{'reference':'sheet.jpg','method':'Procedural geometric reconstruction, not extracted geometry','photographic_sources':[{'id':a,'license':'CC0','url':'https://polyhaven.com/a/'+a} for a in ['metal_plate','rubber_tiles']]}}
for o in sc.objects:
    if o.type in {'MESH','EMPTY'}:manifest['nodes'].append({'name':o.name,'type':o.type,'world_position':gamepos(o.matrix_world.translation),'hinge_axis':'local X along swept hinge approximation' if o.name.startswith(('Elevon','DragRudder')) else None})
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
# Export clean selected scene with only asset meshes and sockets. Uncompressed glTF is auditable.
bpy.ops.object.select_all(action='DESELECT')
for o in sc.objects:
    if o.type in {'MESH','EMPTY'}:o.select_set(True)
bpy.context.view_layer.objects.active=asset[0]
exportpath=ROOT/'b2-bomber.glb'
bpy.ops.export_scene.gltf(filepath=str(exportpath),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
# Pin the required Paint factor in the portable glTF material; images remain neutral.
data=exportpath.read_bytes();jlen,jtype=struct.unpack_from('<II',data,12);gltf=json.loads(data[20:20+jlen]);tail=data[20+jlen:]
for m in gltf['materials']:
    if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.12,.12,.13,1];m.pop('extensions',None)
    if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
    if m['name']=='Interior':m['doubleSided']=True
    if m['name']=='Grime':m['alphaMode']='BLEND';m['doubleSided']=True
js=json.dumps(gltf,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
exportpath.write_bytes(struct.pack('<4sII',b'glTF',2,12+8+len(js)+len(tail))+struct.pack('<II',len(js),0x4e4f534a)+js+tail)
assert exportpath.stat().st_size<=12*1024*1024,exportpath.stat().st_size
(ROOT/'NOTES.md').write_text('\n'.join([
'B-2 Spirit inspired flying wing; 52.4 m span, 21.0 m length, 5.2 m height, gear down, Y-up GLB, nose +Z.',
f'{TRIS:,} triangles; {len(asset)} articulated mesh assemblies; {exportpath.stat().st_size/1048576:.2f} MiB uncompressed GLB.',
'7 materials: Paint (neutral tile × charcoal factor), Metal, Rubber, Interior, Grime, Glass (BLEND), Lamps; no clearcoat or markings.',
'Cycles GPU baked shared tiles: Paint base/normal 1024 px, ORM 512 px; other textured materials 512 px; all ORM ≤512 px.',
'Poly Haven CC0 photographic IDs: metal_plate, rubber_tiles; https://polyhaven.com/license. No other image sources.',
'Differences: cockpit and internal ducts approximated; simplified RAM panel layout; rigid control pivots approximate the swept hinge axes.'
])+'\n')
print('EXPORTED',exportpath,exportpath.stat().st_size,round(time.time()-START,1),flush=True)

# Match offline renders to the runtime Paint factor without baking colour into the neutral image.
n=Paint.node_tree.nodes;l=Paint.node_tree.links;b=n.get('Principled BSDF');base=b.inputs['Base Color'].links[0].from_socket
mult=n.new('ShaderNodeMixRGB');mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;l.new(base,mult.inputs[1]);mult.inputs[2].default_value=(.12,.12,.13,1);l.new(mult.outputs[0],b.inputs['Base Color'])
# Soft daylight, neutral ground. Lighting and cameras never belong to selected export.
world=bpy.data.worlds.new('Soft daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs['Color'].default_value=(.68,.77,.88,1);world.node_tree.nodes.get('Background').inputs['Strength'].default_value=.65
bpy.ops.object.light_add(type='AREA',location=(8,-18,35));key=bpy.context.object;key.name='Studio daylight';key.data.energy=10500;key.data.shape='DISK';key.data.size=24
key.rotation_euler=(Vector((0,0,2))-key.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.light_add(type='SUN',location=(0,0,30));sun=bpy.context.object;sun.data.energy=1.5;sun.data.angle=.14;sun.rotation_euler=(.45,-.5,-.5)
bpy.ops.mesh.primitive_plane_add(size=2000,location=(0,0,-.018));ground=bpy.context.object;ground.name='Render ground';gm=mat('_Ground',(.23,.25,.27),.85);ground.data.materials.append(gm)
bpy.ops.object.camera_add();camera=bpy.context.object;camera.data.type='ORTHO';sc.camera=camera

def aim(pos,target=(0,0,2.3),scale=58):
    camera.location=pos;camera.rotation_euler=(Vector(target)-camera.location).to_track_quat('-Z','Y').to_euler();camera.data.ortho_scale=scale

def render(name,w=1920,h=1080,samples=32):
    sc.render.resolution_x=w;sc.render.resolution_y=h;sc.render.resolution_percentage=100;sc.cycles.samples=samples;sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);print('RENDER',name,round(time.time()-START,1),flush=True)
aim((39,-46,24),(0,-.2,2.2),58)
render('final.png',1920,1080,32)
# Save recoverable clean render scene for a targeted visual correction; deliver script remains standalone.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'working.blend'))
aim((55,-.6,2.6),(0,-.6,2.6),26.5);render('side.png',1920,1080,24)
aim((39,-46,24),(0,-.2,2.2),58)
clay=mat('_Clay',(.42,.42,.42),.72)
sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);sc.view_layers[0].material_override=None
# Six paints share the same view and lighting. Compose the display PNGs losslessly,
# avoiding Blender's second colour transform when copying rendered image pixels.
W,H=640,360
for i,p in enumerate(PAINTS):
    mult.inputs[2].default_value=(*p['linear_rgb'],1);render(f'_paint_{i}.png',W,H,12)
def read_png(path):
    raw=path.read_bytes();off=8;data=b''
    while off<len(raw):
        n=struct.unpack_from('>I',raw,off)[0];tag=raw[off+4:off+8];body=raw[off+8:off+8+n];off+=n+12
        if tag==b'IHDR':w,h,depth,ctype,_,_,_=struct.unpack('>IIBBBBB',body)
        if tag==b'IDAT':data+=body
    assert depth==8 and ctype in [2,6],(depth,ctype)
    channels=4 if ctype==6 else 3;stride=w*channels;scan=zlib.decompress(data);rows=[];prior=bytearray(stride);p=0
    for y in range(h):
        f=scan[p];p+=1;row=bytearray(scan[p:p+stride]);p+=stride
        for x in range(stride):
            a=row[x-channels] if x>=channels else 0;b=prior[x];c=prior[x-channels] if x>=channels else 0
            if f==1:row[x]=(row[x]+a)&255
            elif f==2:row[x]=(row[x]+b)&255
            elif f==3:row[x]=(row[x]+((a+b)//2))&255
            elif f==4:
                q=a+b-c;pa=abs(q-a);pb=abs(q-b);pc=abs(q-c);v=a if pa<=pb and pa<=pc else (b if pb<=pc else c);row[x]=(row[x]+v)&255
        rows.append(bytes(row));prior=row
    return w,h,channels,rows
frames=[read_png(ROOT/f'_paint_{i}.png') for i in range(6)];channels=frames[0][2]
raw=b''.join(b'\x00'+b''.join(frames[(y//H)*3+c][3][y%H] for c in range(3)) for y in range(H*2))
def chunk(tag,data):return struct.pack('>I',len(data))+tag+data+struct.pack('>I',zlib.crc32(tag+data)&0xffffffff)
(ROOT/'paints.png').write_bytes(b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',W*3,H*2,8,6 if channels==4 else 2,0,0,0))+chunk(b'sRGB',b'\x00')+chunk(b'IDAT',zlib.compress(raw,7))+chunk(b'IEND',b''))
for i in range(6):(ROOT/f'_paint_{i}.png').unlink(missing_ok=True)
mult.inputs[2].default_value=(.12,.12,.13,1)
print('DONE',round(time.time()-START,1),flush=True)
