"""Tiger I style game asset. Clean build: blender -b --python-exit-code 1 --python this_file.py
CC0 source maps downloaded from Poly Haven; all delivered material maps baked by Cycles.
Authored Z-up / forward -Y; glTF exporter converts to Y-up / forward +Z.
"""
import bpy, bmesh, math, os, sys, json, struct, subprocess, random, time
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin,cos,pi
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT)
random.seed(1943)
bpy.ops.wm.read_factory_settings(use_empty=True)
scene=bpy.context.scene
scene.render.engine='CYCLES';scene.cycles.samples=48
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'
scene.cycles.device='GPU';scene.cycles.use_denoising=True
scene.render.image_settings.file_format='PNG'
scene.view_settings.view_transform='AgX'
scene.view_settings.look='AgX - Medium High Contrast'
scene.view_settings.exposure=-.65
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
SRC=ROOT/'sources';SRC.mkdir(exist_ok=True)
SOURCES=['green_metal_rust','rusty_metal_04','rubberized_track','dark_wood']
for asset in SOURCES:
 for mp in ['diff','nor_gl','arm']:
  fn=SRC/f'{asset}_{mp}_1k.jpg'
  if not fn.exists():
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mp}_1k.jpg'
   subprocess.run(['curl','-f','-L','--retry','2','-A','Mozilla/5.0',url,'-o',str(fn)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print('SOURCES_READY',flush=True)
materials=[];base_nodes={}
# Bake seven reusable material tiles. This preserves texture density on long panels.
def bake_material(name,asset,color,metal,rough):
 if name!='Burnt' and all((TEX/f'{name}_{kind}.jpg').exists() for kind in ['base','normal','orm']):
  m=bpy.data.materials.new(name);m.use_nodes=True;n=m.node_tree.nodes;l=m.node_tree.links
  bs=n.get('Principled BSDF');ts={}
  for kind in ['base','normal','orm']:
   q=n.new('ShaderNodeTexImage');q.image=bpy.data.images.load(str(TEX/f'{name}_{kind}.jpg'),check_existing=True);q.image.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color';ts[kind]=q
  l.new(ts['base'].outputs[0],bs.inputs['Base Color'])
  nm=n.new('ShaderNodeNormalMap');l.new(ts['normal'].outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
  sep=n.new('ShaderNodeSeparateColor');l.new(ts['orm'].outputs[0],sep.inputs[0]);l.new(sep.outputs['Green'],bs.inputs['Roughness']);l.new(sep.outputs['Blue'],bs.inputs['Metallic'])
  if name=='Glass':bs.inputs['Roughness'].default_value=.22
  if name=='Paint':base_nodes[name]=(m,bs,ts['base'])
  materials.append(m);print('REUSED_BAKE',name,flush=True);return len(materials)-1
 m=bpy.data.materials.new(name);m.use_nodes=True;n=m.node_tree.nodes;l=m.node_tree.links;n.clear()
 out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=n.new('ShaderNodeTexCoord')
 def tex(mp,cs):
  q=n.new('ShaderNodeTexImage');q.image=bpy.data.images.load(str(SRC/f'{asset}_{mp}_1k.jpg'),check_existing=True);q.image.colorspace_settings.name=cs;l.new(uv.outputs['UV'],q.inputs['Vector']);return q
 diff=tex('diff','sRGB');nor=tex('nor_gl','Non-Color');arm=tex('arm','Non-Color')
 bw=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');l.new(bw.outputs[0],ramp.inputs[0])
 if name=='Paint':
  ramp.color_ramp.elements[0].position=.045;ramp.color_ramp.elements[0].color=(.43,.43,.43,1)
  ramp.color_ramp.elements[1].position=.092;ramp.color_ramp.elements[1].color=(.97,.97,.97,1)
 else:
  ramp.color_ramp.elements[0].color=tuple(v*.48 for v in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(color)+(1,)
 if name=='Burnt':
  ramp.color_ramp.elements[0].position=.018;ramp.color_ramp.elements[0].color=(.015,.015,.014,1)
  ramp.color_ramp.elements[1].position=.11;ramp.color_ramp.elements[1].color=(.085,.081,.075,1)
  rust=ramp.color_ramp.elements.new(.064);rust.color=(.043,.040,.033,1)
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=96;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 mul=n.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=.07;l.new(ramp.outputs['Color'],mul.inputs[1]);l.new(noise.outputs['Fac'],mul.inputs[2])
 if name=='Paint':
  stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(28,2,1);l.new(uv.outputs['UV'],stretch.inputs[0])
  streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=2;streak.inputs['Detail'].default_value=3;l.new(stretch.outputs[0],streak.inputs['Vector'])
  sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.27;sr.color_ramp.elements[0].color=(.35,.35,.35,1);sr.color_ramp.elements[1].position=.63;sr.color_ramp.elements[1].color=(1,1,1,1);l.new(streak.outputs['Fac'],sr.inputs[0])
  dirty=n.new('ShaderNodeMixRGB');dirty.blend_type='MULTIPLY';dirty.inputs[0].default_value=.07;l.new(mul.outputs[0],dirty.inputs[1]);l.new(sr.outputs[0],dirty.inputs[2]);mul=dirty
 l.new(mul.outputs[0],bs.inputs['Base Color']);bs.inputs['Metallic'].default_value=metal
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs['Color'],sep.inputs[0])
 rmath=n.new('ShaderNodeMath');rmath.operation='MULTIPLY_ADD';l.new(sep.outputs['Green'],rmath.inputs[0]);rmath.inputs[1].default_value=.17;rmath.inputs[2].default_value=rough-.10;l.new(rmath.outputs[0],bs.inputs['Roughness'])
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.32 if name=='Paint' else .38;l.new(nor.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=2);obj=bpy.context.object;obj.data.materials.append(m)
 target=n.new('ShaderNodeTexImage');n.active=target
 saved={}
 emit=n.new('ShaderNodeEmission')
 for kind,res in [('base',1024),('normal',1024),('orm',512)]:
  if name in ['Burnt','Scars']:res=512
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color';target.image=im;n.active=target
  if kind=='normal':
   l.new(bs.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL',margin=8)
  else:
   if kind=='base':l.new(mul.outputs[0],emit.inputs[0])
   else:
    comb=n.new('ShaderNodeCombineColor');comb.inputs['Red'].default_value=1;comb.inputs['Blue'].default_value=metal;l.new(rmath.outputs[0],comb.inputs['Green']);l.new(comb.outputs[0],emit.inputs[0])
   l.new(emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT',margin=8)
  im.filepath_raw=str(TEX/f'{name}_{kind}.jpg');im.file_format='JPEG';im.save();saved[kind]=im
 bpy.data.objects.remove(obj,do_unlink=True)
 n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 ts={}
 for kind in saved:
  q=n.new('ShaderNodeTexImage');q.image=saved[kind];q.interpolation='Linear';ts[kind]=q
 l.new(ts['base'].outputs[0],bs.inputs['Base Color'])
 nm=n.new('ShaderNodeNormalMap');l.new(ts['normal'].outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 sep=n.new('ShaderNodeSeparateColor');l.new(ts['orm'].outputs[0],sep.inputs[0]);l.new(sep.outputs['Green'],bs.inputs['Roughness']);l.new(sep.outputs['Blue'],bs.inputs['Metallic'])
 if name=='Paint':base_nodes[name]=(m,bs,ts['base'])
 if name=='Glass':bs.inputs['Roughness'].default_value=.22
 materials.append(m);print('BAKED',name,flush=True)
 return len(materials)-1
P=bake_material('Paint','green_metal_rust',(1,1,1),0,.77)
T=bake_material('TrackSteel','rusty_metal_04',(.19,.17,.135),.72,.69)
R=bake_material('Rubber','rubberized_track',(.045,.046,.043),0,.85)
S=bake_material('Steel','rusty_metal_04',(.23,.235,.22),.85,.48)
W=bake_material('Wood','dark_wood',(.25,.13,.055),0,.72)
G=bake_material('Glass','rusty_metal_04',(.06,.115,.13),.5,.25)
L=bake_material('Lamps','rusty_metal_04',(.7,.66,.5),.15,.24)
# Accumulated meshes avoid thousands of dependency-graph objects.
cache={};groups={}
class Part:
 def __init__(self,name,pivot=(0,0,0),parent=None):self.name=name;self.pivot=Vector(pivot);self.parent=parent;self.v=[];self.f=[];self.mi=[];self.sm=[];self.uv=[];groups[name]=self
 def add(self,v,f,mat,sm=False,uv=None):
  st=len(self.v);self.v.extend(v);self.f.extend([tuple(st+j for j in face) for face in f]);self.mi.extend([mat]*len(f));self.sm.extend([sm]*len(f))
  for face in f:
   if uv:self.uv.append([uv[j] for j in face]);continue
   a,b,c=[Vector(v[i]) for i in face[:3]];nn=(b-a).cross(c-a);axis=max(range(3),key=lambda k:abs(nn[k]));coords=[k for k in range(3) if k!=axis]
   self.uv.append([(v[i][coords[0]]*.75,v[i][coords[1]]*.75) for i in face])
 def build(self):
  me=bpy.data.meshes.new(self.name);me.from_pydata([Vector(v)-self.pivot for v in self.v],[],self.f);me.materials.clear()
  for m in materials:me.materials.append(m)
  me.update();uv=me.uv_layers.new(name='UVMap')
  for poly,mat,sm,coords in zip(me.polygons,self.mi,self.sm,self.uv):
   poly.material_index=mat;poly.use_smooth=sm
   for li,c in zip(poly.loop_indices,coords):uv.data[li].uv=c
  ob=bpy.data.objects.new(self.name,me);scene.collection.objects.link(ob);ob.location=self.pivot
  return ob

def box(g,c,d,mat=P,b=.009,rot=None):
 key=tuple(round(a,5) for a in d)+(round(b,5),)
 if key not in cache:
  bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
  for v in bm.verts:v.co.x*=d[0];v.co.y*=d[1];v.co.z*=d[2]
  if b>0:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(b,min(d)*.23),segments=2,affect='EDGES')
  bm.verts.ensure_lookup_table();bm.verts.index_update();cache[key]=([tuple(v.co) for v in bm.verts],[tuple(v.index for v in f.verts) for f in bm.faces]);bm.free()
 v,f=cache[key];c=Vector(c);g.add([tuple((rot@Vector(p) if rot else Vector(p))+c) for p in v],f,mat)
def cylinder(g,c,r,depth,mat=P,axis=(0,0,1),n=32,r2=None):
 axis=Vector(axis).normalized();a=axis.cross(Vector((0,0,1)))
 if a.length<.1:a=Vector((1,0,0))
 a.normalize();b=axis.cross(a);c=Vector(c);v=[]
 for z,rr in [(-depth/2,r),(depth/2,r if r2 is None else r2)]:
  for k in range(n):v.append(tuple(c+axis*z+rr*(a*cos(2*pi*k/n)+b*sin(2*pi*k/n))))
 f=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)];g.add(v,f,mat,True)
def lathe(g,c,profile,mat=P,axis=(0,0,1),n=48):
 axis=Vector(axis).normalized();a=axis.cross(Vector((0,0,1)))
 if a.length<.1:a=Vector((1,0,0))
 a.normalize();b=axis.cross(a);c=Vector(c);v=[];uv=[]
 distance=0.0;previous=profile[0]
 for z,r in profile:
  distance+=math.hypot(z-previous[0],r-previous[1]);previous=(z,r)
  for i in range(n):v.append(tuple(c+axis*z+r*(a*cos(2*pi*i/n)+b*sin(2*pi*i/n))));uv.append((i/n*2,distance*1.5))
 f=[]
 for j in range(len(profile)-1):
  for i in range(n):f.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
 if abs(axis.x)>.9:uv=[(p[1]*.95,p[2]*.95) for p in v]
 g.add(v,f,mat,True,uv)
def tube(g,pts,r,mat=S,n=8):
 pts=[Vector(p) for p in pts];v=[]
 for j,p in enumerate(pts):
  tangent=(pts[min(j+1,len(pts)-1)]-pts[max(j-1,0)]).normalized();a=tangent.cross(Vector((0,0,1)))
  if a.length<.1:a=Vector((1,0,0))
  a.normalize();b=tangent.cross(a)
  for k in range(n):v.append(tuple(p+r*(a*cos(2*pi*k/n)+b*sin(2*pi*k/n))))
 f=[]
 for j in range(len(pts)-1):
  for k in range(n):f.append((j*n+k,j*n+(k+1)%n,(j+1)*n+(k+1)%n,(j+1)*n+k))
 g.add(v,f,mat,True)
def bolt(g,c,axis=(0,0,1),r=.018,mat=S):cylinder(g,c,r,.013,mat,axis,6)
def between(g,a,b,r,mat=S,n=16):a=Vector(a);b=Vector(b);cylinder(g,(a+b)/2,r,(b-a).length,mat,b-a,n)
def prism(g,outline,z0,z1,mat=P,b=.014):
 v=[(x,y,z) for z in [z0,z1] for x,y in outline];N=len(outline);f=[tuple(reversed(range(N))),tuple(range(N,2*N))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
 bm=bmesh.new();vv=[bm.verts.new(p) for p 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));bmesh.ops.bevel(bm,geom=list(bm.edges),offset=b,segments=3,affect='EDGES');bm.verts.index_update();g.add([tuple(v.co) for v in bm.verts],[tuple(v.index for v in f.verts) for f in bm.faces],mat);bm.free()
def detachable(name,parent=None):return Part('Part_'+name,parent=parent)
def side_name(sign):return 'L' if sign>0 else 'R'
H=Part('Hull')
# Hull footprint; bevelled armour plates and genuine panel seams.
prism(H,[(-1.19,-2.78),(1.19,-2.78),(1.2,2.89),(-1.2,2.89)],.47,1.38)
prism(H,[(-1.45,-2.51),(-1.36,-2.79),(1.36,-2.79),(1.45,-2.51),(1.45,2.88),(-1.45,2.88)],1.35,1.88)
box(H,(0,.02,1.885),(2.90,5.72,.065),P,.018)
# Front glacis, lower nose and driver plate.
box(H,(0,-2.87,1.27),(2.62,.60,.09),P,.016,Matrix.Rotation(math.radians(15),3,'X'))
box(H,(0,-2.80,1.62),(2.79,.10,.53),P,.018)
box(H,(0,-2.83,.69),(2.37,.13,.30),P,.018)
# Fenders split into replaceable hinged panels.
for sign in [-1,1]:
 for j in range(6):
  y=-2.54+j*1.005
  FE=detachable(f'Skirt_{side_name(sign)}{j+1}')
  box(FE,(sign*1.58,y,1.31),(.40,.988,.055),P,.009,Matrix.Rotation(sign*.12,3,'Y'))
  box(FE,(sign*1.756,y,1.279),(.035,.982,.078),P,.004)
  for yy in [y-.41,y+.41]:bolt(FE,(sign*1.60,yy,1.35),r=.013)
 MF=detachable('Fender_F'+side_name(sign))
 box(MF,(sign*1.57,-2.99,1.26),(.43,.32,.047),P,.008,Matrix.Rotation(.18,3,'X'))
 MR=detachable('Fender_R'+side_name(sign))
 box(MR,(sign*1.55,2.94,1.22),(.43,.38,.055),P,.007,Matrix.Rotation(-.48,3,'X'))
 for y in [-2.3,-1.3,-.3,.7,1.7,2.65]:
  box(H,(sign*1.47,y,1.5),(.035,.055,.25),P,.004);bolt(H,(sign*1.50,y,1.6),(sign,0,0),.012)
# Track belts: capsule path, physically modelled links with U across / V along.
# Top run slopes down toward idler and has a controlled sag between wheels.
def path_track(t):
 # 4 spans: bottom, rear wrap, top, front wrap; continuous direction
 if t<5.12:return (-2.54+t,.067), (1,0)
 t-=5.12
 if t<pi*.49:
  a=-pi/2+t/.49;return (2.54+.49*cos(a),.557+.49*sin(a)),(-sin(a),cos(a))
 t-=pi*.49
 if t<5.12:
  u=t/5.12;return (2.54-t,1.047+.19*u-.07*sin(pi*u)),(-1,(.19-.07*pi*cos(pi*u))/5.12)
 t-=5.12;a=pi/2+t/.585
 return (-2.54+.585*cos(a),.652+.585*sin(a)),(-sin(a),cos(a))
TRACKLEN=10.24+pi*(.49+.585)
for sign,label in [(1,'L'),(-1,'R')]:
 tr=Part('Track_'+label)
 count=100
 for k in range(count):
  (y,z),(dy,dz)=path_track(k/count*TRACKLEN);angle=math.atan2(dz,dy);rot=Matrix.Rotation(angle,3,'X')
  start=len(tr.f);xc=sign*1.407
  box(tr,(xc,y,z),(.71,.133,.058),T,.009,rot)
  # Raised chevron-like cleats, strong moving silhouette.
  for sx in [-1,1]:
   cc=Vector((xc+sx*.166,y,z))+rot@Vector((0,0,-.048))
   box(tr,cc,(.326,.050,.041),T,.006,rot@Matrix.Rotation(sx*.08,3,'Z'))
  # central inner guide horn
  cc=Vector((xc,y,z))+rot@Vector((0,0,.037))
  vv=[(-.035,-.032,0),(.035,-.032,0),(.035,.032,0),(-.035,.032,0),(-.012,-.016,.070),(.012,-.016,.070),(.012,.016,.070),(-.012,.016,.070)]
  tr.add([tuple(cc+rot@Vector(p)) for p in vv],[(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)],S)
  # pins visible along outer edges
  cylinder(tr,(xc+sign*.35,y,z),.024,.023,S,(1,0,0),8)
  for fi in range(start,len(tr.f)):
   tr.uv[fi]=[((tr.v[idx][0]-xc)/.71+.5,k/count+((tr.v[idx][1]-y)*dy+(tr.v[idx][2]-z)*dz)/TRACKLEN) for idx in tr.f[fi]]
 # Roadwheel station origins ordered front to rear, then idler.
 stations=[(-2.54,.652,'sprocket')]+[(-1.92+i*.55,.523,'road') for i in range(8)]+[(2.54,.557,'idler')]
 for wi,(y,z,typ) in enumerate(stations,1):
  x=sign*(1.47 if wi%2==0 else 1.32);g=Part(f'Wheel_{label}{wi}',(x,y,z));ax=(sign,0,0)
  if typ=='road':
   # twin discs where interleaved suspension shows through outer wheels
   for shift in [-.125,.12]:
    c=(x+sign*shift,y,z)
    lathe(g,c,[(-.042,.36),(-.047,.416),(-.027,.438),(.028,.438),(.048,.416),(.044,.36)],R,ax,48)
    lathe(g,c,[(-.04,.08),(-.04,.36),(-.025,.391),(.035,.397),(.048,.366),(.025,.31),(.002,.14),(.045,.105)],P,ax,48)
   lathe(g,(x+sign*.12,y,z),[(.044,.365),(.049,.382),(.045,.393)],S,ax,48)
   cylinder(g,(x+sign*.178,y,z),.105,.073,P,ax,32);cylinder(g,(x+sign*.219,y,z),.055,.012,S,ax,24)
   for a in range(8):bolt(g,(x+sign*.222,y+.079*cos(a*pi/4),z+.079*sin(a*pi/4)),ax,.011)
  else:
   rr=.51 if typ=='sprocket' else .455
   lathe(g,(x,y,z),[(-.16,rr-.08),(-.16,rr),(.16,rr),(.16,rr-.08),(-.16,rr-.08)],P,ax,56)
   cylinder(g,(x,y,z),.14,.35,P,ax,32)
   for a in range(8):
    an=a*2*pi/8
    box(g,(x+sign*.13,y+(rr*.57)*cos(an),z+(rr*.57)*sin(an)),(.09,rr*.68,.084),P,.015,Matrix.Rotation(an,3,'X'))
    bolt(g,(x+sign*.192,y+.10*cos(an),z+.10*sin(an)),ax,.016)
   if typ=='sprocket':
    for a in range(20):
     an=a*2*pi/20;box(g,(x,y+(rr+.018)*cos(an),z+(rr+.018)*sin(an)),(.29,.075,.056),S,.008,Matrix.Rotation(an,3,'X'))
  # Suspension visible behind wheels, fixed to hull.
  between(H,(sign*1.19,y-.13,.79),(sign*1.27,y,z),.073,S,16)
# Driver visor with a real dark slit, welded casing and top cover.
box(H,(-.69,-2.872,1.65),(.65,.06,.27),S,.008)
box(H,(-.69,-2.916,1.72),(.66,.072,.095),P,.011)
box(H,(-.69,-2.908,1.535),(.66,.066,.067),P,.008)
for x in [-1.0,-.38]:box(H,(x,-2.908,1.633),(.070,.068,.23),P,.008)
box(H,(-.69,-2.914,1.637),(.48,.016,.045),G,.002)
# Hull ball machine gun, separate steel barrel.
lathe(H,(.65,-2.89,1.64),[(-.025,.19),(0,.20),(.065,.177),(.10,.11),(.112,.064)],P,(0,-1,0),40)
cylinder(H,(.65,-3.05,1.64),.034,.23,S,(0,1,0),20)
# Lamps, towing eyes and front spare track rack.
for sign,label in [(1,'L'),(-1,'R')]:
 LA=detachable('Headlight_'+label)
 cylinder(LA,(sign*1.25,-2.49,1.985),.084,.12,P,(0,1,0),32)
 cylinder(LA,(sign*1.25,-2.556,1.985),.065,.008,L,(0,1,0),32)
 LG=detachable('LampGuard_'+label)
 box(LG,(sign*1.25,-2.569,1.99),(.135,.014,.065),P,.005)
 between(H,(sign*1.25,-2.48,1.9),(sign*1.25,-2.48,1.956),.027,P)
 for y in [-2.99,2.97]:
  box(H,(sign*1.05,y,.90),(.12,.12,.32),P,.014)
  pts=[(sign*1.05+.085*cos(a),y-.085,.81+.12*sin(a)) for a in [2*pi*k/20 for k in range(21)]];tube(H,pts,.025,S)
for j in range(12):
 x=-.91+j*.166
 SL=detachable(f'SpareTrack_F{j+1:02d}')
 box(SL,(x,-3.099,1.025),(.15,.08,.37),T,.012)
 for z in [.94,1.15]:box(SL,(x,-3.145,z),(.126,.026,.042),S,.006)
box(H,(0,-3.10,.823),(2.12,.09,.055),P,.008)
AT=detachable('Tools_Glacis')
between(AT,(-.74,-2.91,1.382),(.55,-2.90,1.385),.018,W)
box(AT,(-.76,-2.91,1.384),(.16,.13,.065),S,.012)
for xx in [-.40,.38]:box(H,(xx,-2.905,1.385),(.045,.065,.06),P,.004)
# Deck hatches and ventilation grilles.
for x in [-.68,.68]:
 HH=Part('Hatch_Driver' if x<0 else 'Hatch_Radio',(x,-2.02,1.965),'Hull')
 cylinder(HH,(x,-1.79,1.941),.29,.046,P,n=48)
 tube(HH,[(x-.10,-1.81,1.98),(x-.10,-1.81,2.018),(x+.1,-1.81,2.018),(x+.1,-1.81,1.98)],.012,S)
 box(HH,(x,-2.02,1.965),(.18,.07,.05),P,.006)
for x in [-.81,.81]:
 for y in [1.49,2.35]:
  EG=detachable(f'EngineGrille_{side_name(x)}{1 if y<2 else 2}')
  box(EG,(x,y,1.933),(.96,.71,.027),S,.01)
  for j in range(14):box(EG,(x-.433+j*.066,y,1.956),(.032,.66,.025),P,.003)
  for sy in [-1,1]:box(EG,(x,y+sy*.35,1.966),(.98,.027,.024),P,.004)
box(H,(0,2.03,1.949),(.44,1.46,.05),P,.009)
for yy in [1.44,2.52]:tube(H,[(-.085,yy,1.98),(-.085,yy,2.02),(.085,yy,2.02),(.085,yy,1.98)],.012,S)
# Rear armour, exhaust stacks and protective sheet-metal shields.
box(H,(0,2.944,1.25),(2.6,.095,1.1),P,.012)
for x in [-.54,.54]:
 EX=detachable('Exhaust_'+side_name(x))
 SH=detachable('ExhaustGuard_'+side_name(x))
 cylinder(EX,(x,3.02,1.34),.116,.89,S,n=32)
 lathe(SH,(x,2.965,1.28),[(-.43,.15),(-.40,.19),(.40,.19),(.43,.17)],P,n=40)
 cylinder(EX,(x,2.965,1.80),.122,.035,S,n=32)
 for a in range(4):between(EX,(x+.10*cos(a*pi/2),2.965+.10*sin(a*pi/2),1.80),(x+.10*cos(a*pi/2),2.965+.10*sin(a*pi/2),1.885),.009,S,8)
 cylinder(EX,(x,2.965,1.89),.148,.023,P,n=32)
 for z in [.94,1.62]:box(SH,(x,3.12,z),(.41,.04,.055),P,.007)
for x in [-1.11,1.11]:
 FF=detachable('Feifel_'+side_name(x))
 cylinder(FF,(x,2.79,1.91),.185,.28,P,n=32)
 cylinder(FF,(x,2.79,2.056),.189,.022,S,n=32)
 FP=detachable('FeifelPipe_'+side_name(x))
 between(FP,(x,2.76,1.78),(x*.6,2.7,1.97),.056,R)
RB=detachable('RearStowage')
box(RB,(0,3.033,.72),(.65,.12,.14),P,.016)
# Braided tow cables follow sides and deck, with thimbles and mounting clamps.
for sign in [-1,1]:
 pts=[(sign*1.47,-2.22,1.73),(sign*1.49,-2.36,1.70),(sign*1.49,-2.26,1.65),(sign*1.49,-1.75,1.68),(sign*1.49,-.8,1.71),(sign*1.49,.3,1.70),(sign*1.49,1.4,1.71),(sign*1.48,2.40,1.69),(sign*1.48,2.56,1.76),(sign*1.47,2.4,1.79),(sign*1.47,1.0,1.79),(sign*1.47,-.5,1.78),(sign*1.47,-2.22,1.73)]
 TC=detachable('TowCable_'+side_name(sign))
 tube(TC,pts,.021,S,10)
 # subtle helical strand geometry on long cable run
 for strand in range(3):
  pp=[]
  for k in range(140):
   y=-2.12+k*4.5/139;ang=k*.9+strand*2*pi/3;pp.append((sign*1.49+.014*cos(ang),y,1.706+.014*sin(ang)))
  tube(TC,pp,.005,S,4)
 for y in [-1.9,-.9,.2,1.4,2.3]:box(H,(sign*1.51,y,1.75),(.045,.045,.13),P,.004)
 # Shovel: wooden shaft and forged scoop; axe alongside.
 TS=detachable('Tools_'+side_name(sign))
 between(TS,(sign*1.525,-.92,1.60),(sign*1.525,.92,1.60),.023,W)
 # Pressed spade with rounded shoulders, pointed cutting edge and raised centre rib.
 blade=[(-1.02,1.646),(-1.16,1.697),(-1.29,1.681),(-1.38,1.60),(-1.29,1.519),(-1.16,1.503),(-1.02,1.554)]
 vv=[(sign*(1.535+dx),yy,zz) for dx in [0,.009] for yy,zz in blade];nn=len(blade)
 ff=[tuple(reversed(range(nn))),tuple(range(nn,2*nn))]+[(i,(i+1)%nn,(i+1)%nn+nn,i+nn) for i in range(nn)]
 TS.add(vv,ff,S)
 between(TS,(sign*1.547,-.88,1.60),(sign*1.551,-1.25,1.60),.022,S)

 between(TS,(sign*1.52,.0,1.55),(sign*1.52,1.25,1.48),.018,W)
 blade=[(1.15,1.43),(1.24,1.43),(1.40,1.375),(1.43,1.41),(1.44,1.49),(1.40,1.59),(1.27,1.53),(1.15,1.53)]
 vv=[(sign*(1.53+dx),yy,zz) for dx in [-.019,.019] for yy,zz in blade];nn=len(blade)
 TS.add(vv,[tuple(reversed(range(nn))),tuple(range(nn,2*nn))]+[(i,(i+1)%nn,(i+1)%nn+nn,i+nn) for i in range(nn)],S)

 for y in [-.60,.6]:box(H,(sign*1.55,y,1.60),(.04,.065,.095),P,.005)
 # Deck crowbar and cleaning rods.
 CR=detachable('CleaningRods_'+side_name(sign))
 between(CR,(sign*1.26,.5,1.997),(sign*1.26,2.60,1.997),.019,S)
 for j in range(3):between(CR,(sign*(1.10+j*.052),.61,1.99),(sign*(1.10+j*.052),2.28,1.99),.021,W)
# Turret: characteristic horseshoe outline and flat broad front.
TU=Part('Turret',(0,-.23,1.97))
cylinder(TU,(0,-.23,1.976),1.02,.10,S,n=96)
cylinder(TU,(0,-.23,2.014),1.085,.06,P,n=96)
outline=[(-1.04,-1.28),(1.04,-1.28),(1.145,-.88),(1.19,-.30)]
for i in range(1,26):
 a=i*pi/26;outline.append((1.19*cos(a),-.30+1.20*sin(a)))
outline +=[(-1.19,-.30),(-1.145,-.88)]
prism(TU,outline,2.04,2.694,P,.027)
# roof thin visible plate weld seam
prism(TU,[(x*.99,y) for x,y in outline],2.692,2.719,P,.008)
TB=detachable('Turret_RommelBox','Turret')
box(TB,(0,1.003,2.39),(1.47,.37,.61),P,.015)
box(TB,(0,1.017,2.704),(1.50,.39,.034),P,.008)
for x in [-.52,.52]:
 box(TB,(x,1.203,2.47),(.052,.024,.26),P,.006)
 bolt(TB,(x,1.22,2.42),(0,1,0),.016)
# Side escape hatch and pistol ports.
for sign in [-1,1]:
 SP=detachable('Turret_Port_'+side_name(sign),'Turret')
 cylinder(SP,(sign*1.193,-.02,2.40),.147,.033,P,(1,0,0),40)
 cylinder(SP,(sign*1.217,-.02,2.40),.118,.024,P,(1,0,0),40)
 box(SP,(sign*1.24,-.07,2.40),(.025,.041,.14),S,.003)
 bolt(SP,(sign*1.245,.055,2.39),(sign,0,0),.020)
 SM=detachable('Turret_Smoke_'+side_name(sign),'Turret')
 # three smoke launchers on each forward turret corner, hollow rims
 for j in range(3):
  c=(sign*1.08,-1.11+j*.113,2.52+j*.056)
  ax=(sign*.5,-.72,.48)
  lathe(SM,c,[(-.10,.036),(.10,.044),(.105,.032),(-.065,.029)],S,ax,20)
  between(SM,(sign*.99,-.95,2.40),c,.022,P,12)
 # roof lifting eyes
 tube(TU,[(sign*.88,-.60,2.72),(sign*.88,-.60,2.77),(sign*.81,-.60,2.80),(sign*.74,-.60,2.77),(sign*.74,-.60,2.72)],.015,S)
# Cylindrical early commander's cupola, five vision blocks and hinged hatch.
cup=(.51,.30)
CP=detachable('Turret_CupolaRing','Turret')
lathe(CP,(cup[0],cup[1],2.72),[(0,.365),(.035,.38),(.075,.35),(.215,.35),(.235,.33)],P,n=64)
for j in range(6):
 a=j*2*pi/6;x=cup[0]+.352*cos(a);y=cup[1]+.352*sin(a)
 box(CP,(x,y,2.858),(.14,.012,.037),G,.003,Matrix.Rotation(a-pi/2,3,'Z'))
HC=Part('Hatch_Commander',(.51,.63,2.965),'Turret')
cylinder(HC,(.51,.30,2.969),.34,.035,P,n=64)
for xx in [.41,.51,.61]:cylinder(HC,(xx,.63,2.965),.026,.085,P,(1,0,0),20)
tube(HC,[(.42,.31,2.987),(.42,.31,2.991),(.60,.31,2.991),(.60,.31,2.987)],.009,S)
HL=Part('Hatch_Loader',(-.50,-.16,2.739),'Turret')
cylinder(HL,(-.50,-.38,2.739),.247,.029,P,n=48)
tube(HL,[(-.58,-.39,2.756),(-.58,-.39,2.789),(-.42,-.39,2.789),(-.42,-.39,2.756)],.01,S)
for x,y in [(-.51,.52),(.06,-.71)]:cylinder(TU,(x,y,2.743),.083,.044,P,n=32)
# Mantlet and long level 88mm gun. Pivot at actual trunnion axis.
GN=Part('Gun',(0,-1.31,2.36),'Turret')
box(GN,(0,-1.353,2.365),(1.88,.20,.57),P,.032)
box(GN,(0,-1.475,2.365),(1.72,.12,.48),P,.025)
for sign in [-1,1]:
 cylinder(GN,(sign*.81,-1.43,2.37),.094,.056,P,(1,0,0),32)
 for z in [2.19,2.54]:bolt(GN,(sign*.68,-1.548,z),(0,-1,0),.023)
for a in range(8):
 an=a*pi/4;bolt(GN,(.213*cos(an),-1.575,2.36+.213*sin(an)),(0,-1,0),.013)
# coax hole and sight aperture
for x,z in [(.51,2.41),(-.5,2.44)]:
 cylinder(GN,(x,-1.547,z),.036,.015,S,(0,1,0),24)
 cylinder(GN,(x,-1.558,z),.018,.014,R,(0,1,0),24)
BR=Part('Barrel',(0,-1.52,2.36),'Gun')
lathe(BR,(0,-1.52,2.36),[(0,.245),(.055,.25),(.105,.217),(.21,.185),(.48,.178),(.52,.15),(1.19,.138),(1.26,.12),(2.91,.092),(3.22,.086),(3.25,.109)],P,(0,-1,0),64)
for t,r in [(.10,.231),(.49,.177),(1.21,.141)]:lathe(BR,(0,-1.52-t,2.36),[(-.016,r),(.014,r),(.017,r-.01)],S,(0,-1,0),48)
# Double-baffle muzzle brake: open side ports and blackened bore.
for yy in [-4.84,-5.04,-5.265]:
 lathe(BR,(0,yy,2.36),[(-.030,.061),(-.030,.141),(-.013,.157),(.025,.151),(.03,.061),(-.030,.061)],S,(0,-1,0),48)
# Upper/lower connecting shell sections leave the two side windows physically open.
for side in [1,-1]:
 v=[]
 for rr in [.146,.139]:
  for yy in [-4.82,-5.295]:
   for i in range(17):
    a=(pi*.10+i*pi*.80/16)+(0 if side==1 else pi);v.append((rr*cos(a),yy,2.36+rr*sin(a)))
 f=[]
 for j in range(16):f.extend([(j,j+1,j+18,j+17),(j+34,j+51,j+52,j+35)])
 for j in [0,16]:f.append((j,j+17,j+51,j+34))
 for k in [0,17]:
  for j in range(16):f.append((k+j,k+j+34,k+j+35,k+j+1))
 BR.add(v,f,S,True)
lathe(BR,(0,-5.265,2.36),[(0,.061),(-.30,.061)],R,(0,-1,0),32)
# Roof MG34 on a low anti-aircraft pintle, independently aimable.
MG=Part('MG',(.87,.27,2.88),'Turret')
between(TU,(.87,.27,2.72),(.87,.27,2.88),.022,S)
box(MG,(.87,.19,2.884),(.075,.31,.092),S,.012)
cylinder(MG,(.87,-.22,2.884),.035,.53,S,(0,1,0),24)
for j in range(9):
 lathe(MG,(.87,-.44+j*.05,2.884),[(-.008,.036),(.008,.036)],R,(0,1,0),20)
box(MG,(.87,.39,2.858),(.075,.20,.067),W,.012)
box(MG,(.87,.24,2.806),(.042,.06,.10),S,.005,Matrix.Rotation(-.27,3,'X'))
# Fasteners and welded seams, restrained to structural zones.
for sign in [-1,1]:
 for y in [-2.58,-1.8,-.9,0,.9,1.8,2.72]:bolt(H,(sign*1.39,y,1.928),r=.014)
 for y in [-2.3,-1.2,0,1.2,2.55]:bolt(H,(sign*1.461,y,1.46),(sign,0,0),.016)
 tube(H,[(sign*1.405,-2.58,1.896),(sign*1.414,-1.1,1.897),(sign*1.414,.7,1.897),(sign*1.414,2.8,1.897)],.009,P,6)
# Local chipped paint at exposed edges, brackets and wheel lips.
for sign in [-1,1]:
 for j in range(125):
  yy=random.uniform(-2.62,2.70);zz=random.choice([1.382,1.855])+random.uniform(-.013,.013)
  ll=random.uniform(.012,.062);hh=random.uniform(.002,.009)
  vv=[(sign*1.452,yy-ll/2,zz),(sign*1.453,yy+ll*.34,zz-hh),(sign*1.453,yy+ll/2,zz+hh*.2),(sign*1.452,yy-ll*.23,zz+hh)]
  H.add(vv,[(0,1,2,3) if sign==1 else (3,2,1,0)],S)
 for j in range(100):
  yy=random.uniform(-2.91,2.81);xx=sign*random.uniform(1.49,1.73);zz=1.337+(abs(xx)-1.58)*(-.12)
  ll=random.uniform(.007,.038);ww=random.uniform(.002,.009)
  fj=max(0,min(5,round((yy+2.54)/1.005)))
  groups[f'Part_Skirt_{side_name(sign)}{fj+1}'].add([(xx-ww,yy-ll,zz),(xx+ww,yy-ll*.4,zz+.001),(xx+ww*.4,yy+ll,zz),(xx-ww*.4,yy+ll*.6,zz)],[(0,1,2,3)],S)
 for j in range(80):
  yy=random.uniform(-1.18,.6);zz=random.choice([2.065,2.683])+random.uniform(-.014,.014)
  # side scratch only in front straight portion; arc chips follow actual horseshoe
  if yy<=-.30:xx=sign*(1.145+(.88+yy)/.58*.045)
  else:xx=sign*1.19*math.sqrt(max(0,1-((yy+.30)/1.20)**2))
  ll=random.uniform(.01,.045);hh=random.uniform(.002,.006)
  TU.add([(xx+sign*.002,yy-ll,zz),(xx+sign*.002,yy+ll,zz-hh),(xx+sign*.002,yy+ll*.5,zz+hh)],[(0,1,2) if sign==1 else (2,1,0)],S)
# Every detachable piece has a centred debris pivot, before envelope conversion.
for g in groups.values():
 if g.name.startswith('Part_'):
  g.pivot=Vector(tuple((min(v[k] for v in g.v)+max(v[k] for v in g.v))/2 for k in range(3)))
# Exportable objects and sockets.
# Exact game envelope: width 3.56 m, ground contact 0, height 3.00 m.
allv=[v for g in groups.values() for v in g.v]
xmax=max(abs(v[0]) for v in allv);zmin=min(v[2] for v in allv);zmax=max(v[2] for v in allv)
sx=1.78/xmax;sz=3.0/(zmax-zmin)
def envelope(v):return (v[0]*sx,v[1],(v[2]-zmin)*sz)
for g in groups.values():
 g.v=[envelope(v) for v in g.v];g.pivot=Vector(envelope(g.pivot))
 if g.name in ['Hull','Track_L','Track_R']:g.pivot=Vector((0,0,0))
objects={k:g.build() for k,g in groups.items()}
bpy.context.view_layer.update()
for k,g in groups.items():
 if g.parent:
  ob=objects[k];world=ob.matrix_world.copy();ob.parent=objects[g.parent];ob.matrix_world=world
sockets={'Fx_Engine':((0,2.03,1.96),'Hull'),'Fx_Ammo':((0,-.1,1.0),'Hull'),'Fx_Hatch':((.51,.30,3.0),'Turret'),'Muzzle':((0,-5.295,2.36),'Barrel'),'Seat_Commander':((.51,.30,3.0),'Turret'),'Seat_Driver':((-.69,-1.80,1.92),'Hull'),'Exhaust':((0,2.965,1.89),'Hull'),'Light_Head_L':((1.25,-2.56,1.985),'Hull'),'Light_Head_R':((-1.25,-2.56,1.985),'Hull')}
for name,(pos,parent) in sockets.items():
 ob=bpy.data.objects.new(name,None);scene.collection.objects.link(ob);ob.location=envelope(pos);bpy.context.view_layer.update();world=ob.matrix_world.copy();ob.parent=objects[parent];ob.matrix_world=world;objects[name]=ob
 if name=='Muzzle':ob.rotation_euler=(pi/2,0,0) # +Z local points down Blender -Y; glTF forward +Z
bpy.context.view_layer.update()
# --- tank-damage/v1 retrofit. The geometry above is the original library build. ---
# Empty transforms use the same Y-up conversion as their parents: identity at muzzle.
objects['Muzzle'].rotation_euler=(0,0,0)

def active_only(ob):
 bpy.ops.object.select_all(action='DESELECT');ob.select_set(True);bpy.context.view_layer.objects.active=ob

def apply_mod(ob,mod):
 active_only(ob);bpy.ops.object.modifier_apply(modifier=mod.name)

def finish_mesh(ob):
 bm=bmesh.new();bm.from_mesh(ob.data)
 bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.000001)
 bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=.0000001)
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(ob.data);bm.free()
 mod=ob.modifiers.new('Triangles','TRIANGULATE');apply_mod(ob,mod)

def new_geometry(g,convert=True):
 if convert:
  g.v=[envelope(v) for v in g.v];g.pivot=Vector(envelope(g.pivot))
 ob=g.build()
 if g.parent:
  bpy.context.view_layer.update();world=ob.matrix_world.copy();ob.parent=objects[g.parent];ob.matrix_world=world
 finish_mesh(ob);return ob

def bore(ob,center,radius,zlo,zhi):
 bpy.ops.mesh.primitive_cylinder_add(vertices=64,radius=radius,depth=zhi-zlo,location=(center[0],center[1],(zlo+zhi)/2))
 cutter=bpy.context.object;mod=ob.modifiers.new('Crew opening','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter
 apply_mod(ob,mod);bpy.data.objects.remove(cutter,do_unlink=True)

# Make actual crew openings and closed dark wells. No floating black decals.
for name,base,center,radius,top,bottom in [
 ('Driver','Hull',(-.68,-1.79),.25,1.96,1.34),
 ('Radio','Hull',(.68,-1.79),.25,1.96,1.34),
 ('Loader','Turret',(-.50,-.38),.207,2.75,2.13),
 ('Commander','Turret',(.51,.30),.31,2.99,2.13)]:
 c=envelope((center[0],center[1],top));lo=envelope((0,0,bottom))[2]
 bore(objects[base],c,radius*sx,lo,c[2]+.06)
 g=Part('CrewWell_'+name,parent=base)
 lathe(g,(center[0],center[1],bottom),[(0,0),(0,radius-.012),(.03,radius), (top-bottom-.065,radius),(top-bottom-.06,radius+.018)],R,n=48)
 well=new_geometry(g)
 # Join the well into fixed assembly, preserving the assembly pivot.
 active_only(objects[base]);well.select_set(True);bpy.ops.object.join()

# Fixed cupola liner supports the commander hatch when the outer armoured ring
# is stripped. It is fully hidden in the intact model.
g=Part('Cupola support liner',parent='Turret')
lathe(g,(.51,.30,2.72),[(0,.316),(0,.326),(.228,.326),(.228,.316),(0,.316)],P,n=64)
liner=new_geometry(g);active_only(objects['Turret']);liner.select_set(True);bpy.ops.object.join()
# Fixed attachment bosses remain after the bolt-on equipment is removed.
g=Part('Hidden mounting bosses',parent='Hull')
for sign in [-1,1]:
 for yy in [-1.90,-.90,.2,1.4,2.3]:
  box(g,(sign*1.47,yy,1.75),(.055,.047,.065),P,.004)
 for yy in [-.60,.60]:box(g,(sign*1.495,yy,1.60),(.075,.048,.06),P,.005)
for xx in [-1.11,1.11]:
 for zz in [1.80,1.98]:box(g,(xx,2.88,zz),(.15,.06,.035),P,.005)
mounts=new_geometry(g);active_only(objects['Hull']);mounts.select_set(True);bpy.ops.object.join()

# UV0 goes once round the tracks; a texture transform restores the original tile density.
for mat_index in [T,S]:
 m=materials[mat_index];n=m.node_tree.nodes;l=m.node_tree.links
 uv=n.new('ShaderNodeTexCoord');mapping=n.new('ShaderNodeMapping');mapping.inputs['Scale'].default_value=(1,18,1);l.new(uv.outputs['UV'],mapping.inputs['Vector'])
 for node in n:
  if node.type=='TEX_IMAGE':l.new(mapping.outputs['Vector'],node.inputs['Vector'])
for ob in objects.values():
 if ob.type!='MESH':continue
 if not ob.name.startswith('Track_'):
  for poly in ob.data.polygons:
   if ob.data.materials[poly.material_index].name in ['TrackSteel','Steel']:
    for li in poly.loop_indices:ob.data.uv_layers.active.data[li].uv.y/=18
 finish_mesh(ob)

paints=[{'name':'Dark yellow (default)','linear_rgb':[.47,.42,.28]},{'name':'Olive drab','linear_rgb':[.18,.205,.105]},{'name':'NATO green','linear_rgb':[.12,.18,.105]},{'name':'Desert tan','linear_rgb':[.59,.47,.30]},{'name':'Field grey','linear_rgb':[.24,.29,.265]},{'name':'Winter white','linear_rgb':[.79,.80,.76]}]
def cv(v):return [round(v.x,5),round(v.z,5),round(-v.y,5)]
def count_tris(obs):
 total=0
 for ob in obs:
  if ob.type=='MESH':ob.data.calc_loop_triangles();total+=len(ob.data.loop_triangles)
 return total

def glb_export(obs,path):
 bpy.context.view_layer.update();bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.hide_set(False);o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_image_format='JPEG',export_jpeg_quality=86,export_cameras=False,export_lights=False)
 raw=path.read_bytes();jlen=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jlen]);binary=bytearray(raw[20+jlen:])
 for mesh in doc['meshes']:
  for prim in mesh['primitives']:
   attrs=prim['attributes']
   if 'TANGENT' not in attrs:continue
   ta=doc['accessors'][attrs['TANGENT']];na=doc['accessors'][attrs['NORMAL']]
   tv=doc['bufferViews'][ta['bufferView']];nv=doc['bufferViews'][na['bufferView']]
   for i in range(ta['count']):
    to=8+tv.get('byteOffset',0)+ta.get('byteOffset',0)+i*tv.get('byteStride',16);vals=struct.unpack_from('<4f',binary,to)
    if sum(v*v for v in vals[:3])<1e-12:
     no=8+nv.get('byteOffset',0)+na.get('byteOffset',0)+i*nv.get('byteStride',12);normal=Vector(struct.unpack_from('<3f',binary,no));axis=Vector((1,0,0)) if abs(normal.x)<.9 else Vector((0,1,0));tangent=normal.cross(axis).normalized();struct.pack_into('<4f',binary,to,*tangent,1.0 if vals[3]>=0 else -1.0)
 for m in doc.get('materials',[]):
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.47,.42,.28,1]
  pbr=m.get('pbrMetallicRoughness',{})
  if 'metallicRoughnessTexture' in pbr:m['occlusionTexture']=dict(pbr['metallicRoughnessTexture'])
 j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+binary)
 print('EXPORTED',path.name,count_tris(obs),path.stat().st_size,flush=True)
 return doc

healthy=list(objects.values());healthy_tri=count_tris(healthy)
healthy_doc=glb_export(healthy,ROOT/'tiger-heavy-tank.glb')
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'healthy_checkpoint.blend'))
print('HEALTHY_CHECKPOINT',flush=True)
if '--healthy-only' in sys.argv:sys.exit(0)

# Two real Cycles-baked damage tile sets, from the same CC0 rust scan.
B=bake_material('Burnt','rusty_metal_04',(.065,.057,.047),.58,.89)
SC=bake_material('Scars','rusty_metal_04',(.34,.35,.34),.88,.51)
burnt=materials[B];scars=materials[SC]
# Blackened cavities and bare steel are two regions of a single Scars texture.
# The image itself carries the soot; runtime does not rely on a procedural shader.
import numpy as np
for kind in ['base','normal','orm']:
 im=next(n.image for n in scars.node_tree.nodes if n.type=='TEX_IMAGE' and n.image.name.startswith('Scars_'+kind))
 px=np.array(im.pixels[:],dtype=np.float32).reshape((im.size[1],im.size[0],4))
 if kind=='base':
  px[:,:128,:3]=.004
  # A radial feathered soot field for impact fans in the middle strip.
  yy,xx=np.mgrid[0:px.shape[0],0:256];rad=((xx-128)/128)**2+((yy-256)/256)**2
  px[:,128:384,:3]=(.008+.018*np.clip(rad,0,1))[...,None]
 elif kind=='orm':px[:,:384,1]=.94;px[:,:384,2]=.12
 im.pixels.foreach_set(px.ravel());im.save()

# File B has a stricter texture budget; File A was already exported unchanged.
for mat in [materials[T],materials[R]]:
 for node in mat.node_tree.nodes:
  if node.type=='TEX_IMAGE' and max(node.image.size)>512:
   im=node.image.copy();im.scale(512,512);im.filepath_raw=str(TEX/(mat.name+'_damage_'+('normal' if 'normal' in node.image.name else 'base')+'.jpg'));im.file_format='JPEG';im.save();node.image=im
# All damage meshes are built in the same coordinate frame, no origin changes.
damage={}
def root_empty(name,pivot):
 ob=bpy.data.objects.new(name,None);scene.collection.objects.link(ob);ob.location=pivot;damage[name]=ob;return ob
hd=root_empty('Hull_Damage',(0,0,0));td=root_empty('Turret_Damage',objects['Turret'].matrix_world.translation.copy())
bpy.context.view_layer.update()
def parent_world(ob,parent):
 bpy.context.view_layer.update();w=ob.matrix_world.copy();ob.parent=parent;ob.matrix_world=w

def copy_mesh(source,name,parent=None):
 ob=bpy.data.objects.new(name,source.data.copy());scene.collection.objects.link(ob);ob.matrix_world=source.matrix_world.copy()
 if parent:parent_world(ob,parent)
 return ob

def damage_palette(ob,burn=False):
 # Exactly the five permitted damage materials, no Wood/Glass/Lamps/Steel leakage.
 for i,m in enumerate(ob.data.materials):
  if burn:ob.data.materials[i]=burnt
  elif m.name not in ['Paint','TrackSteel','Rubber','Burnt','Scars']:ob.data.materials[i]=scars if m.name=='Steel' else burnt

def decimate_to(ob,target):
 ob.data.calc_loop_triangles();num=len(ob.data.loop_triangles)
 if num>target:
  mod=ob.modifiers.new('Damage triangle budget','DECIMATE');mod.ratio=target/num;mod.use_collapse_triangulate=True;apply_mod(ob,mod)
 finish_mesh(ob)

part_pairs=[]
selected_twins=['Fender_FL','Fender_FR','Fender_RL','Fender_RR','Skirt_L1','Skirt_L2','Skirt_R1','Skirt_R2','Feifel_L','Feifel_R','ExhaustGuard_L','ExhaustGuard_R','RearStowage','Turret_RommelBox','Turret_Smoke_L','Turret_Smoke_R','Turret_CupolaRing']
for name,src in objects.items():
 if not name.startswith('Part_'):continue
 short=name[5:];twin='Dmg_'+short if short in selected_twins else None
 part_pairs.append({'part':name,'twin':twin})
 if not twin:continue
 ob=copy_mesh(src,twin,td if short.startswith('Turret_') else hd);damage_palette(ob)
 ob.data.materials.append(burnt)
 coords=[v.co.copy() for v in ob.data.vertices];lo=Vector(tuple(min(v[k] for v in coords) for k in range(3)));hi=Vector(tuple(max(v[k] for v in coords) for k in range(3)))
 for v in ob.data.vertices:
  q=v.co;u=(q.y-lo.y)/max(.001,hi.y-lo.y);edge=(q.x-lo.x)/max(.001,hi.x-lo.x)
  if 'Skirt' in short or 'Fender' in short:
   q.z+=.21*sin(u*pi*.75)*(edge if 'L' in short else 1-edge);q.x+=.12*u*u*(1 if 'L' in short else -1)
  elif 'Rommel' in short or 'RearStowage' in short:
   q.y+=.17*sin(q.x*5)*max(0,(q.z-lo.z)/(hi.z-lo.z));q.z-=.15*u*u
  else:q.x+=.06*sin(q.z*9)*u;q.z-=.09*u;q.y+=.10*u*u
 # Scorched areas keep some Paint so damaged twins follow faction recolouring.
 for poly in ob.data.polygons:
  c=poly.center
  if random.random()<.30:poly.material_index=next((i for i,m in enumerate(ob.data.materials) if m==burnt),0)
 damage[twin]=ob;finish_mesh(ob)

# Scar panels: annular petalled rims + inset black centres and steel ricochet ribbons.
# Four sites per face. Every rim is 2--4 mm proud of its own armour plane.
def make_scars(name,centers,norm,parent):
 g=Part(name);normal=Vector(norm).normalized();u=normal.cross(Vector((0,0,1)))
 if u.length<.1:u=Vector((1,0,0))
 u.normalize();v=normal.cross(u)
 for j,center in enumerate(centers):
  c=Vector(envelope(center));r=.047 if j==0 else .026+j*.009;n=20
  vv=[];uv=[]
  # soot fan irregular outer edge, bevelled steel rim, deep-looking black centre
  for ring,rad,depth in [(0,r*2.2,.002),(1,r*1.2,.003),(2,r,.004),(3,r*.65,.0025)]:
   for k in range(n):
    a=k*2*pi/n;rr=rad*(1+.13*sin(k*2.9+j));p=c+u*(rr*cos(a))+v*(rr*sin(a))+normal*depth;vv.append(tuple(p));uv.append((.42 if ring==0 else (.84 if ring in [1,2] else .05),.5+.25*sin(a)))
  faces=[]
  for ring in range(3):
   for k in range(n):faces.append((ring*n+k,ring*n+(k+1)%n,(ring+1)*n+(k+1)%n,(ring+1)*n+k))
  g.add(vv,faces,SC,False,uv)
  cvv=[tuple(c+normal*.0021)]+[tuple(c+u*(r*.65*cos(k*2*pi/n))+v*(r*.65*sin(k*2*pi/n))+normal*.0021) for k in range(n)]
  g.add(cvv,[(0,k+1,(k+1)%n+1) for k in range(n)],SC,False,[(.04,.5)]*len(cvv))
  # A long shallow steel gouge/ricochet trailing away from the strike.
  tail=c+u*(.14+j*.025)+v*.065
  g.add([tuple(c+u*r+normal*.003),tuple(tail+normal*.003),tuple(tail+v*.012+normal*.003),tuple(c+v*.018+normal*.003)],[(0,1,2,3)],SC,False,[(.88,.4),(.95,.6),(.96,.65),(.88,.5)])
 if name.startswith('Scar_Turret_'):
  projected=[]
  for point in g.v:
   q=Vector(point)
   if name.endswith('_L') or name.endswith('_R'):
    intersections=[]
    for aa,bb in zip(outline,outline[1:]+outline[:1]):
     if min(aa[1],bb[1])<=q.y<=max(aa[1],bb[1]) and abs(bb[1]-aa[1])>1e-8:
      t=(q.y-aa[1])/(bb[1]-aa[1]);intersections.append((aa[0]+t*(bb[0]-aa[0]))*sx)
    if intersections:q.x=(max(intersections)+.003) if name.endswith('_L') else (min(intersections)-.003)
   elif name.endswith('_Rear'):
    intersections=[];x=q.x/sx
    for aa,bb in zip(outline,outline[1:]+outline[:1]):
     if min(aa[0],bb[0])<=x<=max(aa[0],bb[0]) and abs(bb[0]-aa[0])>1e-8:
      t=(x-aa[0])/(bb[0]-aa[0]);intersections.append(aa[1]+t*(bb[1]-aa[1]))
    if intersections:q.y=max(intersections)+.003
   projected.append(tuple(q))
  g.v=projected
 ob=new_geometry(g,False);parent_world(ob,parent);damage[name]=ob

make_scars('Scar_Hull_Front',[(-.25,-2.851,1.69),(.24,-2.851,1.48),(-1.12,-2.851,1.57),(.95,-2.851,1.78)],(0,-1,0),hd)
make_scars('Scar_Hull_L',[(1.452,y,z) for y,z in [(-1.8,1.56),(-.9,1.7),(.5,1.53),(1.8,1.68)]],(1,0,0),hd)
make_scars('Scar_Hull_R',[(-1.452,y,z) for y,z in [(-1.7,1.62),(-.7,1.5),(.7,1.69),(1.9,1.54)]],(-1,0,0),hd)
make_scars('Scar_Hull_Rear',[(x,2.994,z) for x,z in [(-.95,1.22),(-.3,1.55),(.3,1.03),(.98,1.53)]],(0,1,0),hd)
make_scars('Scar_Turret_Front',[(x,-1.285,z) for x,z in [(-.85,2.57),(-.3,2.65),(.30,2.60),(.87,2.56)]],(0,-1,0),td)
# Follow the actual curved side armour instead of flat floating scar plates.
for sign,label in [(1,'L'),(-1,'R')]:
 centers=[]
 for yy,zz in [(-.65,2.34),(-.4,2.55),(-.12,2.42),(.20,2.57)]:
  xx=(1.145+(.88+yy)/.58*.045) if yy<=-.30 else 1.19*math.sqrt(1-((yy+.30)/1.20)**2)
  centers.append((sign*(xx+.004),yy,zz))
 make_scars('Scar_Turret_'+label,centers,(sign,0,0),td)
make_scars('Scar_Turret_Rear',[(x,.896,z) for x,z in [(-.24,2.35),(-.10,2.59),(.12,2.45),(.25,2.62)]],(0,1,0),td)

# A thrown belt: copied link geometry, rotated as complete links, with the front break open.
def thrown_track(src,name,parent=None):
 ob=copy_mesh(src,name,parent);bm=bmesh.new();bm.from_mesh(ob.data)
 # Each connected island is one manufactured piece of a link. Its tangent follows
 # the remapped link station; neighbouring cleats/pins retain the same rotation.
 remaining=set(bm.verts)
 while remaining:
  seed=next(iter(remaining));stack=[seed];island=[];remaining.remove(seed)
  while stack:
   q=stack.pop();island.append(q)
   for e in q.link_edges:
    v=e.other_vert(q)
    if v in remaining:remaining.remove(v);stack.append(v)
  center=sum((q.co for q in island),Vector())/len(island);world=src.matrix_world@center
  stations=[Vector(envelope((0,*path_track(k/100*TRACKLEN)[0]))) for k in range(100)]
  idx=min(range(100),key=lambda k:(world.y-stations[k].y)**2+(world.z-stations[k].z)**2)
  station=stations[idx];y=station.y;z=station.z
  (_, _),(dy,dz)=path_track(idx/100*TRACKLEN);angle=math.atan2(dz*sz,dy)
  pivot=Vector((0,y,z))
  if z>.77 and -2.55<y<2.6:
   # Keep every cleat and pin rigidly attached to its original link station.
   shift=Vector((.60 if world.x>0 else -.60,.65,.067-z))
   rot=Matrix.Rotation(-angle,3,'X')
   for q in island:q.co=pivot+rot@(q.co-pivot)+shift
  elif y<-2.35 and z>.22:
   newy=-3.10-(z-.22)*1.6;delta=Vector((0,newy-y,.067-z));rot=Matrix.Rotation(-angle,3,'X')
   for q in island:q.co=pivot+rot@(q.co-pivot)+delta
 bm.to_mesh(ob.data);bm.free();damage_palette(ob);decimate_to(ob,6500);return ob
for label in ['L','R']:
 name='Track_'+label+'_Broken';ob=thrown_track(objects['Track_'+label],name,hd);damage[name]=ob

# Broken mantlet and barrel are copied from healthy meshes, then bent at the bore.
parts=[]
for name in ['Gun','Barrel']:
 ob=copy_mesh(objects[name],name+'_broken_tmp');damage_palette(ob)
 if name=='Barrel':
  inv=ob.matrix_world.inverted()
  for v in ob.data.vertices:
   p=ob.matrix_world@v.co;t=max(0,-p.y-2.2);p.z-=.22*t+.14*t*t;p.x+=.10*t*t;v.co=inv@p
 parts.append(ob)
active_only(parts[0]);parts[1].select_set(True);bpy.ops.object.join();broken=parts[0];broken.name='Gun_Broken';parent_world(broken,td);damage['Gun_Broken']=broken
# Torn muzzle at mid barrel, showing an open, jagged, dark bore.
g=Part('Burst petals')
for k in range(8):
 a=k*pi/4;rr=.12
 vv=[]
 for y,r in [(-3.10,rr),(-3.40,rr+.11),(-3.52,rr+.16)]:
  for da in [-.15,.15]:vv.append((r*cos(a+da)+.1,y,2.03+r*sin(a+da)))
 g.add(vv,[(0,1,3,2),(2,3,5,4)],SC)
petals=new_geometry(g);active_only(broken);petals.select_set(True);bpy.ops.object.join();finish_mesh(broken)

# Wreck meshes are copies of the healthy assemblies, with open hatches, missing
# equipment, sagged running gear, ruptured deck and no surviving Paint material.
def wreck_mesh(turret):
 copies=[]
 skip=['Part_Fender_FL','Part_Skirt_L1','Part_Skirt_L2','Part_Skirt_R2','Part_Feifel_L','Part_ExhaustGuard_R','Part_Turret_RommelBox','Part_Turret_Smoke_R']
 for name,src in objects.items():
  if src.type!='MESH' or name in skip:continue
  is_turret=name in ['Turret','Gun','Barrel','MG','Hatch_Commander','Hatch_Loader'] or name.startswith('Part_Turret_')
  if is_turret!=turret:continue
  ob=copy_mesh(src,'wreck_'+name);damage_palette(ob,True)
  # Open hatch geometry about the original hinge.
  if name.startswith('Hatch_'):ob.rotation_euler.x+=math.radians(-105 if name in ['Hatch_Commander','Hatch_Loader'] else 105)
  if name in ['Gun','Barrel']:
   pivot=objects['Gun'].matrix_world.translation;rot=Matrix.Rotation(math.radians(10),4,'X');ob.matrix_world=Matrix.Translation(pivot)@rot@Matrix.Translation(-pivot)@ob.matrix_world
  if name.startswith('Wheel_'):
   ob.location.z-=.05
   # Rubber bands burn off: shrink only original rubber vertices towards axle.
   rubber_verts={i for poly in src.data.polygons if src.data.materials[poly.material_index].name=='Rubber' for i in poly.vertices}
   for i in rubber_verts:ob.data.vertices[i].co.y*=.92;ob.data.vertices[i].co.z*=.92
  if name.startswith('Part_EngineGrille'):
   lo=min(v.co.y for v in ob.data.vertices);hi=max(v.co.y for v in ob.data.vertices)
   for v in ob.data.vertices:
    u=(v.co.y-lo)/(hi-lo);lift={'Part_EngineGrille_L1':.17,'Part_EngineGrille_L2':.37,'Part_EngineGrille_R1':.28,'Part_EngineGrille_R2':.45}[name];v.co.z+=lift*u*u*(.6+.5*sin(v.co.x*3+1));v.co.x+=.09*sin(u*pi)

  if name.startswith('Track_'):
   bpy.data.objects.remove(ob,do_unlink=True)
   ob=thrown_track(src,'wreck_'+name) if name=='Track_L' else copy_mesh(src,'wreck_'+name)
   damage_palette(ob,True)
   for v in ob.data.vertices:
    if v.co.z>.8:v.co.z-=.06
  copies.append(ob)
 base=next(o for o in copies if o.name==('wreck_Turret' if turret else 'wreck_Hull'))
 # Open turret ring well in hull wreck; closed turret base and basket stub survive.
 if not turret:
  c=objects['Turret'].matrix_world.translation;bore(base,c,1.025,.88,2.2)
  for x in [-.81,.81]:bore(base,envelope((x,2.35,1.8)),.33,1.35,2.10)
  well=Part('Burnt ring well')
  lathe(well,(0,-.23,.87),[(0,0),(0,1.02),(.84,1.02),(.88,1.06),(.90,1.06)],B,n=64)
  copies.append(new_geometry(well))
 else:
  basket=Part('Burnt turret basket')
  lathe(basket,(0,-.23,1.97),[(-.40,.66),(-.37,.72),(-.05,.79),(0,1.01),(.035,1.01)],B,n=64)
  cylinder(basket,(0,-.23,1.575),.67,.06,B,n=64)
  copies.append(new_geometry(basket))
 active_only(base)
 for ob in copies:ob.select_set(True)
 bpy.ops.object.join();base.name='Wreck_Turret' if turret else 'Wreck_Hull';base.parent=None
 damage_palette(base,True);decimate_to(base,17000 if turret else 39000);damage[base.name]=base
 return base
wreck_mesh(False);wreck_mesh(True)
# Consolidate unused slots before export; retain exactly four top-level nodes.
for ob in damage.values():
 if ob.type=='MESH':
  old=list(ob.data.materials);unique=[];remap={}
  for i,mat in enumerate(old):
   if mat not in unique:unique.append(mat)
   remap[i]=unique.index(mat)
  indices=[remap[p.material_index] for p in ob.data.polygons];ob.data.materials.clear()
  for mat in unique:ob.data.materials.append(mat)
  for p,idx in zip(ob.data.polygons,indices):p.material_index=idx
  active_only(ob);bpy.ops.object.material_slot_remove_unused();finish_mesh(ob)
damage_tri=count_tris(damage.values())
assert damage_tri<=110000,damage_tri
assert sorted(o.name for o in damage.values() if not o.parent)==['Hull_Damage','Turret_Damage','Wreck_Hull','Wreck_Turret']
damage_doc=glb_export(list(damage.values()),ROOT/'tiger-heavy-tank-damage.glb')
damage_tri=sum(damage_doc['accessors'][p['indices']]['count']//3 for m in damage_doc['meshes'] for p in m['primitives'])
print('DAMAGE_CHECKPOINT',damage_tri,flush=True)

# Manifest records both file inventories and world-space pivots.
def inventory(obs):return [{'name':o.name,'world_position':cv(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type} for o in obs]
def bounds_of(obs):
 vs=[cv(o.matrix_world@v.co) for o in obs if o.type=='MESH' for v in o.data.vertices]
 return {'min':[min(v[k] for v in vs) for k in range(3)],'max':[max(v[k] for v in vs) for k in range(3)]}
manifest={'asset':'Tiger I style heavy tank destructible retrofit','contract':'tank-damage/v1','units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'triangles':healthy_tri,'damage_triangles':damage_tri,'bounding_box':bounds_of(healthy),'damage_bounding_box':bounds_of(damage.values()),'paints':paints,'nodes':inventory(healthy),'damage_nodes':inventory(damage.values()),'damage':{'parts':part_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']},'track_uv':'UV0 V = once around the belt; KHR_texture_transform preserves original repeated steel grain','provenance':'Original v1 procedural geometry; copied v1 Cycles texture tiles; additions and damage from same source.'}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'damage_checkpoint.blend'))
# Render only round-tripped delivery files. This catches export-only failures.
for ob in list(bpy.data.objects):bpy.data.objects.remove(ob,do_unlink=True)
for mat in list(bpy.data.materials):bpy.data.materials.remove(mat)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'tiger-heavy-tank.glb'))
A={o.name:o for o in scene.objects}
bpy.ops.import_scene.gltf(filepath=str(ROOT/'tiger-heavy-tank-damage.glb'))
D={o.name:o for o in scene.objects if o.name not in A}
for ob in list(A.values())+list(D.values()):ob.rotation_mode='XYZ'
# Both files intentionally share Paint. Blender adds a suffix on the second import.
paint_controls=[]
for m in bpy.data.materials:
 if m.name.split('.')[0]!='Paint':continue
 bs=next(n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED')
 base=next(n for n in m.node_tree.nodes if n.type=='TEX_IMAGE' and 'Paint_base' in n.image.name)
 mix=m.node_tree.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.47,.42,.28,1);m.node_tree.links.new(base.outputs[0],mix.inputs[1]);m.node_tree.links.new(mix.outputs[0],bs.inputs['Base Color']);paint_controls.append(mix)

def visible(ob,yes):ob.hide_render=not yes

def subtree(ob,yes):
 visible(ob,yes)
 for child in ob.children:subtree(child,yes)

def state(k):
 for o in A.values():visible(o,k<4)
 for o in D.values():visible(o,False)
 if k==1:
  for n,o in D.items():
   if n.startswith('Scar_'):visible(o,True)
  for n in ['Fender_FL','Fender_FR','Skirt_L1','Skirt_L2','Turret_RommelBox','Turret_Smoke_L']:
   visible(A['Part_'+n],False);visible(D['Dmg_'+n],True)
 elif k==2:
  for n,o in A.items():
   if n.startswith('Part_'):visible(o,False)
 elif k==3:
  visible(A['Track_L'],False);subtree(A['Gun'],False);visible(D['Track_L_Broken'],True);visible(D['Gun_Broken'],True)
 elif k in [4,5]:
  visible(D['Wreck_Hull'],True);visible(D['Wreck_Turret'],True)

state(0)
ground=bpy.data.materials.new('Presentation ground');ground.diffuse_color=(.19,.205,.21,1);ground.use_nodes=True;ground.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.9
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.035));bpy.context.object.name='Studio ground';bpy.context.object.data.materials.append(ground)
world=bpy.data.worlds.new('Soft daylight');scene.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.64,.72,.83,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
for name,pos,power,size in [('Key',(4,-6,9),1700,6),('Fill',(-5,-2,5),450,6),('Rim',(1,5,7),1400,5)]:
 data=bpy.data.lights.new(name,'AREA');data.energy=power;data.color=(1,.94,.82) if name=='Key' else ((.82,.89,1) if name=='Fill' else (1,.97,.9));data.shape='DISK';data.size=size;ob=bpy.data.objects.new(name,data);scene.collection.objects.link(ob);ob.location=pos;ob.rotation_euler=(Vector((0,-.6,1.1))-ob.location).to_track_quat('-Z','Y').to_euler()
camd=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camd);scene.collection.objects.link(cam);scene.camera=cam
cam.location=(10,-13,7.0);target=Vector((0,-.8,1.40));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=11.7
scene.render.resolution_x=1920;scene.render.resolution_y=1080;scene.render.resolution_percentage=100;scene.cycles.samples=32
scene.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
clay=bpy.data.materials.new('Clay preview');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.37,.37,.37,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
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
cam.location=(11,-.8,1.50);cam.rotation_euler=(Vector((0,-.8,1.50))-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=10.1
scene.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
cam.location=(10,-13,7);cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=11.7
scene.cycles.samples=16;scene.render.resolution_x=800;scene.render.resolution_y=450

def contact_sheet(filename,callback):
 sheet=np.ones((900,2400,4),dtype=np.float32)
 for k in range(6):
  callback(k);scene.render.filepath=str(ROOT/f'proof_{k}.png');bpy.ops.render.render(write_still=True)
  im=bpy.data.images.load(str(ROOT/f'proof_{k}.png'),check_existing=False);a=np.asarray(im.pixels[:],dtype=np.float32).reshape((450,800,4));row=1-k//3;col=k%3;sheet[row*450:(row+1)*450,col*800:(col+1)*800]=a;bpy.data.images.remove(im)
 out=bpy.data.images.new(filename,2400,900,alpha=True);out.pixels.foreach_set(sheet.ravel());out.filepath_raw=str(ROOT/filename);out.file_format='PNG';out.save()
 for k in range(6):(ROOT/f'proof_{k}.png').unlink(missing_ok=True)

def paint_view(k):
 for mix in paint_controls:mix.inputs[2].default_value=tuple(paints[k]['linear_rgb'])+(1,)
contact_sheet('paints.png',paint_view)
paint_view(0)
wreck_matrix=D['Wreck_Turret'].matrix_world.copy()
def state_view(k):
 D['Wreck_Turret'].matrix_world=wreck_matrix;state(k)
 if k==5:
  ob=D['Wreck_Turret'];ob.location=(3.0,-1.5,.75);ob.rotation_euler=(pi,0,0);bpy.context.view_layer.update()
  low=min((ob.matrix_world@v.co).z for v in ob.data.vertices);ob.location.z+=.015-low
contact_sheet('states.png',state_view)
state_view(0)
# Explicit pivot motion proof: each moving node is exercised by thirty degrees.
saved={n:o.matrix_basis.copy() for n,o in A.items()}
for n,o in A.items():
 if n=='Turret':o.rotation_euler.z+=pi/6
 elif n in ['Gun','MG','Hatch_Commander','Hatch_Loader']:o.rotation_euler.x-=pi/6
 elif n.startswith('Wheel_') or n.startswith('Hatch_'):o.rotation_euler.x+=pi/6
 elif n=='Barrel':o.location.y+=.20
scene.render.resolution_x=1280;scene.render.resolution_y=720;scene.render.filepath=str(ROOT/'rig_test.png');bpy.ops.render.render(write_still=True)
for n,m in saved.items():A[n].matrix_basis=m
bpy.context.view_layer.update()
manifest['rig_verification']={'roundtrip':'Both exported GLBs imported into an empty scene','pivot_test':'rig_test.png: turret/gun/MG/wheels/hatches 30 degrees; barrel recoil 0.20 m','states_order':['healthy','hit','stripped','immobilised','wreck','turret blown off'],'healthy_topology_triangles':healthy_tri,'damage_topology_triangles':damage_tri}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
notes=[
 'Tiger I library retrofit; original geometry, frame, pivots, seven baked material tiles and six paints retained; tank-damage/v1 files.',
 f'Triangles: healthy {healthy_tri:,}; damage {damage_tri:,}; GLBs {(ROOT/"tiger-heavy-tank.glb").stat().st_size/1048576:.2f}/{(ROOT/"tiger-heavy-tank-damage.glb").stat().st_size/1048576:.2f} MiB.',
 'Healthy materials: Paint, TrackSteel, Rubber, Steel, Wood, Glass, Lamps. Damage: Paint, Burnt, Scars, TrackSteel, Rubber.',
 'Shared baked base/normal 1024px; ORM 512px; damage tiles 512px except Paint. Neutral Paint × [0.47,0.42,0.28].',
 'Poly Haven CC0: green_metal_rust, rusty_metal_04, rubberized_track, dark_wood; https://polyhaven.com/license ; original tiles copied, damage tiles Cycles baked.',
 'Reference differences retained: roof MG34, simplified concealed running gear; no insignia. Damage uses copied/deformed geometry and tiled scorching; interiors are shallow crew wells.'
]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'tiger_work.blend'))
print('COMPLETE',json.dumps(manifest['bounding_box']),flush=True)

# Captions belong to presentation images only, never to the GLBs.
subprocess.run(['python3','-c',r"""
from PIL import Image,ImageDraw,ImageFont
from pathlib import Path
import sys
p=Path(sys.argv[1]);font=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',20)
for file,labels in [('states.png',['01  HEALTHY','02  HIT / SIX SWAPS','03  STRIPPED','04  IMMOBILISED / GUN DISABLED','05  BURNT WRECK','06  TURRET BLOWN OFF']),('paints.png',['DARK YELLOW','OLIVE DRAB','NATO GREEN','DESERT TAN','FIELD GREY','WINTER WHITE'])]:
 im=Image.open(p/file).convert('RGB');d=ImageDraw.Draw(im)
 for k,label in enumerate(labels):
  x=(k%3)*800;y=(k//3)*450;d.rectangle((x,y,x+800,y+34),fill=(34,39,41));d.text((x+15,y+5),label,font=font,fill=(236,237,232))
 im.save(p/file)
""",str(ROOT)],check=True)
