"""Leclerc-style MBT. Clean build: blender -b --python-exit-code 1 --python this_file.
Authoring frame: x=left, f=forward, z=up; converted to Blender (x,-f,z).
CC0 scan sources are downloaded from Poly Haven and baked through Cycles/OptiX.
Shared 1m tiled material bakes, physical bevel geometry, no unique vehicle atlas.
"""
import bpy, bmesh, math, os, json, random, urllib.request, subprocess, struct
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(71)
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene; S.unit_settings.system='METRIC'; S.unit_settings.scale_length=1
S.render.engine='CYCLES'; S.cycles.device='GPU'; S.cycles.samples=24
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'
S.cycles.use_denoising=True; S.render.image_settings.file_format='PNG'
S.render.bake.margin=4
PAINTS=[('NATO green',[.10,.12,.07]),('Olive drab',[.17,.175,.085]),('Desert tan',[.52,.39,.23]),('Field grey',[.19,.23,.21]),('Winter white',[.72,.75,.71]),('Dark green-black',[.035,.048,.032])]

def download(id,mp):
    p=TEX/f'{id}_{mp}_1k.jpg'
    if not p.exists():
        u=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg'
        subprocess.run(['curl','--fail','--retry','3','-L','-A','Mozilla/5.0',u,'-o',str(p)],check=True)
    return p
# Browsed https://api.polyhaven.com/assets?t=textures; CC0 https://polyhaven.com/license
SOURCES=['green_metal_rust','rusty_metal_03','rubberized_track']
for id in SOURCES:
    for mp in ['diff','nor_gl','arm']: download(id,mp)

def node(nt,typ):return nt.nodes.new(typ)
def link(nt,a,b):nt.links.new(a,b)
def img_node(nt,path,noncolor=False):
    n=node(nt,'ShaderNodeTexImage');n.image=bpy.data.images.load(str(path),check_existing=True)
    if noncolor:n.image.colorspace_settings.name='Non-Color'
    return n

def material_bake(name,source,tint,rough,metal,normal_strength=.2):
    # Bake a shared physical-scale tile, retaining aligned photo microstructure.
    m=bpy.data.materials.new(name+'_source');m.use_nodes=True; nt=m.node_tree;nt.nodes.clear()
    out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');link(nt,bs.outputs[0],out.inputs['Surface'])
    uv=node(nt,'ShaderNodeTexCoord')
    diff=img_node(nt,download(source,'diff'));nr=img_node(nt,download(source,'nor_gl'),True);arm=img_node(nt,download(source,'arm'),True)
    for n in [diff,nr,arm]:link(nt,uv.outputs['UV'],n.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])
    if name=='Paint':
        ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.18;ramp.color_ramp.elements[0].color=(.28,.28,.28,1);ramp.color_ramp.elements[1].color=(.98,.98,.98,1)
    else:
        ramp.color_ramp.elements[0].color=tuple(v*.45 for v in tint)+(1,);ramp.color_ramp.elements[1].color=tuple(tint)+(1,)
    noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=140;noise.inputs['Detail'].default_value=3
    link(nt,uv.outputs['UV'],noise.inputs['Vector'])
    mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.13
    link(nt,ramp.outputs[0],mix.inputs[1]);link(nt,noise.outputs['Fac'],mix.inputs[2])
    # Thin vertical rain/dirt marks are baked into the neutral paint surface.
    scale=node(nt,'ShaderNodeVectorMath');scale.operation='MULTIPLY';scale.inputs[1].default_value=(55,3,1);link(nt,uv.outputs['UV'],scale.inputs[0])
    streak=node(nt,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=2;streak.inputs['Detail'].default_value=3;link(nt,scale.outputs[0],streak.inputs['Vector'])
    st=node(nt,'ShaderNodeValToRGB');st.color_ramp.elements[0].position=.32;st.color_ramp.elements[0].color=(.56,.56,.56,1);st.color_ramp.elements[1].position=.60;link(nt,streak.outputs['Fac'],st.inputs[0])
    m2=node(nt,'ShaderNodeMixRGB');m2.blend_type='MULTIPLY';m2.inputs[0].default_value=.10 if name=='Paint' else .08;link(nt,mix.outputs[0],m2.inputs[1]);link(nt,st.outputs[0],m2.inputs[2]);link(nt,m2.outputs[0],bs.inputs['Base Color'])
    norm=node(nt,'ShaderNodeNormalMap');norm.inputs['Strength'].default_value=normal_strength;link(nt,nr.outputs[0],norm.inputs['Color']);link(nt,norm.outputs[0],bs.inputs['Normal'])
    sep=node(nt,'ShaderNodeSeparateColor');link(nt,arm.outputs[0],sep.inputs[0])
    rm=node(nt,'ShaderNodeMapRange');rm.inputs['To Min'].default_value=max(.1,rough-.10);rm.inputs['To Max'].default_value=min(1,rough+.10);link(nt,sep.outputs['Green'],rm.inputs['Value']);link(nt,rm.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
    bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-10));plane=bpy.context.object;plane.data.materials.append(m)
    target=node(nt,'ShaderNodeTexImage')
    baked={}
    for kind,res in [('base',1024 if name in ('Paint','TrackSteel','Steel','Rubber','Dust') else 256),('normal',512 if name in ('Paint','TrackSteel','Steel','Rubber') else 256),('orm',512 if name in ('Paint','TrackSteel','Steel','Rubber','Dust') else 128)]:
        im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=(name=='Dust' and kind=='base'))
        if kind!='base':im.colorspace_settings.name='Non-Color'
        target.image=im;nt.nodes.active=target;target.select=True
        if kind=='base':
            em=node(nt,'ShaderNodeEmission');link(nt,m2.outputs[0],em.inputs[0]);link(nt,em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT');nt.nodes.remove(em)
        elif kind=='normal':
            link(nt,bs.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
        else:
            combine=node(nt,'ShaderNodeCombineColor');combine.inputs['Red'].default_value=1;link(nt,rm.outputs[0],combine.inputs['Green']);combine.inputs['Blue'].default_value=metal
            em=node(nt,'ShaderNodeEmission');link(nt,combine.outputs[0],em.inputs[0]);link(nt,em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT');nt.nodes.remove(em)
        if name=='Dust' and kind=='base':
            # Baked soft dirt trim: continuous mud residue, feathered with scanned grit.
            sep_uv=node(nt,'ShaderNodeSeparateXYZ');link(nt,uv.outputs['UV'],sep_uv.inputs[0])
            mul=node(nt,'ShaderNodeMath');mul.operation='MULTIPLY';mul.inputs[1].default_value=-1.7;link(nt,sep_uv.outputs['Y'],mul.inputs[0])
            add=node(nt,'ShaderNodeMath');add.operation='ADD';add.inputs[1].default_value=1.25;link(nt,mul.outputs[0],add.inputs[0])
            mask=node(nt,'ShaderNodeMath');mask.operation='MULTIPLY';mask.use_clamp=True;link(nt,add.outputs[0],mask.inputs[0]);link(nt,noise.outputs['Fac'],mask.inputs[1])
            em=node(nt,'ShaderNodeEmission');link(nt,mask.outputs[0],em.inputs[0]);link(nt,em.outputs[0],out.inputs['Surface'])
            ai=bpy.data.images.new('Dust_alpha_bake',res,res,alpha=False);ai.colorspace_settings.name='Non-Color';target.image=ai;nt.nodes.active=target;bpy.ops.object.bake(type='EMIT')
            px=np.array(im.pixels[:],dtype=np.float32).reshape(-1,4);ap=np.array(ai.pixels[:],dtype=np.float32).reshape(-1,4);px[:,3]=ap[:,0];im.pixels.foreach_set(px.ravel());nt.nodes.remove(em)
        ext='jpg' if kind=='base' and name!='Dust' else 'png'
        im.filepath_raw=str(TEX/f'{name}_{kind}.{ext}');im.file_format='JPEG' if ext=='jpg' else 'PNG';im.save();baked[kind]=im
    bpy.data.objects.remove(plane,do_unlink=True)
    final=bpy.data.materials.new(name);final.use_nodes=True;nt=final.node_tree;nt.nodes.clear()
    out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');link(nt,bs.outputs[0],out.inputs['Surface'])
    imgs={}
    for kind in baked:
        n=node(nt,'ShaderNodeTexImage');n.image=baked[kind];n.label=kind;imgs[kind]=n
    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=tuple(PAINTS[0][1])+(1,);link(nt,imgs['base'].outputs[0],mult.inputs[1]);link(nt,mult.outputs[0],bs.inputs['Base Color'])
    else:link(nt,imgs['base'].outputs[0],bs.inputs['Base Color'])
    n=node(nt,'ShaderNodeNormalMap');link(nt,imgs['normal'].outputs[0],n.inputs['Color']);link(nt,n.outputs[0],bs.inputs['Normal'])
    sep=node(nt,'ShaderNodeSeparateColor');link(nt,imgs['orm'].outputs[0],sep.inputs[0]);link(nt,sep.outputs['Green'],bs.inputs['Roughness']);link(nt,sep.outputs['Blue'],bs.inputs['Metallic'])
    # glTF occlusion hookup (red channel of the ORM bake).
    grp=bpy.data.node_groups.get('glTF Material Output')
    if not grp:
        grp=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');grp.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=node(nt,'ShaderNodeGroup');gn.node_tree=grp;link(nt,sep.outputs['Red'],gn.inputs['Occlusion'])
    if name=='Dust':
        link(nt,imgs['base'].outputs['Alpha'],bs.inputs['Alpha']);final.surface_render_method='DITHERED'
    if name=='Glass':bs.inputs['Coat Weight'].default_value=.25
    if name=='Lamps':bs.inputs['Emission Color'].default_value=(.7,.76,.65,1);bs.inputs['Emission Strength'].default_value=.15
    bpy.data.materials.remove(m)
    print('BAKED',name,flush=True)
    return final

spec=[('Paint','green_metal_rust',(1,1,1),.82,0,.27),('Rubber','rubberized_track',(.065,.068,.064),.91,0,.26),('Steel','rusty_metal_03',(.24,.25,.24),.53,.78,.2),('TrackSteel','rusty_metal_03',(.14,.12,.09),.78,.55,.36),('Glass','rusty_metal_03',(.022,.067,.074),.22,.45,.025),('Lamps','rusty_metal_03',(.80,.83,.76),.22,.2,.02),('Weapon','rusty_metal_03',(.045,.052,.048),.59,.72,.16),('Dust','rubberized_track',(.22,.18,.125),.97,0,.3)]
# Material bakes are performed after initial geometry/clay output below.
MATS=[]
class Mesh:
    def __init__(self,name):self.name=name;self.v=[];self.f=[];self.mi=[];self.uv=[]
    def add(self,vs,fs,mat=0,uv=None):
        k=len(self.v);self.v.extend(vs)
        for j,fa in enumerate(fs):
            self.f.append(tuple(k+i for i in fa));self.mi.append(mat)
            if uv is not None:self.uv.append(uv[j])
            else:
                pts=[Vector(vs[i]) for i in fa];n=(pts[1]-pts[0]).cross(pts[2]-pts[0]);a=max(range(3),key=lambda q:abs(n[q]));axes=[q for q in range(3) if q!=a]
                self.uv.append([(p[axes[0]],p[axes[1]]) for p in pts])
    def finish(self,pivot=(0,0,0)):
        me=bpy.data.meshes.new(self.name);me.from_pydata([(x,-y,z) for x,y,z in self.v],[],[tuple(reversed(f)) for f in self.f]);me.update()
        ob=bpy.data.objects.new(self.name,me);bpy.context.collection.objects.link(ob)
        for m in MATS:me.materials.append(m)
        uv=me.uv_layers.new(name='SurfaceUV')
        for poly,mi,coords in zip(me.polygons,self.mi,self.uv):
            poly.material_index=mi
            for li,co in zip(poly.loop_indices,reversed(coords)):uv.data[li].uv=co
        bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free()
        # Smooth only rounded surfaces; split at real panel and chamfer boundaries.
        for p in me.polygons:p.use_smooth=True
        bm=bmesh.new();bm.from_mesh(me)
        for e in bm.edges:
            if e.is_manifold:e.smooth=e.calc_face_angle()<.65
        bm.to_mesh(me);bm.free()
        p=Vector((pivot[0],-pivot[1],pivot[2]));me.transform(Matrix.Translation(-p));ob.location=p
        return ob
B={n:Mesh(n) for n in ['Hull','Turret','Gun','Track_L','Track_R','Hatch_Commander','MG']}
# Mat indices: Paint 0, Rubber 1, Steel 2, TrackSteel 3, Glass 4, Lamps 5, Weapon 6, Dust 7
boxcache={}
def box(g,c,sz,mat=0,bevel=.008,rot=None):
    key=tuple(round(q,5) for q in sz)+(round(bevel,5),)
    if key not in boxcache:
        bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
        for v in bm.verts:v.co=Vector((v.co.x*sz[0],v.co.y*sz[1],v.co.z*sz[2]))
        if bevel:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(bevel,min(sz)*.3),segments=1,affect='EDGES')
        bm.verts.ensure_lookup_table();bm.verts.index_update();boxcache[key]=([tuple(v.co) for v in bm.verts],[[v.index for v in f.verts] for f in bm.faces]);bm.free()
    vs,fs=boxcache[key];R=rot or Matrix.Identity(3);g.add([tuple(R@Vector(v)+Vector(c)) for v in vs],fs,mat)
def cyl(g,c,r,depth,mat=0,axis=(0,0,1),n=32,r2=None):
    # Bevelled circular edge through a four-ring lathe, caps included.
    ax=Vector(axis).normalized();u=ax.cross(Vector((0,1,0)))
    if u.length<.1:u=ax.cross(Vector((1,0,0)))
    u.normalize();v=ax.cross(u);r2=r if r2 is None else r2;b=min(.009,r*.12,depth*.15)
    rings=[(-depth/2,r-b),(-depth/2+b,r),(depth/2-b,r2),(depth/2,r2-b)]
    vs=[tuple(Vector(c)+ax*z+u*(rr*math.cos(i*math.tau/n))+v*(rr*math.sin(i*math.tau/n))) for z,rr in rings for i in range(n)]
    fs=[];uv=[]
    for j in range(3):
        for i in range(n):
            q=(i+1)%n;fs.append((j*n+i,j*n+q,(j+1)*n+q,(j+1)*n+i));uv.append([(i/n*math.tau*r,rings[j][0]),((i+1)/n*math.tau*r,rings[j][0]),((i+1)/n*math.tau*r,rings[j+1][0]),(i/n*math.tau*r,rings[j+1][0])])
    fs+=[tuple(reversed(range(n))),tuple(3*n+i for i in range(n))]
    uv += [[(vs[i][0],vs[i][1]) for i in f] for f in fs[-2:]]
    g.add(vs,fs,mat,uv)
def tube(g,a,b,r,mat=2,n=12):
    a,b=Vector(a),Vector(b);cyl(g,(a+b)/2,r,(b-a).length,mat,b-a,n)
def ring(g,c,ro,ri,depth,mat=2,axis=(0,0,1),n=48):
    ax=Vector(axis).normalized();u=ax.cross(Vector((0,1,0)))
    if u.length<.1:u=ax.cross(Vector((1,0,0)))
    u.normalize();v=ax.cross(u)
    vs=[tuple(Vector(c)+ax*z+(u*math.cos(i*math.tau/n)+v*math.sin(i*math.tau/n))*r) for z,r in [(-depth/2,ro),(depth/2,ro),(-depth/2,ri),(depth/2,ri)] for i in range(n)]
    fs=[]
    for a,b in [(0,1),(1,3),(3,2),(2,0)]:
        for i in range(n):j=(i+1)%n;fs.append((a*n+i,a*n+j,b*n+j,b*n+i))
    g.add(vs,fs,mat)
def polyloft(g,rings,mat=0,bevel=.008):
    vs=[p for r in rings for p in r];n=len(rings[0]);fs=[tuple(reversed(range(n))),tuple((len(rings)-1)*n+i for i in range(n))]
    for j in range(len(rings)-1):
        for i in range(n):q=(i+1)%n;fs.append((j*n+i,j*n+q,(j+1)*n+q,(j+1)*n+i))
    bm=bmesh.new();vv=[bm.verts.new(v) for v in vs]
    for f in fs:bm.faces.new([vv[i] for i in f])
    bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
    if bevel:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=bevel,segments=2,affect='EDGES')
    bm.verts.ensure_lookup_table();bm.verts.index_update();g.add([tuple(v.co) for v in bm.verts],[[v.index for v in f.verts] for f in bm.faces],mat);bm.free()
def prism(g,outline,z0,z1,mat=0):polyloft(g,[[(x,y,z0) for x,y in outline],[(x,y,z1) for x,y in outline]],mat)
def bolt(g,p,axis=(0,0,1),r=.021,mat=2):cyl(g,p,r,.018,mat,axis,6)
def handle(g,a,b,h=.06,mat=2):
    a,b=Vector(a),Vector(b);u=Vector((0,0,h));tube(g,a,a+u,.012,mat,8);tube(g,a+u,b+u,.012,mat,8);tube(g,b,b+u,.012,mat,8)
H=B['Hull'];T=B['Turret'];G=B['Gun']
# Welded hull: changing sections create the sloped glacis and chamfered belly.
sections=[(-3.44,1.42,.42,1.47),(-2.9,1.53,.38,1.52),(1.64,1.53,.38,1.52),(3.44,1.42,.70,1.17)]
rings=[]
for f,w,low,top in sections:rings.append([(-w+.16,f,low),(w-.16,f,low),(w,f,low+.18),(w,f,top-.06),(w-.09,f,top),(-w+.09,f,top),(-w,f,top-.06),(-w,f,low+.18)])
polyloft(H,rings)
# Belly, front towing lugs, glacis seams, rear exhaust grille.
for s in [-1,1]:
    box(H,(s*1.42,0,1.40),(.2,6.64,.16))
    box(H,(s*1.55,2.93,1.15),(.58,.75,.23),1,.015)
    for f in [2.95,-3.38]:
        ring(H,(s*.97,f,.67),.095,.048,.065,2,(1,0,0),20)
        box(H,(s*.97,f-.04,.72),(.14,.18,.16),0)
    # Upper fender stowage panels and skirt suspension.
    for j in range(7):
        f=2.85-j*.92
        box(H,(s*1.61,f,1.45),(.45,.892,.21),0,.008)
        for k in [-1,1]:bolt(H,(s*1.861,f+k*.34,1.47),(s,0,0),.021,0)
    # Segmented armor skirt: low to hide top halves of road wheels.
    for j in range(7):
        f=2.83-j*.93
        if j==0:
            polyloft(H,[[(s*1.62,f+.43,.84),(s*1.84,f+.43,.84),(s*1.84,f-.43,.78),(s*1.62,f-.43,.78)],[(s*1.60,f+.43,1.27),(s*1.855,f+.43,1.27),(s*1.855,f-.43,1.47),(s*1.60,f-.43,1.47)]])
        else:box(H,(s*1.81,f,1.105),(.09,.914,.65),0,.009)
        box(H,(s*1.845,f,.68),(.026,.914,.26),1,.006)
        for dz in [-.25,.25]:
            for df in [-.35,.35]:bolt(H,(s*1.861,f+df,1.105+dz),(s,0,0),.018,0)
        for df in [-.29,.29]:box(H,(s*1.861,f+df,.802),(.027,.085,.045),2,.003)
    # Fender brackets, tie loops and cabling along shoulder.
    for f in [-2.8,-1.8,-.8,.5,1.5,2.5]:
        handle(H,(s*1.36,f-.07,1.54),(s*1.36,f+.07,1.54),.035,2)
    # front headlamp housings and two protected lenses
    lamp_start=len(H.v)
    box(H,(s*1.31,2.86,1.30),(.35,.26,.20),6,.025)
    cyl(H,(s*1.25,3.001,1.32),.072,.028,2,(0,1,0),32)
    cyl(H,(s*1.25,3.02,1.32),.057,.015,5,(0,1,0),32)
    cyl(H,(s*1.43,3.002,1.31),.033,.025,5,(0,1,0),20)
    for xx in [s*1.14,s*1.49]:tube(H,(xx,2.78,1.43),(xx,3.04,1.43),.012,2)
    H.v[lamp_start:]=[(x,f+.12,z+.27) for x,f,z in H.v[lamp_start:]]
    # rear lights, grille side housings
    box(H,(s*1.39,-3.38,1.13),(.28,.12,.50),0,.015)
    box(H,(s*1.39,-3.45,1.22),(.14,.04,.10),5,.008)
    box(H,(s*1.39,-3.45,.99),(.13,.04,.085),5,.008)
box(H,(0,-3.447,1.03),(2.10,.04,.53),6,.004)
for z in [ .80+i*.05 for i in range(10)]:box(H,(0,-3.478,z),(2.04,.055,.021),2,.003)
for x in [-1.025,0,1.025]:box(H,(x,-3.51,1.03),(.025,.028,.54),0,.003)
# engine deck grilles, service hatch frames and longitudinal cooling louvers
for x in [-.80,.0,.80]:
    box(H,(x,-2.64,1.541),(.69,1.08,.045),6,.008)
    for j in range(16):box(H,(x,-3.13+j*.065,1.57),(.66,.032,.027),0,.003)
    for f in [-3.17,-2.10]:box(H,(x,f,1.57),(.73,.034,.04),0,.004)
# Driver hatch on glacis behind nose. Rotate to deck slope.
driver_start=len(H.v)
R=Matrix.Rotation(math.radians(-10.8),3,'X')
box(H,(-.54,2.02,1.454),(.70,.78,.052),0,.018,rot=R)
for x in [-.78,-.55,-.32]:
    box(H,(x,1.75,1.55),(.19,.15,.07),6,.008);box(H,(x,1.825,1.56),(.15,.012,.04),4,.003)
handle(H,(-.72,2.15,1.458),(-.4,2.15,1.458),.045,0)
H.v[driver_start:]=[(x+1.08,f+.17,z-.025) for x,f,z in H.v[driver_start:]]
# Trailing tow cable draped across rear, clamped down.
for s in [-1,1]:
    pts=[(s*(.5+i*.055),-3.515,.60+.11*math.sin(i/15*math.pi)) for i in range(16)]
    for a,b in zip(pts,pts[1:]):tube(H,a,b,.019,2,10)
# Turret ring and angular welded shell.
cyl(T,(0,-.12,1.585),1.08,.14,6,n=64)
outline=[(-.85,1.67),(.85,1.67),(1.48,.98),(1.52,-.94),(1.29,-1.91),(-1.29,-1.91),(-1.52,-.94),(-1.48,.98)]
top=[(-.75,1.48),(.75,1.48),(1.24,.84),(1.25,-.90),(1.19,-1.91),(-1.19,-1.91),(-1.25,-.90),(-1.24,.84)]
polyloft(T,[[(x,y,1.64) for x,y in outline],[(x,y,2.17) for x,y in top]],0,.014)
# Large replaceable cheek packs with characteristic upper chamfer and blunt fronts.
for s in [-1,1]:
    bottom=[(s*.36,1.71,1.66),(s*1.26,1.66,1.66),(s*1.53,1.05,1.66),(s*1.53,-.50,1.66),(s*.87,-.60,1.66)]
    mid=[(s*.38,1.72,1.91),(s*1.28,1.64,1.91),(s*1.57,1.04,1.94),(s*1.54,-.50,2.03),(s*.87,-.60,2.03)]
    upper=[(s*.50,1.50,2.12),(s*1.05,1.48,2.12),(s*1.33,.95,2.16),(s*1.31,-.50,2.21),(s*.87,-.60,2.21)]
    polyloft(T,[bottom,mid,upper],0,.01)
    # panel seams on the upper/side cheek armor, true inset strips
    for f in [.38,-.45]:
        tube(T,(s*1.553,f,1.68),(s*1.55,f,1.96),.006,6,6)
        tube(T,(s*1.55,f,1.96),(s*1.325,f,2.17),.006,6,6)
        for z in [1.75,1.95]:bolt(T,(s*1.56,f+.05,z),(s,0,0),.016,0)
    # Autoloader bustle side armour and rear basket rails
    box(T,(s*1.32,-1.47,1.92),(.20,1.25,.50),0,.012)
    for f in [-1.98,-1.0]:
        for z in [1.76,2.08]:bolt(T,(s*1.429,f,z),(s,0,0),.021,0)
    for z in [1.73,1.86,1.99]:tube(T,(s*1.43,-1.87,z),(s*1.43,-2.43,z),.019,0)
    tube(T,(s*1.43,-2.43,1.73),(s*1.43,-2.43,2.01),.022,2)
    # GALIX smoke launchers: three forward-slanted tubes on each roof corner.
    box(T,(s*1.20,-.83,2.18),(.37,.74,.07),6,.009)
    for j in range(3):
        c=Vector((s*1.24,-.58-j*.25,2.245));ax=Vector((s*.58,.40,.71)).normalized()
        cyl(T,c,.077,.25,6,ax,24);ring(T,c+ax*.126,.078,.058,.025,2,ax,24);cyl(T,c+ax*.137,.054,.008,6,ax,24)
    for j in range(3):
        c=Vector((s*1.47,-1.59-j*.18,1.93));ax=Vector((s*.85,-.35,.35)).normalized();cyl(T,c,.066,.21,6,ax,20)
    # Rear basket uprights and stowage cap clamps
    for f in [-1.88,-2.41]:tube(T,(s*1.42,f,1.7),(s*1.42,f,2.03),.018,0)
for z in [1.72,1.86,2.0]:tube(T,(-1.43,-2.43,z),(1.43,-2.43,z),.018,0)
box(T,(0,-1.98,1.93),(2.42,.10,.49),0,.009)
for x in [-1.05,-.65,.65,1.05]:
    box(T,(x,-2.05,2.12),(.06,.1,.15),2,.005)
    bolt(T,(x,-2.04,1.81),(0,-1,0),.02,0)
# Roof cupola, episcopes, commander's independent sight; max height 2.53m.
cyl(T,(.60,.06,2.205),.47,.095,0,n=64)
for i in range(7):
    a=i*math.tau/7; x=.60+.40*math.cos(a);f=.06+.40*math.sin(a)
    box(T,(x,f,2.253),(.19,.09,.070),6,.008,Matrix.Rotation(a-math.pi/2,3,'Z'))
    box(T,(x,f,2.267),(.135,.095,.043),4,.003,Matrix.Rotation(a-math.pi/2,3,'Z'))
cyl(B['Hatch_Commander'],(.60,.06,2.275),.31,.038,0,n=48)
handle(B['Hatch_Commander'],(.44,.06,2.296),(.69,.06,2.296),.045,0)
for x in [.42,.72]:cyl(T,(x,-.24,2.27),.028,.13,2,(1,0,0),16)
# raised panoramic sight, slightly left of gunner optics
cyl(T,(-.65,.28,2.22),.255,.14,0,n=48)
box(T,(-.65,.28,2.405),(.30,.27,.25),0,.022)
box(T,(-.65,.424,2.415),(.205,.024,.187),6,.007)
box(T,(-.65,.439,2.415),(.165,.015,.16),4,.004)
# Gunner's large armored primary sight to right of gun
sight_start=len(T.v)
box(T,(.56,1.16,2.10),(.42,.49,.26),0,.016)
box(T,(.56,1.416,2.105),(.34,.024,.17),6,.004)
box(T,(.56,1.432,2.11),(.265,.012,.117),4,.003)
T.v[sight_start:]=[(x,f+.55,z) for x,f,z in T.v[sight_start:]]
for f in [-.5,-1.4]:handle(T,(-.35,f,2.182),(.02,f,2.182),.032,0)
# Segmented autoloader roof blow-off panels, inset latches and service hatch.
for x in [-.74,0,.74]:
    box(T,(x,-1.42,2.184),(.70,.70,.028),0,.006)
    for dx in [-.26,.26]:bolt(T,(x+dx,-1.67,2.204),(0,0,1),.014,0)
    handle(T,(x-.10,-1.19,2.204),(x+.10,-1.19,2.204),.025,0)
cyl(T,(-.57,-.60,2.194),.30,.035,0,n=40)
handle(T,(-.72,-.60,2.214),(-.43,-.60,2.214),.04,0)
for x in [-.78,-.52]:cyl(T,(x,-.87,2.208),.025,.11,2,(1,0,0),12)
for x in [-.76,-.65,-.54]:box(T,(x,.518,2.248),(.08,.018,.055),4,.003)
# antenna bases/stubs stay inside the 2.53 m contract (no tall whip).
for x,f in [(.94,-1.65),(-.9,-1.65)]:
    cyl(T,(x,f,2.21),.064,.11,6,n=24);cyl(T,(x,f,2.40),.015,.24,2,n=16)
# Mantlet trunnion and long smooth 120mm thermal sleeve.
box(G,(0,1.57,1.95),(.63,.46,.55),0,.024)
for s in [-1,1]:cyl(G,(s*.31,1.55,1.97),.19,.06,6,(1,0,0),40)
cyl(G,(0,1.98,1.97),.177,.43,0,(0,1,0),48)
segments=[(2.16,2.80,.135),(2.80,3.34,.115),(3.34,4.01,.148),(4.01,4.62,.118),(4.62,5.87,.108),(5.87,6.23,.105)]
for a,b,r in segments:
    cyl(G,(0,(a+b)/2,1.97),r,b-a,0,(0,1,0),48)
    for f in [a+.025,b-.025]:ring(G,(0,f,1.97),r+.006,r-.004,.027,0,(0,1,0),48)
# Hollow muzzle with dark bore; no false solid plug at the opening.
ring(G,(0,6.32,1.97),.103,.060,.22,6,(0,1,0),48)
cyl(G,(0,6.19,1.97),.059,.015,6,(0,1,0),40)
box(G,(0,6.03,2.092),(.105,.19,.055),6,.006)
box(G,(0,5.984,2.125),(.09,.12,.028),6,.004)
# Roof machine gun, compact pintle cradle, barrel cooling jacket and receiver.
MG=B['MG'];cyl(MG,(.60,-.50,2.265),.054,.16,6,n=24)
box(MG,(.60,-.43,2.365),(.125,.40,.12),6,.012)
box(MG,(.77,-.50,2.36),(.22,.25,.16),6,.008)
cyl(MG,(.60,.07,2.38),.027,.61,6,(0,1,0),24)
for f in [-.18,-.10,-.02,.06,.14]:ring(MG,(.60,f,2.38),.035,.029,.025,2,(0,1,0),20)
cyl(MG,(.60,.42,2.38),.020,.14,6,(0,1,0),24)
box(MG,(.6,.31,2.429),(.022,.024,.075),6,.003)
box(MG,(.6,-.70,2.36),(.09,.16,.09),1,.008)
# Running gear: six dual-disc road wheels, front idler, rear toothed sprocket per side.
for s,label in [(1,'L'),(-1,'R')]:
    positions=[(2.90,.68,.405)]+[(2.2-i*.88,.51,.446) for i in range(6)]+[(-2.90,.68,.405)]
    for idx,(f,z,r) in enumerate(positions,1):
        w=Mesh(f'Wheel_{label}{idx}');B[w.name]=w;x=s*1.51
        if idx in [1,8]:
            cyl(w,(x,f,z),r,.40,3,(1,0,0),56)
            ring(w,(s*1.735,f,z),r-.026,r-.083,.044,2,(1,0,0),56)
            cyl(w,(s*1.745,f,z),r-.10,.045,0,(1,0,0),48)
            for j in range(10):
                a=j*math.tau/10
                cyl(w,(s*1.773,f+(r-.16)*math.sin(a),z+(r-.16)*math.cos(a)),.045,.013,6,(s,0,0),16)
            if idx==8:
                for j in range(14):
                    a=j*math.tau/14
                    box(w,(x,f+(r+.012)*math.sin(a),z+(r+.012)*math.cos(a)),(.43,.085,.062),2,.008,Matrix.Rotation(-a,3,'X'))
        else:
            # Two tires separated by a guide-horn channel.
            for dx in [-.145,.145]:
                cyl(w,(x+dx,f,z),r,.145,1,(1,0,0),56)
            cyl(w,(s*1.738,f,z),r-.06,.036,0,(1,0,0),56)
            ring(w,(s*1.762,f,z),r-.070,r-.101,.027,2,(1,0,0),56)
            cyl(w,(s*1.771,f,z),r-.114,.03,0,(1,0,0),48)
            ring(w,(s*1.789,f,z),.215,.183,.019,0,(1,0,0),40)
        cyl(w,(s*1.802,f,z),.112,.070,0,(1,0,0),32)
        cyl(w,(s*1.841,f,z),.060,.020,2,(1,0,0),24)
        for j in range(8):
            a=j*math.tau/8;bolt(w,(s*1.796,f+.148*math.sin(a),z+.148*math.cos(a)),(s,0,0),.016,2)
        # Suspension arms attach to hull, behind wheels.
        tube(H,(s*1.20,f+.25,.76),(s*1.44,f,z),.070,6,16)
    # Capsule belt centerline, bottom=0.065, total extremity ±3.44.
    belt=B['Track_'+label];radius=.51;half=2.86;cz=.592
    perim=4*half+math.tau*radius;count=94;pitch=perim/count
    def trackpoint(t):
        if t<2*half:return (half-t,cz-radius,0,-1)
        t-=2*half
        if t<math.pi*radius:
            a=-math.pi/2-t/radius;return (-half+radius*math.cos(a),cz+radius*math.sin(a),math.cos(a),math.sin(a))
        t-=math.pi*radius
        if t<2*half:return (-half+t,cz+radius,0,1)
        t-=2*half;a=math.pi/2-t/radius;return (half+radius*math.cos(a),cz+radius*math.sin(a),math.cos(a),math.sin(a))
    for j in range(count):
        f,z,nf,nz=trackpoint(j*pitch)
        # local y is tangent; local z is outward normal
        R=Matrix(((1,0,0),(0,nz,nf),(0,-nf,nz)))
        before=len(belt.f)
        box(belt,(s*1.51,f,z),(.60,pitch*.94,.090),3,.008,R)
        # twin rubber pads, raised steel grip bars and side pin bosses
        for dx in [-.157,.157]:
            c=Vector((s*1.51+dx,f,z))+R@Vector((0,0,.061));box(belt,c,(.245,pitch*.67,.042),1,.006,R)
        for dx in [-.278,.278]:
            c=Vector((s*1.51+dx,f,z))+R@Vector((0,pitch*.38,0));cyl(belt,c,.036,.070,2,(1,0,0),8)
        c=Vector((s*1.51,f,z))+R@Vector((0,0,-.105));box(belt,c,(.067,pitch*.40,.13),3,.006,R)
        # All UVs: U across width; V continuous distance ALONG belt, scrollable.
        for fi in range(before,len(belt.f)):
            belt.uv[fi]=[]
            for vi in belt.f[fi]:
                v=Vector(belt.v[vi])-Vector((s*1.51,f,z));loc=R.transposed()@v
                belt.uv[fi].append(((loc.x+.3)/.60,(j*pitch+loc.y)/.65))
# Baked feathered dirt trims, offset 2mm from armor (no coplanar surfaces).
for s in [-1,1]:
    for j in range(1,7):
        f=2.83-j*.93
        vs=[(s*1.862,f-.452,.715),(s*1.862,f+.452,.715),(s*1.862,f+.452,1.12),(s*1.862,f-.452,1.12)]
        H.add(vs,[(0,1,2,3)],7,[[(0,0),(.904,0),(.904,1),(0,1)]])
    # Subtle random chipped paint concentrated around the skirt's bottom seam.
    for j in range(65):
        f=random.uniform(-3.15,2.39);z=random.uniform(.787,.825);sz=random.uniform(.004,.016)
        H.add([(s*1.859,f-sz,z),(s*1.859,f+sz,z+.003),(s*1.859,f+sz*.6,z+.008),(s*1.859,f-sz*.8,z+.006)],[(0,1,2,3)],3)
# Bonded front lower-plate dirt residue.
H.add([(-1.15,3.441,.72),(1.15,3.441,.72),(1.15,3.441,1.15),(-1.15,3.441,1.15)],[(0,1,2,3)],7,[[(0,0),(2,0),(2,1),(0,1)]])
# Thin seams and weld beads on upper nose with lift hooks and bolts.
for s in [-1,1]:
    tube(H,(s*1.27,1.73,1.513),(s*1.21,3.33,1.202),.008,0,8)
    for j in range(12):
        f=1.73+j*.14;z=1.52-(f-1.64)*.194;bolt(H,(s*1.29,f,z+.012),(0,.19,1),.017,0)

# Normalize fixed-hull footprint and outer width to the requested physical envelope.
hmin=min(v[1] for v in H.v);hmax=max(v[1] for v in H.v)
H.v=[(x,(f-(hmax+hmin)/2)*6.88/(hmax-hmin),z) for x,f,z in H.v]
# End links fit inside the 6.88 m hull envelope.
for label in ['L','R']:
    bb=B['Track_'+label];zmin=min(v[2] for v in bb.v);bb.v=[(x,max(-3.44,min(3.44,f)),z-zmin) for x,f,z in bb.v]
xmax=max(abs(v[0]) for b in B.values() for v in b.v)
for b in B.values():b.v=[(x*1.855/xmax,f,z) for x,f,z in b.v]
print('RAW_TRIANGLES',sum(len(f)-2 for b in B.values() for f in b.f),flush=True)
# Finish geometry first with provisional clay material, then inspect before bakes.
clay=bpy.data.materials.new('QA_Clay');clay.diffuse_color=(.32,.34,.35,1);clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.32,.34,.35,1)
MATS=[clay]*8
pivots={'Hull':(0,0,0),'Turret':(0,-.12,1.57),'Gun':(0,1.55,1.97),'Hatch_Commander':(.60,-.24,2.27),'MG':(.60,-.50,2.265)}
for label,s in [('L',1),('R',-1)]:
    for idx,(f,z,r) in enumerate([(2.90,.68,.405)]+[(2.2-i*.88,.51,.446) for i in range(6)]+[(-2.90,.68,.405)],1):pivots[f'Wheel_{label}{idx}']=(s*1.51,f,z)
pivots={name:(p[0]*1.855/xmax,p[1],p[2]) for name,p in pivots.items()}
obs={name:b.finish(pivots.get(name,(0,0,0))) for name,b in B.items()}
bpy.context.view_layer.update()
for name in ['Gun','Hatch_Commander','MG']:
    ob=obs[name];mw=ob.matrix_world.copy();ob.parent=obs['Turret'];ob.matrix_world=mw
sockets={'Muzzle':((0,6.43,1.97),'Gun'),'Seat_Commander':((.60,.06,2.33),'Turret'),'Seat_Driver':((.54,2.19,1.49),'Hull'),'Exhaust':((0,-3.44,1.02),'Hull'),'Light_Head_L':((1.25,3.155,1.59),'Hull'),'Light_Head_R':((-1.25,3.155,1.59),'Hull')}
for name,(pos,parent) in sockets.items():
    ob=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(ob);ob.location=(pos[0],-pos[1],pos[2]);bpy.context.view_layer.update();mw=ob.matrix_world.copy();ob.parent=obs[parent];ob.matrix_world=mw;obs[name]=ob
bpy.context.view_layer.update()
# Render setup: neutral studio daylight, shadow receiving floor; excluded from export.
world=bpy.data.worlds.new('Daylight');S.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.60,.68,.79,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.006));floor=bpy.context.object;floor.name='RenderGround'
fm=bpy.data.materials.new('RenderGround');fm.use_nodes=True;fbs=fm.node_tree.nodes.get('Principled BSDF');fbs.inputs['Base Color'].default_value=(.22,.23,.235,1);fbs.inputs['Roughness'].default_value=.87;floor.data.materials.append(fm)
def area(name,loc,power,size,color):
    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;ld.color=color;lo.rotation_euler=(Vector((0,0,1))-lo.location).to_track_quat('-Z','Y').to_euler()
area('Key',(0,-7,11),2100,7,(1,.94,.84));area('Fill',(7,-2,6),1400,6,(.78,.87,1));area('Rim',(-4,6,8),2400,5,(1,1,1))
camd=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camd);bpy.context.collection.objects.link(cam);S.camera=cam
hero=(10,-15,5.6);target=Vector((0,-.85,1.10))
def camera(loc,orth=11.5):
    cam.location=loc;cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=orth;camd.lens=50
camera(hero,11.7)
S.view_settings.view_transform='AgX';S.view_settings.look='AgX - Medium High Contrast';S.render.resolution_percentage=100
S.render.resolution_x=1920;S.render.resolution_y=1080
def render(file,samples=24):
    S.cycles.samples=samples;S.render.filepath=str(ROOT/file);bpy.ops.render.render(write_still=True)
render('clay.png',16)
# Replace placeholders with baked portable materials.
MATS=[material_bake(*v) for v in spec]
for ob in obs.values():
    if ob.type=='MESH':
        for i,m in enumerate(MATS):ob.data.materials[i]=m
# remove bake source planes and ensure export selection contains only contracted nodes.
for ob in bpy.context.selected_objects:ob.select_set(False)
for ob in obs.values():ob.select_set(True)
bpy.context.view_layer.objects.active=obs['Hull']
triangles=sum(len(p.vertices)-2 for o in obs.values() if o.type=='MESH' for p in o.data.polygons)
print('GEOMETRY_TRIANGLES',triangles,flush=True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'leclerc-tank.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO',export_extras=True)
# Guarantee Paint factor in serialized glTF while keeping the base texture neutral.
# Blender's exporter versions differ in recognizing MixRGB factor multiplication.
p=ROOT/'leclerc-tank.glb';raw=p.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);binchunk=raw[20+jl:]
for mat in doc['materials']:
    if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
s=json.dumps(doc,separators=(',',':')).encode();s+=b' '*((-len(s))%4);p.write_bytes(struct.pack('<III',0x46546c67,2,12+8+len(s)+len(binchunk))+struct.pack('<II',len(s),0x4e4f534a)+s+binchunk)
# world axes after glTF conversion: (x,z,-y), same as author (x,height,forward)
bpy.context.view_layer.update();entries=[];pts=[]
for name,ob in obs.items():
    q=ob.matrix_world.translation;entry={'name':name,'type':'mesh' if ob.type=='MESH' else 'empty','parent':ob.parent.name if ob.parent else None,'world_position':[round(q.x,6),round(q.z,6),round(-q.y,6)]}
    if name=='Turret':entry['rotation_axis']='Y'
    elif name=='Gun' or name.startswith('Wheel_'):entry['rotation_axis']='X'
    if ob.type=='MESH':
        for v in ob.data.vertices:
            q=ob.matrix_world@v.co;pts.append((q.x,q.z,-q.y))
    entries.append(entry)
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
meta={'units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'nodes':entries,'bounding_box':{'min':lo,'max':hi,'size':[hi[i]-lo[i] for i in range(3)]},'triangles':triangles,'materials':[v[0] for v in spec],'paints':[{'name':n,'linear_rgb':v} for n,v in PAINTS],'track_uv':'U across belt; V continuous distance along belt / 0.65m; repeat wrap; scroll V','build':'Cycles OptiX tile bakes; 24 samples; bevelled geometry; no external image sources except CC0 Poly Haven'}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
render('final.png',32)
camera((12,-.85,1.10),11.4);render('side.png',24);camera(hero,11.7)
# Six views composed from actual rendered linear pixels (no mock recolours).
S.render.resolution_x=640;S.render.resolution_y=360
can=np.zeros((720,1920,4),dtype=np.float32)
for i,(name,color) in enumerate(PAINTS):
    MATS[0].node_tree.nodes['PaintTint'].inputs[2].default_value=tuple(color)+(1,)
    render(f'paint_{i}.png',12)
    im=bpy.data.images.load(str(ROOT/f'paint_{i}.png'),check_existing=False)
    ar=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);row=1-i//3;col=i%3;can[row*360:(row+1)*360,col*640:(col+1)*640]=ar;bpy.data.images.remove(im)
    (ROOT/f'paint_{i}.png').unlink()
out=bpy.data.images.new('Paint variants 3x2',width=1920,height=720);out.pixels.foreach_set(can.ravel());out.filepath_raw=str(ROOT/'paints.png');out.file_format='PNG';out.save()
MATS[0].node_tree.nodes['PaintTint'].inputs[2].default_value=tuple(PAINTS[0][1])+(1,)
notes=["French Leclerc-style MBT; articulated assemblies and sockets, hollow 120 mm gun, six road wheels per side, GALIX launchers, bustle and grilles.",f"Triangles: {triangles:,}; GLB: {p.stat().st_size/1048576:.2f} MiB; metres, +Z forward, +Y up.","Materials: Paint, Rubber, Steel, TrackSteel, Glass, Lamps, Weapon, Dust (8); Paint neutral base texture × NATO green [0.10, 0.12, 0.07].","Textures: shared Cycles-baked base/normal at 1024 px or smaller; packed ORM at 512 px or smaller; physically tiled across large panels.","Poly Haven CC0 ids: green_metal_rust, rusty_metal_03, rubberized_track; licence https://polyhaven.com/license; source https://polyhaven.com/a/<id>.","Differences: compact antenna stubs preserve 2.53 m height; generalized minor fittings and stowage; no insignia, labels or manufacturer marks."]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
print('DONE',json.dumps(meta['bounding_box']),p.stat().st_size,flush=True)
