"""M1A2-style neutral-faction game tank. Clean build: Blender -b --python-exit-code 1 --python this.py.
Authoring axes: X left, -Y forward, Z up. glTF conversion produces X left/Y up/Z forward.
Cycles OPTIX tiled material bakes. Sources CC0 https://polyhaven.com/license.
"""
import bpy, bmesh, math, os, json, random, subprocess, struct, time
from mathutils import Vector, Matrix
from math import sin, cos, pi
from pathlib import Path
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
random.seed(17)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.unit_settings.scale_length=1
sc.render.engine='CYCLES';sc.cycles.samples=24;sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
sc.cycles.device='GPU'
sc.render.image_settings.file_format='PNG';sc.render.film_transparent=False
sc.view_settings.view_transform='AgX';sc.view_settings.exposure=-.55
PAINTS=[('Desert tan',[.45,.36,.22]),('Olive drab',[.17,.19,.085]),('NATO green',[.105,.155,.095]),('Field grey',[.23,.26,.235]),('Winter white',[.79,.81,.76]),('Dark green-black',[.038,.05,.034])]
MATN=['Paint','Rubber','Steel','Tracks','Glass','Lamps','Weapons']
MATS=[]

def download(asset,mp):
    f=TEX/f'{asset}_{mp}_1k.jpg'
    if not f.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mp}_1k.jpg'
        subprocess.run(['curl','-fLsS','--retry','3',url,'-o',str(f)],check=True)
    return bpy.data.images.load(str(f),check_existing=True)

def bake_material(name,asset,base,rough,metal,res=512):
    print('BAKE',name,flush=True)
    m=bpy.data.materials.new(name);m.use_nodes=True
    nt=m.node_tree;n=nt.nodes;l=nt.links;n.clear()
    out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
    uv=n.new('ShaderNodeTexCoord')
    diff=n.new('ShaderNodeTexImage');diff.image=download(asset,'diff');l.new(uv.outputs['UV'],diff.inputs['Vector'])
    nor=n.new('ShaderNodeTexImage');nor.image=download(asset,'nor_gl');nor.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],nor.inputs['Vector'])
    arm=n.new('ShaderNodeTexImage');arm.image=download(asset,'arm');arm.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],arm.inputs['Vector'])
    gray=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],gray.inputs[0])
    ramp=n.new('ShaderNodeValToRGB');l.new(gray.outputs[0],ramp.inputs[0])
    # Neutral undercoat with scanned speckle and oxidized wear. Paint alone receives a tint at runtime.
    for e,factor in zip(ramp.color_ramp.elements,[.78,1.0]):e.color=tuple(min(1,c*factor) for c in base)+(1,)
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=32;noise.inputs['Detail'].default_value=4;l.new(uv.outputs['UV'],noise.inputs['Vector'])
    grime=n.new('ShaderNodeValToRGB');grime.color_ramp.elements[0].position=.22;grime.color_ramp.elements[0].color=(.38,.38,.38,1);grime.color_ramp.elements[1].position=.48
    l.new(noise.outputs['Fac'],grime.inputs[0])
    mul=n.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=.55;l.new(ramp.outputs[0],mul.inputs[1]);l.new(grime.outputs[0],mul.inputs[2])
    if name=='Paint':
        stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(22,1.8,1);l.new(uv.outputs['UV'],stretch.inputs[0])
        streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=3.;streak.inputs['Detail'].default_value=3;l.new(stretch.outputs[0],streak.inputs[0])
        sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.28;sr.color_ramp.elements[0].color=(.57,.57,.57,1);sr.color_ramp.elements[1].position=.58;l.new(streak.outputs['Fac'],sr.inputs[0])
        sm=n.new('ShaderNodeMixRGB');sm.blend_type='MULTIPLY';sm.inputs[0].default_value=.33;l.new(mul.outputs[0],sm.inputs[1]);l.new(sr.outputs[0],sm.inputs[2]);mul=sm
    if name=='Burnt':
        charnoise=n.new('ShaderNodeTexNoise');charnoise.inputs['Scale'].default_value=4.4;charnoise.inputs['Detail'].default_value=2;charnoise.inputs['Roughness'].default_value=.72;l.new(uv.outputs['UV'],charnoise.inputs['Vector'])
        charramp=n.new('ShaderNodeValToRGB');l.new(charnoise.outputs['Fac'],charramp.inputs[0])
        cr=charramp.color_ramp;cr.elements.remove(cr.elements[1]);cr.elements[0].position=.25;cr.elements[0].color=(.008,.007,.006,1)
        for pos,col in [(.40,(.016,.015,.014,1)),(.58,(.028,.026,.023,1)),(.74,(.065,.055,.043,1)),(.90,(.11,.105,.096,1))]:cr.elements.new(pos).color=col
        mul=charramp
    l.new(mul.outputs[0],bs.inputs['Base Color'])
    sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
    rmap=n.new('ShaderNodeMapRange');rmap.inputs['To Min'].default_value=max(.1,rough-.12);rmap.inputs['To Max'].default_value=min(1,rough+.1);l.new(sep.outputs[1],rmap.inputs['Value']);l.new(rmap.outputs[0],bs.inputs['Roughness'])
    bs.inputs['Metallic'].default_value=metal
    normal=n.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.12 if name=='Burnt' else (.16 if name=='Paint' else .35);l.new(nor.outputs[0],normal.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.18;bump.inputs['Distance'].default_value=.0004 if name=='Paint' else .0012;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(normal.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
    bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.data.materials.append(m)
    target=n.new('ShaderNodeTexImage');n.active=target
    emission=n.new('ShaderNodeEmission')
    maps={}
    def bake(channel,size,kind='EMIT',source=None):
        cached=TEX/(name+'_'+channel+('.jpg' if channel=='basecolor' else '.png'))
        if cached.exists():
            im=bpy.data.images.load(str(cached),check_existing=True)
            if channel!='basecolor':im.colorspace_settings.name='Non-Color'
            maps[channel]=im
            return
        im=bpy.data.images.new(name+'_'+channel,width=size,height=size,alpha=False)
        if channel!='basecolor':im.colorspace_settings.name='Non-Color'
        target.image=im;n.active=target
        if kind=='EMIT':
            l.new(source,emission.inputs['Color']);l.new(emission.outputs[0],out.inputs[0])
        else:l.new(bs.outputs[0],out.inputs[0])
        sc.cycles.samples=8;sc.render.bake.margin=8
        bpy.ops.object.bake(type=kind)
        im.filepath_raw=str(TEX/(name+'_'+channel+('.jpg' if channel=='basecolor' else '.png')));im.file_format='JPEG' if channel=='basecolor' else 'PNG';im.save();maps[channel]=im
    bake('basecolor',res,source=mul.outputs[0])
    bake('normal',res,kind='NORMAL')
    comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1.;comb.inputs[2].default_value=metal;l.new(rmap.outputs[0],comb.inputs[1])
    bake('orm',512,source=comb.outputs[0])
    bpy.data.objects.remove(plane,do_unlink=True)
    # Only portable baked textures remain in the shipping shader.
    n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
    texnodes={}
    for k,im in maps.items():
        t=n.new('ShaderNodeTexImage');t.image=im;t.label='Cycles baked '+k;texnodes[k]=t
    tint=n.new('ShaderNodeMixRGB');tint.name='PaintFactor';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1
    tint.inputs[2].default_value=tuple(PAINTS[0][1] if name=='Paint' else [1,1,1])+(1,)
    l.new(texnodes['basecolor'].outputs[0],tint.inputs[1]);l.new(tint.outputs[0],bs.inputs['Base Color'])
    normal=n.new('ShaderNodeNormalMap');l.new(texnodes['normal'].outputs[0],normal.inputs[0 if False else 'Color']);l.new(normal.outputs[0],bs.inputs['Normal'])
    sep=n.new('ShaderNodeSeparateColor');l.new(texnodes['orm'].outputs[0],sep.inputs[0]);l.new(sep.outputs[1],bs.inputs['Roughness']);l.new(sep.outputs[2],bs.inputs['Metallic'])
    # Recognized exporter node group carries packed occlusion.
    g=bpy.data.node_groups.get('glTF Material Output')
    if not g:
        g=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');g.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=n.new('ShaderNodeGroup');gn.node_tree=g;l.new(sep.outputs[0],gn.inputs['Occlusion'])
    if name=='Glass':bs.inputs['Metallic'].default_value=.4
    if name=='Lamps':bs.inputs['Emission Color'].default_value=(.5,.44,.3,1);bs.inputs['Emission Strength'].default_value=.16
    MATS.append(m)

# Shared tiles, 1 metre on large surfaces. No unique low-density full-vehicle atlas.
for args in [
 ('Paint','metal_plate_02',(1,1,1),.82,0,1024),
 ('Rubber','rubberized_track',(.052,.048,.041),.92,0,512),
 ('Steel','metal_plate_02',(.24,.255,.25),.53,.78,1024),
 ('Tracks','rusty_metal_03',(.115,.10,.076),.78,.63,1024),
 ('Glass','metal_plate_02',(.015,.065,.076),.18,.4,256),
 ('Lamps','metal_plate_02',(.75,.73,.62),.26,.12,256),
 ('Weapons','metal_plate_02',(.043,.048,.045),.6,.73,512)]:bake_material(*args)

# Mesh templates are bevelled before assembly; rigid groups remain single named nodes.
GROUP={};CACHE={}
def group(name):
    if name not in GROUP:GROUP[name]={'v':[],'f':[],'m':[],'s':[],'uv':[]}
    return GROUP[name]
def template(v,f,bevel=0,smooth=False):
    bm=bmesh.new()
    vv=[bm.verts.new(co) for co in v]
    for face in f:bm.faces.new([vv[i] for i in face])
    bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
    if bevel:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=bevel,segments=1,affect='EDGES',clamp_overlap=True)
    bm.verts.ensure_lookup_table();bm.verts.index_update();bm.faces.ensure_lookup_table()
    v=[tuple(x.co) for x in bm.verts];f=[tuple(x.index for x in face.verts) for face in bm.faces]
    ss=[smooth and len(face.verts)==4 for face in bm.faces]
    bm.free();return v,f,ss

def add(g,tmp,loc=(0,0,0),rot=None,mat=0,uvmode=None):
    data=group(g);off=len(data['v']);v,f,sm=tmp
    R=rot or Matrix.Identity(3);pos=Vector(loc)
    verts=[R@Vector(p)+pos for p in v];data['v'].extend(tuple(p) for p in verts)
    for face,smooth in zip(f,sm):
        data['f'].append(tuple(i+off for i in face));data['m'].append(mat);data['s'].append(smooth)
        a,b,c=[Vector(v[i]) for i in face[:3]];normal=(b-a).cross(c-a);axis=max(range(3),key=lambda k:abs(normal[k]));axes=[k for k in range(3) if k!=axis]
        if uvmode is None:
            data['uv'].append([(v[i][axes[0]]+loc[axes[0]]*.371,v[i][axes[1]]+loc[axes[1]]*.371) for i in face])
        else:data['uv'].append([uvmode(Vector(v[i]),verts[i]) for i in face])

def box(g,loc,size,mat=0,b=.007,rot=None,uvmode=None):
    key=('box',tuple(size),b)
    if key not in CACHE:
        x,y,z=[a/2 for a in size]
        CACHE[key]=template([(-x,-y,-z),(x,-y,-z),(x,y,-z),(-x,y,-z),(-x,-y,z),(x,-y,z),(x,y,z),(-x,y,z)],[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],b)
    add(g,CACHE[key],loc,rot,mat,uvmode)

def cyl(g,loc,r,depth,mat=0,axis=(0,0,1),n=32,r2=None):
    # bevelled cylinder as a lathe, cap and lip are real geometry
    r2=r if r2 is None else r2;bw=min(.005,depth*.12,r*.15)
    prof=[(-depth/2,0),(-depth/2,r-bw),(-depth/2+bw,r),(depth/2-bw,r2),(depth/2,r2-bw),(depth/2,0)]
    lathe(g,loc,prof,mat,axis,n)

def lathe(g,loc,profile,mat=0,axis=(0,0,1),n=48):
    key=('lathe',tuple(profile),n)
    if key not in CACHE:
        v=[];rings=[];f=[];smooth=[]
        for z,r in profile:
            if r==0:rings.append([len(v)]);v.append((0,0,z))
            else:
                rings.append(list(range(len(v),len(v)+n)));v.extend((r*cos(i*2*pi/n),r*sin(i*2*pi/n),z) for i in range(n))
        for A,B in zip(rings,rings[1:]):
            for i in range(n):
                if len(A)==1:face=(A[0],B[(i+1)%n],B[i]);s=False
                elif len(B)==1:face=(A[i],A[(i+1)%n],B[0]);s=False
                else:face=(A[i],A[(i+1)%n],B[(i+1)%n],B[i]);s=True
                f.append(face);smooth.append(s)
        CACHE[key]=(v,f,smooth)
    R=Vector(axis).to_track_quat('Z','Y').to_matrix()
    add(g,CACHE[key],loc,R,mat,lambda p,w:(math.atan2(p.y,p.x)/(2*pi),p.z))

def rod(g,a,b,r=.015,mat=0,n=12):
    a=Vector(a);b=Vector(b);cyl(g,(a+b)/2,r,(b-a).length,mat,b-a,n)

def poly(g,v,f,mat=0,b=.01):add(g,template(v,f,b),mat=mat)

def prism(g,points,zbottom,ztop,mat=0,b=.012):
    n=len(points);v=[(x,y,zbottom) for x,y in points]+[(x,y,ztop) for x,y in points]
    f=[tuple(reversed(range(n))),tuple(range(n,n*2))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
    poly(g,v,f,mat,b)

def sidepoly(g,x,points,thick,mat=0,b=.009):
    n=len(points);v=[(x-thick/2,y,z) for y,z in points]+[(x+thick/2,y,z) for y,z in points]
    f=[tuple(reversed(range(n))),tuple(range(n,n*2))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
    poly(g,v,f,mat,b)

def torus(g,loc,major,minor,mat=0,axis=(0,0,1),n=48,k=8):
    key=('torus',major,minor,n,k)
    if key not in CACHE:
        v=[((major+minor*cos(j*2*pi/k))*cos(i*2*pi/n),(major+minor*cos(j*2*pi/k))*sin(i*2*pi/n),minor*sin(j*2*pi/k)) for i in range(n) for j in range(k)]
        f=[(i*k+j,((i+1)%n)*k+j,((i+1)%n)*k+(j+1)%k,i*k+(j+1)%k) for i in range(n) for j in range(k)]
        CACHE[key]=(v,f,[True]*len(f))
    add(g,CACHE[key],loc,Vector(axis).to_track_quat('Z','Y').to_matrix(),mat)

def bolt(g,loc,axis=(0,0,1),r=.018,mat=2):cyl(g,loc,r,.014,mat,axis,6)
def handle(g,a,b,height=.045,mat=0):
    a=Vector(a);b=Vector(b);up=Vector((0,0,height));rod(g,a,a+up,.009,mat);rod(g,a+up,b+up,.009,mat);rod(g,b+up,b,.009,mat)

# HULL: genuine sloped glacis and lower nose, shoulders, raised rear powerpack.
sections=[(-3.965,1.37,.89,1.20),(-3.43,1.50,.43,1.30),(-2.35,1.48,.40,1.44),(2.48,1.48,.40,1.44),(3.78,1.47,.53,1.48),(3.965,1.38,.60,1.46)]
v=[]
for y,w,zlo,zhi in sections:v.extend([(-w*.83,y,zlo),(w*.83,y,zlo),(w,y,zhi),(-w,y,zhi)])
f=[(3,2,1,0),(20,21,22,23)]+[(i*4+j,i*4+(j+1)%4,(i+1)*4+(j+1)%4,(i+1)*4+j) for i in range(len(sections)-1) for j in range(4)]
poly('Hull',v,f,b=.023)
# upper fenders and six skirt segments (height leaves wheel faces readable)
for s in [-1,1]:
    box(f'Part_Fender_F{"L" if s>0 else "R"}',(s*1.54,-.39,1.405),(.58,6.86,.085),b=.015)
    box(f'Part_Fender_R{"L" if s>0 else "R"}',(s*1.54,3.47,1.405),(.58,.86,.085),b=.015)
    sidepoly(f'Part_Skirt_{"L" if s>0 else "R"}1',s*1.785,[(-3.89,1.36),(-3.47,1.44),(-2.72,1.40),(-2.72,.78),(-3.38,.73)],.09)
    for i in range(6):
        y=-2.72+(i+.5)*1.055
        box(f'Part_Skirt_{"L" if s>0 else "R"}{i+2}',(s*1.78,y,1.073),(.10,1.045,.652),b=.009)
        for yy in [y-.475,y+.475]:
            box(f'Part_Skirt_{"L" if s>0 else "R"}{i+2}',(s*1.837,yy,1.33),(.025,.049,.16),mat=0,b=.003)
            for z in [1.28,1.38]:bolt(f'Part_Skirt_{"L" if s>0 else "R"}{i+2}',(s*1.855,yy,z),(s,0,0),.012)
        # lower skirt latch on alternating plates
        if i%2==0:
            box(f'Part_Skirt_{"L" if s>0 else "R"}{i+2}',(s*1.837,y+.39,.81),(.028,.075,.052),mat=2,b=.003)
    # forward flexible mud guard and protected headlights
    sidepoly(f'Part_Fender_F{"L" if s>0 else "R"}',s*1.58,[(-3.92,1.21),(-3.97,1.07),(-3.67,1.03),(-3.56,1.23)],.49,1,.008)
    box('Hull',(s*1.22,-3.59,1.34),(.34,.18,.13),b=.018)
    box('Hull',(s*1.22,-3.685,1.343),(.24,.016,.074),1,b=.012)
    for xx in [s*1.22-.073,s*1.22+.073]:cyl('Hull',(xx,-3.70,1.347),.036,.014,5,(0,-1,0),24)
    for xx in [s*1.22-.19,s*1.22+.19]:rod(f'Part_HeadlightGuard_{"L" if s>0 else "R"}',(xx,-3.77,1.30),(xx,-3.77,1.46),.018)
    rod(f'Part_HeadlightGuard_{"L" if s>0 else "R"}',(s*1.22-.19,-3.77,1.46),(s*1.22+.19,-3.77,1.46),.018)
    # towing lugs and shackles
    for yy,z in [(-3.65,.85),(3.78,.78)]:
        box('Hull',(s*1.11,yy,z),(.115,.13,.17),b=.025)
        torus('Hull',(s*1.11,yy+(-.07 if yy<0 else .10),z-.07),.09,.022,2,(1,0,0),24,8)
    # suspension arms behind road wheels
    for y in [-2.48+i*.827 for i in range(7)]:rod('Hull',(s*1.28,y+.22,.75),(s*1.50,y,.49),.065,2,16)
    # glacis weld bead and cable hangers
    rod('Hull',(s*1.46,-3.22,1.327),(s*1.46,-1.25,1.456),.008,0,8)
    for y in [-2.5,-1.6,2.3,3.1]:handle('Hull',(s*1.58,y-.075,1.452),(s*1.58,y+.075,1.452),.04)
# Driver hatch and three prism sights
prism('Hatch_Driver',[(-.4,-2.28),(.4,-2.28),(.46,-1.58),(-.46,-1.58)],1.443,1.485,b=.022)
handle('Hatch_Driver',(-.18,-2.04,1.49),(.02,-2.04,1.49),.028)
for x in [-.27,0,.27]:
    box('Hull',(x,-1.62,1.49),(.24,.17,.09),b=.008)
    box('Hull',(x,-1.71,1.506),(.186,.013,.05),4,b=.003)
# engine deck: two gridded intake beds, hinges, louvered turbine exhaust rear
for x in [-.72,.72]:
    box(f'Part_EngineGrille_{"L" if x>0 else "R"}',(x,2.88,1.49),(1.22,1.35,.04),2,b=.008)
    for j in range(18):box(f'Part_EngineGrille_{"L" if x>0 else "R"}',(x,2.28+j*.072,1.518),(1.19,.028,.033),0,b=.003)
    for xx in [x-.6,x+.6]:box(f'Part_EngineGrille_{"L" if x>0 else "R"}',(xx,2.88,1.532),(.035,1.35,.022),b=.003)
    for yy in [2.27,3.46]:box(f'Part_EngineGrille_{"L" if x>0 else "R"}',(x,yy,1.532),(1.22,.04,.024),b=.003)
    handle(f'Part_EngineGrille_{"L" if x>0 else "R"}',(x-.16,3.59,1.49),(x+.16,3.59,1.49),.028)
for x in [-.87,0,.87]:
    box(f'Part_RearGrille_{int(round(x/.87))+2}',(x,3.956,1.01),(.77,.022,.56),2,b=.011)
    for i in range(10):box(f'Part_RearGrille_{int(round(x/.87))+2}',(x,3.981,.78+i*.046),(.72,.045,.021),0,b=.003)
    for xx in [x-.395,x+.395]:box(f'Part_RearGrille_{int(round(x/.87))+2}',(xx,3.99,1.01),(.027,.045,.59),b=.003)
for s in [-1,1]:
    cyl(f'Part_TailLight_{"L" if s>0 else "R"}',(s*1.27,3.977,1.31),.08,.025,1,(0,1,0),24)
    cyl(f'Part_TailLight_{"L" if s>0 else "R"}',(s*1.27,3.998,1.31),.051,.018,5,(0,1,0),24)
# WHEELS (front idler, seven paired road wheels, rear drive sprocket)
for s,label in [(1,'L'),(-1,'R')]:
    for idx,y in enumerate([-3.10]+[-2.48+i*.827 for i in range(7)]+[3.10],1):
        g=f'Wheel_{label}{idx}';z=.79 if idx in [1,9] else .49;r=.445 if idx in [1,9] else .405
        x=s*1.48
        # twin tire shoulders, central gap, dish, lip, hub caps
        for dx in [-.125,.125]:
            lathe(g,(x+s*dx,y,z),[(-.1025,r*.77),(-.1025,r*.96),(-.088,r),(.088,r),(.1025,r*.96),(.1025,r*.77),(-.1025,r*.77)],2 if idx==9 else 1,(s,0,0),40)
            lathe(g,(x+s*(dx+.092),y,z),[(-.012,0),(-.012,r*.84),(.003,r*.91),(.018,r*.91),(.028,r*.83),(.026,r*.69),(-.018,r*.50),(.012,r*.27),(.018,0)],2 if idx==9 else 0,(s,0,0),40)
        cyl(g,(x+s*.249,y,z),r*.21,.056,2,(s,0,0),32)
        cyl(g,(x+s*.28,y,z),r*.14,.018,0,(s,0,0),24)
        for j in range(10):
            a=j*2*pi/10;bolt(g,(x+s*.236,y+cos(a)*r*.48,z+sin(a)*r*.48),(s,0,0),.017,2)
        if idx==9:
            for j in range(16):
                a=j*2*pi/16
                box(g,(x+s*.11,y+sin(a)*r,z+cos(a)*r),(.36,.095,.095),2,b=.009,rot=Matrix.Rotation(-a,3,'X'))
        if idx in [1,9]:
            for j in range(8):
                a=j*pi/4;cyl(g,(x+s*.235,y+sin(a)*r*.65,z+cos(a)*r*.65),.045,.013,2,(s,0,0),12)
# TRACK BELTS: continuous loop of connected double-pad steel shoes.
# Capsule path parameter s maps to V; local X maps to U on every link and connector.
# Sloping lower runs wrap the higher idlers/sprockets, as on the reference.
path=[(-2.48,.060),(2.48,.060),(2.66,.095),(3.10+.5*cos(-pi/4),.79+.5*sin(-pi/4))]
for i in range(1,25):
    a=-pi/4+i*(3*pi/4)/24;path.append((3.10+.5*cos(a),.79+.5*sin(a)))
path.append((-3.10,1.29))
for i in range(1,25):
    a=pi/2+i*(3*pi/4)/24;path.append((-3.10+.5*cos(a),.79+.5*sin(a)))
path.extend([(-2.66,.095),(-2.48,.060)])
lengths=[math.hypot(b[0]-a[0],b[1]-a[1]) for a,b in zip(path,path[1:])];length=sum(lengths);N=92

def trackpoint(t):
    t%=length
    for a,b,seg in zip(path,path[1:],lengths):
        if t<=seg:
            q=t/seg;return a[0]+q*(b[0]-a[0]),a[1]+q*(b[1]-a[1]),math.atan2(b[1]-a[1],b[0]-a[0])
        t-=seg
    return path[0][0],path[0][1],0
for s,label in [(1,'L'),(-1,'R')]:
    g='Track_'+label
    for i in range(N):
        t=(i+.5)*length/N;y,z,a=trackpoint(t);rot=Matrix.Rotation(a,3,'X')
        # local +Y tangent and local -Z outward on bottom
        uv=lambda p,w,tt=t:(p.x/.64+.5,(tt+p.y)/length)
        box(g,(s*1.48,y,z),(.625,length/N+.002,.086),3,b=.009,rot=rot,uvmode=uv)
        for dx in [-.159,.159]:
            p=Vector((s*1.48,y,z))+rot@Vector((dx,0,-.056))
            box(g,p,(.272,length/N-.032,.036),1,b=.010,rot=rot,uvmode=uv)
        for dx in [-.341,.341]:
            p=Vector((s*1.48,y,z))+rot@Vector((dx,0,0))
            box(g,p,(.064,.113,.095),3,b=.009,rot=rot,uvmode=uv)
        # inward guide tooth, visible at the curving ends
        p=Vector((s*1.48,y,z))+rot@Vector((0,0,.071))
        add(g,template([(-.025,-.04,-.035),(.025,-.04,-.035),(.025,.04,-.035),(-.025,.04,-.035),(-.015,0,.055),(.015,0,.055)],[(0,3,2,1),(0,1,5,4),(1,2,5),(2,3,4,5),(3,0,4)],.002),p,rot,2,uv)

# TURRET bearing and low angular armour. Ring axis is y=.12.
cyl('Turret',(0,.12,1.50),1.12,.105,2,n=96)
cyl('Turret',(0,.12,1.54),1.18,.045,0,n=96)
# footprint: broad cheeks; sloped upper perimeter, not stacked boxes
bottom=[(-.40,-1.94),(.40,-1.94),(1.38,-1.77),(1.52,-.65),(1.41,1.63),(1.07,2.03),(-1.07,2.03),(-1.41,1.63),(-1.52,-.65),(-1.38,-1.77)]
top=[(-.40,-1.69),(.40,-1.69),(1.09,-1.53),(1.28,-.58),(1.24,1.59),(.99,1.92),(-.99,1.92),(-1.24,1.59),(-1.28,-.58),(-1.09,-1.53)]
v=[(x,y,1.61) for x,y in bottom]+[(x,y,2.095 if y<0 else 2.07) for x,y in top]
f=[tuple(reversed(range(10))),tuple(range(10,20))]+[(i,(i+1)%10,(i+1)%10+10,i+10) for i in range(10)]
poly('Turret',v,f,b=.021)
# Weld seams along the chamfer between cheek and side armour.
for s in [-1,1]:
    rod('Turret',(s*1.385,-1.76,1.635),(s*1.10,-1.53,2.07),.007,2,8)
    rod('Turret',(s*.405,-1.938,1.635),(s*.405,-1.70,2.075),.006,0,8)
# armour cheek seams and lower edge trim
for s in [-1,1]:
    rod('Turret',(s*.52,-1.915,1.64),(s*1.34,-1.32,1.64),.013,0)
    rod('Turret',(s*1.45,-.51,1.61),(s*1.33,1.59,1.61),.016,2)
    # six-tube smoke grenade launchers on each shoulder, tilted outward/front
    box(f'Part_Turret_Smoke_{"L" if s>0 else "R"}',(s*1.26,-.58,2.017),(.22,.29,.085),b=.014)
    for j in range(6):
        p=(s*(1.29+(j%3)*.058),-.64+(j//3)*.096,2.045+(j//3)*.055)
        axis=Vector((s*.45,-.30,.78)).normalized()
        cyl(f'Part_Turret_Smoke_{"L" if s>0 else "R"}',p,.033,.19,0,axis,16)
        cap=Vector(p)+axis*.102;cyl(f'Part_Turret_Smoke_{"L" if s>0 else "R"}',cap,.025,.008,6,axis,16)
    # side storage boxes and framing, bustle rail spaced off hull
    box(f'Part_Turret_Bin_{"L" if s>0 else "R"}',(s*1.416,.89,1.928),(.14,1.86,.34),b=.015)
    for yy in [.02,.68,1.38,1.82]:
        rod(f'Part_Turret_Bin_{"L" if s>0 else "R"}',(s*1.52,yy,1.72),(s*1.52,yy,2.135),.016)
        rod(f'Part_Turret_Bin_{"L" if s>0 else "R"}',(s*1.36,yy,1.74),(s*1.54,yy,1.74),.016)
    for zz in [1.735,1.96,2.135]:rod(f'Part_Turret_Bin_{"L" if s>0 else "R"}',(s*1.52,-.03,zz),(s*1.52,1.92,zz),.018)
    # spare cable secured along turret edge
    for j in range(2):
        points=[(s*1.45,-.4,1.66+j*.025),(s*1.47,.3,1.66+j*.025),(s*1.40,1.62,1.69+j*.025),(s*1.1,1.97,1.73+j*.025)]
        for a,b in zip(points,points[1:]):rod(f'Part_Turret_TowCable_{"L" if s>0 else "R"}',a,b,.013,2,10)
    # side access plate and hinges
    box(f'Part_Turret_Access_{"L" if s>0 else "R"}',(s*1.398,-.78,1.85),(.065,.33,.19),b=.008)
    for yy in [-.89,-.67]:bolt(f'Part_Turret_Access_{"L" if s>0 else "R"}',(s*1.438,yy,1.88),(s,0,0),.012)
# bustle basket floor and open rear tubular rack
box('Part_Turret_BustleRack',(0,2.12,1.78),(2.88,.59,.06),b=.015)
for x in [-1.43,-.95,-.48,0,.48,.95,1.43]:
    rod('Part_Turret_BustleRack',(x,2.38,1.78),(x,2.38,2.15),.016)
    rod('Part_Turret_BustleRack',(x,1.91,1.82),(x,2.38,1.82),.013)
for zz in [1.82,1.98,2.15]:rod('Part_Turret_BustleRack',(-1.43,2.38,zz),(1.43,2.38,zz),.018)
for s in [-1,1]:
    for zz in [1.82,1.98,2.15]:rod('Part_Turret_BustleRack',(s*1.43,1.85,zz),(s*1.43,2.38,zz),.018)
# basket mesh rods coarse real lattice, no alpha cards
for j in range(27):rod('Part_Turret_BustleRack',(-1.38+j*.106,1.94,1.821),(-1.38+j*.106,2.35,1.821),.005,2,6)
# rear bustle boxes, roof blowout panels, fasteners
for x in [-.65,.65]:
    box(f'Part_Turret_Blowout_{"L" if x>0 else "R"}',(x,1.15,2.097),(1.15,1.08,.035),b=.010)
    for xx in [x-.50,x+.50]:
        for yy in [.68,1.60]:bolt(f'Part_Turret_Blowout_{"L" if x>0 else "R"}',(xx,yy,2.123),r=.012)
    handle(f'Part_Turret_Blowout_{"L" if x>0 else "R"}',(x-.10,1.52,2.12),(x+.10,1.52,2.12),.026)
# Cupola with multiple framed observation blocks
cyl('Part_Turret_Cupola',(-.61,.18,2.14),.385,.10,0,n=64)
for i in range(8):
    a=i*2*pi/8
    p=(-.61+.354*cos(a),.18+.354*sin(a),2.198)
    box('Part_Turret_Cupola',p,(.18,.076,.095),0,b=.008,rot=Matrix.Rotation(a-pi/2,3,'Z'))
    p=(-.61+.396*cos(a),.18+.396*sin(a),2.203)
    box('Part_Turret_Cupola',p,(.13,.012,.049),4,b=.003,rot=Matrix.Rotation(a-pi/2,3,'Z'))
cyl('Hatch_Commander',(-.61,.18,2.258),.315,.047,0,n=64)
torus('Hatch_Commander',(-.61,.18,2.28),.27,.009,2,n=48,k=6)
handle('Hatch_Commander',(-.72,.14,2.287),(-.49,.14,2.287),.033)
rod('Part_Turret_Cupola',(-.93,.46,2.252),(-.35,.46,2.252),.025,0,20)
# Loader hatch
cyl('Turret',(.63,.47,2.12),.31,.065,0,n=48)
cyl('Hatch_Loader',(.63,.47,2.16),.27,.035,0,n=48)
handle('Hatch_Loader',(.52,.48,2.184),(.75,.48,2.184),.036)
# Commander independent thermal viewer and gunner primary sight armored housing
cyl('Part_Turret_CommanderSight',(.60,-.50,2.16),.185,.16,0,n=40)
box('Part_Turret_CommanderSight',(.60,-.50,2.265),(.30,.28,.13),b=.028)
box('Part_Turret_CommanderSight',(.60,-.651,2.27),(.20,.018,.083),4,b=.007)
box('Part_Turret_GunnerSight',(-.57,-1.05,2.139),(.43,.30,.13),b=.008)
box('Part_Turret_GunnerSight',(-.57,-1.206,2.149),(.33,.017,.082),6,b=.004)
for x in [-.67,-.49]:box('Part_Turret_GunnerSight',(x,-1.218,2.153),(.132,.012,.061),4,b=.004)
# Low antenna bases/stowed antenna whips to respect exact transport height
for s in [-1,1]:
    cyl(f'Part_Turret_Antenna_{"L" if s>0 else "R"}',(s*1.02,1.64,2.15),.055,.13,1,n=20)
    cyl(f'Part_Turret_Antenna_{"L" if s>0 else "R"}',(s*1.02,1.64,2.29),.011,.20,2,n=12)
# Remote weapon station supported cradle, ammo box, receiver and long barrel
cyl('Part_Turret_WeaponStation',(-.57,.39,2.315),.115,.065,0,n=32)
box('MG',(-.57,.39,2.374),(.17,.21,.11),0,b=.015)
box('MG',(-.57,.20,2.404),(.095,.51,.072),6,b=.007)
box('MG',(-.77,.36,2.382),(.24,.27,.107),0,b=.009)
rod('MG',(-.57,-.07,2.411),(-.57,-.63,2.411),.019,6,24)
cyl('MG',(-.57,-.17,2.411),.031,.15,6,(0,-1,0),24)
cyl('MG',(-.57,-.64,2.411),.025,.05,6,(0,-1,0),24)
for j in range(6):
    cyl('MG',(-.57,-.125-j*.021,2.435),.008,.006,2,(0,0,1),8)
# GUN: trunnion mantlet + thermal sleeves and bore evacuator, truly hollow muzzle
box('Gun',(0,-1.77,1.826),(.70,.36,.38),0,b=.037)
cyl('Gun',(0,-1.972,1.826),.212,.20,0,(0,-1,0),64)
# lathe along forward -Y; axial positions relative to trunnion
profile=[(0,.165),(.10,.173),(.50,.157),(.55,.160),(.61,.160),(.66,.148),(1.15,.144),(1.20,.182),(1.28,.189),(1.83,.189),(1.93,.15),(2.03,.144),(3.54,.105),(3.60,.111),(3.70,.111),(3.725,.102),(3.725,.060),(3.40,.060)]
lathe('Barrel',(0,-2.080,1.826),profile,0,(0,-1,0),64)
# internal bore dark cylinder, recessed; outer steel muzzle collar
lathe('Barrel',(0,-5.805,1.826),[(0,.102),(-.055,.109),(-.13,.109),(-.13,.062),(0,.062)],2,(0,-1,0),64)
lathe('Barrel',(0,-5.805,1.826),[(-.005,.071),(-.36,.071),(-.36,0)],6,(0,-1,0),64)
for y,r in [(-2.64,.162),(-3.22,.15),(-4.02,.16),(-4.14,.15),(-5.60,.113)]:
    torus('Barrel',(0,y,1.826),r,.009,2,(0,1,0),64,6)
box('Barrel',(0,-5.64,1.954),(.095,.13,.025),6,b=.004)
box('Barrel',(0,-5.64,1.981),(.060,.08,.028),2,b=.004)
# Coaxial opening and armoured flap next to main mantlet
cyl('Turret',(.48,-1.842,1.915),.038,.025,6,(0,-1,0),24)
box('Turret',(-.39,-1.53,2.055),(.19,.21,.037),0,b=.004)

# Real optics framing, trim and mounting screws.
for z,r in [(2.098,.22),(2.138,.20),(2.222,.174)]:
    torus('Part_Turret_CommanderSight',(.60,-.50,z),r,.012,0,n=40,k=6)
for xx in [.485,.715]:
    box('Part_Turret_CommanderSight',(xx,-.668,2.27),(.018,.035,.117),0,b=.003)
    bolt('Part_Turret_CommanderSight',(xx,-.687,2.303),(0,-1,0),.009)
for zz in [2.212,2.328]:box('Part_Turret_CommanderSight',(.60,-.670,zz),(.25,.046,.020),0,b=.004)
box('Part_Turret_CommanderSight',(.60,-.686,2.27),(.196,.008,.076),6,b=.004)
for xx,rr in [(.55,.031),(.646,.020)]:
    cyl('Part_Turret_CommanderSight',(xx,-.695,2.27),rr,.01,4,(0,-1,0),24)
for xx in [-.76,-.38]:
    box('Part_Turret_GunnerSight',(xx,-1.222,2.153),(.025,.051,.108),0,b=.003)
for zz in [2.095,2.207]:box('Part_Turret_GunnerSight',(-.57,-1.223,zz),(.41,.052,.020),0,b=.003)
# Armoured protective discharger backplates with framed circular tube mouths.
for side in [-1,1]:
    axis=Vector((side*.45,-.30,.78)).normalized();rot=axis.to_track_quat('Z','Y').to_matrix()
    pos=Vector((side*1.33,-.57,2.077))
    box(f'Part_Turret_Smoke_{"L" if side>0 else "R"}',pos,(.245,.215,.045),0,b=.012,rot=rot)
    for j in range(6):
        p=Vector((side*(1.29+(j%3)*.058),-.64+(j//3)*.096,2.045+(j//3)*.055))+axis*.105
        torus(f'Part_Turret_Smoke_{"L" if side>0 else "R"}',p,.029,.006,2,axis,16,6)
# RWS yoke, feed box strapping, ejection port and charging handle.
for x in [-.65,-.49]:
    rod('MG',(x,.36,2.33),(x,.22,2.408),.018,0,12)
    cyl('MG',(x,.21,2.40),.029,.03,2,(1,0,0),16)
for yy in [.26,.46]:box('MG',(-.77,yy,2.385),(.246,.018,.11),2,b=.002)
box('MG',(-.622,.15,2.405),(.008,.10,.026),2,b=.002)
rod('MG',(-.63,.12,2.396),(-.69,.12,2.396),.010,6,10)
for j in range(7):
    cyl('MG',(-.68,.24+j*.017,2.426),.008,.052,2,(1,0,0),8)
# Open basket lattice and bracket gussets: structure, not a floating railing.
for x in [-1.42,1.42]:
    for yy in [.0,.70,1.40]:
        rod('Turret',(x,yy,1.74),(x*.88,yy,1.91),.017,0,10)
for i in range(23):
    x=-1.36+i*.12
    rod('Part_Turret_BustleRack',(x,2.386,1.83),(min(1.42,x+.28),2.386,2.13),.0045,2,6)
    rod('Part_Turret_BustleRack',(x,2.386,2.13),(min(1.42,x+.28),2.386,1.83),.0045,2,6)
# Restrained exposed-steel chipping at skirt lower lips and high-contact fasteners.
for side in [-1,1]:
    for i in range(43):
        y=random.uniform(-2.64,3.54);z=random.choice([.758,1.382])+random.uniform(-.008,.008)
        box(f'Part_Skirt_{"L" if side>0 else "R"}{min(7,max(2,int((y+2.72)/1.055)+2))}',(side*1.832,y,z),(.0015,random.uniform(.008,.035),random.uniform(.002,.007)),2,b=0)
# Front glacis anti-slip access pads, carrying neutral paint and close-scale texture.
for side in [-1,1]:
    box('Hull',(side*.86,-2.83,1.382),(.48,.70,.006),0,b=.001,rot=Matrix.Rotation(math.atan(.14/1.08),3,'X'))

# Retrofit-only hidden structure and service fittings.
for side,label in [(1,'L'),(-1,'R')]:
    for i,y in enumerate([-2.0,-.65,.7,2.05],10):
        cyl(f'Wheel_{label}{i}',(side*1.46,y,1.105),.16,.32,1,(1,0,0),24)
        cyl(f'Wheel_{label}{i}',(side*1.635,y,1.105),.10,.025,2,(1,0,0),20)
    for y in [-2.6,-1.6,-.55,.5,1.55,2.6,3.5]:
        box('Hull',(side*1.52,y,1.27),(.17,.13,.18),0,b=.008)
        bolt('Hull',(side*1.615,y,1.30),(side,0,0),.024)
    # Protected rear fender backing, lamp mounting boss and engine door recess.
    box('Hull',(side*1.48,3.43,1.34),(.35,.70,.045),2,b=.008)
    box('Hull',(side*1.27,3.94,1.31),(.20,.018,.19),0,b=.008)
    # Mounting bosses remain when turret equipment is discarded.
    for y,z in [(-.58,1.998),(.25,1.86),(1.35,1.86)]:
        box('Turret',(side*1.30,y,z),(.055,.10,.045),0,b=.005)
        bolt('Turret',(side*1.335,y,z),(side,0,0),.013)
# Wind sensor mast fits the established transport-height envelope.
cyl('Part_Turret_WindSensor',(0,1.80,2.17),.021,.16,2,n=16)
rod('Part_Turret_WindSensor',(-.065,1.80,2.235),(.065,1.80,2.235),.009,2,10)
# Stowage in existing bustle basket, kept below the top rail.
for x in [-.92,0,.92]:
    box('Part_Turret_BustleRack',(x,2.12,1.92),(.64,.34,.22),0,b=.023)
    for xx in [x-.19,x+.19]:box('Part_Turret_BustleRack',(xx,2.12,2.034),(.024,.345,.014),2,b=.003)

# Build rigid mesh nodes, apply all transforms, preserve intended articulation origins.
OBJECTS={}
pivots={'Hull':(0,0,0),'Turret':(0,.12,1.50),'Gun':(0,-1.77,1.826),'Hatch_Commander':(-.61,.46,2.252),'MG':(-.57,.39,2.315),'Track_L':(0,0,0),'Track_R':(0,0,0)}
pivots.update({'Barrel':(0,-2.080,1.826),'Hatch_Driver':(0,-1.58,1.465),'Hatch_Loader':(.63,.73,2.16)})
for s,label in [(1,'L'),(-1,'R')]:
    for i,y in enumerate([-2.0,-.65,.7,2.05],10):pivots[f'Wheel_{label}{i}']=(s*1.46,y,1.105)
    for i,y in enumerate([-3.10]+[-2.48+j*.827 for j in range(7)]+[3.10],1):pivots[f'Wheel_{label}{i}']=(s*1.48,y,.79 if i in [1,9] else .49)
for name,d in GROUP.items():
    origin=Vector(pivots.get(name,tuple(sum(v[k] for v in d['v'])/len(d['v']) for k in range(3))));mesh=bpy.data.meshes.new(name+'_geometry');mesh.from_pydata([Vector(v)-origin for v in d['v']],[],d['f']);mesh.update()
    ob=bpy.data.objects.new(name,mesh);sc.collection.objects.link(ob);ob.location=origin
    for m in MATS:mesh.materials.append(m)
    uv=mesh.uv_layers.new(name='UV0')
    for p,ma,sm,coords in zip(mesh.polygons,d['m'],d['s'],d['uv']):
        p.material_index=ma;p.use_smooth=sm
        for li,co in zip(p.loop_indices,coords):uv.data[li].uv=co
    # Remove collapsed bevel corners and explicitly triangulate for stable runtime topology.
    bm=bmesh.new();bm.from_mesh(mesh)
    bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=0.000001)
    bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=0.000001)
    bmesh.ops.triangulate(bm,faces=list(bm.faces))
    bad=[f for f in bm.faces if f.calc_area()<1e-11]
    if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
    bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(mesh);bm.free()
    if name.startswith(('Track_','Wheel_')):
        ob.location.x*=.97
        for v in mesh.vertices:v.co.x*=.97
    OBJECTS[name]=ob
# all top surfaces/pads contact through positive overlaps, lowest track surface normalized to 0
# Track dimensions define x limits +-1.853; side skirts will be compressed to exact 3.66 width.
allverts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
# dependency graph refresh before world bounds
bpy.context.view_layer.update()
minz=min((o.matrix_world@v.co).z for o in OBJECTS.values() for v in o.data.vertices)
maxz=max((o.matrix_world@v.co).z for o in OBJECTS.values() for v in o.data.vertices)
maxx=max(abs((o.matrix_world@v.co).x) for o in OBJECTS.values() for v in o.data.vertices)
scale_x=1.83/maxx;scale_z=2.44/(maxz-minz)
def convert_point(p):
    x,y,z=p
    if y>3.6:y=3.6+(y-3.6)*(3.965-3.6)/(4.0125-3.6)
    return Vector((x*scale_x,y,(z-minz)*scale_z))
for ob in OBJECTS.values():
    old=ob.location.copy();new=convert_point(old)
    for v in ob.data.vertices:v.co=convert_point(old+v.co)-new
    ob.location=new
    if ob.name in ['Hull','Track_L','Track_R']:
        for v in ob.data.vertices:v.co+=ob.location
        ob.location=(0,0,0)
# Named empty sockets in Blender coordinates; converted on export.
def parent_keep(ob,parent):
    w=ob.matrix_world.copy();ob.parent=parent;ob.matrix_world=w
bpy.context.view_layer.update()
for n in ['Gun','Hatch_Commander','Hatch_Loader','MG']+[n for n in OBJECTS if n.startswith('Part_Turret_')]:parent_keep(OBJECTS[n],OBJECTS['Turret'])
parent_keep(OBJECTS['Barrel'],OBJECTS['Gun'])
parent_keep(OBJECTS['Hatch_Driver'],OBJECTS['Hull'])
# MG retains its original direct Turret parent and yaw pivot.
for n in OBJECTS:
    if n.startswith('Part_') and not n.startswith('Part_Turret_'):parent_keep(OBJECTS[n],OBJECTS['Hull'])
SOCKETS={}
for n,p,parent in [('Muzzle',(0,-5.805,1.826),'Barrel'),('Fx_Engine',(0,2.88,1.52),'Hull'),('Fx_Ammo',(0,.8,.9),'Hull'),('Fx_Hatch',(-.61,.18,2.29),'Turret'),('Seat_Commander',(-.61,.18,2.30),'Turret'),('Seat_Driver',(0,-1.89,1.49),'Hull'),('Exhaust',(0,3.99,1.04),'Hull'),('Light_Head_L',(1.22,-3.70,1.347),'Hull'),('Light_Head_R',(-1.22,-3.70,1.347),'Hull')]:
    e=bpy.data.objects.new(n,None);sc.collection.objects.link(e);e.location=convert_point(p);bpy.context.view_layer.update();parent_keep(e,OBJECTS[parent]);SOCKETS[n]=e
bpy.context.view_layer.update()
assets=list(OBJECTS.values())+list(SOCKETS.values())

# --- tank-damage/v1 retrofit -------------------------------------------------
# Generation is deterministic and begins with factory settings above. Cached
# v1 bakes are deliberately reused byte-for-byte; absent maps bake via Cycles.
def active_only(ob):
    bpy.ops.object.select_all(action='DESELECT');ob.select_set(True);bpy.context.view_layer.objects.active=ob

def boolean_cut(ob,center,radius,depth,axis=(0,0,1)):
    bpy.ops.mesh.primitive_cylinder_add(vertices=40,radius=radius,depth=depth,location=center)
    cut=bpy.context.object;cut.rotation_euler=Vector(axis).to_track_quat('Z','Y').to_euler()
    active_only(ob);mod=ob.modifiers.new('Real 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)

def extra(name,parent=None,origin=None):
    d=GROUP.pop(name);origin=Vector(origin or (0,0,0))
    me=bpy.data.meshes.new(name+'_geometry');me.from_pydata([convert_point(v)-origin for v in d['v']],[],d['f']);me.update()
    ob=bpy.data.objects.new(name,me);sc.collection.objects.link(ob);ob.location=origin
    for m in MATS:me.materials.append(m)
    uv=me.uv_layers.new(name='UV0')
    for p,mi,sm,coords in zip(me.polygons,d['m'],d['s'],d['uv']):
        p.material_index=mi;p.use_smooth=sm
        for li,co in zip(p.loop_indices,coords):uv.data[li].uv=co
    detail=me.uv_layers.new(name='SurfaceDetail')
    for a,b in zip(detail.data,uv.data):a.uv=b.uv
    me.uv_layers.active=uv
    if parent:
        bpy.context.view_layer.update();parent_keep(ob,parent)
    return ob

def cleanup(ob,ratio=1):
    active_only(ob)
    if ratio<1:
        mod=ob.modifiers.new('Budget preserving silhouette','DECIMATE');mod.ratio=ratio
        bpy.ops.object.modifier_apply(modifier=mod.name)
    bm=bmesh.new();bm.from_mesh(ob.data)
    bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=1e-6)
    bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=1e-6)
    bmesh.ops.triangulate(bm,faces=list(bm.faces))
    bad=[f for f in bm.faces if f.calc_area()<1e-11]
    if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
    bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(ob.data);bm.free()

def merge_into(target,others):
    # Object.join keeps the target's original rig pivot and world transform.
    bpy.ops.object.select_all(action='DESELECT');target.select_set(True)
    for ob in others:ob.select_set(True)
    bpy.context.view_layer.objects.active=target;bpy.ops.object.join()

# Isolate the connected armored body before cutting the ring. Booleans on a
# joined assembly of overlapping wheels, brackets and plates can erase faces.
def cut_connected_body(ob,center,radius,depth):
    bm=bmesh.new();bm.from_mesh(ob.data);bm.verts.ensure_lookup_table()
    remaining=set(bm.verts);components=[]
    while remaining:
        seed=remaining.pop();comp={seed};queue=[seed]
        while queue:
            v=queue.pop()
            for edge in v.link_edges:
                other=edge.other_vert(v)
                if other in remaining:remaining.remove(other);comp.add(other);queue.append(other)
        components.append(comp)
    def volume(comp):
        return math.prod(max(v.co[k] for v in comp)-min(v.co[k] for v in comp) for k in range(3))
    core=max(components,key=volume)
    coreids={v.index for v in core}
    fittings=ob.copy();fittings.data=ob.data.copy();fittings.name='_preserved_fittings';sc.collection.objects.link(fittings)
    world=ob.matrix_world.copy();fittings.parent=None;fittings.matrix_world=world
    bmesh.ops.delete(bm,geom=[v for v in bm.verts if v not in core],context='VERTS')
    bm.to_mesh(ob.data);bm.free()
    bm=bmesh.new();bm.from_mesh(fittings.data);bm.verts.ensure_lookup_table()
    bmesh.ops.delete(bm,geom=[v for v in bm.verts if v.index in coreids],context='VERTS')
    bm.to_mesh(fittings.data);bm.free()
    boolean_cut(ob,center,radius,depth)
    merge_into(ob,[fittings])

# Genuine crew wells: closed inner floor, cylindrical wall, dark depth, lip.
for suffix,x,y,topz,radius,base in [('Commander',-.61,.18,2.258,.275,1.77),('Loader',.63,.47,2.16,.235,1.74),('Driver',0,-1.94,1.46,.32,.92)]:
    body=OBJECTS['Hull' if suffix=='Driver' else 'Turret']
    center=convert_point((x,y,(topz+base)/2))
    boolean_cut(body,center,radius*scale_x,(topz-base+.08)*scale_z)
    if suffix=='Commander':cut_connected_body(OBJECTS['Part_Turret_Cupola'],center,radius*scale_x,(topz-base+.08)*scale_z)
    name='_well_'+suffix
    lathe(name,(x,y,0),[(base,0),(base,radius-.008),(topz-.012,radius-.008),(topz-.012,radius+.025),(base-.025,radius+.025),(base-.025,0)],6,n=48)
    torus(name,(x,y,topz-.012),radius+.013,.014,2,n=48,k=6)
    well=extra(name)
    centerline=convert_point((x,y,0))
    for face in well.data.polygons:
        radial=face.center-centerline;radial.z=0
        if face.normal.dot(radial)>0 or face.normal.z>.5:face.material_index=0
    merge_into(body,[well])
# Reserve the healthy budget without removing assembly details.
healthy_meshes=list(OBJECTS.values())
for ob in healthy_meshes:cleanup(ob)
tri=lambda obs:sum(len(p.vertices)-2 for o in obs if o.type=='MESH' for p in o.data.polygons)
count=tri(healthy_meshes)
if count>159900:
    candidates=[o for o in healthy_meshes if o.name.startswith(('Track_','Wheel_'))]
    ratio=max(.80,1-(count-159600)/tri(candidates))
    for o in candidates:cleanup(o,ratio)
# Preserve a single loop UV for track animation while retaining original tiled detail.
for ob in healthy_meshes:
    for stale in list(ob.data.uv_layers):
        if stale.name!='UV0':ob.data.uv_layers.remove(stale)
    layer=ob.data.uv_layers.new(name='SurfaceDetail')
    old=ob.data.uv_layers['UV0']
    for a,b in zip(layer.data,old.data):a.uv=b.uv
    if ob.name.startswith('Track_'):
        for a in layer.data:a.uv.y*=length/.33
    ob.data.uv_layers.active=old
for m in MATS:
    nt=m.node_tree;uv=nt.nodes.new('ShaderNodeUVMap');uv.uv_map='SurfaceDetail'
    for node in nt.nodes:
        if node.type=='TEX_IMAGE':nt.links.new(uv.outputs['UV'],node.inputs['Vector'])

# Restore v1 shared road-wheel geometry (nodes and pivots remain independent).
for side in ['L','R']:
    master=OBJECTS[f'Wheel_{side}2'].data
    for i in range(3,9):OBJECTS[f'Wheel_{side}{i}'].data=master

# glTF patch is limited to the documented paint factor and axis-aligned empties.
def export_glb(filename,obs):
    # Joined well meshes can add an unused UV layer. Normalize slot ordering
    # so glTF exports ONE material per surface, including one recolorable Paint.
    seen=set()
    for ob in obs:
        if ob.type!='MESH' or ob.data.as_pointer() in seen:continue
        seen.add(ob.data.as_pointer());me=ob.data
        first=me.uv_layers.get('UV0') or (me.uv_layers[0] if me.uv_layers else None)
        detail=me.uv_layers.get('SurfaceDetail') or first
        a=[tuple(v.uv) for v in first.data] if first else [(0,0)]*len(me.loops)
        b=[tuple(v.uv) for v in detail.data] if detail else a
        while me.uv_layers:me.uv_layers.remove(me.uv_layers[0])
        for label,coords in [('UV0',a),('SurfaceDetail',b)]:
            layer=me.uv_layers.new(name=label)
            for v,uv in zip(layer.data,coords):v.uv=uv
        me.uv_layers.active_index=0
    bpy.ops.object.select_all(action='DESELECT')
    for ob in obs:ob.hide_set(False);ob.select_set(True)
    bpy.ops.export_scene.gltf(filepath=str(ROOT/filename),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
    path=ROOT/filename;raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];j=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
    for m in j.get('materials',[]):
        if m.get('name')=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1.]
    for node in j['nodes']:
        if node['name']=='Muzzle':node.pop('rotation',None)
    payload=json.dumps(j,separators=(',',':')).encode();payload+=b' '*(-len(payload)%4)
    path.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(payload)+len(binary))+struct.pack('<I4s',len(payload),b'JSON')+payload+binary)
    print('EXPORTED',filename,tri(obs),'triangles',path.stat().st_size,'bytes',flush=True)
    return j

bpy.context.view_layer.update()
healthy=list(OBJECTS.values())+list(SOCKETS.values())
export_glb('abrams-tank.glb',healthy)
# Checkpoint is a usable healthy export before any damage construction.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'healthy-checkpoint.blend'))
if '--healthy-only' in __import__('sys').argv:raise SystemExit(0)

# Two extra shared baked surface sets. Burnt includes scan-driven corrosion,
# soot and pitting; Scars is the exposed steel beneath blasted paint.
bake_material('Burnt','rusty_metal_03',(.075,.069,.060),.94,.28,512)
bake_material('Scars','metal_plate_02',(.28,.29,.29),.64,.72,512)
BURNT=MATS[-2];SCARS=MATS[-1]
tracksteel=MATS[3].copy();tracksteel.name='TrackSteel'
for node in tracksteel.node_tree.nodes:
    if node.type=='TEX_IMAGE':
        im=node.image.copy()
        if max(im.size)>512:im.scale(512,512)
        im.filepath_raw=str(TEX/('Damage_'+node.image.name+'.png'));im.file_format='PNG';im.save();node.image=im

def empty(name,pos):
    ob=bpy.data.objects.new(name,None);sc.collection.objects.link(ob);ob.location=pos;return ob
bpy.context.view_layer.update()
hroot=empty('Hull_Damage',(0,0,0));troot=empty('Turret_Damage',OBJECTS['Turret'].matrix_world.translation.copy())
DAMAGE=[hroot,troot];TWINS={}
def duplicate(src,name,parent=None):
    ob=src.copy();ob.data=src.data.copy();ob.name=name;sc.collection.objects.link(ob)
    w=src.matrix_world.copy();ob.parent=None;ob.matrix_world=w
    if parent:bpy.context.view_layer.update();parent_keep(ob,parent)
    return ob

def damage_materials(ob,burn=False):
    for i,m in enumerate(ob.data.materials):
        ob.data.materials[i]=BURNT if burn else (m if m and m.name in ['Paint','Rubber'] else tracksteel if m and m.name=='Tracks' else SCARS)
    if burn:
        for p in ob.data.polygons:p.material_index=0
        ob.data.materials.clear();ob.data.materials.append(BURNT)

def scorch_patches(ob):
    # Coherent irregular char patches rather than randomly colored triangles.
    bi=len(ob.data.materials);ob.data.materials.append(BURNT)
    for p in ob.data.polygons:
        co=p.center
        if sin(co.y*6+co.z*4)+cos(co.z*9-co.x*5)>.48:p.material_index=bi

hparts=[n for n in OBJECTS if n.startswith('Part_') and not n.startswith('Part_Turret')]
tparts=[n for n in OBJECTS if n.startswith('Part_Turret_')]
# Full set of plate twins + fenders/grilles; at least 8 hull and 3 turret.
chosen=[n for n in hparts if any(k in n for k in ['Skirt_','Fender_R','RearGrille','EngineGrille','TailLight'])]
chosen += [n for n in tparts if any(k in n for k in ['Bin_','BustleRack','Smoke_','CommanderSight','GunnerSight'])]
for k,name in enumerate(chosen):
    src=OBJECTS[name];ob=duplicate(src,'Dmg_'+name[5:],troot if name.startswith('Part_Turret') else hroot)
    damage_materials(ob)
    # World-aligned displacement bends the original component without moving its pivot.
    for v in ob.data.vertices:
        p=v.co
        if 'Skirt' in name:
            weight=max(0,min(1,(.28-p.z)/.65));p.x+=(1 if src.matrix_world.translation.x>0 else -1)*weight*(.11+.08*sin(p.y*5+k));p.z+=.045*sin(p.y*7)*weight
        else:
            p.z+=.065*sin(p.y*4+p.x*3)*max(.2,abs(p.y));p.x+=.025*sin(p.z*9+p.y*2)
    if 'Skirt' in name:
        # Real shot-through opening and raised torn metal petals.
        c=src.matrix_world.translation+Vector((0,.04,-.08))
        boolean_cut(ob,c,.075,.35,(1,0,0))
        # Curled steel around the actual opening, attached to the bent plate.
        bm=bmesh.new();bm.from_mesh(ob.data)
        sign=1 if src.matrix_world.translation.x>0 else -1
        si=next(i for i,m in enumerate(ob.data.materials) if m==SCARS)
        outer=[];inner=[];back=[];segs=20
        for j in range(segs):
            a=2*pi*j/segs;rr=.112*(1+.08*sin(5*a+.3))
            start=Vector((sign*.9,.04+rr*cos(a),-.08+rr*sin(a)))
            hit,point,normal,fi=ob.ray_cast(start,Vector((-sign,0,0)))
            x=point.x if hit else sign*.07
            outer.append(bm.verts.new((x+sign*.002,start.y,start.z)))
            r=.071*(1+.07*sin(5*a+.3))
            inner.append(bm.verts.new((x+sign*(.025+.036*max(0,sin(a*3+.6))),.04+r*cos(a),-.08+r*sin(a))))
            back.append(bm.verts.new((x-sign*.003,.04+r*cos(a),-.08+r*sin(a))))
        for j in range(segs):
            k=(j+1)%segs
            for face in [(outer[j],outer[k],inner[k],inner[j]),(inner[j],inner[k],back[k],back[j]),(back[j],back[k],outer[k],outer[j])]:bm.faces.new(face).material_index=si
        bm.to_mesh(ob.data);bm.free()
    ob.data.update()
    # Char is reserved for exposed edges; avoid arbitrary black triangles on paint.
    if 'Skirt' not in name:scorch_patches(ob)
    cleanup(ob)
    DAMAGE.append(ob);TWINS[name]=ob.name

# Scars: geometry follows actual hull/turret faces. Ray casts land each impact
# on its receiving armour, avoiding guessed heights on sloping cheek plates.
def mesh_patch(name,verts,faces,mat,parent,origin):
    me=bpy.data.meshes.new(name+'_geometry');me.from_pydata([Vector(v)-origin for v in verts],[],faces);me.materials.append(mat);me.update()
    ob=bpy.data.objects.new(name,me);sc.collection.objects.link(ob);ob.location=origin
    uv=me.uv_layers.new(name='UV0')
    for p in me.polygons:
        for li in p.loop_indices:
            c=me.vertices[me.loops[li].vertex_index].co;uv.data[li].uv=(c.x*2+c.y*2,c.z*2+c.y)
    bpy.context.view_layer.update();parent_keep(ob,parent);return ob

def scar_group(body,label,parent):
    verts=[];faces=[];mids=[]
    face_dirs={'Front':Vector((0,-1,0)),'Rear':Vector((0,1,0)),'L':Vector((1,0,0)),'R':Vector((-1,0,0))}
    for face,normal in face_dirs.items():
        vs=[];fs=[];mats=[]
        for j in range(4):
            if label=='Hull':
                target=Vector(((-.83+j*.55) if face in ['Front','Rear'] else 0,(-1.65+j*1.05) if face in ['L','R'] else 0,1.02 if face!='Front' else 1.16))
            else:
                target=Vector(((-.86+j*.55) if face in ['Front','Rear'] else 0,(-.9+j*.72) if face in ['L','R'] else .2,1.87))
            start=target+normal*8; inv=body.matrix_world.inverted();hit,loc,n,idx=body.ray_cast(inv@start,(inv.to_3x3()@(-normal)).normalized())
            if not hit:continue
            c=body.matrix_world@loc;n=(body.matrix_world.to_3x3()@n).normalized();u=n.cross(Vector((0,0,1))).normalized();v=n.cross(u).normalized()
            radius=[.080,.023,.025,.045][j]
            # soot feather ring, gouged bright rim, dark recessed centre.
            rings=[];segs=24
            for rr,depth in [(radius*2.1,.0022),(radius*1.38,.003),(radius,.004),(radius*.72,.0032)]:
                ring=[]
                for i in range(segs):
                    a=2*pi*i/segs;rad=rr*(1+.19*sin(a*3+j*1.3)+.12*sin(a*7+.8))
                    elong=[1,3.8,3.0,1.35][j]
                    fan=1+.55*max(0,cos(a-.4)) if rr>radius*1.5 else 1
                    point=c+u*(cos(a)*rad*elong*fan)+v*(sin(a)*rad)+n*depth
                    ring.append(len(vs));vs.append(point)
                rings.append(ring)
            for ri in range(3):
                for i in range(segs):fs.append((rings[ri][i],rings[ri][(i+1)%segs],rings[ri+1][(i+1)%segs],rings[ri+1][i]));mats.append(0 if ri==0 else 1)
            fs.append(tuple(rings[-1]));mats.append(0)
            # Forward-directed chipped splinters and a ricochet furrow.
            for k in range(3):
                a=.6+k*.7+j;tip=c+u*(radius*(1.9+k*.4))+v*(.03*k)+n*.0035
                base=c+u*radius*.6+v*(.015*k)+n*.0035
                off=len(vs);vs.extend([base,base+v*.013,tip]);fs.append((off,off+1,off+2));mats.append(1)
        ob=mesh_patch('Scar_'+label+'_'+face,vs,fs,BURNT,parent,parent.matrix_world.translation.copy())
        ob.data.materials.append(SCARS)
        for p,mi in zip(ob.data.polygons,mats):p.material_index=mi
        cleanup(ob);DAMAGE.append(ob)
scar_group(OBJECTS['Hull'],'Hull',hroot);scar_group(OBJECTS['Turret'],'Turret',troot)

# Thrown track is made from rigid connected components of the source belt:
# shoes retain their shape; upper/front shoes fall and spill ahead on the floor.
def thrown_track(src,name,parent):
    ob=duplicate(src,name,parent);damage_materials(ob)
    bm=bmesh.new();bm.from_mesh(ob.data);bm.verts.ensure_lookup_table()
    unseen=set(bm.verts);components=[]
    while unseen:
        seed=unseen.pop();part=[seed];stack=[seed]
        while stack:
            a=stack.pop()
            for e in a.link_edges:
                b=e.other_vert(a)
                if b in unseen:unseen.remove(b);stack.append(b);part.append(b)
        components.append(part)
    uv=bm.loops.layers.uv.get('UV0')
    rear_top=sum(lengths[:27])
    sign=1 if src.name.startswith('Track_L') else -1
    for comp in components:
        samples=[loop[uv].uv.y for vert in comp for loop in vert.link_loops]
        index=max(0,min(N-1,int(round((sum(samples)/len(samples))*N-.5))))
        t=(index+.5)*length/N;y,z,angle=trackpoint(t)
        if t<=rear_top:continue
        center=convert_point((sign*1.48*.97,y,z))
        d=t-rear_top
        def loose_at(distance):
            side=.57*min(1,distance/1.25)+.045*sin(distance*1.8)*min(1,distance)
            return Vector((center.x+sign*side,3.10-distance,max(.131,1.29-distance*.92)))
        target=loose_at(d)
        tangent=(loose_at(d+.01)-loose_at(max(0,d-.01))).normalized()
        lateral=Vector((1,0,0));lateral=(lateral-tangent*lateral.dot(tangent)).normalized()
        normal=lateral.cross(tangent).normalized()
        frame=Matrix((lateral,tangent,normal)).transposed()
        old=Matrix.Rotation(-angle,3,'X')
        for vert in comp:vert.co=target+frame@(old@(vert.co-center))
    low=min(v.co.z for v in bm.verts)
    if low<0:
        for vert in bm.verts:vert.co.z-=low
    bm.to_mesh(ob.data);bm.free();cleanup(ob,.8);return ob
for side in ['L','R']:
    ob=thrown_track(OBJECTS['Track_'+side],'Track_'+side+'_Broken',hroot);DAMAGE.append(ob)

# Broken gun: mantlet is unchanged, copied barrel bends down at midlength;
# ragged open lips are separate geometry merged into the one swap mesh.
gun=duplicate(OBJECTS['Gun'],'Gun_Broken',troot);tube=duplicate(OBJECTS['Barrel'],'_burst_tube')
for v in tube.data.vertices:
    w=tube.matrix_world@v.co
    if w.y<-3.58:
        d=-3.58-w.y;w.z-=.20*d+.13*d*d;w.x+=.05*d*d
        v.co=tube.matrix_world.inverted()@w
merge_into(gun,[tube]);damage_materials(gun);scorch_patches(gun);cleanup(gun);DAMAGE.append(gun)
# Full wrecks use the actual healthy geometry and keep canonical pivots.
def join_copies(names,name,origin):
    copied=[]
    for n in names:
        if n.startswith('Hatch_'):continue
        ob=duplicate(OBJECTS[n],'_wreck_'+n)
        if n=='Hull':cut_connected_body(ob,convert_point((0,.12,1.32)),1.045,.65)
        if n.startswith('Wheel_'):
            # Burned rubber shoulders disappear down to the steel wheel carcass.
            rubberverts={vi for p in ob.data.polygons if ob.data.materials[p.material_index].name=='Rubber' for vi in p.vertices}
            for vi in rubberverts:
                v=ob.data.vertices[vi];rad=math.hypot(v.co.y,v.co.z)
                if rad>.02:v.co.y*=max(0,rad-.046)/rad;v.co.z*=max(0,rad-.046)/rad
        damage_materials(ob,True);copied.append(ob)
    root=copied.pop(0);root.name=name;merge_into(root,copied)
    world=root.matrix_world.copy();coords=[world@v.co for v in root.data.vertices]
    root.parent=None;root.matrix_world=Matrix.Translation(origin)
    for v,w in zip(root.data.vertices,coords):v.co=w-Vector(origin)
    return root
hullnames=[n for n in OBJECTS if not n.startswith('Part_Turret_') and n not in ['Turret','Gun','Barrel','MG','Hatch_Commander','Hatch_Loader']]
hullnames=[n for n in hullnames if not ('Skirt' in n and n.endswith(('2','4','6'))) and not n.startswith('Part_EngineGrille') and n!='Track_L']
turretnames=[n for n in OBJECTS if n not in hullnames and (n.startswith('Part_Turret_') or n in ['Turret','Gun','Barrel','MG','Hatch_Commander','Hatch_Loader'])]
wh=join_copies(hullnames,'Wreck_Hull',Vector((0,0,0)))
wt=join_copies(turretnames,'Wreck_Turret',OBJECTS['Turret'].matrix_world.translation.copy())
loose=duplicate(next(o for o in DAMAGE if o.name=='Track_L_Broken'),'_wreck_thrown');damage_materials(loose,True);merge_into(wh,[loose])
# Open ring well through wreck deck, with a dark bounded cavity.
# The ring cut was isolated to the copied armored body before assembly.
name='_wreck_ring';lathe(name,(0,.12,0),[(.56,0),(.56,1.035),(1.43,1.035),(1.43,1.09),(.51,1.09),(.51,0)],7,n=64)
merge_into(wh,[extra(name)])
# Original hull metal deforms near the engine explosion, suspension settles.
for v in wh.data.vertices:
    p=v.co
    if p.y>2.15 and p.z>1.32 and abs(p.x)<1.34:p.z+=.20*max(0,sin((p.y-2.15)*3))*sin(p.x*5)**2
    if .10<p.z<1.2:p.z=max(.02,p.z-.05)
    if abs(p.x)>1.65 and p.z<1.1:p.x+=.055*sin(p.y*2.4)
# Exposed raised engine louvres copied from actual removable decks.
for n in ['Part_EngineGrille_L','Part_EngineGrille_R']:
    ob=duplicate(OBJECTS[n],'_ruptured_'+n);damage_materials(ob,True)
    ob.rotation_euler.y+=.38 if n.endswith('L') else -.29;ob.location.z+=.12
    merge_into(wh,[ob])
# Close turret underside already in healthy shell; add ring and basket stub.
name='_basket_stub';lathe(name,(0,.12,0),[(.77,.69),(.80,.74),(1.49,.94),(1.53,.94),(1.53,.75),(.77,.60),(.77,.69)],7,n=48)
cyl(name,(0,.12,.78),.68,.06,7,n=48);merge_into(wt,[extra(name)])
for v in wt.data.vertices:
    w=wt.matrix_world@v.co
    if w.y<-1.99:
        d=-1.99-w.y;w.z-=.17*d;v.co=wt.matrix_world.inverted()@w
# Open crew hatches in wreck from copies, remove their closed surfaces first.
# Cut the closed lid and inner well cap at each hatch, then add rotated lids.
for suffix,x,y,z,r in [('Commander',-.61,.18,2.258,.325),('Loader',.63,.47,2.16,.28),('Driver',0,-1.94,1.46,.36)]:
    target=wh if suffix=='Driver' else wt
    # Healthy body already has a bounded crew opening; closed lids were omitted.
    lid=duplicate(OBJECTS['Hatch_'+suffix],'_blown_'+suffix);damage_materials(lid,True)
    lid.rotation_euler.x=math.radians(-78 if suffix!='Driver' else -65)
    merge_into(target,[lid])
for ob in [wh,wt]:cleanup(ob,.36);DAMAGE.append(ob)
# Damage texture set whitelist is enforced on every primitive, including joins.
for ob in DAMAGE:
    if ob.type=='MESH':
        for i,m in enumerate(ob.data.materials):
            if m and m.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:ob.data.materials[i]=BURNT if ob in [wh,wt] else SCARS
bpy.context.view_layer.update()
# Measured final budget, including both wreck meshes and every swap.
if tri(DAMAGE)>109500:
    excess=tri(DAMAGE)-109000
    ratio=max(.35,1-excess/tri([wh,wt]))
    for ob in [wh,wt]:cleanup(ob,ratio)
export_glb('abrams-tank-damage.glb',DAMAGE)

# Manifest of both files in the game frame.
def gamepos(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
def bounds(obs):
    pts=[o.matrix_world@v.co for o in obs if o.type=='MESH' for v in o.data.vertices]
    lo=[min(gamepos(p)[i] for p in pts) for i in range(3)];hi=[max(gamepos(p)[i] for p in pts) for i in range(3)]
    return {'min':lo,'max':hi,'size':[round(b-a,6) for a,b in zip(lo,hi)]}
def node_record(o):
    return {'name':o.name,'world_position':gamepos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'empty','axis':'X' if o.name.startswith(('Wheel_','Hatch_')) or o.name=='Gun' else 'Y' if o.name in ['Turret','MG'] else 'Z' if o.name=='Barrel' else None}
manifest={'asset':'abrams-tank','units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'bbox':bounds(healthy),'triangles':tri(healthy),'damage_triangles':tri(DAMAGE),'materials':MATN,'nodes':[node_record(o) for o in healthy],'damage_nodes':[node_record(o) for o in DAMAGE],'paints':[{'name':n,'linear_rgb':rgb} for n,rgb in PAINTS],'damage':{'contract':'tank-damage/v1','parts':[{'part':n,'twin':TWINS.get(n)} for n in hparts+tparts],'scars':[o.name for o in DAMAGE if o.name.startswith('Scar_')],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'track_uv':'UV0 V follows one full loop; SurfaceDetail preserves v1 tiled appearance','texture_bake':{'engine':'Cycles','device':'OPTIX','samples':8,'source':'CC0 Poly Haven; v1 cached maps preserved; new damage maps baked'},'wheel_roles':{'1':'front idler','2-8':'road wheels','9':'rear sprocket','10-13':'return rollers'},'damage_bbox':bounds(DAMAGE)}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join([
'M1A2 Abrams v1 retrofit: same silhouette/materials/pivots; detachable panels, crew wells, recoil rig and tank-damage/v1 swaps.',
f'Healthy {tri(healthy):,} triangles; damage {tri(DAMAGE):,}; wreck {tri([wh,wt]):,}; dimensions 9.77 × 3.66 × 2.44 m.',
'Healthy materials: Paint, Rubber, Steel, Tracks, Glass, Lamps, Weapons; damage: Paint, Burnt, Scars, TrackSteel, Rubber.',
'Textures: healthy base/normal <=1024 px, ORM 512 px; damage <=512 px except unchanged Paint; Cycles OPTIX tiled bakes.',
'Poly Haven CC0: metal_plate_02, rusty_metal_03, rubberized_track; https://polyhaven.com/license ; original cached textures copied without modifying v1.',
'Differences: original short transport antennas and lowered roof fittings retained; damage is authored generic battle damage, no insignia.'
])+'\n')
print('BUILT_ALL',manifest['triangles'],manifest['damage_triangles'],flush=True)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'damage-checkpoint.blend'))
# --- Proof uses the delivered files, imported into an EMPTY scene ------------
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene;sc.render.engine='CYCLES';sc.cycles.device='GPU';sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences;prefs.compute_device_type='OPTIX';prefs.get_devices()
for device in prefs.devices:device.use=device.type=='OPTIX'
sc.view_settings.view_transform='AgX';sc.view_settings.exposure=-.55
bpy.ops.import_scene.gltf(filepath=str(ROOT/'abrams-tank.glb'))
HA={o.name:o for o in sc.objects};healthy_import=list(HA.values())
bpy.ops.import_scene.gltf(filepath=str(ROOT/'abrams-tank-damage.glb'))
DA={o.name:o for o in sc.objects if o not in healthy_import}
for ob in list(HA.values())+list(DA.values()):ob.rotation_mode='XYZ'
# Collapse duplicate imported materials only when their semantic names match.
for ob in DA.values():
    if ob.type=='MESH':
        for i,mat in enumerate(ob.data.materials):
            if mat and mat.name.startswith('Paint.') and bpy.data.materials.get('Paint'):ob.data.materials[i]=bpy.data.materials['Paint']

def show_tree(ob,visible):
    ob.hide_render=not visible
    for child in ob.children:show_tree(child,visible)
def state(n):
    for ob in HA.values():ob.hide_render=n>=4
    for ob in DA.values():ob.hide_render=True
    if n==1:
        for name,ob in DA.items():
            if name.startswith('Scar_'):ob.hide_render=False
        swaps=['Part_Skirt_L2','Part_Skirt_L4','Part_Skirt_R3','Part_Fender_RL','Part_Turret_Bin_L','Part_Turret_Smoke_L']
        for name in swaps:
            if name in HA and name in TWINS:show_tree(HA[name],False);show_tree(DA[TWINS[name]],True)
    if n==2:
        for name,ob in HA.items():
            if name.startswith('Part_'):show_tree(ob,False)
        show_tree(HA['MG'],False)
    if n==3:
        for name in ['Track_L','Gun']:
            show_tree(HA[name],False);DA[name+'_Broken'].hide_render=False
    if n>=4:
        DA['Wreck_Hull'].hide_render=False;DA['Wreck_Turret'].hide_render=False
    # Blender glTF import returns original Z-up positions, same as source.
    DA['Wreck_Turret'].location=Vector((0,.12,1.50))
    # Manifest is the authority for pivot after original normalization.
    tp=next(x['world_position'] for x in manifest['damage_nodes'] if x['name']=='Wreck_Turret')
    DA['Wreck_Turret'].location=(tp[0],-tp[2],tp[1]);DA['Wreck_Turret'].rotation_euler=(0,0,0)
    if n==5:
        DA['Wreck_Turret'].location=(3.65,.90,1.25)
        DA['Wreck_Turret'].rotation_euler=(pi,0,pi)
        bpy.context.view_layer.update()
        ob=DA['Wreck_Turret'];low=min((ob.matrix_world@v.co).z for v in ob.data.vertices);ob.location.z-=low
    bpy.context.view_layer.update()

bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.015));ground=bpy.context.object;ground.name='Studio_floor'
groundmat=bpy.data.materials.new('Studio');groundmat.use_nodes=True;bs=groundmat.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(.22,.245,.27,1);bs.inputs['Roughness'].default_value=.87;ground.data.materials.append(groundmat)
sc.world=bpy.data.worlds.new('Soft daylight');sc.world.use_nodes=True;sc.world.node_tree.nodes.get('Background').inputs[0].default_value=(.67,.76,.90,1);sc.world.node_tree.nodes.get('Background').inputs[1].default_value=.5
for name,loc,energy,size in [('Key',(1,-5,9),2200,7),('Fill',(-6,-2,5),1200,6),('Rim',(2,6,7),2400,5)]:
    ld=bpy.data.lights.new(name,'AREA');ld.energy=energy;ld.shape='DISK';ld.size=size;ob=bpy.data.objects.new(name,ld);sc.collection.objects.link(ob);ob.location=loc;ob.rotation_euler=(Vector((0,0,1))-ob.location).to_track_quat('-Z','Y').to_euler()
camd=bpy.data.cameras.new('Presentation');cam=bpy.data.objects.new('Presentation',camd);sc.collection.objects.link(cam);sc.camera=cam;camd.type='ORTHO'
TARGET=Vector((0,-.5,1.05))
def view(loc,scale):
    cam.location=loc;cam.rotation_euler=(TARGET-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=scale
view((10,-14,5.8),11.8)
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.render.image_settings.file_format='PNG'
sc.cycles.samples=32;state(0)
sc.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
clay=bpy.data.materials.new('Clay');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.36,.36,.36,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.76
sc.view_layers[0].material_override=clay;sc.cycles.samples=16;sc.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
TARGET=Vector((0,-.75,1.05));view((14,-.75,1.05),11.1);sc.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
TARGET=Vector((0,-.5,1.05));view((10,-14,5.8),11.8)
# Pivot proof rotates each independent moving node by exactly 30 degrees.
original_rotations={n:o.rotation_euler.copy() for n,o in HA.items()}
for n,o in HA.items():
    if n in ['Turret','MG']:o.rotation_euler.z+=pi/6
    elif n=='Gun' or n.startswith('Hatch_'):o.rotation_euler.x-=pi/6
    elif n.startswith('Wheel_'):o.rotation_euler.x+=pi/6
    elif n=='Barrel':o.rotation_euler.y+=pi/6
sc.render.filepath=str(ROOT/'rig-check.png');bpy.ops.render.render(write_still=True)
for n,r in original_rotations.items():HA[n].rotation_euler=r
sc.render.resolution_x=800;sc.render.resolution_y=450;sc.cycles.samples=16

def grid_save(name,files,w=800,h=450):
    pixels=np.ones((h*2,w*3,4),dtype=np.float32)
    for i,path in enumerate(files):
        im=bpy.data.images.load(str(path),check_existing=False);arr=np.asarray(im.pixels[:],dtype=np.float32).reshape(h,w,4)
        row=1-i//3;col=i%3;pixels[row*h:(row+1)*h,col*w:(col+1)*w]=arr;bpy.data.images.remove(im)
    im=bpy.data.images.new(name,width=w*3,height=h*2);im.pixels.foreach_set(pixels.ravel());im.filepath_raw=str(ROOT/name);im.file_format='PNG';im.save()
# A transparent direct multiplication graph makes the paint grid independent
# of glTF importer node naming; it uses the exact exported neutral base map.
paint=bpy.data.materials['Paint'];nt=paint.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED')
tex=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image and ('basecolor' in n.image.name.lower() or n.image.colorspace_settings.name=='sRGB'))
mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(tex.outputs['Color'],mix.inputs[1]);nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
files=[]
for i,(name,color) in enumerate(PAINTS):
    mix.inputs[2].default_value=tuple(color)+(1,);sc.render.filepath=str(ROOT/f'paint_{i}.png');bpy.ops.render.render(write_still=True);files.append(sc.render.filepath)
grid_save('paints.png',files);mix.inputs[2].default_value=tuple(PAINTS[0][1])+(1,)
files=[]
# Same view and scale for all six states, wide enough for the ejected turret.
TARGET=Vector((1.1,-.5,.95));view((11,-15,7.3),14.1)
for i in range(6):
    state(i);sc.render.filepath=str(ROOT/f'state_{i}.png');bpy.ops.render.render(write_still=True);files.append(sc.render.filepath)
grid_save('states.png',files)
state(0);TARGET=Vector((0,-.5,1.05));view((10,-14,5.8),11.8)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'proof-imported.blend'))
print('COMPLETE: BOTH GLBS REIMPORTED AND PROOF RENDERED',flush=True)
