"""Rebuild the UH-1H style game asset from an empty Blender 5.2 scene.
Run: blender -b --python-exit-code 1 --python build_huey-helicopter.py
CC0 photographic inputs: Poly Haven rusty_metal_04, fabric_leather_01, denim_fabric.
Geometry: metres, Blender +X aircraft left, -Y nose, +Z up. glTF export Y up.
"""
import bpy, math, os, json, urllib.request, subprocess, random, sys
from mathutils import Vector
from pathlib import Path
from math import sin,cos,pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
random.seed(47)
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene;S.render.engine='CYCLES';S.cycles.samples=24
S.cycles.use_denoising=True;S.cycles.max_bounces=6
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
S.cycles.device='GPU';S.unit_settings.system='METRIC'
S.render.image_settings.file_format='PNG';S.render.film_transparent=False
S.view_settings.view_transform='AgX';S.view_settings.look='AgX - Medium High Contrast'
GROUPS={};MATS={};PIVOTS={}
def add(o,name,mat='Paint',group='Fuselage'):
    o.name=name
    if mat:o.data.materials.append(MATS[mat])
    GROUPS.setdefault(group,[]).append(o)
    return o

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

def bevel(o,w=.015,segments=3):
    m=o.modifiers.new('manufactured edge radii','BEVEL');m.width=w;m.segments=segments
    m.limit_method='ANGLE';m.angle_limit=.5
    activate(o);bpy.ops.object.modifier_apply(modifier=m.name)
    for p in o.data.polygons:p.use_smooth=True
    m=o.modifiers.new('weighted corner normals','WEIGHTED_NORMAL');m.keep_sharp=True;m.weight=30
    try:bpy.ops.object.modifier_apply(modifier=m.name)
    except:pass
    return o

def box(name,loc,size,mat='Paint',w=.015,group='Fuselage'):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.scale=size
    bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    add(o,name,mat,group)
    if w:bevel(o,w)
    return o

def mesh(name,verts,faces,mat='Paint',group='Fuselage',smooth=True):
    me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
    import bmesh
    bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free();me.update()
    o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);add(o,name,mat,group)
    for p in me.polygons:p.use_smooth=smooth
    return o

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

def cylinder(name,a,b,r,mat='Metal',group='Fuselage',n=16,r2=None):
    a=Vector(a);b=Vector(b);delta=b-a
    bpy.ops.mesh.primitive_cone_add(vertices=n,radius1=r,radius2=r if r2 is None else r2,depth=delta.length,location=(a+b)/2)
    o=bpy.context.object;o.rotation_euler=delta.to_track_quat('Z','Y').to_euler()
    activate(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);add(o,name,mat,group)
    bevel(o,min(.007,r*.18),2);return o

def sphere(name,loc,scale,mat='Paint',group='Fuselage',segments=24,rings=12):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=segments,ring_count=rings,radius=1,location=loc)
    o=bpy.context.object;o.scale=scale;activate(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);add(o,name,mat,group)
    for p in o.data.polygons:p.use_smooth=True
    return o

def tube(name,points,r,mat='Paint',group='Fuselage',n=10):
    # Round bent landing tubing with a convex quadratic arc (no ground-plane overshoot).
    if len(points)>2 and ('skid' in name or 'cross strut' in name):
        pp=[Vector(p) for p in points];rounded=[pp[0]]
        for i in range(1,len(pp)-1):
            prev,cur,nxt=pp[i-1:i+2];d=min(.17,(cur-prev).length*.25,(nxt-cur).length*.25)
            a=cur+(prev-cur).normalized()*d;b=cur+(nxt-cur).normalized()*d
            for j in range(7):
                t=j/6;rounded.append((1-t)**2*a+2*(1-t)*t*cur+t*t*b)
        rounded.append(pp[-1]);points=rounded
    # Parallel transport frame, stable circular cross sections.
    vs=[];fs=[]
    for i,p in enumerate(points):
        p=Vector(p);t=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(0,i-1)])
        t.normalize();ref=Vector((0,0,1)) if abs(t.z)<.9 else Vector((0,1,0))
        u=t.cross(ref).normalized();v=t.cross(u).normalized()
        for j in range(n):vs.append(p+r*(cos(j*2*pi/n)*u+sin(j*2*pi/n)*v))
    for i in range(len(points)-1):
        for j in range(n):a=i*n+j;b=i*n+(j+1)%n;fs.append((a,b,b+n,a+n))
    fs.append(tuple(reversed(range(n))));fs.append(tuple((len(points)-1)*n+j for j in range(n)))
    return mesh(name,vs,fs,mat,group)

def panel(name,pts,mat='Paint',thick=.018,group='Fuselage'):
    o=mesh(name,pts,[tuple(range(len(pts)))],mat,group,False)
    m=o.modifiers.new('sheet metal thickness','SOLIDIFY');m.thickness=thick
    activate(o);bpy.ops.object.modifier_apply(modifier=m.name)
    bevel(o,.008,3);return o

def frame(name,pts,mat='Paint',r=.035,group='Fuselage'):
    return tube(name,pts+[pts[0]],r,mat,group,12)

def rivet(loc,axis='X',group='Fuselage',mat='Metal'):
    sc=[.012,.012,.012];sc['XYZ'.index(axis)]=.005
    return sphere('flush rivet',loc,sc,mat,group,8,4)

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

# Photographic tiling materials are baked once on a UV-calibrated metre tile.
def download(id,mp):
    f=TEX/f'{id}_{mp}_1k.jpg'
    if not f.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg'
        subprocess.run(['curl','--fail','--retry','2','-L','-A','Mozilla/5.0',url,'-o',str(f)],check=True,stdout=subprocess.DEVNULL)
    return f

def image_node(nt,path,color=True):
    n=nt.nodes.new('ShaderNodeTexImage');n.image=bpy.data.images.load(str(path),check_existing=True)
    n.image.colorspace_settings.name='sRGB' if color else 'Non-Color';return n

def bake_mat(name,src,color,rough,metal,normal_strength=.3,neutral=False):
    m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
    out=nt.nodes.new('ShaderNodeOutputMaterial');p=nt.nodes.new('ShaderNodeBsdfPrincipled');nt.links.new(p.outputs['BSDF'],out.inputs['Surface'])
    p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
    tc=nt.nodes.new('ShaderNodeTexCoord')
    if src:
        diff=image_node(nt,download(src,'diff'));nor=image_node(nt,download(src,'nor_gl'),False);arm=image_node(nt,download(src,'arm'),False)
        nt.links.new(tc.outputs['UV'],diff.inputs['Vector']);nt.links.new(tc.outputs['UV'],nor.inputs['Vector']);nt.links.new(tc.outputs['UV'],arm.inputs['Vector'])
        bw=nt.nodes.new('ShaderNodeRGBToBW');nt.links.new(diff.outputs['Color'],bw.inputs[0])
        ramp=nt.nodes.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.08;ramp.color_ramp.elements[1].position=.75
        lo=.76 if neutral else .55;hi=.96 if neutral else 1.1
        ramp.color_ramp.elements[0].color=(*[c*lo for c in color],1);ramp.color_ramp.elements[1].color=(*[c*hi for c in color],1)
        nt.links.new(bw.outputs[0],ramp.inputs[0]);base=ramp.outputs[0]
        normal=nt.nodes.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=normal_strength;nt.links.new(nor.outputs['Color'],normal.inputs['Color']);nt.links.new(normal.outputs['Normal'],p.inputs['Normal'])
        sep=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(arm.outputs[0],sep.inputs[0])
        rm=nt.nodes.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=max(.15,rough-.12);rm.inputs['To Max'].default_value=min(1,rough+.12);nt.links.new(sep.outputs['Green'],rm.inputs['Value']);nt.links.new(rm.outputs[0],p.inputs['Roughness'])
    else:
        rgb=nt.nodes.new('ShaderNodeRGB');rgb.outputs[0].default_value=(*color,1);base=rgb.outputs[0];rm=None
    # Subtle weather, grease stippling and streaking, photographed microsurface underneath.
    noise=nt.nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=8;noise.inputs['Detail'].default_value=3
    mapping=nt.nodes.new('ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(2.5,.35,1);nt.links.new(tc.outputs['UV'],mapping.inputs[0]);nt.links.new(mapping.outputs[0],noise.inputs['Vector'])
    ramp2=nt.nodes.new('ShaderNodeValToRGB');ramp2.color_ramp.elements[0].color=(.78,.78,.78,1);ramp2.color_ramp.elements[1].color=(1,1,1,1);nt.links.new(noise.outputs['Fac'],ramp2.inputs[0])
    mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(base,mix.inputs[1]);nt.links.new(ramp2.outputs[0],mix.inputs[2]);nt.links.new(mix.outputs[0],p.inputs['Base Color'])
    bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-20));o=bpy.context.object;o.data.materials.append(m)
    result={}
    S.cycles.samples=8
    for kind,size in [('base',1024 if src else 64),('normal',(512 if name in ['Metal','Rubber','Interior'] else 1024) if src else 64),('orm',512 if src else 64)]:
        im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
        im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color'
        target=nt.nodes.new('ShaderNodeTexImage');target.image=im;nt.nodes.active=target
        if kind=='base':
            bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'},margin=8)
        elif kind=='normal':bpy.ops.object.bake(type='NORMAL',margin=8)
        else:
            comb=nt.nodes.new('ShaderNodeCombineColor');comb.inputs['Red'].default_value=1;comb.inputs['Green'].default_value=rough;comb.inputs['Blue'].default_value=metal
            if src:nt.links.new(rm.outputs[0],comb.inputs['Green'])
            em=nt.nodes.new('ShaderNodeEmission');nt.links.new(comb.outputs[0],em.inputs['Color']);nt.links.new(em.outputs[0],out.inputs['Surface'])
            bpy.ops.object.bake(type='EMIT',margin=8);nt.links.new(p.outputs['BSDF'],out.inputs['Surface'])
        im.filepath_raw=str(TEX/f'{name}_{kind}.jpg' if kind=='base' else TEX/f'{name}_{kind}.png');im.file_format='JPEG' if kind=='base' else 'PNG';S.render.image_settings.quality=90;im.save();result[kind]=im
    bpy.data.objects.remove(o,do_unlink=True)
    nt.nodes.clear();out=nt.nodes.new('ShaderNodeOutputMaterial');p=nt.nodes.new('ShaderNodeBsdfPrincipled');nt.links.new(p.outputs['BSDF'],out.inputs['Surface'])
    nodes={}
    for k,im in result.items():n=nt.nodes.new('ShaderNodeTexImage');n.image=im;nodes[k]=n
    if neutral:
        mul=nt.nodes.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(.10,.11,.06,1);nt.links.new(nodes['base'].outputs['Color'],mul.inputs[1]);nt.links.new(mul.outputs[0],p.inputs['Base Color']);m['paint_multiplier_node']=mul.name
    else:nt.links.new(nodes['base'].outputs['Color'],p.inputs['Base Color'])
    normal=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(nodes['normal'].outputs[0],normal.inputs['Color']);nt.links.new(normal.outputs[0],p.inputs['Normal'])
    sep=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(nodes['orm'].outputs[0],sep.inputs[0]);nt.links.new(sep.outputs['Green'],p.inputs['Roughness']);nt.links.new(sep.outputs['Blue'],p.inputs['Metallic'])
    # Exporter-recognised packed occlusion binding.
    grp=bpy.data.node_groups.get('glTF Material Output')
    if not grp:
        grp=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');grp.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    g=nt.nodes.new('ShaderNodeGroup');g.node_tree=grp;nt.links.new(sep.outputs['Red'],g.inputs['Occlusion'])
    MATS[name]=m
    print('BAKED',name,flush=True)

for args in [('Paint','rusty_metal_04',(1,1,1),.77,.12,.18,True),('Metal','rusty_metal_04',(.20,.22,.23),.43,.82,.25,False),('Rubber','fabric_leather_01',(.036,.040,.042),.72,.05,.13,False),('Fabric','denim_fabric',(.19,.22,.14),.9,0,.45,False),('Interior','rusty_metal_04',(.17,.18,.15),.83,.06,.15,False),('Glass',None,(.21,.31,.34),.13,.10,0,False),('Lamps',None,(1,1,1),.26,.0,0,False),('Accent',None,(1,1,1),.65,.1,0,False)]:bake_mat(*args)
# Clear laminated glazing: exported alpha Blend; no opaque black window tricks.
p=MATS['Glass'].node_tree.nodes.get('Principled BSDF');p.inputs['Alpha'].default_value=.18;p.inputs['Transmission Weight'].default_value=.08
MATS['Glass'].surface_render_method='BLENDED';MATS['Glass'].use_backface_culling=False
nt=MATS['Glass'].node_tree;v=nt.nodes.new('ShaderNodeVertexColor');v.layer_name='Tint'
mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1
base_link=p.inputs['Base Color'].links[0].from_socket;nt.links.new(base_link,mix.inputs[1]);nt.links.new(v.outputs['Color'],mix.inputs[2]);nt.links.new(mix.outputs[0],p.inputs['Base Color'])
for name in ['Accent','Lamps']:
    nt=MATS[name].node_tree;p=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');v=nt.nodes.new('ShaderNodeVertexColor');v.layer_name='Tint';nt.links.new(v.outputs['Color'],p.inputs['Base Color'])
    if name=='Lamps':nt.links.new(v.outputs['Color'],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=.025

def tint(o,c):
    at=o.data.color_attributes.new(name='Tint',type='BYTE_COLOR',domain='CORNER')
    for x in at.data:x.color=(*c,1)
    return o

# Lower fuselage: the genuine Huey rounded boat-hull and blunt radome.
# Ring loft with an elliptical rounded rectangle, enough longitudinal curvature for close views.
def loft(name,sections,mat='Paint',n=40,power=2.5,group='Fuselage'):
    vs=[];fs=[]
    for y,w,bot,top in sections:
        for j in range(n):
            t=j*2*pi/n;ct=cos(t);st=sin(t)
            x=w*math.copysign(abs(ct)**(2/power),ct);z=(bot+top)/2+(top-bot)/2*math.copysign(abs(st)**(2/power),st)
            vs.append((x,y,z))
    for k in range(len(sections)-1):
        for j in range(n):a=k*n+j;b=k*n+(j+1)%n;fs.append((a,b,b+n,a+n))
    fs.append(tuple(reversed(range(n))));fs.append(tuple((len(sections)-1)*n+j for j in range(n)))
    return mesh(name,vs,fs,mat,group)

hull=loft('curved lower cabin shell',[(-3.75,.12,1.05,1.35),(-3.68,.47,.91,1.56),(-3.5,.77,.81,1.77),(-3.2,.97,.76,1.88),(-2.9,1.075,.74,1.88),(-2.5,1.12,.73,1.69),(-1.7,1.135,.71,1.03),(-.6,1.14,.7,.99),(.7,1.14,.72,1.0),(1.7,1.12,.75,1.05),(2.7,1.08,.80,1.10),(3.2,.65,1.08,1.39)],n=48)
# Main cabin roof with curved shoulder, no skin across the open doors.
roof=loft('arched roof and overhead cabin skin',[(-2.63,.90,2.71,2.81),(-2.42,1.01,2.72,2.88),(-1.8,1.08,2.72,2.96),(-.6,1.10,2.72,2.97),(.8,1.095,2.70,2.94),(1.7,1.08,2.67,2.90),(2.65,1.04,2.54,2.79),(3.1,.66,2.20,2.43)],n=48,power=2.05)
# Real floor and aft shell bulkhead, cabin visible all the way through.
box('cabin floor',(0,-.15,.99),(2.12,3.9,.09),'Interior',.035)
box('rear bulkhead',(0,1.83,1.8),(1.99,.15,1.7),'Interior',.10)
for side in [-1,1]:
    x=side*1.11
    # rear structure around windows/engine shoulder
    panel('aft fixed side skin',[(x,1.23,1.02),(x,2.79,1.05),(side*1.03,2.76,2.52),(side*1.07,1.56,2.74),(x,1.23,2.74)],thick=.045)
    # broad open sliding-door aperture: -0.95 .. 1.25
    box('front door jamb',(x,-.94,1.83),(.085,.11,1.82),'Paint',.025)
    box('aft door jamb',(x,1.23,1.85),(.09,.095,1.75),'Paint',.022)
    box('structural cargo door header',(x,.15,2.73),(.12,2.40,.22),'Paint',.032)
    tube('upper door rail',[(x*1.035,-1.03,2.70),(x*1.035,3.02,2.70)],.027,'Metal')
    tube('door track weather strip',[(x*1.04,-1.03,2.655),(x*1.04,3.02,2.655)],.012,'Rubber')
    tube('lower sill rail',[(x*1.026,-1.0,1.06),(x*1.026,3.02,1.12)],.025,'Metal')
    # Pilot door lower skin and its true window.
    panel('pilot door skin',[(side*1.05,-2.69,1.04),(side*1.09,-.97,.91),(x,-.97,2.70),(side*1.03,-2.05,2.74)],thick=.033)
    # Windows sit fractionally outward, an aperture cut comes below through boolean.
    win=[(side*1.13,-2.39,1.74),(side*1.145,-1.22,1.69),(side*1.145,-1.22,2.51),(side*1.08,-1.89,2.62)]
    # Cut actual hole through pilot-door panel using matching polygon extruded along X.
    door=GROUPS['Fuselage'][-1]
    cutter=mesh('window cutting tool',[(p[0]-side*.25,p[1],p[2]) for p in win]+[(p[0]+side*.25,p[1],p[2]) for p in win],[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],None)
    mod=door.modifiers.new('true pilot glazing aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter;activate(door)
    try:bpy.ops.object.modifier_apply(modifier=mod.name)
    except:door.modifiers.remove(mod)
    GROUPS['Fuselage'].remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
    panel('pilot clear side glazing',win,'Glass',.008);frame('pilot window rubber gasket',win,'Rubber',.026);frame('pilot window metal frame',[(p[0]+side*.01,p[1],p[2]) for p in win],'Paint',.017)
    tube('pilot sliding window division',[(side*1.154,-1.67,1.74),(side*1.154,-1.67,2.55)],.018,'Paint')
    box('pilot door handle',(side*1.16,-1.12,1.51),(.035,.19,.032),'Metal',.012)
    for yy in [-1.17,-1.75,-2.30]:rivet((side*1.16,yy,1.23))
    # Sliding cargo doors stowed aft; two framed windows with genuine transparency.
    group='Door_L' if side==1 else 'Door_R';PIVOTS[group]=(side*1.21,1.42,2.65)
    xx=side*1.225;y0=1.32;y1=3.08
    panel('sliding cargo door lower',[(xx,y0,1.02),(xx,y1,1.15),(xx,y1,1.71),(xx,y0,1.62)],group=group,thick=.035)
    box('cargo door top frame',(xx,2.2,2.58),(.05,1.78,.15),'Paint',.03,group)
    for yy in [y0,2.19,y1]:box('cargo door stile',(xx,yy,2.08),(.05,.10,1.08),'Paint',.025,group)
    for ya,yb in [(1.40,2.12),(2.27,3.00)]:
        pts=[(xx,ya,1.70),(xx,yb,1.73),(xx,yb,2.47),(xx,ya,2.49)]
        panel('cargo door clear window',pts,'Glass',.009,group);frame('cargo window gasket',pts,'Rubber',.023,group)
    box('cargo door latch',(xx+side*.035,1.48,1.53),(.03,.19,.045),'Metal',.01,group)
    for yy in [1.37,3.0]:cylinder('door track roller',(side*1.17,yy,2.68),(side*1.26,yy,2.68),.06,'Metal',group,12)
    # Handles and boarding step.
    tube('boarding grab handle',[(side*1.16,-.88,1.35),(side*1.25,-.88,1.45),(side*1.25,-.88,1.80),(side*1.16,-.88,1.87)],.021,'Metal')
    tube('boarding foot step',[(side*1.11,.38,1.00),(side*1.38,.38,.79),(side*1.38,.91,.79),(side*1.1,.91,1.0)],.032,'Metal')

# Continuous roof/cockpit shoulders close the compound-curved frame junctions.
for side in [-1,1]:
    panel('cockpit upper shoulder skin',[(side*.94,-2.42,2.63),(side*1.073,-1.92,2.61),(side*1.11,-.96,2.62),(side*1.11,-.96,2.83),(side*1.06,-1.91,2.89),(side*.96,-2.47,2.82)],thick=.040)
    panel('windscreen forehead closure',[(side*.048,-2.62,2.74),(side*.94,-2.42,2.63),(side*.96,-2.47,2.82),(side*.048,-2.64,2.83)],thick=.032)
    panel('aft roof rail shoulder',[(side*1.05,1.23,2.58),(side*1.08,2.80,2.51),(side*1.08,2.80,2.71),(side*1.05,1.23,2.85)],thick=.034)
    tube('cockpit lower sill gasket',[(side*.95,-3.03,1.79),(side*1.075,-2.63,1.67),(side*1.11,-2.30,1.48),(side*1.115,-.98,1.13)],.023,'Paint')
# Sloped twin windshields with thick curved perimeter, centre spine and eyebrow.
for side in [-1,1]:
    pts=[(side*.065,-3.36,1.78),(side*.91,-3.04,1.79),(side*.92,-2.40,2.66),(side*.065,-2.60,2.77)]
    panel('laminated front windshield',pts,'Glass',.012);frame('windshield black seal',pts,'Rubber',.037);frame('windshield structural rim',[(x,y-.016,z) for x,y,z in pts],'Paint',.026)
    # Corner quarter light follows compound side/nose contour.
    pts2=[(side*.94,-3.02,1.80),(side*1.067,-2.66,1.73),(side*1.073,-1.92,2.64),(side*.96,-2.37,2.66)]
    panel('corner cockpit quarter light',pts2,'Glass',.011);frame('corner glass rim',pts2,'Paint',.025)
    # Anti-glare top pane.
    pts3=[(side*.11,-2.54,2.80),(side*.87,-2.37,2.72),(side*.99,-1.98,2.87),(side*.13,-2.13,2.95)]
    cut=mesh('eyebrow glazing cutter',[(x,y,z-.28) for x,y,z in pts3]+[(x,y,z+.28) for x,y,z in pts3],[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],None)
    mod=roof.modifiers.new('real eyebrow glass aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;activate(roof);bpy.ops.object.modifier_apply(modifier=mod.name)
    GROUPS['Fuselage'].remove(cut);bpy.data.objects.remove(cut,do_unlink=True)
    eyebrow=panel('overhead roof glazing',pts3,'Glass',.010);tint(eyebrow,(.20,.65,.32));frame('overhead glazing rubber',pts3,'Rubber',.025)
    tube('windscreen wiper arm',[(side*.16,-3.37,1.84),(side*.46,-2.90,2.27),(side*.77,-2.76,2.37)],.014,'Metal')
    tube('windscreen wiper blade',[(side*.52,-2.94,2.27),(side*.81,-2.78,2.31)],.018,'Rubber')
# Radome anti-glare painted-black cap built as separated inset panel, curved skin follows hull.
hull.data.materials.append(MATS['Rubber'])
for face in hull.data.polygons:
    if face.index < 4*48 and face.index%48 < 24:face.material_index=1
# Nose belly chin glazing, retained reference characteristic.
for side in [-1,1]:
    pts=[(side*.15,-3.65,1.01),(side*.68,-3.52,1.07),(side*.83,-3.39,1.38),(side*.15,-3.62,1.40)]
    # Cut all the way through the curved nose before fitting recessed glazing.
    cut=mesh('chin window cutter',[(x,y-.45,z) for x,y,z in pts]+[(x,y+.45,z) for x,y,z in pts],[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],None)
    mod=hull.modifiers.new('real lower cockpit aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;activate(hull);bpy.ops.object.modifier_apply(modifier=mod.name)
    GROUPS['Fuselage'].remove(cut);bpy.data.objects.remove(cut,do_unlink=True)
    panel('lower chin observation glazing',[(x,y-.055,z) for x,y,z in pts],'Glass',.012);frame('chin window gasket',[(x,y-.055,z) for x,y,z in pts],'Paint',.027)
    tube('nose panel seam',[(side*.12,-3.72,1.39),(side*.65,-3.52,1.56),(side*.94,-3.16,1.60),(side*1.075,-2.79,1.52)],.007,'Metal')
    for j in range(11):
        yy=-2.6+j*.15;rivet((side*1.142,yy,1.095))
    sphere('nose fastener cap',(side*.58,-3.46,1.56),(.025,.012,.025),'Metal',segments=12,rings=6)

# Smooth the actual nose shell after aperture booleans; its front panels are uniform anti-glare black.
for face in hull.data.polygons:
    face.material_index=1 if face.center.y < -2.9 else 0
    face.use_smooth=True
# Interior: instruments and seats remain visible through alpha glazing and cargo apertures.
dash=box('instrument panel housing',(0,-2.60,1.70),(1.65,.23,.49),'Rubber',.07);dash.rotation_euler[0]=math.radians(15)
for x in [-.60,-.38,-.14,.12,.36,.59]:
    for z in [1.58,1.77]:
        cylinder('instrument bezel',(x,-2.438,z),(x,-2.418,z),.066,'Metal',n=20)
        cylinder('instrument dark dial',(x,-2.418,z),(x,-2.412,z),.053,'Rubber',n=20)
        tube('instrument needle',[(x,-2.409,z),(x+.031,-2.409,z+.014)],.003,'Accent',n=5)
for x in [-.72,-.56,-.40,.41,.57,.73]:cylinder('panel switch',(x,-2.405,1.43),(x,-2.36,1.45),.009,'Metal',n=8)
box('centre console',(0,-1.95,1.29),(.28,.98,.37),'Interior',.035)
for j in range(9):box('radio cooling and selector slot',(0,-2.28+j*.075,1.485),(.2,.008,.008),'Rubber',.002)
for side in [-1,1]:
    x=side*.52
    # tubular seat frame and realistically separated upholstered cushions
    box('pilot seat cushion',(x,-1.69,1.23),(.53,.58,.13),'Fabric',.075)
    back=box('pilot seat back',(x,-1.38,1.64),(.55,.14,.69),'Fabric',.075);back.rotation_euler[0]=-.10
    box('pilot seat top headrest',(x,-1.35,2.09),(.37,.13,.18),'Fabric',.065)
    for dx in [-.23,.23]:tube('pilot seat frame',[(x+dx,-1.96,1.01),(x+dx,-1.96,1.23),(x+dx,-1.42,1.25),(x+dx,-1.31,2.05)],.022,'Metal')
    tube('cyclic stick',[(x,-2.11,1.02),(x,-2.12,1.27),(x,-2.06,1.43)],.022,'Metal');sphere('cyclic grip',(x,-2.06,1.47),(.034,.04,.085),'Rubber',segments=12,rings=8)
    tube('collective lever',[(x+side*.32,-1.45,1.08),(x+side*.32,-1.79,1.38)],.024,'Metal');cylinder('collective grip',(x+side*.32,-1.79,1.38),(x+side*.32,-1.99,1.41),.031,'Rubber')
    for dx in [-.12,.12]:box('anti torque pedal',(x+dx,-2.37,1.08),(.15,.13,.025),'Metal',.008)
    # Canvas shoulder harness straps.
    for dx in [-.13,.13]:tube('seat safety harness',[(x+dx,-1.45,1.96),(x+dx*.8,-1.48,1.61),(x+dx*.5,-1.72,1.31)],.022,'Rubber',n=6)
    empty('Seat_Pilot' if side==-1 else 'Seat_Copilot',(x,-1.62,1.35))
# transverse aft troop bench, three individual fabric cushions; two forward folding seats.
for j,x in enumerate([-.67,0,.67]):
    box('troop canvas cushion',(x,1.32,1.22),(.59,.53,.10),'Fabric',.055)
    box('troop canvas back',(x,1.65,1.63),(.61,.075,.62),'Fabric',.06)
    empty('Troop_'+str(j+1),(x,1.29,1.34))
    for dx in [-.23,.23]:tube('troop folding frame',[(x+dx,1.08,1.02),(x+dx,1.08,1.24),(x+dx,1.63,1.24),(x+dx,1.72,1.94)],.020,'Metal')
    for dz in [1.49,1.78]:tube('canvas seat horizontal seam',[(x-.25,1.605,dz),(x+.25,1.605,dz)],.005,'Interior',n=5)
for j,side in enumerate([-1,1]):
    box('forward jumpseat canvas',(side*.65,-.63,1.21),(.62,.45,.095),'Fabric',.05)
    box('forward jumpseat back',(side*.65,-.87,1.64),(.6,.065,.66),'Fabric',.045)
    empty('Troop_'+str(j+4),(side*.65,-.55,1.33))
# Quilted sound-proofing aft wall catches raking light.
for ix in range(9):
    for iz in range(6):
        o=box('quilted acoustic wall pad',(-.88+ix*.22,1.735,1.15+iz*.245),(.213,.035,.236),'Interior',.032)
        rivet((-.88+ix*.22,1.709,1.15+iz*.245),'Y')
for side in [-1,1]:
    tube('cabin overhead handhold',[(side*.74,-.66,2.65),(side*.74,-.66,2.52),(side*.74,.65,2.52),(side*.74,.65,2.65)],.017,'Metal')
# Engine doghouse and rounded T53 cowling, aft circular exhaust.
loft('turbine engine cowling',[(-.83,.28,2.88,3.02),(-.62,.49,2.86,3.25),(-.25,.55,2.84,3.42),(.48,.55,2.83,3.44),(1.17,.48,2.80,3.38),(1.62,.41,2.71,3.25),(1.92,.32,2.63,3.15)],n=40)
for side in [-1,1]:
    box('engine inlet dark recess',(side*.531,-.05,3.18),(.02,.60,.33),'Rubber',.025)
    for j in range(8):box('engine inlet louver',(side*.547,-.30+j*.076,3.18),(.024,.018,.30),'Metal',.006)
    for yy in [.57,1.02,1.48]:tube('engine cowling panel rib',[(side*.47,yy,2.92),(side*.5,yy,3.20),(side*.37,yy,3.36),(0,yy,3.43)],.008,'Metal')
    for yy in [-.4,.3,1.0,1.5]:box('cowling latch',(side*.5,yy,2.98),(.025,.065,.032),'Metal',.009)
# Exhaust is a hollow ring, black depth visible from rear.
def ring(name,center,r1,r2,length,axis='Y',mat='Metal'):
    c=Vector(center);vs=[];fs=[];n=40
    for d,r in [(-length/2,r1),(length/2,r1),(length/2,r2),(-length/2,r2)]:
        for j in range(n):
            co=(r*cos(j*2*pi/n),d,r*sin(j*2*pi/n)) if axis=='Y' else (d,r*cos(j*2*pi/n),r*sin(j*2*pi/n))
            vs.append(c+Vector(co))
    for k in range(4):
        for j in range(n):fs.append((k*n+j,k*n+(j+1)%n,((k+1)%4)*n+(j+1)%n,((k+1)%4)*n+j))
    return mesh(name,vs,fs,mat)
ring('turbine exhaust rolled lip',(0,1.89,3.04),.29,.255,.28)
cylinder('deep exhaust darkness',(0,1.73,3.04),(0,1.75,3.04),.252,'Rubber',n=40)
empty('Exhaust_L',(-.10,2.07,3.04));empty('Exhaust_R',(.10,2.07,3.04))
# Tail boom, nonuniform taper with true sections and banded panels.
boomsecs=[(2.8,.82,1.04,2.43),(3.1,.64,1.14,2.28),(3.5,.46,1.29,2.17),(4.0,.39,1.39,2.12),(5.2,.29,1.59,2.11),(6.3,.22,1.75,2.14),(7.4,.17,1.88,2.18),(8.20,.14,1.99,2.27),(8.55,.12,2.10,2.36)]
loft('tapered riveted tail boom',boomsecs,n=40,power=2.15)
for yy in [3.21,3.64,4.58,5.48,6.37,7.21,7.92]:
    # Linear interpolate body cross section for circumferential panel breaks and rivets.
    for a,b in zip(boomsecs,boomsecs[1:]):
        if a[0]<=yy<=b[0]:
            t=(yy-a[0])/(b[0]-a[0]);w=a[1]*(1-t)+b[1]*t;lo=a[2]*(1-t)+b[2]*t;hi=a[3]*(1-t)+b[3]*t;break
    pts=[(w*1.004*cos(j*2*pi/40),yy,(lo+hi)/2+(hi-lo)/2*1.005*sin(j*2*pi/40)) for j in range(41)]
    tube('tail boom panel seam',pts,.004,'Interior',n=5)
    for j in range(16):
        th=j*2*pi/16;rivet((w*1.015*cos(th),yy,(lo+hi)/2+(hi-lo)/2*1.017*sin(th)),'Y',mat='Paint')
# Dorsal driveshaft cover and tail pitch cables.
tube('tail driveshaft dorsal cover',[(0,2.5,2.28),(0,4.2,2.19),(0,6.1,2.20),(0,7.50,2.25),(0,8.38,2.44)],.062,'Paint',n=14)
for side in [-1,1]:tube('tail control cable',[(side*.30,4.2,1.50),(side*.21,6.3,1.84),(side*.16,8.23,2.19)],.008,'Metal',n=6)
# Swept tall tail fin, airfoil sections in X with rounded leading/trailing edges.
fvs=[];ffs=[];fn=40
for z,lead,trail in [(2.04,7.35,8.35),(2.18,7.41,8.64),(2.42,7.59,8.68),(2.88,7.90,8.76),(3.42,8.22,8.85),(3.53,8.28,8.85)]:
    chord=trail-lead
    for j in range(fn):
        th=j*2*pi/fn;u=(1-cos(th))/2;x=.105*chord*sin(th)*max(.02,sin(pi*u))**.38
        fvs.append((x,lead+u*chord,z))
for k in range(5):
    for j in range(fn):a=k*fn+j;b=k*fn+(j+1)%fn;ffs.append((a,b,b+fn,a+fn))
ffs.extend([tuple(reversed(range(fn))),tuple(5*fn+j for j in range(fn))]);mesh('swept tail airfoil fin',fvs,ffs,'Paint')
sphere('tail fin root fairing',(0,7.93,2.23),(.17,.61,.17),'Paint',segments=28,rings=14)
# Stabilizer planes at tail, elliptical actual airfoil.
def airfoil(name,origin,direction,length,chord,mat='Rubber',group='MainRotor',twist=0):
    origin=Vector(origin);direction=Vector(direction).normalized();span=direction;ch=Vector((-direction.y,direction.x,0));up=span.cross(ch).normalized();vs=[];fs=[]
    stations=[0,.12,.3,.6,.85,1.0];np=28
    for q in stations:
        c=chord*(1-.12*q);angle=math.radians(twist*(q-.25));cv=ch*cos(angle)+up*sin(angle);nv=-ch*sin(angle)+up*cos(angle)
        for j in range(np):
            th=j*2*pi/np;u=(1-cos(th))/2
            thick=5*.115*(.2969*math.sqrt(max(u,.00001))-.126*u-.3516*u*u+.2843*u**3-.1015*u**4)
            z=math.copysign(thick,sin(th))+ .01*sin(pi*u)
            vs.append(origin+span*(q*length)+cv*((u-.30)*c)+nv*(z*c))
    for k in range(len(stations)-1):
        for j in range(np):a=k*np+j;b=k*np+(j+1)%np;fs.append((a,b,b+np,a+np))
    fs.append(tuple(reversed(range(np))));fs.append(tuple((len(stations)-1)*np+j for j in range(np)))
    return mesh(name,vs,fs,mat,group)
for side in [-1,1]:airfoil('tail horizontal elevator',(side*.16,6.75,1.91),(side,0,0),1.05,.58,'Paint','Fuselage',-4)
# Skids: curved continuous tubular rails resting exactly at ground level.
for side in [-1,1]:
    x=side*1.25
    points=[(x,-2.78,.30),(x,-2.65,.19),(x,-2.40,.10),(x,-2.12,.075),(x,1.69,.075),(x,1.93,.105),(x,2.04,.145)]
    tube('continuous landing skid',points,.075,'Metal',n=20)
    for yy in [-1.72,1.03]:
        pts=[(side*.65,yy,.83),(side*.77,yy,.73),(side*.96,yy,.48),(x,yy,.13)]
        tube('arched skid cross strut',pts,.065,'Paint',n=18)
        ring('skid clamp',(x,yy,.077),.085,.078,.11,'Y','Metal')
        box('skid strut attachment',(side*.70,yy,.80),(.22,.19,.085),'Metal',.025)
    for yy in [-1.2,-.3,.6]:box('skid anti slip foot tread',(x,yy,.149),(.13,.31,.017),'Rubber',.008)
# Door guns: visibly detailed two M60 style receiver/barrel/pintle assemblies, no turret.
for side in [-1,1]:
    xx=side*1.34;yy=.41;zz=1.57
    cylinder('door gun pedestal',(side*1.00,yy,1.04),(xx,yy,1.47),.041,'Metal')
    cylinder('pintle traverse bearing',(xx,yy,1.43),(xx,yy,1.64),.071,'Metal')
    # Both barrels face forward/out slightly; local -Y used for recognizable broadside.
    box('M60 receiver',(xx,yy-.14,zz+.16),(.14,.48,.17),'Metal',.018)
    box('M60 top cover',(xx,yy-.15,zz+.255),(.148,.32,.026),'Metal',.008)
    cylinder('M60 barrel',(xx,yy-.38,zz+.17),(xx,yy-1.18,zz+.17),.026,'Metal',n=16)
    cylinder('M60 flash suppressor',(xx,yy-1.18,zz+.17),(xx,yy-1.29,zz+.17),.038,'Metal',n=16)
    for j in range(5):cylinder('M60 barrel cooling ring',(xx,yy-.48-j*.065,zz+.17),(xx,yy-.495-j*.065,zz+.17),.043,'Metal',n=14)
    box('M60 buttstock',(xx,yy+.29,zz+.14),(.105,.34,.125),'Rubber',.035)
    grip=box('M60 pistol grip',(xx,yy+.04,zz-.01),(.062,.085,.19),'Rubber',.015);grip.rotation_euler[0]=-.2
    tube('M60 trigger guard',[(xx,yy-.08,zz+.10),(xx,yy-.16,zz-.005),(xx,yy+.01,zz-.025),(xx,yy+.06,zz+.08)],.009,'Metal',n=7)
    tube('M60 barrel carrying handle',[(xx,yy-.42,zz+.22),(xx,yy-.42,zz+.36),(xx,yy-.15,zz+.36),(xx,yy-.15,zz+.26)],.013,'Metal')
    box('M60 front sight',(xx,yy-1.12,zz+.22),(.022,.025,.14),'Metal',.005)
    box('M60 rear sight',(xx,yy+.025,zz+.29),(.06,.04,.057),'Metal',.006)
    box('M60 ammunition can',(xx+side*.21,yy-.04,1.42),(.25,.36,.30),'Interior',.026)
    box('M60 ammunition can lid',(xx+side*.21,yy-.04,1.58),(.27,.38,.027),'Metal',.008)
    for j in range(9):cylinder('linked ammunition belt',(xx+side*(.11+j*.012),yy-.25+j*.025,1.73-j*.012),(xx+side*(.21+j*.012),yy-.25+j*.025,1.73-j*.012),.012,'Metal',n=8)
    box('M60 ejection port',(xx+side*.073,yy-.17,zz+.15),(.006,.13,.064),'Rubber',.004)
    tube('M60 receiver cover seam',[(xx+side*.075,yy-.36,zz+.23),(xx+side*.075,yy+.08,zz+.23)],.004,'Rubber',n=6)
    for j in range(5):
        cylinder('M60 cooling shroud perforation',(xx+side*.036,yy-.51-j*.047,zz+.17),(xx+side*.045,yy-.51-j*.047,zz+.17),.010,'Rubber',n=8)
    box('ammunition can latch',(xx+side*.341,yy-.04,1.48),(.018,.07,.09),'Metal',.006)
    tube('ammunition can carrying handle',[(xx+side*.21,yy-.17,1.60),(xx+side*.21,yy-.17,1.64),(xx+side*.21,yy+.06,1.64),(xx+side*.21,yy+.06,1.60)],.011,'Metal',n=8)
    empty('Hardpoint_L1' if side==1 else 'Hardpoint_R1',(xx,yy,1.61))
# Main rotor transmission, mast and Bell teetering head.
cylinder('transmission mast base',(0,0,2.95),(0,0,3.38),.20,'Metal',n=32)
for z,r in [(3.25,.25),(3.40,.16),(3.79,.14),(4.16,.17)]:cylinder('mast collar',(0,0,z-.035),(0,0,z+.035),r,'Metal',n=32)
cylinder('main rotor mast',(0,0,3.32),(0,0,4.30),.066,'Metal',n=24)
for side in [-1,1]:
    tube('mast support brace',[(side*.28,-.2,3.0),(side*.18,-.15,3.43)],.023,'Metal')
PIVOTS['MainRotor']=(0,0,4.27)
cylinder('main teeter hinge',(-.20,0,4.27),(.20,0,4.27),.10,'Metal','MainRotor',24)
box('rotor yoke',(0,0,4.29),(.33,.70,.105),'Metal',.025,'MainRotor')
# Sweep rotor at 15 degrees relative to fuselage, reference still fully legible.
angle=math.radians(16);span=Vector((sin(angle),cos(angle),0))
for side in [-1,1]:
    v=span*side
    cylinder('rotor blade grip',v*.12+Vector((0,0,4.28)),v*.65+Vector((0,0,4.28)),.065,'Metal','MainRotor',20)
    airfoil('twisted main rotor blade',v*.53+Vector((0,0,4.30)),v,6.77,.48,'Rubber','MainRotor',-9)
    tip=airfoil('yellow rotor safety tip',v*7.12+Vector((0,0,4.30)),v,.18,.428,'Accent','MainRotor',-9);tint(tip,(.75,.51,.025))
    for q in [.24,.52]:cylinder('rotor grip pitch bolt',(v*q+Vector((-.1,0,4.28))),(v*q+Vector((.1,0,4.28))),.025,'Metal','MainRotor',12)
for side in [-1,1]:
    cylinder('swashplate pitch actuator',(side*.17,-.12,3.38),(side*.15,-.10,3.77),.021,'Metal')
    sphere('swashplate spherical link',(side*.15,-.10,3.77),(.032,.032,.032),'Metal',segments=12,rings=6)
# Stabilizer flybar crosses the blade span and has characteristic end weights.
bar=Vector((cos(angle),-sin(angle),0))
cylinder('Bell stabilizer bar',-bar*1.06+Vector((0,0,3.98)),bar*1.06+Vector((0,0,3.98)),.021,'Metal','MainRotor',16)
for side in [-1,1]:
    v=bar*side
    cylinder('stabilizer bar end weight',v*.95+Vector((0,0,3.98)),v*1.12+Vector((0,0,3.98)),.075,'Metal','MainRotor',18)
    tube('pitch link',[(v*.40+Vector((0,0,3.99))),(span*side*.34+Vector((0,0,4.26)))],.017,'Metal','MainRotor')
# Tail two-blade airfoils, shaft along the aircraft X axis.
PIVOTS['TailRotor']=(.255,8.80,3.06)
cylinder('tail rotor gearbox',(-.12,8.80,3.06),(.27,8.80,3.06),.13,'Metal',n=28)
# Create planar airfoil in XY then rotate onto YZ around local pivot.
for side in [-1,1]:
    angle=math.radians(33);direction=Vector((0,sin(angle)*side,cos(angle)*side))
    hub=Vector(PIVOTS['TailRotor']);vs=[];fs=[];n=28
    for q in [0,.15,.4,.7,.87,1]:
        chord=.19*(1-.14*q);dv=Vector((0,cos(angle),-sin(angle)));nv=Vector((1,0,0))
        for j in range(n):
            th=j*2*pi/n;u=(1-cos(th))/2;thick=.10*sin(th)*sin(pi*u)**.5
            vs.append(hub+direction*(.12+q*1.18)+dv*((u-.35)*chord)+nv*thick*chord)
    for k in range(5):
        for j in range(n):a=k*n+j;b=k*n+(j+1)%n;fs.append((a,b,b+n,a+n))
    fs.extend([tuple(reversed(range(n))),tuple(5*n+j for j in range(n))]);tail=mesh('tail rotor airfoil',vs,fs,'Rubber','TailRotor')
    tail.data.materials.append(MATS['Accent']);tint(tail,(.48,.025,.018))
    for poly in tail.data.polygons:
        if 4*n <= poly.index < 5*n or poly.index==5*n+1:poly.material_index=1
cylinder('tail rotor hub cap',(.27,8.80,3.06),(.34,8.80,3.06),.092,'Metal','TailRotor',24)
# Small silhouette anchors: aerials, lights, wiring, pitot, tail skid.
tube('tail protection stinger',[(0,8.0,1.89),(0,8.68,1.58),(0,8.97,1.61)],.023,'Metal')
for side in [-1,1]:
    tube('roof wire cutter rail',[(side*.59,-2.31,2.88),(side*.59,-2.40,3.02),(side*.59,-1.9,2.95)],.018,'Metal')
    tube('nose pitot tube',[(side*.98,-2.59,1.59),(side*1.08,-2.62,1.59),(side*1.08,-3.04,1.63)],.014,'Metal')
    light=sphere('navigation light',(side*1.13,-.61,2.82),(.052,.09,.041),'Lamps',segments=16,rings=8);tint(light,(.45,.008,.008) if side==1 else (.01,.28,.045))
    empty('Light_Nav_L' if side==1 else 'Light_Nav_R',(side*1.13,-.61,2.82))
    for y in [-.8,-.4,0,.4,.8,1.2]:
        for z in [1.11,2.62]:rivet((side*1.13,y,z),mat='Paint')
beacon=sphere('red anti collision beacon',(0,1.42,3.39),(.072,.072,.08),'Lamps',segments=20,rings=10);tint(beacon,(.65,.006,.008));empty('Light_Beacon',(0,1.42,3.44))
panel('roof dorsal blade antenna',[(.02,-1.16,2.95),(.02,-1.10,3.35),(.02,-.86,3.32),(.02,-.80,2.95)],'Rubber',.03)
panel('belly blade antenna',[(0,-2.58,.79),(0,-2.70,.42),(0,-2.27,.74)],'Metal',.022)
tube('tail aerial',[(0,8.76,3.45),(0,9.0,4.06)],.006,'Metal',n=6)
# Dense, purposeful flush fasteners along the cabin crown, lower seam and open frames.
for side in [-1,1]:
    for j in range(28):
        yy=-1.95+j*.135;rivet((side*1.075,yy,2.775),mat='Paint')
    for yy in [-.97,1.235]:
        for j in range(12):rivet((side*1.157,yy,1.12+j*.13),mat='Paint')
    for j in range(16):rivet((side*1.06,-1.0+j*.17,.89),mat='Paint')
# Apply all object transforms, produce material-scale UVs and join rigid assemblies only.
for group,obs in GROUPS.items():
    obs[:]=[o for o in obs if o.name in bpy.data.objects]
    for o in obs:
        if not o.data.color_attributes.get('Tint'):tint(o,(1,1,1))
        activate(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);uv_project(o,1.3 if o.data.materials and o.data.materials[0].name=='Fabric' else .8)
    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
    bpy.context.scene.cursor.location=PIVOTS.get(group,(0,0,0));bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    # Triangulate deterministic production topology.
    mod=o.modifiers.new('game triangles','TRIANGULATE');activate(o);bpy.ops.object.modifier_apply(modifier=mod.name)
    GROUPS[group]=o
# Keep geometry within production budget; smooth subdivision only where actual surface benefits.
tris=sum(len(o.data.polygons) for o in GROUPS.values())
print('PRE_EXPORT_TRIANGLES',tris,flush=True)
if tris<60000:
    # Refining curved hull/curves was already done; deterministic triangle bisection preserves shape.
    import bmesh
    o=GROUPS['Fuselage'];bm=bmesh.new();bm.from_mesh(o.data)
    need=60000-tris
    edges=sorted([e for e in bm.edges if e.calc_length()>.035],key=lambda e:e.calc_length(),reverse=True)[:int(need/2)+1]
    bmesh.ops.subdivide_edges(bm,edges=edges,cuts=1,use_grid_fill=False)
    bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free()
ASSETS=list(GROUPS.values())+[o for o in bpy.context.scene.objects if o.type=='EMPTY']
# Set the specified overall height exactly while keeping the ground-contact datum.
bpy.context.view_layer.update()
zmax=max((o.matrix_world@v.co).z for o in GROUPS.values() for v in o.data.vertices)
zmin=min((o.matrix_world@v.co).z for o in GROUPS.values() for v in o.data.vertices)
zscale=4.4/(zmax-zmin)
for o in ASSETS:
    if o.type=='MESH':
        for v in o.data.vertices:v.co.z*=zscale
    o.location.z=(o.location.z-zmin)*zscale
activate(GROUPS['Fuselage']);S.cursor.location=(0,0,0);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
# Remove collapsed bevel corners and zero-area caps after all transforms.
import bmesh
for o in GROUPS.values():
    bm=bmesh.new();bm.from_mesh(o.data)
    dead=[f for f in bm.faces if f.calc_area()<1e-10]
    bmesh.ops.delete(bm,geom=dead,context='FACES')
    bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free();o.data.update()
tris=sum(len(o.data.polygons) for o in GROUPS.values())
# Explicit recolour contract in six plausible service finishes, values are linear RGB.
paints=[{'name':'Olive drab','linear_rgb':[.10,.11,.06]},{'name':'Air superiority light grey','linear_rgb':[.42,.46,.46]},{'name':'Dark grey','linear_rgb':[.075,.088,.092]},{'name':'Navy blue grey','linear_rgb':[.085,.135,.165]},{'name':'Desert sand','linear_rgb':[.43,.34,.20]},{'name':'Black','linear_rgb':[.018,.021,.019]}]
# Export only asset nodes; no helpers or stage.
bpy.ops.object.select_all(action='DESELECT')
for o in ASSETS:o.select_set(True)
bpy.context.view_layer.objects.active=GROUPS['Fuselage']
bpy.ops.export_scene.gltf(filepath=str(ROOT/'huey-helicopter.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_attributes=True,export_extras=False)
# glTF paint factors are defined explicitly, independent of exporter node heuristics.
import struct
f=ROOT/'huey-helicopter.glb';data=f.read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);binary=bytearray(data[20+jl:])
fixed_tangents=0
for gm in doc.get('meshes',[]):
    for prim in gm['primitives']:
        if 'TANGENT' not in prim['attributes']:continue
        ta=doc['accessors'][prim['attributes']['TANGENT']];na=doc['accessors'][prim['attributes']['NORMAL']]
        tv=doc['bufferViews'][ta['bufferView']];nv=doc['bufferViews'][na['bufferView']]
        to=8+tv.get('byteOffset',0)+ta.get('byteOffset',0);no=8+nv.get('byteOffset',0)+na.get('byteOffset',0)
        for i in range(ta['count']):
            off=to+i*tv.get('byteStride',16);t=struct.unpack_from('<4f',binary,off)
            if sum(x*x for x in t[:3]) < 1e-10:
                n=Vector(struct.unpack_from('<3f',binary,no+i*nv.get('byteStride',12)))
                axis=Vector((1,0,0)) if abs(n.x)<.9 else Vector((0,1,0));fallback=n.cross(axis).normalized()
                struct.pack_into('<4f',binary,off,*fallback,1.0);fixed_tangents+=1
print('REPAIRED_ZERO_TANGENTS',fixed_tangents,flush=True)
for m in doc['materials']:
    if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.10,.11,.06,1];m.get('extensions',{}).pop('KHR_materials_clearcoat',None)
    if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4);f.write_bytes(struct.pack('<III',0x46546c67,2,12+8+len(js)+len(binary))+struct.pack('<II',len(js),0x4e4f534a)+js+binary)
def gl(v):return [round(v[0],5),round(v[2],5),round(-v[1],5)]
bpy.context.view_layer.update();points=[o.matrix_world@Vector(c) for o in GROUPS.values() for c in o.bound_box]
low=[min(gl(v)[i] for v in points) for i in range(3)];high=[max(gl(v)[i] for v in points) for i in range(3)]
meta={'asset':'UH-1H Huey style utility helicopter','units':'metres','forward':'+Z','up':'+Y','left':'+X','nodes':[{'name':o.name,'world_position':gl(o.matrix_world.translation),'type':o.type,'rotation_axis':[0,1,0] if o.name=='MainRotor' else ([1,0,0] if o.name=='TailRotor' else None)} for o in ASSETS],'bounding_box':{'min':low,'max':high,'size':[round(high[i]-low[i],5) for i in range(3)]},'paints':paints,'triangles':tris,'source_dimensions':{'main_rotor_diameter_m':14.6,'fuselage_nominal_length_m':12.7,'length_rotors_turning_nominal_m':17.4,'height_nominal_m':4.4},'bakes':{'engine':'Cycles','device':'OPTIX','samples':8,'mapping':'shared metre-scale tiling sets, 0.8 repeats/metre; fabric 1.3 repeats/metre','passes':['DIFFUSE COLOR','NORMAL tangent','EMIT packed occlusion roughness metallic'],'sources':['rusty_metal_04','fabric_leather_01','denim_fabric']}}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
(ROOT/'NOTES.md').write_text('UH-1H Huey style utility helicopter; open sliding doors, visible cockpit, two M60 style door guns, articulated rotors.\n'+f'{tris:,} triangles; {f.stat().st_size/1048576:.2f} MiB GLB; metres, +Z nose, +Y up.\n'+'8 materials: Paint, Metal, Rubber, Fabric, Interior, Glass, Lamps, Accent.\n'+'Shared Cycles baked tiles: base 1024 px; Paint/Fabric normals 1024, other normals 512; ORM 512; constants 64 px.\n'+'CC0 Poly Haven ids: rusty_metal_04, fabric_leather_01, denim_fabric (https://polyhaven.com/license).\n'+'Reference differences: angular shading around chin windows; simplified engine internals, avionics and fine weapon/rotor mechanisms.\n')
print('EXPORTED',str(f),'TRIS',tris,'BYTES',f.stat().st_size,flush=True)
# Studio: soft daylight, believable contact shadow; exclude from exported asset.
S.cycles.samples=32;S.render.resolution_x=1920;S.render.resolution_y=1080;S.render.resolution_percentage=100
world=bpy.data.worlds.new('soft daylight');S.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.82,1);world.node_tree.nodes['Background'].inputs[1].default_value=.55
floor=bpy.data.materials.new('studio ground');floor.diffuse_color=(.28,.30,.31,1);floor.use_nodes=True;floor.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.28,.30,.31,1);floor.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.9
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.008));ground=bpy.context.object;ground.name='RENDER_ground';ground.data.materials.append(floor)
def area(name,loc,power,size):
    d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);bpy.context.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,1,1.8))-o.location).to_track_quat('-Z','Y').to_euler()
area('large daylight key',(3,-5,10),2200,8);area('cool sky fill',(-6,-1,6),1500,7);area('aft rim',(3,8,8),2200,6)
camd=bpy.data.cameras.new('render camera');cam=bpy.data.objects.new('render camera',camd);bpy.context.collection.objects.link(cam);S.camera=cam
camd.type='ORTHO'
def view(loc,target,scale):
    cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=scale
view((13,-17,9),(0,2.0,1.8),15.8)
def render(name):
    S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);print('RENDERED',name,flush=True)
render('final.png')
# Cabin close-up is additional quality evidence, not a different asset.
view((8,-10,5.4),(0,-.2,1.7),8.7);render('close.png')
view((19,1.2,2.6),(0,1.2,2.3),18.6);render('side.png')
view((13,-17,9),(0,2.0,1.8),15.8)
clay=bpy.data.materials.new('plain grey validation');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.32,.32,.32,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.75
old={};old_indices={}
for o in GROUPS.values():
    old[o.name]=list(o.data.materials);old_indices[o.name]=[p.material_index for p in o.data.polygons];o.data.materials.clear();o.data.materials.append(clay)
render('clay.png')
for o in GROUPS.values():
    o.data.materials.clear()
    for m in old[o.name]:o.data.materials.append(m)
    for poly,idx in zip(o.data.polygons,old_indices[o.name]):poly.material_index=idx
paint=MATS['Paint']
S.cycles.samples=12;S.render.resolution_x=640;S.render.resolution_y=360
palette_pixels=[]
# Generic set glTF-imported factor's Mix node or Principled base.
def set_paint(c):
    nt=paint.node_tree
    for n in nt.nodes:
        if n.type=='MIX' and n.data_type=='RGBA':
            for inp in n.inputs:
                if inp.name in ['A','B'] and inp.type=='RGBA' and not inp.is_linked:inp.default_value=(*c,1)
            return
        if n.type=='MIX_RGB' and n.blend_type=='MULTIPLY':
            for inp in n.inputs[1:]:
                if not inp.is_linked:inp.default_value=(*c,1);return
    p=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');p.inputs['Base Color'].default_value=(*c,1)
for i,p in enumerate(paints):
    set_paint(p['linear_rgb']);S.render.filepath=str(TEX/f'palette_{i}.png');bpy.ops.render.render(write_still=True)
# Blender image pixel compositor keeps build self-contained (no Pillow dependency).
import numpy as np
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i in range(6):
    im=bpy.data.images.load(str(TEX/f'palette_{i}.png'));a=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(a);a=a.reshape(360,640,4)
    row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640,:]=a
im=bpy.data.images.new('six actual paint variants',1920,720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
print('BUILD_COMPLETE',flush=True)
