"""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 bake_tile(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'])
    if name=='Burnt':
        char=node(nt,'ShaderNodeTexNoise');char.inputs['Scale'].default_value=1.25;char.inputs['Detail'].default_value=3;char.inputs['Roughness'].default_value=.72;link(nt,uv.outputs['UV'],char.inputs['Vector'])
        cr=node(nt,'ShaderNodeValToRGB');link(nt,char.outputs['Fac'],cr.inputs[0])
        cr.color_ramp.elements.remove(cr.color_ramp.elements[1])
        e=cr.color_ramp.elements[0];e.position=.20;e.color=(.008,.010,.011,1)
        for position,color in [(.43,(.022,.024,.025,1)),(.64,(.042,.041,.039,1)),(.82,(.052,.035,.023,1)),(.95,(.025,.021,.017,1))]:
            e=cr.color_ramp.elements.new(position);e.color=color
        link(nt,cr.outputs[0],m2.inputs[1]);m2.inputs[0].default_value=.08
    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 512 if name=='Burnt' 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

def material_bake(name,source,tint,rough,metal,normal_strength=.2):
    if name=='Burnt' and not (TEX/'burnt_v3.flag').exists():
        m=bake_tile(name,source,tint,rough,metal,normal_strength);(TEX/'burnt_v3.flag').write_text('charred steel v3');return m
    baked={}
    for kind in ('base','normal','orm'):
        ext='jpg' if kind=='base' and name!='Dust' else 'png'
        path=TEX/f'{name}_{kind}.{ext}'
        if not path.exists(): return bake_tile(name,source,tint,rough,metal,normal_strength)
        baked[kind]=bpy.data.images.load(str(path),check_existing=True)
        if kind!='base':baked[kind].colorspace_settings.name='Non-Color'
    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
    print('LOADED',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=[];self.tags=[];self.tag=None
    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);self.tags.append(self.tag)
            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))
    H.tag='Part_Fender_F'+('L' if s==1 else 'R')
    box(H,(s*1.55,2.93,1.15),(.58,.75,.23),1,.015)
    H.tag=None
    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):
        H.tag=f"Part_Stowage_{'L' if s==1 else 'R'}{j+1}"
        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):
        H.tag=f"Part_Skirt_{'L' if s==1 else 'R'}{j+1}"
        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)
    H.tag=None
    # 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)
    H.tag="Part_LampGuard_"+("L" if s==1 else "R")
    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:]]
    H.tag=None
    # 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)
H.tag='Part_ExhaustGrille'
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)
H.tag=None
# engine deck grilles, service hatch frames and longitudinal cooling louvers
for x in [-.80,.0,.80]:
    H.tag=None
    box(H,(x,-2.64,1.541),(.69,1.08,.045),6,.008)
    H.tag=f"Part_EngineGrille_{x}"
    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)
H.tag=None
# Driver hatch on glacis behind nose. Rotate to deck slope.
H.tag='Hatch_Driver'
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:]]
H.tag=None
# Trailing tow cable draped across rear, clamped down.
for s in [-1,1]:
    H.tag="Part_TowCable_"+("L" if s==1 else "R")
    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)
H.tag=None
# 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]:
    T.tag="Part_Turret_Cheek_"+("L" if s==1 else "R")
    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
    T.tag="Part_Turret_SideBin_"+("L" if s==1 else "R")
    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)
    T.tag="Part_Turret_BustleRack"
    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.
    T.tag="Part_Turret_GALIX_"+("L" if s==1 else "R")
    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)
    T.tag="Part_Turret_BustleRack"
    # 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)
T.tag="Part_Turret_BustleStowage"
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)
T.tag="Part_Turret_Cupola"
# 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)
T.tag=None
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
T.tag="Part_Turret_CommanderSight"
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
T.tag="Part_Turret_GunnerSight"
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:]]
T.tag=None
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 j,x in enumerate([-.74,0,.74],1):
    T.tag=f'Part_Turret_Blowoff_{j}'
    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)
T.tag=None
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)]:
    T.tag="Part_Turret_Antenna_"+("L" if x>0 else "R")
    cyl(T,(x,f,2.21),.064,.11,6,n=24);cyl(T,(x,f,2.40),.015,.24,2,n=16)
T.tag=None
# 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)
G.tag="Barrel"
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'];MG.tag='Part_Turret_MGMount';cyl(MG,(.60,-.50,2.265),.054,.16,6,n=24)
MG.tag=None
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)
TRACK_SPANS={}
TRACK_ZSHIFT={}
# 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))
    TRACK_SPANS[label]=[]
    for j in range(count):
        vstart=len(belt.v)
        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)/perim))
        TRACK_SPANS[label].append((vstart,len(belt.v),f,z,R.copy()))
# Baked feathered dirt trims, offset 2mm from armor (no coplanar surfaces).
for s in [-1,1]:
    for j in range(1,7):
        H.tag=f"Part_Skirt_{'L' if s==1 else 'R'}{j+1}"
        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.tag=f"Part_Skirt_{'L' if s==1 else 'R'}{min(7,max(1,round((2.83-f)/.93)+1))}"
        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)
H.tag=None
# 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);TRACK_ZSHIFT[label]=-zmin;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]
# Retrofit extraction: no vertex is moved; original hull normalization remains identical.
for source in list(B.values()):
    groups={}
    for fi,tag in enumerate(source.tags):
        if tag:groups.setdefault(tag,[]).append(fi)
    for tag,indices in groups.items():
        dest=B.setdefault(tag,Mesh(tag))
        used=sorted(set(k for fi in indices for k in source.f[fi]));offset=len(dest.v);remap={k:offset+j for j,k in enumerate(used)}
        dest.v.extend(source.v[k] for k in used)
        for fi in indices:
            dest.f.append(tuple(remap[k] for k in source.f[fi]));dest.mi.append(source.mi[fi]);dest.uv.append(source.uv[fi]);dest.tags.append(None)
    keep=[i for i,t in enumerate(source.tags) if not t]
    source.f=[source.f[i] for i in keep];source.mi=[source.mi[i] for i in keep];source.uv=[source.uv[i] for i in keep];source.tags=[None]*len(keep)
    # Drop unreferenced vertices so split source bounds and damaged copies stay meaningful.
    used=sorted(set(k for f in source.f for k in f));remap={k:i for i,k in enumerate(used)}
    source.v=[source.v[k] for k in used];source.f=[tuple(remap[k] for k in f) for f in source.f]
# Concealed support skin, mounts and rollers survive removal of every detachable plate.
for side,label in [(1,'L'),(-1,'R')]:
    box(H,(side*1.48,-.1,1.28),(.055,6.15,.38),0,.006)
    for j in range(7):
        f=2.83-j*.93
        for d in [-.28,.28]:
            box(H,(side*1.57,f+d,1.39),(.19,.07,.11),2,.006)
            cyl(H,(side*1.678,f+d,1.40),.025,.018,2,(side,0,0),4)
    for j in range(4):
        w=Mesh(f'Wheel_{label}{j+9}');B[w.name]=w
        cyl(w,(side*1.51,1.9-j*1.26,1.03),.135,.36,1,(1,0,0),10)
        cyl(w,(side*1.70,1.9-j*1.26,1.03),.09,.025,2,(1,0,0),10)
    # Rear mudflaps occupy existing rear fender footprint.
    w=Mesh('Part_Fender_R'+label);B[w.name]=w
    box(w,(side*1.57,-3.30,.83),(.48,.045,.52),1,.008)
# Dark coamings under crew hatches; boolean openings below are cut after finishing.
ring(H,(.54,2.19,1.35),.30,.28,.12,6,n=24)
cyl(H,(.54,2.19,1.18),.30,.04,6,n=24)
ring(T,(.60,.06,2.18),.30,.285,.14,6,n=32)
cyl(T,(.60,.06,1.96),.30,.04,6,n=24)
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),'Barrel':(0,1.765,1.97),'Hatch_Driver':(.54,1.80,1.49)}
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()}
for name,b in B.items():
    if name.startswith('Part_'):pivots[name]=tuple((min(v[i] for v in b.v)+max(v[i] for v in b.v))/2 for i in range(3))
for side,label in [(1,'L'),(-1,'R')]:
    for j in range(4):pivots[f'Wheel_{label}{j+9}']=(side*1.51,1.9-j*1.26,1.03)
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']+[n for n in obs if n.startswith('Part_Turret_')]:
    ob=obs[name];mw=ob.matrix_world.copy();ob.parent=obs['Turret'];ob.matrix_world=mw
ob=obs['Barrel'];mw=ob.matrix_world.copy();ob.parent=obs['Gun'];ob.matrix_world=mw
sockets={'Fx_Engine':((0,-2.65,1.56),'Hull'),'Fx_Ammo':((0,-.6,1.05),'Hull'),'Fx_Hatch':((.60,.06,2.31),'Turret'),'Muzzle':((0,6.43,1.97),'Barrel'),'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()
# Machine real openings into the closed crew hatches' supporting armor.
def normalize_uv(ob):
    if ob.type!='MESH':return
    me=ob.data;uv=me.uv_layers.get('SurfaceUV')
    if uv is None:return
    for other in list(me.uv_layers):
        if other!=uv:me.uv_layers.remove(other)
    me.uv_layers.active_index=0;uv.active_render=True
    for poly in me.polygons:
        if all(uv.data[i].uv.length<1e-9 for i in poly.loop_indices):
            a=max(range(3),key=lambda k:abs(poly.normal[k]));axes=[k for k in range(3) if k!=a]
            for i in poly.loop_indices:
                q=ob.matrix_world@me.vertices[me.loops[i].vertex_index].co;uv.data[i].uv=(q[axes[0]],q[axes[1]])

def boolean_cylinder(ob,center,radius,depth,torn=False):
    # Cut one closed armor solid, never an overlapping assembly of fittings.
    me=ob.data;adj=[[] for _ in me.vertices]
    for e in me.edges:
        a,b=e.vertices;adj[a].append(b);adj[b].append(a)
    seen=set();candidates=[];c=Vector(center)
    for seed in range(len(adj)):
        if seed in seen:continue
        group=[];stack=[seed];seen.add(seed)
        while stack:
            k=stack.pop();group.append(k)
            for q in adj[k]:
                if q not in seen:seen.add(q);stack.append(q)
        points=[ob.matrix_world@me.vertices[k].co for k in group]
        lo=Vector(tuple(min(p[i] for p in points) for i in range(3)));hi=Vector(tuple(max(p[i] for p in points) for i in range(3)))
        intersects=(lo.x<c.x+radius*1.6 and hi.x>c.x-radius*1.6 and lo.y<c.y+radius and hi.y>c.y-radius) if torn else (lo.x<c.x-radius*.8 and hi.x>c.x+radius*.8 and lo.y<c.y-radius*.8 and hi.y>c.y+radius*.8)
        if intersects and hi.z>c.z-depth/2 and lo.z<c.z+depth/2:
            candidates.append(((hi.x-lo.x)*(hi.y-lo.y)*(hi.z-lo.z),set(group)))
    if not candidates:return
    ids=max(candidates,key=lambda item:item[0])[1]
    rest=me.copy();bm=bmesh.new();bm.from_mesh(rest);bm.verts.ensure_lookup_table()
    bmesh.ops.delete(bm,geom=[v for v in bm.verts if v.index in ids],context='VERTS');bm.to_mesh(rest);bm.free()
    bm=bmesh.new();bm.from_mesh(me);bm.verts.ensure_lookup_table()
    bmesh.ops.delete(bm,geom=[v for v in bm.verts if v.index not in ids],context='VERTS');bm.to_mesh(me);bm.free()
    if torn:
        bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,radius=radius,location=center);bpy.context.object.scale=(1.6,1,1.5)
    else:bpy.ops.mesh.primitive_cylinder_add(vertices=32,radius=radius,depth=depth,location=center)
    cut=bpy.context.object;bpy.context.view_layer.update();bpy.context.view_layer.objects.active=ob
    mod=ob.modifiers.new('Machined opening','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cut
    bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
    bm=bmesh.new();bm.from_mesh(ob.data);bm.from_mesh(rest);bm.to_mesh(ob.data);bm.free();bpy.data.meshes.remove(rest)
boolean_cylinder(obs['Hull'],(.54,-2.19,1.49),.278,.56)
boolean_cylinder(obs['Turret'],(.60,-.06,2.30),.282,.62)
boolean_cylinder(obs['Part_Turret_Cupola'],(.60,-.06,2.30),.282,.62)
# 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)
for ob in obs.values():normalize_uv(ob)
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 once around entire loop; 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)

# ==================== tank-damage/v1 retrofit ====================
# Damage geometry derives from actual healthy source meshes, in the same world frame.
print('DAMAGE_BUILD_BEGIN',flush=True)
for ob in obs.values():ob.hide_render=True
Burnt=material_bake('Burnt','rusty_metal_03',(.12,.105,.088),.95,.20,.30)
Scars=material_bake('Scars','rusty_metal_03',(.36,.37,.35),.64,.74,.45)

def reduced_material(src,name):
    m=src.copy();m.name=name
    for n in m.node_tree.nodes:
        if n.type=='TEX_IMAGE' and n.image:
            n.image=n.image.copy()
            if max(n.image.size)>512:n.image.scale(512,512)
            n.image.filepath_raw=str(TEX/(name+'_'+n.label+'.png'));n.image.file_format='PNG';n.image.save()
    return m
DTrack=reduced_material(MATS[3],'DamageTrackSteel')
DRubber=reduced_material(MATS[1],'DamageRubber')
DMATS=[MATS[0],Burnt,Scars,DTrack,DRubber]
damage={}
def parent_keep(ob,parent):
    bpy.context.view_layer.update();mw=ob.matrix_world.copy();ob.parent=parent;ob.matrix_world=mw;bpy.context.view_layer.update()
def damage_empty(name,pos):
    ob=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(ob);ob.location=pos;damage[name]=ob;return ob
HD=damage_empty('Hull_Damage',(0,0,0));TD=damage_empty('Turret_Damage',obs['Turret'].matrix_world.translation.copy())
def remap_damage(ob,burn=False):
    names=[m.name if m else '' for m in ob.data.materials]
    if 'Dust' in names:
        bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.delete(bm,geom=[f for f in bm.faces if names[f.material_index]=='Dust'],context='FACES');bm.to_mesh(ob.data);bm.free()
    inds=[]
    for poly in ob.data.polygons:
        old=names[poly.material_index] if poly.material_index<len(names) else ''
        inds.append(1 if burn else (0 if old=='Paint' else 4 if old=='Rubber' else 3 if old in ('Steel','TrackSteel') else 1))
    ob.data.materials.clear()
    for m in DMATS:ob.data.materials.append(m)
    for poly,idx in zip(ob.data.polygons,inds):poly.material_index=idx

def duplicate(src,name,burn=False):
    ob=src.copy();ob.data=src.data.copy();ob.name=name;bpy.context.collection.objects.link(ob)
    mw=src.matrix_world.copy();ob.parent=None;ob.matrix_world=mw;ob.hide_render=False;ob.hide_viewport=False
    remap_damage(ob,burn);return ob

def decimate_to(ob,target):
    count=sum(len(f.vertices)-2 for f in ob.data.polygons)
    if count>target:
        bpy.context.view_layer.objects.active=ob
        mod=ob.modifiers.new('Game damage budget','DECIMATE');mod.ratio=target/count;mod.use_collapse_triangulate=True
        bpy.ops.object.modifier_apply(modifier=mod.name)

def join_objects(objects,name,pivot):
    bpy.ops.object.select_all(action='DESELECT')
    for ob in objects:ob.select_set(True)
    bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();out=objects[0];out.name=name
    bpy.context.view_layer.update();mw=out.matrix_world.copy()
    out.data.transform(Matrix.Translation(-Vector(pivot))@mw);out.matrix_world=Matrix.Translation(pivot)
    return out

# Bullet/fragment strikes: irregular petalled rims and dark concave cores; real shallow mesh.
def impact_mesh(name,center,normal,radius=.075,elongate=1.0,kind=0):
    normal=Vector(normal).normalized();u=normal.cross(Vector((0,0,1)))
    if u.length<.1:u=normal.cross(Vector((0,1,0)))
    u.normalize();v=normal.cross(u);c=Vector(center)
    # Rotate irregular scars independently so strikes never form repeated round targets.
    turn=(kind*.71+.18);u,v=u*math.cos(turn)+v*math.sin(turn),-u*math.sin(turn)+v*math.cos(turn)
    vs=[];faces=[];mi=[];n=20
    penetration=kind==3
    stretch=elongate if kind==2 else 1.18 if penetration else 1.7
    squash=.32 if kind==2 else .75 if penetration else .43
    rings=[(2.2,.002),(1.04,.003),(1.,.0035),(.69,.004),(.50,.003),(.34,.0028)]
    for row,(rr,depth) in enumerate(rings):
        for i in range(n):
            a=i*math.tau/n
            jag=1+.23*math.sin(i*7.7+kind)+.12*math.sin(i*2.3)
            fan=(1+.50*max(0,math.sin(a+.6))) if row==0 else 1
            r=radius*rr*jag*fan
            d=depth if not penetration else min(.004,depth*(1+.15*math.sin(i*2.8)))
            vs.append(c+u*(r*math.cos(a)*stretch)+v*(r*math.sin(a)*squash)+normal*d)
    for row in range(5):
        for i in range(n):
            j=(i+1)%n;faces.append((row*n+i,row*n+j,(row+1)*n+j,(row+1)*n+i))
            mi.append(1 if row==0 or (penetration and row==4) or (row==1 and i%5<2) else 2)
    faces.append(tuple(5*n+i for i in range(n)));mi.append(1 if penetration else 2)
    me=bpy.data.meshes.new(name);me.from_pydata(vs,[],faces);me.update()
    ob=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(ob)
    for m in DMATS:me.materials.append(m)
    layer=me.uv_layers.new(name='SurfaceUV')
    for p,idx in zip(me.polygons,mi):
        p.material_index=idx
        for li in p.loop_indices:
            co=me.vertices[me.loops[li].vertex_index].co-c;layer.data[li].uv=(co.dot(u)*3+.5,co.dot(v)*3+.5)
    return ob

# Bent/torn replaceable assemblies, each retaining its healthy Part origin.
twin_names=['Part_Skirt_'+side+str(i) for side in ('L','R') for i in (1,2,4,6)]
twin_names+=['Part_Fender_FL','Part_Fender_FR','Part_ExhaustGrille','Part_Stowage_L7','Part_Stowage_R7']
twin_names+=['Part_Turret_Cheek_L','Part_Turret_Cheek_R','Part_Turret_SideBin_L','Part_Turret_GALIX_L','Part_Turret_BustleRack']
for index,name in enumerate(twin_names):
    src=obs[name];ob=duplicate(src,name.replace('Part_','Dmg_',1));damage[ob.name]=ob
    vv=ob.data.vertices;lo=min(v.co.z for v in vv);hi=max(v.co.z for v in vv);span=max(.05,hi-lo)
    side=1 if src.matrix_world.translation.x>=0 else -1
    for vert in vv:
        t=(hi-vert.co.z)/span
        vert.co.x+=side*.19*t*t*(.7+.3*math.sin(vert.co.y*7))
        vert.co.y+=.09*t*t*math.sin(vert.co.x*4+index)
        vert.co.z-=.055*t*t
    # Tear off an irregular lower corner, retaining real thickness in the original plate.
    if 'Skirt' in name:
        bpy.context.view_layer.update();wc=ob.matrix_world.translation
        boolean_cylinder(ob,(wc.x+side*.03,wc.y+.36,wc.z-.22),.20,.60,torn=True)
    for poly in ob.data.polygons:
        if poly.center.z<lo+span*.28 or random.random()<.075:poly.material_index=1
    parent_keep(ob,TD if name.startswith('Part_Turret_') else HD)

# Eight face-specific scar assemblies, each has four visually distinct strikes.
scar_specs={
 'Scar_Hull_Front':([(x,-3.39,.84+.08*i) for i,x in enumerate([-.83,-.32,.28,.83])],(0,-1,0)),
 'Scar_Hull_L':([(1.516,y,1.26) for y in [-1.9,-.65,.5,1.6]],(1,0,0)),
 'Scar_Hull_R':([(-1.516,y,1.26) for y in [-1.9,-.65,.5,1.6]],(-1,0,0)),
 'Scar_Hull_Rear':([(x,3.44,1.21) for x in [-.9,-.3,.3,.9]],(0,1,0)),
 'Scar_Turret_Front':([(x,-1.685,1.83) for x in [-.68,-.43,.43,.68]],(0,-1,0)),
 'Scar_Turret_L':([(1.54,y,1.82) for y in [-.75,-.3,.2,.72]],(1,0,0)),
 'Scar_Turret_R':([(-1.54,y,1.82) for y in [-.75,-.3,.2,.72]],(-1,0,0)),
 'Scar_Turret_Rear':([(x,2.038,1.91) for x in [-.9,-.3,.3,.9]],(0,1,0))}
from mathutils.bvhtree import BVHTree
bv=[];bf=[]
for src in obs.values():
    if src.type!='MESH':continue
    offset=len(bv);bv.extend([src.matrix_world@v.co for v in src.data.vertices]);bf.extend([tuple(offset+k for k in f.vertices) for f in src.data.polygons])
healthy_bvh=BVHTree.FromPolygons(bv,bf)
for name,(positions,normal) in scar_specs.items():
    pieces=[]
    for i,pos in enumerate(positions):
        n=Vector(normal);hit,face_normal,_,_=healthy_bvh.ray_cast(Vector(pos)+n*4,-n,8)
        if hit is None:hit=Vector(pos);face_normal=n
        pieces.append(impact_mesh(name+str(i),hit,face_normal,.045+.012*(i%2),2.6 if i==2 else 1,kind=i))
    ob=join_objects(pieces,name,(0,0,0));damage[name]=ob;parent_keep(ob,TD if 'Turret' in name else HD)

# Broken belts are the same mesh links, with a severed front arc and sagged upper run.
for side,label in [(1,'L'),(-1,'R')]:
    ob=duplicate(obs['Track_'+label],'Track_'+label+'_Broken');damage[ob.name]=ob
    # Preserve all link topology and pad materials while moving each rigid link as a unit.
    for start,end,f,z,R in TRACK_SPANS[label]:
        old=Vector((side*1.51*1.855/xmax,-f,z+TRACK_ZSHIFT[label]))
        nf,nz=f,z+TRACK_ZSHIFT[label];angle=0
        if z>.90:
            # Top links drop onto the suspension; front half sprawls ahead on the ground.
            if f>.15:
                nf=3.05+(f-.15)*.76;nz=.07;angle=0
            else:
                nf=f;nz=.65+.22*(abs(f)/2.86)**1.4;angle=.08*math.sin(f)
        elif f>2.91:
            nf=3.1+(z-.08)*.95;nz=.07;angle=0
        else:continue
        new=Vector((old.x,-nf,nz))
        # Convert the source's local track normal to Blender and turn moved links flat.
        author_to_bl=Matrix(((1,0,0),(0,-1,0),(0,0,1)))
        rot=author_to_bl@R@ author_to_bl
        dest=Matrix.Rotation(angle,3,'X')
        moved=list(range(start,min(end,len(ob.data.vertices))))
        for k in moved:
            ob.data.vertices[k].co=new+dest@rot.transposed()@(ob.data.vertices[k].co-old)
        if nz==.07 and moved:
            bottom=min(ob.data.vertices[k].co.z for k in moved)
            for k in moved:ob.data.vertices[k].co.z-=bottom
    decimate_to(ob,14500);parent_keep(ob,HD)

# Knocked-out gun: original mantlet and barrel copies, drooped with a peeled muzzle split.
gunparts=[duplicate(obs['Gun'],'broken mantlet'),duplicate(obs['Barrel'],'burst barrel')]
bar=gunparts[1];pivot=Vector((0,-1.765,1.97));bp=bar.matrix_world.translation.copy()
for v in bar.data.vertices:
    w=bar.matrix_world@v.co;distance=max(0,-w.y-3.35)
    w.z-=distance*.22
    if -w.y>3.52:
        w.x+=math.copysign(.13*min(1,distance),w.x if abs(w.x)>.001 else .001)
    v.co=bar.matrix_world.inverted()@w
# Trim forward half to form a jagged burst end, add curled steel tongues.
bm=bmesh.new();bm.from_mesh(bar.data)
bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),plane_co=(0,-(4.15-1.765),0),plane_no=(0,-1,0),clear_outer=True)
bm.to_mesh(bar.data);bm.free()
for i in range(8):
    a=i*math.tau/8;ax=math.cos(a);az=math.sin(a)
    # authored Mesh is converted by finish; tongue flares away from bore.
    g=Mesh('burst steel petal')
    vs=[(ax*.14,3.98,1.83+az*.14),(ax*.14-az*.025,3.98,1.83+az*.14+ax*.025),(ax*.29-az*.025,4.34,1.69+az*.29+ax*.025),(ax*.29,4.34,1.69+az*.29)]
    vs+= [(x+ax*.008,f,z+az*.008) for x,f,z in vs[:]]
    g.add(vs,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],2);petal=g.finish();remap_damage(petal);gunparts.append(petal)
gb=join_objects(gunparts,'Gun_Broken',obs['Gun'].matrix_world.translation);damage[gb.name]=gb;parent_keep(gb,TD)
decimate_to(gb,6500)

# Wreck: burnt copies, missing armor, exposed turret well, opened crew hatches, sagged suspension.
skip_wreck={'Part_Skirt_L1','Part_Skirt_L2','Part_Skirt_L4','Part_Skirt_R2','Part_Skirt_R6','Part_Turret_Cheek_L','Part_Turret_SideBin_L','Part_Turret_CommanderSight','Part_Turret_GALIX_L'}
wh=[];wt=[]
for name,src in obs.items():
    if src.type!='MESH' or name in skip_wreck:continue
    is_turret=(name in ['Turret','Gun','Barrel','MG','Hatch_Commander'] or name.startswith('Part_Turret_'))
    ob=duplicate(src,'wreck copy '+name,True)
    if name=='Track_L':
        bpy.data.objects.remove(ob,do_unlink=True);ob=duplicate(damage['Track_L_Broken'],'wreck broken belt',True)
    if name.startswith('Wheel_'):
        # Rubber burns away to the narrower steel wheel skeleton.
        for v in ob.data.vertices:
            v.co.y*=.90;v.co.z*=.90
        ob.location.z-=.05
    if name=='Hatch_Commander' or name=='Hatch_Driver':
        ob.rotation_euler.x=math.radians(-105)
    if name.startswith('Part_EngineGrille'):
        for v in ob.data.vertices:v.co.z+=.24*max(0,1-abs(v.co.x)*2)+.08*math.sin(v.co.y*13)
        ob.rotation_euler.x=.15
    if name.startswith('Part_Skirt') and name.endswith(('3','5')):
        ob.rotation_euler.y=.23
    if name in ('Gun','Barrel'):
        mat=Matrix.Translation(obs['Gun'].matrix_world.translation)@Matrix.Rotation(math.radians(13),4,'X')@Matrix.Translation(-obs['Gun'].matrix_world.translation)
        ob.matrix_world=mat@ob.matrix_world
    (wt if is_turret else wh).append(ob)
WH=join_objects(wh,'Wreck_Hull',(0,0,0));damage[WH.name]=WH
# Open, black-lined turret ring: the exposed well reads as a real burnt cavity.
boolean_cylinder(WH,(0,.12,1.42),1.015,.78)
oldMATS=MATS;MATS=DMATS
well=Mesh('burnt turret well');ring(well,(0,-.12,1.26),1.045,.99,.37,1,n=64);cyl(well,(0,-.12,.99),1.00,.055,1,n=64)
wellob=well.finish();WH=join_objects([WH,wellob],'Wreck_Hull',(0,0,0));damage['Wreck_Hull']=WH
WT=join_objects(wt,'Wreck_Turret',obs['Turret'].matrix_world.translation);damage[WT.name]=WT
basket=Mesh('closed turret underside and basket stub');cyl(basket,(0,-.12,1.565),1.06,.09,1,n=64);ring(basket,(0,-.12,1.36),.78,.71,.34,1,n=48);cyl(basket,(0,-.12,1.18),.78,.06,1,n=48)
for a in range(8):
    t=a*math.tau/8;tube(basket,(.76*math.cos(t),-.12+.76*math.sin(t),1.2),(.76*math.cos(t),-.12+.76*math.sin(t),1.56),.025,1,10)
basketob=basket.finish();WT=join_objects([WT,basketob],'Wreck_Turret',obs['Turret'].matrix_world.translation);damage['Wreck_Turret']=WT
MATS=oldMATS
for wreck in (WH,WT):
    burnt_slot=next(i for i,m in enumerate(wreck.data.materials) if m.name=='Burnt')
    for face in wreck.data.polygons:face.material_index=burnt_slot
decimate_to(WH,39500);decimate_to(WT,17500)
# Everything is a damage child except the four contracted roots.
for name,ob in damage.items():ob.hide_render=False
bpy.context.view_layer.update()

def export_glb(path,objects):
    for ob in objects:normalize_uv(ob)
    bpy.ops.object.select_all(action='DESELECT')
    for o in objects:o.hide_set(False);o.select_set(True)
    bpy.context.view_layer.objects.active=next(o for o in objects if o.type=='MESH')
    bpy.ops.export_scene.gltf(filepath=str(path),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)
    raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);tail=raw[20+jl:]
    for m in doc.get('materials',[]):
        if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
        if m['name']=='DamageTrackSteel':m['name']='TrackSteel'
        if m['name']=='DamageRubber':m['name']='Rubber'
    enc=json.dumps(doc,separators=(',',':')).encode();enc+=b' '*((-len(enc))%4)
    path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(enc)+len(tail))+struct.pack('<II',len(enc),0x4e4f534a)+enc+tail)
    return doc

docB=export_glb(ROOT/'leclerc-tank-damage.glb',list(damage.values()))
for n in docB['nodes']:
    if n['name'] in ('Wreck_Hull','Wreck_Turret'):
        assert all(docB['materials'][p['material']]['name']=='Burnt' for p in docB['meshes'][n['mesh']]['primitives'])
damage_tri=sum(len(f.vertices)-2 for o in damage.values() if o.type=='MESH' for f in o.data.polygons)
assert damage_tri<=110000,damage_tri
assert {damage[n].name for n in damage if damage[n].parent is None}=={'Hull_Damage','Turret_Damage','Wreck_Hull','Wreck_Turret'}
meta['damage']={'contract':'tank-damage/v1','parts':[{'part':n,'twin':n.replace('Part_','Dmg_',1) if n.replace('Part_','Dmg_',1) in damage else None} for n in obs if n.startswith('Part_')],'scars':[n for n in damage if n.startswith('Scar_')],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret'],'triangles':damage_tri}
meta['file']='leclerc-tank.glb'
meta['damage']['file']='leclerc-tank-damage.glb'
damage_points=[o.matrix_world@v.co for o in damage.values() if o.type=='MESH' for v in o.data.vertices]
damage_min=[min(p.x for p in damage_points),min(p.z for p in damage_points),min(-p.y for p in damage_points)]
damage_max=[max(p.x for p in damage_points),max(p.z for p in damage_points),max(-p.y for p in damage_points)]
meta['damage']['bounding_box']={'min':damage_min,'max':damage_max,'size':[b-a for a,b in zip(damage_min,damage_max)]}
meta['damage_nodes']=[]
for n,o in damage.items():
    q=o.matrix_world.translation
    meta['damage_nodes'].append({'name':n,'type':'mesh' if o.type=='MESH' else 'empty','parent':o.parent.name if o.parent else None,'world_position':[round(q.x,6),round(q.z,6),round(-q.y,6)]})
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
print('DAMAGE_EXPORTED',damage_tri,flush=True)

# PROOF is rendered from BOTH serialized GLBs, imported into a genuinely empty scene.
for o in list(bpy.data.objects):bpy.data.objects.remove(o,do_unlink=True)
for ng in list(bpy.data.node_groups):
    if 'glTF' in ng.name:bpy.data.node_groups.remove(ng,do_unlink=True)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'leclerc-tank.glb'))
healthy={o.name:o for o in bpy.context.scene.objects}
bpy.ops.import_scene.gltf(filepath=str(ROOT/'leclerc-tank-damage.glb'))
dmg={o.name:o for o in bpy.context.scene.objects if o.name not in healthy}
for o in list(healthy.values())+list(dmg.values()):o.rotation_mode='XYZ'
S.world=world
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.006));floor=bpy.context.object;floor.data.materials.append(fm)
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('Proof Camera');cam=bpy.data.objects.new('Proof Camera',camd);bpy.context.collection.objects.link(cam);S.camera=cam
camera(hero,13.2);S.render.resolution_x=768;S.render.resolution_y=432
canvas=np.zeros((864,2304,4),dtype=np.float32)
wt_matrix=dmg['Wreck_Turret'].matrix_world.copy()
for state in range(6):
    for o in healthy.values():o.hide_render=state>=4
    for o in dmg.values():o.hide_render=True
    dmg['Wreck_Turret'].matrix_world=wt_matrix
    if state==1:
        for n,o in dmg.items():
            if n.startswith('Scar_'):o.hide_render=False
        for n in ['Part_Skirt_L1','Part_Skirt_L2','Part_Skirt_R2','Part_Fender_FL','Part_Turret_Cheek_L','Part_Turret_GALIX_L']:
            healthy[n].hide_render=True;dmg[n.replace('Part_','Dmg_',1)].hide_render=False
    elif state==2:
        for n,o in healthy.items():
            if n.startswith('Part_'):o.hide_render=True
    elif state==3:
        healthy['Track_L'].hide_render=True;healthy['Gun'].hide_render=True;healthy['Barrel'].hide_render=True
        dmg['Track_L_Broken'].hide_render=False;dmg['Gun_Broken'].hide_render=False
    elif state>=4:
        dmg['Wreck_Hull'].hide_render=False;dmg['Wreck_Turret'].hide_render=False
        if state==5:
            o=dmg['Wreck_Turret'];o.location=(3.65,.65,1.0);o.rotation_euler=(0,math.radians(178),math.radians(12));bpy.context.view_layer.update()
            bottom=min((o.matrix_world@v.co).z for v in o.data.vertices);o.location.z-=bottom
    render(f'state_{state}.png',20)
    im=bpy.data.images.load(str(ROOT/f'state_{state}.png'),check_existing=False)
    ar=np.array(im.pixels[:],dtype=np.float32).reshape(432,768,4);row=1-state//3;col=state%3
    canvas[row*432:(row+1)*432,col*768:(col+1)*768]=ar;bpy.data.images.remove(im)
out=bpy.data.images.new('Damage states 3x2',width=2304,height=864);out.pixels.foreach_set(canvas.ravel());out.filepath_raw=str(ROOT/'states.png');out.file_format='PNG';out.save()
# Rig proof: thirty-degree articulation, pivots stay fixed under their own rotations.
for o in healthy.values():o.hide_render=False
for o in dmg.values():o.hide_render=True
saved={}
for n,o in healthy.items():
    if n in ('Turret','Gun','MG') or n.startswith('Wheel_') or n.startswith('Hatch_'):
        saved[n]=o.rotation_euler.copy();before=o.matrix_world.translation.copy()
        o.rotation_euler[2 if n in ('Turret','MG') else 0]+=math.radians(-30 if n=='Gun' or n.startswith('Hatch_') else 30)
        bpy.context.view_layer.update();assert (o.matrix_world.translation-before).length<1e-5,n
render('rig.png',16)
for n,r in saved.items():healthy[n].rotation_euler=r
notes[0]='Retrofit of the existing Leclerc-style asset: detachable armor/fittings, recoil barrel, crew openings, damage twins, impact scars, broken tracks and burnt wrecks.'
notes[1]=f'Healthy: {triangles:,} triangles, {(ROOT/"leclerc-tank.glb").stat().st_size/1048576:.2f} MiB; damage: {damage_tri:,} triangles, {(ROOT/"leclerc-tank-damage.glb").stat().st_size/1048576:.2f} MiB.'
notes[2]='Healthy materials unchanged (8); damage uses Paint, Burnt, Scars, TrackSteel, Rubber. Paint factor and all six original paints preserved.'
notes[3]='Original Cycles-baked shared textures reused: base <=1024px, ORM <=512px; new Burnt/Scars baked with Cycles OptiX; damage textures <=512px except Paint.'
notes[5]='Reference differences retained: antenna stubs, generalized fittings; original asset has no external fuel drums. Added concealed mounts/crew wells; states and articulation verified from exports.'
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
print('RETROFIT_DONE',triangles,damage_tri,flush=True)
