"""Tiger-style game helicopter. Blender 5.2, clean-scene deterministic build.
CC0 source maps: https://polyhaven.com/a/blue_metal_plate ; baked reusable 1m tiles.
Coordinates during authoring: X left, -Y forward, Z up. glTF converts to X,Y,-Z.
"""
import bpy, bmesh, math, os, json, urllib.request, struct, random, sys
from mathutils import Vector, Matrix
from math import sin, cos, pi
ROOT=os.path.dirname(os.path.abspath(__file__))
TEX=os.path.join(ROOT,'textures');os.makedirs(TEX,exist_ok=True)
random.seed(23)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES'
sc.cycles.samples=32;sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
sc.cycles.device='GPU';sc.render.bake.margin=8
sc.view_settings.view_transform='AgX';sc.view_settings.look='AgX - Medium High Contrast';sc.view_settings.exposure=-.4
# Reusable, physically scaled baked surface sets, not a whole-aircraft atlas.
for mp in ['diff','nor_gl','arm']:
    dst=os.path.join(TEX,'blue_metal_plate_'+mp+'_1k.jpg')
    if not os.path.exists(dst):
        url='https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/blue_metal_plate/blue_metal_plate_'+mp+'_1k.jpg'
        urllib.request.urlretrieve(url,dst)
source={k:bpy.data.images.load(os.path.join(TEX,'blue_metal_plate_'+k+'_1k.jpg'),check_existing=True) for k in ['diff','nor_gl','arm']}
source['nor_gl'].colorspace_settings.name='Non-Color';source['arm'].colorspace_settings.name='Non-Color'
M={}; baked={}
def material(name,col,metal,rough):
    m=bpy.data.materials.new(name);m.use_nodes=True
    p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*col,1);p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
    M[name]=m;return m
for name,col,metal,rough in [('Paint',(.9,.9,.9),0,.76),('Metal',(.22,.24,.26),.85,.4),('Rubber',(.018,.021,.023),0,.86),('Weapons',(.08,.095,.085),.38,.66),('Interior',(.035,.044,.05),.05,.79)]:
    m=material(name,col,metal,rough);nt=m.node_tree;n=nt.nodes;l=nt.links;p=n.get('Principled BSDF')
    uv=n.new('ShaderNodeTexCoord')
    diff=n.new('ShaderNodeTexImage');diff.image=source['diff'];l.new(uv.outputs['UV'],diff.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=.022;ramp.color_ramp.elements[1].position=.065
    if name=='Paint':
        ramp.color_ramp.elements[0].color=(.78,.78,.78,1);ramp.color_ramp.elements[1].color=(.98,.98,.98,1)
    else:
        ramp.color_ramp.elements[0].color=(*(c*.58 for c in col),1);ramp.color_ramp.elements[1].color=(*(min(c*1.3,1) for c in col),1)
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=125;noise.inputs['Detail'].default_value=2;l.new(uv.outputs['UV'],noise.inputs['Vector'])
    mapping=n.new('ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(18,1.4,1);l.new(uv.outputs['UV'],mapping.inputs[0])
    streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=4;streak.inputs['Detail'].default_value=3;l.new(mapping.outputs[0],streak.inputs['Vector'])
    sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].color=(.55,.55,.55,1);sr.color_ramp.elements[0].position=.24;sr.color_ramp.elements[1].position=.52;l.new(streak.outputs['Fac'],sr.inputs[0])
    mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.12;l.new(ramp.outputs[0],mix.inputs[1]);l.new(sr.outputs[0],mix.inputs[2]);l.new(mix.outputs[0],p.inputs['Base Color'])
    norm=n.new('ShaderNodeTexImage');norm.image=source['nor_gl'];l.new(uv.outputs['UV'],norm.inputs['Vector'])
    nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.028 if name=='Paint' else .18;l.new(norm.outputs[0],nm.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.045;bump.inputs['Distance'].default_value=.002;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],p.inputs['Normal'])
    arm=n.new('ShaderNodeTexImage');arm.image=source['arm'];l.new(uv.outputs['UV'],arm.inputs['Vector'])
    sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
    rm=n.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=rough-.08;rm.inputs['To Max'].default_value=min(rough+.1,1);l.new(sep.outputs['Green'],rm.inputs[0]);l.new(rm.outputs[0],p.inputs['Roughness'])
    bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.data.materials.append(m)
    target=n.new('ShaderNodeTexImage');n.active=target
    base=bpy.data.images.new(name+'_BaseColor',1024,1024);target.image=base
    bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'});base.filepath_raw=os.path.join(TEX,name+'_base.jpg');base.file_format='JPEG';base.save()
    normal=bpy.data.images.new(name+'_Normal',1024,1024);normal.colorspace_settings.name='Non-Color';target.image=normal
    bpy.ops.object.bake(type='NORMAL');normal.filepath_raw=os.path.join(TEX,name+'_normal.png');normal.file_format='PNG';normal.save()
    orm=bpy.data.images.new(name+'_ORM',512,512);orm.colorspace_settings.name='Non-Color';target.image=orm
    combine=n.new('ShaderNodeCombineColor');combine.inputs['Red'].default_value=1;combine.inputs['Blue'].default_value=metal;l.new(rm.outputs[0],combine.inputs['Green'])
    emission=n.new('ShaderNodeEmission');l.new(combine.outputs[0],emission.inputs[0]);l.new(emission.outputs[0],n.get('Material Output').inputs['Surface'])
    bpy.ops.object.bake(type='EMIT');orm.filepath_raw=os.path.join(TEX,name+'_orm.png');orm.file_format='PNG';orm.save()
    bpy.data.objects.remove(plane,do_unlink=True)
    # Fresh portable PBR graph. Diffuse factor is patched into glTF below.
    n.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
    t=n.new('ShaderNodeTexImage');t.image=base;l.new(t.outputs[0],p.inputs['Base Color'])
    t=n.new('ShaderNodeTexImage');t.image=normal;nm=n.new('ShaderNodeNormalMap');l.new(t.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
    t=n.new('ShaderNodeTexImage');t.image=orm;sep=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],sep.inputs[0]);l.new(sep.outputs['Green'],p.inputs['Roughness']);l.new(sep.outputs['Blue'],p.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')
    g=n.new('ShaderNodeGroup');g.node_tree=group;l.new(sep.outputs['Red'],g.inputs['Occlusion'])
    baked[name]=(base,normal,orm)
    print('BAKED',name,flush=True)
glass=material('Glass',(.028,.055,.075),.16,.12);gp=glass.node_tree.nodes.get('Principled BSDF');gp.inputs['Alpha'].default_value=.40;gp.inputs['Transmission Weight'].default_value=.08;glass.surface_render_method='BLENDED';glass.diffuse_color=(.028,.055,.075,.40)
lamps=material('Lamps',(.52,.22,.12),.1,.22);lp=lamps.node_tree.nodes.get('Principled BSDF');lp.inputs['Emission Color'].default_value=(.6,.13,.035,1);lp.inputs['Emission Strength'].default_value=1.2
ln=lamps.node_tree.nodes.new('ShaderNodeVertexColor');ln.layer_name='LampTint';lamps.node_tree.links.new(ln.outputs['Color'],lp.inputs['Base Color']);lamps.node_tree.links.new(ln.outputs['Color'],lp.inputs['Emission Color'])
# Mesh builders. Groups are rigid mesh nodes with independent animation pivots.
groups={k:[] for k in ['Fuselage','MainRotor','TailRotor','Door_L','Door_R','Gun']}
G='Fuselage'
def finish(o,mat='Paint',bevel=0,smooth=False):
    o.data.materials.append(M[mat]);groups[G].append(o)
    if mat=='Lamps':
        attr=o.data.color_attributes.new(name='LampTint',type='BYTE_COLOR',domain='CORNER')
        color=(.015,.65,.09,1) if o.location.x<0 else (.65,.008,.004,1)
        for c in attr.data:c.color=color
    bpy.context.view_layer.objects.active=o
    if bevel:
        mod=o.modifiers.new('Machined edge radii','BEVEL');mod.width=bevel;mod.segments=3
        bpy.ops.object.modifier_apply(modifier=mod.name)
    if smooth:
        for p in o.data.polygons:p.use_smooth=True
    return o
def mesh(name,v,f,mat='Paint',bev=0,smooth=False):
    me=bpy.data.meshes.new(name);me.from_pydata(v,[],f);me.update();o=bpy.data.objects.new(name,me);sc.collection.objects.link(o)
    bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o
    bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
    return finish(o,mat,bev,smooth)
def box(name,loc,dim,mat='Paint',bev=.025):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.dimensions=dim;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);return finish(o,mat,bev)
def sphere(name,loc,scale,mat='Paint',seg=32,rings=16):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);return finish(o,mat,0,True)
def rod(name,a,b,r,mat='Metal',verts=16,r2=None):
    a,b=Vector(a),Vector(b);delta=b-a
    bpy.ops.mesh.primitive_cone_add(vertices=verts,radius1=r,radius2=r if r2 is None else r2,depth=delta.length,location=(a+b)/2);o=bpy.context.object;o.name=name;o.rotation_euler=delta.to_track_quat('Z','Y').to_euler();bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);return finish(o,mat,.004 if r>.025 else 0,True)
def tube(name,pts,r=.012,mat='Metal',closed=False):
    cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=1;cu.bevel_depth=r;cu.bevel_resolution=2
    spl=cu.splines.new('POLY');spl.points.add(len(pts)-1)
    for p,co in zip(spl.points,pts):p.co=(*co,1)
    spl.use_cyclic_u=closed;o=bpy.data.objects.new(name,cu);sc.collection.objects.link(o);bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o;bpy.ops.object.convert(target='MESH');return finish(o,mat,0,True)
def loft(name,sections,mat='Paint',sides=16,bev=.02):
    # y, halfwidth, bottom, top; rounded superelliptical cross-section
    dense=[]
    for j in range(len(sections)-1):
        a=Vector(sections[max(j-1,0)]);b=Vector(sections[j]);c=Vector(sections[j+1]);d=Vector(sections[min(j+2,len(sections)-1)])
        for k in range(4):
            t=k/4
            q=.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t)
            dense.append(tuple(q))
    sections=dense+[sections[-1]]
    v=[]
    for y,w,bot,top in sections:
        for i in range(sides):
            a=2*pi*i/sides;x=math.copysign(abs(cos(a))**.88,cos(a))*w;z=(bot+top)/2+math.copysign(abs(sin(a))**.91,sin(a))*(top-bot)/2;v.append((x,y,z))
    f=[]
    for j in range(len(sections)-1):
        for i in range(sides):f.append((j*sides+i,j*sides+(i+1)%sides,(j+1)*sides+(i+1)%sides,(j+1)*sides+i))
    f.extend([tuple(reversed(range(sides))),tuple((len(sections)-1)*sides+i for i in range(sides))]);return mesh(name,v,f,mat,bev,True)
def panel(name,pts,thick=.025,mat='Paint',bev=.008):
    o=mesh(name,pts,[tuple(range(len(pts)))],mat)
    sol=o.modifiers.new('Panel thickness','SOLIDIFY');sol.thickness=thick;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=sol.name)
    if bev:
        mod=o.modifiers.new('Panel edge','BEVEL');mod.width=bev;mod.segments=2;bpy.ops.object.modifier_apply(modifier=mod.name)
    return o
def torus(name,loc,major,minor,mat='Rubber',axis='X'):
    bpy.ops.mesh.primitive_torus_add(major_segments=(16 if major<.05 else 24 if major<.12 else 48),minor_segments=(8 if major<.12 else 12),location=loc,major_radius=major,minor_radius=minor);o=bpy.context.object;o.name=name
    if axis=='X':o.rotation_euler[1]=pi/2
    if axis=='Y':o.rotation_euler[0]=pi/2
    return finish(o,mat,0,True)
# Slender continuous composite airframe, nose -4.8 to tail +9.3.
body_sections=[(-4.8,.035,1.22,1.34),(-4.55,.24,1.04,1.55),(-3.8,.40,.96,1.71),(-2.8,.51,.90,1.82),(-1.5,.55,.88,1.95),(-.2,.56,.92,2.12),(1.15,.54,1.01,2.13),(2.2,.43,1.12,1.86),(3.0,.31,1.17,1.63)]
loft('Rounded composite monocoque',body_sections,sides=32,bev=.012)
loft('Gearbox shoulder',[(-1.0,.41,1.68,2.55),(-.5,.47,1.82,2.64),(.7,.49,1.9,2.61),(1.9,.43,1.7,2.35),(2.65,.31,1.5,1.80)],sides=32,bev=.01)
loft('Tapered composite tail boom',[(1.9,.43,1.11,1.95),(2.8,.33,1.15,1.69),(4,.255,1.17,1.62),(6,.19,1.20,1.56),(7.9,.155,1.20,1.51),(8.8,.14,1.20,1.51),(9.3,.025,1.29,1.38)],sides=32,bev=.012)
# Cockpit open volume: planar clear panels with structural frame; front gunner lower than pilot.
# Canopy section bottom edge and roof ridge pairs.
stations=[(-4.02,.34,1.56,1.69),(-3.10,.46,1.68,2.19),(-2.43,.48,1.76,2.24),(-1.64,.47,1.86,2.61),(-.80,.43,1.95,2.63)]
def hull_side(y,z):
    j=next((i for i in range(len(body_sections)-1) if body_sections[i][0]<=y<=body_sections[i+1][0]),len(body_sections)-2)
    a,b=body_sections[j:j+2];t=(y-a[0])/(b[0]-a[0]);w,bot,top=[a[k]*(1-t)+b[k]*t for k in range(1,4)]
    q=min(.999,abs((z-(bot+top)/2)/((top-bot)/2)))
    return w*(1-q**(2/.91))**(.88/2)
for side in [-1,1]:
    verts=[]
    for j in range(len(stations)-1):
        a,b=stations[j:j+2]
        for k in range(8):
            t=k/8;y,w,z,top=[a[q]*(1-t)+b[q]*t for q in range(4)]
            for r in range(4):
                f=r/3;zz=z-.24*f
                x=w*(1-f)+(hull_side(y,zz)+.006)*f
                verts.append((side*x,y,zz))
    y,w,z,top=stations[-1]
    for r in range(4):
        f=r/3;zz=z-.24*f;verts.append((side*(w*(1-f)+(hull_side(y,zz)+.006)*f),y,zz))
    faces=[(i*4+r,i*4+r+1,(i+1)*4+r+1,(i+1)*4+r) for i in range(len(verts)//4-1) for r in range(3)]
    mesh('Continuous cockpit sill shoulder',verts,faces,'Paint',0,True)
for s in [-1,1]:
    # front and rear side windows; door hinges at upper rails
    for i in range(len(stations)-1):
        y,w,b,t=stations[i];y2,w2,b2,t2=stations[i+1]
        pts=[(s*w,y,b),(s*w2,y2,b2),(s*w2*.76,y2,t2),(s*w*.76,y,t)]
        G='Door_L' if s==1 and i>=2 else 'Door_R' if s==-1 and i<=1 else 'Fuselage'
        panel('Canopy glazing',pts,.008,'Glass',.004)
        tube('Canopy structural perimeter',pts,.032,'Paint',True)
        # sealing rubber just inside frame
        center=sum((Vector(p) for p in pts),Vector())/4
        pp=[tuple(center+(Vector(p)-center)*.955) for p in pts];tube('Glazing seal',pp,.009,'Rubber',True)
        if (s==1 and i==3) or (s==-1 and i==1):
            rod('Canopy hinge',(s*w2*.76,y2-.23,t2+.016),(s*w2*.76,y2-.06,t2+.016),.039,'Metal',16)
            rod('Door handle',(s*(w+.022),y+.22,b+.12),(s*(w+.022),y+.41,b+.13),.018,'Metal',12)
    G='Fuselage'
for i in range(len(stations)-1):
    y,w,b,t=stations[i];yy,ww,bb,tt=stations[i+1]
    pts=[(-w*.76,y,t),(w*.76,y,t),(ww*.76,yy,tt),(-ww*.76,yy,tt)]
    panel('Windscreen and overhead glazing',pts,.008,'Glass',.003);tube('Roof canopy bows',pts,.027,'Paint',True)
# Rear canopy bulkhead and roof cap
panel('Cockpit rear firewall',[(-.43,-.79,1.67),(.43,-.79,1.67),(.327,-.79,2.63),(-.327,-.79,2.63)],.07)
# Visible two-place interior
for y,z in [(-2.83,1.24),(-1.42,1.48)]:
    box('Seat pan',(0,y,z),(.47,.45,.12),'Interior',.065)
    back=box('Armoured seat back',(0,y+.22,z+.32),(.48,.13,.65),'Interior',.05);back.rotation_euler[0]=-.12
    box('Head restraint',(0,y+.24,z+.7),(.29,.15,.20),'Rubber',.045)
    for s in [-1,1]:
        tube('Seat harness',[(s*.13,y+.135,z+.59),(s*.15,y+.10,z+.23),(s*.07,y-.05,z+.085)],.025,'Rubber')
        box('Side console',(s*.31,y-.05,z+.10),(.12,.62,.15),'Interior',.022)
    box('Instrument coaming',(0,y-.46,z+.34),(.58,.15,.31),'Interior',.035)
    for x in [-.17,0,.17]:
        panel('Cockpit display',[(x-.062,y-.374,z+.26),(x+.062,y-.374,z+.26),(x+.062,y-.374,z+.39),(x-.062,y-.374,z+.39)],.005,'Glass')
    rod('Cyclic grip',(0,y-.22,z+.07),(0,y-.24,z+.36),.018,'Rubber',12)
    for s in [-1,1]:box('Pedal',(s*.14,y-.64,z-.03),(.14,.08,.05),'Metal',.008)
# Flush forward avionics cover, no Apache nose drum.
loft('Nose upper avionics fairing',[(-4.48,.16,1.33,1.55),(-4.0,.30,1.42,1.66),(-3.80,.33,1.46,1.68)],sides=24,bev=.008)
# Two engines housed side-by-side under ONE continuous sculpted upper cowl.
loft('Unified twin engine composite cowl',[(-.54,.62,2.00,2.60),(-.15,.68,2.00,2.67),(.45,.71,2.00,2.73),(1.30,.68,1.99,2.66),(1.98,.57,1.93,2.49),(2.33,.37,1.83,2.15)],sides=40,bev=.012)
for side in [-1,1]:
    x=side*.36
    rod('Recessed engine inlet',(x,-.55,2.40),(x,-.603,2.40),.183,'Rubber',40)
    torus('Faired inlet lip',(x,-.615,2.40),.184,.024,'Paint','Y')
    for k in range(9):
        xx=x+(k-4)*.034
        half=math.sqrt(max(.16**2-((k-4)*.034)**2,0))
        rod('Intake screen rib',(xx,-.626,2.40-half),(xx,-.626,2.40+half),.006,'Metal',8)
    # Exhaust lips sit within the tapered rear cowl.
    rod('Exhaust duct',(side*.39,1.88,2.29),(side*.39,2.24,2.25),.195,'Metal',40)
    rod('Exhaust interior',(side*.39,2.242,2.25),(side*.39,2.253,2.25),.161,'Rubber',40)
    torus('Exhaust rim',(side*.39,2.255,2.25),.174,.017,'Metal','Y')
    for yy in [.28,1.47]:
        tube('Engine access seam',[(side*.40,yy,2.68),(side*.59,yy,2.60),(side*.69,yy,2.43),(side*.70,yy,2.23)],.004,'Rubber')
        box('Flush cowl latch',(side*.711,yy,2.34),(.012,.09,.034),'Metal',.005)
    for i in range(7):
        yy=.66+i*.076
        box('Engine cooling slot',(side*.69,yy,2.25),(.018,.036,.12),'Weapons',.007)
# Wing airfoil-like tapered solids
for s in [-1,1]:
    v=[(s*.48,-.12,1.76),(s*.48,.88,1.72),(s*2.32,.88,1.66),(s*2.32,-.10,1.70),(s*.48,-.12,1.65),(s*.48,.88,1.62),(s*2.32,.88,1.59),(s*2.32,-.10,1.61)]
    mesh('Short straight 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)
    tube('Wing leading edge trim',[(s*.54,-.12,1.70),(s*2.31,-.10,1.66)],.015,'Metal')
    for x in [1.35,2.14]:
        box('Weapon pylon',(s*x,.40,1.38),(.15,.40,.42),'Weapons',.022)
        box('Pylon sway brace',(s*x,.4,1.22),(.32,.35,.085),'Metal',.018)
    # 19-tube rocket pod, real hollow-looking front apertures
    x=s*2.14
    rod('Rocket pod shell',(x,-.52,1.04),(x,1.0,1.04),.252,'Weapons',48)
    for yy in [-.43,.86]:torus('Rocket pod reinforcement band',(x,yy,1.04),.253,.018,'Metal','Y')
    face=rod('Rocket pod front face',(x,-.542,1.04),(x,-.56,1.04),.233,'Metal',48)
    holes=[(0,0)]+[(r*cos(a*2*pi/n),r*sin(a*2*pi/n)) for r,n in [(.084,6),(.169,12)] for a in range(n)]
    cutters=[]
    for dx,dz in holes:
        bpy.ops.mesh.primitive_cylinder_add(vertices=16,radius=.0315,depth=.20,location=(x+dx,-.55,1.04+dz),rotation=(pi/2,0,0));cutters.append(bpy.context.object)
    bpy.ops.object.select_all(action='DESELECT')
    for ob in cutters:ob.select_set(True)
    bpy.context.view_layer.objects.active=cutters[0];bpy.ops.object.join();cutter=bpy.context.object
    bpy.context.view_layer.objects.active=face
    mod=face.modifiers.new('Nineteen machined launch apertures','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
    bpy.data.objects.remove(cutter,do_unlink=True)
    for dx,dz in holes:
        rod('Rocket tube dark bore',(x+dx,-.527,1.04+dz),(x+dx,-.531,1.04+dz),.032,'Rubber',12)
        torus('Rocket tube rim',(x+dx,-.585,1.04+dz),.033,.005,'Metal','Y')
    # four anti armour missiles in two-over-two carriage
    for dx in [-.11,.11]:
        for z in [.88,1.08]:
            xx=s*1.35+dx
            rod('Missile launch tube',(xx,-.48,z),(xx,.98,z),.077,'Weapons',24)
            rod('Missile pointed ogive',(xx,-.48,z),(xx,-.78,z),.077,'Weapons',24,r2=.014)
            for yy in [-.35,.79]:torus('Missile tube clamp',(xx,yy,z),.079,.009,'Metal','Y')
            for a in [0,pi/2,pi,3*pi/2]:
                p=Vector((xx,.75,z));d=Vector((cos(a),0,sin(a)))
                panel('Missile stabiliser',[tuple(p+d*.06),tuple(p+d*.16+Vector((0,.17,0))),tuple(p+d*.16+Vector((0,.32,0))),tuple(p+d*.06+Vector((0,.32,0)))],.008,'Weapons',.004)
    sphere('Wing navigation lamp',(s*2.32,.58,1.66),(.035,.075,.035),'Lamps',20,10)
# Tall swept fin, tailplane at its base, and twin vertical endplates.
v=[(-.105,7.60,1.32),(-.065,8.94,3.39),(-.055,9.30,3.41),(-.095,8.76,1.32),(.105,7.60,1.32),(.065,8.94,3.39),(.055,9.30,3.41),(.095,8.76,1.32)]
mesh('Swept composite vertical fin',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 side in [-1,1]:
    x=side*1.24
    mesh('Tailplane',[(side*.08,7.89,1.51),(x,8.06,1.52),(x,8.71,1.49),(side*.08,8.62,1.46),(side*.08,7.89,1.43),(x,8.06,1.46),(x,8.71,1.43),(side*.08,8.62,1.40)],[(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=.022)
    panel('Tiger tailplane vertical endplate',[(x,8.10,1.42),(x,8.24,1.79),(x,8.52,1.77),(x,8.70,1.45),(x,8.60,1.31),(x,8.24,1.31)],.047,'Paint',.017)
# Landing gear. Two sprung main legs and small tail wheel contact ground exactly.
for s in [-1,1]:
    rod('Main oleo upper',(s*.45,-.86,1.01),(s*.95,-.66,.45),.067,'Weapons',20)
    rod('Oleo polished piston',(s*.94,-.69,.52),(s*1.04,-.62,.32),.042,'Metal',20)
    rod('Gear drag brace',(s*.48,-1.24,1.00),(s*.99,-.65,.33),.044,'Weapons',16)
    rod('Wheel axle',(s*.98,-.64,.32),(s*1.19,-.64,.32),.068,'Metal',20)
    torus('Main tyre',(s*1.17,-.64,.32),.231,.089,'Rubber')
    rod('Wheel alloy hub',(s*1.075,-.64,.32),(s*1.267,-.64,.32),.153,'Metal',32)
    rod('Wheel hub inset',(s*1.265,-.64,.32),(s*1.276,-.64,.32),.108,'Weapons',32)
    for i in range(8):
        a=i*pi/4;rod('Wheel bolts',(s*1.278,-.64+.078*cos(a),.32+.078*sin(a)),(s*1.29,-.64+.078*cos(a),.32+.078*sin(a)),.013,'Metal',8)
    for i in range(40):
        a=i*2*pi/40
        ob=box('Tyre tread',(s*1.17,-.64+.312*sin(a),.32+.312*cos(a)),(.12,.031,.009),'Rubber',.002);ob.rotation_euler[0]=-a
    tube('Hydraulic gear line',[(s*.48,-.91,1.01),(s*.82,-.79,.72),(s*.95,-.7,.45),(s*1.04,-.66,.37)],.009,'Rubber')
rod('Tailwheel forward brace',(0,8.12,1.31),(0,9.08,.35),.028,'Weapons',16)
rod('Tailwheel drag leg',(0,8.80,1.32),(0,9.13,.23),.037,'Weapons',16)
for s in [-1,1]:rod('Tailwheel fork',(s*.09,8.94,.65),(s*.09,9.15,.18),.024,'Metal',12)
torus('Tail tyre',(0,9.15,.18),.126,.054,'Rubber')
rod('Tail hub',(-.065,9.15,.18),(.065,9.15,.18),.079,'Metal',24)
# Chin turret, flexible ammunition feed and offset chain-gun barrel.
G='Gun';sphere('Turret cradle',(0,-3.34,.95),(.25,.30,.20),'Weapons')
rod('Gun elevation axle',(-.22,-3.38,.84),(.22,-3.38,.84),.10,'Metal',24)
box('Gun receiver',(0,-3.57,.77),(.17,.59,.18),'Weapons',.027)
rod('Chain gun barrel',(0,-3.81,.79),(0,-4.67,.84),.033,'Metal',20)
rod('Muzzle brake',(0,-4.61,.836),(0,-4.78,.846),.047,'Weapons',20)
rod('Muzzle bore',(0,-4.784,.846),(0,-4.791,.846),.025,'Rubber',16)
for s in [-1,1]:tube('Turret protective frame',[(s*.23,-3.1,.93),(s*.25,-3.4,.59),(s*.17,-3.95,.65),(s*.1,-4.13,.78)],.017,'Metal')
tube('Ammunition feed',[(.21,-3.08,1.02),(.28,-3.22,.83),(.26,-3.43,.75),(.10,-3.56,.75)],.067,'Rubber')
for i in range(10):box('Ammunition belt link',(.248,-3.2-i*.026,.84-i*.009),(.10,.02,.084),'Metal',.003)
G='Fuselage'
# Mast and stationary optical sight, hub spins below sight.
rod('Rotor mast',(0,0,2.59),(0,0,3.58),.085,'Metal',32)
rod('Mast bearing',(0,0,2.67),(0,0,2.83),.175,'Weapons',32)
for s in [-1,1]:rod('Transmission support',(s*.3,.17,2.61),(s*.27,.17,2.91),.042,'Metal',16)
rod('Sight stem',(0,0,3.24),(0,0,3.59),.11,'Weapons',32)
sphere('Spherical mast mounted sight',(0,0,3.53),(.275,.275,.27),'Weapons',48,24)
rod('Sight optical window',(-.102,-.247,3.55),(-.102,-.268,3.55),.092,'Glass',32)
rod('Sight optical window',(.105,-.247,3.55),(.105,-.269,3.55),.061,'Glass',24)
# True thick NACA sections with spanwise taper, twist and swept tips.
def blade(name,origin,angle,r0,length,chord,mat='Weapons',tail=False):
    spans=26;profile=20;v=[];f=[]
    for j in range(spans+1):
        t=j/spans;r=r0+length*t;ch=chord*(.78+.22*min(t*5,1))*(1-.25*max((t-.86)/.14,0));tw=math.radians(10-15*t)
        sweep=chord*.10*max((t-.88)/.12,0)
        for k in range(profile):
            a=2*pi*k/profile;u=(1-cos(a))/2;th=5*.115*(.2969*math.sqrt(max(u,0))-.126*u-.3516*u*u+.2843*u**3-.1015*u**4)
            h=(1 if k<profile/2 else -1)*th*ch;cc=(u-.28)*ch
            tang=cc*cos(tw)-h*sin(tw)+sweep;zz=cc*sin(tw)+h*cos(tw)
            p=Vector((r*cos(angle)-tang*sin(angle),r*sin(angle)+tang*cos(angle),zz))
            if tail:p=Vector((p.z,p.x,p.y))
            v.append(tuple(Vector(origin)+p))
    for j in range(spans):
        for k in range(profile):f.append((j*profile+k,j*profile+(k+1)%profile,(j+1)*profile+(k+1)%profile,(j+1)*profile+k))
    f.extend([tuple(reversed(range(profile))),tuple(spans*profile+k for k in range(profile))]);return mesh(name,v,f,mat,0,True)
G='MainRotor'
rod('Main hub',(0,0,3.06),(0,0,3.20),.23,'Metal',40)
torus('Swashplate',(0,0,2.94),.24,.032,'Metal','Z')
for i in range(4):
    a=.22+i*pi/2;d=Vector((cos(a),sin(a),0))
    rod('Blade grip',tuple(d*.16+Vector((0,0,3.13))),tuple(d*.66+Vector((0,0,3.13))),.07,'Metal',20)
    for r in [.30,.48]:
        pt=d*r+Vector((0,0,3.15));box('Rotor grip forged cap',pt,(.16,.16,.07),'Metal',.02)
        rod('Rotor retention bolt',tuple(pt+Vector((0,0,-.08))),tuple(pt+Vector((0,0,.08))),.022,'Metal',12)
    rod('Pitch linkage',tuple(d*.24+Vector((0,0,2.95))),tuple(d*.53+Vector((-.04,.03,3.12))),.017,'Metal',12)
    blade('Twisted main rotor airfoil',(0,0,3.14),a,.58,5.92,.31)
G='TailRotor';hub=(-.23,8.80,2.99)
rod('Tail rotor shaft',(.10,8.8,2.99),(-.35,8.8,2.99),.074,'Metal',24)
sphere('Tail rotor central hub',hub,(.11,.13,.13),'Metal',24,12)
for i in range(3):
    a=.50+i*2*pi/3;blade('Twisted tail rotor airfoil',hub,a,.10,.72,.16,tail=True)
    rod('Tail pitch link',(-.29,8.8,2.99),(-.29,8.8+.22*cos(a),2.99+.22*sin(a)),.021,'Metal',12)
G='Fuselage'
# Service panels, flush seams, fasteners, vents and fittings; deliberately zoned.
def skin_x(y,z):
    sections=body_sections
    a,b=next(((a,b) for a,b in zip(sections,sections[1:]) if a[0]<=y<=b[0]),(sections[-2],sections[-1]))
    t=(y-a[0])/(b[0]-a[0]);w,bot,top=[a[i]*(1-t)+b[i]*t for i in range(1,4)]
    # Interpolate the authored 24-sided superellipse exactly.
    ring=[]
    for i in range(13):
        ang=-pi/2+i*pi/12
        ring.append(((bot+top)/2+math.copysign(abs(sin(ang))**.91,sin(ang))*(top-bot)/2,abs(cos(ang))**.88*w))
    za,zb=next(((a,b) for a,b in zip(ring,ring[1:]) if a[0]<=z<=b[0]),(ring[5],ring[6]))
    q=(z-za[0])/max(zb[0]-za[0],1e-5)
    return za[1]*(1-q)+zb[1]*q

for s in [-1,1]:
    for yy,zz,w,h,xx in [(-2.65,1.36,.66,.28,.5),(-.95,1.39,.58,.30,.55),(1.45,1.47,.46,.33,.5)]:
        pts=[(s*xx,yy-w/2,zz-h/2),(s*xx,yy+w/2,zz-h/2),(s*xx,yy+w/2,zz+h/2),(s*xx,yy-w/2,zz+h/2)]
        outline=[]
        for a,b in zip(pts,pts[1:]+pts[:1]):
            for j in range(5):
                yy2=a[1]+(b[1]-a[1])*j/5;zz2=a[2]+(b[2]-a[2])*j/5
                outline.append((s*(skin_x(yy2,zz2)+.005),yy2,zz2))
        tube('Recessed service panel seam',outline,.003,'Rubber',True)
        for p in pts:
            a=(s*(skin_x(p[1]+.035,p[2]+.024)+.006),p[1]+.035,p[2]+.024);b=(a[0]+s*.008,a[1],a[2]);rod('Flush panel fastener',a,b,.012,'Metal',10)
        box('Panel latch',(s*(skin_x(yy+.14,zz)+.012),yy+.14,zz),(.015,.07,.03),'Metal',.004)
    rod('Crew boarding step',(s*.51,-.98,1.02),(s*.75,-.98,.85),.024,'Metal',12)
    rod('Crew foot rail',(s*.75,-1.30,.85),(s*.75,-.78,.85),.022,'Metal',12)
    rod('Grab rail',(s*.46,-.83,2.13),(s*.52,-.38,2.13),.017,'Metal',12)
    # sensors / cable cutters adjacent to cockpit
    box('Radar warning receiver',(s*.56,.3,1.93),(.12,.19,.12),'Weapons',.02)
    rod('Wing antenna',(s*2.1,.53,1.72),(s*2.40,.53,1.74),.016,'Metal',12)
    rod('Engine ventilation fastener row',(s*.64,1.48,2.56),(s*.68,1.48,2.56),.012,'Metal',10)
for yy,z in [(2.7,1.87),(5.0,1.59),(9.08,3.41)]:
    rod('Whip aerial base',(0,yy,z-.06),(0,yy,z+.05),.028,'Weapons',12)
    rod('Whip aerial',(0,yy,z+.05),(0,yy+.04,min(z+.39,3.79)),.006,'Metal',8,r2=.002)
box('Belly avionics keel',(0,2.55,1.10),(.15,.39,.18),'Weapons',.02)
panel('Dorsal wire cutter',[(0,-.45,2.60),(0,-.75,2.86),(0,-.63,2.55)],.032,'Metal',.006)
sphere('Beacon',(0,1.05,2.71),(.065,.065,.068),'Lamps',20,10)
# UV tiling in physical metres; planar seams correspond to mechanical boundaries.
# Remove numerical slivers, triangulate and join the rigid assemblies.
pivots={'Fuselage':(0,0,0),'MainRotor':(0,0,3.14),'TailRotor':hub,'Door_L':(.3572,-1.64,2.61),'Door_R':(-.3496,-3.10,2.19),'Gun':(0,-3.34,.95)}
objects=[]
for name,obs in groups.items():
    for o in obs:
        bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o
        bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
        me=o.data;uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers.active
        for poly in me.polygons:
            ax=max(range(3),key=lambda k:abs(poly.normal[k]));axes=[k for k in range(3) if k!=ax]
            for li in poly.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)
    bpy.ops.object.select_all(action='DESELECT')
    for o in obs:o.select_set(True)
    bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
    sc.cursor.location=pivots[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
    bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bad=[f for f in bm.faces if f.calc_area()<1e-11]
    if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
    bm.to_mesh(o.data);bm.free();objects.append(o)
# Required attachment nodes, coordinates exported Y up.
sockets={'Seat_Pilot':(0,-1.42,1.58),'Seat_Gunner':(0,-2.83,1.34),'Hardpoint_L1':(1.35,.4,1.57),'Hardpoint_R1':(-1.35,.4,1.57),'Hardpoint_L2':(2.14,.4,1.57),'Hardpoint_R2':(-2.14,.4,1.57),'Light_Nav_L':(2.32,.58,1.66),'Light_Nav_R':(-2.32,.58,1.66),'Light_Beacon':(0,1.05,2.71),'Exhaust_L':(.39,2.25,2.25),'Exhaust_R':(-.39,2.25,2.25)}
for name,loc in sockets.items():
    o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=loc;o.empty_display_size=.1;objects.append(o)
paints=[{'name':'Olive grey (default)','linear_rgb':[.20,.22,.20]},{'name':'Air-superiority light grey','linear_rgb':[.48,.52,.55]},{'name':'Dark grey','linear_rgb':[.105,.12,.135]},{'name':'Navy blue-grey','linear_rgb':[.12,.18,.23]},{'name':'Desert sand','linear_rgb':[.49,.38,.23]},{'name':'Black','linear_rgb':[.035,.04,.042]}]
tri=sum(len(o.data.polygons) for o in objects if o.type=='MESH')
pts=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
def cv(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
pts=[cv(v) for v in pts];bounds={'min':[min(v[i] for v in pts) for i in range(3)],'max':[max(v[i] for v in pts) for i in range(3)]}
metadata={'units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'nodes':[{'name':o.name,'world_position':cv(o.location),'type':o.type} for o in objects],'bounds':bounds,'triangles':tri,'rotors':{'MainRotor':{'axis':[0,1,0]},'TailRotor':{'axis':[1,0,0]}},'doors':{'Door_L':{'hinge_axis':[-.03616,.02379,-.99906]},'Door_R':{'hinge_axis':[-.02262,.0744,-.99697]}},'paints':paints,'dimensions':{'fuselage_length_m':14.1,'main_rotor_diameter_m':13.0,'sight_height_m':3.8}}
with open(os.path.join(ROOT,'nodes.json'),'w') as f:json.dump(metadata,f,indent=2)
bpy.ops.object.select_all(action='DESELECT')
for o in objects:o.select_set(True)
GLB=os.path.join(ROOT,'tiger-helicopter.glb')
bpy.ops.export_scene.gltf(filepath=GLB,export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_animations=False,export_cameras=False,export_lights=False,export_image_format='AUTO')
# Set exact game-editable baseColorFactor and alpha contract in the delivered file.
data=open(GLB,'rb').read();jl,jt=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+jl]);binary=data[20+jl:]
for m in doc['materials']:
    if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.20,.22,.20,1]
    if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
    if m['name']=='Lamps':
        # glTF emissiveFactor cannot multiply vertex colour; unlit preserves coloured LEDs.
        m.pop('emissiveFactor',None);m['extensions']={'KHR_materials_unlit':{}}
        m['pbrMetallicRoughness']['baseColorFactor']=[1,1,1,1]
if 'KHR_materials_unlit' not in doc.setdefault('extensionsUsed',[]):doc['extensionsUsed'].append('KHR_materials_unlit')
# Remove redundant white vertex colours from non-lamp primitives, then compact buffers.
for me in doc['meshes']:
    for pr in me['primitives']:
        if doc['materials'][pr['material']]['name']!='Lamps':pr['attributes'].pop('COLOR_0',None)
used_acc=sorted({a for me in doc['meshes'] for pr in me['primitives'] for a in list(pr['attributes'].values())+[pr['indices']]})
amap={a:i for i,a in enumerate(used_acc)}
for me in doc['meshes']:
    for pr in me['primitives']:
        pr['attributes']={k:amap[v] for k,v in pr['attributes'].items()};pr['indices']=amap[pr['indices']]
doc['accessors']=[doc['accessors'][i] for i in used_acc]
used_views=sorted({a['bufferView'] for a in doc['accessors']}|{im['bufferView'] for im in doc['images']})
vmap={v:i for i,v in enumerate(used_views)};oldbin=binary[8:];newbin=bytearray();views=[]
for vi in used_views:
    view=dict(doc['bufferViews'][vi]);offset=view.get('byteOffset',0)
    newbin.extend(b'\x00'*((-len(newbin))%4));view['byteOffset']=len(newbin);newbin.extend(oldbin[offset:offset+view['byteLength']]);views.append(view)
for a in doc['accessors']:a['bufferView']=vmap[a['bufferView']]
for im in doc['images']:im['bufferView']=vmap[im['bufferView']]
doc['bufferViews']=views;doc['buffers'][0]['byteLength']=len(newbin)
newbin.extend(b'\x00'*((-len(newbin))%4));binary=struct.pack('<II',len(newbin),0x004e4942)+newbin
j=json.dumps(doc,separators=(',',':')).encode();j+=b' ' *((-len(j))%4)
open(GLB,'wb').write(struct.pack('<4sII',b'glTF',2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+binary)
assert 60000<=tri<=140000,tri
assert os.path.getsize(GLB)<=12000000,os.path.getsize(GLB)
print('EXPORTED',tri,os.path.getsize(GLB),flush=True)
# Rendering uses exactly the same baked maps and colour multiplier.
p=M['Paint'].node_tree.nodes.get('Principled BSDF');nt=M['Paint'].node_tree;src=p.inputs['Base Color'].links[0].from_socket
mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.20,.22,.20,1);nt.links.new(src,mix.inputs[1]);nt.links.new(mix.outputs[0],p.inputs['Base Color'])
# Studio daylight on a matte neutral ground, all staging excluded from export.
ground=material('StudioGround',(.29,.32,.35),0,.9)
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.006));floor=bpy.context.object;floor.name='STUDIO Ground';floor.data.materials.append(ground)
sc.world=bpy.data.worlds.new('Daylight World');sc.world.use_nodes=True;sc.world.node_tree.nodes.get('Background').inputs[0].default_value=(.57,.66,.78,1);sc.world.node_tree.nodes.get('Background').inputs[1].default_value=.55
for name,loc,power,size in [('Large daylight',(-7,-5,11),1900,9),('Soft fill',(8,-1,6),1350,8),('Rear edge',(1,10,10),2200,7)]:
    ld=bpy.data.lights.new(name,'AREA');lo=bpy.data.objects.new(name,ld);sc.collection.objects.link(lo);lo.location=loc;ld.energy=power;ld.shape='DISK';ld.size=size;lo.rotation_euler=(Vector((0,1,1.5))-lo.location).to_track_quat('-Z','Y').to_euler()
ld=bpy.data.lights.new('Sun','SUN');lo=bpy.data.objects.new('Sun',ld);sc.collection.objects.link(lo);ld.energy=1.6;ld.angle=.28;lo.rotation_euler=(.4,-.5,-.5)
camd=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camd);sc.collection.objects.link(cam);sc.camera=cam
cam.location=(16,-21,7.1);target=Vector((0,1.6,1.7));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=17.5;camd.lens=48
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.render.image_settings.file_format='PNG'
def render(name):sc.render.filepath=os.path.join(ROOT,name);bpy.ops.render.render(write_still=True)
render('final.png')
camloc=cam.location.copy();camrot=cam.rotation_euler.copy();scale=camd.ortho_scale
cam.location=(20,2.0,2.0);cam.rotation_euler=(Vector((0,2.0,2.0))-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=17.2;render('side.png')
cam.location=camloc;cam.rotation_euler=camrot;camd.ortho_scale=scale
clay=bpy.data.materials.new('Clay');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.39,.39,.39,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.65
saved={};saved_indices={}
for o in objects:
    if o.type=='MESH':
        saved[o.name]=list(o.data.materials);saved_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 objects:
    if o.type=='MESH':
        o.data.materials.clear()
        for m in saved[o.name]:o.data.materials.append(m)
        for p,idx in zip(o.data.polygons,saved_indices[o.name]):p.material_index=idx
# Individual six-colour views combined using Blender image buffers.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
import numpy as np
atlas=np.zeros((720,1920,4),dtype=np.float32)
for i,paint in enumerate(paints):
    mix.inputs[2].default_value=(*paint['linear_rgb'],1)
    render('_paint_'+str(i)+'.png');im=bpy.data.images.load(sc.render.filepath,check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape((360,640,4));row=1-i//3;atlas[row*360:(row+1)*360,(i%3)*640:(i%3+1)*640]=arr;bpy.data.images.remove(im);os.unlink(sc.render.filepath)
out=bpy.data.images.new('Paint variants',1920,720);out.pixels.foreach_set(atlas.ravel());out.filepath_raw=os.path.join(ROOT,'paints.png');out.file_format='PNG';out.save()
notes=[
'Eurocopter Tiger-style composite tandem attack helicopter: unified engine cowl, three-blade right tail rotor, endplate tailplane; metres, +Z nose / +Y up, animated-part pivots and attachment sockets.',
f'{tri:,} triangles; GLB {os.path.getsize(GLB)/1048576:.2f} MiB; six rigid mesh nodes.',
'Seven game materials: Paint, Metal, Rubber, Weapons, Interior, Glass (BLEND), Lamps; matte recolourable Paint.',
'Five shared Cycles-baked tile sets: base colour and tangent normal 1024²; packed ORM 512²; photographic surface at 0.8 repeats/metre.',
'Poly Haven ID: blue_metal_plate — https://polyhaven.com/a/blue_metal_plate — CC0 https://polyhaven.com/license; diff, nor_gl, arm inputs.',
'Differences: reference-inspired reconstruction; simplified wiring and cockpit instrumentation; no insignia or text; no flight animation clips.'
]
open(os.path.join(ROOT,'NOTES.md'),'w').write('\n'.join(notes)+'\n')
print('BUILD_COMPLETE',flush=True)
