"""T-90 style game asset. Run in Blender 5.2 background; no input blend required.
CC0 scan sources are downloaded directly from Poly Haven. All shipping shaders
are Cycles-baked reusable tiles; the host Python Pillow installation encodes JPEG textures.
Budget A: 100k-160k triangles, 8 materials, <=12 MB. Budget B: <=110k triangles, 5 materials.
v3 retrofit: preserve the v2 geometry and baked look; split detachable assemblies and add a matched damage kit.
Proofs: front three-quarter, orthographic side, clay, six paints, six imported damage states and articulated pivots.
Axes during authoring: X left, -Y forward, Z up; glTF exporter converts to Y up.
"""
import bpy, math, random, json, os, sys, struct, urllib.request, subprocess, hashlib
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
random.seed(90)
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
PAINTS=[('Olive drab',[.12,.14,.08]),('NATO green',[.075,.12,.065]),('Desert tan',[.48,.36,.20]),('Field grey',[.19,.22,.19]),('Winter white',[.78,.80,.76]),('Dark green-black',[.027,.043,.026])]
PH_IDS=['painted_metal_shutter','rusty_metal_02','bark_brown_02','rubber_tiles','denim_fabric']
bpy.ops.wm.read_factory_settings(use_empty=True)
scene=bpy.context.scene;scene.unit_settings.system='METRIC';scene.unit_settings.scale_length=1
scene.render.engine='CYCLES';scene.cycles.samples=16;scene.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
try:prefs.compute_device_type='OPTIX';prefs.get_devices()
except:prefs.compute_device_type='CUDA';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
assert any(d.use for d in prefs.devices),'GPU required'
scene.cycles.device='GPU';scene.render.bake.margin=8
scene.render.image_settings.file_format='PNG'
scene.view_settings.view_transform='AgX';scene.view_settings.look='AgX - Medium High Contrast'
print('GPU',[(d.name,d.type,d.use) for d in prefs.devices],flush=True)

# Acquire only CC0 photographic source maps, with a browser user agent for CDN.
def source(asset,kind):
    p=TEX/f'{asset}_{kind}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
        req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
        try:
            with urllib.request.urlopen(req,timeout=90) as r:p.write_bytes(r.read())
        except Exception:
            subprocess.run(['curl','--fail','-L','-A','Mozilla/5.0',url,'-o',str(p)],check=True)
    im=bpy.data.images.load(str(p),check_existing=True)
    if kind!='diff':im.colorspace_settings.name='Non-Color'
    return im
sources={} # lazy source loading: preserved v2 bakes take priority

# Bake eight material tiles, using aligned scan albedo/normal/ARM and procedural
# fine wear/grime. This retains physical texel density rather than one tank atlas.
def bake_material(name,asset,color,rough,metal,res=1024,neutral=False):
    cache={k:TEX/f'{name}_{k}.{ "png" if k=="orm" else "jpg"}' for k in ['base','normal','orm']}
    if all(p.exists() for p in cache.values()):
        baked={k:bpy.data.images.load(str(p),check_existing=True) for k,p in cache.items()}
        for k in ['normal','orm']:baked[k].colorspace_settings.name='Non-Color'
        return tile_material(name,baked,neutral)
    if asset not in sources:sources[asset]={k:source(asset,k) for k in ['diff','nor_gl','arm']}
    m=bpy.data.materials.new(name+'_source');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')
    imgs={}
    for k in ['diff','nor_gl','arm']:
        q=n.new('ShaderNodeTexImage');q.image=sources[asset][k];l.new(uv.outputs['UV'],q.inputs['Vector']);imgs[k]=q
    bw=n.new('ShaderNodeRGBToBW');l.new(imgs['diff'].outputs['Color'],bw.inputs[0])
    ramp=n.new('ShaderNodeValToRGB');l.new(bw.outputs[0],ramp.inputs[0])
    if neutral:
        ramp.color_ramp.elements[0].position=.08;ramp.color_ramp.elements[0].color=(.62,.62,.62,1)
        ramp.color_ramp.elements[1].position=.72;ramp.color_ramp.elements[1].color=(.94,.94,.94,1)
    else:
        ramp.color_ramp.elements[0].color=tuple(c*.38 for c in color)+(1,)
        ramp.color_ramp.elements[1].color=tuple(min(1,c*1.8) for c in color)+(1,)
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=85;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
    mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.18;l.new(ramp.outputs[0],mix.inputs[1]);l.new(noise.outputs['Fac'],mix.inputs[2])
    l.new(mix.outputs[0],bs.inputs['Base Color'])
    if neutral:
        dirt=n.new('ShaderNodeTexNoise');dirt.inputs['Scale'].default_value=5;dirt.inputs['Detail'].default_value=5;dirt.inputs['Roughness'].default_value=.75;l.new(uv.outputs['UV'],dirt.inputs['Vector'])
        stains=n.new('ShaderNodeValToRGB');l.new(dirt.outputs['Fac'],stains.inputs[0])
        stains.color_ramp.elements[0].position=.28;stains.color_ramp.elements[0].color=(.12,.12,.12,1)
        stains.color_ramp.elements[1].position=.63;stains.color_ramp.elements[1].color=(1,1,1,1)
        stainmix=n.new('ShaderNodeMixRGB');stainmix.blend_type='MULTIPLY';stainmix.inputs[0].default_value=.72;l.new(mix.outputs[0],stainmix.inputs[1]);l.new(stains.outputs[0],stainmix.inputs[2]);l.new(stainmix.outputs[0],bs.inputs['Base Color'])
    if name=='Burnt':
        char=n.new('ShaderNodeTexNoise');char.inputs['Scale'].default_value=4.5;char.inputs['Detail'].default_value=5;char.inputs['Roughness'].default_value=.8;l.new(uv.outputs['UV'],char.inputs['Vector'])
        ash=n.new('ShaderNodeValToRGB');l.new(char.outputs['Fac'],ash.inputs[0]);cr=ash.color_ramp
        cr.elements[0].position=.27;cr.elements[0].color=(.004,.0045,.0045,1)
        cr.elements[1].position=.76;cr.elements[1].color=(.105,.045,.015,1)
        for pos,col in [(.43,(.012,.013,.012,1)),(.57,(.058,.061,.057,1)),(.66,(.17,.16,.14,1))]:cr.elements.new(pos).color=col
        char_mix=n.new('ShaderNodeMixRGB');char_mix.blend_type='MULTIPLY';char_mix.inputs[0].default_value=.27;l.new(ash.outputs[0],char_mix.inputs[1]);l.new(bw.outputs[0],char_mix.inputs[2]);l.new(char_mix.outputs[0],bs.inputs['Base Color'])
    if name=='Lamps':
        uvsep=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],uvsep.inputs[0]);white=n.new('ShaderNodeMath');white.operation='GREATER_THAN';white.inputs[1].default_value=.5;l.new(uvsep.outputs['X'],white.inputs[0])
        palette=n.new('ShaderNodeMixRGB');l.new(white.outputs[0],palette.inputs[0]);l.new(mix.outputs[0],palette.inputs[1]);palette.inputs[2].default_value=(.28,.34,.30,1);l.new(palette.outputs[0],bs.inputs['Base Color'])
    norm=n.new('ShaderNodeNormalMap');norm.inputs['Strength'].default_value=.28 if neutral else .5;l.new(imgs['nor_gl'].outputs['Color'],norm.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.14;bump.inputs['Distance'].default_value=.0015;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(norm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
    split=n.new('ShaderNodeSeparateColor');l.new(imgs['arm'].outputs[0],split.inputs[0])
    rr=n.new('ShaderNodeMapRange');rr.inputs['To Min'].default_value=max(.1,rough-.14);rr.inputs['To Max'].default_value=min(1,rough+.10);l.new(split.outputs['Green'],rr.inputs['Value']);l.new(rr.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
    bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-20));plane=bpy.context.object;plane.data.materials.append(m)
    target=n.new('ShaderNodeTexImage');n.active=target
    baked={}
    for kind in ['base','normal','orm']:
        size=min(res,512) if kind=='orm' else res
        im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
        if kind!='base':im.colorspace_settings.name='Non-Color'
        target.image=im;n.active=target
        if kind=='base':bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
        elif kind=='normal':bpy.ops.object.bake(type='NORMAL')
        else:
            em=n.new('ShaderNodeEmission');combine=n.new('ShaderNodeCombineColor')
            ao=n.new('ShaderNodeMapRange');ao.inputs['To Min'].default_value=.78;ao.inputs['To Max'].default_value=1;l.new(split.outputs['Red'],ao.inputs['Value']);l.new(ao.outputs[0],combine.inputs['Red']);l.new(rr.outputs[0],combine.inputs['Green']);combine.inputs['Blue'].default_value=metal
            l.new(combine.outputs[0],em.inputs['Color']);l.new(em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
        im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save()
        if kind in ['base','normal']:
            jpg=TEX/f'{name}_{kind}.jpg'
            subprocess.run(['python3','-c',"from PIL import Image; import sys; Image.open(sys.argv[1]).convert('RGB').save(sys.argv[2],quality=int(sys.argv[3]),subsampling=0)",im.filepath_raw,str(jpg),'94' if kind=='normal' else '90'],check=True)
            im=bpy.data.images.load(str(jpg),check_existing=False)
            if kind=='normal':im.colorspace_settings.name='Non-Color'
        baked[kind]=im
    bpy.data.objects.remove(plane,do_unlink=True);bpy.data.materials.remove(m)
    return tile_material(name,baked,neutral)

def tile_material(name,baked,neutral=False):
    mat=bpy.data.materials.new(name);mat.use_nodes=True;nt=mat.node_tree;n=nt.nodes;l=nt.links;bs=n.get('Principled BSDF')
    tex=n.new('ShaderNodeTexImage');tex.image=baked['base'];tex.label='Cycles baked CC0 scan + neutral grime' if neutral else 'Cycles baked CC0 scan + wear'
    if neutral:
        mul=n.new('ShaderNodeMixRGB');mul.name='PaintFactor';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=tuple(PAINTS[0][1])+(1,);l.new(tex.outputs[0],mul.inputs[1]);l.new(mul.outputs[0],bs.inputs['Base Color'])
    else:l.new(tex.outputs[0],bs.inputs['Base Color'])
    nm=n.new('ShaderNodeTexImage');nm.image=baked['normal'];normal=n.new('ShaderNodeNormalMap');l.new(nm.outputs[0],normal.inputs['Color']);l.new(normal.outputs[0],bs.inputs['Normal'])
    orm=n.new('ShaderNodeTexImage');orm.image=baked['orm'];sep=n.new('ShaderNodeSeparateColor');l.new(orm.outputs[0],sep.inputs[0]);l.new(sep.outputs['Green'],bs.inputs['Roughness']);l.new(sep.outputs['Blue'],bs.inputs['Metallic'])
    group=bpy.data.node_groups.get('glTF Material Output')
    if not group:
        group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=n.new('ShaderNodeGroup');gn.node_tree=group;l.new(sep.outputs['Red'],gn.inputs['Occlusion'])
    bs.inputs['Coat Weight'].default_value=0
    print('MATERIAL',name,flush=True)
    return mat
mats={}
for args in [('Paint','rusty_metal_02',(1,1,1),.82,.04,1024,True),('Rubber','rubber_tiles',(.022,.025,.024),.91,0,1024,False),('Steel','rusty_metal_02',(.16,.175,.135),.59,.58,1024,False),('TrackSteel','rusty_metal_02',(.075,.068,.054),.75,.68,1024,False),('Wood','bark_brown_02',(.23,.12,.051),.94,0,1024,False),('Glass','painted_metal_shutter',(.014,.055,.075),.2,.28,256,False),('Lamps','painted_metal_shutter',(.20,.008,.011),.14,.40,256,False),('Canvas','denim_fabric',(.17,.14,.10),.94,0,512,False)]:
    mats[args[0]]=bake_material(*args)

# Geometry builders. All primitives are transformed before bevels and each
# mechanical assembly is consolidated into its explicitly named mesh node.
parts={};current='Hull';detach=None
def reg(o,mat):
    o.data.materials.append(mats[mat]);parts.setdefault(current,[]).append(o)
    if detach:
        vg=o.vertex_groups.new(name=detach);vg.add(list(range(len(o.data.vertices))),1,'REPLACE')
    return o
def active(o):
    bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o
def bevel(o,w=.01,seg=2):
    if w:
        mod=o.modifiers.new('Machined edge radii','BEVEL');mod.width=w;mod.segments=seg
        active(o);bpy.ops.object.modifier_apply(modifier=mod.name)
    return o
def box(label,loc,dim,mat='Paint',bev=.012,rot=None):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=label;
    for v in o.data.vertices:
        v.co.x*=dim[0];v.co.y*=dim[1];v.co.z*=dim[2]
    o.data.update()
    if rot:o.rotation_euler=rot
    reg(o,mat);bevel(o,bev,1 if bev<=.012 else 2);return o
def mesh(label,verts,faces,mat='Paint',bev=0,smooth=False):
    me=bpy.data.meshes.new(label);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(label,me);bpy.context.collection.objects.link(o);reg(o,mat)
    if smooth:
        for p in me.polygons:p.use_smooth=True
    bevel(o,bev);return o
def cyl(label,a,b,r,mat='Paint',vertices=24,r2=None):
    a=Vector(a);b=Vector(b);d=b-a
    bpy.ops.mesh.primitive_cone_add(vertices=vertices,radius1=r,radius2=r if r2 is None else r2,depth=d.length,location=(a+b)*.5)
    o=bpy.context.object;o.name=label;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();reg(o,mat)
    for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
    bevel(o,min(.006,r*.09),1 if r<.07 else 2);return o
def tube(label,points,r,mat='Steel',sides=8):
    verts=[]
    for i,p in enumerate(points):
        t=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(0,i-1)])
        t.normalize();n=t.cross(Vector((0,0,1)))
        if n.length<.01:n=t.cross(Vector((0,1,0)))
        n.normalize();b=t.cross(n)
        for j in range(sides):verts.append(Vector(p)+r*(n*math.cos(j*2*math.pi/sides)+b*math.sin(j*2*math.pi/sides)))
    faces=[]
    for i in range(len(points)-1):
        for j in range(sides):faces.append((i*sides+j,i*sides+(j+1)%sides,(i+1)*sides+(j+1)%sides,(i+1)*sides+j))
    faces.extend([tuple(reversed(range(sides))),tuple(range((len(points)-1)*sides,len(points)*sides))]);return mesh(label,verts,faces,mat,0,True)
def lathe_x(label,x,y,z,profile,mat='Paint',segments=48):
    vs=[(x+ax,y+r*math.cos(t*2*math.pi/segments),z+r*math.sin(t*2*math.pi/segments)) for ax,r in profile for t in range(segments)]
    fs=[]
    for k in range(len(profile)-1):
        for t in range(segments):fs.append((k*segments+t,k*segments+(t+1)%segments,(k+1)*segments+(t+1)%segments,(k+1)*segments+t))
    return mesh(label,vs,fs,mat,0,True)
def bolt(loc,axis='Z',r=.018,mat='Steel'):
    p=Vector(loc);d=Vector({'X':(1,0,0),'Y':(0,1,0),'Z':(0,0,1)}[axis])*.016
    return cyl('Hex fastener',p-d*.5,p+d*.5,r,mat,6)
def handle(p,width=.15,height=.035,axis='X',mat='Steel'):
    x,y,z=p
    if axis=='X':pts=[(x-width/2,y,z),(x-width/2,y,z+height),(x+width/2,y,z+height),(x+width/2,y,z)]
    else:pts=[(x,y-width/2,z),(x,y-width/2,z+height),(x,y+width/2,z+height),(x,y+width/2,z)]
    return tube('Grab handle',pts,.011,mat,8)
def panel_bolts(cx,cy,z,w,h):
    for dx in [-w/2+.045,w/2-.045]:
        for dy in [-h/2+.045,h/2-.045]:bolt((cx+dx,cy+dy,z+.007),'Z',.014)

# Welded hull: faceted bow, chamfered shoulders, narrow lower tub.
sections=[(-3.43,1.28,.71,1.02),(-3.10,1.53,.48,1.24),(-2.15,1.53,.43,1.37),(2.85,1.53,.44,1.37),(3.30,1.38,.60,1.22)]
vs=[]
for y,w,low,top in sections:vs.extend([(-w+.12,y,low),(-w,y,low+.15),(-w,y,top-.12),(-w+.14,y,top),(w-.14,y,top),(w,y,top-.12),(w,y,low+.15),(w-.12,y,low)])
fs=[]
for i in range(len(sections)-1):
    for j in range(8):fs.append((i*8+j,i*8+(j+1)%8,(i+1)*8+(j+1)%8,(i+1)*8+j))
fs.extend([tuple(reversed(range(8))),tuple(range(32,40))]);mesh('Armoured welded hull',vs,fs,'Paint',.025)
# Distinct inclined glacis with ERA tiles and seams.
for row,y in enumerate([-2.91,-2.48]):
    detach=f'Part_Glacis_ERA{row+1}'
    for x in [-.96,-.48,0,.48,.96]:
        z=1.265+(y+2.91)*.14
        box('Glacis applique armour',(x,y,z),(.46,.40,.065),'Paint',.012,(.14,0,0))
        panel_bolts(x,y,z+.04,.44,.37)
detach='Hatch_Driver'
box('Driver hatch',(-.27,-1.84,1.405),(.64,.64,.055),'Paint',.055)
handle((-.27,-1.72,1.44),.21)
detach='Part_Driver_Sight'
box('Driver periscope housing',(-.27,-2.15,1.45),(.30,.11,.10),'Paint',.012)
box('Driver periscope glass',(-.27,-2.21,1.46),(.23,.008,.058),'Glass',.004)
detach=None
# Bow tow lugs, welded towing beam and guards.
cyl('Bow tow bar',(-1.28,-3.41,.91),(1.28,-3.41,.91),.044,'Steel',24)
for x in [-1.03,1.03]:
    box('Tow lug foundation',(x,-3.33,.79),(.20,.13,.20),'Paint',.025)
    pts=[(x-.07,-3.44,.8),(x-.08,-3.49,.64),(x+.08,-3.49,.64),(x+.07,-3.44,.8)]
    tube('Tow shackle',pts,.027,'Steel',12)
# Fenders, skirt panels, their hinges and front ERA sections.
for s in [-1,1]:
    side='L' if s>0 else 'R';detach=f'Part_Fender_R{side}'
    box('Fender deck',(s*1.61,0,1.36),(.54,6.26,.10),'Paint',.025)
    # front fender angled nose
    detach=f'Part_Fender_F{side}'
    box('Front mudguard',(s*1.62,-3.19,1.18),(.54,.50,.08),'Rubber',.018,(.34,0,0))
    for j in range(7):
        y=-2.65+j*.84
        detach=f'Part_Skirt_{side}{j+1}'
        box('Flexible rubber skirt',(s*1.815,y,1.145),(.068,.805,.37),'Rubber',.009)
        cyl('Skirt hinge',(s*1.855,y-.32,1.335),(s*1.855,y+.32,1.335),.022,'Steel',12)
        for yy in [y-.29,y+.29]:bolt((s*1.862,yy,1.28),'X',.019)
        if j<3:
            box('Side reactive armour',(s*1.855,y,1.18),(.07,.755,.32),'Paint',.013)
            for yy in [y-.31,y+.31]:
                for z in [1.08,1.30]:bolt((s*1.898,yy,z),'X',.018)
    for y in [-2.15,-1.05,.05,1.15,2.25]:
        detach=f'Part_Stowage_{side}{round((y+2.15)/1.1)+1}'
        box('Fender storage chest',(s*1.61,y,1.435),(.47,.99,.085),'Paint',.025)
        box('Storage lid lip',(s*1.61,y,1.478),(.48,1.0,.025),'Paint',.007)
        handle((s*1.61,y,1.491),.17)
        for yy in [y-.38,y+.38]:box('Chest latch',(s*1.87,yy,1.442),(.024,.055,.065),'Steel',.004)
    # lamp at front, red Lamps material reserved for dazzlers, headlight uses Glass
    detach=None
    cyl('Headlamp casing',(s*1.30,-2.85,1.39),(s*1.30,-3.00,1.39),.095,'Paint')
    # Reflector bowl and proud bezel surrounding a shallow domed lens.
    vv=[];ff=[]
    for yy,rr in [(-3.004,.094),(-3.036,.094),(-3.037,.073),(-3.010,.073)]:
        for j in range(32):
            a=j*math.tau/32;vv.append((s*1.30+rr*math.cos(a),yy,1.39+rr*math.sin(a)))
    for k in range(4):
        for j in range(32):ff.append((k*32+j,k*32+(j+1)%32,((k+1)%4)*32+(j+1)%32,((k+1)%4)*32+j))
    mesh('Headlamp deep reflector bezel',vv,ff,'Steel',.002,True)
    vv=[(s*1.30,-3.032,1.39)];ff=[]
    for k in range(1,5):
        rr=.072*k/4;yy=-3.032+.016*(k/4)**2
        for j in range(32):a=j*math.tau/32;vv.append((s*1.30+rr*math.cos(a),yy,1.39+rr*math.sin(a)))
    for j in range(32):ff.append((0,1+j,1+(j+1)%32))
    for k in range(3):
        for j in range(32):ff.append((1+k*32+j,1+(k+1)*32+j,1+(k+1)*32+(j+1)%32,1+k*32+(j+1)%32))
    mesh('Domed recessed headlamp lens',vv,ff,'Lamps',0,True)['lamp_white']=True
    detach=f'Part_LampGuard_{side}'
    tube('Lamp guard',[(s*1.30-.13,-3.01,1.31),(s*1.30-.13,-3.03,1.52),(s*1.30+.13,-3.03,1.52),(s*1.30+.13,-3.01,1.31)],.013,'Steel')
detach=None
# Rear engine deck: true recesses with separate grille bars, hinges, filler caps.
box('Engine deck',(0,2.13,1.405),(2.51,2.02,.07),'Paint',.018)
for x in [-.66,.66]:
    detach=None
    box('Engine grille recess',(x,2.14,1.446),(.98,1.38,.025),'Rubber',.008)
    detach='Part_EngineGrille_'+('L' if x>0 else 'R')
    for k in range(16):box('Engine cooling slat',(x,1.51+k*.083,1.472),(.95,.033,.028),'Steel',.006)
    for yy in [1.43,2.86]:box('Grille frame',(x,yy,1.469),(1.05,.045,.04),'Paint',.008)
    for xx in [x-.51,x+.51]:box('Grille side frame',(xx,2.14,1.468),(.035,1.47,.04),'Paint',.006)
detach=None
for x in [-1.1,1.1]:
    cyl('Fuel filler',(x,2.92,1.46),(x,2.92,1.505),.104,'Paint');handle((x,2.92,1.51),.11,.018)
box('Rear exhaust dark opening',(-1.535,2.65,1.17),(.07,.62,.24),'Rubber',.012)
detach='Part_ExhaustShield'
box('Exhaust top shield',(-1.59,2.65,1.305),(.19,.69,.055),'Steel',.009)
# Recovery timber: cylinder with irregular bark silhouette and real retaining straps.
detach='Part_RecoveryLog'
log=cyl('Recovery log',(-1.68,3.265,1.07),(1.68,3.265,1.07),.147,'Wood',48)
for v in log.data.vertices:
    v.co.x*=1+random.uniform(-.018,.018);v.co.y*=1+random.uniform(-.018,.018)
for x in [-1.12,1.12]:
    detach='Part_RecoveryLog'
    tube('Log retaining strap',[(x,3.10,.91),(x,3.37,.94),(x,3.42,1.07),(x,3.37,1.19),(x,3.10,1.21)],.024,'Steel',8)
    detach=None
    box('Log bracket',(x,3.14,.93),(.11,.19,.09),'Paint',.012)
    for dx in [-.03,.03]:bolt((x+dx,3.245,.93),'Y',.014)
# Long tow cables follow the hull shoulders, with eyes.
for s in [-1,1]:
    detach='Part_TowCable_'+('L' if s>0 else 'R')
    pts=[(s*1.24,-2.97,1.28),(s*1.39,-2.45,1.43),(s*1.42,-1.5,1.44),(s*1.42,0,1.44),(s*1.42,1.55,1.44),(s*1.30,2.94,1.44)]
    tube('Braided tow cable',pts,.021,'Steel',10)
    for p in [pts[0],pts[-1]]:
        x,y,z=p;tube('Cable eye',[(x-.05,y,z),(x-.085,y+.12,z),(x,y+.20,z),(x+.085,y+.12,z),(x+.05,y,z)],.018,'Steel',10)

detach=None
print('HULL_BUILT',sum(map(len,parts.values())),flush=True)
# Wheel assemblies: separate hubs, rounded rubber tyres, pressed wheel dishes,
# radial stiffeners, recessed pockets and exposed hub fasteners.
wheel_pivots={}
for side,s in [('L',1),('R',-1)]:
    ys=[-2.87,-2.13,-1.28,-.43,.42,1.27,2.12,2.86]
    for k,y in enumerate(ys):
        current=f'Wheel_{side}{k+1}';z=.71 if k in [0,7] else .49;r=.34 if k in [0,7] else .415;x=s*1.525
        wheel_pivots[current]=(x,y,z)
        # mirrored radial profile remains winding-consistent after normal recalculation
        profile=[(-.18,0),(-.18,r*.75),(-.165,r*.94),(-.12,r),(.12,r),(.165,r*.94),(.18,r*.75),(.18,0)]
        lathe_x('Rubber running tyre',x,y,z,profile,'Rubber',48)
        outward=x+s*.18
        prof=[(s*0,r*.78),(s*.025,r*.79),(s*.045,r*.71),(s*.05,r*.58),(s*.00,r*.32),(s*.035,r*.20)]
        lathe_x('Pressed steel wheel dish',outward,y,z,prof,'Paint',48)
        cyl('Hub boss',(outward,y,z),(outward+s*.095,y,z),r*.24,'Paint',32)
        cyl('Hub cap',(outward+s*.095,y,z),(outward+s*.111,y,z),r*.16,'Steel',24)
        count=6 if k not in [0,7] else 8
        for j in range(count):
            t=j*2*math.pi/count;dy=math.cos(t);dz=math.sin(t)
            cyl('Recessed wheel pocket',(outward+s*.036,y+dy*r*.51,z+dz*r*.51),(outward+s*.041,y+dy*r*.51,z+dz*r*.51),r*.115,'Rubber',12)
            bolt((outward+s*.073,y+dy*r*.28,z+dz*r*.28),'X',.017)
            # radial rib between recesses
            tt=t+math.pi/count
            o=box('Wheel dish rib',(outward+s*.043,y+math.cos(tt)*r*.52,z+math.sin(tt)*r*.52),(.018,r*.31,.045),'Paint',.005)
            o.rotation_euler.x=tt
        if k==7:
            for j in range(16):
                t=j*2*math.pi/16
                o=box('Drive sprocket tooth',(x,y+math.cos(t)*.345,z+math.sin(t)*.345),(.33,.073,.08),'Steel',.008);o.rotation_euler.x=t
print('WHEELS_BUILT',sum(map(len,parts.values())),flush=True)
# Closed track path sampled by arc length. Each link has tread bars, hinge pins
# and inward guide teeth. V follows increasing belt distance on every face.
for side,s in [('L',1),('R',-1)]:
    current=f'Track_{side}';x=s*1.45
    path=[]
    # path front top -> rear top -> rear wrap -> bottom -> front wrap
    for i in range(41):
        t=i/40;path.append((-2.87+5.73*t,1.065-.045*math.sin(math.pi*t)))
    for i in range(1,21):
        a=math.pi/2-i*math.pi/20;path.append((2.86+.355*math.cos(a),.71+.355*math.sin(a)))
    path.extend([(2.52,.20),(2.25,.073),(-2.24,.073),(-2.55,.21),(-2.87,.355)])
    for i in range(1,21):
        a=-math.pi/2-i*math.pi/20;path.append((-2.87+.355*math.cos(a),.71+.355*math.sin(a)))
    path.append(path[0]);lens=[0]
    for a,b in zip(path,path[1:]):lens.append(lens[-1]+math.dist(a,b))
    total=lens[-1];N=100
    def sample(t):
        t%=total
        for j in range(len(lens)-1):
            if lens[j+1]>=t:
                f=(t-lens[j])/(lens[j+1]-lens[j]);a=Vector(path[j]);b=Vector(path[j+1]);return a.lerp(b,f),(b-a).normalized()
    for i in range(N):
        p,d=sample((i+.5)*total/N);y,z=p;ang=math.atan2(d.y,d.x)
        o=box('Cast track shoe',(x,y,z),(.62,total/N*.91,.068),'TrackSteel',.007,(ang,0,0))
        # Project all link component UVs later with per-link local longitudinal V.
        o['belt_total']=total;o['belt_v']=(i+.5)*total/N;o['belt_ang']=ang;o['belt_center']=(x,y,z)
        for delta in [-.044,.044]:
            yy=y+math.cos(ang)*delta;zz=z+math.sin(ang)*delta
            o=box('Raised traction grouser',(x,yy-math.sin(ang)*.044,zz+math.cos(ang)*.044),(.60,.026,.026),'TrackSteel',.004,(ang,0,0))
            o['belt_total']=total;o['belt_v']=(i+.5)*total/N;o['belt_ang']=ang;o['belt_center']=(x,y,z)
        # steel pin heads on both exposed edges
        for edge in [-1,1]:
            o=cyl('Track hinge pin',(x+edge*.298,y,z),(x+edge*.327,y,z),.028,'Steel',10)
            o['belt_total']=total;o['belt_v']=(i+.5)*total/N;o['belt_ang']=ang;o['belt_center']=(x,y,z)
        if i%2==0:
            normal=Vector((0,math.sin(ang),-math.cos(ang)));along=Vector((0,math.cos(ang),math.sin(ang)));base=Vector((x,y,z))+normal*.038
            vv=[base+Vector((xx,0,0))+along*yy for xx,yy in [(-.035,-.045),(.035,-.045),(.035,.045),(-.035,.045)]]
            vv.extend([base+normal*.090+Vector((xx,0,0)) for xx in [-.016,.016]])
            o=mesh('Tapered inner track guide',vv,[(3,2,1,0),(0,1,5,4),(1,2,5),(2,3,4,5),(3,0,4)],'TrackSteel',.003);o['belt_total']=total;o['belt_v']=(i+.5)*total/N;o['belt_ang']=ang;o['belt_center']=(x,y,z)

print('TRACKS_BUILT',sum(map(len,parts.values())),flush=True)
# Raised cast turret: continuous rounded shoulder over a visible circular race.
current='Turret';ring_y=-.25
rings=[(1.40,1.04,1.06),(1.46,1.16,1.18),(1.56,1.23,1.26),
       (1.73,1.245,1.29),(1.92,1.18,1.23),(2.09,1.025,1.10),
       (2.19,.86,.94),(2.24,.68,.76),(2.255,.52,.60)]
vs=[];ns=80
for z,rx,ry in rings:
    for j in range(ns):
        a=j*math.tau/ns;x=rx*math.cos(a);y=ring_y+ry*math.sin(a)
        if y<-.85:y=-.85+(y+.85)*.88
        vs.append((x,y,z+random.uniform(-.0015,.0015)))
fs=[]
for i in range(len(rings)-1):
    for j in range(ns):fs.append((i*ns+j,i*ns+(j+1)%ns,(i+1)*ns+(j+1)%ns,(i+1)*ns+j))
fs.extend([tuple(reversed(range(ns))),tuple(range((len(rings)-1)*ns,len(rings)*ns))]);mesh('Cast turret shell',vs,fs,'Paint',0,True)
cyl('Turret race',(0,ring_y,1.365),(0,ring_y,1.465),1.075,'Steel',80)
cyl('Painted turret ring retaining collar',(0,ring_y,1.375),(0,ring_y,1.420),1.10,'Paint',80)
# Two straight chevrons of Kontakt-style wedge modules flank a narrow mantlet.
for s in [-1,1]:
    side='L' if s>0 else 'R'
    tangent=Vector((s*.735,.678,0));radial=Vector((s*.678,-.735,0))
    for k in range(3):
        detach=f'Part_Turret_ERA_{side}{k+1}'
        center=Vector((s*(.49+k*.335),-1.43+k*.309,1.86))
        # Broad upper sloping face, sharp projecting ridge, returned lower face.
        profile=[(-.18,-.28),(.07,-.28),(.35,-.035),(.08,.27),(-.18,.27)]
        vv=[center+tangent*u+radial*d+Vector((0,0,z)) for d,z in profile for u in [-.217,.217]]
        ff=[]
        for j in range(5):ff.append((j*2,j*2+1,((j+1)%5)*2+1,((j+1)%5)*2))
        ff.extend([(0,2,4,6,8),(1,9,7,5,3)])
        mesh('Chevron reactive armour cassette',vv,ff,'Paint',.013)
        detach=None
        for u in [-.145,.145]:
            # Exposed ears join each cassette back to the curved cast cheek.
            a=center+tangent*u-radial*.15+Vector((0,0,-.12))
            b=center+tangent*u-radial*.42+Vector((0,0,-.12))
            cyl('ERA mounting spacer',a,b,.032,'Steel',16)
            box('ERA bolted mounting ear',a,(.08,.08,.095),'Paint',.009)

        detach=f'Part_Turret_ERA_{side}{k+1}'
        for u in [-.213,.213]:
            tube('ERA folded edge trim',[center+tangent*u+radial*.075+Vector((0,0,-.26)),center+tangent*u+radial*.352+Vector((0,0,-.035)),center+tangent*u+radial*.085+Vector((0,0,.26))],.008,'Steel',6)
        for u in [-.17,.17]:
            q=center+tangent*u+radial*.10+Vector((0,0,.272));bolt(q,'Z',.012,'Steel')
    # Roof ERA sits on the rounded shoulder behind the primary front chevrons.
    detach=f'Part_Turret_ShoulderERA_{side}'
    for k in range(3):
        xx=s*(.40+k*.255);yy=-1.02+k*.21
        box('Shoulder ERA upper tile',(xx,yy,2.165),(.26,.30,.085),'Paint',.011,(.30,-s*.24,s*.50))
    detach=f'Part_Turret_Bin_{side}'
    box('Turret rear flank stowage',(s*1.14,.64,1.935),(.35,.90,.39),'Paint',.035)
    box('Turret flank lid',(s*1.14,.64,2.142),(.38,.94,.035),'Paint',.009)
    handle((s*1.14,.64,2.17),.22)
    for y in [.30,.97]:box('Turret stowage latch',(s*1.325,y,1.95),(.025,.06,.14),'Steel',.004)
    # Shtora emitters on individual mounts, visibly beside the narrow barrel boot.
    detach=None
    box('Dazzler mounting foot',(s*.56,-1.40,1.86),(.18,.34,.15),'Steel',.015)
    detach=f'Part_Turret_Dazzler_{side}'
    box('Shtora armoured housing',(s*.59,-1.66,1.925),(.34,.30,.34),'Paint',.027)
    cyl('Dazzler bezel',(s*.59,-1.815,1.925),(s*.59,-1.845,1.925),.128,'Steel',48)
    cyl('IR ruby lens',(s*.59,-1.846,1.925),(s*.59,-1.852,1.925),.106,'Lamps',48)['lamp_red']=True
    for k in range(6):box('Dazzler cooling fin',(s*.59,-1.78+k*.035,2.101),(.27,.012,.017),'Steel',.003)
    for dx in [-.151,.151]:box('Dazzler side frame',(s*.59+dx,-1.86,1.925),(.023,.054,.327),'Paint',.004)
    for zz in [1.774,2.076]:box('Dazzler cross frame',(s*.59,-1.86,zz),(.30,.054,.024),'Paint',.004)
    # Two banks of three angled smoke launchers with supporting bracket plate.
    detach=f'Part_Turret_Smoke_{side}'
    box('Smoke bank bracket',(s*1.19,-.20,1.93),(.12,.50,.33),'Paint',.014,(0,-s*.22,0))
    for k in range(6):
        xx=s*(1.18+(k%3)*.075);yy=-.39+(k//3)*.18;zz=2.00-(k%3)*.10
        a=Vector((xx,yy,zz));b=a+Vector((s*.20,-.17,.21))
        cyl('Smoke launcher tube',a,b,.052,'Steel',24)
        cyl('Smoke launcher rim',b-(b-a).normalized()*.018,b,.059,'Steel',24)
        cyl('Smoke launcher cap',b,b+(b-a).normalized()*.008,.046,'Rubber',24)
# Raised rear bustle storage with wrapped bands and distinct lid seams.
detach='Part_Turret_RearBin'
box('Rear turret stowage bin',(0,1.01,1.98),(1.69,.39,.37),'Paint',.055)
box('Rear bin lid',(0,1.01,2.177),(1.72,.415,.035),'Paint',.010)
for x in [-.67,.67]:
    box('Rear bin retaining band',(x,1.218,1.99),(.045,.018,.32),'Steel',.004)
    handle((x,1.01,2.20),.17)
detach=None
roof_before={id(o) for group in parts.values() for o in group}
# Cast-to-machined roof weld beads and rear bracket fasteners.
for x in [-.57,.54]:
    pts=[(x+.295*math.cos(j*math.tau/48),.15+.295*math.sin(j*math.tau/48),2.008+random.uniform(-.002,.002)) for j in range(49)]
    tube('Hatch coaming weld',pts,.008,'Steel',6)
# Roof plates, optics and sensors.
for x,y,w,h in [(-.57,-.04,.56,.63),(.54,.15,.56,.64),(.10,-.77,.45,.22)]:
    box('Roof welded plate',(x,y,1.995),(w,h,.04),'Paint',.018);panel_bolts(x,y,2.022,w,h)
detach='Part_Turret_RoofERA'
for x in [-.25,.03,.31]:box('Roof ERA tile',(x,-.93,1.94),(.24,.20,.10),'Paint',.010,(.16,0,0))
detach='Part_Turret_GunnerSight'
box('Gunner optical hood',(-.49,-.68,2.036),(.23,.25,.23),'Paint',.012)
box('Gunner optical glass',(-.49,-.812,2.052),(.16,.012,.135),'Glass',.004)
for x,y in [(-.95,.44),(.97,.55)]:
    detach='Part_Turret_Antenna_'+('L' if x>0 else 'R')
    cyl('Antenna shoulder mount',(x,y,1.64),(x,y,1.91),.058,'Paint',20)
    cyl('Antenna foot',(x,y,1.90),(x,y,2.03),.045,'Paint',20)
    cyl('Antenna spring',(x,y,2.03),(x,y,2.11),.016,'Steel',16)
    cyl('Short flexible antenna',(x,y,2.11),(x,y,2.23),.006,'Steel',8,r2=.002)
# Commander closed hatch as its own pivoting assembly.
detach='Part_Turret_CupolaRing'
cyl('Commander hatch coaming',(.54,.15,1.99),(.54,.15,2.042),.31,'Paint',48)
detach=None
current='Hatch_Commander'
cyl('Commander hatch lid',(.54,.15,2.04),(.54,.15,2.084),.285,'Paint',48)
handle((.54,.10,2.09),.18,.027)
cyl('Hatch hinge',(.31,.405,2.07),(.77,.405,2.07),.032,'Steel',20)
current='Turret'
detach='Hatch_Loader'
cyl('Loader hatch',(-.57,.15,2.00),(-.57,.15,2.052),.275,'Paint',48)
handle((-.57,.15,2.058),.17,.025)
detach='Part_Turret_CommanderSights'
# Commander periscope ring segments.
for i in range(5):
    a=math.radians(195+i*28);x=.54+.32*math.cos(a);y=.15+.32*math.sin(a)
    box('Commander periscope block',(x,y,2.066),(.11,.07,.075),'Paint',.008,(0,0,a))
    box('Commander periscope lens',(x,y,2.108),(.080,.045,.009),'Glass',.002,(0,0,a))

detach=None
for group in parts.values():
    for o in group:
        if id(o) not in roof_before:o.location.z+=.255
# Gun: mantlet, protective fabric bellows, concentric barrel sections and hollow bore.
current='Gun';gunz=1.925
cyl('Cast gun mantlet',(0,-1.02,gunz),(0,-1.64,gunz),.245,'Paint',48,r2=.125)
for j in range(6):
    y=-1.34-j*.044;cyl('Mantlet boot rib',(0,y,gunz),(0,y-.025,gunz),.175-j*.010,'Rubber',40)
# Tailored canvas mantlet cover: uneven folded rings with a clamped barrel cuff.
vs=[];fs=[];nseg=48
for k in range(15):
    t=k/14;y=-1.20-.54*t;r=.270*(1-t)+.137*t
    fold=.014*math.sin(t*math.pi*12)*(math.sin(math.pi*t)**.5)
    for j in range(nseg):
        a=j*math.tau/nseg;rr=r+fold+.006*math.sin(7*a+9*t)*math.sin(math.pi*t)
        vs.append((rr*math.cos(a),y+.007*math.sin(5*a)*math.sin(math.pi*t),gunz+rr*math.sin(a)))
for k in range(14):
    for j in range(nseg):fs.append((k*nseg+j,k*nseg+(j+1)%nseg,(k+1)*nseg+(j+1)%nseg,(k+1)*nseg+j))
mesh('Pleated mantlet canvas boot',vs,fs,'Canvas',0,True)
cyl('Canvas retaining cuff',(0,-1.735,gunz),(0,-1.77,gunz),.144,'Steel',48)
detach='Barrel'
# gun forward overall tip at -6.10, back hull to +3.43 =9.53
for y1,y2,r in [(-1.61,-2.15,.122),(-2.15,-3.37,.114),(-3.37,-4.18,.143),(-4.18,-4.88,.102),(-4.88,-5.76,.091),(-5.76,-5.92,.083)]:
    cyl('125 mm barrel sleeve',(0,y1,gunz),(0,y2,gunz),r,'Steel',48,r2=r*.97)
for y,r in [(-1.77,.135),(-2.16,.125),(-3.36,.13),(-3.42,.151),(-4.15,.151),(-4.23,.114),(-4.87,.109),(-5.74,.099)]:
    cyl('Thermal shroud clamp',(0,y-.017,gunz),(0,y+.017,gunz),r,'Steel',40)
# Hollow muzzle sleeve ring (no solid disk at front).
vs=[];fs=[]
for y,r in [(-6.10,.083),(-6.105,.064),(-5.92,.064),(-5.92,.081)]:
    for j in range(48):a=j*math.tau/48;vs.append((r*math.cos(a),y,gunz+r*math.sin(a)))
for k in range(4):
    for j in range(48):fs.append((k*48+j,k*48+(j+1)%48,((k+1)%4)*48+(j+1)%48,((k+1)%4)*48+j))
mesh('Muzzle bore steel ring',vs,fs,'Steel',0,True)
cyl('Dark bore interior',(0,-5.915,gunz),(0,-5.92,gunz),.063,'Rubber',32)
# Roof NSVT style machine gun, separate articulated mesh; compact stowed cradle.
detach=None
current='MG'
mg_before=set(id(o) for group in parts.values() for o in group)
detach='Part_Turret_MGStation'
box('MG pedestal',(.54,.29,2.12),(.17,.19,.15),'Paint',.010)
detach=None
box('Machine gun receiver',(.54,-.01,2.18),(.115,.45,.095),'Steel',.009)
cyl('Machine gun barrel',(.54,-.22,2.19),(.54,-1.05,2.19),.018,'Steel',20)
cyl('MG flash hider',(.54,-1.05,2.19),(.54,-1.14,2.19),.027,'Steel',20)
box('Ammunition box',(.73,.03,2.16),(.23,.28,.13),'Paint',.012)
box('Ammo lid',(.73,.03,2.231-.012),(.24,.29,.02),'Paint',.004)
tube('MG cradle',[(.43,.20,2.07),(.43,.20,2.20),(.64,.20,2.20),(.64,.20,2.07)],.014,'Steel')

# Tall pintle and braced cradle distinguish the roof MG from a flat roof bar.
for o in parts['MG']:o.location.z+=.345
cyl('MG pintle column',(.54,.29,2.31),(.54,.29,2.50),.055,'Steel',24)
tube('MG triangular elevation brace',[(.40,.31,2.34),(.40,.46,2.56),(.40,.17,2.58),(.40,.31,2.34)],.018,'Steel',10)
# Feed tray, linked belt, pistol grip and gas tube give the roof weapon a readable construction.
box('MG feed tray',(.64,.04,2.50),(.17,.13,.025),'Steel',.005)
for j in range(8):
    cyl('MG linked cartridge',(.645+j*.014,-.015,2.505),(.645+j*.014,.06,2.505),.0055,'Steel',10)
box('MG rear grip',(.54,.267,2.46),(.037,.05,.10),'Rubber',.006,(.2,0,0))
cyl('MG gas tube',(.54,-.23,2.51),(.54,-.76,2.51),.011,'Steel',16)
tube('MG rear aiming frame',[(.47,.22,2.51),(.47,.29,2.555),(.61,.29,2.555),(.61,.22,2.51)],.008,'Steel',8)
# Low fender shoulders reveal the full turret silhouette. Fuel drums behind hull.
current='Hull'
fuel_before=set(id(o) for o in parts['Hull'])
for x in [-.86,.86]:
    detach='Part_FuelDrum_'+('L' if x>0 else 'R')
    cyl('Rear auxiliary fuel drum',(x-.48,3.11,1.53),(x+.48,3.11,1.53),.245,'Paint',64)
    for dx in [-.46,-.29,.29,.46]:
        cyl('Fuel drum rolled hoop',(x+dx-.012,3.11,1.53),(x+dx+.012,3.11,1.53),.251,'Steel' if abs(dx)<.4 else 'Paint',64)
    for dx in [-.49,.49]:
        cyl('Fuel drum end inset',(x+dx,3.11,1.53),(x+dx+(.005 if dx>0 else -.005),3.11,1.53),.216,'Paint',48)
    cyl('Fuel drum filler',(x,3.10,1.767),(x,3.10,1.794),.046,'Steel',20)
    for dx in [-.28,.28]:
        detach=None
        box('Drum mounting saddle',(x+dx,3.08,1.30),(.08,.39,.12),'Paint',.018)
        detach='Part_FuelDrum_'+('L' if x>0 else 'R')
        tube('Drum retaining strap',[(x+dx,2.91,1.40),(x+dx,2.87,1.58),(x+dx,3.0,1.77),(x+dx,3.19,1.77),(x+dx,3.35,1.60),(x+dx,3.30,1.38)],.014,'Steel',8)

for o in parts['Hull']:
    if id(o) not in fuel_before:o.location.y+=.09;o.location.z-=.10
for x in [-.86,.86]:
    detach='Part_FuelDrum_'+('L' if x>0 else 'R')
    tube('Fuel drum feed hose',[(x,3.16,1.66),(x+.12,3.15,1.60),(x+.15,3.06,1.43),(x+.15,2.88,1.44)],.013,'Rubber',8)
detach=None
print('ALL_PARTS_BUILT',sum(map(len,parts.values())),flush=True)
# Recalculate normals, assign shared tiled UVs, preserve longitudinal belt mapping,
# then join each assembly and set its true articulation origin.
import bmesh
origins={'Hull':(0,0,0),'Turret':(0,ring_y,1.39),'Gun':(0,-1.07,gunz),'Hatch_Commander':(.54,.405,2.325),'MG':(.54,.29,2.43),'Track_L':(0,0,0),'Track_R':(0,0,0),**wheel_pivots}
objects={}
for name,items in parts.items():
    print('ASSEMBLY',name,len(items),flush=True)
    for o in items:
        o.data.transform(o.matrix_basis.to_3x3().to_4x4());o.rotation_euler=(0,0,0);o.scale=(1,1,1)
        bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=0.000001);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
        uv=o.data.uv_layers.active or o.data.uv_layers.new(name='UVMap')
        for p in o.data.polygons:
            n=p.normal;axis=max(range(3),key=lambda i:abs(n[i]));coords=[i for i in range(3) if i!=axis]
            for li in p.loop_indices:
                v=o.data.vertices[o.data.loops[li].vertex_index].co
                if 'belt_v' in o:
                    w=v+o.location;c=o['belt_center'];a=o['belt_ang'];uv.data[li].uv=((w.x-c[0])/.65+.5,(o['belt_v']+(w.y-c[1])*math.cos(a)+(w.z-c[2])*math.sin(a))/o['belt_total'])
                else:uv.data[li].uv=(v[coords[0]]*.85+o.location[coords[0]]*.137,v[coords[1]]*.85+o.location[coords[1]]*.137)
                if 'lamp_white' in o:uv.data[li].uv.x=.62+.30*(uv.data[li].uv.x%1)
                elif 'lamp_red' in o:uv.data[li].uv.x=.06+.30*(uv.data[li].uv.x%1)
    bpy.ops.object.select_all(action='DESELECT')
    for o in items:o.select_set(True)
    bpy.context.view_layer.objects.active=items[0];bpy.ops.object.join();o=items[0];o.name=name
    scene.cursor.location=origins[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR');objects[name]=o
# Join leaves unused slots; prune while keeping eight global materials.
for name,o in objects.items():
    active(o)
    bpy.ops.object.material_slot_remove_unused()
    # Apply final triangulation, making file/manifest triangle counts identical.
    tri=o.modifiers.new('Shipping triangulation','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
for child,parent in [('Gun','Turret'),('Hatch_Commander','Turret'),('MG','Turret')]:
    o=objects[child];w=o.matrix_world.copy();o.parent=objects[parent];o.matrix_world=w
sockets={'Muzzle':((0,-6.105,gunz),'Gun'),'Seat_Commander':((.54,.15,2.375),'Turret'),'Seat_Driver':((-.27,-1.84,1.49),'Hull'),'Exhaust':((-1.60,2.65,1.19),'Hull'),'Light_Head_L':((1.30,-3.02,1.39),'Hull'),'Light_Head_R':((-1.30,-3.02,1.39),'Hull')}
for name,(pos,par) in sockets.items():
    o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=pos;w=o.matrix_world.copy();bpy.context.view_layer.update();w=o.matrix_world.copy();o.parent=objects[par];o.matrix_world=w;objects[name]=o
scene.cursor.location=(0,0,0);bpy.context.view_layer.update()
# Exact contract: width 3.78, overall length 9.53; ground contact zero. Hull
# footprint centre stays at 0, and one global correction transforms pivots too.
meshobjects=[o for o in objects.values() if o.type=='MESH']
pts=[o.matrix_world@v.co for o in meshobjects for v in o.data.vertices]
lo=Vector([min(v[i] for v in pts) for i in range(3)]);hi=Vector([max(v[i] for v in pts) for i in range(3)])
# Ground is defined by bottom faces of track shoes; maintain all wheel contacts.
scale=Vector((3.78/(hi.x-lo.x),9.53/(hi.y-lo.y),2.23/(hi.z-lo.z)))
worlds={o.name:o.matrix_world.copy() for o in objects.values()}
S=Matrix.Diagonal((scale.x,scale.y,scale.z,1))
for o in objects.values():
    # Running gear keeps circular wheel profiles and the same scale in its YZ plane.
    running=o.name.startswith('Wheel_') or o.name.startswith('Track_')
    zscale=scale.y if running else scale.z
    # Gun cross sections retain a circular bore around the trunnion origin.
    mesh_zscale=scale.x if o.name=='Gun' else zscale
    if o.type=='MESH':o.data.transform(Matrix.Diagonal((scale.x,scale.y,mesh_zscale,1)))
    old=worlds[o.name].translation
    new=Vector((old.x*scale.x,old.y*scale.y,(old.z-lo.z)*zscale))
    o.matrix_world=Matrix.Translation(new)
    bpy.context.view_layer.update()
# Static hull/track pivots must be exactly on the world ground origin.
saved_socket_worlds={o.name:o.matrix_world.copy() for o in objects.values() if o.type=='EMPTY'}
for name in ['Hull','Track_L','Track_R']:
    active(objects[name]);scene.cursor.location=(0,0,0);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
for name,w in saved_socket_worlds.items():objects[name].matrix_world=w
bpy.context.view_layer.update()
# Weapon components retain their own material under paint variants.
for o in [objects['MG']]:
    for slot in o.material_slots:
        if slot.material==mats['Paint']:slot.material=mats['Steel']
# Save standalone clean source state for diagnostics; delivery is generated here.
tris=sum(len(o.data.polygons) for o in meshobjects)
if tris>160000:
    ratio=149000/tris
    for o in meshobjects:
        active(o);dec=o.modifiers.new('Budget preserving reduction','DECIMATE');dec.ratio=ratio;dec.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=dec.name)
        tri=o.modifiers.new('Final triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
    tris=sum(len(o.data.polygons) for o in meshobjects)
assert 80000 <= tris <= 160000, f'Triangle budget exceeded: {tris}'
# The fixed hull including its fittings is precisely 6.86 m, centred at zero.
# Keep the gun at 9.53 m overall after this small fabrication-envelope correction.
hull=objects['Hull'];yy=[(hull.matrix_world@v.co).y for v in hull.data.vertices]
hull_center=(min(yy)+max(yy))*.5;hull_ratio=6.86/(max(yy)-min(yy))
for v in hull.data.vertices:v.co.y=(v.co.y-hull_center)*hull_ratio
for name in ['Seat_Driver','Exhaust','Light_Head_L','Light_Head_R']:
    objects[name].location.y=(objects[name].location.y-hull_center)*hull_ratio
bpy.context.view_layer.update()
gun=objects['Gun'];pivot_y=gun.matrix_world.translation.y;tip_y=min((gun.matrix_world@v.co).y for v in gun.data.vertices)
gun_ratio=(-6.10-pivot_y)/(tip_y-pivot_y)
for v in gun.data.vertices:v.co.y*=gun_ratio
muzzle=objects['Muzzle'];mw=muzzle.matrix_world.copy();mw.translation.y=-6.10;muzzle.matrix_world=mw
bpy.context.view_layer.update()
print('GEOMETRY',tris,'triangles',len(meshobjects),'mesh nodes',flush=True)


# Split original disconnected components using retained author-time membership.
# This happens AFTER the unchanged v2 dimensional correction and decimation.
def parent_keep(o,par):
    bpy.context.view_layer.update();w=o.matrix_world.copy();o.parent=par;o.matrix_parent_inverse=Matrix.Identity(4);o.matrix_world=w;bpy.context.view_layer.update()
def pivot(o,pos):
    active(o);scene.cursor.location=pos;bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
def center(o):
    pts=[o.matrix_world@v.co for v in o.data.vertices]
    return Vector([(min(p[i] for p in pts)+max(p[i] for p in pts))*.5 for i in range(3)])
def split_tag(o,tag):
    vg=o.vertex_groups.get(tag)
    if not vg:return None
    indices={v.index for v in o.data.vertices if any(g.group==vg.index and g.weight>.5 for g in v.groups)}
    if not indices:return None
    active(o);bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='DESELECT');bpy.ops.object.mode_set(mode='OBJECT')
    for v in o.data.vertices:v.select=v.index in indices
    old=set(bpy.data.objects);bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.separate(type='SELECTED');bpy.ops.object.mode_set(mode='OBJECT')
    new=list(set(bpy.data.objects)-old);assert len(new)==1,(tag,len(new))
    q=new[0];q.name=tag;pivot(q,center(q));return q
for original in list(objects.values()):
    if original.type!='MESH':continue
    tags=[g.name for g in original.vertex_groups if g.name.startswith('Part_') or g.name in ('Barrel','Hatch_Driver','Hatch_Loader')]
    for tag in tags:
        q=split_tag(original,tag)
        if q:
            objects[tag]=q;parent_keep(q,objects['Gun'] if tag=='Barrel' else objects['Turret'] if original.name in ('Turret','MG') else objects['Hull'])
# Bore axis origin: mouth of mantlet. Preserve all world-space mesh vertices.
gun=objects['Gun'];barrel=objects['Barrel'];gp=gun.matrix_world.translation
pivot(barrel,(gp.x,gp.y+(-1.77+1.07)*scale.y*gun_ratio,gp.z))
parent_keep(objects['Muzzle'],barrel);objects['Muzzle'].rotation_euler=(0,0,0)
for name in ('Hatch_Driver','Hatch_Loader'):
    o=objects[name];c=center(o);pts=[o.matrix_world@v.co for v in o.data.vertices]
    pivot(o,(c.x,max(p.y for p in pts)-.025,c.z))
# The roof weapon retains its required MG mesh and pivot, beneath detachable station root.
parent_keep(objects['MG'],objects['Part_Turret_MGStation'])
# Add surfaces hidden by closed hatches: real cut openings, collar and dark crew wells.
def boolean_cut(target,cutter):
    # Boolean independently on connected solids: overlapping welds/plates in a
    # joined game mesh must not change inside/outside classification of the shell.
    bpy.context.view_layer.update()
    pts=[cutter.matrix_world@Vector(v) for v in cutter.bound_box]
    lo=Vector([min(v[i] for v in pts)-.001 for i in range(3)]);hi=Vector([max(v[i] for v in pts)+.001 for i in range(3)])
    adj=[[] for v in target.data.vertices]
    for e in target.data.edges:
        a,b=e.vertices;adj[a].append(b);adj[b].append(a)
    seen=set();groups=[]
    for i in range(len(adj)):
        if i in seen:continue
        stack=[i];seen.add(i);group=[]
        while stack:
            k=stack.pop();group.append(k)
            for j in adj[k]:
                if j not in seen:seen.add(j);stack.append(j)
        wp=[target.matrix_world@target.data.vertices[j].co for j in group]
        if all(max(v[k] for v in wp)>=lo[k] and min(v[k] for v in wp)<=hi[k] for k in range(3)):groups.append(group)
    # Preserve indices by separate copies, then delete affected original vertices.
    pieces=[];remove=set()
    for group in groups:
        keep=set(group);remove.update(keep);q=target.copy();q.data=target.data.copy();bpy.context.collection.objects.link(q);q.parent=None;q.matrix_world=target.matrix_world.copy()
        bm=bmesh.new();bm.from_mesh(q.data);bm.verts.ensure_lookup_table();bmesh.ops.delete(bm,geom=[v for v in bm.verts if v.index not in keep],context='VERTS');bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(q.data);bm.free()
        active(q);m=q.modifiers.new('Crew aperture','BOOLEAN');m.operation='DIFFERENCE';m.solver='EXACT';m.object=cutter;bpy.ops.object.modifier_apply(modifier=m.name);pieces.append(q)
    bm=bmesh.new();bm.from_mesh(target.data);bm.verts.ensure_lookup_table();bmesh.ops.delete(bm,geom=[v for v in bm.verts if v.index in remove],context='VERTS');bm.to_mesh(target.data);bm.free()
    if pieces:append_to(target,pieces)
    bpy.data.objects.remove(cutter,do_unlink=True)
def append_to(target,items):
    bpy.ops.object.select_all(action='DESELECT');target.select_set(True)
    for q in items:q.select_set(True)
    bpy.context.view_layer.objects.active=target;bpy.ops.object.join()
def free_cyl(name,loc,r,depth,mat,verts=32):
    bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=r,depth=depth,location=loc);o=bpy.context.object;o.name=name;o.data.materials.append(mat);return o
for hn in ['Hatch_Driver','Hatch_Commander','Hatch_Loader']:
    hatch=objects[hn];c=center(hatch);r=.21 if hn=='Hatch_Driver' else .22;target=objects['Hull'] if hn=='Hatch_Driver' else objects['Turret']
    cutter=free_cyl('aperture cutter',(c.x,c.y,c.z-.14),r,.48,mats['Steel']);boolean_cut(target,cutter)
    # Thin-walled well with a deep, black floor, never a bottomless opening.
    vs=[];fs=[];N=32
    for z,rr in [(c.z-.027,r+.009),(c.z-.027,r),(c.z-.42,r),(c.z-.42,r+.009)]:
        for j in range(N):a=j*math.tau/N;vs.append((c.x+rr*math.cos(a),c.y+rr*math.sin(a),z))
    for k in range(4):
        for j in range(N):fs.append((k*N+j,k*N+(j+1)%N,((k+1)%4)*N+(j+1)%N,((k+1)%4)*N+j))
    current='retrofit';well=mesh('Crew well liner',vs,fs,'Steel',0,True)
    bottom=free_cyl('Dark crew-space floor',(c.x,c.y,c.z-.425),r,.012,mats['Rubber'])
    append_to(target,[well,bottom])
# Return rollers and skirt hardpoints remain visible when panels are removed.
for side,sign in [('L',1),('R',-1)]:
    for i,y in enumerate([-1.65,0,1.65]):
        current='retrofit';before=set(bpy.data.objects);x=sign*1.49
        cyl('Return roller',(x-sign*.12,y,.87),(x+sign*.12,y,.87),.115,'Rubber',24)
        cyl('Return hub',(x+sign*.10,y,.87),(x+sign*.15,y,.87),.055,'Steel',16)
        qs=list(set(bpy.data.objects)-before);q=qs[0];append_to(q,qs[1:]);q.name=f'Wheel_{side}{9+i}';pivot(q,(x,y,.87));objects[q.name]=q
    before=set(bpy.data.objects);current='retrofit'
    for j in range(7):
        y=-2.65+j*.84
        box('Skirt frame anchor',(sign*1.54,y,1.12),(.09,.16,.14),'Paint',.01)
        bolt((sign*1.59,y,1.12),'X',.025)
    append_to(objects['Hull'],list(set(bpy.data.objects)-before))
# Type-specific snorkel stored across rear turret, clear of existing stowage.
current='retrofit';before=set(bpy.data.objects)
cyl('Stowed snorkel tube',(-.68,.91,1.42),(.68,.91,1.42),.075,'Paint',32)
for x in [-.5,.5]:cyl('Snorkel clamp',(x-.022,.91,1.42),(x+.022,.91,1.42),.088,'Steel',24)
qs=list(set(bpy.data.objects)-before);q=qs[0];append_to(q,qs[1:]);q.name='Part_Turret_Snorkel';pivot(q,center(q));parent_keep(q,objects['Turret']);objects[q.name]=q
for name,pos,par in [('Fx_Engine',(0,2.1,1.25),'Hull'),('Fx_Ammo',(0,-.1,.72),'Hull'),('Fx_Hatch',tuple(center(objects['Hatch_Commander'])+Vector((0,0,.035))),'Turret')]:
    q=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(q);q.location=pos;parent_keep(q,objects[par]);objects[name]=q
# Newly added undersurfaces use the same tiling material set.
for o in objects.values():
    if o.type!='MESH':continue
    if not o.data.uv_layers:
        uv=o.data.uv_layers.new(name='UVMap')
        for face in o.data.polygons:
            axes=[i for i in range(3) if i!=max(range(3),key=lambda i:abs(face.normal[i]))]
            for li in face.loop_indices:
                v=o.data.vertices[o.data.loops[li].vertex_index].co;uv.data[li].uv=(v[axes[0]]*.85,v[axes[1]]*.85)
    active(o);tri=o.modifiers.new('Final rig triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
meshobjects=[o for o in objects.values() if o.type=='MESH'];tris=sum(len(o.data.polygons) for o in meshobjects)
assert tris<=160000,tris
bpy.context.view_layer.update()
print('RETROFIT_HEALTHY',tris,len(objects),flush=True)

# Studio lighting and cameras for clay and texture verification.
world=bpy.data.worlds.new('Soft daylight');scene.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.72,.79,.88,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
floor=bpy.data.materials.new('Studio floor');floor.diffuse_color=(.19,.21,.23,1);floor.use_nodes=True;floor.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.19,.21,.23,1);floor.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.87
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.008));ground=bpy.context.object;ground.name='Studio floor';ground.data.materials.append(floor)
def area(name,loc,power,size):
    bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.name=name;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.rotation_euler=(Vector((0,-.5,1))-o.location).to_track_quat('-Z','Y').to_euler()
area('Daylight key',(5,-7,10),1900,7);area('Sky fill',(-6,-2,6),1200,8);area('Soft rim',(2,6,8),1900,6)
bpy.ops.object.camera_add();cam=bpy.context.object;scene.camera=cam
cam.data.type='ORTHO';cam.data.ortho_scale=11.8
cam.location=(9,-12,5.6);target=Vector((0,-1.02,1.02));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()
scene.render.resolution_x=1920;scene.render.resolution_y=1080;scene.render.resolution_percentage=100
scene.cycles.samples=32
# Clay rendered first from the exact geometry, before trusting texture review.
clay=bpy.data.materials.new('QA clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.38,.38,.38,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
# Clay proof rendered after the healthy export.
# Export selected asset nodes only, baked textures, no cameras or light objects.
bpy.ops.object.select_all(action='DESELECT')
for o in objects.values():o.select_set(True)
bpy.context.view_layer.objects.active=objects['Hull']
paint_nt=mats['Paint'].node_tree;paint_bs=paint_nt.nodes.get('Principled BSDF');paint_mix=paint_nt.nodes['PaintFactor'];paint_tex=paint_mix.inputs[1].links[0].from_socket
paint_nt.links.new(paint_tex,paint_bs.inputs['Base Color'])
bpy.ops.export_scene.gltf(filepath=str(ROOT/'t90-tank.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
paint_nt.links.new(paint_mix.outputs[0],paint_bs.inputs['Base Color'])
# Explicitly set the paint factor in the serialized glTF; Blender exporter
# versions differ in how they recognize a texture x RGB multiply graph.
def patch_paint(path):
    raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);oldbin=raw[28+jl:]
    for m in doc['materials']:
        if m['name']=='Paint':m.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=PAINTS[0][1]+[1]
    # Standard KHR_mesh_quantization packs unit normals into signed bytes,
    # retaining all geometry and UVs while saving over two MB. No decoder needed.
    replacements={}
    normals={p['attributes']['NORMAL'] for mesh in doc['meshes'] for p in mesh['primitives']}
    for ai in normals:
        ac=doc['accessors'][ai];vi=ac['bufferView'];view=doc['bufferViews'][vi]
        assert ac['componentType']==5126 and ac.get('byteOffset',0)==0
        arr=np.frombuffer(oldbin,dtype='<f4',count=ac['count']*3,offset=view.get('byteOffset',0)).reshape(-1,3)
        packed=np.zeros((len(arr),4),dtype=np.int8);packed[:,:3]=np.clip(np.rint(arr*127),-127,127).astype(np.int8)
        replacements[vi]=packed.tobytes();ac['componentType']=5120;ac['normalized']=True;ac.pop('min',None);ac.pop('max',None);view['byteStride']=4
    binary=bytearray()
    for i,v in enumerate(doc['bufferViews']):
        binary.extend(b'\x00'*((-len(binary))%4));offset=v.get('byteOffset',0)
        data=replacements.get(i,oldbin[offset:offset+v['byteLength']]);v['byteOffset']=len(binary);v['byteLength']=len(data);binary.extend(data)
    binary.extend(b'\x00'*((-len(binary))%4));doc['buffers'][0]['byteLength']=len(binary)
    for key in ['extensionsUsed','extensionsRequired']:
        if 'KHR_mesh_quantization' not in doc.setdefault(key,[]):doc[key].append('KHR_mesh_quantization')
    out=json.dumps(doc,separators=(',',':')).encode();out+=b' '*((-len(out))%4)
    path.write_bytes(struct.pack('<4sII',b'glTF',2,28+len(out)+len(binary))+struct.pack('<II',len(out),0x4e4f534a)+out+struct.pack('<II',len(binary),0x004e4942)+binary)
patch_paint(ROOT/'t90-tank.glb')
# Node positions and bounds are expressed in the shipping glTF axes, never Blender axes.
def gl(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
node_data=[]
for name,o in objects.items():
    node_data.append({'name':name,'type':'mesh' if o.type=='MESH' else 'empty','parent':o.parent.name if o.parent else None,'world_position':gl(o.matrix_world.translation),'rotation_axis':'Y' if name in ('Turret','MG') else 'X' if name.startswith('Wheel_') or name in ('Gun','Barrel','Hatch_Commander','Hatch_Driver','Hatch_Loader') else None})
pts=[o.matrix_world@v.co for o in meshobjects for v in o.data.vertices];gpts=[gl(p) for p in pts]
minimum=[min(v[i] for v in gpts) for i in range(3)];maximum=[max(v[i] for v in gpts) for i in range(3)]
manifest={'asset':'T-90 style main battle tank','units':'metres','forward':'+Z','up':'+Y','left':'+X','nodes':node_data,'bounding_box':{'min':minimum,'max':maximum,'size':[round(b-a,6) for a,b in zip(minimum,maximum)]},'triangles':tris,'hull_length':6.86,'materials':list(mats),'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'track_uv':'UV V follows belt arc length; U runs across shoes. Repeat wrap.','wheel_order':'1 front idler, 2-7 road wheels, 8 rear drive sprocket; added 9-11 return rollers front to back (legacy names retained)','bakes':{'engine':'Cycles GPU','samples':16,'base_normal':1024,'orm':512,'small_optics':256,'sources':PH_IDS},'revision_limit':3}
manifest['build_script_sha256']=hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
manifest['glb_sha256']=hashlib.sha256((ROOT/'t90-tank.glb').read_bytes()).hexdigest()
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
notes=["T-90 style MBT: cast turret, Kontakt ERA, 125 mm gun, Shtora dazzlers, skirts, rear fuel drums and recovery log; Y-up metres.",f"Triangles: {tris:,}; 23 articulated/static mesh nodes plus 6 gameplay sockets.","Materials: Paint, Rubber, Steel, TrackSteel, Wood, Glass, Lamps, Canvas; Paint is neutral-detail texture times linear colour.","Textures: tiled Cycles bakes; colour/normal 1024 max, ORM 512 max; optics 256; all embedded in GLB.","Poly Haven CC0 IDs: "+', '.join(PH_IDS)+"; https://polyhaven.com/license and https://polyhaven.com/a/<id>.","Differences: height constrained to 2.23 m including MG; short antennae; simplified cast welds and under-hull suspension; no insignia or markings."]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
assert os.path.getsize(ROOT/'t90-tank.glb')<=12000000, 'GLB exceeds 12 MB'
print('EXPORTED',os.path.getsize(ROOT/'t90-tank.glb'),flush=True)
# tank-damage/v1: damage-only exports, built entirely from healthy mesh copies.
healthy=objects.copy();damage={};damage_pairs=[]
def empty(name,pos):
    q=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(q);q.location=pos;bpy.context.view_layer.update();damage[name]=q;return q
hd=empty('Hull_Damage',(0,0,0));td=empty('Turret_Damage',healthy['Turret'].matrix_world.translation)
# Bake new reusable charred steel / impact tiles through the same Cycles path.
damage_recipe='char-ash-rust-v2'
if not (TEX/'damage_recipe.txt').exists() or (TEX/'damage_recipe.txt').read_text()!=damage_recipe:
    for mn in ['Burnt','Scars']:
        for kind,ext in [('base','jpg'),('normal','jpg'),('orm','png')]:
            (TEX/f'{mn}_{kind}.{ext}').unlink(missing_ok=True)
mats['Burnt']=bake_material('Burnt','rusty_metal_02',(.055,.045,.037),.96,.32,512)
mats['Scars']=bake_material('Scars','rusty_metal_02',(.32,.34,.35),.52,.78,512)
(TEX/'damage_recipe.txt').write_text(damage_recipe)
# File B shares the exact Paint images, while all other damage textures are 512.
def small_copy(mat):
    m=mat.copy();m.name=mat.name+'_Damage512'
    for n in m.node_tree.nodes:
        if n.type=='TEX_IMAGE' and n.image and max(n.image.size)>512:
            im=n.image.copy();im.scale(512,512);im.filepath_raw=str(TEX/(im.name+'_damage.png'));im.file_format='PNG';im.save();n.image=im
    return m
small_track=small_copy(mats['TrackSteel']);small_rubber=small_copy(mats['Rubber'])
def copy_mesh(src,name,par=None,burn=False):
    q=src.copy();q.data=src.data.copy();bpy.context.collection.objects.link(q);q.name=name;q.parent=None;q.matrix_world=src.matrix_world.copy()
    for slot in q.material_slots:
        old=slot.material.name
        slot.material=mats['Burnt'] if burn or old in ['Wood','Canvas'] else mats['Paint'] if old=='Paint' else small_rubber if old=='Rubber' else small_track if old=='TrackSteel' else mats['Scars']
    if par:parent_keep(q,par)
    return q

def triangulate(q):
    active(q);m=q.modifiers.new('Damage triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=m.name)
def simplify(q,ratio):
    active(q);m=q.modifiers.new('Damage budget','DECIMATE');m.ratio=ratio;m.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=m.name);triangulate(q)
def dent(q,strength=.16):
    # Analytic smooth bend plus a sharp crushed corner; deterministic per mesh.
    coords=[v.co for v in q.data.vertices];lo=Vector([min(v[i] for v in coords) for i in range(3)]);hi=Vector([max(v[i] for v in coords) for i in range(3)]);extent=hi-lo
    axis=max(range(3),key=lambda i:extent[i]);other=(axis+1)%3
    for v in q.data.vertices:
        t=(v.co[axis]-lo[axis])/max(.001,extent[axis]);bend=max(0,(t-.38)/.62)
        v.co[other]+=strength*bend*bend
        v.co.z-=strength*.65*math.sin(t*math.pi)
    q.data.update()

def punched(q):
    # A genuine hole through the copied plate with a bevel on exposed edges.
    c=center(q);dims=q.dimensions;axis=min(range(3),key=lambda i:dims[i]);radius=min(.105,sorted(dims)[1]*.24)
    cut=free_cyl('damage cut',c,radius,max(dims)+.5,mats['Scars'],12)
    if axis==0:cut.rotation_euler.y=math.pi/2
    if axis==1:cut.rotation_euler.x=math.pi/2
    boolean_cut(q,cut)
    bevel(q,.004,1)

selected=[f'Part_Skirt_{side}{i}' for side in ['L','R'] for i in [1,2,3,4]]+['Part_Fender_FL','Part_Fender_FR','Part_Glacis_ERA1','Part_FuelDrum_L','Part_RecoveryLog']
selected += ['Part_Turret_ERA_L1','Part_Turret_ERA_R1','Part_Turret_Bin_L','Part_Turret_Dazzler_R','Part_Turret_Smoke_L']
for i,name in enumerate(selected):
    if name not in healthy:continue
    src=healthy[name];dn='Dmg_'+name[5:];q=copy_mesh(src,dn,td if name.startswith('Part_Turret_') else hd)
    dent(q,.22 if 'Skirt' in name or 'Fender' in name else .12)
    if 'Skirt' in name or 'ERA' in name:punched(q)
    triangulate(q);damage[dn]=q;damage_pairs.append({'part':name,'twin':dn})
for name in healthy:
    if name.startswith('Part_') and name not in selected:damage_pairs.append({'part':name,'twin':None})
# Copies of individual link components are transformed onto a snapped, slack route.
def broken_track(src,name):
    q=copy_mesh(src,name,hd);inv=q.matrix_world.inverted();side=1 if '_L_' in name else -1
    # Preserve every shoe as a rigid component; articulate its frame along an
    # open route rather than flattening vertices (which collapses hinge pins).
    old=[];new=[];front_start=lens[-22]
    for i in range(100):
        arc=(i+.5)*total/100;p,d=sample(arc)
        c=Vector((side*1.45*scale.x,p.x*scale.y,(p.y-lo.z)*scale.y));old.append(c)
        v=c.copy()
        if arc<lens[40]:
            t=arc/lens[40];v.x+=side*(.53*(1-t)**.7);v.z-=.91*(1-t)**1.2
        elif arc>front_start:
            t=(arc-front_start)/(total-front_start)
            length=1.45 if side>0 else 1.10
            v.y=-2.87*scale.y-length*t
            # Two kinks and unequal tail lengths: a loose, thrown belt.
            knots=[(0,0),(.34,.20),(.67,-.06),(1,.27 if side>0 else -.14)]
            for (ta,xa),(tb,xb) in zip(knots,knots[1:]):
                if ta<=t<=tb:v.x+=side*(xa+(xb-xa)*(t-ta)/(tb-ta));break
            v.z=.145+.14*max(0,1-t/.20)
        new.append(v)
    # Each connected solid (shoe, grouser, pin, guide) inherits its shoe frame.
    adjacency=[[] for v in q.data.vertices]
    for e in q.data.edges:
        a,b=e.vertices;adjacency[a].append(b);adjacency[b].append(a)
    visited=set();shoe_vertices={}
    for vi in range(len(q.data.vertices)):
        if vi in visited:continue
        stack=[vi];visited.add(vi);component=[]
        while stack:
            j=stack.pop();component.append(j)
            for k in adjacency[j]:
                if k not in visited:visited.add(k);stack.append(k)
        centre=sum((q.matrix_world@q.data.vertices[j].co for j in component),Vector())/len(component)
        i=min(range(100),key=lambda k:(centre-old[k]).length_squared)
        shoe_vertices.setdefault(i,[]).extend(component)
        _,d=sample((i+.5)*total/100);along=Vector((0,d.x,d.y)).normalized()
        if i==0:tangent=(new[1]-new[0]).normalized()
        elif i==99:tangent=(new[99]-new[98]).normalized()
        else:tangent=(new[i+1]-new[i-1]).normalized()
        # Across-shoe X stays almost horizontal while yaw follows tail kinks.
        across=Vector((1,0,0));across=(across-tangent*across.dot(tangent)).normalized();normal=across.cross(tangent)
        original_normal=Vector((1,0,0)).cross(along)
        for j in component:
            v=q.data.vertices[j];delta=q.matrix_world@v.co-old[i]
            v.co=inv@(new[i]+across*delta.x+tangent*delta.dot(along)+normal*delta.dot(original_normal))
    # Seat each loose shoe on its lowest grouser while retaining rigid thickness.
    for i,indices in shoe_vertices.items():
        if (i+.5)*total/100>front_start+.20*(total-front_start):
            low=min((q.matrix_world@q.data.vertices[j].co).z for j in indices)
            for j in indices:
                p=q.matrix_world@q.data.vertices[j].co;p.z-=low
                q.data.vertices[j].co=inv@p
    low=min((q.matrix_world@v.co).z for v in q.data.vertices)
    for v in q.data.vertices:v.co.z-=low
    simplify(q,.39);return q
for side in ['L','R']:
    q=broken_track(healthy['Track_'+side],'Track_'+side+'_Broken');damage[q.name]=q
# Gun_Broken preserves trunnion origin, with a bent/burst bore copied from Barrel.
q=copy_mesh(healthy['Gun'],'Gun_Broken',td);b=copy_mesh(healthy['Barrel'],'burst barrel');inv=b.matrix_world.inverted();gp=healthy['Gun'].matrix_world.translation
for v in b.data.vertices:
    p=b.matrix_world@v.co;d=max(0,-p.y-3.15)
    if d>0:
        p.z-=d*.25;p.x+=.10*d
        if d<.30:p.x+=math.copysign(.08*math.sin(d/.3*math.pi),p.x if abs(p.x)>.005 else .01)
    v.co=inv@p
append_to(q,[b]);simplify(q,.52);damage[q.name]=q
# Impacts raycast onto complete bare armour, 2–4 mm proud. Each face gets four.
# Select a genuinely black char texel for cavities instead of a grey material disc.
char_image=next(n.image for n in mats['Burnt'].node_tree.nodes if n.type=='TEX_IMAGE' and '_base' in n.image.name)
cp=list(char_image.pixels);cw,ch=char_image.size
ci=min(range(0,cw*ch,17),key=lambda i:sum(cp[4*i:4*i+3]))
char_uv=((ci%cw+.5)/cw,(ci//cw+.5)/ch)
from mathutils.bvhtree import BVHTree
def scar_face(target,name,origin,direction,par):
    deps=bpy.context.evaluated_depsgraph_get();tree=BVHTree.FromObject(target,deps)
    direction=Vector(direction);direction.normalize();origin=Vector(origin);inv=target.matrix_world.inverted();basis=direction.cross(Vector((0,0,1)))
    if basis.length<.1:basis=Vector((1,0,0))
    basis.normalize();up=direction.cross(basis).normalized();items=[]
    for k in range(4):
        start=origin+basis*((k-1.5)*.32)+up*((k%2-.5)*.16)
        hit,n,fi,dist=tree.ray_cast(inv@start,inv.to_3x3()@direction)
        if hit is None:continue
        p=target.matrix_world@hit;n=target.matrix_world.to_3x3()@n;n.normalize()
        if n.dot(direction)>0:n=-n
        u=n.cross(Vector((0,0,1)))
        if u.length<.1:u=Vector((1,0,0))
        u.normalize();w=n.cross(u);r=[.055,.075,.10,.065][k];N=20;vs=[];faces=[]
        # Burn halo, bright gouge shoulder, torn rim and black cavity.
        for ring,(rad,h) in enumerate([(r*1.9,.002),(r*.94,.003),(r*.74,.004),(r*.54,.0021)]):
            for j in range(N):
                a=j*math.tau/N;rr=rad*(1+.22*math.sin(j*2.3+k))
                if ring==0:rr*=1+.8*max(0,math.cos(a-.7))**4
                point=p+u*(math.cos(a)*rr*(2.0 if k==1 else 1))+w*(math.sin(a)*rr*(.42 if k==1 else .82))
                # Project every rim vertex onto the curved/angled armour face.
                hp,hn,_,_=tree.ray_cast(inv@(point+n*.22),inv.to_3x3()@(-n),.45)
                if hp is not None:point=target.matrix_world@hp
                vs.append(point+n*h)
        for row in range(3):
            for j in range(N):faces.append((row*N+j,row*N+(j+1)%N,(row+1)*N+(j+1)%N,(row+1)*N+j))
        faces.append(tuple(range(3*N,4*N)))
        me=bpy.data.meshes.new('impact mesh');me.from_pydata(vs,[],faces);me.materials.append(mats['Scars']);me.materials.append(mats['Burnt']);me.update()
        ob=bpy.data.objects.new('armour strike',me);bpy.context.collection.objects.link(ob);uv=me.uv_layers.new()
        for face in me.polygons:
            face.material_index=1 if face.index<N or face.index>=3*N else 0
            for li in face.loop_indices:
                v=me.vertices[me.loops[li].vertex_index].co-p
                uv.data[li].uv=char_uv if face.material_index==1 else (v.dot(u)*6+.5,v.dot(w)*6+.5)
        items.append(ob)
    assert items,name
    q=items[0];append_to(q,items[1:]);q.name=name;pivot(q,(0,0,0) if par==hd else td.location);parent_keep(q,par);triangulate(q);damage[name]=q
for prefix,target,par,z,front,rear,width in [('Hull',healthy['Hull'],hd,.99,-5,5,4),('Turret',healthy['Turret'],td,1.65,-3,3,3)]:
    for face,origin,d in [('Front',(0,front,z),(0,1,0)),('Rear',(0,rear,z),(0,-1,0)),('L',(width,-.15,z),(-1,0,0)),('R',(-width,-.15,z),(1,0,0))]:scar_face(target,'Scar_'+prefix+'_'+face,origin,d,par)
# Burnt wreck copies, retaining hatches as opened geometry within joined meshes.
wh=[];wt=[]
for name,src in healthy.items():
    if src.type!='MESH':continue
    turret=name in ('Turret','Gun','Barrel','MG','Hatch_Commander','Hatch_Loader') or name.startswith('Part_Turret_')
    if name.startswith('Part_') and any(x in name for x in ['Skirt','Fender','Stowage','ERA','FuelDrum','Dazzler','Smoke']):
        # Missing and hanging plates change the outline substantially.
        if sum(map(ord,name))%4:continue
    if name.startswith('Track_'):src=damage[name+'_Broken']
    ob=copy_mesh(src,'wreck copy',burn=True)
    if name.startswith('Wheel_'):
        # Burn tyres away, keep metal wheel dish and hubs.
        rubber_ids={i for i,m in enumerate(src.data.materials) if m and m.name=='Rubber'}
        bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.material_index in rubber_ids],context='FACES');bm.to_mesh(ob.data);bm.free();ob.location.z-=.05
    if name.startswith('Hatch_'):ob.rotation_euler.x=math.radians(-105)
    if name in ('Gun','Barrel'):
        M=Matrix.Translation(gp)@Matrix.Rotation(math.radians(12),4,'X')@Matrix.Translation(-gp);ob.matrix_world=M@ob.matrix_world
    if name=='Hull':
        c=healthy['Turret'].matrix_world.translation
        cut=free_cyl('Open turret well',(c.x,c.y,1.12),.91,.68,mats['Burnt'],64);boolean_cut(ob,cut)
        for sx in [-1,1]:
            bpy.ops.mesh.primitive_cube_add(size=1,location=(sx*.654,2.14,1.30));cut=bpy.context.object;cut.scale=(.98,1.42,.65);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);boolean_cut(ob,cut)
            # A recessed engine bay beneath each ruptured louvre panel.
            bpy.ops.mesh.primitive_cube_add(size=1,location=(sx*.654,2.14,.96));floor=bpy.context.object;floor.scale=(.98,1.42,.025);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);floor.data.materials.append(mats['Burnt']);wh.append(floor)
        for v in ob.data.vertices:
            p=ob.matrix_world@v.co
            if p.y>1.2 and p.z>1.17 and abs(p.x)<1.1:v.co.z+=.19*math.sin((p.y-1.2)*2.5)*(1-abs(p.x)/1.2)
    if 'EngineGrille' in name:ob.rotation_euler.x=.2;ob.location.z+=.14
    if name.startswith('Part_'):dent(ob,.10)
    (wt if turret else wh).append(ob)
# Hollow turret ring and dark well floor remain when the turret is blown off.
c=healthy['Turret'].matrix_world.translation
current='wreck';before=set(bpy.data.objects)
for r,z in [(.91,.94),(.92,1.19)]:
    vs=[];fs=[];N=64
    for rr,zz in [(r,z),(r-.045,z),(r-.045,z-.12),(r,z-.12)]:
        for j in range(N):a=j*math.tau/N;vs.append((c.x+rr*math.cos(a),c.y+rr*math.sin(a),zz))
    for k in range(4):
        for j in range(N):fs.append((k*N+j,k*N+(j+1)%N,((k+1)%4)*N+(j+1)%N,((k+1)%4)*N+j))
    mesh('Burnt turret ring well',vs,fs,'Burnt',0,True)
wh.extend(list(set(bpy.data.objects)-before));wh.append(free_cyl('Burnt interior floor',(c.x,c.y,.66),.90,.035,mats['Burnt'],48))
# Turret underside is closed and carries a short basket / race, visible upside down.
wt.append(free_cyl('Turret underside race',(c.x,c.y,c.z-.07),.87,.15,mats['Burnt'],48));wt.append(free_cyl('Burnt basket stub',(c.x,c.y,c.z-.25),.50,.24,mats['Burnt'],32))
for name,items,pos in [('Wreck_Hull',wh,Vector((0,0,0))),('Wreck_Turret',wt,c)]:
    q=items[0];append_to(q,items[1:]);q.name=name;q.parent=None;pivot(q,pos);triangulate(q);damage[name]=q
wreck_tris=sum(len(damage[n].data.polygons) for n in ['Wreck_Hull','Wreck_Turret'])
for name in ['Wreck_Hull','Wreck_Turret']:simplify(damage[name],min(1,56000/wreck_tris))
# File B has exactly four top-level nodes. Preserve original part pivots.
for q in damage.values():
    if q.type=='MESH':triangulate(q)
damage_tris=sum(len(o.data.polygons) for o in damage.values() if o.type=='MESH')
if damage_tris>109000:
    factor=107000/damage_tris
    for q in damage.values():
        if q.type=='MESH':simplify(q,factor)
# Deformation can collapse a few pin caps; remove only zero-area triangles.
for q in damage.values():
    if q.type!='MESH':continue
    q.data.validate(verbose=False,clean_customdata=False)
    bm=bmesh.new();bm.from_mesh(q.data)
    bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.calc_area()<1e-12],context='FACES_ONLY')
    bm.to_mesh(q.data);bm.free();q.data.update();triangulate(q)
damage_tris=sum(len(o.data.polygons) for o in damage.values() if o.type=='MESH')
assert damage_tris<=110000,damage_tris
assert sorted(q.name for q in damage.values() if q.parent is None)==['Hull_Damage','Turret_Damage','Wreck_Hull','Wreck_Turret']
# Export small maps under contract material names; avoid duplicate Paint sets.
for mat in [mats['TrackSteel'],mats['Rubber']]:mat.name+='__Healthy'
small_track.name='TrackSteel';small_rubber.name='Rubber'
bpy.ops.object.select_all(action='DESELECT')
for o in damage.values():o.select_set(True)
paint_nt.links.new(paint_tex,paint_bs.inputs['Base Color'])
bpy.ops.export_scene.gltf(filepath=str(ROOT/'t90-tank-damage.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_materials='EXPORT',export_extras=True)
paint_nt.links.new(paint_mix.outputs[0],paint_bs.inputs['Base Color']);patch_paint(ROOT/'t90-tank-damage.glb')
small_track.name='TrackSteel_Damage512';small_rubber.name='Rubber_Damage512';mats['TrackSteel'].name='TrackSteel';mats['Rubber'].name='Rubber'
for o in damage.values():o.hide_render=True;o.hide_set(True)
manifest['damage']={'contract':'tank-damage/v1','parts':damage_pairs,'scars':[n for n in damage if n.startswith('Scar_')],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']}
manifest['damage_nodes']=[{'name':n,'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'empty','world_position':gl(o.matrix_world.translation)} for n,o in damage.items()]
manifest['files']={'t90-tank.glb':{'triangles':tris,'nodes':[n for n in healthy],'bytes':(ROOT/'t90-tank.glb').stat().st_size},'t90-tank-damage.glb':{'triangles':damage_tris,'nodes':list(damage),'bytes':(ROOT/'t90-tank-damage.glb').stat().st_size}}
damage_points=[gl(o.matrix_world@v.co) for o in damage.values() if o.type=='MESH' for v in o.data.vertices]
damage_min=[min(p[i] for p in damage_points) for i in range(3)];damage_max=[max(p[i] for p in damage_points) for i in range(3)]
manifest['files']['t90-tank.glb'].update({'bounding_box':manifest['bounding_box'],'materials':['Paint','Rubber','Steel','TrackSteel','Wood','Glass','Lamps','Canvas']})
manifest['files']['t90-tank-damage.glb'].update({'bounding_box':{'min':damage_min,'max':damage_max,'size':[round(b-a,6) for a,b in zip(damage_min,damage_max)]},'materials':['Paint','Burnt','Scars','TrackSteel','Rubber'],'wreck_triangles':sum(len(damage[n].data.polygons) for n in ['Wreck_Hull','Wreck_Turret'])})
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
notes[1]=f'Triangles: healthy {tris:,}; damage {damage_tris:,}; {sum(n.startswith("Part_") for n in healthy)} detachable assemblies; matched rig pivots.'
notes[2]='Materials A: Paint, Rubber, Steel, TrackSteel, Wood, Glass, Lamps, Canvas; B: Paint, Burnt, Scars, TrackSteel, Rubber.'
notes[3]='Textures: preserved Cycles v2 colour/normal <=1024 and ORM <=512; new Cycles Burnt/Scars 512; damage non-Paint maps <=512.'
notes[5]='Retrofit preserves v2 shape/paint/pivots; short antennae and simplified welds/suspension remain; added crew wells, return rollers and stowed snorkel.'
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
print('DAMAGE_EXPORTED',damage_tris,flush=True)

bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'asset_scene.blend'))
scene.view_layers[0].material_override=clay;scene.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True);scene.view_layers[0].material_override=None
scene.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
hero_matrix=cam.matrix_world.copy();hero_scale=cam.data.ortho_scale
cam.location=(12,-1.0,1.15);target=Vector((0,-1.0,1.15));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=10.6
scene.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
cam.matrix_world=hero_matrix;cam.data.ortho_scale=hero_scale
# Six consistent camera views; combine actual render pixels without extra libraries.
scene.render.resolution_x=640;scene.render.resolution_y=360;scene.cycles.samples=12
sheet=np.zeros((720,1920,4),dtype=np.float32);sheet[:,:,3]=1
for i,(name,col) in enumerate(PAINTS):
    mats['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=tuple(col)+(1,)
    scene.render.filepath=str(TEX/f'paint_{i}.png');bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(scene.render.filepath,check_existing=False);buf=np.asarray(im.pixels[:],dtype=np.float32).reshape(360,640,4)
    y=(1-i//3)*360;x=(i%3)*640;sheet[y:y+360,x:x+640,:]=buf;bpy.data.images.remove(im)
image=bpy.data.images.new('Paint variants',1920,720,alpha=True);image.pixels.foreach_set(sheet.ravel());image.filepath_raw=str(ROOT/'paints.png');image.file_format='PNG';image.save()
mats['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=tuple(PAINTS[0][1])+(1,)
scene.render.resolution_x=1920;scene.render.resolution_y=1080
print('COMPLETE',tris,manifest['bounding_box'],flush=True)

# Consumer proof: start a genuinely empty scene, import both delivered GLBs.
bpy.ops.wm.read_factory_settings(use_empty=True)
scene=bpy.context.scene;scene.render.engine='CYCLES';scene.cycles.device='GPU';scene.cycles.samples=20;scene.cycles.use_denoising=True
scene.view_settings.view_transform='AgX';scene.view_settings.look='AgX - Medium High Contrast'
bpy.ops.import_scene.gltf(filepath=str(ROOT/'t90-tank.glb'))
A={o.name:o for o in bpy.context.scene.objects}
bpy.ops.import_scene.gltf(filepath=str(ROOT/'t90-tank-damage.glb'))
B={o.name:o for o in bpy.context.scene.objects if o.name not in A}
assert set(B)==set(damage),'Roundtrip node names changed'
world=bpy.data.worlds.new('Proof daylight');scene.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.72,.79,.88,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
floor=bpy.data.materials.new('Proof floor');floor.use_nodes=True;floor.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.19,.21,.23,1);floor.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.87
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.008));ground=bpy.context.object;ground.data.materials.append(floor)
area('Daylight key',(5,-7,10),1900,7);area('Sky fill',(-6,-2,6),1200,8);area('Soft rim',(2,6,8),1900,6)
bpy.ops.object.camera_add();cam=bpy.context.object;scene.camera=cam;cam.data.type='ORTHO';cam.data.ortho_scale=12.8
cam.location=(9,-12,6.3);target=Vector((0,-.75,1.0));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()
scene.render.resolution_x=960;scene.render.resolution_y=540;scene.render.resolution_percentage=100
scene.render.image_settings.file_format='PNG'

def show_tree(q,show):
    q.hide_render=not show
    for child in q.children:show_tree(child,show)

def state(n):
    for q in A.values():q.hide_render=False
    for q in B.values():q.hide_render=True
    if n==1:
        for name,q in B.items():
            if name.startswith('Scar_'):q.hide_render=False
        six=['Part_Skirt_L1','Part_Skirt_L2','Part_Skirt_R1','Part_Glacis_ERA1','Part_Turret_ERA_L1','Part_Turret_Dazzler_R']
        for name in six:show_tree(A[name],False);B['Dmg_'+name[5:]].hide_render=False
    elif n==2:
        for name,q in A.items():
            if name.startswith('Part_'):show_tree(q,False)
    elif n==3:
        show_tree(A['Track_L'],False);B['Track_L_Broken'].hide_render=False;show_tree(A['Gun'],False);B['Gun_Broken'].hide_render=False
    elif n in [4,5]:
        for q in A.values():q.hide_render=True
        B['Wreck_Hull'].hide_render=False;B['Wreck_Turret'].hide_render=False
        if n==5:
            q=B['Wreck_Turret'];q.rotation_mode='XYZ';q.rotation_euler=(0,math.radians(100),math.radians(-25));q.location=(3.8,-.8,1.15);bpy.context.view_layer.update()
            low=min((q.matrix_world@v.co).z for v in q.data.vertices);q.location.z-=low
    bpy.context.view_layer.update()

sheet=np.zeros((1080,2880,4),dtype=np.float32);sheet[:,:,3]=1
for i in range(6):
    state(i);scene.render.filepath=str(TEX/f'state_{i}.png');bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(scene.render.filepath,check_existing=False);buf=np.asarray(im.pixels[:],dtype=np.float32).reshape(540,960,4)
    y=(1-i//3)*540;x=(i%3)*960;sheet[y:y+540,x:x+960]=buf;bpy.data.images.remove(im)
im=bpy.data.images.new('Six exported damage states',2880,1080,alpha=True);im.pixels.foreach_set(sheet.ravel());im.filepath_raw=str(ROOT/'states.png');im.file_format='PNG';im.save()
# 30 degree articulation proof on the imported healthy rig, with pivot invariants.
state(0)
changed=[];checks=[]
for name,q in A.items():
    if name in ['Turret','Gun','Barrel','MG'] or name.startswith('Wheel_') or name.startswith('Hatch_'):
        axis=2 if name in ['Turret','MG'] else 1 if name=='Barrel' else 0
        angle=-30 if name=='Gun' or name.startswith('Hatch_') else 30
        q.rotation_mode='XYZ'
        before=q.matrix_local.translation.copy();old=q.rotation_euler.copy();q.rotation_euler[axis]+=math.radians(angle);bpy.context.view_layer.update()
        checks.append({'node':name,'axis_blender':'XYZ'[axis],'angle_degrees':angle,'pivot_translation_error':(q.matrix_local.translation-before).length})
        changed.append((q,old))
scene.render.resolution_x=1920;scene.render.resolution_y=1080;scene.render.filepath=str(ROOT/'pivots.png');bpy.ops.render.render(write_still=True)
for q,old in changed:q.rotation_euler=old
assert all(c['pivot_translation_error']<1e-6 for c in checks)
manifest['pivot_checks']=checks
manifest['proof']={'states':'Imported both exported GLBs into a factory-empty Blender scene; six Cycles GPU renders','state_order':['healthy','hit','stripped','immobilised','wreck','turret blown off'],'pivots':'Imported healthy moving nodes rotated 30 degrees; local pivot translations invariant; pivots.png'}
manifest['damage_glb_sha256']=hashlib.sha256((ROOT/'t90-tank-damage.glb').read_bytes()).hexdigest()
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
print('PROOFS_COMPLETE',flush=True)
