"""Mi-24-inspired game asset. Run with Blender 5.2 --background --python this_file.
All geometry, tiled Cycles bakes, GLB, metadata and presentation renders are rebuilt.
Author coordinates X=left, -Y=nose, Z=up; glTF export converts to X/Y/Z game axes.
"""
import bpy, math, os, sys, json, struct, urllib.request, subprocess, random
import numpy as np
from mathutils import Vector
from math import sin, cos, pi, sqrt
from pathlib import Path
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(24)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials): bpy.data.materials.remove(m)
sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=12
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.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG'; sc.render.film_transparent=False
sc.view_settings.view_transform='AgX'
sc.render.bake.margin=4
GROUP='Fuselage'; groups={}; mats={}

def download(id,map):
    p=TEX/f'{id}_{map}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
        subprocess.run(['curl','--fail','-L','--retry','2','-A','Mozilla/5.0',url,'-o',str(p)],check=True)
    return str(p)

def node(nt,t): return nt.nodes.new(t)
def link(nt,a,b): nt.links.new(a,b)
def material_bake(name,color,rough,metal,id='blue_metal_plate',normal_strength=.15):
    # Shared 2 m tile: scan luminance + elongated grime, baked on a UV fixture.
    source=bpy.data.materials.new(name+'_source'); source.use_nodes=True
    nt=source.node_tree; nt.nodes.clear(); out=node(nt,'ShaderNodeOutputMaterial'); bs=node(nt,'ShaderNodeBsdfPrincipled')
    link(nt,bs.outputs['BSDF'],out.inputs['Surface'])
    uv=node(nt,'ShaderNodeTexCoord')
    diff=node(nt,'ShaderNodeTexImage'); diff.image=bpy.data.images.load(download(id,'diff'),check_existing=True)
    link(nt,uv.outputs['UV'],diff.inputs['Vector'])
    bw=node(nt,'ShaderNodeRGBToBW'); link(nt,diff.outputs['Color'],bw.inputs[0])
    ramp=node(nt,'ShaderNodeValToRGB'); link(nt,bw.outputs[0],ramp.inputs[0])
    a=.33 if name=='Paint' else .54; b=.98 if name=='Paint' else 1.18
    ramp.color_ramp.elements[0].position=.025; ramp.color_ramp.elements[0].color=tuple(c*a for c in color)+(1,)
    ramp.color_ramp.elements[1].position=.105; ramp.color_ramp.elements[1].color=tuple(min(1,c*b) for c in color)+(1,)
    vec=node(nt,'ShaderNodeVectorMath'); vec.operation='MULTIPLY'; vec.inputs[1].default_value=(32,2,1); link(nt,uv.outputs['UV'],vec.inputs[0])
    noise=node(nt,'ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=4; noise.inputs['Detail'].default_value=3
    link(nt,vec.outputs['Vector'],noise.inputs['Vector'])
    gr=node(nt,'ShaderNodeValToRGB'); link(nt,noise.outputs['Fac'],gr.inputs[0]); gr.color_ramp.elements[0].color=(.68,.68,.68,1); gr.color_ramp.elements[1].color=(1,1,1,1)
    mix=node(nt,'ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=.32; link(nt,ramp.outputs[0],mix.inputs[1]); link(nt,gr.outputs[0],mix.inputs[2]); link(nt,mix.outputs[0],bs.inputs['Base Color'])
    nor=node(nt,'ShaderNodeTexImage'); nor.image=bpy.data.images.load(download(id,'nor_gl'),check_existing=True); nor.image.colorspace_settings.name='Non-Color'; link(nt,uv.outputs['UV'],nor.inputs['Vector'])
    nm=node(nt,'ShaderNodeNormalMap'); nm.inputs['Strength'].default_value=normal_strength; link(nt,nor.outputs[0],nm.inputs['Color']); link(nt,nm.outputs[0],bs.inputs['Normal'])
    arm=node(nt,'ShaderNodeTexImage'); arm.image=bpy.data.images.load(download(id,'arm'),check_existing=True); arm.image.colorspace_settings.name='Non-Color'; link(nt,uv.outputs['UV'],arm.inputs['Vector'])
    sep=node(nt,'ShaderNodeSeparateColor'); link(nt,arm.outputs[0],sep.inputs[0])
    rr=node(nt,'ShaderNodeMath'); rr.operation='MULTIPLY_ADD'; rr.inputs[1].default_value=.18; rr.inputs[2].default_value=rough-.09; link(nt,sep.outputs[1],rr.inputs[0]); link(nt,rr.outputs[0],bs.inputs['Roughness']); bs.inputs['Metallic'].default_value=metal
    bpy.ops.mesh.primitive_plane_add(size=2); plane=bpy.context.object; plane.name='BakeFixture'; plane.data.materials.append(source)
    target=node(nt,'ShaderNodeTexImage'); emit=node(nt,'ShaderNodeEmission')
    images={}
    for kind,res in [('BaseColor',1024),('Normal',1024),('ORM',512)]:
        im=bpy.data.images.new(name+'_'+kind,res,res,alpha=False); target.image=im; nt.nodes.active=target
        if kind!='BaseColor': im.colorspace_settings.name='Non-Color'
        if kind=='Normal':
            link(nt,bs.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='NORMAL')
        else:
            if kind=='BaseColor': link(nt,mix.outputs[0],emit.inputs['Color'])
            else:
                comb=node(nt,'ShaderNodeCombineColor'); comb.inputs[0].default_value=1; comb.inputs[2].default_value=metal; link(nt,rr.outputs[0],comb.inputs[1]); link(nt,comb.outputs[0],emit.inputs['Color'])
            link(nt,emit.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='EMIT')
        im.filepath_raw=str(TEX/(name+'_'+kind+'.png')); im.file_format='PNG'; im.save(); images[kind]=im
    bpy.data.objects.remove(plane,do_unlink=True)
    mat=bpy.data.materials.new(name); mat.use_nodes=True; nt=mat.node_tree; p=nt.nodes.get('Principled BSDF')
    for kind in images:
        tx=node(nt,'ShaderNodeTexImage'); tx.image=images[kind]; tx.label='Cycles baked '+kind
        if kind=='BaseColor':
            if name=='Paint':
                mult=node(nt,'ShaderNodeMixRGB'); mult.name='PaintTint'; mult.blend_type='MULTIPLY'; mult.inputs[0].default_value=1; mult.inputs[2].default_value=(.20,.22,.14,1); link(nt,tx.outputs[0],mult.inputs[1]); link(nt,mult.outputs[0],p.inputs['Base Color'])
            else: link(nt,tx.outputs[0],p.inputs['Base Color'])
        elif kind=='Normal':
            n=node(nt,'ShaderNodeNormalMap'); link(nt,tx.outputs[0],n.inputs['Color']); link(nt,n.outputs[0],p.inputs['Normal'])
        else:
            s=node(nt,'ShaderNodeSeparateColor'); link(nt,tx.outputs[0],s.inputs[0]); link(nt,s.outputs[1],p.inputs['Roughness']); link(nt,s.outputs[2],p.inputs['Metallic'])
    p.inputs['Coat Weight'].default_value=0
    if name=='Glass':
        p.inputs['Alpha'].default_value=.52; p.inputs['Transmission Weight'].default_value=.08; p.inputs['IOR'].default_value=1.46
        mat.surface_render_method='BLENDED'; mat.diffuse_color=(.13,.20,.24,.30)
    if name=='Lamps': p.inputs['Emission Color'].default_value=(.6,.07,.025,1); p.inputs['Emission Strength'].default_value=1
    mats[name]=mat; print('BAKED',name,flush=True)
    return mat

# Each exported surface is a baked, portable shared texture set.
for args in [('Paint',(1,1,1),.64,.04,'blue_metal_plate',.30),('Metal',(.16,.18,.19),.49,.68,'blue_metal_plate',.24),('Rubber',(.028,.032,.034),.83,0,'rubber_tiles',.18),('Interior',(.075,.105,.10),.72,.04,'blue_metal_plate',.09),('Weapons',(.13,.155,.10),.62,.30,'blue_metal_plate',.14),('Glass',(.12,.19,.23),.16,.12,'blue_metal_plate',.008),('Lamps',(.55,.065,.025),.24,.1,'blue_metal_plate',.02)]: material_bake(*args)
if '--preflight' in sys.argv:
    bpy.ops.mesh.primitive_uv_sphere_add(); ob=bpy.context.object; ob.data.materials.append(mats['Paint'])
    bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB',use_selection=True)
    print('PREFLIGHT_OK'); sys.exit(0)

def register(o,mat):
    o.data.materials.append(mats[mat]); groups.setdefault(GROUP,[]).append(o)
    return o

def mesh(name,verts,faces,mat='Paint',uvs=None,smooth=True):
    me=bpy.data.meshes.new(name); me.from_pydata(verts,[],faces); me.update(); o=bpy.data.objects.new(name,me); bpy.context.collection.objects.link(o); register(o,mat)
    uv=me.uv_layers.new(name='UVMap')
    for poly in me.polygons:
        poly.use_smooth=smooth
        for li in poly.loop_indices:
            vi=me.loops[li].vertex_index; co=me.vertices[vi].co
            uv.data[li].uv=uvs[vi] if uvs else (co.y/2,co.z/2)
    return o

def apply(o):
    bpy.context.view_layer.objects.active=o; o.select_set(True)
    for mod in list(o.modifiers): bpy.ops.object.modifier_apply(modifier=mod.name)
    o.select_set(False)

def bevel(o,w=.025,seg=2):
    m=o.modifiers.new('Machined edge','BEVEL'); m.width=w; m.segments=seg; apply(o); return o

def box(name,loc,scale,mat='Paint',bev=.025):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.name=name; o.scale=scale; bpy.ops.object.transform_apply(location=False,rotation=False,scale=True); register(o,mat)
    if bev: bevel(o,bev,3)
    return o

def ell(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); register(o,mat)
    for p in o.data.polygons:p.use_smooth=True
    return o

def cyl(name,a,b,r,mat='Metal',r2=None,n=24):
    a,b=Vector(a),Vector(b); d=b-a
    bpy.ops.mesh.primitive_cone_add(vertices=n,radius1=r,radius2=r if r2 is None else r2,depth=d.length,location=(a+b)/2)
    o=bpy.context.object; o.name=name; o.rotation_euler=d.to_track_quat('Z','Y').to_euler(); register(o,mat)
    for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
    bevel(o,min(.009,r*.12),2)
    return o

def tube(name,pts,r=.014,mat='Metal',closed=False,n=8):
    verts=[]; faces=[]
    pts=[Vector(p) for p in pts]
    for i,p in enumerate(pts):
        tangent=(pts[(i+1)%len(pts)]-pts[i-1] if closed else pts[min(i+1,len(pts)-1)]-pts[max(i-1,0)]).normalized()
        ref=Vector((0,0,1)) if abs(tangent.z)<.95 else Vector((1,0,0)); u=tangent.cross(ref).normalized(); v=tangent.cross(u)
        for k in range(n): verts.append(p+r*(u*cos(2*pi*k/n)+v*sin(2*pi*k/n)))
    for i in range(len(pts) if closed else len(pts)-1):
        for k in range(n): faces.append((i*n+k,i*n+(k+1)%n,((i+1)%len(pts))*n+(k+1)%n,((i+1)%len(pts))*n+k))
    if not closed: faces.extend([tuple(range(n-1,-1,-1)),tuple((len(pts)-1)*n+k for k in range(n))])
    return mesh(name,verts,faces,mat)

def ring(name,center,r,minor=.04,axis='Y',mat='Metal',n=48):
    c=Vector(center); pts=[]
    for j in range(n):
        a=2*pi*j/n; v=(r*cos(a),0,r*sin(a)) if axis=='Y' else ((0,r*cos(a),r*sin(a)) if axis=='X' else (r*cos(a),r*sin(a),0)); pts.append(c+Vector(v))
    return tube(name,pts,minor,mat,True,8)

def loft(name,sections,mat='Paint',n=64,steps=5,power=1):
    # Catmull-Rom spline gives a continuous, authored pressure hull.
    ss=[]
    for i in range(len(sections)-1):
        p0=np.array(sections[max(i-1,0)]); p1=np.array(sections[i]); p2=np.array(sections[i+1]); p3=np.array(sections[min(i+2,len(sections)-1)])
        for j in range(steps):
            t=j/steps; ss.append(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t))
    ss.append(sections[-1]); verts=[]; uvs=[]
    for y,w,z,h in ss:
        for j in range(n+1):
            a=2*pi*j/n; verts.append((w*math.copysign(abs(cos(a))**power,cos(a)),y,z+h*math.copysign(abs(sin(a))**power,sin(a)))); uvs.append((y/2,j/n*3))
    faces=[]
    for i in range(len(ss)-1):
        for j in range(n):faces.append((i*(n+1)+j,i*(n+1)+j+1,(i+1)*(n+1)+j+1,(i+1)*(n+1)+j))
    faces += [tuple(range(n-1,-1,-1)),tuple((len(ss)-1)*(n+1)+j for j in range(n))]
    o=mesh(name,verts,faces,mat,uvs)
    return o

def rivet(p,mat='Metal',r=.017):return ell('Flush fastener',p,(r,r,r*.6),mat,8,6)

# Lower forward hull: the upper cockpit is genuinely open behind the glazing.
loft('Armoured nose and keel',[(-6.5,.09,1.72,.13),(-6.3,.46,1.75,.36),(-5.9,.68,1.85,.50),(-5.2,.82,1.94,.59),(-4.45,.85,2.05,.64),(-3.7,.88,2.13,.82),(-2.9,1.03,2.38,1.10),(-2.3,1.10,2.48,1.21)],n=64,steps=5,power=.80)
# Main cabin with separately skinned, operable doors and real glass window holes.
N=64; ys=np.linspace(-2.3,3.0,54); verts=[]; faces=[]
def cabin_point(y,a,offset=0):
    w=1.10-(max(0,y-1.4)*.105); h=1.21-(max(0,y-1.5)*.045)
    return ((w+offset)*math.copysign(abs(cos(a))**.78,cos(a)),float(y),2.48+(h+offset)*math.copysign(abs(sin(a))**.82,sin(a)))
# Individual quad patches assigned to fixed hull / side doors, glass cutouts integrated.
patches={}
for i in range(len(ys)-1):
    for j in range(N):
        a=2*pi*j/N; b=2*pi*(j+1)/N; yc=(ys[i]+ys[i+1])/2; ac=(a+b)/2
        side=1 if cos(ac)>0 else -1; az=math.asin(sin(ac))
        door=(-1.72<yc<.40 and -.72<az<.70)
        group=('Door_L' if side==1 else 'Door_R') if door else 'Fuselage'
        window=(.18<az<.52 and any(abs(yc-q)<.225 for q in [-1.22,-.48,.75,1.56]))
        mat='Glass' if window else 'Paint'
        key=(group,mat); vs,fs,uv=patches.setdefault(key,([],[],[])); k=len(vs)
        for y,t in [(ys[i],a),(ys[i+1],a),(ys[i+1],b),(ys[i],b)]:vs.append(cabin_point(y,t));uv.append((y/2,t*1.21/2))
        fs.append((k,k+1,k+2,k+3))
for (g,m),(vs,fs,uv) in patches.items():
    GROUP=g; o=mesh('Cabin skin '+g+' '+m,vs,fs,m,uv)
    # Weld quad patch boundaries before creating real sheet thickness.
    import bmesh
    bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=.0001);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
    so=o.modifiers.new('Sheet thickness','SOLIDIFY');so.thickness=.022;apply(o)
GROUP='Fuselage'
loft('Aft fairing',[(2.65,.96,2.5,1.15),(3.3,.88,2.62,1.03),(4.1,.69,2.81,.78),(5.3,.55,2.95,.62),(7.1,.38,3.13,.43),(9.3,.23,3.32,.29),(10,.18,3.45,.25)],n=56,steps=5,power=.80)
# Cabin door outlines, window compression seals, hinges, rails and grab handles.
for side in [-1,1]:
    def cp(y,z,off=.028):
        a=math.asin(max(-.99,min(.99,math.copysign(abs((z-2.48)/1.21)**(1/.82),z-2.48))))
        return cabin_point(y,a if side==1 else pi-a,off)
    GROUP='Door_L' if side==1 else 'Door_R'
    outline=[cp(-1.76,1.61),cp(.40,1.61),cp(.40,3.30),cp(-1.76,3.30)]
    tube('Door perimeter gasket',outline,.018,'Rubber',True)
    for z in [1.88,2.89]:
        p=cp(.40,z,.05);cyl('Door hinge',Vector(p)+Vector((0,0,-.09)),Vector(p)+Vector((0,0,.09)),.044,'Metal')
    p=cp(-1.41,2.25,.07);tube('Recessed door pull',[Vector(p)+Vector((0,-.12,0)),Vector(p)+Vector((side*.07,-.1,.04)),Vector(p)+Vector((side*.07,.1,.04)),Vector(p)+Vector((0,.12,0))],.024,'Metal')
    GROUP='Fuselage'
    for yy in [-1.22,-.48,.75,1.56]:
        GROUP=('Door_L' if side==1 else 'Door_R') if yy<0 else 'Fuselage'
        pts=[cp(yy-.255,2.79),cp(yy+.255,2.79),cp(yy+.255,3.17),cp(yy-.255,3.17)]
        tube('Window rubber reveal',pts,.032,'Rubber',True);tube('Window metal trim',[Vector(p)+Vector((side*.012,0,0)) for p in pts],.012,'Metal',True)
    GROUP='Fuselage'
    for yy in [-1.76,.4,2.15]:
        tube('Riveted cabin joint',[cp(yy,z,.018) for z in np.linspace(1.55,3.4,20)],.007,'Metal')
        for z in np.linspace(1.63,3.3,15):rivet(cp(yy+.055,z,.024),r=.013)
    tube('Boarding step',[(side*.9,-1.4,1.4),(side*1.38,-1.4,1.14),(side*1.38,-.8,1.14),(side*.95,-.8,1.4)],.032,'Metal')
# Internal floor and eight troop seats visible when the doors open.
box('Troop compartment floor',(0,.2,1.40),(1.85,4.9,.10),'Interior')
for side in [-1,1]:
    for k in range(4):
        y=-1.2+k*.85
        box('Troop seat cushion',(side*.65,y,1.83),(.49,.65,.16),'Interior',.055)
        box('Troop back pad',(side*.91,y,2.15),(.13,.66,.58),'Interior',.05)
        cyl('Seat leg',(side*.67,y,1.4),(side*.67,y,1.77),.033,'Metal')
# Twin turbine housing with intake lips, exhaust throats and fastened access panels.
for side in [-1,1]:
    o=loft('Turbine nacelle',[(-2.8,.28,4.06,.30),(-2.57,.48,4.10,.48),(-1.9,.50,4.12,.51),(-.8,.48,4.16,.51),(.35,.43,4.13,.46),(1.4,.29,3.92,.30),(1.8,.13,3.79,.16)],n=40,steps=4)
    o.location.x=side*.53
    ell('Intake centre bullet',(side*.53,-2.83,4.07),(.32,.22,.34),'Paint')
    ring('Intake rolled rim',(side*.53,-2.73,4.09),.365,.043,'Y','Metal')
    cyl('Intake shadow throat',(side*.53,-2.70,4.09),(side*.53,-2.77,4.09),.35,'Rubber',n=40)
    # Large side exhaust: dark recessed throat, metallic double-wall lip.
    cyl('Exhaust outer duct',(side*.70,-.40,4.05),(side*1.25,-.25,4.01),.39,'Metal',r2=.43,n=48)
    cyl('Exhaust dark interior',(side*1.255,-.25,4.01),(side*1.267,-.25,4.01),.337,'Rubber',n=48)
    ring('Exhaust lip',(side*1.28,-.25,4.01),.37,.054,'X','Metal')
    ring('Exhaust inner liner',(side*1.286,-.25,4.01),.326,.018,'X','Metal')
    for yy in [-2.1,-1.45,.55]:
        pts=[(side*.53+.49*cos(a),yy,4.12+.5*sin(a)) for a in np.linspace(0,pi,30)]
        tube('Nacelle panel line',pts,.009,'Metal')
        for p in pts[::4]:rivet(p,r=.016)
    box('Engine ventilation housing',(side*.91,2.4,3.14),(.15,.85,.31),'Paint',.075)
    for k in range(8):box('Cooling louvre',(side*1.00,2.1+k*.08,3.15),(.035,.033,.20),'Metal',.008)
loft('Gearbox shoulder',[(-.8,.51,4.56,.15),(-.3,.58,4.62,.20),(.6,.45,4.57,.17),(1.05,.2,4.48,.1)],n=40,steps=4)
# Stepped tandem bubble canopies, real thin glass and external structural bows.
def canopy(name,cy,cz,rx,ry,rz):
    verts=[];faces=[];uv=[]; rows=18; cols=48
    # Top half ellipsoid, all quads except pole fan; no coincident pole vertices.
    for i in range(rows):
        t=(pi/2)*(i/rows)
        for j in range(cols+1):
            a=2*pi*j/cols;verts.append((rx*cos(t)*cos(a),cy+ry*cos(t)*sin(a),cz+rz*sin(t)));uv.append((j/cols,i/rows))
    for i in range(rows-1):
        for j in range(cols):faces.append((i*(cols+1)+j,i*(cols+1)+j+1,(i+1)*(cols+1)+j+1,(i+1)*(cols+1)+j))
    top=len(verts);verts.append((0,cy,cz+rz));uv.append((.5,1))
    for j in range(cols):faces.append(((rows-1)*(cols+1)+j,(rows-1)*(cols+1)+j+1,top))
    mesh(name,verts,faces,'Glass',uv)
    # Armoured lower cockpit shell continues from the glass sill into the keel.
    lower=[(x,y,cz-(z-cz)*.66) for x,y,z in verts]
    mesh(name+' lower armoured tub',lower,[tuple(reversed(f)) for f in faces],'Paint',[(y/2,z/2) for x,y,z in lower])
    # Riveted rim strip and exterior windshield wipers.
    for a in np.linspace(0,2*pi,25)[:-1]:
        rivet((rx*cos(a)*1.004,cy+ry*sin(a)*1.004,cz+.022),'Metal',.013)
    for side in [-1,1]:
        tube('Windshield wiper',[(side*.19,cy-ry*.86,cz+.18),(side*.31,cy-ry*.72,cz+rz*.60),(side*.35,cy-ry*.66,cz+rz*.67)],.012,'Rubber')
    tube('Canopy sill',[(rx*cos(a),cy+ry*sin(a),cz) for a in np.linspace(0,2*pi,65)[:-1]],.055,'Paint',True)
    # Transverse bows at distinct fore/aft frame stations follow the ellipsoid exactly.
    for yoff in [-.64*ry,.40*ry]:
        f=sqrt(1-(yoff/ry)**2)
        pts=[(rx*f*cos(t),cy+yoff,cz+rz*f*sin(t)) for t in np.linspace(0,pi,35)]
        tube('Canopy armored bow',pts,.047,'Paint');tube('Glazing black seal',[Vector(p)+Vector((0,-.018,-.015)) for p in pts],.017,'Rubber')
    pts=[(0,cy+ry*cos(t),cz+rz*sin(t)) for t in np.linspace(.13,pi-.13,40)]
    tube('Canopy centre divider',pts,.027,'Metal')
    for side in [-1,1]:
        tube('Canopy release rail',[(side*rx*.94,cy-.35,cz+.07),(side*rx*.98,cy+.35,cz+.07)],.022,'Metal')
    # Visible seat, headrest, harness, side consoles and instrument binnacle.
    box('Cockpit seat cushion',(0,cy+.13,cz-.14),(.55,.55,.16),'Interior',.075)
    back=box('Armoured seat back',(0,cy+.47,cz+.24),(.57,.17,.71),'Interior',.07);back.rotation_euler.x=.12
    box('Head rest',(0,cy+.45,cz+.66),(.32,.19,.19),'Rubber',.06)
    for side in [-1,1]:
        tube('Seat harness',[(side*.17,cy+.35,cz+.58),(side*.16,cy+.28,cz+.20),(side*.23,cy+.01,cz-.01)],.035,'Rubber')
        box('Side console',(side*.47,cy-.10,cz-.06),(.16,.67,.19),'Interior')
    panel=box('Instrument coaming',(0,cy-.52,cz+.13),(.97,.20,.43),'Interior',.07);panel.rotation_euler.x=-.24
    for xx in [-.30,-.1,.1,.30]:
        for zz in [cz+.04,cz+.22]:
            cyl('Instrument dial',(xx,cy-.401,zz),(xx,cy-.385,zz),.066,'Rubber',n=16)
            ring('Instrument bezel',(xx,cy-.382,zz),.063,.008,'Y','Metal',24)
            tube('Gauge needle',[(xx-.025,cy-.374,zz-.014),(xx+.022,cy-.374,zz+.025)],.003,'Metal',n=4)
    tube('Cyclic stick',[(0,cy-.15,cz-.19),(0,cy-.2,cz+.18),(.02,cy-.30,cz+.25)],.022,'Metal')
    ell('Control grip',(.02,cy-.30,cz+.25),(.038,.08,.038),'Rubber',16,8)
canopy('Gunner bubble',-5.20,2.40,.75,1.15,.83)
canopy('Pilot bubble',-3.53,3.04,.80,1.03,.89)
# Forward optical housing and a chin four-barrel turret.
ell('Nose sensor blister',(0,-6.10,1.80),(.40,.40,.25),'Paint')
for side in [-1,1]:ell('Nose side sensor',(side*.52,-5.93,1.88),(.20,.24,.14),'Metal')
GROUP='Gun';ell('Chin turret',(0,-5.69,1.26),(.43,.43,.32),'Weapons')
cyl('Gun mantlet',(0,-5.7,1.27),(0,-6.18,1.27),.17,'Metal')
for a in [pi/4,3*pi/4,5*pi/4,7*pi/4]:
    x=.065*cos(a);z=1.27+.065*sin(a)
    cyl('Rotary gun barrel',(x,-6.02,z),(x,-6.91,z),.028,'Metal',n=12)
    cyl('Muzzle bore',(x,-6.914,z),(x,-6.919,z),.019,'Rubber',n=12)
ring('Barrel brace',(0,-6.62,1.27),.086,.022,'Y','Metal',24)
GROUP='Fuselage'
# Pitot boom beside gunner windshield.
tube('Pitot boom',[(.66,-5.67,2.47),(.72,-6.92,2.50),(.72,-7.13,2.50)],.019,'Metal')
# Stub wings are thick tapered swept airfoils, with two pod stations and outer rails.
def wing(side):
    verts=[];faces=[];uv=[]; n=28
    sections=[(side*.90,-.00,3.04,1.80,.20),(side*1.40,.14,2.94,1.65,.17),(side*2.35,.45,2.61,1.24,.12),(side*3.10,.65,2.37,.92,.085)]
    for x,y,z,ch,t in sections:
        for j in range(n):
            a=2*pi*j/n; u=(1-cos(a))/2
            verts.append((x,y+(u-.45)*ch,z+sin(a)*t));uv.append((x/2,u))
    for k in range(len(sections)-1):
        for j in range(n):faces.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
    faces.extend([tuple(range(n-1,-1,-1)),tuple((len(sections)-1)*n+j for j in range(n))]);mesh('Swept stub wing',verts,faces,'Paint',uv)
    for xx in [1.35,2.0,2.75]:
        x=side*xx; z=3.22-.26*xx;y=.18*xx
        tube('Wing panel seam',[(x,y-.54,z),(x,y+.58,z)],.009,'Metal')
    for i,xx in enumerate([1.70,2.47]):
        y=.32 if i==0 else .48; z=2.54 if i==0 else 2.25;x=side*xx
        box('Weapon pylon',(x,y,z),(.14,.59,.53),'Weapons',.04)
        podz=z-.58
        cyl('Rocket pod shell',(x,y-.83,podz),(x,y+.74,podz),.32,'Metal',n=48)
        for py in [y-.68,y+.55]:ring('Pod reinforcing band',(x,py,podz),.321,.020,'Y','Weapons')
        cyl('Rocket front plate',(x,y-.84,podz),(x,y-.87,podz),.302,'Weapons',n=48)
        # Nineteen dark recessed tubes with metal rims and inset projectile tips.
        holes=[(0,0)]+[(.105*cos(a),.105*sin(a)) for a in np.linspace(0,2*pi,7)[:-1]]+[(.215*cos(a),.215*sin(a)) for a in np.linspace(0,2*pi,13)[:-1]]
        for dx,dz in holes:
            cyl('Rocket tube mouth',(x+dx,y-.880,podz+dz),(x+dx,y-.893,podz+dz),.040,'Rubber',n=12)
            ring('Rocket tube lip',(x+dx,y-.896,podz+dz),.041,.008,'Y','Metal',12)
        cyl('Pod aft cap',(x,y+.74,podz),(x,y+.88,podz),.32,'Weapons',r2=.23,n=40)
    # Twin missile rails at wingtip, each attached by a real bracket.
    for h in [-.11,.11]:
        x=side*3.03; z=1.98+h
        box('Missile rail',(x,.62,z),(.105,1.63,.07),'Metal',.012)
        cyl('Rail attachment',(x,.5,2.37),(x,.5,z),.044,'Metal')
        mx=x+side*.13
        cyl('Missile body',(mx,-.20,z),(mx,1.35,z),.073,'Weapons',n=24)
        cyl('Missile nose',(mx,-.20,z),(mx,-.53,z),.073,'Weapons',r2=.008,n=24)
        for a in [0,pi/2,pi,3*pi/2]:
            d=Vector((cos(a),0,sin(a))); c=Vector((mx,1.12,z));v=[c+d*.06+Vector((0,-.20,0)),c+d*.22+Vector((0,.08,0)),c+d*.20+Vector((0,.24,0)),c+d*.06+Vector((0,.22,0))]
            o=mesh('Missile stabilizer',v,[(0,1,2,3)],'Weapons');s=o.modifiers.new('Fin thickness','SOLIDIFY');s.thickness=.015;apply(o);bevel(o,.004)
for s in [-1,1]:wing(s)
# Landing gear down: oleos, torque links, diagonal drag braces and detailed wheels.
def wheel(x,y,z,r,width):
    cyl('Wheel axle',(x-width*.7,y,z),(x+width*.7,y,z),.095,'Metal')
    # Tire torus around X shaft, shaped section with a broad running tread.
    verts=[];faces=[];uv=[];n=56;m=12
    for i in range(n):
        a=2*pi*i/n
        for j in range(m):
            b=2*pi*j/m;rr=r-width*.43+width*.43*cos(b)
            verts.append((x+width*.53*sin(b),y+rr*sin(a),z+rr*cos(a)));uv.append((i/n*3,j/m))
    for i in range(n):
        for j in range(m):faces.append((i*m+j,i*m+(j+1)%m,((i+1)%n)*m+(j+1)%m,((i+1)%n)*m+j))
    mesh('Pneumatic tire',verts,faces,'Rubber',uv)
    for s in [-1,1]:
        cyl('Wheel dished hub',(x+s*width*.37,y,z),(x+s*width*.49,y,z),r*.48,'Weapons',n=32)
        ring('Hub rim',(x+s*width*.50,y,z),r*.48,.016,'X','Metal',32)
        cyl('Axle cap',(x+s*width*.50,y,z),(x+s*width*.53,y,z),r*.16,'Metal',n=20)
        for a in np.linspace(0,2*pi,9)[:-1]:
            ell('Wheel lug',(x+s*width*.515,y+r*.30*sin(a),z+r*.30*cos(a)),(.017,.017,.017),'Metal',8,6)
    for off in [-.075,0,.075]:
        ring('Tread groove',(x+off,y,z),r-.012,.010,'X','Interior',56)
for side in [-1,1]:
    x=side*1.39;y=1.15;r=.48
    wheel(x,y,r,r,.29)
    cyl('Main polished oleo',(side*1.18,.93,.56),(side*1.05,.62,1.34),.070,'Metal')
    cyl('Main strut housing',(side*1.05,.62,1.22),(side*.88,.36,1.98),.105,'Weapons')
    tube('Main drag brace',[(side*1.37,1.1,.55),(side*1.20,1.65,1.27),(side*.88,1.72,1.77)],.057,'Metal')
    tube('Oleo torque scissors',[(side*1.1,.66,1.11),(side*1.30,.69,.92),(side*1.24,.82,.78)],.035,'Metal')
    tube('Brake hydraulic hose',[(side*.99,.48,1.75),(side*1.18,.64,1.07),(side*1.45,.96,.57)],.013,'Rubber')
    box('Main gear door',(side*.94,1.12,1.63),(.08,.78,.61),'Paint',.09)
for x in [-.17,.17]:wheel(x,-4.18,.31,.31,.20)
cyl('Nose gear fork',(-.30,-4.18,.31),(.30,-4.18,.31),.045,'Metal')
cyl('Nose oleo',(0,-4.18,.35),(0,-4.01,1.03),.062,'Metal')
cyl('Nose strut',(0,-4.01,.85),(0,-3.85,1.54),.082,'Weapons')
tube('Nose gear drag stay',[(0,-4.16,.60),(0,-3.46,1.28),(0,-3.48,1.62)],.046,'Metal')
# Swept vertical fin, integrated with the tapering boom.
verts=[(-.16,9.14,3.30),(-.16,9.99,3.38),(-.105,11.0,5.79),(-.105,10.30,5.84),(.16,9.14,3.30),(.16,9.99,3.38),(.105,11.0,5.79),(.105,10.30,5.84)]
o=mesh('Swept tail fin',verts,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],'Paint');bevel(o,.09,3)
for side in [-1,1]:
    verts=[(side*.13,8.68,3.61),(side*1.24,8.87,3.60),(side*1.24,9.35,3.56),(side*.14,9.42,3.52)]
    o=mesh('Tailplane',verts,[(0,1,2,3)],'Paint');sol=o.modifiers.new('Tailplane thickness','SOLIDIFY');sol.thickness=.09;apply(o);bevel(o,.035,3)
    tube('Tail skid brace',[(side*.17,9.04,3.07),(side*.24,9.92,2.36),(side*.12,9.62,3.10)],.031,'Metal')
# Boom longitudinal seams and sparse riveted bulkhead joints.
for yy in [4.6,5.8,7,8.2,9.2]:
    w=np.interp(yy,[4.1,5.3,7.1,9.3],[.69,.55,.38,.23]);z=np.interp(yy,[4.1,9.3],[2.81,3.32]);h=np.interp(yy,[4.1,5.3,7.1,9.3],[.78,.62,.43,.29])
    pts=[(w*cos(a),yy,z+h*sin(a)) for a in np.linspace(0,2*pi,65)[:-1]]
    tube('Tail boom panel seam',pts,.006,'Metal',True)
    for p in pts[::5]:rivet(p,r=.012)
# Antennas, wiring and position lamps.
tube('Dorsal antenna',[(0,3.5,3.66),(0,3.65,4.23)],.018,'Metal')
tube('Tail aerial',[(0,6.8,3.60),(0,7.15,4.04)],.014,'Metal')
tube('Aerial wire',[(0,3.65,4.23),(0,10.3,5.81)],.004,'Rubber',n=4)
for side in [-1,1]:ell('Navigation lamp',(side*3.10,.60,2.41),(.057,.085,.044),'Lamps',16,8)
ell('Beacon',(0,3.64,3.78),(.065,.065,.09),'Lamps',16,8)
# Main rotor mechanical hub and real twisted double-surface airfoil blades.
GROUP='MainRotor';hubz=5.25
cyl('Mast',(0,0,4.53),(0,0,5.30),.115,'Metal',n=32)
for z,r in [(4.76,.28),(4.93,.36),(5.12,.25),(5.29,.25)]:cyl('Rotor bearing',(0,0,z-.045),(0,0,z+.045),r,'Metal',n=40)
ell('Rotor head cap',(0,0,5.38),(.24,.24,.15),'Weapons',32,12)
def blade(angle,length=8.65,tail=False):
    # Closed NACA-style cambered aerofoil. Root pitch 12 deg, tip pitch 3 deg.
    n=24; nr=24;verts=[];faces=[];uv=[]
    for i in range(nr):
        r=.63+(length-.63)*i/(nr-1);f=i/(nr-1);ch=.48 if not tail else .17
        ch*=.80+.20*min(1,f*5);pitch=math.radians(12-9*f)
        sweep=.04+max(0,f-.7)*.43
        for j in range(n):
            a=2*pi*j/n;u=(1-cos(a))/2
            thick=5*.12*(.2969*sqrt(max(u,0))-.126*u-.3516*u*u+.2843*u**3-.1036*u**4)
            yy=(u-.25)*ch; zz=math.copysign(thick*ch,sin(a))+.017*ch*sin(pi*u)
            tang=yy*cos(pitch)-zz*sin(pitch)+sweep;z=hubz+yy*sin(pitch)+zz*cos(pitch)-.085*f*f
            verts.append((r*cos(angle)-tang*sin(angle),r*sin(angle)+tang*cos(angle),z));uv.append((r/2,u*.4))
    for i in range(nr-1):
        for j in range(n):faces.append((i*n+j,i*n+(j+1)%n,(i+1)*n+(j+1)%n,(i+1)*n+j))
    faces.extend([tuple(range(n-1,-1,-1)),tuple((nr-1)*n+j for j in range(n))]);mesh('Twisted main rotor airfoil',verts,faces,'Weapons',uv)
for i in range(5):
    a=2*pi*i/5+.21; d=Vector((cos(a),sin(a),0))
    cyl('Blade grip',d*.18+Vector((0,0,hubz)),d*.85+Vector((0,0,hubz)),.080,'Metal')
    cyl('Pitch link',d*.37+Vector((0,0,4.83)),d*.58+Vector((0,0,5.26)),.023,'Metal',n=12)
    for r in [.39,.54]:ring('Grip collar',d*r+Vector((0,0,hubz)),.102,.018,'Z','Metal',24)
    blade(a)
# Tail rotor shaft along X; three shaped blades on port side.
GROUP='TailRotor';tc=Vector((.30,10.54,5.23))
cyl('Tail rotor shaft',(-.15,10.54,5.23),(.50,10.54,5.23),.089,'Metal')
ell('Tail rotor hub',tc,(.15,.15,.15),'Metal',24,12)
for i in range(3):
    a=i*2*pi/3+pi/2;d=Vector((0,cos(a),sin(a)));t=Vector((0,-sin(a),cos(a)))
    cyl('Tail grip',tc+d*.05,tc+d*.29,.051,'Metal',n=16)
    vs=[];fs=[];uv=[]
    for k in range(12):
        r=.22+1.05*k/11;ch=.19*(1-.18*k/11)
        for j in range(16):
            q=2*pi*j/16;u=(1-cos(q))/2;off=(u-.30)*ch;th=.016*sin(q)
            vs.append(tc+d*r+t*off+Vector((th+off*.18,0,0)));uv.append((r,u))
    for k in range(11):
        for j in range(16):fs.append((k*16+j,k*16+(j+1)%16,(k+1)*16+(j+1)%16,(k+1)*16+j))
    fs.extend([tuple(range(15,-1,-1)),tuple(11*16+j for j in range(16))]);mesh('Tail rotor airfoil',vs,fs,'Weapons',uv)
GROUP='Fuselage'
# Fixed hydraulic lines and hoisting eyes provide close-range scale.
for side in [-1,1]:
    tube('Engine handhold',[(side*.86,-1.8,3.80),(side*1.08,-1.8,3.84),(side*1.08,-1.42,3.84),(side*.88,-1.42,3.80)],.026,'Metal')
    for yy in [-3,-2.5]:
        ell('Armour attachment',(side*.91,yy,2.35),(.07,.1,.05),'Metal',12,8)
    tube('Access hand rail',[(side*.91,-2.0,2.25),(side*1.12,-2.0,2.25),(side*1.12,-2.0,2.80),(side*.94,-2.0,2.80)],.023,'Metal')
# Join rigid assemblies, apply transforms/modifiers, recalculate normals and triangulate.
pivots={'Fuselage':(0,0,0),'MainRotor':(0,0,hubz),'TailRotor':tuple(tc),'Door_L':(1.1,.4,2.48),'Door_R':(-1.1,.4,2.48),'Gun':(0,-5.69,1.26)}
asset=[]
for name,obs in groups.items():
    bpy.ops.object.select_all(action='DESELECT')
    for ob in obs:ob.select_set(True)
    bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();ob=bpy.context.object;ob.name=name
    bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
    bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=.000005);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bmesh.ops.triangulate(bm,faces=bm.faces);bm.to_mesh(ob.data);bm.free()
    dec=ob.modifiers.new('Game mesh optimization','DECIMATE');dec.ratio=.60;dec.use_collapse_triangulate=True;apply(ob)
    sc.cursor.location=pivots[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR');asset.append(ob)
# Required sockets (empty nodes) in game-space metadata and GLB.
def empty(name,p):
    ob=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(ob);ob.location=p;ob.empty_display_size=.12;asset.append(ob)
empty('Seat_Gunner',(0,-5.12,2.36));empty('Seat_Pilot',(0,-3.50,2.97))
for side in [-1,1]:
    for k in range(4):empty('Troop_'+str(k+1+(0 if side==1 else 4)),(side*.65,-1.2+k*.85,1.92))
    label='L' if side==1 else 'R'
    for k,xx in enumerate([1.70,2.47,3.03]):empty('Hardpoint_'+label+str(k+1),(side*xx,.32+k*.14,2.83-k*.25))
    empty('Light_Nav_'+label,(side*3.10,.60,2.41));empty('Exhaust_'+label,(side*1.28,-.25,4.01))
empty('Light_Beacon',(0,3.64,3.78))
paints=[{'name':'olive','linearRGB':[.20,.22,.14]},{'name':'desert sand','linearRGB':[.48,.36,.20]},{'name':'air-superiority light grey','linearRGB':[.48,.53,.56]},{'name':'dark grey','linearRGB':[.12,.14,.15]},{'name':'navy blue-grey','linearRGB':[.13,.20,.26]},{'name':'black','linearRGB':[.033,.037,.035]}]
def game(p):return [round(float(p[0]),6),round(float(p[2]),6),round(float(-p[1]),6)]
points=[o.matrix_world@v.co for o in asset if o.type=='MESH' for v in o.data.vertices];gp=np.array([game(p) for p in points]);tris=sum(len(o.data.polygons) for o in asset if o.type=='MESH')
meta={'units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'nodes':{o.name:{'position':game(o.location),'type':o.type} for o in asset},'boundingBox':{'min':gp.min(axis=0).tolist(),'max':gp.max(axis=0).tolist(),'size':(gp.max(axis=0)-gp.min(axis=0)).tolist()},'triangles':tris,'paints':paints,'MainRotor':{'axis':[0,1,0],'bladeCount':5,'nominalDiameter':17.3},'TailRotor':{'axis':[1,0,0],'bladeCount':3},'doors':{'Door_L':{'axis':[0,1,0],'openDegrees':95},'Door_R':{'axis':[0,1,0],'openDegrees':-95}},'textureDesign':'Shared 2 m tiled Cycles-baked scan detail; geometric panel joints and fasteners','source':'sheet.jpg; original procedural reconstruction, no insignia'}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
# Direct texture for export, then set the legal glTF baseColorFactor explicitly.
pnt=mats['Paint'].node_tree;pbr=pnt.nodes.get('Principled BSDF');tint=pnt.nodes['PaintTint'];base=tint.inputs[1].links[0].from_socket
pnt.links.new(base,pbr.inputs['Base Color'])
glb=ROOT/'hind-helicopter.glb'
bpy.ops.export_scene.gltf(filepath=str(glb),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='JPEG',export_jpeg_quality=88,export_extras=True)
raw=glb.read_bytes();jl,jt=struct.unpack_from('<II',raw,12);doc=json.loads(raw[20:20+jl]);binchunk=raw[20+jl:]
for mat in doc['materials']:
    if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=[.20,.22,.14,1]
    if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4);glb.write_bytes(struct.pack('<III',0x46546c67,2,20+len(js)+len(binchunk))+struct.pack('<II',len(js),0x4e4f534a)+js+binchunk)
pnt.links.new(tint.outputs[0],pbr.inputs['Base Color'])
print('EXPORTED',tris,'triangles',glb.stat().st_size,'bytes',flush=True)
(ROOT/'NOTES.md').write_text('\n'.join(['Mi-24 Hind-inspired assault helicopter, metre scale; tandem cockpit, cabin, armament and articulated nodes.',f'{tris:,} triangles; {glb.stat().st_size/1048576:.2f} MiB GLB; six rigid mesh nodes plus attachment empties.','7 materials: Paint, Metal, Rubber, Interior, Weapons, Glass (BLEND), Lamps; matte recolourable Paint.','Shared Cycles-baked base colour / normal 1024 px and packed ORM 512 px; tiled at approximately 2 m.','Poly Haven CC0: blue_metal_plate, rubber_tiles; https://polyhaven.com/license ; source maps downloaded from dl.polyhaven.org.','Differences: interpreted panel layout, simplified cockpit instruments and undercarriage internals; no faction markings.'])+'\n')
# Presentation scene: neutral daylight and ground contact; excluded from export.
groundmat=bpy.data.materials.new('Studio Ground');groundmat.diffuse_color=(.30,.33,.34,1);groundmat.use_nodes=True;groundmat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.85
bpy.ops.mesh.primitive_plane_add(size=2000);floor=bpy.context.object;floor.name='Render ground';floor.location.z=-.015;floor.data.materials.append(groundmat)
world=bpy.data.worlds.new('Soft daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs[0].default_value=(.69,.77,.85,1);world.node_tree.nodes.get('Background').inputs[1].default_value=.55
for name,loc,power,size in [('Key',(5,-9,13),2200,8),('Fill',(-9,-5,7),1400,9),('Rim',(3,9,12),2600,7)]:
    ld=bpy.data.lights.new(name,'AREA');lo=bpy.data.objects.new(name,ld);bpy.context.collection.objects.link(lo);lo.location=loc;ld.energy=power;ld.shape='DISK';ld.size=size;lo.rotation_euler=(Vector((0,0,2.5))-lo.location).to_track_quat('-Z','Y').to_euler()
ld=bpy.data.lights.new('Day sun','SUN');lo=bpy.data.objects.new('Day sun',ld);bpy.context.collection.objects.link(lo);lo.rotation_euler=(.35,-.5,-.5);ld.energy=1.3;ld.angle=.25
camd=bpy.data.cameras.new('Presentation');cam=bpy.data.objects.new('Presentation',camd);bpy.context.collection.objects.link(cam);sc.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((19,-25,10.0),(0,1,2.7),22.5)
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
sc.cycles.samples=32
sc.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
view((25,1.0,3),(0,1,3),22.2);sc.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
view((19,-25,10.0),(0,1,2.7),22.5)
clay=bpy.data.materials.new('Clay QA');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.42,.42,.42,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.65
saved={}
for ob in asset:
    if ob.type=='MESH':
        saved[ob.name]=list(ob.data.materials)
        for i in range(len(ob.data.materials)):ob.data.materials[i]=clay
sc.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True)
for ob in asset:
    if ob.type=='MESH':
        for i,m in enumerate(saved[ob.name]):ob.data.materials[i]=m
# Six identical camera views, assembled through Blender image pixels (no Pillow needed).
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12;tiles=[]
for i,p in enumerate(paints):
    tint.inputs[2].default_value=tuple(p['linearRGB'])+(1,);sc.render.filepath=str(ROOT/f'paint_{i}.png');bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(sc.render.filepath,check_existing=False);tiles.append(np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4));bpy.data.images.remove(im)
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,ar in enumerate(tiles):
    x=(i%3)*640;y=(1-i//3)*360;canvas[y:y+360,x:x+640]=ar
im=bpy.data.images.new('Paint variants',1920,720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
for i in range(6):(ROOT/f'paint_{i}.png').unlink(missing_ok=True)
tint.inputs[2].default_value=(.20,.22,.14,1)
print('BUILD_COMPLETE',flush=True)
