"""Reproducible AH-64 inspired game asset. Blender 5.2, Cycles CUDA/OptiX.
Authoring coordinates X left, -Y forward, Z up; glTF converts to Y up.
Only external imagery: Poly Haven CC0 metal_plate and rubberized_track.
"""
import bpy, bmesh, math, os, json, urllib.request, struct, sys, random
from mathutils import Vector
from math import sin, cos, pi
from pathlib import Path
import numpy as np
# Scope: 6 moving/fixed mesh nodes, 11 sockets, 8 materials, 60k-140k tris.
# Delivery: <=12 MB GLB, <=1024 base/normal, <=512 ORM, 1920x1080 beauty/side/clay.
# Cameras: lower front-left three-quarter hero + true side; max 3 visual revisions.
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(64)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for d in list(bpy.data.materials): bpy.data.materials.remove(d)
sc=bpy.context.scene; sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
sc.render.engine='CYCLES'; sc.cycles.samples=24; 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()
        if any(d.type!='CPU' for d in prefs.devices): break
    except Exception: pass
for d in prefs.devices: d.use=d.type!='CPU'
sc.cycles.device='GPU'; print('GPU',[(d.name,d.use) for d in prefs.devices],flush=True)
sc.render.image_settings.file_format='PNG'; sc.view_settings.view_transform='AgX'
GROUP='Fuselage'; parts={k:[] for k in ['Fuselage','MainRotor','TailRotor','Door_L','Door_R','Gun']}
M={}
def active(o):
    bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def finish(o,name,mat,bev=0,smooth=False,bevel_segments=2):
    o.name=name; o.data.materials.append(M[mat] if isinstance(mat,str) else mat)
    active(o); bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    if bev:
        mod=o.modifiers.new('Machined edge radii','BEVEL'); mod.width=bev; mod.segments=bevel_segments
        bpy.ops.object.modifier_apply(modifier=mod.name)
    if smooth:
        for p in o.data.polygons:p.use_smooth=True
    parts[GROUP].append(o); return o

def mesh(name,v,f,mat='Paint',bev=.01,smooth=False):
    me=bpy.data.meshes.new(name); me.from_pydata(v,[],f); me.update()
    bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=bm.faces); bm.to_mesh(me); bm.free()
    o=bpy.data.objects.new(name,me); sc.collection.objects.link(o)
    return finish(o,name,mat,bev,smooth)
def box(name,loc,scale,mat='Paint',bev=.025):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.scale=scale
    return finish(o,name,mat,bev)
def ell(name,loc,scale,mat='Paint',seg=32,rings=16):
    if seg<=8:bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1,radius=1,location=loc)
    else:bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,radius=1,location=loc)
    o=bpy.context.object;o.scale=scale
    return finish(o,name,mat,0,True)
def cyl(name,a,b,r,mat='Metal',n=24,r2=None):
    a,b=Vector(a),Vector(b); mid=(a+b)/2
    bpy.ops.mesh.primitive_cone_add(vertices=n,radius1=r,radius2=r if r2 is None else r2,depth=(b-a).length,location=mid)
    o=bpy.context.object;o.rotation_euler=(b-a).to_track_quat('Z','Y').to_euler()
    return finish(o,name,mat,min(.009,r*.15),True,1)
def tube(name,pts,r=.015,mat='Metal'):
    cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.bevel_depth=r;cu.bevel_resolution=1;cu.resolution_u=1
    sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
    for p,co in zip(sp.points,pts):p.co=(*co,1)
    o=bpy.data.objects.new(name,cu);sc.collection.objects.link(o);active(o);bpy.ops.object.convert(target='MESH')
    return finish(o,name,mat,0,True)
def torus(name,loc,major,minor,mat='Rubber',axis='X',majorseg=40):
    bpy.ops.mesh.primitive_torus_add(major_segments=majorseg,minor_segments=12,location=loc,major_radius=major,minor_radius=minor)
    o=bpy.context.object
    if axis=='X':o.rotation_euler[1]=pi/2
    if axis=='Y':o.rotation_euler[0]=pi/2
    return finish(o,name,mat,0,True)
def loft(name,sections,mat='Paint',N=12,bev=.035):
    # y, half width, bottom, top; chamfered rectangular cross sections
    v=[]
    for y,w,b,t in sections:
        profile=[(w*.72,t),(w,t-.15*(t-b)),(w,b+.18*(t-b)),(w*.72,b),(-w*.72,b),(-w,b+.18*(t-b)),(-w,t-.15*(t-b)),(-w*.72,t)]
        v.extend([(x,y,z) for x,z in profile])
    f=[tuple(reversed(range(8))),tuple(range(len(v)-8,len(v)))]
    for j in range(len(sections)-1):
        for k in range(8):f.append((j*8+k,j*8+(k+1)%8,(j+1)*8+(k+1)%8,(j+1)*8+k))
    return mesh(name,v,f,mat,bev)
def foil(name,root,angle,length,chord,mat='Composite',tail=False):
    # Closed NACA 0012 airfoil, spanwise washout and swept tips.
    v=[]; f=[]; spans=18; n=16
    for i in range(spans+1):
        t=i/spans; r=.28+length*t; c=chord*(1-.28*t); twist=math.radians(10-15*t)
        sweep=max(0,t-.89)*(.14 if tail else .9)
        for side in [1,-1]:
            for k in range(n):
                u=(1-cos(pi*k/(n-1)))/2
                thick=5*.12*c*(.2969*math.sqrt(u)-.126*u-.3516*u*u+.2843*u**3-.1036*u**4)
                q=(u-.25)*c+sweep; zz=side*thick
                q,zz=q*cos(twist)-zz*sin(twist),q*sin(twist)+zz*cos(twist)
                x=r*cos(angle)-q*sin(angle); y=r*sin(angle)+q*cos(angle)
                if tail:pos=(root[0]+zz,root[1]+x,root[2]+y)
                else:pos=(root[0]+x,root[1]+y,root[2]+zz-.05*t*t)
                v.append(pos)
    ring=2*n
    # weld redundant leading/trailing edge vertices afterward
    for i in range(spans):
        for k in range(n-1):
            for s in [0,1]:
                a=i*ring+s*n+k;b=a+1;f.append((a,b,b+ring,a+ring))
        f.extend([(i*ring,i*ring+n,(i+1)*ring+n,(i+1)*ring), (i*ring+n-1,i*ring+2*n-1,(i+1)*ring+2*n-1,(i+1)*ring+n-1)])
    f.extend([tuple(list(range(n))+list(reversed(range(n,2*n)))),tuple([spans*ring+k for k in list(range(n))+list(reversed(range(n,2*n)))])])
    o=mesh(name,v,f,mat,0,True)
    bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=.00001);bmesh.ops.dissolve_degenerate(bm,edges=bm.edges,dist=.000001);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
    return o

# Bake a reusable one-metre material coupon: surface scan + neutral grime.
def download(asset,map):
    p=TEX/f'{asset}_{map}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{map}_1k.jpg'
        urllib.request.urlretrieve(url,p)
    return bpy.data.images.load(str(p),check_existing=True)
source={a:{m:download(a,m) for m in ['diff','nor_gl','arm']} for a in ['metal_plate','rubberized_track']}
def material(name,color,rough,metal,asset='metal_plate'):
    m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;n=nt.nodes;l=nt.links;n.clear()
    out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
    bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
    M[name]=m
    tex=n.new('ShaderNodeTexImage');tex.image=source[asset]['diff']
    bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs['Color'],bw.inputs[0])
    ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.05;ramp.color_ramp.elements[0].color=(.77,.77,.77,1);ramp.color_ramp.elements[1].position=.72;ramp.color_ramp.elements[1].color=(.99,.99,.99,1);l.new(bw.outputs[0],ramp.inputs[0])
    uv=n.new('ShaderNodeTexCoord');noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=90;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
    mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.13;l.new(ramp.outputs[0],mix.inputs[1]);l.new(noise.outputs['Fac'],mix.inputs[2])
    streakmap=n.new('ShaderNodeVectorMath');streakmap.operation='MULTIPLY';streakmap.inputs[1].default_value=(8,.8,1);l.new(uv.outputs['UV'],streakmap.inputs[0])
    streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=3.8;streak.inputs['Detail'].default_value=5;l.new(streakmap.outputs[0],streak.inputs['Vector'])
    grime=n.new('ShaderNodeValToRGB');grime.color_ramp.elements[0].position=.30;grime.color_ramp.elements[0].color=(.37,.37,.37,1);grime.color_ramp.elements[1].position=.64;grime.color_ramp.elements[1].color=(1,1,1,1);l.new(streak.outputs['Fac'],grime.inputs[0])
    weather=n.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=.26 if name=='Paint' else .12;l.new(mix.outputs[0],weather.inputs[1]);l.new(grime.outputs[0],weather.inputs[2])
    tint=n.new('ShaderNodeMixRGB');tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;l.new(weather.outputs[0],tint.inputs[1]);tint.inputs[2].default_value=(*((1,1,1) if name=='Paint' else color),1)
    l.new(tint.outputs[0],bs.inputs['Base Color'])
    norm=n.new('ShaderNodeTexImage');norm.image=source[asset]['nor_gl'];norm.image.colorspace_settings.name='Non-Color'
    nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=0 if name=='Glass' else (.25 if name=='Paint' else .35);l.new(norm.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
    arm=n.new('ShaderNodeTexImage');arm.image=source[asset]['arm'];arm.image.colorspace_settings.name='Non-Color'
    sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0]);rm=n.new('ShaderNodeMath');rm.operation='MULTIPLY_ADD';rm.inputs[1].default_value=.18;rm.inputs[2].default_value=rough-.09;l.new(sep.outputs[1],rm.inputs[0]);l.new(rm.outputs[0],bs.inputs['Roughness'])
    bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.data.materials.append(m);active(plane)
    baked={};target=n.new('ShaderNodeTexImage');em=n.new('ShaderNodeEmission')
    for typ,res in [('base',1024 if name=='Paint' else 512),('normal',512),('orm',512)]:
        im=bpy.data.images.new(name+'_'+typ,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if typ=='base' else 'Non-Color';target.image=im;n.active=target
        if typ=='base':l.new(tint.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);bake='EMIT'
        elif typ=='normal':l.new(bs.outputs[0],out.inputs[0]);bake='NORMAL'
        else:
            comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1;comb.inputs[2].default_value=metal;l.new(rm.outputs[0],comb.inputs[1]);l.new(comb.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);bake='EMIT'
        sc.cycles.samples=8;sc.render.bake.margin=4;bpy.ops.object.bake(type=bake)
        im.filepath_raw=str(TEX/(name+'_'+typ+'.png'));im.file_format='PNG';im.save();baked[typ]=im
    bpy.data.objects.remove(plane,do_unlink=True)
    n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0]);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
    for typ in baked:
        tx=n.new('ShaderNodeTexImage');tx.image=baked[typ];tx.label=typ
        if typ=='base':
            if name=='Paint':
                mul=n.new('ShaderNodeMixRGB');mul.name='Paint tint';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(*color,1);l.new(tx.outputs[0],mul.inputs[1]);l.new(mul.outputs[0],bs.inputs['Base Color'])
            else:l.new(tx.outputs[0],bs.inputs['Base Color'])
        if typ=='normal':
            nm=n.new('ShaderNodeNormalMap');l.new(tx.outputs[0],nm.inputs[1]);l.new(nm.outputs[0],bs.inputs['Normal'])
        if typ=='orm':
            sep=n.new('ShaderNodeSeparateColor');l.new(tx.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],bs.inputs['Roughness']);l.new(sep.outputs[2],bs.inputs['Metallic'])
            group=bpy.data.node_groups.get('glTF Material Output')
            if not group:
                group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
            gn=n.new('ShaderNodeGroup');gn.node_tree=group;l.new(sep.outputs[0],gn.inputs['Occlusion'])
    if name=='Glass':
        bs.inputs['Alpha'].default_value=.34;bs.inputs['Roughness'].default_value=.13
        # alpha blended dielectric, visible cockpit in core glTF
        for link in list(bs.inputs['Roughness'].links):l.remove(link)
        m.surface_render_method='BLENDED';m.use_transparency_overlap=False
    if name=='Lamps':
        bs.inputs['Emission Color'].default_value=(.13,.34,.32,1);bs.inputs['Emission Strength'].default_value=.8
    print('BAKED',name,flush=True)
    return m
for args in [('Paint',(.10,.11,.08),.77,.08),('Metal',(.23,.25,.24),.38,.85),('Rubber',(.021,.025,.027),.88,0,'rubberized_track'),('Composite',(.025,.029,.027),.77,.02),('Interior',(.046,.052,.049),.83,.05,'rubberized_track'),('Weapons',(.075,.084,.066),.63,.45),('Glass',(.055,.075,.085),.10,.0),('Lamps',(.28,.40,.38),.2,.2)]:material(*args)
# Main armoured lower hull, broad shoulder, tapered aft machinery bay.
loft('Monocoque armoured fuselage',[(-4.95,.30,1.05,1.64),(-4.65,.56,.95,1.87),(-3.65,.70,.90,1.94),(-1.5,.78,.84,1.93),(.65,.82,.83,2.34),(1.8,.72,.91,2.22),(2.8,.48,1.01,1.82),(3.4,.36,1.08,1.62)])
loft('Transmission spine',[(-.65,.48,1.75,2.79),(.15,.56,1.7,2.85),(1.15,.55,1.68,2.61),(2.15,.40,1.4,2.26)],bev=.075)
# Long, gently rising tail boom with elliptic cross sections.
v=[];N=24
for y,w,z,h in [(2.35,.60,1.38,.49),(3.2,.48,1.34,.38),(5,.32,1.30,.26),(7,.22,1.31,.20),(9.2,.17,1.35,.18),(10.12,.15,1.38,.18)]:
    v += [(w*cos(2*pi*k/N),y,z+h*sin(2*pi*k/N)) for k in range(N)]
f=[tuple(reversed(range(N))),tuple(range(5*N,6*N))]+[(j*N+k,j*N+(k+1)%N,(j+1)*N+(k+1)%N,(j+1)*N+k) for j in range(5) for k in range(N)]
mesh('Tapered stressed skin tail boom',v,f,bev=.015,smooth=True)
# Cockpit floor, tandem instruments and crashworthy seats.
for idx,(y,z) in enumerate([(-3.27,1.59),(-1.61,1.93)]):
    box('Cockpit tub', (0,y,z-.18),(.97,1.26,.16),'Interior')
    box('Seat cushion',(0,y+.20,z),(.43,.48,.13),'Interior',.065)
    seat=box('Seat armored back',(0,y+.47,z+.38),(.47,.14,.68),'Interior',.07);seat.rotation_euler[0]=-.12
    box('Head restraint',(0,y+.48,z+.77),(.30,.18,.23),'Rubber',.05)
    for s in [-1,1]:
        tube('Harness webbing',[(s*.14,y+.385,z+.67),(s*.13,y+.33,z+.36),(s*.16,y+.12,z+.10)],.023,'Composite')
        box('Side console',(s*.34,y-.08,z+.08),(.15,.65,.19),'Interior',.015)
        for k in range(4):ell('Console switch',(s*.34,y-.28+k*.13,z+.184),(.018,.022,.014),'Metal',12,8)
    panel=box('Instrument coaming',(0,y-.50,z+.34),(.76,.21,.34),'Interior',.035)
    for x in [-.22,.02,.25]:
        box('MFD bezel',(x,y-.615,z+.36),(.19,.017,.20),'Metal',.009)
        box('MFD glass',(x,y-.627,z+.36),(.16,.006,.165),'Lamps',.004)
    cyl('Cyclic',(0,y-.10,z+.04),(0,y-.14,z+.32),.022,'Metal',12)
    ell('Cyclic handgrip',(0,y-.14,z+.33),(.035,.045,.07),'Rubber',16,8)
# Canopy framework uses a sequence of actual cross-sectional arches.
arches=[(-4.02,.58,1.73,1.93),(-2.93,.57,1.80,2.58),(-1.87,.58,1.88,2.84),(-.54,.54,1.97,2.90)]
# side polygons stepped taper, movable rear doors per side
for s,side in [(1,'L'),(-1,'R')]:
    for i in range(3):
        a,b=arches[i],arches[i+1]
        pts=[(s*a[1],a[0],a[2]),(s*a[1]*.79,a[0],a[3]),(s*b[1]*.79,b[0],b[3]),(s*b[1],b[0],b[2])]
        GROUP='Door_'+side if i>=1 else 'Fuselage'
        glass=mesh('Canopy armored glass '+side,pts,[(0,1,2,3)],'Glass',0)
        solid=glass.modifiers.new('Laminated glazing thickness','SOLIDIFY');solid.thickness=.012;active(glass);bpy.ops.object.modifier_apply(modifier=solid.name)
        tube('Canopy perimeter seal',pts+[pts[0]],.028,'Rubber')
        center=sum((Vector(p) for p in pts),Vector())/4
        inner=[tuple(Vector(p)+(center-Vector(p)).normalized()*.065+Vector((s*.012,0,0))) for p in pts]
        outer=[tuple(Vector(p)+Vector((s*.012,0,0))) for p in pts]
        frame=mesh('Armored canopy perimeter frame',outer+inner,[(k,(k+1)%4,(k+1)%4+4,k+4) for k in range(4)],'Paint',0)
        solid=frame.modifiers.new('Frame metal thickness','SOLIDIFY');solid.thickness=.023;active(frame);bpy.ops.object.modifier_apply(modifier=solid.name)
        for j in [0,2]:
            pa,pb=Vector(pts[j]),Vector(pts[(j+1)%4])
            for t in [.15,.40,.65,.85]:ell('Canopy flush fastener',pa.lerp(pb,t)+Vector((s*.025,0,0)),(.016,.016,.016),'Metal',12,8)
        if i>=1:
            tube('Door grab handle',[(s*(b[1]+.035),b[0]-.30,b[2]+.06),(s*(b[1]+.07),b[0]-.30,b[2]+.10),(s*(b[1]+.07),b[0]-.12,b[2]+.10),(s*(b[1]+.035),b[0]-.12,b[2]+.06)],.018,'Metal')
    GROUP='Fuselage'
for i in range(3):
    a,b=arches[i],arches[i+1]
    pts=[(-a[1]*.79,a[0],a[3]),(a[1]*.79,a[0],a[3]),(b[1]*.79,b[0],b[3]),(-b[1]*.79,b[0],b[3])]
    mesh('Canopy windscreen roof',pts,[(0,1,2,3)],'Glass' if i==0 else 'Paint',0)
    tube('Roof arch',pts+[pts[0]],.035,'Paint')
    tube('Central windscreen divider',[(0,a[0],a[3]+.014),(0,b[0],b[3]+.014)],.024,'Paint')
box('Aft canopy bulkhead',(0,-.50,2.40),(1.03,.13,.90),'Paint',.07)
# Asymmetric TADS gimbal with recessed optical windows and PNVS upper turret.
ell('TADS transverse bearing',(0,-4.80,1.45),(.54,.25,.29),'Weapons',32,16)
ell('TADS large optical ball',(.22,-4.88,1.39),(.34,.28,.30),'Paint',40,20)
cyl('TADS lens rubber surround',(.22,-5.115,1.39),(.22,-5.159,1.39),.202,'Rubber',40)
cyl('TADS recessed objective',(.22,-5.163,1.39),(.22,-5.175,1.39),.172,'Glass',40)
box('TADS auxiliary camera housing',(-.31,-4.96,1.48),(.29,.33,.36),'Weapons',.07)
box('TADS auxiliary camera rim',(-.31,-5.13,1.48),(.22,.022,.27),'Rubber',.03)
box('TADS auxiliary camera window',(-.31,-5.145,1.48),(.166,.013,.212),'Glass',.027)
cyl('PNVS bearing',(0,-4.45,1.83),(0,-4.45,1.95),.20,'Metal')
box('PNVS rotating head',(0,-4.51,1.99),(.49,.42,.25),'Paint',.09)
box('PNVS optical seal',(0,-4.727,1.99),(.29,.023,.15),'Rubber',.04)
box('PNVS window',(0,-4.743,1.99),(.24,.015,.11),'Glass',.025)
for x in [-.46,.46]:
    cyl('TADS pivot cap',(x,-4.79,1.44),(x*1.06,-4.79,1.44),.12,'Metal',24)
# Engine nacelles are rounded shoulder pods, not cubes.
for s in [-1,1]:
    eng=loft('T700 engine housing',[(-.45,.37,1.90,2.62),(-.20,.43,1.82,2.66),(1.4,.43,1.81,2.60),(1.95,.35,1.88,2.48)],bev=.12)
    for p in eng.data.vertices:p.co.x+=s*.97
    ell('Engine intake forward fairing',(s*.98,-.30,2.29),(.365,.26,.34),'Paint',40,20)
    torus('Intake rolled lip',(s*.98,-.615,2.29),.244,.060,'Paint','Y',48)
    cyl('Intake shadow',(s*.98,-.645,2.29),(s*.98,-.65,2.29),.247,'Rubber',48)
    torus('Intake inner ring',(s*.98,-.66,2.29),.247,.024,'Metal','Y')
    cyl('Intake centre cone',(s*.98,-.66,2.29),(s*.98,-.78,2.29),.09,'Metal',24,.024)
    for k in range(12):
        a=2*pi*k/12
        cyl('Inlet stator',(s*.98+.10*cos(a),-.669,2.29+.10*sin(a)),(s*.98+.232*cos(a+.15),-.669,2.29+.232*sin(a+.15)),.008,'Metal',8)
    box('Exhaust frame',(s*.98,1.99,2.19),(.65,.17,.57),'Metal',.075)
    box('Exhaust dark throat',(s*.98,2.086,2.19),(.52,.035,.43),'Rubber',.05)
    for k in range(6):box('Exhaust infrared suppressor',(s*.98-.23+k*.092,2.12,2.19),(.025,.08,.40),'Metal',.009)
    for yy in [.32,1.29]:
        tube('Engine removable band',[(s*.64,yy,2.59),(s*.95,yy,2.675),(s*1.32,yy,2.51),(s*1.40,yy,2.05)],.015,'Metal')
    for j in range(3):
        yy=.05+j*.41
        box('Service latch',(s*1.401,yy,2.28),(.025,.12,.05),'Metal',.01)
# Swept stub wing volumes and stores.
for s in [-1,1]:
    v=[(s*.70,-.40,1.69),(s*.78,1.00,1.67),(s*2.55,.70,1.58),(s*2.67,-.06,1.58),(s*.70,-.40,1.55),(s*.78,1.,1.52),(s*2.55,.70,1.50),(s*2.67,-.06,1.50)]
    mesh('Swept stub wing',v,[(0,1,2,3),(4,7,6,5),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],bev=.035)
    for xx in [1.55,2.34]:
        box('Pylon shoe',(s*xx,.26,1.37),(.16,.57,.40),'Weapons',.045)
    # outer 19-shot pod, open rocket tube mouths
    x=s*2.34
    cyl('Rocket pod body',(x,-.91,1.04),(x,1.02,1.04),.285,'Weapons',48)
    for yy in [-.83,.88]:torus('Pod strengthening hoop',(x,yy,1.04),.284,.013,'Metal','Y')
    cyl('Pod rear cap',(x,1.02,1.04),(x,1.07,1.04),.265,'Metal',48)
    holes=[(0,0)]+[(rad*cos(2*pi*k/count),rad*sin(2*pi*k/count)) for rad,count in [(.105,6),(.205,12)] for k in range(count)]
    for dx,dz in holes:
        torus('Rocket tube lip',(x+dx,-.925,1.04+dz),.040,.006,'Metal','Y',16)
        cyl('Rocket tube bore',(x+dx,-.929,1.04+dz),(x+dx,-.935,1.04+dz),.034,'Rubber',16)
        cyl('Rocket nose deep in tube',(x+dx,-.922,1.04+dz),(x+dx,-.925,1.04+dz),.021,'Weapons',12)
    # inner paired two-level Hellfire launch rails and missiles
    x=s*1.52
    for z in [.98,.72]:
        box('Missile twin rail',(x,.16,z+.12),(.63,1.85,.05),'Metal',.012)
        for dx in [-.20,.20]:
            xx=x+dx
            cyl('Missile casing',(xx,-.94,z),(xx,.95,z),.078,'Weapons',24)
            cyl('Missile seeker ogive',(xx,-1.26,z),(xx,-.94,z),.011,'Weapons',24,.078)
            cyl('Missile nozzle',(xx,.95,z),(xx,1.04,z),.064,'Metal',20,.049)
            for yy in [-.50,.72]:
                for a in range(4):
                    ang=a*pi/2+pi/4;dr=Vector((cos(ang),0,sin(ang)));p=Vector((xx,yy,z))
                    verts=[p+dr*.07,p+dr*.22+Vector((0,.19,0)),p+dr*.22+Vector((0,.40,0)),p+dr*.07+Vector((0,.37,0))]
                    o=mesh('Missile stabilizer',[tuple(t) for t in verts],[(0,1,2,3)],'Weapons',0)
                    sol=o.modifiers.new('Fin thickness','SOLIDIFY');sol.thickness=.009;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
# Main landing gear, brake plumbing, hubs and tread ribs.
for s in [-1,1]:
    x=s*1.05;y=-1.16;z=.345
    cyl('Main oleo upper',(s*.61,-.75,1.14),(s*.90,-1.12,.50),.074,'Paint',24)
    cyl('Oleo polished piston',(s*.90,-1.12,.53),(x,y,z),.046,'Metal',24)
    cyl('Trailing gear brace',(s*.70,-1.93,1.01),(x,y,.40),.044,'Metal',20)
    cyl('Axle',(s*.87,y,z),(s*1.24,y,z),.067,'Metal',24)
    torus('Main pneumatic tire',(x,y,z),.252,.093,'Rubber','X',56)
    cyl('Recessed alloy wheel',(s*.93,y,z),(s*1.17,y,z),.185,'Metal',40)
    cyl('Wheel hub cap',(s*1.17,y,z),(s*1.19,y,z),.092,'Weapons',32)
    for k in range(10):
        a=2*pi*k/10
        cyl('Wheel stud',(s*1.17,y+.135*cos(a),z+.135*sin(a)),(s*1.188,y+.135*cos(a),z+.135*sin(a)),.018,'Metal',8)
    for k in range(44):
        a=2*pi*k/44
        pts=[(x-.065,y+.332*cos(a-.025),z+.332*sin(a-.025)),(x,y+.338*cos(a),z+.338*sin(a)),(x+.065,y+.332*cos(a+.025),z+.332*sin(a+.025))]
        tube('Raised tyre tread',pts,.007,'Rubber')
    tube('Hydraulic brake hose',[(s*.68,-.82,1.12),(s*.74,-.95,.86),(s*.97,-1.08,.47),(s*1.04,-1.10,.38)],.012,'Rubber')
# Swept tail fin with true thickness, tail stabilator.
outline=[(8.77,1.37),(9.60,1.41),(10.22,3.12),(9.65,3.30),(9.25,2.82)]
v=[(s*.085,y,z) for s in [-1,1] for y,z in outline];n=len(outline)
f=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
mesh('Swept vertical stabilizer',v,f,bev=.028)
for s in [-1,1]:
    v=[(0,9.20,1.55),(s*1.39,9.25,1.52),(s*1.49,9.72,1.53),(0,9.69,1.59),(0,9.2,1.49),(s*1.39,9.25,1.48),(s*1.49,9.72,1.49),(0,9.69,1.53)]
    mesh('Tailplane',v,[(0,1,2,3),(4,7,6,5),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],bev=.016)
for s in [-1,1]:
    cyl('Tailwheel fork',(s*.11,9.79,1.30),(s*.15,10.16,.23),.039,'Metal',16)
    cyl('Tailwheel diagonal link',(s*.12,9.43,1.25),(s*.15,10.11,.44),.036,'Paint',16)
torus('Tailwheel',(0,10.15,.205),.15,.055,'Rubber','X',40)
cyl('Tailwheel hub',(-.08,10.15,.205),(.08,10.15,.205),.103,'Metal',28)
cyl('Tailwheel spring',(0,9.72,1.14),(0,9.99,.48),.062,'Metal',20)
# Antennas, steps, hatches, rivets, cooling grilles.
for s in [-1,1]:
    for yy in [-3.63,-2.80,-1.94,-.90,.24,1.25]:
        x=s*(.70+min(1,max(0,(yy+3.65)/4.30))*.12)
        tube('Skin vertical joint',[(x,yy,1.09),(x*1.002,yy,1.53)],.006,'Composite')
        for zz in [1.10,1.24,1.39,1.52]:
            ell('Flush skin rivet',(x*1.006,yy+.038,zz),(.008,.013,.013),'Metal',8,6)
    for yy in [-2.4,-1.65,-.90]:
        tube('Crew access step',[(s*.77,yy,1.16),(s*.94,yy,1.06),(s*.94,yy+.32,1.06),(s*.77,yy+.32,1.16)],.018,'Metal')
    box('Vent dark recess',(s*.794,1.04,1.40),(.043,.56,.46),'Rubber',.04)
    for k in range(10):box('Cooling louvre',(s*.823,.80+k*.052,1.40),(.019,.021,.38),'Paint',.007)
    for yy in [-3.3,-2.3,-1.2,.25]:
        xx={-3.3:.713,-2.3:.750,-1.2:.786,.25:.812}[yy]
        box('Lower hull service panel',(s*(xx+.006),yy,1.18),(.018,.53,.24),'Paint',.023)
        for dy in [-.22,.22]:
            for zz in [1.10,1.26]:ell('Panel screw',(s*(xx+.018),yy+dy,zz),(.009,.013,.013),'Metal',8,6)
    for yy in [3.5,5.2,6.9,8.4]:
        w=.45 if yy==3.5 else .30 if yy==5.2 else .22 if yy==6.9 else .18
        tube('Tail access seam',[(s*w,yy,1.24),(s*w,yy+.36,1.25),(s*w*.94,yy+.36,1.47),(s*w*.94,yy,1.47),(s*w,yy,1.24)],.006,'Composite')
    cyl('Pitot boom',(s*.56,-3.73,1.83),(s*.80,-4.37,1.89),.013,'Metal',12)
    ell('Navigation light fairing',(s*2.62,.18,1.61),(.065,.16,.045),'Paint')
    ell('Navigation lamp',(s*2.665,.10,1.63),(.023,.056,.022),'Lamps',16,8)
for y,z in [(3.35,1.68),(7.42,1.51)]:
    mesh('Dorsal blade antenna',[(-.025,y,z),(.025,y,z),(.02,y+.34,z+.37),(-.02,y+.34,z+.37),(-.02,y+.44,z),(.02,y+.44,z)],[(0,1,2,3),(0,3,4),(1,5,2),(2,5,4,3),(0,4,5,1)],'Composite',.009)
cyl('Tail whip aerial',(0,9.72,3.22),(0,9.91,3.74),.007,'Metal',8,.002)
ell('Beacon',(0,1.07,2.68),(.060,.060,.046),'Lamps',16,8)
# Gun articulated turret with cradle, feed chute and single chain-gun barrel.
GROUP='Gun'
cyl('Gun azimuth ring',(0,-3.25,.78),(0,-3.25,1.02),.22,'Metal',32)
ell('Gun elevation gear',(0,-3.25,.72),(.24,.23,.18),'Weapons')
box('Chain gun receiver',(0,-3.49,.66),(.18,.62,.17),'Weapons',.025)
cyl('30mm single barrel',(0,-3.66,.69),(0,-4.64,.67),.031,'Metal',24)
cyl('Barrel end collar',(0,-4.56,.672),(0,-4.67,.670),.046,'Weapons',24)
cyl('Muzzle bore',(0,-4.672,.670),(0,-4.675,.670),.024,'Rubber',20)
for s in [-1,1]:
    tube('Gun cradle',[(s*.19,-3.12,.90),(s*.24,-3.12,.56),(s*.15,-3.65,.52),(s*.10,-3.88,.67)],.026,'Metal')
    cyl('Gun trunnion',(s*.11,-3.32,.73),(s*.29,-3.32,.73),.076,'Metal',24)
for k in range(12):box('Ammunition feed link',(.21,-3.10+k*.028,.72+.08*sin(k*.18)),(.10,.025,.09),'Metal',.006)
GROUP='Fuselage'
# Mast, swashplate and radar remain fixed; rotating hub assembly independent.
cyl('Transmission mast',(0,0,2.68),(0,0,3.37),.092,'Metal',32)
for z,r in [(2.92,.24),(3.02,.27),(3.12,.16)]:cyl('Mast bearing',(0,0,z),(0,0,z+.045),r,'Metal',40)
for s in [-1,1]:
    for y in [-.19,.19]:cyl('Mast support',(s*.34,y,2.78),(s*.18,y,3.08),.029,'Metal',16)
cyl('Radar riser',(0,0,3.33),(0,0,3.60),.15,'Metal',32)
dome=ell('Longbow mast radar',(0,0,3.65),(.60,.60,.22),'Paint',48,24)
for v in dome.data.vertices:
    t=v.co.z/.22;v.co.z=math.copysign(abs(t)**.55,t)*.22
torus('Radar shell seam',(0,0,3.65),.601,.006,'Composite','Z',56)
GROUP='MainRotor'
cyl('Main hub',(0,0,3.20),(0,0,3.38),.21,'Metal',40)
for k in range(4):
    a=math.radians(83)+k*pi/2
    cyl('Rotor grip',(0,0,3.29),(.64*cos(a),.64*sin(a),3.29),.066,'Metal',24)
    boxo=box('Elastomeric bearing',(.43*cos(a),.43*sin(a),3.29),(.23,.17,.11),'Composite',.035);boxo.rotation_euler[2]=a
    cyl('Pitch link',(.19*cos(a+.2),.19*sin(a+.2),3.00),(.47*cos(a+.16),.47*sin(a+.16),3.29),.017,'Metal',12)
    foil('Twisted composite rotor blade',(0,0,3.30),a,7.013,.39)
    cyl('Blade retention bolt',(.55*cos(a),.55*sin(a),3.20),(.55*cos(a),.55*sin(a),3.39),.025,'Metal',12)
GROUP='TailRotor';root=(.26,9.40,2.85)
cyl('Tail rotor shaft',(-.04,9.40,2.85),(.37,9.40,2.85),.067,'Metal',24)
ell('Tail gearbox',(.15,9.40,2.85),(.20,.17,.17),'Paint')
for a in [25,155,205,335]:
    foil('Tail rotor airfoil',root,math.radians(a),.73,.18,'Composite',True)
    ang=math.radians(a);cyl('Tail rotor grip',root,(root[0],root[1]+.35*cos(ang),root[2]+.35*sin(ang)),.028,'Metal',16)
GROUP='Fuselage'

# Fine structural detailing: seam strips, flush rivet rows and access fasteners.
for s in [-1,1]:
    shoulder=[(s*.56,-4.65,1.74),(s*.70,-3.65,1.80),(s*.78,-1.5,1.79),(s*.82,.65,2.12)]
    tube('Canopy sill and armor joint',shoulder,.013,'Composite')
    # Dense paired rivet rows on lower hull, following the skin instead of hovering.
    for k in range(51):
        yy=-3.6+k*.104; xx=.70+min(1,max(0,(yy+3.65)/4.3))*.12
        for zz in [1.055,1.70]:
            ell('Countersunk hull fastener',(s*(xx+.002),yy,zz),(.004,.008,.008),'Metal',8,6)
    # Raised avionic cheek plates and RWR sensor blisters.
    xx=s*.575
    ell('Nose warning sensor',(xx,-4.44,1.69),(.045,.13,.10),'Paint',24,12)
    cyl('Warning receiver lens',(xx,-4.56,1.69),(xx,-4.58,1.69),.065,'Composite',20)
    # Nacelle inspection panel outline and paired latches.
    for ya,yb in [(-.02,.70),(.80,1.45)]:
        x=s*1.405
        tube('Engine hatch gasket',[(x,ya,2.05),(x,yb,2.05),(x,yb,2.43),(x,ya,2.43),(x,ya,2.05)],.005,'Composite')
        for yy in [ya+.07,yb-.07]:
            for zz in [2.09,2.39]:ell('Engine panel fastener',(x+s*.005,yy,zz),(.006,.009,.009),'Metal',8,6)
    # Top nacelle anti-slip access tread, restrained geometric groove rows.
    for yy in [.12,.19,.26,.33,.40,.47,.54]:
        tube('Engine top access tread',[(s*.79,yy,2.663),(s*1.12,yy,2.663)],.006,'Composite')
    # Wing root reinforced inspection cover and hardpoint sway braces.
    top=[(s*.88,-.23,1.703),(s*1.35,-.10,1.677),(s*1.34,.73,1.663),(s*.88,.81,1.697)]
    mesh('Wing root nonslip panel',top,[(0,1,2,3)],'Composite',0)
    for xx in [1.52,2.34]:
        for yy in [.03,.48]:
            cyl('Store sway brace',(s*(xx-.17),yy,1.47),(s*(xx-.24),yy,1.24),.019,'Metal',12)
            cyl('Store sway brace',(s*(xx+.17),yy,1.47),(s*(xx+.24),yy,1.24),.019,'Metal',12)
    # Engine accessories and aft fuselage hydraulic line.
    tube('Aft hydraulic conduit',[(s*.69,1.6,1.18),(s*.54,2.1,1.10),(s*.45,2.8,1.12)],.013,'Metal')
    for y in [2.1,2.4]:box('Conduit clamp',(s*.54,y,1.11),(.08,.032,.028),'Paint',.006)
# Tail longitudinal skin seam with regularly spaced flush rivets.
for s in [-1,1]:
    pts=[(s*.48,3.2,1.34),(s*.32,5,1.30),(s*.22,7,1.31),(s*.17,9.2,1.35)]
    tube('Tail boom longitudinal skin joint',pts,.005,'Composite')
    for a,b in zip(pts,pts[1:]):
        for k in range(11):
            p=Vector(a).lerp(Vector(b),k/11);p.z+=.025;p.x+=s*.004
            ell('Tail skin rivet',p,(.005,.008,.008),'Metal',8,6)


# Apache-style extended forward avionics bays: faceted protective cheek fairings.
for s in [-1,1]:
    cheek=loft('Extended forward avionics bay',[(-3.96,.10,1.12,1.57),(-3.22,.26,1.01,1.69),(-1.30,.31,.99,1.77),(.65,.29,1.04,1.76),(1.22,.15,1.12,1.60)],'Paint',bev=.035)
    for v in cheek.data.vertices:v.co.x+=s*.77
    # Top joint follows the outer shoulders of the armoured bay.
    tube('Avionics bay upper seam',[(s*.87,-3.96,1.50),(s*1.03,-3.22,1.59),(s*1.08,-1.30,1.65),(s*1.06,.65,1.65)],.007,'Composite')
    for yy,xx,top in [(-2.85,1.04,1.59),(-2.02,1.062,1.62),(-1.13,1.078,1.64),(-.25,1.069,1.64),(.48,1.061,1.63)]:
        tube('Avionics bay access door seam',[(s*xx,yy-.33,1.17),(s*xx,yy+.33,1.17),(s*xx,yy+.33,top),(s*xx,yy-.33,top),(s*xx,yy-.33,1.17)],.005,'Composite')
        for dy in [-.28,.28]:
            for zz in [1.21,top-.04]:ell('Avionics door Dzus fastener',(s*(xx+.005),yy+dy,zz),(.006,.011,.011),'Metal',8,6)
        box('Avionics latch',(s*(xx+.010),yy+.16,1.36),(.018,.07,.04),'Metal',.006)
    # Narrow wear along the landing-step brackets, interrupted rather than continuous.
    for yy in [-2.5,-1.7,-.9]:
        tube('Recessed crew foothold',[(s*1.08,yy,1.11),(s*1.15,yy,1.02),(s*1.15,yy+.22,1.02),(s*1.08,yy+.22,1.11)],.016,'Metal')
# Correct the reference's substantial chin turret and receiver bulk.
for o in parts['Gun']:
    pivot=Vector((0,-3.25,.86));inv=o.matrix_world.inverted()
    for v in o.data.vertices:
        p=o.matrix_world@v.co-pivot
        p=Vector((p.x*1.25,p.y*1.18,p.z*1.35))
        v.co=inv@(pivot+p)

# UVs tiled in metres on dominant face plane, maintaining density independent of part size.
for group,obs in parts.items():
    for o in obs:
        active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
        me=o.data;uv=me.uv_layers.active or me.uv_layers.new(name='SurfaceTiles')
        for p in me.polygons:
            axis=max(range(3),key=lambda a:abs(p.normal[a]));axes=[a for a in range(3) if a!=axis]
            for li in p.loop_indices:
                co=o.matrix_world@me.vertices[me.loops[li].vertex_index].co
                uv.data[li].uv=(co[axes[0]]*.8,co[axes[1]]*.8)
# Join rigid assemblies and position the actual pivots.
pivots={'Fuselage':(0,0,0),'MainRotor':(0,0,3.30),'TailRotor':root,'Door_L':(.45,-1.87,2.84),'Door_R':(-.45,-1.87,2.84),'Gun':(0,-3.25,.86)}
assets=[]
for group,obs in parts.items():
    bpy.ops.object.select_all(action='DESELECT')
    for o in obs:o.select_set(True)
    bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=group;o.data.name=group
    sc.cursor.location=pivots[group];bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    # triangulate and remove accidental zero-area faces before export
    bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=bm.faces);bmesh.ops.dissolve_degenerate(bm,edges=bm.edges,dist=.000001);bm.to_mesh(o.data);bm.free()
    assets.append(o)
def empty(name,loc):
    o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=loc;o.empty_display_size=.15;assets.append(o)
for n,p in [('Seat_Pilot',(0,-1.41,1.93)),('Seat_Gunner',(0,-3.07,1.59)),('Light_Nav_L',(2.665,.1,1.63)),('Light_Nav_R',(-2.665,.1,1.63)),('Light_Beacon',(0,1.07,2.68)),('Exhaust_L',(.98,2.12,2.19)),('Exhaust_R',(-.98,2.12,2.19))]:empty(n,p)
for s,side in [(1,'L'),(-1,'R')]:
    for k,x in enumerate([1.52,2.34],1):empty(f'Hardpoint_{side}{k}',(s*x,.26,1.50))
paints=[{'name':'Olive','linear_rgb':[.10,.11,.08]},{'name':'Desert sand','linear_rgb':[.43,.32,.18]},{'name':'Air-superiority light grey','linear_rgb':[.42,.46,.47]},{'name':'Dark grey','linear_rgb':[.085,.095,.10]},{'name':'Navy blue-grey','linear_rgb':[.085,.13,.17]},{'name':'Black','linear_rgb':[.018,.022,.023]}]
def yup(p):return [round(p[0],5),round(p[2],5),round(-p[1],5)]
pts=[o.matrix_world@Vector(c) for o in assets if o.type=='MESH' for c in o.bound_box]
points=[yup(p) for p in pts];lo=[min(p[i] for p in points) for i in range(3)];hi=[max(p[i] for p in points) for i in range(3)]
tri=sum(len(o.data.polygons) for o in assets if o.type=='MESH')
assert 60000<=tri<=140000, f'Triangle budget exceeded: {tri}'
meta={'units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'bounding_box':{'min':lo,'max':hi,'size':[hi[i]-lo[i] for i in range(3)]},'nodes':{o.name:{'world_position':yup(o.location),'type':o.type} for o in assets},'rotation_axes':{'MainRotor':[0,1,0],'TailRotor':[1,0,0],'Gun':[0,1,0],'Door_L':[0,0,1],'Door_R':[0,0,1]},'paints':paints,'triangles':tri,'design_dimensions':{'fuselage_length':15.5,'rotor_diameter':14.6,'overall_rotating_length':17.7,'height':3.87},'textures':'Shared tiled bake sets, 1.25 m repeat; base/normal <=1024; ORM 512; CC0 Poly Haven.'}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
bpy.ops.object.select_all(action='DESELECT')
for o in assets:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'apache-helicopter.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_animations=False,export_cameras=False,export_lights=False)
# Set the paint factor explicitly, preserving neutral base map.
p=ROOT/'apache-helicopter.glb';data=p.read_bytes();ln,typ=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+ln]);binary=data[20+ln:]
for m in doc['materials']:
    if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.1,.11,.08,1]
    if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
p.write_bytes(struct.pack('<III',0x46546c67,2,20+len(js)+len(binary))+struct.pack('<II',len(js),0x4e4f534a)+js+binary)
assert p.stat().st_size<=12000000, f'GLB exceeds 12 MB: {p.stat().st_size}'
print('EXPORTED',tri,'triangles',p.stat().st_size,'bytes',flush=True)
# Studio daylight captures: no render helpers are included in GLB.
sc.cycles.samples=48;sc.cycles.use_denoising=True
world=bpy.data.worlds.new('Soft daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.63,.70,.80,1);world.node_tree.nodes['Background'].inputs[1].default_value=.25
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.018));ground=bpy.context.object;ground.name='RENDER_GROUND'
gm=bpy.data.materials.new('RENDER_GROUND');gm.diffuse_color=(.21,.23,.24,1);ground.data.materials.append(gm)
def area(name,loc,energy,size,target):
    bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.name=name;o.data.energy=energy;o.data.shape='DISK';o.data.size=size;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler()
area('Daylight key',(3,-7,12),3000,7,(0,1,1));area('Broad rim',(-7,6,9),1800,7,(0,2,1));area('Nose fill',(2,-10,5),450,6,(0,-2,1))
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO';cam.data.lens=48
center=Vector((0,1.9,1.65));cam.location=center+Vector((17,-22,7));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=19.8
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
# save scene as remote-only recovery checkpoint
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'source.blend'))
def render(name):sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
render('final.png')
oldloc=cam.location.copy();oldrot=cam.rotation_euler.copy();oldscale=cam.data.ortho_scale
cam.location=(22,2,2.1);target=Vector((0,2,1.9));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=19.9
render('side.png');cam.location=oldloc;cam.rotation_euler=oldrot;cam.data.ortho_scale=oldscale
clay=bpy.data.materials.new('RENDER_CLAY');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.36,.36,.36,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.72
originals={}
for o in assets:
    if o.type=='MESH':
        originals[o.name]=list(o.data.materials)
        for i in range(len(o.data.materials)):o.data.materials[i]=clay
render('clay.png')
for o in assets:
    if o.type=='MESH':
        for i,m in enumerate(originals[o.name]):o.data.materials[i]=m
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
pixels=[]
for paint in paints:
    M['Paint'].node_tree.nodes['Paint tint'].inputs[2].default_value=(*paint['linear_rgb'],1)
    sc.render.filepath=str(ROOT/'paint_temp.png');bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(str(ROOT/'paint_temp.png'),check_existing=False)
    pixels.append(np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4));bpy.data.images.remove(im)
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,a in enumerate(pixels):canvas[(1-i//3)*360:(2-i//3)*360,(i%3)*640:(i%3+1)*640]=a
im=bpy.data.images.new('Six paint options',width=1920,height=720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
M['Paint'].node_tree.nodes['Paint tint'].inputs[2].default_value=(.1,.11,.08,1)
(ROOT/'paint_temp.png').unlink(missing_ok=True)
(ROOT/'NOTES.md').write_text('AH-64 Apache-inspired helicopter; metres, Y-up, +Z nose; articulated rotors, doors and chin gun.\n'+f'{tri:,} triangles; {p.stat().st_size/1048576:.2f} MiB GLB; six rigid mesh nodes and eleven sockets.\n'+'Materials: Paint, Metal, Rubber, Composite, Interior, Weapons, Glass (BLEND), Lamps.\n'+'Cycles GPU-baked tiled maps: Paint base 1024 px; other base maps, all normals and ORM 512 px.\n'+'Poly Haven CC0: metal_plate, rubberized_track; https://polyhaven.com/license; https://polyhaven.com/a/metal_plate; https://polyhaven.com/a/rubberized_track.\n'+'Reference differences: simplified avionics and engine internals; subtle generic wear; no insignia or markings; no flight animation.\n')
print('COMPLETE',flush=True)
