"""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=24;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=-.6
# 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=(.60,.61,.60,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=.085 if name=='Paint' else .23;l.new(norm.outputs[0],nm.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.10;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=(baked['Metal'][1] if name in ['Rubber','Weapons','Interior'] else 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
    v=[]
    for y,w,bot,top in sections:
        for i in range(sides):
            a=2*pi*i/sides;x=math.copysign(abs(cos(a))**.62,cos(a))*w;z=(bot+top)/2+math.copysign(abs(sin(a))**.72,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.
loft('Armoured lower airframe',[(-4.62,.23,1.04,1.65),(-4.30,.40,1.0,1.77),(-3.5,.60,.99,1.91),(-2.3,.70,.93,1.94),(-.8,.73,.90,2.04),(.8,.72,.91,2.22),(1.8,.61,1.00,2.18),(2.7,.45,1.08,1.87),(3.05,.37,1.12,1.69)],sides=24,bev=.04)
loft('Upper gearbox and engine deck',[(-.95,.55,1.7,2.70),(-.55,.57,1.8,2.73),(.8,.58,1.7,2.68),(1.8,.5,1.5,2.45),(2.7,.32,1.45,1.85)],sides=20,bev=.045)
loft('Tapered tail boom',[(1.7,.48,1.02,1.98),(2.6,.43,1.02,1.78),(4,.31,1.02,1.59),(6,.23,1.03,1.49),(8.25,.19,.99,1.39),(8.8,.17,1.01,1.43),(9.3,.07,1.09,1.27)],sides=24,bev=.025)
# Cockpit open volume: planar clear panels with structural frame; front gunner lower than pilot.
# Canopy section bottom edge and roof ridge pairs.
stations=[(-3.87,.43,1.66,1.85),(-2.96,.56,1.76,2.36),(-1.86,.58,1.85,2.67),(-.87,.56,1.95,2.69)]
for s in [-1,1]:
    # front and rear side windows; door hinges at upper rails
    for i in range(3):
        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 else 'Door_R') if i>0 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 i>0:
            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(3):
    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',[(-.56,-.84,1.67),(.56,-.84,1.67),(.43,-.84,2.69),(-.43,-.84,2.69)],.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)
# Lower nose optical sensor ball and housing
sphere('Nose sensor mount',(0,-4.45,1.51),(.32,.30,.32),'Paint')
rod('Electro optical lens surround',(0,-4.63,1.51),(0,-4.80,1.51),.225,'Rubber',40)
rod('Electro optical lens',(0,-4.798,1.51),(0,-4.805,1.51),.182,'Glass',40)
box('Nose upper sensor',(.02,-4.36,1.83),(.28,.31,.19),'Weapons',.04)
# Twin turboshaft housings: sculpted shoulders and cylindrical intake lips.
for s in [-1,1]:
    o=loft('Turboshaft cowling',[(-.15,.31,1.88,2.60),(.1,.4,1.82,2.66),(1.25,.4,1.82,2.6),(1.9,.33,1.86,2.49)],sides=20,bev=.045);o.location.x=s*.80
    rod('Intake outer lip',(s*.84,-.12,2.24),(s*.84,-.38,2.24),.267,'Paint',48)
    rod('Intake shadow',(s*.84,-.382,2.24),(s*.84,-.389,2.24),.212,'Rubber',40)
    torus('Intake rim',(s*.84,-.39,2.24),.216,.019,'Metal','Y')
    sphere('Intake centre spinner',(s*.84,-.405,2.24),(.095,.055,.095),'Weapons')
    for i in range(12):
        a=i*pi/6
        rod('Compressor vane',(s*.84+.098*cos(a),-.395,2.24+.098*sin(a)),(s*.84+.193*cos(a+.18),-.397,2.24+.193*sin(a+.18)),.009,'Metal',8)
    # A rectangular infrared-suppressed exhaust with recessed fins
    box('Exhaust shroud',(s*.81,2.0,2.22),(.68,.36,.62),'Metal',.06)
    box('Exhaust cavity',(s*.81,2.187,2.22),(.51,.015,.48),'Rubber',.035)
    for xx in [-.18,-.06,.06,.18]:box('Exhaust cooling baffle',(s*.81+xx,2.20,2.22),(.026,.035,.44),'Weapons',.008)
    # Engine straps and removable service cover edges
    for yy in [.32,1.25]:
        tube('Engine cover seam',[(s*.47,yy,2.50),(s*.65,yy,2.635),(s*1.06,yy,2.62),(s*1.18,yy,2.39),(s*1.18,yy,2.05)],.008,'Rubber')
    for yy in [.25,1.2]:box('Cowling latch',(s*1.205,yy,2.27),(.026,.10,.045),'Metal',.008)
# Wing airfoil-like tapered solids
for s in [-1,1]:
    v=[(s*.61,-.31,1.73),(s*.61,1.08,1.66),(s*2.40,.87,1.57),(s*2.42,.16,1.65),(s*.61,-.31,1.62),(s*.61,1.08,1.56),(s*2.40,.87,1.49),(s*2.42,.16,1.56)]
    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)
    tube('Wing leading edge trim',[(s*.73,-.27,1.68),(s*2.4,.16,1.61)],.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.43,.58,1.60),(.035,.075,.035),'Lamps',20,10)
# Fin rising from tail boom, swept profile
v=[(-.115,7.93,1.20),(-.095,8.68,3.44),(-.065,9.28,3.47),(-.105,8.83,1.20),(.115,7.93,1.20),(.095,8.68,3.44),(.065,9.28,3.47),(.105,8.83,1.20)]
mesh('Swept vertical tail',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=.055)
for s in [-1,1]:
    mesh('Horizontal tailplane',[(s*.1,8.08,1.43),(s*1.05,8.38,1.44),(s*1.10,8.81,1.40),(s*.1,8.76,1.38),(s*.1,8.08,1.37),(s*1.05,8.38,1.38),(s*1.1,8.81,1.34),(s*.1,8.76,1.32)],[(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=.025)
# Landing gear. Two sprung main legs and small tail wheel contact ground exactly.
for s in [-1,1]:
    rod('Main oleo upper',(s*.57,-1.03,1.03),(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*.55,-1.95,1.05),(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*.61,-1.06,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.10),(0,9.08,.35),.028,'Weapons',16)
rod('Tailwheel drag leg',(0,8.80,1.15),(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('Mast mounted sight',(0,0,3.62),(.50,.275,.18),'Weapons',48,24)
rod('Sight optical window',(-.25,-.268,3.62),(-.25,-.289,3.62),.103,'Glass',32)
rod('Sight optical window',(.19,-.268,3.62),(.19,-.289,3.62),.067,'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,3.07)
rod('Tail rotor shaft',(-.12,8.8,3.07),(.35,8.8,3.07),.074,'Metal',24)
sphere('Tail rotor central hub',hub,(.11,.13,.13),'Metal',24,12)
for i in range(4):
    a=.50+i*pi/2;blade('Twisted tail rotor airfoil',hub,a,.10,.72,.16,tail=True)
    rod('Tail pitch link',(.29,8.8,3.07),(.29,8.8+.22*cos(a),3.07+.22*sin(a)),.021,'Metal',12)
G='Fuselage'
# Service panels, flush seams, fasteners, vents and fittings; deliberately zoned.
def skin_x(y,z):
    sections=[(-4.62,.23,1.04,1.65),(-4.30,.40,1.,1.77),(-3.5,.60,.99,1.91),(-2.3,.70,.93,1.94),(-.8,.73,.90,2.04),(.8,.72,.91,2.22),(1.8,.61,1.,2.18),(2.7,.45,1.08,1.87)]
    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))**.72,sin(ang))*(top-bot)/2,abs(cos(ang))**.62*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.9,1.40,.90,.36,.617),(-1.75,1.40,.78,.44,.706),(-.65,1.40,.61,.52,.734),(.77,1.36,.57,.62,.725),(1.64,1.42,.43,.43,.629)]:
        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,.005,'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)
    # Lower sill bolts spanning whole cockpit
    for i in range(34):
        yy=-3.45+i*.132;xx=.65 if yy< -2.3 else .738
        rod('Fuselage sill rivet',(s*xx,yy,1.07),(s*(xx+.011),yy,1.07),.010,'Metal',8)
    # tail boom panel frames at physical intervals
    for yy,w,bot,top in [(3.2,.39,1.04,1.72),(4.55,.29,1.02,1.56),(6.10,.23,1.03,1.49),(7.70,.197,1.0,1.4)]:
        pts=[]
        for k in range(25):
            a=2*pi*k/24;pts.append((w*cos(a),yy,(bot+top)/2+(top-bot)/2*sin(a)))
        tube('Tail circumferential seam',pts,.006,'Rubber')
    for i in range(10):
        yy=2.30+i*.085
        box('Cooling louvre',(s*.44,yy,1.59),(.028,.043,.14),'Weapons',.008)
    rod('Crew boarding step',(s*.72,-.98,1.02),(s*1.03,-.98,.85),.024,'Metal',12)
    rod('Crew foot rail',(s*1.03,-1.30,.85),(s*1.03,-.78,.85),.022,'Metal',12)
    rod('Grab rail',(s*.63,-.83,2.13),(s*.63,-.38,2.13),.017,'Metal',12)
    # sensors / cable cutters adjacent to cockpit
    box('Radar warning receiver',(s*.78,.3,1.93),(.12,.19,.12),'Weapons',.02)
    rod('Wing antenna',(s*2.1,.53,1.72),(s*2.56,.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.65),(8.80,3.48)]:
    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.01),(.15,.39,.16),'Weapons',.02)
panel('Dorsal wire cutter',[(0,-.45,2.73),(0,-.75,2.98),(0,-.63,2.70)],.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':(.43,-1.86,2.67),'Door_R':(-.43,-1.86,2.67),'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.43,.58,1.6),'Light_Nav_R':(-2.43,.58,1.6),'Light_Beacon':(0,1.05,2.71),'Exhaust_L':(.81,2.21,2.22),'Exhaust_R':(-.81,2.21,2.22)}
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':[0,0,1]},'Door_R':{'hinge_axis':[0,0,1]}},'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,8);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=18.8;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=[
'Tiger-style tandem-seat attack helicopter; 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 (final); metal_plate (discarded early study) — 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)
