"""K2-style MBT, authored in metres. Blender 5.2, Cycles/OptiX.
Run: blender -b --python-exit-code 1 --python build_k2-tank.py
Optional -- --proof-only reloads exported GLBs for proof rendering.
CC0: Poly Haven rusty_metal_02, rust_coarse_01, rusty_metal_04, metal_plate, rubberized_track
"""
import bpy, bmesh, math, os, sys, json, struct, urllib.request, random
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
ROOT=Path(__file__).resolve().parent
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
random.seed(28)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=24
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
sc.cycles.device='GPU'; sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG'; sc.render.film_transparent=False
sc.world.color=(.24,.24,.24)
sc.view_settings.view_transform='AgX'
M={}; GROUPS={}; healthy=[]; damage=[]
PAINTS=[('forest green',[.10,.13,.08]),('olive drab',[.17,.17,.085]),('NATO green',[.075,.12,.075]),('desert tan',[.48,.36,.21]),('field grey',[.22,.25,.22]),('winter white',[.72,.75,.70])]

def download(asset,typ):
 p=CACHE/f'{asset}_{typ}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{typ}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
  with urllib.request.urlopen(req,timeout=90) as r:p.write_bytes(r.read())
 return str(p)

def material(name,asset,color,rough,metal,res=512):
 """Bake a shared physical tile, including photographic and procedural detail."""
 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'); out=n.get('Material Output')
 tex=n.new('ShaderNodeTexImage');tex.image=bpy.data.images.load(download(asset,'diff'),check_existing=True)
 uv=n.new('ShaderNodeTexCoord');l.new(uv.outputs['UV'],tex.inputs['Vector'])
 bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');l.new(bw.outputs[0],ramp.inputs[0])
 if name=='Paint':a,b=(.50,.50,.50,1),(.98,.98,.98,1)
 else:a,b=tuple(c*.42 for c in color)+(1,),tuple(color)+(1,)
 ramp.color_ramp.elements[0].position=.12;ramp.color_ramp.elements[0].color=a
 ramp.color_ramp.elements[1].position=.8;ramp.color_ramp.elements[1].color=b
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=48;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=.16
 l.new(ramp.outputs[0],mix.inputs[1]);l.new(noise.outputs['Fac'],mix.inputs[2])
 # Baked fine streaks, broad mottling and dust film, neutral for recolouring.
 if name in ['Paint','Burnt']:
  mapping=n.new('ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(5,24,1);l.new(uv.outputs['UV'],mapping.inputs[0])
  streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=2.8;streak.inputs['Detail'].default_value=3.0;l.new(mapping.outputs[0],streak.inputs['Vector'])
  sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.29;sr.color_ramp.elements[0].color=(.28,.28,.28,1);sr.color_ramp.elements[1].position=.64;sr.color_ramp.elements[1].color=(1,1,1,1);l.new(streak.outputs['Fac'],sr.inputs[0])
  mm=n.new('ShaderNodeMixRGB');mm.blend_type='MULTIPLY';mm.inputs[0].default_value=.24 if name=='Paint' else .85;l.new(mix.outputs[0],mm.inputs[1]);l.new(sr.outputs[0],mm.inputs[2]);mix=mm
 l.new(mix.outputs[0],bs.inputs['Base Color']);bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 normal=n.new('ShaderNodeTexImage');normal.image=bpy.data.images.load(download(asset,'nor_gl'),check_existing=True);normal.image.colorspace_settings.name='Non-Color'
 norm=n.new('ShaderNodeNormalMap');norm.inputs['Strength'].default_value=.055 if name=='Paint' else (0 if name in ['Glass','Lamps','Interior'] else .3);l.new(normal.outputs['Color'],norm.inputs['Color']);l.new(norm.outputs[0],bs.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=bpy.data.images.load(download(asset,'arm'),check_existing=True);arm.image.colorspace_settings.name='Non-Color'
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
 rr=n.new('ShaderNodeMath');rr.operation='MULTIPLY_ADD';rr.inputs[1].default_value=.24;rr.inputs[2].default_value=rough-.12;l.new(sep.outputs[1],rr.inputs[0]);l.new(rr.outputs[0],bs.inputs['Roughness'])
 bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.data.materials.append(mat)
 target=n.new('ShaderNodeTexImage');n.active=target
 images={}
 emit=n.new('ShaderNodeEmission')
 for kind,size in [('base',res),('normal',min(res,512)),('orm',min(512,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':l.new(mix.outputs[0],emit.inputs[0]);l.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT',margin=4)
  elif kind=='normal':l.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL',margin=4)
  else:
   comb=n.new('ShaderNodeCombineColor');l.new(sep.outputs[0],comb.inputs[0]);comb.inputs[2].default_value=metal;l.new(rr.outputs[0],comb.inputs[1]);l.new(comb.outputs[0],emit.inputs[0]);l.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT',margin=4)
  im.filepath_raw=str(CACHE/(name+'_'+kind+('.jpg' if kind=='base' else '.png')));im.file_format='JPEG' if kind=='base' else 'PNG';im.save();images[kind]=im
 bpy.data.objects.remove(plane,do_unlink=True)
 n.clear();bs=n.new('ShaderNodeBsdfPrincipled');out=n.new('ShaderNodeOutputMaterial');l.new(bs.outputs[0],out.inputs[0])
 for kind,im in images.items():
  t=n.new('ShaderNodeTexImage');t.image=im
  if kind=='base':
   if name=='Paint':
    mu=n.new('ShaderNodeMixRGB');mu.blend_type='MULTIPLY';mu.inputs[0].default_value=1;mu.inputs[2].default_value=tuple(PAINTS[0][1])+(1,);mu.name='PaintTint';l.new(t.outputs[0],mu.inputs[1]);l.new(mu.outputs[0],bs.inputs['Base Color'])
   else:l.new(t.outputs[0],bs.inputs['Base Color'])
  elif kind=='normal':nm=n.new('ShaderNodeNormalMap');l.new(t.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   se=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],se.inputs[0]);l.new(se.outputs[1],bs.inputs['Roughness']);l.new(se.outputs[2],bs.inputs['Metallic'])
   grp=bpy.data.node_groups.get('glTF Material Output')
   if grp is None:
    grp=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');grp.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   occ=n.new('ShaderNodeGroup');occ.node_tree=grp;l.new(se.outputs[0],occ.inputs['Occlusion'])
 M[name]=mat;print('BAKED',name,flush=True)
 return mat

class Geo:
 def __init__(self):self.v=[];self.f=[];self.mi=[];self.sm=[]
 def add(self,v,f,mat='Paint',smooth=False):
  off=len(self.v);self.v.extend(v);self.f.extend(tuple(off+i for i in face) for face in f);self.mi.extend([mat]*len(f));self.sm.extend([smooth]*len(f))
 def box(self,c,s,mat='Paint',rot=None):
  vs=[Vector((x*s[0]/2,y*s[1]/2,z*s[2]/2)) for x,y,z in [(-1,-1,-1),(1,-1,-1),(1,1,-1),(-1,1,-1),(-1,-1,1),(1,-1,1),(1,1,1),(-1,1,1)]]
  if rot:vs=[rot@v for v in vs]
  self.add([Vector(c)+v for v in vs],[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],mat)
 def cyl(self,c,r,depth,mat='Paint',axis=(0,0,1),n=24,r2=None):
  a=Vector(axis).normalized();u=a.cross(Vector((0,1,0)))
  if u.length<.1:u=a.cross(Vector((1,0,0)))
  u.normalize();v=a.cross(u);verts=[]
  for h,rad in [(-depth/2,r),(depth/2,r if r2 is None else r2)]:
   verts.extend(Vector(c)+a*h+rad*(u*math.cos(i*2*math.pi/n)+v*math.sin(i*2*math.pi/n)) for i in range(n))
  self.add(verts,[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],mat)
  self.sm[-n:]=[True]*n
 def line(self,points,r,mat='Metal',n=8):
  for a,b in zip(points,points[1:]):a,b=Vector(a),Vector(b);self.cyl((a+b)/2,r,(b-a).length,mat,b-a,n)
 def ring(self,c,ri,ro,depth,mat='Paint',axis=(0,0,1),n=48):
  a=Vector(axis).normalized();u=a.cross(Vector((0,1,0)))
  if u.length<.1:u=a.cross(Vector((1,0,0)))
  u.normalize();v=a.cross(u);vs=[]
  for h,r in [(-depth/2,ro),(depth/2,ro),(depth/2,ri),(-depth/2,ri)]:vs += [Vector(c)+a*h+r*(u*math.cos(i*2*math.pi/n)+v*math.sin(i*2*math.pi/n)) for i in range(n)]
  self.add(vs,[(j*n+i,j*n+(i+1)%n,((j+1)%4)*n+(i+1)%n,((j+1)%4)*n+i) for j in range(4) for i in range(n)],mat)
  self.sm[-4*n:-3*n]=[True]*n;self.sm[-2*n:-n]=[True]*n
 def loft(self,rings,mat='Paint'):
  n=len(rings[0]);self.add(sum(rings,[]),[tuple(reversed(range(n))),tuple(range((len(rings)-1)*n,len(rings)*n))]+[(j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i) for j in range(len(rings)-1) for i in range(n)],mat)
 def object(self,name,pivot=(0,0,0),parent=None,bev=.008,collection=healthy):
  mesh=bpy.data.meshes.new(name);mesh.from_pydata([Vector(v)-Vector(pivot) for v in self.v],[],self.f);mesh.update()
  bm=bmesh.new();bm.from_mesh(mesh);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(mesh);bm.free()
  o=bpy.data.objects.new(name,mesh);sc.collection.objects.link(o);o.location=pivot
  used=list(dict.fromkeys(self.mi))
  for m in used:mesh.materials.append(M[m])
  for poly,m,sm in zip(mesh.polygons,self.mi,self.sm):poly.material_index=used.index(m);poly.use_smooth=sm
  bpy.context.view_layer.objects.active=o;o.select_set(True)
  if bev:
   mod=o.modifiers.new('Machined edge chamfers','BEVEL');mod.width=bev;mod.segments=1;mod.affect='EDGES';mod.limit_method='ANGLE'
   bpy.ops.object.modifier_apply(modifier=mod.name)
  # Deterministic metre-scaled box projection, shared trim/tile textures.
  uv=mesh.uv_layers.new(name='UVMap') if not mesh.uv_layers else mesh.uv_layers[0]
  for p in mesh.polygons:
   ax=max(range(3),key=lambda i:abs(p.normal[i]));ids=[i for i in range(3) if i!=ax]
   for li in p.loop_indices:
    co=mesh.vertices[mesh.loops[li].vertex_index].co+Vector(pivot);uv.data[li].uv=(co[ids[0]]*.72,co[ids[1]]*.72)
   # Keep cylindrical walls smooth; armour faces retain their hard planes.
  # preserve geometry position while expressing a true local pivot
  if parent:o.parent=parent;o.location=Vector(pivot)-parent.matrix_world.translation
  o.select_set(False);collection.append(o);return o

def empty(name,pos,parent=None,col=healthy):
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=pos
 if parent:o.parent=parent;o.location=Vector(pos)-parent.matrix_world.translation
 col.append(o);return o

def bolts(g,points,axis=(0,0,1),mat='Metal',r=.018):
 for p in points:g.cyl(p,r,.012,mat,axis,n=6)

def octagon(xw,yfront,yrear,z,ch=.2):
 return [(-xw+ch,yfront,z),(xw-ch,yfront,z),(xw,yfront+ch,z),(xw,yrear-ch,z),(xw-ch,yrear,z),(-xw+ch,yrear,z),(-xw,yrear-ch,z),(-xw,yfront+ch,z)]

def build():
 for args in [('Paint','rusty_metal_02',(1,1,1),.8,.08,1024),('Rubber','rubberized_track',(.045,.05,.045),.88,0,512),('TrackSteel','rust_coarse_01',(.19,.17,.13),.66,.7,512),('Metal','rust_coarse_01',(.22,.24,.23),.5,.8,512),('Glass','metal_plate',(.014,.032,.05),.18,.55,256),('Lamps','metal_plate',(.82,.73,.51),.3,.1,256),('Interior','metal_plate',(.015,.018,.014),.98,0,256),('Burnt','rusty_metal_04',(.070,.073,.070),.94,.2,512),('Scars','rust_coarse_01',(.48,.50,.51),.48,.85,512)]:material(*args)
 # Low protected tub and broad glacis; hull is a complete closed solid.
 g=Geo();g.loft([octagon(1.23,-3.30,3.5,.39,.35),octagon(1.42,-3.75,3.75,1.12,.2),octagon(1.64,-2.86,3.68,1.48,.16)])
 # open crew well under hinged driver's hatch, dark cylinder interior
 g.ring((.62,-2.28,1.5),.25,.32,.06)
 # longitudinal sponson rails and suspension mounts, visible stripped
 for s in [-1,1]:
  g.box((s*1.43,0,1.18),(.15,6.85,.26))
  for y in [-2.4,-1.45,-.5,.45,1.4,2.35]:
   g.cyl((s*1.35,y,.61),.16,.22,'Metal',(1,0,0));g.line([(s*1.36,y,.68),(s*1.45,y+.22,.55)],.065,'Metal')
  bolts(g,[(s*1.53,y,1.33) for y in np.arange(-2.9,3.3,.4)])
 # rear exhaust bay inset / ribs
 g.box((0,3.735,1.0),(2.38,.022,.48),'Interior')
 for z in np.arange(.82,1.23,.045):g.box((0,3.73,z),(2.2,.035,.012),'Metal')
 for s in [-1,1]:
  g.ring((s*.94,-3.61,.84),.055,.095,.07,'Metal',(0,1,0),20)
  g.ring((s*.94,3.705,.66),.055,.095,.07,'Metal',(0,1,0),20)
  g.box((s*.94,-3.57,.84),(.19,.13,.16));g.box((s*.94,3.635,.66),(.19,.15,.15))
 hull=g.object('Hull',bev=.015)
 # Turret low wedge: stepped plan and strongly sloped cheeks.
 tp=(0,-.3,1.53);g=Geo()
 g.ring(tp,.76,1.0,.10,'Metal')
 g.loft([octagon(1.45,-1.99,2.25,1.59,.43),octagon(1.49,-1.77,2.23,1.80,.48),octagon(1.24,-1.42,2.18,2.12,.40)])
 # Crew wells are bored through the roof after assembly.
 for x,y in [(-.6,.32),(.58,.12)]:g.ring((x,y,2.145),.245,.325,.045)
 # Permanent weld beads and armour mounting bosses remain on a stripped turret.
 for side in [-1,1]:
  g.line([(side*1.494,-.94,1.80),(side*1.494,1.62,1.80)],.005,'Metal',6)
  for yy in [-.72,-.22,.38,.88,1.38]:
   g.cyl((side*1.45,yy,1.86),.037,.030,'Paint',(1,0,0),16)
   bolts(g,[(side*1.47,yy,1.86)],(side,0,0),r=.013)
 for yy in [-.48,.88,1.75]:g.line([(-1.10,yy,2.123),(1.10,yy,2.123)],.004,'Metal',6)
 turret=g.object('Turret',tp,bev=.012);bpy.context.view_layer.update()
 # modular cheek armour as independently detachable wedges, radar inset
 for s,label in [(1,'L'),(-1,'R')]:
  for j,(ya,yb) in enumerate([(-1.72,-.66),(-.64,.40),(.42,1.40)]):
   g=Geo();x0=1.26 if j==0 else 1.35;x1=1.51
   g.loft([[(s*x0,ya,1.65),(s*x1,ya+.1,1.65),(s*x1,yb,1.65),(s*x0,yb,1.65)],[(s*(x0-.10),ya+.22,2.06),(s*(x1-.10),ya+.24,2.00),(s*(x1-.10),yb,2.05),(s*(x0-.10),yb,2.10)]])
   bolts(g,[(s*(x1+.008),yy,zz) for yy in [ya+.28,yb-.12] for zz in [1.73,1.88]],(s,0,0),r=.019)
   g.object(f'Part_Turret_Armour_{label}{j+1}',(s*1.39,(ya+yb)/2,1.86),turret,.006)
  g=Geo();g.box((s*1.525,-.85,1.86),(.075,.60,.24));g.box((s*1.57,-.85,1.865),(.012,.52,.19),'Rubber');bolts(g,[(s*1.584,y,z) for y in [-1.08,-.62] for z in [1.80,1.93]],(s,0,0),r=.012)
  g.object(f'Part_Turret_Radar_{label}',(s*1.53,-.85,1.86),turret,.004)
 # Layered frontal cheek appliques follow the wedge, with recessed panel joints.
 for s,label in [(1,'L'),(-1,'R')]:
  g=Geo();q=[(s*.48,-1.896,1.69),(s*1.01,-1.896,1.69),(s*.81,-1.477,2.075),(s*.45,-1.477,2.075)]
  g.add(q+[(x,y-.028,z+.017) for x,y,z in q],[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)])
  for z in [1.75,1.97]:
   y=-1.896+(z-1.69)*(.419/.385)-.032
   bolts(g,[(s*.54,y,z+.019),(s*(.94-(z-1.75)*.48),y,z+.019)],(0,-.68,.73),r=.014)
  g.object('Part_Turret_Cheek_'+label,(s*.72,-1.69,1.89),turret,.005)
 # mantlet trunnion, barrel starts exactly at exit
 gp=(0,-1.65,1.86);g=Geo();g.loft([octagon(.46,-2.18,-1.5,1.59,.09),octagon(.35,-2.12,-1.48,2.12,.08)])
 g.cyl((0,-2.18,1.86),.23,.22,'Paint',(0,1,0),48)
 gun=g.object('Gun',gp,turret,.012);bpy.context.view_layer.update()
 bp=(0,-2.29,1.86);g=Geo()
 # concentric tube, thermal sleeve, fume extractor and restrained collars
 for ya,yb,r in [(-2.29,-2.82,.157),(-2.82,-3.20,.14),(-3.20,-3.68,.205),(-3.68,-4.02,.137),(-4.02,-6.78,.105),(-6.78,-7.05,.115)]:
  g.ring((0,(ya+yb)/2,1.86),.061,r,abs(yb-ya),'Paint' if yb>-6.9 else 'Metal',(0,1,0),48)
 for y in [-2.37,-2.74,-3.18,-3.68,-3.86,-4.03,-6.77]:g.ring((0,y,1.86),.11,.151 if y>-4 else .12,.045,'Paint',(0,1,0),48)
 for i in range(14):
  a=i*math.tau/14;g.line([(math.cos(a)*.16,-2.4,1.86+math.sin(a)*.16),(math.cos(a)*.16,-2.69,1.86+math.sin(a)*.16)],.006,'Paint',6)
 g.box((0,-6.90,2.012),(.105,.17,.085),'Paint');g.box((0,-6.987,2.012),(.060,.007,.042),'Glass')
 barrel=g.object('Barrel',bp,gun,.003);bpy.context.view_layer.update();empty('Muzzle',(0,-7.05,1.86),barrel)
 # rolling gear, six equal road wheels each side + raised end wheels + return rollers
 for s,label in [(1,'L'),(-1,'R')]:
  entries=[(-3.05,.82,.43,'idler')]+[(y,.52,.43,'road') for y in [-2.4,-1.45,-.50,.45,1.40,2.35]]+[(3.08,.85,.44,'sprocket')]+[(y,1.16,.18,'return') for y in [-1.9,-.1,1.75]]
  entries.sort(key=lambda q:q[0])
  for j,(y,z,r,kind) in enumerate(entries):
   c=(s*1.45,y,z);g=Geo()
   g.ring(c,r*.77,r,.39,'Rubber' if kind=='road' else 'TrackSteel',(1,0,0),48)
   g.cyl(c,r*.77,.32,'Paint',(1,0,0),48)
   for side in [-1,1]:
    x=s*1.45+side*.207
    g.ring((x,y,z),r*.76,r*.92,.040,'Paint',(1,0,0),48)
    g.cyl((x-side*.035,y,z),r*.77,.022,'Paint',(1,0,0),48)
    g.ring((x-side*.012,y,z),r*.62,r*.76,.035,'Paint',(1,0,0),48)
    g.ring((x+side*.03,y,z),r*.20,r*.26,.022,'Metal',(1,0,0),32)
    g.cyl((x+side*.037,y,z),r*.16,.038,'Paint',(1,0,0),24)
    bolts(g,[(x+side*.037,y+math.cos(a)*r*.42,z+math.sin(a)*r*.42) for a in np.arange(0,math.tau,math.tau/8)],(1,0,0),r=.017 if r>.3 else .009)
   if kind=='sprocket':
    g=Geo();g.cyl(c,.14,.46,'Metal',(1,0,0),32)
    for xx in [s*1.45-.19,s*1.45+.19]:
     g.ring((xx,y,z),.30,.405,.055,'TrackSteel',(1,0,0),48)
     g.cyl((xx,y,z),.17,.07,'Paint',(1,0,0),32)
     for aa in np.arange(0,math.tau,math.tau/8):
      g.line([(xx,y+.15*math.cos(aa),z+.15*math.sin(aa)),(xx,y+.34*math.cos(aa),z+.34*math.sin(aa))],.043,'Paint',8)
      bolts(g,[(xx+s*.04,y+.11*math.cos(aa),z+.11*math.sin(aa))],(1,0,0),r=.014)
    for a in np.arange(0,math.tau,math.tau/14):g.box((s*1.45,y+math.cos(a)*r,z+math.sin(a)*r),(.40,.09,.09),'TrackSteel',Matrix.Rotation(a,3,'X'))
   wh=g.object(f'Wheel_{label}{j+1}',c,bev=.003)
   wh['wheel_role']=kind;wh['side']=label
   for p in wh.data.polygons:
    if len(p.vertices)==4:p.use_smooth=True
  make_track(s,label)
 # Each sawtooth plate has real thickness, fittings, mounting bosses underneath.
 for s,label in [(1,'L'),(-1,'R')]:
  for i in range(7):
   ya=-3.25+i*.91;yb=ya+.895;g=Geo()
   yz=[(ya,1.39),(yb,1.39),(yb,.91)]
   for k in range(4):yz.extend([(yb-k*.223-.10,.83),(yb-(k+1)*.223,.91)])
   vs=[(s*x,y,z) for x in [1.69,1.75] for y,z in yz];n=len(yz)
   g.add(vs,[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(j,(j+1)%n,(j+1)%n+n,j+n) for j in range(n)])
   bolts(g,[(s*1.757,y,1.33) for y in [ya+.10,yb-.10]],(s,0,0),r=.019)
   g.box((s*1.725,(ya+yb)/2,1.41),(.11,.15,.05),'Metal')
   g.object(f'Part_Skirt_{label}{i+1}',(s*1.72,(ya+yb)/2,1.12),bev=.004)
  # four bent front/rear fenders, frontal lamp assemblies
  for front,y in [('F',-3.36),('R',3.34)]:
   g=Geo();g.box((s*1.45,y,1.29),(.70,.73,.10),'Paint',Matrix.Rotation(.22 if front=='F' else -.20,3,'X'))
   g.box((s*1.47,y+(-.32 if front=='F' else .32),1.10),(.64,.055,.25),'Rubber')
   g.object(f'Part_Fender_{front}{label}',(s*1.45,y,1.25),bev=.008)
  g=Geo();g.box((s*1.12,-3.59,1.20),(.36,.13,.17),'Rubber')
  for x in [s*1.12-.09,s*1.12+.09]:g.cyl((x,-3.67,1.21),.059,.024,'Lamps',(0,1,0),24)
  g.line([(s*.91,-3.69,1.12),(s*.91,-3.69,1.33),(s*1.34,-3.69,1.33),(s*1.34,-3.69,1.12)],.015,'Metal')
  g.object(f'Part_LampGuard_{label}',(s*1.12,-3.62,1.23),bev=.003)
  g=Geo();g.box((s*1.30,3.70,1.13),(.32,.07,.22));g.cyl((s*1.3,3.740,1.14),.07,.018,'Lamps',(0,1,0),24);g.object(f'Part_TailLamp_{label}',(s*1.3,3.70,1.13),bev=.004)
 # engine deck complete below separately detachable grilles
 for x in [-.67,.67]:
  g=Geo();g.box((x,2.93,1.492),(1.18,1.10,.035),'Rubber')
  g.ring((x,2.96,1.525),.42,.46,.035,'Metal',n=64)
  for yy in np.arange(2.46,3.45,.055):g.box((x,yy,1.535),(1.08,.018,.025),'Paint')
  for xx in np.arange(x-.5,x+.51,.12):g.box((xx,2.94,1.52),(.018,1.02,.026),'Metal')
  g.object('Part_EngineGrille_'+('L' if x>0 else 'R'),(x,2.94,1.52),bev=.003)
 g=Geo();g.box((0,3.67,1.36),(1.95,.15,.20));g.object('Part_ExhaustShroud',(0,3.67,1.36),bev=.009)
 # bustle bins and tubular cage wrap rear turret
 g=Geo();g.box((0,2.17,1.88),(2.67,.46,.43));
 for x in [-.91,0,.91]:
  g.box((x,2.409,1.9),(.86,.018,.32));bolts(g,[(x+dx,2.429,z) for dx in [-.35,.35] for z in [1.78,2.02]],(0,1,0))
 g.object('Part_Turret_BustleBins',(0,2.17,1.88),turret,.012)
 g=Geo()
 for z in [1.66,1.82,1.99,2.09]:g.line([(-1.48,1.51,z),(-1.48,2.52,z),(1.48,2.52,z),(1.48,1.51,z)],.021,'Paint')
 for x in [-1.48,-.95,0,.95,1.48]:g.line([(x,2.52,1.64),(x,2.52,2.12)],.023,'Paint')
 g.object('Part_Turret_BustleRack',(0,2.39,1.88),turret,.003)
 # hatches: opaque recessed wells, hinged lids with bars and latches
 for name,c,r,parent in [('Commander',(-.6,.32,2.17),.28,turret),('Loader',(.58,.12,2.17),.265,turret),('Driver',(.62,-2.28,1.54),.275,None)]:
  g=Geo();g.cyl(c,r,.047,'Paint',n=12);g.ring((c[0],c[1],c[2]+.029),r-.045,r-.025,.012,'Metal',n=12)
  g.line([(c[0]-.09,c[1],c[2]+.03),(c[0]-.09,c[1],c[2]+.085),(c[0]+.09,c[1],c[2]+.085),(c[0]+.09,c[1],c[2]+.03)],.014,'Paint')
  g.cyl((c[0],c[1]+r,c[2]),.039,.26,'Metal',(1,0,0),16)
  g.object('Hatch_'+name,(c[0],c[1]+r,c[2]),parent,.004)
 # Roof architecture: autoloader access covers, seam trims and cupola periscopes.
 for i,(x,y,w,h) in enumerate([(-.70,1.28,.83,.83),(.64,1.27,.84,.85),(0,-.61,.54,.66)]):
  g=Geo();g.box((x,y,2.14),(w,h,.045),'Paint')
  bolts(g,[(x+dx,y+dy,2.168) for dx in [-w/2+.055,w/2-.055] for dy in [-h/2+.055,0,h/2-.055]],r=.014)
  g.line([(x-.095,y+.12,2.172),(x-.095,y+.12,2.22),(x+.095,y+.12,2.22),(x+.095,y+.12,2.172)],.011,'Metal')
  g.object('Part_Turret_RoofPanel'+str(i+1),(x,y,2.16),turret,.006)
 g=Geo();g.ring((-.60,.32,2.145),.29,.38,.055,'Paint',n=32)
 for a in np.arange(0,math.tau,math.tau/7):
  x=-.60+.344*math.cos(a);y=.32+.344*math.sin(a)
  g.box((x,y,2.17),(.11,.067,.055),'Rubber',Matrix.Rotation(a,3,'Z'));g.box((x,y,2.2),(.079,.050,.009),'Glass',Matrix.Rotation(a,3,'Z'))
 g.object('Part_Turret_CupolaRing',(-.60,.32,2.16),turret,.003)
 # welded hull access strip and small deck recesses, retained when equipment is stripped
 g=Geo()
 for x in [-.79,-.15]:
  g.box((x,-2.14,1.49),(.47,.82,.018),'Paint')
  bolts(g,[(x+dx,yy,1.505) for dx in [-.19,.19] for yy in [-2.49,-1.79]],r=.012)
 for s in [-1,1]:
  g.line([(s*1.35,-2.66,1.495),(s*1.35,3.48,1.495)],.008,'Metal')
  for yy in [-2.6,-1.4,0,1.4,2.6]:
   g.box((s*1.56,yy,1.47),(.10,.18,.035));bolts(g,[(s*1.56,yy,1.492)],r=.013)
 detailing=g.object('_HullFixedDetail',bev=.003,collection=[]);join_into(hull,[detailing])
 # optical panoramic towers with armoured hood, independent glass panes
 for label,x,y in [('Commander',-.70,-.23),('Gunner',.66,-1.08)]:
  g=Geo();g.cyl((x,y,2.13),.18,.12,'Paint',n=32);g.box((x,y,2.26),(.28,.25,.22));g.box((x,y-.132,2.26),(.19,.012,.17),'Glass');g.box((x,y,2.385),(.32,.29,.025))
  g.object('Part_Turret_Sight_'+label,(x,y,2.24),turret,.007)
 # smoke dischargers, near corners
 for s,label in [(1,'L'),(-1,'R')]:
  g=Geo();g.box((s*1.16,-1.38,2.055),(.28,.29,.075))
  for yy in [-1.45,-1.34]:
   for xx in [s*1.1,s*1.23]:g.ring((xx,yy,2.115),.026,.044,.13,'Metal',(0,-.5,.87),16)
  g.object('Part_Turret_Smoke_'+label,(s*1.16,-1.39,2.10),turret,.003)
  g=Geo();g.cyl((s*1.04,1.52,2.16),.055,.10);g.cyl((s*1.04,1.52,2.29),.012,.22,'Metal',n=12);g.object('Part_Turret_Antenna_'+label,(s*1.04,1.52,2.27),turret,.002)
 # roof machine gun, pivot and receiver / barrel / ammo box
 g=Geo();g.cyl((-.52,.61,2.22),.075,.19,'Metal');g.box((-.52,.43,2.33),(.12,.42,.13),'Metal');g.cyl((-.52,-.06,2.34),.024,.60,'Metal',(0,1,0),24)
 g.ring((-.52,-.38,2.34),.013,.028,.07,'Metal',(0,1,0),20);g.box((-.72,.47,2.29),(.24,.26,.20));g.box((-.52,.71,2.31),(.10,.20,.07),'Rubber')
 g.box((-.52,.43,2.389),(.105,.28,.014),'Metal')
 g.box((-.52,.70,2.30),(.22,.035,.045),'Metal')
 for xx in [-.61,-.43]:g.cyl((xx,.72,2.30),.019,.13,'Rubber',n=16)
 g.ring((-.52,.12,2.34),.025,.038,.18,'Metal',(0,1,0),24)
 for yy in [.06,.11,.16]:
  for xx in [-.559,-.481]:g.cyl((xx,yy,2.34),.012,.004,'Rubber',(1,0,0),12)
 g.line([(-.52,-.28,2.35),(-.52,-.28,2.397)],.008,'Metal',8)
 g.object('MG',(-.52,.61,2.24),turret,.003)
 # tied down tow cables and deck tools
 for s,label in [(1,'L'),(-1,'R')]:
  g=Geo();pts=[(s*1.49,yy,1.5+.025*math.sin(yy*2)) for yy in np.linspace(-1.75,2.8,32)];g.line(pts,.022,'Metal',10)
  for yy in [-1.75,2.8]:g.ring((s*1.49,yy,1.52),.044,.07,.025,'Metal',n=20)
  for yy in [-1.5,.1,1.8]:g.box((s*1.49,yy,1.52),(.11,.09,.04))
  g.object('Part_TowCable_'+label,(s*1.49,.5,1.52),bev=.002)
  g=Geo();g.box((s*1.16,2.03,1.52),(.30,.64,.12));g.line([(s*1.16,1.77,1.6),(s*1.16,2.3,1.6)],.028,'Metal');g.object('Part_ToolBox_'+label,(s*1.16,2.03,1.56),bev=.008)
 for name,p,parent in [('Seat_Commander',(-.6,.32,2.24),turret),('Seat_Driver',(.62,-2.28,1.6),None),('Exhaust',(0,3.79,1.26),None),('Light_Head_L',(1.12,-3.69,1.21),None),('Light_Head_R',(-1.12,-3.69,1.21),None),('Fx_Engine',(0,2.95,1.53),None),('Fx_Ammo',(0,.4,.9),None),('Fx_Hatch',(-.6,.32,2.23),turret)]:empty(name,p,parent)

 for base,x,y,top,r in [(hull,.62,-2.28,1.5,.25),(turret,-.6,.32,2.14,.245),(turret,.58,.12,2.14,.235)]:
  bpy.ops.mesh.primitive_cylinder_add(vertices=48,radius=r,depth=.40,location=(x,y,top-.12));cut=bpy.context.object
  bpy.context.view_layer.objects.active=base;mod=base.modifiers.new('Crew hatch opening','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
  well=Geo();well.ring((x,y,top-.17),r-.009,r+.006,.31,'Interior',n=48);well.cyl((x,y,top-.33),r,.022,'Interior',n=48)
  lining=well.object('_crew_well',collection=[],bev=0);join_into(base,[lining])
 bpy.context.view_layer.update()
 return hull,turret,gun,barrel

# capsule track centreline, bottom touches exactly ground after thickness
TRACK_R=.59; TRACK_Y=2.95; TRACK_Z=.66

def track_path(t):
 # perimeter in y/z; straight bottom -> front arc -> upper -> rear arc
 L=4*TRACK_Y+2*math.pi*TRACK_R;t=t%L
 if t<2*TRACK_Y:return (-TRACK_Y+t,.07,0)
 t-=2*TRACK_Y
 if t<math.pi*TRACK_R:
  a=-math.pi/2+t/TRACK_R;return(TRACK_Y+TRACK_R*math.cos(a),TRACK_Z+TRACK_R*math.sin(a),a+math.pi/2)
 t-=math.pi*TRACK_R
 if t<2*TRACK_Y:return(TRACK_Y-t,1.25,math.pi)
 t-=2*TRACK_Y;a=math.pi/2+t/TRACK_R;return(-TRACK_Y+TRACK_R*math.cos(a),TRACK_Z+TRACK_R*math.sin(a),a+math.pi/2)

def make_track(s,label,broken=False,parent=None):
 g=Geo();L=4*TRACK_Y+math.tau*TRACK_R;N=94
 for i in range(N):
  y,z,a=track_path(i*L/N)
  if broken:
   if y< -2.7 and z>.2:continue
   if z>1.0:z=.13+max(0,(y+.8))*.10;a=0
   if i<14:y=-4.3+i*.145;z=.07;a=0
  rot=Matrix.Rotation(a,3,'X');c=Vector((s*1.45,y,z+.003))
  g.box(c,(.60,L/N*.92,.105),'TrackSteel',rot)
  for dx in [-.155,.155]:g.box(c+rot@Vector((dx,0,-.061)),(.24,L/N*.69,.024),'Rubber' if not broken else 'TrackSteel',rot)
  g.box(c+rot@Vector((0,0,.075)),(.08,.065,.10),'TrackSteel',rot)
  g.cyl(c+rot@Vector((0,L/N*.45,0)),.025,.66,'TrackSteel',(1,0,0),10)
 name=f'Track_{label}'+('_Broken' if broken else '')
 o=g.object(name,(s*1.45,0,0) if not broken else (0,0,0),parent,bev=.002,collection=damage if broken else healthy)
 minz=min(v.co.z+o.location.z for v in o.data.vertices)
 for v in o.data.vertices:v.co.z-=minz
 if not broken:
  # V is arc length once around loop; U runs across belt.
  uv=o.data.uv_layers.active
  for p in o.data.polygons:
   for li in p.loop_indices:
    v=o.data.vertices[o.data.loops[li].vertex_index].co+o.location;y,z=v.y,v.z
    if y>TRACK_Y:dist=2*TRACK_Y+(math.atan2(z-TRACK_Z,y-TRACK_Y)+math.pi/2)*TRACK_R
    elif y< -TRACK_Y:dist=4*TRACK_Y+math.pi*TRACK_R+(math.atan2(z-TRACK_Z,y+TRACK_Y)-math.pi/2)%math.tau*TRACK_R
    elif z<TRACK_Z:dist=y+TRACK_Y
    else:dist=2*TRACK_Y+math.pi*TRACK_R+TRACK_Y-y
    uv.data[li].uv=((v.x-s*1.45)/.66+.5,dist/L)
 return o

def duplicate(o,name,parent=None,col=damage):
 n=o.copy();n.data=o.data.copy();n.name=name;sc.collection.objects.link(n)
 world=o.matrix_world.copy();n.parent=parent;n.matrix_world=world;col.append(n);return n

def make_damage(hull,turret,gun,barrel):
 hd=empty('Hull_Damage',(0,0,0),col=damage);td=empty('Turret_Damage',tuple(turret.location),col=damage);bpy.context.view_layer.update()
 candidates=[o for o in healthy if o.name.startswith('Part_Skirt_')][:10]+[o for o in healthy if o.name.startswith(('Part_Fender_','Part_Turret_Armour_','Part_Turret_Bustle'))]
 twins={}
 for o in candidates:
  n=duplicate(o,o.name.replace('Part_','Dmg_',1),td if o.parent==turret else hd)
  # damage in local space, leaving the mount edge attached
  for v in n.data.vertices:
   z=v.co.z;f=max(0,min(1,(-z+.05)*3))
   v.co.x+=f*(.24*math.sin(v.co.y*5)+.22)*(1 if o.location.x>=0 else -1)
   v.co.z-=f*.18*(.6+.4*math.sin(v.co.y*9))
  # Visible torn slot through each plate using an irregular cutting volume.
  if 'Skirt' in o.name:
   bpy.ops.mesh.primitive_uv_sphere_add(segments=12,ring_count=8,radius=1,location=n.matrix_world.translation+Vector((0,.10,-.08)))
   cut=bpy.context.object;cut.scale=(.34,.105,.13);bpy.context.view_layer.objects.active=n
   mod=n.modifiers.new('Shell perforation','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut
   bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
  burnindex=len(n.data.materials);n.data.materials.append(M['Burnt'])
  for poly in n.data.polygons:
   if (poly.center.y-.1)**2+(poly.center.z+.08)**2<.22**2:poly.material_index=burnindex
  twins[o.name]=n.name
 # Eight armour-face scars, 4 impacts each, millimetres proud of the underlying face.
 for is_turret,parent in [(False,hd),(True,td)]:
  for face in ['Front','L','R','Rear']:
   g=Geo()
   if is_turret:
    positions={'Front':[(x,-1.873,1.74) for x in [-.88,-.59,.63,.92]],'L':[(1.479,y,1.74) for y in [-.7,-.05,.65,1.25]],'R':[(-1.479,y,1.74) for y in [-.7,-.05,.65,1.25]],'Rear':[(x,2.235,1.78) for x in [-.8,-.32,.30,.8]]}
   else:
    positions={'Front':[(x,-3.47805,1.23) for x in [-.85,-.35,.27,.80]],'L':[(1.457,y,1.18) for y in [-2.1,-.75,.8,2.25]],'R':[(-1.457,y,1.18) for y in [-2.1,-.75,.8,2.25]],'Rear':[(x,3.755,1.01) for x in [-.8,-.3,.3,.8]]}
   normal=Vector({'Front':(0,-.691,.723) if is_turret else (0,-.375,.927),'Rear':(0,1,0),'L':(1,0,0),'R':(-1,0,0)}[face]);u=normal.cross(Vector((0,0,1))).normalized();v=normal.cross(u).normalized()
   for k,p in enumerate(positions[face]):
    c=Vector(p)+normal*.001;r=.065 if k<3 else .10
    # irregular soot apron, raised bright petalled steel rim, black cavity
    vv=[c+normal*.001]+[c+u*math.cos(a)*r*(2.0+.25*math.sin(a*5+k))+v*math.sin(a)*r*1.55 for a in np.arange(0,math.tau,math.tau/18)]
    g.add(vv,[(0,i+1,(i+1)%18+1) for i in range(18)],'Burnt')
    g.cyl(c+normal*.002,r*.63,.002,'Burnt',normal,24)
    # rim alternates torn raised petal tips, its base follows armour
    rings=[]
    for rad,d in [(r*1.15,.0002),(r*.77,.002),(r*.62,.001)]:
     rings.append([c+(u*math.cos(a)+v*math.sin(a))*rad+normal*(d*(1+.35*math.cos(a*7))) for a in np.arange(0,math.tau,math.tau/28)])
    g.add(sum(rings,[]),[(j*28+i,j*28+(i+1)%28,(j+1)*28+(i+1)%28,(j+1)*28+i) for j in range(2) for i in range(28)],'Scars')
    if k<3:
     pts=[c+u*.07,c+u*.17+v*.035,c+u*.24+v*.045]
     for a,b in zip(pts,pts[1:]):
      w=normal.cross(b-a).normalized()*.008
      g.add([a-w+normal*.002,a+normal*.0004,a+w+normal*.002,b-w+normal*.002,b+normal*.0004,b+w+normal*.002],[(0,1,4,3),(1,2,5,4)],'Scars')
   g.object('Scar_'+('Turret' if is_turret else 'Hull')+'_'+face,tuple(parent.location),parent,0,damage)
 make_track(1,'L',True,hd);make_track(-1,'R',True,hd)
 # gun damage assembled from original mantlet and a bent, open-ended barrel
 bg=duplicate(gun,'Gun_Broken',td);bpy.context.view_layer.update()
 bb=duplicate(barrel,'_burstbarrel',bg)
 for v in bb.data.vertices:
  yy=v.co.y
  if yy< -1.8:v.co.z-=(-yy-1.8)*.21
  if yy< -2.5:v.co.x+=.10*math.sin((-yy-2.5)*1.3)
 # join barrel into mantlet preserving Gun origin
 join_into(bg,[bb]);damage.remove(bb) if bb in damage else None
 # wreck copies from healthy, preserving all relative mesh alignments
 wh_pieces=[];wt_pieces=[]
 skip_parts={f'Part_Skirt_{s}{i}' for s in ['L','R'] for i in [1,3,5,7]}
 skip_parts.update(['Part_Fender_FL','Part_Fender_RR','Part_Turret_BustleBins','Part_Turret_Armour_L1'])
 for o in list(healthy):
  if o.type!='MESH' or o.name in skip_parts:continue
  is_t=(o==turret or o.parent==turret or o==gun or o==barrel)
  source=next(d for d in damage if d.name=='Track_L_Broken') if o.name=='Track_L' else o
  n=duplicate(source,'_wreck_'+o.name,None,col=[])
  n.data.materials.clear();n.data.materials.append(M['Burnt'])
  for p in n.data.polygons:p.material_index=0
  if o.name.startswith('Wheel_'):
   n.location.z-=.05
   if o.get('wheel_role')=='road':
    for v in n.data.vertices:
     rr=math.hypot(v.co.y,v.co.z)
     if rr>.407:v.co.y*=.395/rr;v.co.z*=.395/rr
  if o.name.startswith('Hatch_'):n.rotation_euler.x=math.radians(-72)
  if o in [gun,barrel]:
   pv=gun.matrix_world.translation;rot=Matrix.Translation(pv)@Matrix.Rotation(math.radians(14),4,'X')@Matrix.Translation(-pv);n.matrix_world=rot@n.matrix_world
  if o.name.startswith('Track_'):
   for v in n.data.vertices:
    if v.co.z>1:v.co.z-=.22*max(0,1-(v.co.y/3)**2)
  if 'EngineGrille' in o.name:
   for v in n.data.vertices:v.co.z+=.28*max(0,1-abs(v.co.y)*1.5)
  if o==hull:
   bpy.ops.mesh.primitive_cylinder_add(vertices=64,radius=.98,depth=1.3,location=(0,-.3,1.4));cut=bpy.context.object
   bpy.context.view_layer.objects.active=n;mod=n.modifiers.new('Open burnt turret well','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
   bpy.ops.mesh.primitive_cube_add(size=1,location=(0,2.92,1.4));cut=bpy.context.object;cut.scale=(1.55,.82,.7)
   bpy.context.view_layer.objects.active=n;mod=n.modifiers.new('Ruptured engine bay','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
  (wt_pieces if is_t else wh_pieces).append(n)
 # well floor and scorched seating, ring and underside basket stub
 g=Geo();g.box((0,2.92,.93),(1.56,.85,.35),'Burnt');g.cyl((0,-.3,.56),.95,.08,'Burnt',n=48);g.ring((0,-.3,1.43),.94,1.04,.12,'Burnt',n=64)
 for x in [-.4,.4]:g.box((x,-.3,.8),(.32,.45,.36),'Burnt')
 wh_pieces.append(g.object('_well',bev=.004,collection=[]))
 g=Geo();g.ring((0,-.3,1.48),.74,.95,.18,'Burnt',n=64);g.cyl((0,-.3,1.15),.70,.12,'Burnt',n=48)
 for a in np.arange(0,math.tau,math.tau/8):g.line([(.7*math.cos(a),-.3+.7*math.sin(a),1.16),(.83*math.cos(a),-.3+.83*math.sin(a),1.5)],.036,'Burnt')
 wt_pieces.append(g.object('_basket',bev=.003,collection=[]))
 wh=wh_pieces[0];join_into(wh,wh_pieces[1:]);wh.name='Wreck_Hull';origin(wh,(0,0,0));damage.append(wh)
 wt=wt_pieces[0];join_into(wt,wt_pieces[1:]);wt.name='Wreck_Turret';origin(wt,tuple(turret.location));damage.append(wt)
 for o in [wh,wt]:
  bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Wreck mesh budget','DECIMATE');mod.ratio=.40;bpy.ops.object.modifier_apply(modifier=mod.name)
 return twins

def origin(o,p):
 world=o.matrix_world.copy();dest=Matrix.Translation(p);o.data.transform(dest.inverted()@world);o.matrix_world=dest

def join_into(base,others):
 bpy.ops.object.select_all(action='DESELECT');base.select_set(True)
 for o in others:o.select_set(True)
 bpy.context.view_layer.objects.active=base;bpy.ops.object.join();base.select_set(False)

def budget(objects,target):
 total=sum(len(p.vertices)-2 for o in objects if o.type=='MESH' for p in o.data.polygons)
 if total>target:
  ratio=target/total*.985
  for o in objects:
   if o.type!='MESH':continue
   for slot in o.material_slots:
    if slot.material is None:slot.material=M['Paint']
   bpy.context.view_layer.objects.active=o
   mod=o.modifiers.new('Game geometry budget','DECIMATE');mod.ratio=ratio
   bpy.ops.object.modifier_apply(modifier=mod.name)
 for o in objects:
  if o.type!='MESH':continue
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
  bad=[f for f in bm.faces if f.calc_area()<1e-10]
  if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
  bm.to_mesh(o.data);bm.free();o.data.update()
 print('BUDGET',total,'->',metrics(objects)['triangles'],flush=True)

def export(objects,filename):
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:
  if o.name in bpy.data.objects:o.hide_set(False);o.hide_viewport=False;o.hide_render=False;o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(ROOT/filename),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_extras=True)
 # Explicit glTF paint factor, independent of exporter mix-node recognition.
 p=ROOT/filename;data=p.read_bytes();jlen=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jlen]);blob=data[20+jlen:]
 for m in doc.get('materials',[]):
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
 js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
 p.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(js)+len(blob))+struct.pack('<I4s',len(js),b'JSON')+js+blob)
 print('EXPORTED',filename,p.stat().st_size,flush=True)

def metrics(objects):
 bpy.context.view_layer.update();pts=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
 cv=lambda p:[round(p.x,5),round(p.z,5),round(-p.y,5)]
 return {'nodes':[{'name':o.name,'parent':o.parent.name if o.parent else None,'position':cv(o.matrix_world.translation)} for o in objects], 'bounds':{'min':[min(cv(p)[i] for p in pts) for i in range(3)],'max':[max(cv(p)[i] for p in pts) for i in range(3)]},'triangles':sum(len(p.vertices)-2 for o in objects if o.type=='MESH' for p in o.data.polygons)}

def studio():
 # new render stage; never exported
 mat=bpy.data.materials.new('Studio');mat.diffuse_color=(.28,.30,.32,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.28,.30,.32,1);mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.86
 bpy.ops.mesh.primitive_plane_add(size=200);floor=bpy.context.object;floor.name='_StudioFloor';floor.location.z=-.015;floor.data.materials.append(mat)
 sc.world.use_nodes=True;sc.world.node_tree.nodes.get('Background').inputs[0].default_value=(.55,.63,.74,1);sc.world.node_tree.nodes.get('Background').inputs[1].default_value=.55
 for name,loc,energy,size in [('Key',(2,-6,10),1900,7),('Fill',(-7,-1,5),1100,6),('Rim',(2,6,8),2100,5)]:
  data=bpy.data.lights.new(name,'AREA');data.energy=energy;data.shape='DISK';data.size=size;o=bpy.data.objects.new(name,data);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
 data=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',data);sc.collection.objects.link(cam);sc.camera=cam;data.type='ORTHO';data.ortho_scale=12.8
 camera((10,-14,6.0),(0,-1.05,1.05))
 return floor

def camera(loc,target,scale=None):
 cam=sc.camera;cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler()
 if scale:cam.data.ortho_scale=scale

def render(name,w=1920,h=1080,samples=32):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.render.resolution_percentage=100;sc.cycles.samples=samples;sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);print('RENDER',name,flush=True)

def composite(files,name):
 arrays=[]
 for p in files:
  im=bpy.data.images.load(str(ROOT/p),check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(im.size[1],im.size[0],4);arrays.append(a);bpy.data.images.remove(im)
 h,w,_=arrays[0].shape;out=np.concatenate([np.concatenate(arrays[3:],axis=1),np.concatenate(arrays[:3],axis=1)],axis=0)
 im=bpy.data.images.new(name,width=w*3,height=h*2);im.pixels.foreach_set(out.ravel());im.filepath_raw=str(ROOT/name);im.file_format='PNG';im.save()


def proof():
 # Required proof is strictly a fresh-scene import of both delivered binary files.
 # Let Blender's importer construct its version-specific full settings group.
 prior=bpy.data.node_groups.get('glTF Material Output')
 if prior:prior.name='_AuthoringOcclusion'
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
 bpy.ops.import_scene.gltf(filepath=str(ROOT/'k2-tank.glb'));a=list(sc.objects)
 bpy.ops.import_scene.gltf(filepath=str(ROOT/'k2-tank-damage.glb'));b=[o for o in sc.objects if o not in a]
 studio();names={o.name:o for o in a+b}
 for o in a+b:
  transform=o.matrix_world.copy();o.rotation_mode='XYZ';o.matrix_world=transform
 def visibility(oo,show):
  for o in oo:o.hide_render=not show
 def subtree(o):return [o]+list(o.children_recursive)
 def state_reset():visibility(a,True);visibility(b,False)
 files=[]
 for i in range(6):
  state_reset()
  if i==1:
   for o in b:
    if o.name.startswith('Scar_'):o.hide_render=False
   for key in ['Skirt_L1','Skirt_L2','Skirt_L3','Skirt_R1','Fender_FL','Turret_Armour_L1']:
    if 'Part_'+key in names:names['Part_'+key].hide_render=True
    if 'Dmg_'+key in names:names['Dmg_'+key].hide_render=False
  if i==2:
   for o in a:
    if o.name.startswith('Part_'):o.hide_render=True
  if i==3:
   names['Track_L'].hide_render=True;names['Track_L_Broken'].hide_render=False
   visibility(subtree(names['Gun']),False);names['Gun_Broken'].hide_render=False
  if i>=4:
   visibility(a,False);names['Wreck_Hull'].hide_render=False;names['Wreck_Turret'].hide_render=False
  if i==5:
   names['Wreck_Turret'].location=(3.6,-.9,.78);names['Wreck_Turret'].rotation_euler=(.08,math.radians(170),.25)
   bpy.context.view_layer.update()
   thrown=names['Wreck_Turret'];thrown.location.z-=min((thrown.matrix_world@v.co).z for v in thrown.data.vertices)
   thrown.location.x+=1.98-min((thrown.matrix_world@v.co).x for v in thrown.data.vertices)
  render(f'_state{i}.png',800,450,16);files.append(f'_state{i}.png')
 composite(files,'states.png')
 # Restore frame for proof of six recolour factors.
 state_reset();camera((10,-14,6),(0,-1.05,1.05),12.8)
 paints=[m for m in bpy.data.materials if m.name=='Paint' or m.name.startswith('Paint.')]
 files=[]
 for i,(name,c) in enumerate(PAINTS):
  for mat in paints:
   bs=next((n for n in mat.node_tree.nodes if n.type=='BSDF_PRINCIPLED'),None)
   if not bs:continue
   # Imported glTF may express texture*factor through a multiply node.
   socket=bs.inputs['Base Color']
   if socket.is_linked:
    node=socket.links[0].from_node
    if node.type=='MIX':
     for s in node.inputs:
      if s.type=='RGBA' and not s.is_linked:s.default_value=tuple(c)+(1,)
    elif node.type=='MIX_RGB':
     for s in node.inputs[1:]:
      if not s.is_linked:s.default_value=tuple(c)+(1,)
    else:
     nt=mat.node_tree;old=socket.links[0].from_socket;mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=tuple(c)+(1,);nt.links.new(old,mix.inputs[1]);nt.links.new(mix.outputs[0],socket)
   else:socket.default_value=tuple(c)+(1,)
  render(f'_paint{i}.png',800,450,16);files.append(f'_paint{i}.png')
 composite(files,'paints.png')
 # source rotation checks from exported nodes; all attached meshes follow their pivots
 state_reset()
 for mat in paints:
  for n in mat.node_tree.nodes:
   if n.type=='MIX':
    for s in n.inputs:
     if s.type=='RGBA' and not s.is_linked:s.default_value=tuple(PAINTS[0][1])+(1,)
   elif n.type=='MIX_RGB':
    for s in n.inputs[1:]:
     if not s.is_linked:s.default_value=tuple(PAINTS[0][1])+(1,)
 for o in a:
  if o.name=='Turret':o.rotation_euler.z+=math.radians(30)
  elif o.name=='Barrel':o.location.y+=.25;o.rotation_euler.y+=math.radians(30)
  elif o.name in ['Gun','MG'] or o.name.startswith('Hatch_'):o.rotation_euler.x-=math.radians(30)
  elif o.name.startswith('Wheel_'):o.rotation_euler.x+=math.radians(30)
 render('pivots.png',1280,720,16)
 print('PROOF_COMPLETE',flush=True)

if '--proof-only' in sys.argv:
 proof()
else:
 hull,turret,gun,barrel=build()
 budget(healthy,126000)
 export(healthy,'k2-tank.glb');ha=metrics(healthy)
 # Healthy image and clay establish the geometry before damage is constructed.
 studio();render('final.png',1920,1080,40)
 camera((12,-1.63,1.20),(0,-1.63,1.20),12.1);render('side.png',1920,1080,32)
 camera((10,-14,6),(0,-1.05,1.05),12.8)
 clay=bpy.data.materials.new('_Clay');clay.diffuse_color=(.35,.35,.35,1);clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.35,.35,.35,1)
 sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);sc.view_layers[0].material_override=None
 twins=make_damage(hull,turret,gun,barrel)
 
 for o in damage:
  if o.type=='MESH':
   for slot in o.material_slots:
    if slot.material is None:slot.material=M['Burnt']
    if slot.material.name in ['Metal','Glass','Lamps']:slot.material=M['Scars']
    elif slot.material.name=='Interior':slot.material=M['Burnt']
 budget(damage,105000)
 export(damage,'k2-tank-damage.glb');da=metrics(damage)
 manifest={'schema':'tank-damage/v1','units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'healthy':ha,'damaged':da,'paints':[{'name':n,'linearRGB':c} for n,c in PAINTS], 'damage':{'parts':[{'part':o.name,'twin':twins.get(o.name)} for o in healthy if o.name.startswith('Part_')],'scars':[o.name for o in damage if o.name.startswith('Scar_')],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']}}
 (ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
 (ROOT/'NOTES.md').write_text('K2 Black Panther style MBT, metre scale, articulated and destructible; no insignia.\n'+f'Triangles: healthy {ha["triangles"]:,}; damage {da["triangles"]:,}.\n'+'Materials: Paint, Rubber, TrackSteel, Metal, Glass, Lamps, Interior; damage also Burnt and Scars.\n'+'Textures: Cycles-baked shared tiles; Paint 1024px, other maps 256–512px, ORM at most 512px.\n'+'Poly Haven CC0 ids: rusty_metal_02, rust_coarse_01, rusty_metal_04, metal_plate, rubberized_track; https://polyhaven.com/license .\n'+'Reference differences: simplified suspension internals and sensor internals; artistic battle damage; simplified fire-control optics and concealed running gear.\n')
 proof()
