"""T-14 Armata-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 mathutils.bvhtree import BVHTree
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=-1.1
PAINTS=[('Olive drab',[.13,.16,.10]),('NATO green',[.075,.12,.065]),('Desert tan',[.46,.35,.20]),('Field grey',[.22,.25,.22]),('Winter white',[.78,.80,.75]),('Dark green-black',[.028,.042,.027])]
MATN=['Paint','Rubber','Scars','TrackSteel','Glass','Lamps','Weapons','Burnt','GunFinish']
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','-A','Mozilla/5.0','-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,([.30,2.4] if name=='Burnt' else [.78,1.0])):e.color=tuple(min(1,c*factor) for c in base)+(1,)
    detailuv=n.new('ShaderNodeVectorMath');detailuv.operation='MULTIPLY';detailuv.inputs[1].default_value=(1,4 if name=='Paint' else 1,1);l.new(uv.outputs['UV'],detailuv.inputs[0])
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=32;noise.inputs['Detail'].default_value=4;l.new(detailuv.outputs[0],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=.13;l.new(mul.outputs[0],sm.inputs[1]);l.new(sr.outputs[0],sm.inputs[2]);mul=sm
        # Height-addressed neutral dirt trim: muddy lower plates, cleaner upper casing.
        uvsep=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],uvsep.inputs[0])
        band=n.new('ShaderNodeMapRange');band.clamp=True;band.inputs['From Min'].default_value=.16;band.inputs['From Max'].default_value=.39;band.inputs['To Min'].default_value=.18;band.inputs['To Max'].default_value=1.;l.new(uvsep.outputs['Y'],band.inputs[0])
        dirt=n.new('ShaderNodeMixRGB');dirt.blend_type='MULTIPLY';dirt.inputs[0].default_value=.86;l.new(mul.outputs[0],dirt.inputs[1]);l.new(band.outputs[0],dirt.inputs[2]);mul=dirt
    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=.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):
        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])
    l.new(sep.outputs[0],comb.inputs[0])
    bake('orm',256,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','metal_plate_02',(.052,.048,.041),.92,0,512),
 ('Scars','metal_plate_02',(.24,.255,.25),.53,.78,512),
 ('TrackSteel','rusty_metal_03',(.105,.10,.09),.78,.63,512),
 ('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),
 ('Burnt','rusty_metal_03',(.07,.065,.06),.94,.35,512),
 ('GunFinish','metal_plate_02',(.13,.16,.10),.82,.18,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 and mat==0:
            wn=(verts[face[1]]-verts[face[0]]).cross(verts[face[2]]-verts[face[0]]).normalized()
            horizontal=0 if abs(wn.y)>abs(wn.x) else 1
            data['uv'].append([(verts[i][horizontal]*.45,verts[i].z/3.3) if abs(wn.z)<.8 else (verts[i].x*.45,.60+verts[i].y*.055) for i in face])
        elif 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)

# Authoring frame X=left, -Y=forward, Z=up. All coordinates in metres.
PIVOTS={'Hull':(0,0,0),'Turret':(0,.05,1.91),'Gun':(0,-1.28,2.39),'Barrel':(0,-1.76,2.39),'MG':(.53,.48,2.96),'Track_L':(0,0,0),'Track_R':(0,0,0),'Hatch_Driver':(-.62,-1.86,1.862),'Hatch_Commander':(.62,-1.86,1.862)}
PARENTS={};ROLES={}
def sheet(g,verts,thick=.022,mat=0):
    # Closed thin armour panel, with actual inner skin and rolled edges.
    v=[Vector(p) for p in verts];normal=(v[1]-v[0]).cross(v[2]-v[0]).normalized()
    n=len(v);vv=[tuple(p) for p in v]+[tuple(p-normal*thick) for p in v]
    ff=[tuple(range(n)),tuple(reversed(range(n,2*n)))]+[(i,i+n,(i+1)%n+n,(i+1)%n) for i in range(n)]
    poly(g,vv,ff,mat,min(.004,thick*.2))
def plate(g,points,zlo,zhi,mat=0):prism(g,points,zlo,zhi,mat,.01)
def weld(g,a,b):rod(g,a,b,.006,0,8)
def lug(g,p,axis=(1,0,0)):
    torus(g,p,.065,.018,2,axis,n=20,k=6)
def hollow(g,p,axis,length,r,mat=6,n=24):
    lathe(g,p,[(0,r*.84),(.007,r),(length-.012,r),(length,r*.93),(length,r*.72),(length-.09,r*.72)],mat,axis,n)

# Hull tub: welded lower body, pitched glacis, long continuous shoulder/deck.
sections=[(-4.35,1.47,.98,1.31),(-3.8,1.52,.60,1.59),(-2.55,1.52,.48,1.82),(2.85,1.50,.48,1.82),(4.17,1.45,.67,1.72),(4.35,1.38,.84,1.64)]
v=[]
for y,w,zlo,zhi in sections:v.extend([(-w*.80,y,zlo),(w*.80,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(5) for j in range(4)]
poly('HullCore',v,f,b=.025)
# Ring well actually open: roof around the circular turret race, depth beneath.
torus('Hull',(0,.05,1.852),.91,.049,0,n=64,k=8)
lathe('Hull',(0,.05,1.52),[(0,.89),(.32,.89),(.32,.94),(.0,.94)],6,n=64)
cyl('Hull',(0,.05,1.50),.89,.04,6,n=64)
# Engine deck underlying every removable grille remains complete.
for x in [-.70,.70]:
    box('Hull',(x,3.08,1.791),(1.28,1.78,.052),6,b=.015)
    for j in range(10):box('Hull',(x,2.30+j*.17,1.825),(1.21,.065,.025),2,b=.004)
    for yy in [2.20,3.96]:weld('Hull',(x-.65,yy,1.825),(x+.65,yy,1.825))
    g='Part_EngineGrille_'+('L' if x>0 else 'R')
    for xx in [x-.65,x+.65]:box(g,(xx,3.08,1.857),(.045,1.84,.055),b=.004)
    for yy in [2.17,3.99]:box(g,(x,yy,1.857),(1.34,.045,.055),b=.004)
    for j in range(24):box(g,(x,2.24+j*.073,1.858),(1.25,.033,.047),b=.003,rot=Matrix.Rotation(.25,3,'X'))
    for xx in [x-.53,x+.53]:
        for yy in [2.18,3.98]:bolt(g,(xx,yy,1.893),r=.016)
# Crew are in the hull: two functional hatch wells and a third seat under the front deck.
# Cut apertures through the roof after mesh construction; collar and interior geometry are here.
for x,name in [(-.62,'Driver'),(.62,'Commander')]:
    z=1.837
    # Six sided armoured hatch coaming; dark tub visible when open.
    lathe('Hull',(x,-2.20,1.25),[(0,.28),(.55,.28),(.61,.32),(.61,.37),(.52,.37),(0,.34)],6,n=32)
    cyl('Hull',(x,-2.20,1.25),.32,.045,6,n=32)
    box('Hull',(x,-2.10,1.40),(.32,.33,.12),1,b=.04)
    box('Hull',(x,-1.94,1.52),(.32,.11,.37),1,b=.05)
    g='Hatch_'+name
    pts=[(x-.32,-2.50),(x+.25,-2.50),(x+.35,-2.37),(x+.35,-1.99),(x+.23,-1.88),(x-.27,-1.88),(x-.35,-2.01),(x-.35,-2.37)]
    prism(g,pts,1.846,1.901,b=.012)
    for xx in [x-.22,x+.22]:cyl(g,(xx,-1.88,1.876),.034,.15,2,(1,0,0),16)
    handle(g,(x-.12,-2.39,1.91),(x+.12,-2.39,1.91),.045)
    for j in [-1,0,1]:
        box('Hull',(x+j*.22,-2.57,1.84),(.20,.18,.095),b=.01)
        box('Hull',(x+j*.22,-2.669,1.866),(.155,.013,.045),4,b=.003)
box('Hull',(0,-2.15,1.29),(.36,.40,.10),1,b=.04)
box('Hull',(0,-1.94,1.46),(.36,.13,.34),1,b=.04)
# Front deck welds and access plates; no faction insignia or lettering.
for x in [-1.10,1.10]:
    for yy in [-3.65,-3.22]:
        zz=1.59+(yy+3.8)*(.23/1.25)
        box('Hull',(x,yy,zz+.012),(.36,.34,.015),0,b=.009,rot=Matrix.Rotation(math.atan(.23/1.25),3,'X'))
        for xx in [x-.13,x+.13]:bolt('Hull',(xx,yy,zz+.025),r=.01)
for x in [-.90,.90]:weld('Hull',(x,-3.77,1.605),(x,-2.63,1.816))
for s,label in [(1,'L'),(-1,'R')]:
    # Backing side skin, horizontal mounting channel, running gear suspension.
    box('Hull',(s*1.51,.10,1.54),(.09,7.88,.16),b=.012)
    for yy in [-2.88+i*.96 for i in range(7)]:
        rod('Hull',(s*1.19,yy+.26,.85),(s*1.45,yy,.56),.073,2,16)
        cyl('Hull',(s*1.25,yy+.26,.85),.12,.14,2,(1,0,0),20)
    for i in range(8):
        yy=-3.22+i*.92
        box('Hull',(s*1.58,yy,1.68),(.16,.11,.12),2,b=.007)
        bolt('Hull',(s*1.67,yy,1.68),(s,0,0),.025)
    # Heavy side skirt plates, individually detachable with overlap seams.
    for i in range(7):
        a=-3.65+i*.86;b=a+.843;g=f'Part_Skirt_{label}{i+1}'
        sidepoly(g,s*1.672,[(a,1.76),(b,1.80),(b,.90),(a,.90)],.105,b=.009)
        sidepoly(g,s*1.664,[(a,.905),(b,.905),(b,.69),(a,.70)],.038,1,.006)
        for yy in [a+.075,b-.075]:
            box(g,(s*1.734,yy,1.61),(.018,.044,.22),b=.003)
            for zz in [1.51,1.70]:bolt(g,(s*1.743,yy,zz),(s,0,0),.012,mat=0)
        box(g,(s*1.733,(a+b)/2,.925),(.024,.11,.039),2,b=.003)
        # Neutral lower dirt/wear geometry is subtle and stays on its part.
        for j in range(6):
            yy=random.uniform(a+.03,b-.03)
            box(g,(s*1.727,yy,.906+random.uniform(.005,.022)),(.0018,random.uniform(.012,.044),.004),2,b=0)
    # Front and rear fenders/mudguards removable in four quadrants.
    for end,ya,yb in [('F',-4.35,-3.65),('R',2.37,4.22)]:
        g=f'Part_Fender_{end}{label}'
        zz=1.42 if end=='F' else 1.79
        sidepoly(g,s*1.57,[(ya,zz-.15),(ya,zz),(yb,1.79),(yb,zz-.15)],.30,b=.012)
        if end=='F':box(g,(s*1.51,ya+.013,1.13),(.46,.056,.27),1,b=.008)
        else:box(g,(s*1.51,yb-.025,1.26),(.44,.07,.40),1,b=.008)
    # Headlamp pods and removable guards, fixed Lamps remain on hull.
    x=s*1.21;y=-3.93;z=1.54
    box('Hull',(x,y,z),(.28,.15,.13),b=.018)
    box('Hull',(x,y-.082,z),(.22,.011,.082),1,b=.012)
    for xx in [x-.055,x+.055]:cyl('Hull',(xx,y-.096,z),.033,.018,5,(0,-1,0),20)
    g='Part_LampGuard_'+label
    for xx in [x-.155,x+.155]:rod(g,(xx,y-.13,z-.06),(xx,y-.13,z+.11),.011,0,10)
    rod(g,(x-.155,y-.13,z+.11),(x+.155,y-.13,z+.11),.011,0,10)
    for yy,zz in [(-4.22,.99),(4.15,.93)]:
        box('Hull',(s*1.08,yy,zz),(.13,.19,.17),b=.014)
        lug('Hull',(s*1.08,yy+(-.11 if yy<0 else .11),zz-.075))
    # Exhaust mounting, protected rear side exhaust port.
    box('Hull',(s*1.47,3.00,1.44),(.06,.66,.28),6,b=.012)
    for yy in [2.70,3.28]:box('Hull',(s*1.52,yy,1.46),(.12,.05,.32),2,b=.004)
    g='Part_ExhaustShroud_'+label
    sidepoly(g,s*1.59,[(2.61,1.67),(3.38,1.65),(3.39,1.35),(2.61,1.35)],.08,b=.014)
    for j in range(9):box(g,(s*1.638,2.67+j*.079,1.49),(.016,.031,.20),6,b=.003)
    # Rear slat armour: separate substantial sections, supported from the complete hull.
    for idx,(ya,yb) in enumerate([(2.38,3.25),(3.27,4.27)],1):
        g=f'Part_Slat_{label}{idx}'
        for yy in [ya,yb]:
            box(g,(s*1.73,yy,1.46),(.038,.036,.80),0,b=.004)
            rod('Hull',(s*1.49,yy,1.31),(s*1.715,yy,1.14),.02,2,8)
        for j in range(12):box(g,(s*1.73,(ya+yb)/2,1.085+j*.067),(.031,yb-ya,.019),0,b=.003)
    # Rear stowage box above the track with hinges and handles.
    g='Part_Stowage_'+label
    box(g,(s*1.42,2.16,1.875),(.47,.57,.18),b=.012)
    box(g,(s*1.42,2.16,1.978),(.49,.60,.027),b=.006)
    handle(g,(s*1.42-.08,2.16,1.995),(s*1.42+.08,2.16,1.995),.035)
# Rear slat gate across back face.
g='Part_Slat_Rear'
for x in [-1.71,-.57,.57,1.71]:box(g,(x,4.33,1.47),(.034,.034,.76),0,b=.003)
for j in range(12):box(g,(0,4.33,1.105+j*.063),(3.45,.026,.018),0,b=.003)
for x in [-1.4,1.4]:
    rod('Hull',(x,4.12,1.20),(x,4.32,1.18),.026,2,10)
    box('Hull',(x,4.22,1.46),(.11,.028,.052),5,b=.007)
# Rear towing cable, stranded steel with real coiled eyes.
g='Part_TowCable'
for strand in range(3):
    pts=[]
    for i in range(21):
        x=-1.18+2.36*i/20;pts.append((x,4.27+.006*cos(i*2.0+strand*2*pi/3),.75+.21*(x/1.18)**2+.006*sin(i*2+strand*2*pi/3)))
    for a,b in zip(pts,pts[1:]):rod(g,a,b,.0055,2,6)
for x in [-1.23,1.23]:torus(g,(x,4.26,.99),.07,.014,2,(0,1,0),24,6)
# Tool set secured to engine deck and spare shoes on aft deck.
g='Part_Tools'
rod(g,(.22,2.43,1.91),(.22,3.59,1.91),.019,6,12)
box(g,(.22,3.62,1.91),(.17,.22,.032),2,b=.008)
rod(g,(-.16,2.58,1.91),(-.16,3.78,1.91),.017,6,12)
for yy in [2.75,3.38]:box(g,(.03,yy,1.925),(.44,.052,.05),2,b=.004)
for i in range(3):
    box('Part_SpareTracks',(-.55+i*.24,4.08,1.75),(.19,.46,.065),3,b=.009)
    box('Part_SpareTracks',(-.55+i*.24,4.08,1.792),(.055,.47,.022),2,b=.003)

# Wheels: seven road stations per side, front idler, rear sprocket, four upper return rollers.
for s,label in [(1,'L'),(-1,'R')]:
    wheeldefs=[(-3.73,.86,.40,'idler')]+[(-2.88+i*.96,.565,.465,'road') for i in range(7)]+[(3.70,.88,.43,'sprocket')]+[(-2.2+i*1.46,1.29,.17,'return') for i in range(4)]
    wheeldefs.sort(key=lambda w:w[0])
    for i,(y,z,r,role) in enumerate(wheeldefs,1):
        g=f'Wheel_{label}{i}';x=s*1.43;PIVOTS[g]=(x,y,z);ROLES[g]=role
        if role=='road':
            # Twin rubber tires and recessed steel web with bearing hub.
            for xx in [x-s*.105,x+s*.115]:
                lathe(g,(xx,y,z),[(-.075,.34),(-.075,r-.025),(-.05,r),(.05,r),(.075,r-.025),(.075,.34)],1,(1,0,0),48)
            lathe(g,(x+s*.197,y,z),[(-.024,0),(-.024,.385),(0,.40),(.03,.38),(.03,.30),(.017,.255),(.017,.17),(.054,.14),(.063,0)],0,(s,0,0),48)
            torus(g,(x+s*.233,y,z),.302,.009,2,(1,0,0),48,6)
            cyl(g,(x+s*.268,y,z),.108,.035,0,(1,0,0),32)
            for j in range(8):
                a=j*2*pi/8;bolt(g,(x+s*.235,y+.215*cos(a),z+.215*sin(a)),(s,0,0),.018)
        else:
            lathe(g,(x,y,z),[(-.14,0),(-.14,r*.8),(-.12,r),(.12,r),(.145,r*.85),(.145,r*.64),(.16,r*.5),(.17,0)],0 if role!='return' else 1,(1,0,0),40 if role!='return' else 24)
            cyl(g,(x+s*.19,y,z),r*.31,.07,2,(1,0,0),24)
            if role in ['idler','sprocket']:
                for j in range(8):
                    a=j*2*pi/8
                    cyl(g,(x+s*.151,y+r*.61*cos(a),z+r*.61*sin(a)),r*.14,.012,6,(1,0,0),12)
                if role=='sprocket':
                    for j in range(15):
                        a=j*2*pi/15
                        box(g,(x,y+(r+.015)*cos(a),z+(r+.015)*sin(a)),(.29,.10,.085),2,b=.009,rot=Matrix.Rotation(a-pi/2,3,'X'))
# Track centreline polygon rounded by sampled cubic-free arc construction.
# Bottom sits at .044; shoe half thickness .044 touches ground exactly.
path=[(-2.91,.0485),(2.92,.0485),(3.76,.51),(4.08,.88),(3.90,1.22),(3.63,1.37),(-3.58,1.37),(-3.91,1.20),(-4.08,.87),(-3.78,.47)]
lengths=[(Vector(path[(i+1)%len(path)])-Vector(path[i])).length for i in range(len(path))];LOOP=sum(lengths)
def trackpoint(t):
    t=t%LOOP
    for i,ll in enumerate(lengths):
        if t<=ll:
            a=Vector(path[i]);b=Vector(path[(i+1)%len(path)]);p=a+(b-a)*t/ll;d=(b-a).normalized();return p,d
        t-=ll
    return Vector(path[0]),Vector((1,0))
for s,label in [(1,'L'),(-1,'R')]:
    g='Track_'+label;count=100
    for i in range(count):
        t=(i+.5)*LOOP/count;p,d=trackpoint(t);y,z=p;x=s*1.43;R=Matrix.Rotation(math.atan2(d.y,d.x),3,'X')
        uv=lambda local,world,t=t: ((local.x+.27)/.54,(t+local.y)/LOOP)
        box(g,(x,y,z),(.555,LOOP/count*.98,.072),3,b=.005,rot=R,uvmode=uv)
        # Ridge, guide tooth and pin ends ride the same local frame.
        for xx in [-.207,.207]:
            pos=Vector((x,y,z))+R@Vector((xx,0,-.040))
            box(g,pos,(.117,LOOP/count*.82,.017),2,b=.003,rot=R,uvmode=uv)
        pos=Vector((x,y,z))+R@Vector((0,0,.069))
        box(g,pos,(.045,.067,.074),3,b=.008,rot=R,uvmode=uv)
        for xx in [-.289,.289]:
            pos=Vector((x,y,z))+R@Vector((xx,LOOP/count*.37,0))
            cyl(g,pos,.022,.022,2,(1,0,0),8)
    # All pin UVs are reassigned to the closest loop coordinate below.

# Unmanned turret: complete narrow armoured gunhouse under removable thin casing.
cyl('Turret',(0,.05,1.954),.90,.14,6,n=64)
prism('Turret',[(-.60,-1.29),(.60,-1.29),(.80,-.62),(.76,1.20),(-.76,1.20),(-.80,-.62)],2.02,2.73,6,b=.028)
# rear autoloader/bustle under the outer shell
box('Turret',(0,1.76,2.41),(1.52,1.29,.52),b=.024)
for x in [-.60,.60]:
    for y in [-.96,.23,1.05,2.11]:
        box('Turret',(x,y,2.737),(.14,.14,.038),2,b=.005)
        bolt('Turret',(x,y,2.768),r=.021)
# Mantlet and barrel have separate pivots; no fume extractor on the smooth 125 mm tube.
lathe('Gun',(0,-1.43,2.39),[(-.35,0),(-.35,.265),(-.28,.32),(.18,.32),(.25,.24),(.25,0)],6,(0,1,0),8)
cyl('Gun',(0,-1.29,2.39),.28,.80,6,(1,0,0),48)
# Gun trunnion side covers
for x in [-.405,.405]:cyl('Gun',(x,-1.29,2.39),.16,.028,0,(1,0,0),32)
# Tube forward: progressive taper and thin sleeve joins, with real bore.
lathe('Barrel',(0,-1.76,2.39),[(0,.165),(.13,.165),(.18,.135),(1.22,.127),(1.24,.126),(2.34,.115),(2.36,.114),(3.50,.099),(4.53,.087),(4.65,.087),(4.69,.078),(4.69,.0625),(4.35,.0625)],8,(0,-1,0),64)
for t,r in [(.19,.137),(1.23,.128),(2.35,.116),(3.48,.102),(4.47,.091)]:torus('Barrel',(0,-1.76-t,2.39),r,.006,2,(0,1,0),48,6)
cyl('Barrel',(0,-6.08,2.39),.062,.014,6,(0,1,0),32)
box('Barrel',(0,-6.23,2.483),(.065,.14,.022),6,b=.004)
# Large thin casing cheek panels. Facets follow the reference, not thick cast armour.
for s,label in [(1,'L'),(-1,'R')]:
    g=f'Part_Turret_CasingFront_{label}'
    sheet(g,[(s*.36,-1.60,2.12),(s*1.07,-1.43,2.08),(s*1.38,-.82,2.21),(s*1.21,-.85,2.69),(s*.66,-1.07,2.81),(s*.40,-1.52,2.70)],.028)
    # Upper cheek folds close the casing around the narrow recessed mantlet.
    sheet(g,[(s*.40,-1.52,2.70),(s*.66,-1.07,2.81),(s*.22,-1.05,2.80),(s*.28,-1.55,2.66)],.024)
    sheet(g,[(s*.66,-1.07,2.81),(s*1.21,-.85,2.69),(s*1.19,-.72,2.74),(s*1.01,-.71,2.796)],.022)
    for j in range(4):
        bolt(g,(s*(.69+j*.135),-1.075+j*.07,2.812-j*.028),r=.010,mat=0)
    g=f'Part_Turret_CasingSide_{label}'
    sheet(g,[(s*1.08,-.69,2.06),(s*1.40,-.56,2.25),(s*1.27,.79,2.53),(s*.98,.95,2.78),(s*1.19,-.72,2.74)],.025)
    sheet(g,[(s*1.19,-.72,2.74),(s*.98,.95,2.78),(s*.92,.94,2.796),(s*1.01,-.71,2.796)],.022)
    # Faceted lower side closes the large casing, with a narrow APS recess below.
    sheet(g,[(s*1.08,-.69,2.06),(s*1.40,-.56,2.25),(s*1.27,.79,2.53),(s*1.055,.965,2.20),(s*.88,.65,2.035)],.025)
    sheet(g,[(s*1.38,-.82,2.21),(s*1.21,-.85,2.69),(s*1.19,-.72,2.74),(s*1.40,-.56,2.25),(s*1.08,-.69,2.06)],.022)
    sheet(g,[(s*1.27,.79,2.53),(s*.98,.95,2.78),(s*.94,1.00,2.71),(s*.96,.92,2.20),(s*1.055,.965,2.20)],.022)
    # Lower angular cheek fold, left open around APS tube mouths.
    sheet(g,[(s*.60,-1.25,2.05),(s*1.07,-1.42,2.08),(s*1.10,-.73,2.05),(s*.85,-.63,1.99)],.026)
    g=f'Part_Turret_Bustle_{label}'
    sheet(g,[(s*.96,.92,2.20),(s*1.07,2.64,2.31),(s*.91,2.79,2.48),(s*.93,2.69,2.69),(s*.94,1.00,2.71)],.03)
    for yy in [1.10,1.85,2.62]:
        for zz in [2.36,2.69]:bolt(g,(s*1.007,yy,zz),(s,0,0),.013)
    # Casing mounting bosses remain attached to the core after panels are stripped.
    for yy in [-.55,.57]:
        rod('Turret',(s*.76,yy,2.49),(s*1.19,yy,2.49),.037,2,12)
        bolt('Turret',(s*1.21,yy,2.49),(s,0,0),.028)
    # Four AESA sensor panel housings set into canted upper faces.
    g=f'Part_Turret_Radar_{label}'
    R=Matrix.Rotation(s*.20,3,'Y')
    box(g,(s*1.22,.29,2.635),(.075,.39,.24),0,b=.009,rot=R)
    box(g,(s*1.267,.29,2.635),(.021,.32,.17),6,b=.005,rot=R)
    # Protected sight opening on each front quarter; recessed dark optics with frame.
    g=f'Part_Turret_Sight_{label}'
    box(g,(s*.98,-.80,2.67),(.32,.31,.21),b=.012)
    box(g,(s*.98,-.967,2.67),(.245,.024,.13),6,b=.004)
    for xx in [s*.98-.07,s*.98+.07]:box(g,(xx,-.982,2.67),(.063,.009,.089),4,b=.002)
    # Five Afghanit-style active-protection launch tubes at turret base.
    g=f'Part_Turret_APS_{label}'
    for j in range(5):
        p=Vector((s*(.62+j*.125),-.81+j*.16,2.07));axis=Vector((s*.23,-.97,-.025)).normalized()
        hollow(g,p-axis*.19,axis,.41,.078,6,24)
        torus(g,p+axis*.214,.068,.008,2,axis,20,6)
        cyl(g,p+axis*.16,.055,.008,6,axis,16)
    # Roof upward smoke discharger array with small individually capped tubes.
    g=f'Part_Turret_Smoke_{label}'
    box(g,(s*.82,.65,2.80),(.20,.29,.055),b=.007)
    for j in range(6):
        p=(s*(.77+(j%2)*.10),.56+(j//2)*.09,2.825)
        hollow(g,p,(s*.25,0,.968),.095,.030,6,16)
    g=f'Part_Turret_Antenna_{label}'
    cyl(g,(s*.83,2.16,2.77),.05,.10,6,n=20)
    rod(g,(s*.83,2.16,2.81),(s*.83,2.16,3.30),.006,6,10)
# Removable roof casing, rear casing and access covers with real seams.
prism('Part_Turret_Roof',[(-.62,-1.05),(.62,-1.05),(1.01,-.71),(.92,.94),(-.92,.94),(-1.01,-.71)],2.75,2.796,b=.006)
box('Part_Turret_RoofRear',(0,1.83,2.69),(1.86,1.79,.036),b=.007)
box('Part_Turret_Rear',(0,2.76,2.465),(1.78,.044,.44),b=.008)
for yy in [1.13,1.88,2.55]:
    box('Part_Turret_RoofRear',(0,yy,2.716),(1.67,.009,.004),6,b=.001)
    for xx in [-.70,.70]:bolt('Part_Turret_RoofRear',(xx,yy+.043,2.724),r=.011,mat=0)
for xx in [-.52,.52]:
    box('Part_Turret_Roof',(xx,.10,2.808),(.58,.88,.012),b=.005)
    handle('Part_Turret_Roof',(xx-.07,.34,2.818),(xx+.07,.34,2.818),.028)
    for yy in [-.26,.46]:
        for x in [xx-.24,xx+.24]:bolt('Part_Turret_Roof',(x,yy,2.826),r=.009,mat=0)
for j in range(16):box('Part_Turret_Rear',(-.77+j*.103,2.79,2.52),(.046,.021,.27),6,b=.003)
for x in [-.76,.76]:
        for yy in [-.58,.42,1.33,2.54]:bolt('Part_Turret_Roof' if yy<.9 else 'Part_Turret_RoofRear',(x,yy,2.803 if yy<.9 else 2.723),r=.014,mat=0)
# Remote panoramic sight; unmistakable elevated slab-sided housing, no crew cupola.
cyl('Part_Turret_Panoramic',( .50,.47,2.87),.23,.17,0,n=32)
prism('Part_Turret_Panoramic',[(.25,.19),(.76,.19),(.83,.42),(.72,.78),(.25,.78)],2.93,3.28,b=.012)
box('Part_Turret_Panoramic',(.49,.181,3.12),(.35,.019,.24),6,b=.005)
for x,r in [(.40,.060),(.60,.042)]:cyl('Part_Turret_Panoramic',(x,.163,3.13),r,.018,4,(0,-1,0),24)
box('Part_Turret_Panoramic',(.51,.44,3.291),(.59,.59,.018),b=.006)
# Angled optic shroud, inset aperture frame and cable loom around its rotary base.
sidepoly('Part_Turret_Panoramic',.809,[(.19,3.02),(.39,3.03),(.68,3.25),(.68,3.28),(.24,3.28)],.04,b=.006)
for xx in [.28,.72]:
    box('Part_Turret_Panoramic',(xx,.161,3.12),(.027,.042,.28),6,b=.004)
for z in [2.991,3.255]:box('Part_Turret_Panoramic',(.50,.15,z),(.43,.046,.025),6,b=.003)
for j in range(5):box('Part_Turret_Panoramic',(.262,.37+j*.065,3.07),(.013,.019,.18),6,b=.002)
torus('Part_Turret_Panoramic',(.50,.47,2.865),.244,.012,6,n=32,k=6)
# Roof MG attached to remote mount; narrow receiver, feed belt and barrel.
box('MG',(-.30,.36,2.92),(.14,.39,.13),6,b=.008)
rod('MG',(-.30,.18,2.95),(-.30,-.54,2.95),.022,6,20)
hollow('MG',(-.30,-.54,2.95),(0,-1,0),.13,.028,6,20)
box('MG',(-.48,.42,2.92),(.20,.27,.18),6,b=.009)
for xx in [-.37,-.23]:rod('MG',(xx,.45,2.79),(xx,.35,2.96),.016,2,12)
for j in range(7):cyl('MG',(-.39,.29+j*.023,2.98),.007,.07,2,(1,0,0),8)
PIVOTS['MG']=(-.30,.39,2.82)

# Build mesh nodes from pre-bevelled geometry; no object transform application on moving parts.
OBJECTS={}
def make_object(name,d,origin=None):
    if origin is None:origin=PIVOTS.get(name)
    if origin is None:
        vv=np.array(d['v']);origin=(vv.min(axis=0)+vv.max(axis=0))*.5
    origin=Vector(origin);me=bpy.data.meshes.new(name+'_mesh');me.from_pydata([Vector(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 mat in MATS:me.materials.append(mat)
    uv=me.uv_layers.new(name='UV0')
    for p,ma,sm,coords in zip(me.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
    bm=bmesh.new();bm.from_mesh(me)
    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-8]
    if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
    bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free()
    return ob
for name,d in GROUP.items():OBJECTS[name]=make_object(name,d)
for name in ['Track_L','Track_R']:
    low=min(v.co.z for v in OBJECTS[name].data.vertices)
    for v in OBJECTS[name].data.vertices:v.co.z-=low
bpy.context.view_layer.update()
# Real hatch apertures through the continuous hull roof. A closed underfloor remains below.
for x in [-.62,.62]:
    bpy.ops.mesh.primitive_cylinder_add(vertices=32,radius=.285,depth=.90,location=(x,-2.2,1.94));cutter=bpy.context.object
    ob=OBJECTS['HullCore'];bpy.context.view_layer.objects.active=ob
    mod=ob.modifiers.new('crew well','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter
    bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cutter,do_unlink=True)
# Cut the turret aperture into the watertight core only, before joining fittings.
bpy.ops.mesh.primitive_cylinder_add(vertices=48,radius=.87,depth=.90,location=(0,.05,1.90));cutter=bpy.context.object
ob=OBJECTS['HullCore'];bpy.context.view_layer.objects.active=ob
mod=ob.modifiers.new('turret well','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter
bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cutter,do_unlink=True)
bpy.ops.object.select_all(action='DESELECT');OBJECTS['Hull'].select_set(True);OBJECTS['HullCore'].select_set(True);bpy.context.view_layer.objects.active=OBJECTS['Hull'];bpy.ops.object.join();OBJECTS.pop('HullCore')
# Collapse redundant bevel vertices, preserving the required articulated nodes.
for name,ob in OBJECTS.items():
    ratio=.55 if name.startswith('Track_') else .82 if ROLES.get(name)=='road' else 1
    if ratio<1:
        bpy.context.view_layer.objects.active=ob;mod=ob.modifiers.new('game mesh budget','DECIMATE');mod.ratio=ratio;bpy.ops.object.modifier_apply(modifier=mod.name)
    keep=ob.data.uv_layers.get('UV0') or ob.data.uv_layers.active
    for layer in list(ob.data.uv_layers):
        if layer!=keep:ob.data.uv_layers.remove(layer)
    if keep:keep.name='UVMap';ob.data.uv_layers.active_index=0;keep.active_render=True
# Decimation may slightly move a sole vertex: restore exact ground contact.
for name in ['Track_L','Track_R']:
    ob=OBJECTS[name];low=min(v.co.z for v in ob.data.vertices)
    for v in ob.data.vertices:v.co.z-=low
# Track pin faces use the belt's once-around-loop V as well, preserving animation seam.
for label in ['L','R']:
    ob=OBJECTS['Track_'+label];uv=ob.data.uv_layers.active
    for p in ob.data.polygons:
        world=ob.matrix_world@p.center
        best=(1e20,0)
        acc=0
        for i,ll in enumerate(lengths):
            a=Vector(path[i]);b=Vector(path[(i+1)%len(path)]);q=Vector((world.y,world.z));t=max(0,min(1,(q-a).dot(b-a)/(ll*ll)));dist=(q-a-(b-a)*t).length
            if dist<best[0]:best=(dist,(acc+t*ll)/LOOP,i,t)
            acc+=ll
        for li in p.loop_indices:
            v=ob.data.vertices[ob.data.loops[li].vertex_index].co
            a=Vector(path[best[2]]);b=Vector(path[(best[2]+1)%len(path)]);tangent=(b-a).normalized()
            delta=(Vector((v.y,v.z))-Vector((world.y,world.z))).dot(tangent)
            uv.data[li].uv=((v.x-(1.43 if label=='L' else -1.43)+.30)/.60,best[1]+delta/LOOP)
def parent_keep(ob,parent):
    bpy.context.view_layer.update();w=ob.matrix_world.copy();ob.parent=parent;ob.matrix_world=w
for name,ob in OBJECTS.items():
    if name=='Barrel':parent_keep(ob,OBJECTS['Gun'])
    elif name in ['Gun','MG'] or name.startswith('Part_Turret_'):parent_keep(ob,OBJECTS['Turret'])
    elif name.startswith('Hatch_'):parent_keep(ob,OBJECTS['Hull'])
SOCKETS={}
for name,p,par in [('Muzzle',(0,-6.45,2.39),'Barrel'),('Seat_Commander',(.62,-2.2,1.95),'Hull'),('Seat_Driver',(-.62,-2.2,1.95),'Hull'),('Exhaust',(1.63,3.0,1.49),'Hull'),('Light_Head_L',(1.21,-4.026,1.54),'Hull'),('Light_Head_R',(-1.21,-4.026,1.54),'Hull'),('Fx_Engine',(0,3.1,1.88),'Hull'),('Fx_Ammo',(0,.3,1.06),'Hull'),('Fx_Hatch',(.62,-2.2,1.93),'Hull')]:
    ob=bpy.data.objects.new(name,None);sc.collection.objects.link(ob);ob.location=p;parent_keep(ob,OBJECTS[par]);SOCKETS[name]=ob
assets=list(OBJECTS.values())+list(SOCKETS.values())

def tri_count(obs):return sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in obs if o.type=='MESH')
def export_glb(path,obs):
    bpy.ops.object.select_all(action='DESELECT')
    # Export the neutral texture directly; explicitly insert paint tint in serialized PBR.
    for m in MATS:
        n=m.node_tree.nodes;bs=next(n for n in n if n.type=='BSDF_PRINCIPLED');t=n.get('PaintFactor')
        if t:m.node_tree.links.new(t.inputs[1].links[0].from_socket,bs.inputs['Base Color'])
    for ob in obs:
        ob.hide_set(False);ob.select_set(True)
        if ob.type=='MESH':
            bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
            bad=[f for f in bm.faces if f.calc_area()<1e-8]
            if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
            bm.to_mesh(ob.data);bm.free();ob.data.update()
    bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
    raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];j=json.loads(raw[20:20+jl]);tail=raw[20+jl:]
    for m in j.get('materials',[]):
        if m.get('name')=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1.]
    unique=[];byname={};remap={}
    def clean_texcoords(data):
        if isinstance(data,dict):
            if 'texCoord' in data:data['texCoord']=0
            for v in data.values():clean_texcoords(v)
        elif isinstance(data,list):
            for v in data:clean_texcoords(v)
    for i,m in enumerate(j.get('materials',[])):
        clean_texcoords(m)
        if m['name'] not in byname:byname[m['name']]=len(unique);unique.append(m)
        remap[i]=byname[m['name']]
    for mesh in j.get('meshes',[]):
        for prim in mesh['primitives']:
            if 'material' in prim:prim['material']=remap[prim['material']]
    j['materials']=unique
    # Degenerate UVs on thin link end faces have no unique tangent. Supply an
    # orthonormal fallback instead of serializing Blender's zero-length vector.
    payload=bytearray(tail)
    def float_view(ai):
        ac=j['accessors'][ai];bv=j['bufferViews'][ac['bufferView']]
        width={'VEC3':3,'VEC4':4}[ac['type']]
        return np.ndarray((ac['count'],width),dtype='<f4',buffer=payload,offset=8+bv.get('byteOffset',0)+ac.get('byteOffset',0),strides=(bv.get('byteStride',width*4),4))
    for mesh in j.get('meshes',[]):
        for prim in mesh['primitives']:
            at=prim['attributes']
            if 'TANGENT' not in at:continue
            tang=float_view(at['TANGENT']);norm=float_view(at['NORMAL'])
            for k in np.where(np.linalg.norm(tang[:,:3],axis=1)<.5)[0]:
                normal=norm[k];axis=np.array([1.,0.,0.]) if abs(normal[0])<.8 else np.array([0.,1.,0.])
                v=np.cross(normal,axis);v/=max(np.linalg.norm(v),1e-12);tang[k,:3]=v;tang[k,3]=1.
    # Test the actual serialized float positions: export rounding can collapse
    # tiny fracture slivers that were non-zero in the authoring mesh.
    for mesh in j.get('meshes',[]):
        for prim in mesh['primitives']:
            pos=float_view(prim['attributes']['POSITION']).astype(np.float64)
            ac=j['accessors'][prim['indices']];bv=j['bufferViews'][ac['bufferView']]
            dtype={5121:'u1',5123:'<u2',5125:'<u4'}[ac['componentType']]
            ix=np.ndarray((ac['count'],),dtype=dtype,buffer=payload,offset=8+bv.get('byteOffset',0)+ac.get('byteOffset',0))
            triangles=ix.copy().reshape(-1,3);points=pos[triangles]
            keep=np.linalg.norm(np.cross(points[:,1]-points[:,0],points[:,2]-points[:,0]),axis=1)>=1e-12
            if not keep.all():
                clean=triangles[keep].ravel();ix[:len(clean)]=clean;ac['count']=len(clean)
                if 'min' in ac:ac['min']=[int(clean.min())]
                if 'max' in ac:ac['max']=[int(clean.max())]
    # Standard glTF quantization keeps unit vectors precise while saving ~2 MB.
    # Positions and UVs remain full float; no Draco/Meshopt decoder is required.
    vector_accessors={p['attributes'][k] for m in j['meshes'] for p in m['primitives'] for k in ['NORMAL','TANGENT'] if k in p['attributes']}
    replacements={}
    for ai in vector_accessors:
        ac=j['accessors'][ai];values=float_view(ai).copy();width=values.shape[1]
        packed=np.zeros((len(values),4),dtype='<i2');packed[:,:width]=np.rint(np.clip(values,-1,1)*32767).astype('<i2')
        vi=ac['bufferView'];replacements[vi]=packed.tobytes();ac['componentType']=5122;ac['normalized']=True;ac['byteOffset']=0
        ac.pop('min',None);ac.pop('max',None);j['bufferViews'][vi]['byteStride']=8
    rawbin=bytes(payload[8:]);newbin=bytearray()
    for vi,bv in enumerate(j['bufferViews']):
        newbin.extend(b'\0'*(-len(newbin)%4));offset=bv.get('byteOffset',0)
        data=replacements.get(vi,rawbin[offset:offset+bv['byteLength']]);bv['byteOffset']=len(newbin);bv['byteLength']=len(data);newbin.extend(data)
    newbin.extend(b'\0'*(-len(newbin)%4));j['buffers'][0]['byteLength']=len(newbin)
    for key in ['extensionsUsed','extensionsRequired']:
        j.setdefault(key,[])
        if 'KHR_mesh_quantization' not in j[key]:j[key].append('KHR_mesh_quantization')
    tail=struct.pack('<I4s',len(newbin),b'BIN\0')+newbin
    jt=json.dumps(j,separators=(',',':')).encode();jt+=b' '*(-len(jt)%4)
    path.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(jt)+len(tail))+struct.pack('<I4s',len(jt),b'JSON')+jt+tail)
    for m in MATS:
        n=m.node_tree.nodes;bs=next(n for n in n if n.type=='BSDF_PRINCIPLED');t=n.get('PaintFactor')
        if t:m.node_tree.links.new(t.outputs[0],bs.inputs['Base Color'])
    print('EXPORTED',path.name,tri_count(obs),path.stat().st_size,flush=True)
    return j
healthy_json=export_glb(ROOT/'t14-armata-tank.glb',assets)
# DAMAGE LIBRARY — copies retain the healthy frame and exact detachable-part pivots.
DAMAGE={};TWINS={};SCARS=[]
def empty(name,loc):
    ob=bpy.data.objects.new(name,None);sc.collection.objects.link(ob);ob.location=loc;DAMAGE[name]=ob;return ob
hd=empty('Hull_Damage',(0,0,0));td=empty('Turret_Damage',PIVOTS['Turret'])
def damage_copy(src,name,parent=None):
    ob=src.copy();ob.data=src.data.copy();sc.collection.objects.link(ob);ob.name=name
    w=src.matrix_world.copy();ob.parent=None;ob.matrix_world=w
    if parent:parent_keep(ob,parent)
    # Damage uses only the five contracted material names.
    for i,m in enumerate(ob.data.materials):
        if m.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:ob.data.materials[i]=MATS[7]
    DAMAGE[name]=ob;return ob
# Substantial armour panels bent off their planes, not just recoloured duplicates.
chosen=['Part_Skirt_L1','Part_Skirt_L2','Part_Skirt_L3','Part_Skirt_R1','Part_Skirt_R3','Part_Skirt_R5','Part_Fender_FL','Part_Fender_RR','Part_Slat_L1','Part_Slat_R2','Part_Slat_Rear','Part_EngineGrille_L','Part_Turret_CasingFront_L','Part_Turret_CasingSide_R','Part_Turret_Bustle_L','Part_Turret_Rear']
for idx,name in enumerate(chosen):
    src=OBJECTS[name];dn='Dmg_'+name[5:];ob=damage_copy(src,dn,td if name.startswith('Part_Turret') else hd);TWINS[name]=dn
    # Local deformation keeps the origin unchanged; hinge-edge displacement grows across panel.
    for v in ob.data.vertices:
        p=v.co
        if 'Skirt' in name or 'Casing' in name:
            side=1 if '_L' in name else -1
            p.x+=side*(.10*sin(p.y*5+idx)+.20*max(0,-p.z))
            p.z-=.09*(.5+.5*sin(p.y*4+idx))
            if p.y>0:p.y+=.06*p.z
        elif 'Slat' in name:
            p.x+=.13*sin(p.y*3.1+p.z*6);p.z-=.12*max(0,cos(p.y*3))
        elif 'Grille' in name:p.z+=.22*sin(p.y*2.5)+.14
        else:p.z-=.13+max(0,p.y)*.16;p.x+=.07*sin(p.y*3)
    # A real irregular through-hole with lifted jagged rim on skirt/casing twins.
    if 'Skirt' in name:
        bpy.context.view_layer.update();centre=ob.matrix_world.translation.copy();centre.z+=.08
        bpy.ops.mesh.primitive_cylinder_add(vertices=9,radius=.135,depth=.55,location=centre,rotation=(0,pi/2,0));cut=bpy.context.object
        bpy.context.view_layer.objects.active=ob;mod=ob.modifiers.new('shrapnel puncture','BOOLEAN');mod.object=cut;mod.operation='DIFFERENCE';mod.solver='EXACT'
        bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
        for v in ob.data.vertices:
            rr=math.hypot(v.co.y,v.co.z-.08)
            if .125<rr<.19:
                a=math.atan2(v.co.z-.08,v.co.y);v.co.x+=(1 if '_L' in name else -1)*.055*(.5+.5*cos(a*7))
    # Scorched patches are material zones on surviving geometry.
    for p in ob.data.polygons:
        if (p.center.y*7+p.center.z*4+idx)%3<.8:p.material_index=7
# Scar sheets are irregular concentric soot / bare steel / black concavity rings.
scar_vertices=[];scar_faces=[]
for src in OBJECTS.values():
    off=len(scar_vertices);scar_vertices.extend(src.matrix_world@v.co for v in src.data.vertices)
    scar_faces.extend(tuple(off+i for i in p.vertices) for p in src.data.polygons)
scar_surface=BVHTree.FromPolygons(scar_vertices,scar_faces,all_triangles=False)
def impact_mesh(name,centre,normal,seed):
    rnd=random.Random(seed);normal=Vector(normal).normalized();tangent=Vector((0,1,0)) if abs(normal.x)>.8 else Vector((1,0,0));bitangent=normal.cross(tangent).normalized();R=Matrix((tangent,bitangent,normal)).transposed();c=Vector(centre)
    g=name;start=set(GROUP)
    # Multiple impacts distributed along each face, with petals and directional furrows.
    for k in range(4):
        off=Vector(((-.55+k*.36),.02*sin(k*4),0));cc=c+R@off
        hit,nn,_,distance=scar_surface.ray_cast(cc+normal*.65,-normal,1.6)
        if hit is not None:
            cc=hit;localnormal=nn.normalized()
        else:localnormal=normal
        tangent=Vector((0,1,0)) if abs(localnormal.x)>.8 else Vector((1,0,0))
        bitangent=localnormal.cross(tangent).normalized();tangent=bitangent.cross(localnormal).normalized();RR=Matrix((tangent,bitangent,localnormal)).transposed()
        r=.052 if k else .085;n=13;vs=[]
        shape=[.77+rnd.random()*.40 for j in range(n)]
        # All geometry is 2–4mm above armour except curled penetration petals.
        for radius,h in [(r*2.1,.002),(r*1.11,.003),(r,.004),(r*.78,.008),(r*.63,.003)]:
            for j in range(n):
                a=2*pi*j/n;rr=radius*shape[j];hh=h+(rnd.random()*.021 if radius==r and k==0 else 0)
                xx=rr*cos(a)*(1 if k==0 else 1.7);yy=rr*sin(a)*(1 if k==0 else .32)
                vs.append(tuple(cc+RR@Vector((xx,yy,hh))))
        for layer in range(4):
            ff=[(layer*n+j,layer*n+(j+1)%n,(layer+1)*n+(j+1)%n,(layer+1)*n+j) for j in range(n)]
            poly(g,vs,ff,7 if layer==0 else 2 if layer in [1,2] else 7,0)
        # Deep-looking closed black inside (surface overlay never exposes void).
        centreidx=len(vs);vv=[tuple(cc+localnormal*.002)]+[vs[4*n+j] for j in range(n)]
        poly(g,vv,[(0,j+1,(j+1)%n+1) for j in range(n)],7,0)
        # grazing ricochet slash, raised steel lip and soot fan.
        if k:
            a=cc+RR@Vector((-.10,.024,.003));b=cc+RR@Vector((.12,.029,.004));rod(g,a,b,.003,2,8)
    ob=make_object(name,GROUP.pop(g),centre);DAMAGE[name]=ob;SCARS.append(name);return ob
scar_specs=[('Hull_Front',(0,-4.12,1.430),(0,-.46,.89)),('Hull_L',(1.729,.0,1.24),(1,0,0)),('Hull_R',(-1.729,.0,1.24),(-1,0,0)),('Hull_Rear',(0,4.349,1.28),(0,1,0)),('Turret_Front',(0,-1.305,2.54),(0,-1,0)),('Turret_L',(1.325,.0,2.40),(1,0,0)),('Turret_R',(-1.325,.0,2.40),(-1,0,0)),('Turret_Rear',(0,2.415,2.46),(0,1,0))]
for i,(nm,p,n) in enumerate(scar_specs):
    ob=impact_mesh('Scar_'+nm,p,n,90+i);parent_keep(ob,td if nm.startswith('Turret') else hd)
# Broken belts copy every link, preserve grouping, drop the upper run and extend a loose end.
for label in ['L','R']:
    src=OBJECTS['Track_'+label];ob=damage_copy(src,'Track_'+label+'_Broken',hd)
    # Work by connected component (one shoe/bar/pin) so links remain hard metal.
    bm=bmesh.new();bm.from_mesh(ob.data);seen=set()
    for v in bm.verts:
        if v in seen:continue
        todo=[v];comp=[];seen.add(v)
        while todo:
            vv=todo.pop();comp.append(vv)
            for edge in vv.link_edges:
                q=edge.other_vert(vv)
                if q not in seen:seen.add(q);todo.append(q)
        c=sum((vv.co for vv in comp),Vector())/len(comp)
        for vv in comp:
            if c.z>1.0:
                vv.co.z=.055+(vv.co.z-c.z)*.35+.06*(abs(c.y)/4)**2
                vv.co.x+=(.67 if label=='L' else -.67)*max(.40,1-abs(c.y)/6)
            elif c.y<-3.0:
                vv.co.y=-3.0-(c.z/.95)*1.70
                vv.co.z=max(.019,.048+(vv.co.z-c.z)*.45)
    bm.to_mesh(ob.data);bm.free()
    bpy.context.view_layer.objects.active=ob;mod=ob.modifiers.new('broken belt budget','DECIMATE');mod.ratio=.44;bpy.ops.object.modifier_apply(modifier=mod.name)
# Broken gun combines exact mantlet and tube copies; tube bends abruptly with peeled muzzle.
def merge_copies(name,sources,origin,material=None,ratio=1.0):
    origin=Vector(origin);verts=[];faces=[];mi=[];uvs=[];smooth=[]
    for src in sources:
        off=len(verts);verts.extend(tuple(src.matrix_world@v.co-origin) for v in src.data.vertices)
        layer=src.data.uv_layers.active
        for p in src.data.polygons:
            faces.append(tuple(off+i for i in p.vertices));mi.append(material if material is not None else p.material_index);smooth.append(p.use_smooth)
            uvs.append([tuple(layer.data[i].uv) for i in p.loop_indices] if layer else [(0,0)]*len(p.vertices))
    d={'v':[tuple(Vector(v)+origin) for v in verts],'f':faces,'m':mi,'s':smooth,'uv':uvs}
    ob=make_object(name,d,origin)
    if ratio<1:
        bpy.context.view_layer.objects.active=ob;mod=ob.modifiers.new('wreck simplification','DECIMATE');mod.ratio=ratio;bpy.ops.object.modifier_apply(modifier=mod.name)
    return ob
bpy.context.view_layer.update()
ob=merge_copies('Gun_Broken',[OBJECTS['Gun'],OBJECTS['Barrel']],PIVOTS['Gun']);DAMAGE[ob.name]=ob
for v in ob.data.vertices:
    if v.co.y<-1.4:
        t=-v.co.y-1.4;v.co.z-=t*.26;v.co.x+=.09*t
    if v.co.y<-3.3:
        v.co.x*=1.0+(-v.co.y-3.3)*.8
for i,m in enumerate(ob.data.materials):
    if m.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:ob.data.materials[i]=MATS[7]
parent_keep(ob,td)
# Burnt wreck meshes assembled from exact healthy components, with selected missing panels.
# Open hatches, caved suspension, ruptured engine grilles, loose track; one mesh per wreck half.
wreck_sources=[]
for name,src in OBJECTS.items():
    if name in ['Turret','Gun','Barrel','MG'] or name.startswith('Part_Turret_'):continue
    if name in ['Part_Skirt_L2','Part_Skirt_L3','Part_Skirt_R4','Part_Skirt_R5','Part_Slat_L1','Part_Slat_R2','Part_Fender_FL']:continue
    cp=src.copy();cp.data=src.data.copy();sc.collection.objects.link(cp);cp.parent=None;cp.matrix_world=src.matrix_world.copy()
    if name.startswith('Hatch_'):cp.rotation_euler.x=math.radians(-108)
    if name.startswith('Wheel_'):
        cp.location.z-=.05
        # Rubber burnt off, exposing thinner metal wheels.
        for v in cp.data.vertices:
            if v.co.y*v.co.y+v.co.z*v.co.z>.39**2:v.co.y*=.93;v.co.z*=.93
    if name.startswith('Part_EngineGrille'):
        for v in cp.data.vertices:v.co.z+=.32*cos(v.co.y*2)+.16*sin(v.co.x*5)
    if name=='Track_L':
        bpy.data.objects.remove(cp,do_unlink=True);cp=DAMAGE['Track_L_Broken'].copy();cp.data=cp.data.copy();sc.collection.objects.link(cp);cp.parent=None
    elif name=='Track_R':
        for v in cp.data.vertices:
            if v.co.z>1.1:v.co.z-=.12*(1-min(1,abs(v.co.y)/4))
    elif name.startswith('Part_Skirt'):
        for v in cp.data.vertices:v.co.x+=.09*sin(v.co.y*5);v.co.z-=.10
    wreck_sources.append(cp)
bpy.context.view_layer.update()
wh=merge_copies('Wreck_Hull',wreck_sources,(0,0,0),7,.36);DAMAGE['Wreck_Hull']=wh
for cp in wreck_sources:bpy.data.objects.remove(cp,do_unlink=True)
wreck_sources=[]
for name,src in OBJECTS.items():
    if not (name in ['Turret','Gun','Barrel','MG'] or name.startswith('Part_Turret_')):continue
    if name in ['Part_Turret_CasingSide_L','Part_Turret_CasingFront_R','Part_Turret_Roof','Part_Turret_Antenna_L','Part_Turret_Panoramic']:continue
    cp=src.copy();cp.data=src.data.copy();sc.collection.objects.link(cp);cp.parent=None;cp.matrix_world=src.matrix_world.copy()
    if name in ['Gun','Barrel']:
        pivot=Vector(PIVOTS['Gun']);R=Matrix.Rotation(math.radians(13),4,'X');cp.matrix_world=Matrix.Translation(pivot)@R@Matrix.Translation(-pivot)@cp.matrix_world
    if name.startswith('Part_Turret'):
        for v in cp.data.vertices:v.co.z-=.035;v.co.x+=.05*sin(v.co.y*5)
    wreck_sources.append(cp)
# Closed underside, bearing ring and short basket stub visible if blown off.
torus('WreckBasket',(0,.05,1.91),.84,.065,7,n=48,k=8)
lathe('WreckBasket',(0,.05,1.56),[(0,.61),(.30,.61),(.36,.81),(.42,.81),(.42,0)],7,n=48)
cp=make_object('WreckBasket',GROUP.pop('WreckBasket'),PIVOTS['Turret']);wreck_sources.append(cp)
bpy.context.view_layer.update()
wt=merge_copies('Wreck_Turret',wreck_sources,PIVOTS['Turret'],7,.42);DAMAGE['Wreck_Turret']=wt
for cp in wreck_sources:bpy.data.objects.remove(cp,do_unlink=True)
# Drop empty unused material slots from damage; the exporter includes only referenced slots.
bpy.context.view_layer.update()
damage_json=export_glb(ROOT/'t14-armata-tank-damage.glb',list(DAMAGE.values()))

# Manifest records world-space positions in the consumer's +Y-up 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(v)[i] for v in pts) for i in range(3)];hi=[max(gamepos(v)[i] for v in pts) for i in range(3)]
    return {'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]}
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':'t14-armata-tank','contract':'tank-damage/v1','units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'bbox':bounds(assets),'triangles':sum(healthy_json['accessors'][p['indices']]['count']//3 for m in healthy_json['meshes'] for p in m['primitives']),'damage_triangles':sum(damage_json['accessors'][p['indices']]['count']//3 for m in damage_json['meshes'] for p in m['primitives']),'materials':MATN,'nodes':[node_record(o) for o in assets],'damage_nodes':[node_record(o) for o in DAMAGE.values()],'files':{'healthy':'t14-armata-tank.glb','damage':'t14-armata-tank-damage.glb'},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'damage':{'parts':[{'part':n,'twin':TWINS.get(n)} for n in OBJECTS if n.startswith('Part_')],'scars':SCARS,'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'wheel_roles':ROLES,'track_uv':'U across each belt, V normalized once around the full loop; modeled links.','texture_bake':{'engine':'Cycles','device':'OPTIX','samples':8,'maps':'basecolor EMIT, tangent normal NORMAL, packed ORM EMIT','sources':['metal_plate_02','rusty_metal_03'],'licence':'CC0 https://polyhaven.com/license'}}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join([
'T-14 Armata-style tank, articulated unmanned turret and hull crew hatches, detachable casing and rear slats; tank-damage/v1 pairs.',
f"Triangles: healthy {manifest['triangles']:,}; damage {manifest['damage_triangles']:,}; world dimensions {manifest['bbox']['size']} m.",
'Materials: Paint, Rubber, Scars, TrackSteel, Glass, Lamps, Weapons, Burnt, GunFinish; neutral Paint texture with linear factor [0.13, 0.16, 0.10].',
'Textures: Paint base/normal 1024 px, other base/normal <=512 px, all packed ORM 256 px; Cycles OPTIX 8-sample tile bakes.',
'Poly Haven CC0 IDs: metal_plate_02, rusty_metal_03; https://polyhaven.com/license ; diff, nor_gl, arm maps, procedural grime and rain streaks baked.',
'Differences: reference-inspired generic small fittings; shortened antenna whips to retain 3.30 m overall height; simplified interior and damage fracture topology.'
])+'\n')
print('BUILD_GEOMETRY_COMPLETE',manifest['triangles'],manifest['damage_triangles'],manifest['bbox'],flush=True)
# PROOF: discard the entire authoring scene and import both actual exported binaries.
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 d in prefs.devices:d.use=d.type=='OPTIX'
bpy.ops.import_scene.gltf(filepath=str(ROOT/'t14-armata-tank.glb'))
HEALTH={o.name:o for o in sc.objects}
existing=set(sc.objects)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'t14-armata-tank-damage.glb'))
DMG={o.name:o for o in sc.objects if o not in existing}
for o in list(HEALTH.values())+list(DMG.values()):o.rotation_mode='XYZ'
# Apply imported transforms / parenting exactly as a game would; no reconstruction for proofs.
def visibility(healthy=True,damage=False):
    for o in HEALTH.values():o.hide_render=not healthy
    for o in DMG.values():o.hide_render=not damage
visibility()
# Soft daylight studio, excluded from delivered binaries.
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));ground=bpy.context.object;ground.name='Studio_floor'
m=bpy.data.materials.new('Studio');m.use_nodes=True;m.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.38,.40,.42,1);m.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.88;ground.data.materials.append(m)
sc.world=bpy.data.worlds.new('Soft daylight');sc.world.use_nodes=True;sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.75,.81,.90,1);sc.world.node_tree.nodes['Background'].inputs[1].default_value=.45
for name,loc,energy,size in [('Key',(1,-6,10),1800,7),('Fill',(-6,-1,5),1100,6),('Rim',(2,6,7),1800,5)]:
    ld=bpy.data.lights.new(name,'AREA');ld.energy=energy;ld.shape='DISK';ld.size=size;o=bpy.data.objects.new(name,ld);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1.2))-o.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,-.55,1.3))
def view(loc=(10,-14,7.0),scale=12.5):
    cam.location=loc;cam.rotation_euler=(TARGET-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=scale
view()
sc.view_settings.view_transform='AgX';sc.view_settings.exposure=-1.1
sc.render.image_settings.file_format='PNG';sc.render.resolution_percentage=100
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.cycles.samples=32
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['Principled BSDF'].inputs['Base Color'].default_value=(.40,.40,.40,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.78
sc.view_layers[0].material_override=clay;sc.render.filepath=str(ROOT/'clay.png');sc.cycles.samples=16;bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
TARGET=Vector((0,-1.0,1.5));view((14,-1.0,1.5),11.7);sc.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
TARGET=Vector((0,-.55,1.3));view()
# Actual 30-degree articulation render and pivot displacement assertions.
rest={n:o.matrix_world.translation.copy() for n,o in HEALTH.items() if n in ['Turret','Gun','Barrel','MG'] or n.startswith(('Wheel_','Hatch_'))}
rotations={};local_pivot_before={n:HEALTH[n].location.copy() for n in rest}
for n in rest:
    o=HEALTH[n];rotations[n]=o.rotation_euler.copy()
    if n=='Barrel':o.rotation_euler.y+=math.radians(30)
    elif n in ['Turret','MG']:o.rotation_euler.z+=math.radians(30)
    else:o.rotation_euler.x+=math.radians(-30 if n=='Gun' or n.startswith('Hatch_') else 30)
bpy.context.view_layer.update()
for n in rest:
    assert (HEALTH[n].location-local_pivot_before[n]).length<1e-6, 'Pivot moved: '+n
# Children naturally orbit their parent; local pivots themselves stay unchanged.
sc.render.resolution_x=960;sc.render.resolution_y=540;sc.cycles.samples=12
sc.render.filepath=str(ROOT/'rig-test.png');bpy.ops.render.render(write_still=True)
for n,rot in rotations.items():HEALTH[n].rotation_euler=rot
bpy.context.view_layer.update()
# Paint factor socket on glTF-imported materials: multiply node input or direct base color.
paintmats=[m for m in bpy.data.materials if m.name=='Paint' or m.name.startswith('Paint.')]
def set_paint(color):
    for m in paintmats:
        nt=m.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED')
        base=bs.inputs['Base Color']
        if base.is_linked:
            n=base.links[0].from_node
            if n.type=='MIX':n.inputs[7].default_value=tuple(color)+(1,)
            elif n.type=='MIX_RGB':n.inputs[2].default_value=tuple(color)+(1,)
        else:base.default_value=tuple(color)+(1,)
def grid_render(path,configs):
    sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
    grid=np.ones((720,1920,4),dtype=np.float32)
    for i,fn in enumerate(configs):
        fn();bpy.context.view_layer.update();sc.render.filepath=str(ROOT/f'_proof_{i}.png');bpy.ops.render.render(write_still=True)
        im=bpy.data.images.load(sc.render.filepath,check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4)
        row=1-i//3;col=i%3;grid[row*360:(row+1)*360,col*640:(col+1)*640]=arr;bpy.data.images.remove(im)
        os.unlink(sc.render.filepath)
    im=bpy.data.images.new(path,width=1920,height=720);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/path);im.file_format='PNG';im.save()
grid_render('paints.png',[lambda c=c:set_paint(c) for _,c in PAINTS]);set_paint(PAINTS[0][1])
def healthy():visibility()
def hit():
    visibility()
    for n in SCARS:DMG[n].hide_render=False
    for n in ['Part_Skirt_L1','Part_Skirt_L2','Part_Fender_FL','Part_Slat_L1','Part_Turret_CasingFront_L','Part_Turret_Bustle_L']:
        HEALTH[n].hide_render=True;DMG[TWINS[n]].hide_render=False
    DMG['Hull_Damage'].hide_render=False;DMG['Turret_Damage'].hide_render=False
def stripped():
    visibility()
    for n,o in HEALTH.items():
        if n.startswith('Part_'):o.hide_render=True
def immobilised():
    visibility();HEALTH['Track_L'].hide_render=True;HEALTH['Gun'].hide_render=True;HEALTH['Barrel'].hide_render=True
    for n in ['Hull_Damage','Turret_Damage','Track_L_Broken','Gun_Broken']:DMG[n].hide_render=False
def wreck():
    visibility(False,False);DMG['Wreck_Hull'].hide_render=False;DMG['Wreck_Turret'].hide_render=False
def blown():
    wreck();o=DMG['Wreck_Turret'];o.location=(3.4,.85,1.03);o.rotation_euler=(.40,1.95,.30)
    bpy.context.view_layer.update();low=min((o.matrix_world@v.co).z for v in o.data.vertices);o.location.z-=low
grid_render('states.png',[healthy,hit,stripped,immobilised,wreck,blown])
# Restore straight-ahead complete asset in source proof scene for diagnostics.
DMG['Wreck_Turret'].location=PIVOTS['Turret'];DMG['Wreck_Turret'].rotation_euler=(0,0,0);visibility()
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'t14-review.blend'))
print('COMPLETE: clean build, GPU bakes, two exports and imported binary proofs.',flush=True)
