"""Merkava Mk 4 style game asset. Blender 5.2, clean deterministic build.
Run: blender -b --python-exit-code 1 --python build_merkava-tank.py
Author space: X left, -Y forward, Z up. glTF converts to X left,Y up,Z forward.
Photographic source textures: Poly Haven CC0. No insignia or text on asset.
"""
import bpy, math, random, os, json, struct, urllib.request, subprocess, sys
import numpy as np
from mathutils import Vector, Matrix
from pathlib import Path
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(41)
PI=math.pi
bpy.ops.wm.read_factory_settings(use_empty=True)
scene=bpy.context.scene
scene.render.engine='CYCLES';scene.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'
scene.cycles.device='GPU';scene.cycles.use_denoising=True
scene.render.image_settings.file_format='PNG'
scene.view_settings.view_transform='AgX'
scene.world=bpy.data.worlds.new('Daylight');scene.world.use_nodes=True
scene.world.node_tree.nodes['Background'].inputs[0].default_value=(.60,.68,.8,1)
scene.world.node_tree.nodes['Background'].inputs[1].default_value=.45
MATS={}; HEALTH=[]; DAMAGE=[]; PARTS=[]; PIVOTS={}
PAINTS=[('Sand', [.30,.29,.24]),('Olive drab',[.16,.18,.105]),('NATO green',[.075,.12,.075]),('Desert tan',[.46,.35,.21]),('Field grey',[.19,.22,.20]),('Winter white',[.76,.78,.73])]

def download(id,map):
 p=TEX/f'{id}_{map}_1k.jpg'
 if not p.exists():
  u=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
  subprocess.run(['curl','-fL','--retry','2','-s','-A','Mozilla/5.0',u,'-o',str(p)],check=True)
 return p

def bake_material(name,source,color,rough,metal,res=512):
 """Bake a shared, tileable photographic + procedural surface in Cycles."""
 m=bpy.data.materials.new(name);m.use_nodes=True; nt=m.node_tree;n=nt.nodes;l=nt.links;n.clear()
 out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 uv=n.new('ShaderNodeTexCoord')
 imgs={}
 for mp in ['diff','nor_gl','arm']:
  t=n.new('ShaderNodeTexImage');t.image=bpy.data.images.load(str(download(source,mp)),check_existing=True)
  if mp!='diff':t.image.colorspace_settings.name='Non-Color'
  l.new(uv.outputs['UV'],t.inputs[0]);imgs[mp]=t
 bw=n.new('ShaderNodeRGBToBW');l.new(imgs['diff'].outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');l.new(bw.outputs[0],ramp.inputs[0]);ramp.color_ramp.elements[0].position=.05;ramp.color_ramp.elements[1].position=.8
 # Neutral paint bake allows runtime colour replacement.
 if name=='Paint':
  lo=(.57,.57,.57,1);hi=(.95,.95,.95,1);ramp.color_ramp.elements[0].position=.012;ramp.color_ramp.elements[1].position=.35
 elif name=='Burnt':lo=(.004,.004,.003,1);hi=(.11,.065,.027,1);ramp.color_ramp.elements[0].position=.025;ramp.color_ramp.elements[1].position=.65
 else:lo=tuple(c*.48 for c in color)+(1,);hi=tuple(min(c*1.35,1) for c in color)+(1,)
 ramp.color_ramp.elements[0].color=lo;ramp.color_ramp.elements[1].color=hi
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=75;noise.inputs['Detail'].default_value=3
 l.new(uv.outputs['UV'],noise.inputs[0]);mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.23;l.new(ramp.outputs[0],mix.inputs[1]);l.new(noise.outputs['Fac'],mix.inputs[2])
 norm=n.new('ShaderNodeNormalMap');norm.inputs['Strength'].default_value=.08 if name=='Paint' else .20;l.new(imgs['nor_gl'].outputs[0],norm.inputs['Color'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.13;bump.inputs['Distance'].default_value=.0008;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(norm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
 l.new(mix.outputs[0],bs.inputs['Base Color']);bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=2);tile=bpy.context.object;tile.name='_BakeTile';tile.data.materials.append(m)
 baked={}
 for kind in ['base','normal','orm']:
  size=res if kind!='orm' else 512
  im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
  if kind!='base':im.colorspace_settings.name='Non-Color'
  target=n.new('ShaderNodeTexImage');target.image=im;n.active=target
  if kind=='normal':
   l.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
  else:
   em=n.new('ShaderNodeEmission')
   if kind=='base':l.new(mix.outputs[0],em.inputs[0])
   else:
    sep=n.new('ShaderNodeSeparateColor');l.new(imgs['arm'].outputs[0],sep.inputs[0])
    comb=n.new('ShaderNodeCombineColor');l.new(sep.outputs[0],comb.inputs[0]);comb.inputs[1].default_value=rough;comb.inputs[2].default_value=metal
    rr=n.new('ShaderNodeMath');rr.operation='MULTIPLY_ADD';rr.inputs[1].default_value=.18;rr.inputs[2].default_value=rough-.1;l.new(sep.outputs[1],rr.inputs[0]);l.new(rr.outputs[0],comb.inputs[1]);l.new(comb.outputs[0],em.inputs[0])
   l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
  ext='jpg' if kind=='base' else 'png';im.filepath_raw=str(TEX/f'{name}_{kind}.{ext}');im.file_format='JPEG' if kind=='base' else 'PNG';im.save();baked[kind]=im
 bpy.data.objects.remove(tile,do_unlink=True)
 n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 for kind,im in baked.items():
  t=n.new('ShaderNodeTexImage');t.image=im;t.label=kind;t.name=kind
  if kind=='base':
   if name=='Paint':
    mult=n.new('ShaderNodeMixRGB');mult.name='PaintFactor';mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;mult.inputs[2].default_value=(*PAINTS[0][1],1);l.new(t.outputs[0],mult.inputs[1]);l.new(mult.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:
   sp=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],sp.inputs[0]);l.new(sp.outputs[1],bs.inputs['Roughness']);l.new(sp.outputs[2],bs.inputs['Metallic'])
 # glTF exporter recognizes this shared ORM occlusion socket.
   group=bpy.data.node_groups.get('glTF Material Output')
   if group is None:
    group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   occ=n.new('ShaderNodeGroup');occ.node_tree=group;l.new(sp.outputs[0],occ.inputs['Occlusion'])
 m.diffuse_color=(*color,1);MATS[name]=m
 print('BAKED',name,flush=True)
 return m

# Mesh accumulator: rounded profiles rather than object-level transforms.
class Geo:
 def __init__(self):self.v=[];self.f=[];self.mi=[];self.smooth=[];self.uv=[]
 def add(self,v,f,mat='Paint',smooth=False,uv=None):
  off=len(self.v);self.v.extend(v)
  for j,face in enumerate(f):
   self.f.append(tuple(i+off for i in face));self.mi.append(mat);self.smooth.append(smooth)
   if uv is not None:self.uv.append(uv[j])
   else:
    pts=[Vector(v[i]) for i in face];normal=(pts[1]-pts[0]).cross(pts[2]-pts[0]);axis=max(range(3),key=lambda a:abs(normal[a]));axes=[i for i in range(3) if i!=axis]
    self.uv.append([(p[axes[0]]*.7,p[axes[1]]*.7) for p in pts])
 def box(self,c,d,mat='Paint',bev=.008,rot=None):
  # bevelled cuboid, 24 vertices, 6 faces +12 edge strips+8 corners
  h=[a/2 for a in d];b=min(bev,min(h)*.4)
  v=[];ids={}
  for axis in range(3):
   for a in [-1,1]:
    for q in [-1,1]:
     for r in [-1,1]:
      signs=[0,0,0];signs[axis]=a;oth=[i for i in range(3) if i!=axis];signs[oth[0]]=q;signs[oth[1]]=r
      p=[signs[i]*(h[i] if i==axis else h[i]-b) for i in range(3)]
      ids[(axis,tuple(signs))]=len(v);v.append(p)
  f=[]
  for axis in range(3):
   oth=[i for i in range(3) if i!=axis]
   for s in [-1,1]:
    face=[]
    for a,b1 in [(-1,-1),(1,-1),(1,1),(-1,1)]:
     sig=[0,0,0];sig[axis]=s;sig[oth[0]]=a;sig[oth[1]]=b1;face.append(ids[(axis,tuple(sig))])
    f.append(face)
  for ax in range(3):
   for ay in range(ax+1,3):
    az=3-ax-ay
    for sx in [-1,1]:
     for sy in [-1,1]:
      sig=[0,0,0];sig[ax]=sx;sig[ay]=sy;sig[az]=-1;t1=tuple(sig);sig[az]=1;t2=tuple(sig)
      f.append([ids[(ax,t1)],ids[(ax,t2)],ids[(ay,t2)],ids[(ay,t1)]])
  for sx in [-1,1]:
   for sy in [-1,1]:
    for sz in [-1,1]:f.append([ids[(a,(sx,sy,sz))] for a in range(3)])
  # enforce outward winding
  for face in f:
   p,q,r=[Vector(v[i]) for i in face[:3]]
   if (q-p).cross(r-p).dot(sum((Vector(v[i]) for i in face),Vector())/len(face))<0:face.reverse()
  R=Matrix.Identity(3) if rot is None else rot
  vv=[tuple(R@Vector(p)+Vector(c)) for p in v];self.add(vv,f,mat)
 def lathe(self,c,profile,axis='X',mat='Paint',n=48):
  # profile is [(axial offset,radius)]; matching end radii 0 yields caps
  v=[]
  for a,r in profile:
   for i in range(n):
    t=2*PI*i/n;p=(a,r*math.cos(t),r*math.sin(t))
    if axis=='Y':p=(p[1],p[0],p[2])
    elif axis=='Z':p=(p[1],p[2],p[0])
    v.append(tuple(Vector(c)+Vector(p)))
  f=[]
  for j in range(len(profile)-1):
   for i in range(n):
    a=j*n+i;b=j*n+(i+1)%n;d=(j+1)*n+i;e=(j+1)*n+(i+1)%n
    if profile[j][1]==0:f.append([a,e,d])
    elif profile[j+1][1]==0:f.append([a,b,d])
    else:f.append([a,b,e,d])
  # fix axis Y parity
  if axis=='Y':f=[list(reversed(a)) for a in f]
  start=len(self.f);self.add(v,f,mat,True)
  # Flat cap normals preserve machined hubs and bolt heads; curved walls remain smooth.
  for j in range(len(profile)-1):
   if profile[j][0]==profile[j+1][0] and (profile[j][1]==0 or profile[j+1][1]==0):
    for i in range(n):self.smooth[start+j*n+i]=False
 def cyl(self,c,r,depth,axis='Z',mat='Paint',n=24):
  b=min(.008,depth*.1,r*.08);self.lathe(c,[(-depth/2,0),(-depth/2,r-b),(-depth/2+b,r),(depth/2-b,r),(depth/2,r-b),(depth/2,0)],axis,mat,n)
 def tube(self,points,r,mat='Metal',n=8):
  pts=[Vector(p) for p in points];v=[]
  for j,p in enumerate(pts):
   d=(pts[min(j+1,len(pts)-1)]-pts[max(0,j-1)]).normalized();u=d.cross(Vector((0,0,1)))
   if u.length<.01:u=d.cross(Vector((0,1,0)))
   u.normalize();w=d.cross(u)
   for i in range(n):v.append(tuple(p+r*(u*math.cos(i*2*PI/n)+w*math.sin(i*2*PI/n))))
  f=[]
  for j in range(len(pts)-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])
  f.extend([list(reversed(range(n))),list(range((len(pts)-1)*n,len(pts)*n))]);self.add(v,f,mat,True)
 def ring(self,c,r,t,axis='Z',mat='Metal',n=36,k=6):
  v=[];f=[]
  for i in range(n):
   a=i*2*PI/n
   for j in range(k):
    b=j*2*PI/k;p=((r+t*math.cos(b))*math.cos(a),(r+t*math.cos(b))*math.sin(a),t*math.sin(b))
    if axis=='X':p=(p[2],p[0],p[1])
    if axis=='Y':p=(p[0],p[2],p[1])
    v.append(tuple(Vector(c)+Vector(p)))
  for i in range(n):
   for j in range(k):f.append([i*k+j,((i+1)%n)*k+j,((i+1)%n)*k+(j+1)%k,i*k+(j+1)%k])
  self.add(v,f,mat,True)
 def prism(self,poly,offset,mat='Paint'):
  N=len(poly);v=poly+[tuple(Vector(p)+Vector(offset)) for p in poly]
  f=[list(reversed(range(N))),list(range(N,N*2))]+[[i,(i+1)%N,(i+1)%N+N,i+N] for i in range(N)];self.add(v,f,mat)
 def loft(self,rings,mat='Paint',caps=True):
  n=len(rings[0]);f=[]
  for j in range(len(rings)-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 caps:f.extend([list(reversed(range(n))),list(range((len(rings)-1)*n,len(rings)*n))])
  self.add(sum(rings,[]),f,mat)
 def obj(self,name,pivot=(0,0,0),parent=None,damage=False):
  mesh=bpy.data.meshes.new(name);mesh.from_pydata([tuple(Vector(v)-Vector(pivot)) for v in self.v],[],self.f);mesh.update()
  names=list(dict.fromkeys(self.mi))
  for mn in names:mesh.materials.append(MATS[mn])
  uv=mesh.uv_layers.new(name='UVMap')
  for p,mat,sm,coords in zip(mesh.polygons,self.mi,self.smooth,self.uv):
   p.material_index=names.index(mat);p.use_smooth=sm
   for li,co in zip(p.loop_indices,coords):uv.data[li].uv=co
  o=bpy.data.objects.new(name,mesh);scene.collection.objects.link(o);o.location=pivot;o['author_pivot']=list(pivot)
  if parent:o.parent=parent;o.location=Vector(pivot)-Vector(parent['author_pivot'])
  (DAMAGE if damage else HEALTH).append(o)
  if name.startswith('Part_'):PARTS.append(o)
  return o

def empty(name,loc,parent=None,damage=False):
 o=bpy.data.objects.new(name,None);scene.collection.objects.link(o);o.location=loc;o['author_pivot']=list(loc)
 if parent:o.parent=parent;o.location=Vector(loc)-Vector(parent['author_pivot'])
 (DAMAGE if damage else HEALTH).append(o);return o

def bolts(g,positions,axis='Z',r=.019,mat='Metal'):
 for p in positions:g.cyl(p,r,.015,axis,mat,6)
def chipped_edge(g,a,b,normal,count=12):
 a=Vector(a);b=Vector(b);n=Vector(normal).normalized();direction=(b-a).normalized();across=n.cross(direction).normalized()
 for j in range(count):
  t=(j+random.uniform(.1,.9))/count;p=a.lerp(b,t)+n*.0016;ln=random.uniform(.011,.055);w=random.uniform(.002,.007)
  g.add([tuple(p-direction*ln),tuple(p+direction*ln*.7),tuple(p+direction*ln*.5+across*w),tuple(p-direction*ln*.7+across*w*.6)],[[0,1,2,3]],'Metal')

def handle(g,c,width=.18,axis='Z'):
 x,y,z=c
 if axis=='Z':pts=[(x-width/2,y,z),(x-width/2,y,z+.035),(x+width/2,y,z+.035),(x+width/2,y,z)]
 else:pts=[(x,y-width/2,z),(x+.035,y-width/2,z),(x+.035,y+width/2,z),(x,y+width/2,z)]
 g.tube(pts,.011,'Paint',8)

def build_healthy():
 # Continuous hull armor shell, detailed even with all bolt-on armor stripped.
 h=Geo()
 sections=[(-3.8,1.48,.64,1.12),(-3.18,1.65,.48,1.36),(-1.55,1.65,.44,1.77),(2.95,1.65,.46,1.77),(3.8,1.5,.61,1.59)]
 rings=[]
 for y,w,lo,hi in sections:rings.append([(-w+.2,y,lo),(w-.2,y,lo),(w,y,lo+.2),(w,y,hi-.07),(w-.11,y,hi),(-w+.11,y,hi),(-w,y,hi-.07),(-w,y,lo+.2)])
 # The lower bow sweeps rearward under the glacis rather than ending as a box.
 for i in [0,1]:
  x,y,z=rings[0][i];rings[0][i]=(x,-3.29,z)
 for i in [2,7]:
  x,y,z=rings[0][i];rings[0][i]=(x,-3.58,z)
 h.loft(rings)
 for s in [-1,1]:
  h.box((s*.93,-3.79,1.025),(.16,.09,.17),'Paint',.016)
  h.ring((s*.93,-3.85,1.018),.07,.022,'X','Metal',24,8)
 # turret ring well, dark cavity with a full lower floor
 h.lathe((0,.58,1.78),[(-.46,1.03),(.015,1.03),(.045,1.1),(.06,1.14),(.015,1.17),(-.46,1.17)],'Z','Metal',64)
 h.cyl((0,.58,1.30),1.02,.035,'Z','Interior',64)
 for s in [-1,1]:
  # backing panels, weld rails and suspension arms
  for y in np.linspace(-2.9,2.9,7):
   h.box((s*1.655,y,1.40),(.045,.75,.35),'Paint',.006)
   bolts(h,[(s*1.69,y-.28,1.51),(s*1.69,y+.28,1.51)],'X')
  h.tube([(s*1.65,-3.1,1.63),(s*1.67,3.4,1.63)],.013,'Metal')
  for y in [-2.48,-1.48,-.48,.52,1.52,2.52]:
   h.tube([(s*1.23,y+.23,.95),(s*1.39,y,.59)],.075,'Metal',12)
  # tow lugs on front and back
  for y,z in [(-3.68,1.04),(3.77,.87)]:h.ring((s*.93,y,z),.078,.027,'Y','Metal',24,8)
 # Rolled weld crowns break broad armor joins and catch grazing daylight.
 for y,z in [(-3.17,1.365),(-1.55,1.775)]:
  h.tube([(-1.45,y,z),(1.45,y,z)],.007,'Paint',6)
 # panel seams on front sloping deck
 for x in [-1.05,-.48,.18,.82]:
  h.tube([(x,-3.61,1.196),(x,-1.66,1.75)],.005,'Metal',6)
 for y in [-3.18,-2.6,-1.9]:
  z=1.36+(-y-3.18)*(-.251)
  for x in [-1.2,-.4,.4,1.2]:bolts(h,[(x,y,z+.009)],r=.016)
 chipped_edge(h,(-1.34,-3.8,1.124),(1.34,-3.8,1.124),(0,-.5,1),38)
 hull=h.obj('Hull')
 turret=Geo()
 # Mk4 low broad wedge, widest below shoulder; forward nose is exceptionally flat.
 rings=[ [(-.47,-1.72,1.88),(.47,-1.72,1.88),(1.40,-.84,1.84),(1.67,.35,1.84),(1.49,2.5,1.86),(-1.49,2.5,1.86),(-1.67,.35,1.84),(-1.40,-.84,1.84)],
 [(-.48,-1.92,2.04),(.48,-1.92,2.04),(1.58,-.85,2.035),(1.78,.38,2.09),(1.53,2.52,2.10),(-1.53,2.52,2.10),(-1.78,.38,2.09),(-1.58,-.85,2.035)],
 [(-.42,-.77,2.37),(.42,-.77,2.37),(1.07,-.38,2.39),(1.20,.66,2.42),(1.29,2.19,2.31),(-1.29,2.19,2.31),(-1.2,.66,2.42),(-1.07,-.38,2.39)] ]
 turret.loft(rings)
 for i in range(8):chipped_edge(turret,rings[1][i],rings[1][(i+1)%8],(0,0,1),14)
 turret.cyl((0,.58,1.83),1.07,.15,'Z','Metal',64)
 tur=turret.obj('Turret',(0,.58,1.80));PIVOTS['Turret']={'axis':'Y','point':[0,1.8,-.58]}
 # Sloped modular front cheek segments, conformal armor plates with fine gaps.
 for s,label in [(1,'L'),(-1,'R')]:
  for j in range(3):
   g=Geo();t0=j/3;t1=(j+1)/3
   # forward cheek from near gun to broad outer shoulder
   inner0=(s*(.49+.62*t0),-1.90+.82*t0,2.055)
   inner1=(s*(.49+.62*t1),-1.90+.82*t1,2.055)
   outer0=(s*(.44+.73*t0),-.77+.62*t0,2.392)
   outer1=(s*(.44+.73*t1),-.77+.62*t1,2.392)
   poly=[inner0,inner1,outer1,outer0]
   g.prism(poly,(0,0,.036))
   for a in [inner0,inner1,outer0,outer1]:bolts(g,[(a[0],a[1]+.025,a[2]+.046)],r=.011)
   g.obj(f'Part_Turret_Cheek_{label}{j+1}',tuple(sum((Vector(p) for p in poly),Vector())/4),tur)
  # wide lateral wedge module, defining angular shoulders
  g=Geo();poly=[(s*1.14,-1.05,2.055),(s*1.72,.33,2.11),(s*1.55,1.15,2.14),(s*1.19,.7,2.46),(s*1.04,-.3,2.43)]
  g.prism(poly,(s*.025,0,.012));g.obj(f'Part_Turret_SideArmour_{label}',(s*1.4,.2,2.25),tur)
  g=Geo();g.box((s*1.38,1.78,2.24),(.32,1.24,.32),bev=.025)
  for y in [1.25,2.25]:g.box((s*1.55,y,2.25),(.025,.045,.27),'Metal',.006)
  handle(g,(s*1.56,1.8,2.27),.22,'X');g.obj(f'Part_Turret_Stowage_{label}',(s*1.38,1.78,2.24),tur)
 # Gun mantlet, a sleeve fitted between cheek modules
 g=Geo();g.box((0,-1.30,2.095),(.60,.54,.43),bev=.065);g.lathe((0,-1.53,2.12),[(-.14,.20),(-.12,.22),(.10,.22),(.14,.18)],'Y','Paint',48)
 gun=g.obj('Gun',(0,-1.26,2.12),tur);PIVOTS['Gun']={'axis':'X','point':[0,2.12,1.26]}
 g=Geo()
 # barrel incl thermal jacket, bore is modelled and dark
 profile=[(-5.05,.104),(-5.04,.12),(-3.84,.12),(-3.80,.135),(-3.55,.135),(-3.52,.185),(-2.95,.185),(-2.91,.135),(-1.68,.135),(-1.64,.16)]
 g.lathe((0,0,2.12),profile,'Y','Paint',48)
 g.lathe((0,0,2.12),[(-5.24,.068),(-5.24,.104),(-5.05,.104),(-5.05,.069),(-4.96,.069)],'Y','Metal',48)
 g.cyl((0,-4.97,2.12),.068,.015,'Y','Interior',32)
 for y in [-4.98,-4.65,-4.24,-3.87,-3.60,-3.50,-2.96,-2.88,-2.1,-1.72]:g.ring((0,y,2.12),.125 if y< -3.6 else (.185 if -3.6<y<-2.94 else .138),.009,'Y','Paint',40,6)
 barrel=g.obj('Barrel',(0,-1.65,2.12),gun);empty('Muzzle',(0,-5.24,2.12),barrel)
 PIVOTS['Barrel']={'axis':'Z','point':[0,2.12,1.65]}
 # commander / loader hatch coamings with deep dark crew wells
 for name,x,y,r in [('Commander',-.57,.72,.32),('Loader',.58,1.0,.30),('Driver',.77,-2.03,.275)]:
  z=2.438 if name!='Driver' else 1.67;parent=tur if name!='Driver' else hull
  g=Geo();g.lathe((x,y,z),[(-.30,r-.045),(-.03,r-.045),(.015,r),(-.035,r+.055),(-.31,r+.045)],'Z','Interior',40)
  g.cyl((x,y,z-.29),r,.025,'Z','Interior',32);g.ring((x,y,z),r+.01,.025,'Z','Paint',40,8)
  if name!='Driver':g.obj('Part_Turret_Cupola_'+name,(x,y,z),tur)
  else:g.obj('Driver_Well',(x,y,z),hull)
  g=Geo();g.cyl((x,y,z+.034),r,.065,'Z','Paint',40);handle(g,(x,y-.08,z+.073))
  g.cyl((x,y+r-.018,z+.025),.034,.27,'X','Metal',16)
  o=g.obj('Hatch_'+name,(x,y+r-.018,z+.025),parent);PIVOTS[o.name]={'axis':'X','point':[x,z+.025,-(y+r-.018)]}
  empty('Seat_'+name,(x,y,z+.12),parent)
 # driver & turret periscopes, each has a protected glass aperture
 for name,x,y,z,pa in [('Driver',.77,-2.32,1.665,hull),('Commander',-.62,.24,2.445,tur),('Gunner',.62,-.37,2.44,tur)]:
  g=Geo();g.box((x,y,z+.087),(.37,.24,.18),bev=.016);g.box((x,y-.124,z+.085),(.28,.012,.095),'Glass',.007)
  g.box((x,y-.135,z+.15),(.36,.025,.023),'Paint',.004)
  g.obj(('Part_Turret_Sight_' if pa==tur else 'Part_Visor_')+name,(x,y,z+.08),pa)
 # roof MG 7.62 mm, articulated at pedestal, max height 2.66
 g=Geo();g.cyl((-.57,.47,2.49),.052,.12,'Z','Metal',20);g.box((-.57,.35,2.59),(.14,.37,.12),'Metal',.015)
 g.cyl((-.57,-.11,2.60),.024,.64,'Y','Metal',20);g.cyl((-.57,-.44,2.60),.031,.055,'Y','Metal',20)
 g.box((-.71,.34,2.56),(.16,.20,.16),'Metal',.01);g.box((-.57,.57,2.58),(.11,.15,.075),'Rubber',.012)
 g.tube([(-.57,.54,2.58),(-.57,.59,2.50)],.019,'Metal');mg=g.obj('MG',(-.57,.47,2.46),tur);PIVOTS['MG']={'axis':'Y','point':[-.57,2.46,-.47]}
 # Elevated angular sight shields and recessed access panels break the roof silhouette.
 for s,side in [(1,'L'),(-1,'R')]:
  g=Geo();x=s*1.13
  g.prism([(x,.77,2.41),(x,1.28,2.39),(x,1.28,2.65),(x,.94,2.65)],(s*.16,0,0))
  g.box((x+s*.085,.948,2.545),(.11,.016,.15),'Glass',.006)
  g.obj('Part_Turret_OpticShield_'+side,(x,1.05,2.5),tur)
  g=Geo()
  for j in range(3):
   y=.55+j*.49
   # modular roof joint, inset black groove bordered by machined plate
   g.tube([(s*.87,y,2.437),(s*.89,y+.29,2.43),(s*1.09,y+.30,2.428)],.0045,'Metal',6)
   bolts(g,[(s*.94,y+.03,2.445),(s*1.07,y+.27,2.438)],r=.012)
  for j in range(2):
   x=s*(.64+j*.35);y=-1.23+j*.16;z=2.26+j*.05
   g.tube([(x-.11,y,z),(x-.11,y+.24,z+.066),(x+.11,y+.24,z+.066),(x+.11,y,z),(x-.11,y,z)],.009,'Metal',6)
  g.obj('Turret_SurfaceDetail_'+side,(s*.8,0,2.3),tur)
 # Front deck service panel, lifting loops, recessed seams and locking hardware.
 g=Geo()
 for x,y in [(.17,-2.63),(.17,-2.02),(-.9,-2.97)]:
  z=1.36+(y+3.18)*.251+.018
  g.box((x,y,z),(.30,.29,.017),'Paint',.009,Matrix.Rotation(.247,3,'X'))
  handle(g,(x,y,z+.025),.18)
  bolts(g,[(x-.11,y-.1,z-.008),(x+.11,y+.1,z+.04)],r=.013)
 for x in [-1.25,1.25]:
  for y in [-2.7,-2.15]:
   z=1.36+(y+3.18)*.251+.02
   g.ring((x,y,z+.028),.041,.014,'X','Paint',20,6)
 details=g.obj('_HullDetails');join_objects([hull,details],hull)
 # track loops and individually pivoted roadwheels, front sprocket and rear idler
 for s,side in [(1,'L'),(-1,'R')]:
  for i,y in enumerate([-2.48,-1.48,-.48,.52,1.52,2.52]):
   g=Geo();x=s*1.48;z=.59
   g.lathe((x,y,z),[(-.23,0),(-.23,.38),(-.20,.45),(-.15,.475),(.15,.475),(.20,.45),(.23,.38),(.23,0)],'X','Rubber',48)
   for dx in [-.235,.235]:
    g.lathe((x+dx,y,z),[(-.009,0),(-.009,.31),(0,.395),(.024,.4),(.045,.355),(.020,.33),(.006,.28),(.008,.15),(.064,.125),(.086,.105),(.086,0)],'X','Paint',48)
    g.ring((x+dx+s*.046,y,z),.322,.012,'X','Metal',40,6)
   outer=x+s*.31;g.cyl((outer,y,z),.116,.04,'X','Metal',32)
   bolts(g,[(outer+s*.025,y+.19*math.cos(a*PI/4),z+.19*math.sin(a*PI/4)) for a in range(8)],'X',.019)
   o=g.obj(f'Wheel_{side}{i+2}',(x,y,z));o['role']='RoadWheel';PIVOTS[o.name]={'axis':'X','point':[x,z,-y]}
  for idx,y,kind in [(1,-3.12,'Sprocket'),(8,3.12,'Idler')]:
   g=Geo();x=s*1.48;z=.69
   g.lathe((x,y,z),[(-.23,.12),(-.23,.40),(-.18,.445),(-.12,.445),(-.11,.18),(.11,.18),(.12,.445),(.18,.445),(.23,.40),(.23,.12)],'X','TrackSteel',48)
   for a in range(12):
    t=a*2*PI/12;g.tube([(x+s*.23,y+.13*math.cos(t),z+.13*math.sin(t)),(x+s*.23,y+.38*math.cos(t),z+.38*math.sin(t))],.035,'Paint',8)
   if kind=='Sprocket':
    for a in range(18):
     t=a*2*PI/18;g.box((x,y+.46*math.cos(t),z+.46*math.sin(t)),(.42,.075,.075),'TrackSteel',.009,Matrix.Rotation(t,3,'X'))
   g.cyl((x,y,z),.13,.54,'X','Paint',32);o=g.obj(f'Wheel_{side}{idx}',(x,y,z));o['role']=kind;PIVOTS[o.name]={'axis':'X','point':[x,z,-y]}
  for i,y in enumerate([-2,-.65,.7,2.05]):
   g=Geo();g.cyl((s*1.49,y,1.22),.17,.33,'X','Rubber',28);g.cyl((s*1.68,y,1.22),.11,.055,'X','Metal',24)
   o=g.obj(f'Wheel_{side}{i+9}',(s*1.49,y,1.22));o['role']='ReturnRoller';PIVOTS[o.name]={'axis':'X','point':[s*1.49,1.22,-y]}
  track=make_track(s,side)
  # six individually removable scalloped heavy side skirt sections
  for i,y in enumerate(np.linspace(-2.9,2.85,6)):
   g=Geo();x=s*1.77;w=1.135
   outline=[(x,y-w/2,1.66),(x,y+w/2,1.66),(x,y+w/2,1.10),(x,y+.32,1.07),(x,y+.16,1.045),(x,y,1.035),(x,y-.16,1.045),(x,y-.32,1.08),(x,y-w/2,1.15)]
   outline=[(xx,yy,(min(zz,1.36+(yy+3.18)*.251+.03) if zz>1.6 else zz-.15)) for xx,yy,zz in outline]
   g.prism(outline,(s*.052,0,0))
   chipped_edge(g,(x+s*.053,y-w/2,outline[0][2]),(x+s*.053,y+w/2,outline[1][2]),(s,0,0),13)
   # Heavy flexible dust strip follows each scallop, with genuine thickness.
   low=outline[2:];band=[(xx+s*.056,yy,zz+.145) for xx,yy,zz in reversed(low)]+[(xx+s*.056,yy,zz-.008) for xx,yy,zz in low]
   g.prism(band,(s*.012,0,0),'Rubber')
   for yy in [y-.34,y+.34]:
    g.box((x+s*.039,yy,min(1.69,1.36+(yy+3.18)*.251+.06)),(.084,.12,.048),'Paint',.006)
    bolts(g,[(x+s*.066,yy,min(1.56,1.36+(yy+3.18)*.251-.03)),(x+s*.066,yy,1.04)],'X',.015)
   zt=min(1.68,1.36+(y+3.18)*.251+.04)
   g.tube([(x+s*.055,y-.18,zt),(x+s*.085,y-.18,zt+.06),(x+s*.085,y-.06,zt+.06),(x+s*.055,y-.06,zt)],.014,'Paint')
   g.obj(f'Part_Skirt_{side}{i+1}',(x,y,1.35))
  for end,y in [('F',-3.42),('R',3.39)]:
   g=Geo();g.box((s*1.55,y,1.29 if end=='F' else 1.61),(.59,.72,.11),'Paint',.02)
   g.box((s*1.80,y,1.12 if end=='F' else 1.35),(.036,.65,.32),'Rubber',.009)
   g.obj('Part_Fender_'+end+side,(s*1.6,y,1.36))
 # frontal lights, protective cages and deck detail
 for s,side in [(1,'L'),(-1,'R')]:
  g=Geo();g.box((s*1.17,-3.13,1.425),(.27,.22,.17),'Metal',.024);g.box((s*1.17,-3.246,1.425),(.19,.009,.10),'Lamps',.012)
  g.obj('Headlamp_'+side,(s*1.17,-3.13,1.425),hull)
  g=Geo()
  for dx in [-.155,.155]:g.tube([(s*1.17+dx,-3.25,1.35),(s*1.17+dx,-3.26,1.54),(s*1.17+dx,-3.02,1.54)],.016,'Paint')
  g.tube([(s*1.17-.155,-3.26,1.54),(s*1.17+.155,-3.26,1.54)],.016,'Paint');g.obj('Part_LampGuard_'+side,(s*1.17,-3.17,1.47))
  empty('Light_Head_'+side,(s*1.17,-3.26,1.425),hull)
 # front RIGHT powerpack louvers and exhaust, safely forward of turret
 for j,(cx,cy,w,lng) in enumerate([(-.75,-2.2,.66,.88),(-.72,-1.47,.75,.40)]):
  g=Geo();z=1.36+(cy+3.18)*.251+.01
  g.box((cx,cy,z),(w,lng,.06),'Metal',.015)
  for yy in np.linspace(cy-lng/2+.045,cy+lng/2-.045,12):g.box((cx,yy,z+.047),(w-.065,.028,.035),'Paint',.004,Matrix.Rotation(.20,3,'X'))
  g.obj(f'Part_EngineGrille_{j+1}',(cx,cy,z))
 g=Geo();g.box((-1.68,-1.78,1.57),(.16,.67,.23),'Metal',.02)
 for yy in np.linspace(-2.05,-1.51,8):g.box((-1.78,yy,1.58),(.06,.028,.16),'Paint',.004)
 g.obj('Part_ExhaustShroud',(-1.73,-1.78,1.57));empty('Exhaust',(-1.81,-1.78,1.56),hull);empty('Fx_Engine',(-.65,-2.08,1.68),hull);empty('Fx_Ammo',(0,2.1,1.1),hull)
 empty('Fx_Hatch',(-.57,.72,2.52),tur)
 # hull rear access hatch, vertical hinge, actual thick door and recessed black space
 g=Geo();g.box((0,3.25,1.04),(.71,.065,.92),'Interior',.04)
 for xx in [-.36,.36]:g.box((xx,3.51,1.04),(.06,.54,.92),'Interior',.01)
 for zz in [.57,1.50]:g.box((0,3.51,zz),(.76,.54,.055),'Interior',.01)
 g.obj('Rear_Interior',(0,3.51,1.04),hull)
 g=Geo();g.box((0,3.842,1.04),(.67,.065,.85),'Paint',.028)
 for z in [.73,1.34]:g.cyl((-.34,3.856,z),.032,.13,'Z','Metal',16)
 g.tube([(.22,3.888,.98),(.26,3.918,.98),(.26,3.918,1.12),(.22,3.888,1.12)],.013,'Metal')
 o=g.obj('Rear_Door',(-.34,3.842,1.04),hull);PIVOTS['Rear_Door']={'axis':'Y','point':[-.34,1.04,-3.842]}
 for s,side in [(1,'L'),(-1,'R')]:
  g=Geo();g.box((s*.97,3.80,1.30),(.66,.23,.65),'Paint',.024)
  for z in [1.06,1.28,1.5]:g.box((s*.97,3.93,z),(.59,.035,.035),'Paint',.006)
  g.tube([(s*.71,3.95,1.08),(s*1.2,3.95,1.52)],.019,'Paint');g.obj('Part_RearStowage_'+side,(s*.97,3.8,1.3))
  g=Geo();g.cyl((s*1.39,3.75,1.54),.068,.04,'Y','Lamps',24);g.obj('TailLamp_'+side,(s*1.39,3.75,1.54),hull)
 # turret rear basket: open rails and mesh floor, ball-and-chain curtain
 g=Geo()
 for z in [1.99,2.10,2.21,2.32]:g.tube([(-1.46,2.27,z),(-1.40,3.02,z),(1.40,3.02,z),(1.46,2.27,z)],.022,'Paint',10)
 for x in np.linspace(-1.4,1.4,9):g.tube([(x,2.40,1.99),(x,3.02,1.99),(x,3.02,2.32)],.017,'Metal',8)
 for y in np.linspace(2.45,2.98,5):g.tube([(-1.40,y,1.99),(1.40,y,1.99)],.014,'Metal')
 # loops alternate planes; ball bottoms are very distinctive Merkava signature
 for x in np.linspace(-1.34,1.34,29):
  for k in range(5):g.ring((x,2.93,1.94-k*.047),.025,.006,'Y' if k%2==0 else 'X','Metal',10,4)
  g.lathe((x,2.93,1.69),[(-.032,0),(-.026,.023),(0,.032),(.026,.023),(.032,0)],'Z','Metal',10)
 for side in [-1,1]:
  for yy in np.linspace(2.38,2.90,7):
   xx=side*(1.46-(yy-2.27)*.08)
   for k in range(5):g.ring((xx,yy,1.94-k*.047),.025,.006,'Y' if k%2==0 else 'X','Metal',8,4)
   g.lathe((xx,yy,1.69),[(-.032,0),(-.026,.023),(0,.032),(.026,.023),(.032,0)],'Z','Metal',8)
 g.obj('Part_Turret_BustleRack',(0,2.69,2.1),tur)
 for s,side in [(1,'L'),(-1,'R')]:
  g=Geo()
  for j in range(4):
   x=s*(1.02+.085*j);y=-.57+.105*j;z=2.38
   g.tube([(x,y,z),(x,y-.16,z+.10)],.043,'Metal',16)
   g.cyl((x,y-.16,z+.10),.039,.015,'Y','Interior',16)
  g.box((s*1.16,-.43,2.35),(.38,.28,.09),'Paint',.015);g.obj('Part_Turret_Smoke_'+side,(s*1.16,-.5,2.4),tur)
  g=Geo();g.cyl((s*1.27,2.06,2.4),.04,.16,'Z','Metal',16)
  g.tube([(s*1.27,2.06,2.43),(s*1.27,2.06,2.66)],.007,'Rubber',8);g.obj('Part_Turret_Antenna_'+side,(s*1.27,2.06,2.48),tur)
 # towing cables and pioneer tools on back deck, no unsupported props
 g=Geo()
 pts=[(1.52,2.3,1.83),(1.52,3.30,1.78),(.7,3.49,1.75),(-.8,3.49,1.75),(-1.49,3.25,1.78),(-1.49,2.6,1.82)]
 g.tube(pts,.024,'Metal',10)
 for p in [pts[0],pts[-1]]:g.ring(p,.078,.02,'Z','Metal',24,6)
 g.obj('Part_TowCable',(0,3.1,1.8))
 g=Geo();g.tube([(.5,2.46,1.82),(.5,3.32,1.82)],.023,'Rubber',10);g.box((.5,3.40,1.82),(.19,.25,.033),'Metal',.025)
 g.tube([(.84,2.6,1.82),(.84,3.4,1.82)],.025,'Rubber',10);g.box((.84,2.56,1.82),(.30,.11,.065),'Metal',.012)
 g.obj('Part_ToolSet',(.7,3,1.82))
 return hull,tur,gun,barrel

def path_track(t):
 # loop starts bottom front, proceeds rearward, over idler, upper forward, sprocket
 straight=6.24;rad=.59;total=2*straight+2*PI*rad;a=(t%1)*total
 if a<straight:return -3.12+a,.075,1.,0.
 a-=straight
 if a<PI*rad:
  q=-PI/2+a/rad;return 3.12+rad*math.cos(q),.665+rad*math.sin(q),-math.sin(q),math.cos(q)
 a-=PI*rad
 if a<straight:return 3.12-a,1.255,-1.,0.
 a-=straight;q=PI/2+a/rad;return -3.12+rad*math.cos(q),.665+rad*math.sin(q),-math.sin(q),math.cos(q)

def make_track(s,side,broken=False):
 g=Geo();N=100
 for i in range(N):
  t=(i+.5)/N;y,z,dy,dz=path_track(t)
  if broken:
   if t>.50:
    if t<.90:
     q=(t-.5)/.4;y=3.12-q*6.24;z=.075+1.18*math.exp(-q*17);dy=-1;dz=-3.2*math.exp(-q*17)
    else:y=-3.12-(t-.9)*14;z=.075;dy=-1;dz=0
  R=Matrix.Rotation(math.atan2(dz,dy),3,'X')
  start=len(g.f);startv=len(g.v)
  g.box((s*1.49,y,z),(.65,.146,.105),'TrackSteel',.009,R)
  tangent=Vector((0,dy,dz)).normalized();pin=Vector((s*1.49,y,z))+tangent*((2*6.24+2*PI*.59)/N*.5)
  g.cyl(pin,.027,.665,'X','TrackSteel',8)
  g.box((s*1.49,y,z),(.45,.106,.127),'Rubber',.008,R)
  # edge gripping cleats and guide tooth
  for dx in [-.263,.263]:g.box((s*1.49+dx,y,z),(.072,.118,.15),'TrackSteel',.006,R)
  normal=Vector((0,-dz,dy));p=Vector((s*1.49,y,z))+normal*.11
  g.box(p,(.085,.08,.14),'TrackSteel',.009,R)
  if broken and t>.50:
   shift=s*.70*(1-math.exp(-(t-.5)*45))
   for j in range(startv,len(g.v)):
    p=g.v[j];g.v[j]=(p[0]+shift,p[1],max(0,p[2]))
  for k in range(start,len(g.f)):
   # longitudinal V exactly one cycle, not generic per-link UVs
   face=g.f[k];g.uv[k]=[((g.v[j][0]-s*1.49)/.65+.5,t+((Vector(g.v[j])-Vector((s*1.49,y,z))).dot(Vector((0,dy,dz))))/(2*6.24+2*PI*.59)) for j in face]
 return g.obj('Track_'+side+('_Broken' if broken else ''),damage=broken)

def duplicate(o,name,damage=True):
 cp=o.copy();cp.data=o.data.copy() if o.data else None;scene.collection.objects.link(cp);cp.name=name;cp.parent=None;cp.matrix_world=o.matrix_world.copy()
 (DAMAGE if damage else HEALTH).append(cp);return cp

def setparent(o,p):
 mw=o.matrix_world.copy();o.parent=p;o.matrix_world=mw

def apply_mod(o,mod):
 bpy.context.view_layer.objects.active=o;o.select_set(True);bpy.ops.object.modifier_apply(modifier=mod.name);o.select_set(False)

def make_damage(hull,tur,gun,barrel):
 hd=empty('Hull_Damage',(0,0,0),damage=True);td=empty('Turret_Damage',tuple(tur.matrix_world.translation),damage=True)
 twins=[]
 targets=[o for o in PARTS if o.name.startswith('Part_Skirt_')][:8]+[o for o in PARTS if o.name in ['Part_Fender_FL','Part_Fender_FR','Part_EngineGrille_1','Part_RearStowage_L','Part_Turret_SideArmour_L','Part_Turret_Cheek_R2','Part_Turret_BustleRack']]
 for idx,o in enumerate(targets):
  cp=duplicate(o,o.name.replace('Part_','Dmg_',1));twins.append((o.name,cp.name));setparent(cp,td if o.parent==tur else hd)
  # tear and buckle in local coordinates, leaving attachment edge in place
  for v in cp.data.vertices:
   p=v.co
   if o.name.startswith('Part_Skirt'):
    bend=max(0,.2-p.z);p.x+=(-1 if 'R' in o.name else 1)*bend*(.3+.4*math.sin(p.y*4));p.z-=.12*max(0,-p.y+.1)
   elif 'Rack' in o.name:p.x+=.12*math.sin(p.y*5+p.z*3);p.z-=max(0,p.y)*.32
   else:p.z+=.11*math.sin(p.x*6+p.y*4)*max(.25,abs(p.x)+abs(p.y))
  if 'Skirt' in o.name:
   # actual penetration with interior wall, not a floating decal
   bpy.ops.mesh.primitive_uv_sphere_add(segments=12,ring_count=8,radius=.13,location=cp.matrix_world@Vector((0,-.13,-.03)))
   cutter=bpy.context.object;cutter.scale=(2.7,1,1);bpy.context.view_layer.update()
   mod=cp.modifiers.new('Shell penetration','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter
   apply_mod(cp,mod);bpy.data.objects.remove(cutter,do_unlink=True)
   scorch=len(cp.data.materials);cp.data.materials.append(MATS['Burnt']);steel=len(cp.data.materials);cp.data.materials.append(MATS['Scars'])
   sign=1 if '_L' in o.name else -1
   for ve in cp.data.vertices:
    rad=math.hypot(ve.co.y+.13,ve.co.z+.03)
    if .11<rad<.22:ve.co.x+=sign*.065*(1-abs(rad-.145)/.11)
   cp.data.update()
   for face in cp.data.polygons:
    rad=math.hypot(face.center.y+.13,face.center.z+.03)
    if rad<.25 and max(math.hypot(cp.data.vertices[i].co.y+.13,cp.data.vertices[i].co.z+.03) for i in face.vertices)<.28:face.material_index=steel if .10<rad<.145 else scorch
  cp['swaps']=o.name
 # facial impact clusters: gouged concentric facets, soot fan, curled petals
 scar_names=[]
 from mathutils.bvhtree import BVHTree
 def armor_tree(obs):
  vv=[];ff=[]
  for ob in obs:
   off=len(vv);vv.extend([tuple(ob.matrix_world@v.co) for v in ob.data.vertices]);ff.extend([tuple(off+i for i in f.vertices) for f in ob.data.polygons])
  return BVHTree.FromPolygons(vv,ff)
 hulltree=armor_tree([hull]+[o for o in PARTS if o.name.startswith('Part_Skirt')])
 turtree=armor_tree([tur]+[o for o in PARTS if o.name.startswith(('Part_Turret_Cheek','Part_Turret_SideArmour'))])
 def scar(name,center,normal,parent):
  g=Geo();c=Vector(center);n=Vector(normal).normalized();tree=turtree if parent==td else hulltree
  hit,nn,_,_=tree.ray_cast(c+n*2,-n,4)
  if hit is not None:c=hit;n=nn if nn.dot(n)>0 else -nn
  u=n.cross(Vector((0,0,1)))
  if u.length<.1:u=n.cross(Vector((0,1,0)))
  u.normalize();v=n.cross(u)
  for k in range(4):
   p=c+u*((k-1.5)*.25)+v*(.09*math.sin(k*2.2));rr=.031 if k else .061
   elongate=2.7 if k in [1,3] else 1.;thin=.43 if k in [1,3] else 1.
   # concentric soot and ragged steel ring; patches conform to face plane
   ring=[]
   for rad,z in [(rr*2,.0022),(rr*1.16,.0028),(rr,.0038),(rr*.71,.004),(rr*.55,.0030)]:
    ring.append([tuple(p+u*(elongate*rad*(1+.23*math.sin(a*4.7+k))*math.cos(a*2*PI/16))+v*(thin*rad*math.sin(a*2*PI/16))+n*z) for a in range(16)])
   g.loft(ring[:2],'Burnt',False);g.loft(ring[1:],'Scars',False)
   g.prism([tuple(p+u*elongate*rr*.55*math.cos(a*2*PI/16)+v*thin*rr*.55*math.sin(a*2*PI/16)+n*.0025) for a in range(16)],tuple(n*.0005),'Burnt' if k==0 else 'Scars')
   if k==0:
    for a in range(7):
     q=a*2*PI/7;vv=[p+(u*math.cos(q+d)+v*math.sin(q+d))*r+n*z for d,r,z in [(-.22,rr*.8,.006),(.22,rr*.8,.006),(.14,rr*1.23,.011),(-.1,rr*1.19,.009)]]
     g.prism([tuple(p1) for p1 in vv],tuple(n*.004),'Scars')
  # Every vertex is 2–4mm above the real armor face (petals may curl further).
  for i,pt in enumerate(g.v):
   pt=Vector(pt);height=max(.002,min(.014,(pt-c).dot(n)));flat=pt-n*(pt-c).dot(n)
   hit,nn,_,_=tree.ray_cast(flat+n*2,-n,4)
   if hit is not None:g.v[i]=tuple(hit+n*height)
  ob=g.obj(name,tuple(c),parent,True);scar_names.append(name)
 scar('Scar_Hull_Front',(0,-3.5,1.267),(0,-.27,.96),hd)
 scar('Scar_Hull_L',(1.679,.1,1.49),(1,0,0),hd)
 scar('Scar_Hull_R',(-1.679,.1,1.49),(-1,0,0),hd)
 scar('Scar_Hull_Rear',(0,3.879,1.06),(0,1,0),hd)
 scar('Scar_Turret_Front',(.70,-1.24,2.292),(0,-.3,.954),td)
 scar('Scar_Turret_L',(1.74,.39,2.20),(1,0,.3),td)
 scar('Scar_Turret_R',(-1.74,.39,2.20),(-1,0,.3),td)
 scar('Scar_Turret_Rear',(0,2.525,2.12),(0,1,0),td)
 for s,side in [(1,'L'),(-1,'R')]:setparent(make_track(s,side,True),hd)
 # gun knocked out: exact mantlet copy plus bent tube, cut muzzle with peeled petals
 gb=duplicate(gun,'Gun_Broken');setparent(gb,td)
 bc=duplicate(barrel,'_BrokenBarrel')
 for ve in bc.data.vertices:
  w=bc.matrix_world@ve.co
  if w.y< -3.3:w.z-=(-3.3-w.y)*.29;w.x+=(-3.3-w.y)*.06
  ve.co=bc.matrix_world.inverted()@w
 join_objects([gb,bc],gb)
 g=Geo()
 for a in range(8):
  t=a*PI/4;g.tube([(.12*math.cos(t),-3.4,2.12+.12*math.sin(t)),(.20*math.cos(t),-3.7,2.12+.20*math.sin(t)),(.29*math.cos(t),-3.84,2.12+.23*math.sin(t))],.025,'Scars',6)
 peels=g.obj('_Peels',damage=True);join_objects([gb,peels],gb)
 # Wrecks are direct copies of the healthy geometry, simplified and scorched.
 def wreck(name,objs,pivot):
  copies=[]
  for o in objs:
   if o.type!='MESH':continue
   if o.name.startswith('Part_Skirt') and o.name[-1] in '235':continue
   cp=duplicate(o,'_wreck_'+o.name)
   if o.name.startswith(('Wheel','Track')):
    import bmesh
    rubber_slots=[i for i,m in enumerate(cp.data.materials) if m and m.name=='Rubber']
    bm=bmesh.new();bm.from_mesh(cp.data);bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.material_index in rubber_slots],context='FACES');bm.to_mesh(cp.data);bm.free()
   cp.data.materials.clear();cp.data.materials.append(MATS['Burnt'])
   for p in cp.data.polygons:p.material_index=0
   for vert in cp.data.vertices:
    w=cp.matrix_world@vert.co
    if name=='Wreck_Hull':
     if o.name.startswith('Wheel'):
      center=o.matrix_world.translation;dy=w.y-center.y;dz=w.z-center.z;rr=math.hypot(dy,dz)
      if rr>.40:w.y=center.y+dy*(rr-.045)/rr;w.z=center.z+dz*(rr-.045)/rr
      w.z-=.05
     if o.name.startswith('Track') and w.z>.85:w.z-=.14*math.sin(w.y*1.2)**2
     if o.name=='Hull' and -2.6<w.y<-1.5 and w.z>1.4:w.z+=.15*math.sin(w.x*6)*math.sin(w.y*5)
    elif o.name in ['Gun','Barrel']:
     if w.y< -1.26:w.z-=(-1.26-w.y)*.20
    vert.co=cp.matrix_world.inverted()@w
   if o.name=='Part_EngineGrille_1':
    cp.matrix_world=cp.matrix_world @ Matrix.Rotation(math.radians(48),4,'Y');cp.location.z+=.16
   if o.name=='Rear_Door':cp.matrix_world=cp.matrix_world @ Matrix.Rotation(math.radians(65),4,'Z')
   if o.name.startswith('Hatch_'):
    cp.matrix_world=cp.matrix_world @ Matrix.Rotation(math.radians(-105),4,'X')
   if o.name.startswith('Part_Skirt'):
    cp.rotation_euler.y=math.radians(12 if 'L' in o.name else -17)
   copies.append(cp)
  target=copies[0];join_objects(copies,target);target.name=name
  # relocate pivot in mesh coordinates without altering world placement
  old=target.matrix_world.copy();P=Matrix.Translation(pivot)
  target.data.transform(P.inverted()@old);target.matrix_world=P
  dec=target.modifiers.new('Wreck reduction','DECIMATE');dec.ratio=.55;apply_mod(target,dec)
  return target
 def is_tur(o):
  p=o
  while p:
   if p==tur:return True
   p=p.parent
  return False
 wh=wreck('Wreck_Hull',[o for o in HEALTH if not is_tur(o) and o.name!='Track_L']+[bpy.data.objects['Track_L_Broken']],(0,0,0))
 # Split a real opening into the front-right engine bay beneath the lifted grille.
 bpy.ops.mesh.primitive_cube_add(size=1,location=(-.69,-2.20,1.65));cutter=bpy.context.object;cutter.dimensions=(.57,.72,.57);bpy.context.view_layer.update()
 mod=wh.modifiers.new('Ruptured engine bay','BOOLEAN');mod.object=cutter;mod.operation='DIFFERENCE';apply_mod(wh,mod);bpy.data.objects.remove(cutter,do_unlink=True)
 g=Geo()
 for j in range(7):
  x=-.98+j*.09;g.prism([(x,-2.56,1.54),(x+.065,-2.56,1.54),(x+.07,-2.40,1.77+random.uniform(0,.12)),(x,-2.42,1.8)],(0,.014,0),'Burnt')
 g.box((-.69,-2.2,1.23),(.56,.71,.12),'Burnt',.02)
 debris=g.obj('_RupturedDeck',damage=True);join_objects([wh,debris],wh)
 wt=wreck('Wreck_Turret',[o for o in HEALTH if is_tur(o)],tuple(tur.matrix_world.translation))
 # turret underside is sealed with a dark ring and basket stump for blow-off state
 g=Geo();g.cyl((0,.58,1.80),1.08,.07,'Z','Burnt',48);g.lathe((0,.58,1.50),[(-.12,.63),(.16,.63),(.2,.92),(.24,.95)],'Z','Burnt',40);g.cyl((0,.58,1.38),.63,.035,'Z','Burnt',40)
 stub=g.obj('_BasketStub',damage=True);join_objects([wt,stub],wt)
 return {'parts':[{'part':p.name,'twin':dict(twins).get(p.name)} for p in PARTS], 'scars':scar_names,'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']}

def join_objects(obs,target):
 bpy.ops.object.select_all(action='DESELECT')
 for ob in obs:ob.select_set(True)
 bpy.context.view_layer.objects.active=target;bpy.ops.object.join()
 # purge references that Blender join removed
 for ls in [DAMAGE,HEALTH]:
  ls[:]=[o for o in ls if valid(o)]
 return target

def valid(o):
 try:return o.name in bpy.data.objects
 except ReferenceError:return False

def cleanup_normals(objs):
 import bmesh
 for o in objs:
  if o.type!='MESH':continue
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=.000001);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bmesh.ops.triangulate(bm,faces=bm.faces);dead=[f for f in bm.faces if f.calc_area()<1e-10];bmesh.ops.delete(bm,geom=dead,context='FACES');bm.to_mesh(o.data);bm.free()

def triangles(obs):
 return sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in obs if o.type=='MESH')

def export(path,objs):
 bpy.ops.object.select_all(action='DESELECT')
 for o in objs:o.hide_set(False);o.hide_render=False;o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO',export_extras=True)
 # exporter multiply support varies; enforce the public Paint contract in JSON.
 raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);tail=raw[20+jl:]
 for m in doc.get('materials',[]):
  if m['name']=='Paint':m.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=[.30,.29,.24,1]
 js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
 path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(js)+len(tail))+struct.pack('<II',len(js),0x4e4f534a)+js+tail)
 print('EXPORT',path.name,triangles(objs),path.stat().st_size,flush=True)
 return doc

def setup_render():
 g=Geo();g.box((0,0,-.105),(200,200,.12),'Ground',.01);ground=g.obj('_Ground');HEALTH.remove(ground)
 for name,loc,power,size in [('Key',(-5,-6,10),1900,8),('Fill',(7,-3,6),1000,7),('Rim',(1,7,8),1600,6)]:
  data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;o=bpy.data.objects.new(name,data);scene.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);scene.collection.objects.link(cam);scene.camera=cam;cam.data.type='ORTHO';cam.data.ortho_scale=11.8;cam.data.clip_end=500
 camera((10,-13,5.9),(0,-.2,1.1));return ground

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

def render(file,w=1920,h=1080,samples=32):
 scene.render.resolution_x=w;scene.render.resolution_y=h;scene.render.resolution_percentage=100;scene.cycles.samples=samples;scene.render.filepath=str(ROOT/file);bpy.ops.render.render(write_still=True)

def set_visible(objs,value):
 for o in objs:
  if valid(o):o.hide_render=not value

def grid(paths,out,labels):
 # Pure numpy contact sheet: each input is a real Blender render, no image synthesis.
 tiles=[]
 for p in paths:
  im=bpy.data.images.load(str(ROOT/p),check_existing=False);w,h=im.size;a=np.array(im.pixels[:],dtype=np.float32).reshape(h,w,4);tiles.append(a);bpy.data.images.remove(im)
 rows=[np.concatenate(tiles[:3],axis=1),np.concatenate(tiles[3:],axis=1)]
 a=np.concatenate(list(reversed(rows)),axis=0);im=bpy.data.images.new(out,width=a.shape[1],height=a.shape[0]);im.pixels.foreach_set(a.ravel());im.filepath_raw=str(ROOT/out);im.file_format='PNG';im.save();bpy.data.images.remove(im)
 # labels are documented in nodes.json; images themselves contain no lettering.

def manifest_nodes(objs):
 rows=[];points=[]
 for o in objs:
  pos=o.matrix_world.translation
  row={'name':o.name,'position':[round(pos.x,5),round(pos.z,5),round(-pos.y,5)],'parent':o.parent.name if o.parent else None,'type':o.type}
  if o.get('role'):row['role']=o['role']
  if o.type=='MESH':
   p=[o.matrix_world@Vector(v) for v in o.bound_box];points+=p;row['triangles']=sum(len(f.vertices)-2 for f in o.data.polygons)
  rows.append(row)
 bounds={'min':[min(p.x for p in points),min(p.z for p in points),min(-p.y for p in points)],'max':[max(p.x for p in points),max(p.z for p in points),max(-p.y for p in points)]}
 return rows,bounds


def cut_openings(hull,tur):
 bpy.ops.mesh.primitive_cube_add(size=1,location=(0,3.67,1.04));cutter=bpy.context.object;cutter.dimensions=(.65,.72,.85);bpy.context.view_layer.update()
 mod=hull.modifiers.new('Rear access chamber','BOOLEAN');mod.object=cutter;mod.operation='DIFFERENCE';apply_mod(hull,mod);bpy.data.objects.remove(cutter,do_unlink=True)
 for ob,loc,r,depth in [(hull,(0,.58,1.74),1.015,.75),(hull,(.77,-2.03,1.64),.228,.40),(tur,(-.57,.72,2.42),.27,.42),(tur,(.58,1.,2.42),.25,.42)]:
  bpy.ops.mesh.primitive_cylinder_add(vertices=48,radius=r,depth=depth,location=loc);cutter=bpy.context.object
  mod=ob.modifiers.new('Machined crew opening','BOOLEAN');mod.object=cutter;mod.operation='DIFFERENCE';apply_mod(ob,mod);bpy.data.objects.remove(cutter,do_unlink=True)

def index_running_gear():
 # All running gear indexes increase front-to-back, including return rollers.
 for side in ['L','R']:
  obs=sorted([o for o in HEALTH if o.name.startswith('Wheel_'+side)],key=lambda o:o.matrix_world.translation.y)
  specs=[PIVOTS.pop(o.name) for o in obs]
  for i,o in enumerate(obs):o.name='_WheelTemp_'+side+str(i)
  for i,(o,spec) in enumerate(zip(obs,specs)):
   o.name=f'Wheel_{side}{i+1}';PIVOTS[o.name]=spec

def recenter_parts():
 for ob in PARTS:
  bpy.context.view_layer.update();mw=ob.matrix_world.copy()
  center=sum((mw@Vector(v) for v in ob.bound_box),Vector())/8
  new=mw.copy();new.translation=center;ob.data.transform(new.inverted()@mw);ob.matrix_world=new;ob['author_pivot']=list(center)
 bpy.context.view_layer.update()

def fit_dimensions():
 # Keep the hull footprint center fixed; trim accessories within the nominal envelope.
 bpy.context.view_layer.update()
 pts=[o.matrix_world@v.co for o in HEALTH if o.type=='MESH' for v in o.data.vertices]
 xmin=min(p.x for p in pts);xmax=max(p.x for p in pts);zmin=min(p.z for p in pts);zmax=max(p.z for p in pts);ymax=max(p.y for p in pts)
 def fit(v):
  p=v.copy();p.x=(p.x-(xmin+xmax)/2)*3.72/(xmax-xmin)
  if p.y>3.4:p.y=3.4+(p.y-3.4)*(.4/(ymax-3.4))
  p.z=max(0,(p.z-zmin)*2.66/(zmax-zmin))
  return p
 # Work from current world matrices so all moving pivots remain on their hardware.
 snapshots=[(o,o.matrix_world.copy()) for o in HEALTH]
 for o,mw in snapshots:
  if o.type=='MESH':
   for v in o.data.vertices:v.co=mw.inverted()@fit(mw@v.co)
 # Move all pivot translations consistently with geometry, preserving local rotation axes.
 for o,mw in snapshots:
  new=mw.copy();new.translation=Vector((0,0,0)) if o.name=='Hull' else fit(mw.translation)
  if o.type=='MESH':o.data.transform(new.inverted()@mw)
  o.matrix_world=new
  bpy.context.view_layer.update()
  if o.name in PIVOTS:
   p=new.translation;PIVOTS[o.name]['point']=[p.x,p.z,-p.y]
  o['author_pivot']=list(new.translation)
 bpy.context.view_layer.update()

def reduce_budget(objects,target):
 count=triangles(objects)
 if count<=target:return
 fixed=[o for o in objects if o.name=='Barrel'];fixed_count=triangles(fixed)
 ratio=(target-fixed_count)/(count-fixed_count)
 for o in objects:
  if o.type!='MESH' or o in fixed:continue
  mod=o.modifiers.new('Game mesh budget','DECIMATE');mod.ratio=ratio;apply_mod(o,mod)
 cleanup_normals(objects)

def main():
 global scene, HEALTH, DAMAGE, MATS
 scene.cycles.samples=4
 # All externally sourced images are the listed CC0 Poly Haven texture maps.
 specs=[('Paint','rusty_metal_03',(.3,.29,.24),.83,.06,1024),('Rubber','rubberized_track',(.035,.034,.03),.92,0,512),('TrackSteel','rusty_metal_03',(.115,.105,.087),.70,.68,512),('Metal','metal_plate',(.13,.14,.14),.53,.82,512),('Glass','metal_plate',(.018,.039,.052),.23,.45,512),('Lamps','metal_plate',(.67,.72,.69),.25,.22,512),('Interior','metal_plate',(.012,.014,.013),.94,0,512),('Burnt','rusty_metal_03',(.032,.029,.025),.96,.25,512),('Scars','rusty_metal_03',(.19,.19,.18),.56,.8,512)]
 for args in specs:bake_material(*args)
 # ground is rendering only, excluded from all asset exports
 ground=bpy.data.materials.new('Ground');ground.diffuse_color=(.22,.235,.23,1);ground.use_nodes=True;ground.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.22,.235,.23,1);ground.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.92;MATS['Ground']=ground
 hull,tur,gun,barrel=build_healthy();bpy.context.view_layer.update();cleanup_normals(HEALTH);cut_openings(hull,tur)
 # bevel silhouette-changing armor, while retaining the moving pivot matrices.
 for o in HEALTH:
  if o.type=='MESH' and (o.name in ['Hull','Turret'] or o.name.startswith(('Part_Skirt','Part_Turret_Cheek','Part_Turret_SideArmour'))):
   mod=o.modifiers.new('Armor edge machining','BEVEL');mod.width=.004;mod.segments=2;apply_mod(o,mod)
 fit_dimensions();recenter_parts();index_running_gear();reduce_budget(HEALTH,128000);fit_dimensions();recenter_parts()
 print('HEALTH_TRIANGLES',triangles(HEALTH),flush=True)
 export(ROOT/'merkava-tank.glb',HEALTH)
 health_nodes,bounds=manifest_nodes(HEALTH)
 set_visible(HEALTH,True);setup_render()
 clay=bpy.data.materials.new('_Clay');clay.diffuse_color=(.34,.34,.34,1);clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.34,.34,.34,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
 scene.view_layers[0].material_override=clay;render('clay.png',1920,1080,20);scene.view_layers[0].material_override=None
 render('final.png',1920,1080,40);camera((12,-.7,1.33),(0,-.7,1.33),10.6);bpy.data.objects['_Ground'].hide_render=True;render('side.png',1920,1080,24);bpy.data.objects['_Ground'].hide_render=False;camera((-9,12,5.2),(0,.2,1.2),10.8);render('rear.png',1280,720,24);camera((10,-13,5.9),(0,-.2,1.1))
 damage=make_damage(hull,tur,gun,barrel);bpy.context.view_layer.update();cleanup_normals(DAMAGE)
 for o in DAMAGE:
  if o.type=='MESH':
   for i,m in enumerate(o.data.materials):
    if m is None or m.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:o.data.materials[i]=MATS['Scars' if m and m.name=='Metal' else 'Burnt']
 reduce_budget(DAMAGE,108000)
 export(ROOT/'merkava-tank-damage.glb',DAMAGE)
 damage_nodes,damage_bounds=manifest_nodes(DAMAGE)
 manifest={'asset':'Merkava Mk 4 style','contract':'tank-damage/v1','units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'bounding_box':bounds,'healthy_triangles':triangles(HEALTH),'damage_triangles':triangles(DAMAGE),'files':{'merkava-tank.glb':health_nodes,'merkava-tank-damage.glb':damage_nodes},'paints':[{'name':n,'linearRGB':c} for n,c in PAINTS],'damage':damage,'pivots':PIVOTS,'states_grid':['healthy','hit','stripped','immobilised','wreck','turret blown off'],'paints_grid':[p[0] for p in PAINTS]}
 (ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
 # All proof frames are loaded from the two delivered GLBs into an empty scene.
 bpy.ops.wm.read_factory_settings(use_empty=True)

 scene=bpy.context.scene;scene.render.engine='CYCLES';scene.cycles.device='GPU';scene.cycles.use_denoising=True;scene.view_settings.view_transform='AgX';scene.render.image_settings.file_format='PNG'
 scene.world=bpy.data.worlds.new('Daylight');scene.world.use_nodes=True;scene.world.node_tree.nodes['Background'].inputs[0].default_value=(.60,.68,.8,1);scene.world.node_tree.nodes['Background'].inputs[1].default_value=.45
 bpy.ops.import_scene.gltf(filepath=str(ROOT/'merkava-tank.glb'));HEALTH=list(scene.objects)
 bpy.ops.import_scene.gltf(filepath=str(ROOT/'merkava-tank-damage.glb'));DAMAGE=[o for o in scene.objects if o not in HEALTH]
 MATS={m.name:m for m in bpy.data.materials}
 ground=bpy.data.materials.new('Ground');ground.use_nodes=True;ground.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.22,.235,.23,1);MATS['Ground']=ground
 setup_render();camera((10,-13,5.9),(.45,-.2,1.1),13.2);set_visible(DAMAGE,False)
 def show_state(mode):
  set_visible(HEALTH,mode<4);set_visible(DAMAGE,False)
  if mode==1:
   for name in damage['scars']:bpy.data.objects[name].hide_render=False
   for pair in [p for p in damage['parts'] if p['twin']][:6]:bpy.data.objects[pair['part']].hide_render=True;bpy.data.objects[pair['twin']].hide_render=False
  elif mode==2:
   for o in HEALTH:
    if o.name.startswith('Part_'):o.hide_render=True
  elif mode==3:
   for name in ['Track_L','Gun','Barrel']:bpy.data.objects[name].hide_render=True
   for name in ['Track_L_Broken','Gun_Broken']:bpy.data.objects[name].hide_render=False
  elif mode>=4:
   for name in damage['wreck']:bpy.data.objects[name].hide_render=False
  if mode==5:
   ob=bpy.data.objects['Wreck_Turret'];ob.location=(3.5,.7,.78);ob.rotation_mode='XYZ';ob.rotation_euler=(.3,1.9,.6)
 paths=[]
 for i in range(6):
  show_state(i);f=f'_state{i}.png';render(f,768,432,16);paths.append(f)
 grid(paths,'states.png',manifest['states_grid'])
 camera((10,-13,5.9),(0,-.2,1.1));show_state(0)
 # imported glTF material factor is faithfully represented by a MULTIPLY node
 paint=bpy.data.materials['Paint'];bs=next(n for n in paint.node_tree.nodes if n.type=='BSDF_PRINCIPLED');links=paint.node_tree.links
 if bs.inputs['Base Color'].is_linked:
  src=bs.inputs['Base Color'].links[0].from_socket
  # glTF import normally has a factor multiply already: replace its factor.
  factor=next((n for n in paint.node_tree.nodes if n.type=='MIX' and n.data_type=='RGBA'),None)
  if factor is None:
   mix=paint.node_tree.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1
   # avoid squaring default factor: locate actual base image node directly
   base=next(n for n in paint.node_tree.nodes if n.type=='TEX_IMAGE' and n.image and ('base' in n.image.name or 'Base Color' in n.label))
   links.new(base.outputs[0],mix.inputs[1]);links.new(mix.outputs[0],bs.inputs['Base Color']);sock=mix.inputs[2]
  else:sock=factor.inputs[7]
 else:sock=bs.inputs['Base Color']
 paths=[]
 for i,(name,col) in enumerate(PAINTS):
  sock.default_value=(*col,1);f=f'_paint{i}.png';render(f,768,432,16);paths.append(f)
 grid(paths,'paints.png',manifest['paints_grid']);sock.default_value=(*PAINTS[0][1],1)
 # Moving-node pivot proof, all tested on the exported/imported consumer representation.
 rig=[];pivot_test=[]
 for o in HEALTH:
  if o.name in PIVOTS or o.name=='Barrel':
   o.rotation_mode='XYZ';rig.append((o,o.rotation_euler.copy()));pivot_test.append({'node':o.name,'local_pivot_before':list(o.location),'test':'recoil_0.18m' if o.name=='Barrel' else 'rotate_30deg'});axis=2 if o.name in ['Turret','MG','Rear_Door'] else 0
   if o.name=='Barrel':o.location.y+=.18
   else:o.rotation_euler[axis]+=(-PI/6 if o.name.startswith('Hatch') or o.name=='Gun' else PI/6)
 bpy.context.view_layer.update()
 for test in pivot_test:
  o=bpy.data.objects[test['node']];test['local_pivot_after']=list(o.location)
  if o.name!='Barrel':assert (Vector(test['local_pivot_before'])-o.location).length<1e-6
 manifest['rig_test']={'passed':True,'tests':pivot_test,'proof':'rig-test.png','note':'Blender author-space coordinates in test records; public pivots above use glTF coordinates.'}
 (ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
 render('rig-test.png',1920,1080,20)
 for o,r in rig:
  o.rotation_euler=r
  if o.name=='Barrel':o.location.y-=.18
 bpy.context.view_layer.update()
 (ROOT/'NOTES.md').write_text('\n'.join([
 'Merkava Mk 4 style tank, rigged and destructible; neutral recolourable paint; metres / glTF Y-up.',
 f"Triangles: healthy {manifest['healthy_triangles']:,}; damage {manifest['damage_triangles']:,} including both wreck assemblies.",
 'Materials: Paint, Rubber, TrackSteel, Metal, Glass, Lamps, Interior; damage adds Burnt and Scars.',
 'Textures: Paint base/normal 1024; all other maps 512; ORM 512; shared tileable Cycles bakes.',
 'Poly Haven CC0: metal_plate, rubberized_track, rusty_metal_03; https://polyhaven.com/license .',
 'Reference differences: procedural reconstruction; simplified optical units, suspension plumbing and local wear; no markings.'
 ])+'\n')
 print('BUILD_COMPLETE',flush=True)
if __name__=='__main__':main()
