"""Challenger-inspired tank; clean Blender 5.2 build, CC0 tile bakes, two GLBs and proofs.
Author frame: X left, -Y forward, Z up. glTF conversion: X left, Y up, Z forward.
Run: blender -b --python-exit-code 1 --python build_challenger2-tank.py
Budget: healthy 80-140k triangles / 12 MB; damage <=110k. Three visual revisions max.
"""
import bpy, bmesh, math, os, json, random, urllib.request, pathlib, sys, struct, hashlib
import numpy as np
from mathutils import Vector, Matrix
from math import sin,cos,pi
ROOT=pathlib.Path(__file__).resolve().parent
BUILD_SOURCE_SHA256=hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest()
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(22)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for d in list(bpy.data.materials): bpy.data.materials.remove(d)
sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=24
prefs=bpy.context.preferences.addons['cycles'].preferences
for backend in ('OPTIX','CUDA'):
 try:
  prefs.compute_device_type=backend; prefs.get_devices()
  if any(d.type!='CPU' for d in prefs.devices): break
 except: pass
for d in prefs.devices: d.use=d.type!='CPU'
sc.cycles.device='GPU'; sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG'; sc.render.film_transparent=False
sc.view_settings.view_transform='AgX'
print('GPU',[(d.name,d.type,d.use) for d in prefs.devices],flush=True)
MATS={}; healthy=[]; damage=[]; groups={}
PAINTS=[('Desert tan',[.42,.36,.24]),('NATO green',[.105,.145,.078]),('Olive drab',[.16,.175,.085]),('Field grey',[.21,.235,.20]),('Winter white',[.72,.75,.70]),('Dark green-black',[.037,.051,.033])]

def download(id,mp):
 p=TEX/f'{id}_{mp}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
  with urllib.request.urlopen(req,timeout=80) as r: p.write_bytes(r.read())
 return bpy.data.images.load(str(p),check_existing=True)

def texture_material(name,base,metal,rough,id='metal_plate',size=512):
 """Cycles bake of a photographic tile plus procedural streak/grain, shared by all parts."""
 mat=bpy.data.materials.new(name); mat.use_nodes=True; nt=mat.node_tree; n=nt.nodes; l=nt.links
 n.clear(); out=n.new('ShaderNodeOutputMaterial'); bs=n.new('ShaderNodeBsdfPrincipled')
 l.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=n.new('ShaderNodeTexCoord')
 diff=n.new('ShaderNodeTexImage'); diff.image=download(id,'diff'); l.new(uv.outputs['UV'],diff.inputs['Vector'])
 nr=n.new('ShaderNodeTexImage'); nr.image=download(id,'nor_gl'); nr.image.colorspace_settings.name='Non-Color'; l.new(uv.outputs['UV'],nr.inputs['Vector'])
 arm=n.new('ShaderNodeTexImage'); arm.image=download(id,'arm'); arm.image.colorspace_settings.name='Non-Color'; l.new(uv.outputs['UV'],arm.inputs['Vector'])
 bw=n.new('ShaderNodeRGBToBW'); l.new(diff.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB'); ramp.color_ramp.elements[0].position=.012 if name=='Paint' else .05; ramp.color_ramp.elements[1].position=.20 if name=='Paint' else .75
 lo=.65 if name=='Paint' else .50; hi=.99 if name=='Paint' else 1.22
 ramp.color_ramp.elements[0].color=tuple(min(1,c*lo) for c in base)+(1,)
 ramp.color_ramp.elements[1].color=tuple(min(1,c*hi) for c in base)+(1,)
 l.new(bw.outputs[0],ramp.inputs[0])
 mapping=n.new('ShaderNodeVectorMath'); mapping.operation='MULTIPLY'; mapping.inputs[1].default_value=(9,1.8,1); l.new(uv.outputs['UV'],mapping.inputs[0])
 noise=n.new('ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=3.5; noise.inputs['Detail'].default_value=5; l.new(mapping.outputs[0],noise.inputs['Vector'])
 grime=n.new('ShaderNodeValToRGB'); grime.color_ramp.elements[0].position=.28; grime.color_ramp.elements[0].color=(.38,.38,.38,1); grime.color_ramp.elements[1].position=.58; grime.color_ramp.elements[1].color=(1,1,1,1); l.new(noise.outputs['Fac'],grime.inputs[0])
 mix=n.new('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=.10 if name=='Paint' else .27; l.new(ramp.outputs[0],mix.inputs[1]); l.new(grime.outputs[0],mix.inputs[2]); l.new(mix.outputs[0],bs.inputs['Base Color'])
 normal=n.new('ShaderNodeNormalMap'); normal.inputs['Strength'].default_value=.12 if name=='Paint' else (.025 if name in ['Glass','Lamps','Rubber'] else .22); l.new(nr.outputs['Color'],normal.inputs['Color']); l.new(normal.outputs[0],bs.inputs['Normal'])
 bs.inputs['Roughness'].default_value=rough; bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,-20)); tile=bpy.context.object; tile.data.materials.append(mat)
 baked={}; emit=n.new('ShaderNodeEmission')
 for typ,res in [('base',size),('normal',min(size,512)),('orm',512)]:
  im=bpy.data.images.new(name+'_'+typ,width=res,height=res,alpha=False); im.colorspace_settings.name='sRGB' if typ=='base' else 'Non-Color'
  target=n.new('ShaderNodeTexImage'); target.image=im; n.active=target
  if typ=='normal':
   l.new(bs.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='NORMAL',margin=4)
  else:
   if typ=='base': l.new(mix.outputs[0],emit.inputs['Color'])
   else:
    sep=n.new('ShaderNodeSeparateColor'); l.new(arm.outputs['Color'],sep.inputs[0]); comb=n.new('ShaderNodeCombineColor'); comb.inputs[0].default_value=1; comb.inputs[1].default_value=rough; comb.inputs[2].default_value=metal
    # photographic roughness is restrained to military matte range
    mr=n.new('ShaderNodeMapRange'); mr.inputs['To Min'].default_value=max(.1,rough-.12); mr.inputs['To Max'].default_value=min(1,rough+.12); l.new(sep.outputs[1],mr.inputs[0]); l.new(mr.outputs[0],comb.inputs[1]); l.new(comb.outputs[0],emit.inputs['Color'])
   l.new(emit.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='EMIT',margin=4)
  im.filepath_raw=str(TEX/(name+'_'+typ+'.png')); im.file_format='JPEG' if typ=='base' else 'PNG'; im.filepath_raw=str(TEX/(name+'_'+typ+('.jpg' if typ=='base' else '.png'))); im.save(); baked[typ]=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])
 bt=n.new('ShaderNodeTexImage'); bt.image=baked['base']; bt.label='Cycles baked photographic neutral paint' if name=='Paint' else 'Cycles baked photographic tile'
 if name=='Paint':
  mul=n.new('ShaderNodeMix'); mul.data_type='RGBA'; mul.blend_type='MULTIPLY'; mul.inputs[0].default_value=1; mul.inputs[7].default_value=(*PAINTS[0][1],1); l.new(bt.outputs[0],mul.inputs[6]); l.new(mul.outputs[2],bs.inputs['Base Color']); groups['paint_factor']=mul
 else: l.new(bt.outputs[0],bs.inputs['Base Color'])
 nm=n.new('ShaderNodeTexImage'); nm.image=baked['normal']; normal=n.new('ShaderNodeNormalMap'); l.new(nm.outputs[0],normal.inputs['Color']); l.new(normal.outputs[0],bs.inputs['Normal'])
 orm=n.new('ShaderNodeTexImage'); orm.image=baked['orm']; sep=n.new('ShaderNodeSeparateColor'); l.new(orm.outputs[0],sep.inputs[0]); l.new(sep.outputs[1],bs.inputs['Roughness']); l.new(sep.outputs[2],bs.inputs['Metallic'])
 # glTF occlusion socket recognizer
 if 'glTF Material Output' not in bpy.data.node_groups:
  ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 g=n.new('ShaderNodeGroup'); g.node_tree=bpy.data.node_groups['glTF Material Output']; l.new(sep.outputs[0],g.inputs[0])
 MATS[name]=mat; print('BAKED',name,flush=True); return mat

# Preflight bakes and later materials share this tested route.
texture_material('Paint',(1,1,1),.08,.81,'rusty_metal_03',1024)
texture_material('Rubber',(.026,.028,.025),0,.9,'rusty_metal_04',512)
texture_material('TrackSteel',(.16,.15,.13),.78,.65,'rusty_metal_04',512)
texture_material('Glass',(.065,.12,.135),.65,.25,'metal_plate',256)
texture_material('Lamps',(.62,.63,.55),.18,.3,'metal_plate',256)
texture_material('Burnt',(.105,.095,.081),.52,.9,'rusty_metal_04',512)
texture_material('Scars',(.32,.31,.28),.8,.55,'rusty_metal_04',512)

class Mesh:
 def __init__(self): self.v=[]; self.f=[]; self.mi=[]; self.sm=[]; self.uv=[]
 def face(self,idx,mat='Paint',smooth=False,uv=None):
  self.f.append(idx); self.mi.append(list(MATS).index(mat)); self.sm.append(smooth); self.uv.append(uv)
 def box(self,c,d,mat='Paint',rot=None):
  i=len(self.v); pts=[Vector((sx*d[0]/2,sy*d[1]/2,sz*d[2]/2)) for sx,sy,sz 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: pts=[rot@p for p in pts]
  self.v.extend([tuple(p+Vector(c)) for p in pts])
  for face in [(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)]: self.face(tuple(i+j for j in face),mat)
 def loft(self,rings,mat='Paint'):
  i=len(self.v); n=len(rings[0]); self.v.extend([p for ring in rings for p in ring]); self.face(tuple(i+j for j in reversed(range(n))),mat)
  for k in range(len(rings)-1):
   for j in range(n): self.face((i+k*n+j,i+k*n+(j+1)%n,i+(k+1)*n+(j+1)%n,i+(k+1)*n+j),mat)
  self.face(tuple(i+(len(rings)-1)*n+j for j in range(n)),mat)
 def cyl(self,c,r,depth,mat='Paint',axis=(0,0,1),n=32,r2=None):
  ax=Vector(axis).normalized(); u=ax.cross(Vector((0,0,1)) if abs(ax.z)<.9 else Vector((0,1,0))).normalized(); v=ax.cross(u); start=len(self.v); r2=r if r2 is None else r2
  for h,rr in [(-depth/2,r),(depth/2,r2)]:
   self.v.extend([tuple(Vector(c)+ax*h+rr*(u*cos(2*pi*j/n)+v*sin(2*pi*j/n))) for j in range(n)])
  self.face(tuple(start+j for j in reversed(range(n))),mat)
  self.face(tuple(start+n+j for j in range(n)),mat)
  for j in range(n): self.face((start+j,start+(j+1)%n,start+n+(j+1)%n,start+n+j),mat,True,[(j/n,0),((j+1)/n,0),((j+1)/n,depth), (j/n,depth)])
 def tube(self,a,b,r,mat='Paint',n=12):
  a=Vector(a); b=Vector(b); self.cyl((a+b)/2,r,(b-a).length,mat,b-a,n)
 def ring(self,c,r,minor,mat='Paint',axis=(0,0,1),n=48,k=8):
  ax=Vector(axis).normalized(); u=ax.cross(Vector((0,0,1)) if abs(ax.z)<.9 else Vector((0,1,0))).normalized(); v=ax.cross(u); start=len(self.v)
  for j in range(n):
   radial=u*cos(j*2*pi/n)+v*sin(j*2*pi/n)
   for t in range(k): self.v.append(tuple(Vector(c)+radial*(r+minor*cos(t*2*pi/k))+ax*minor*sin(t*2*pi/k)))
  for j in range(n):
   for t in range(k): self.face((start+j*k+t,start+((j+1)%n)*k+t,start+((j+1)%n)*k+(t+1)%k,start+j*k+(t+1)%k),mat,True)
 def path(self,points,r,mat='Paint',n=8):
  for a,b in zip(points,points[1:]): self.tube(a,b,r,mat,n)
 def bolt(self,c,axis=(0,0,1),r=.018,mat='TrackSteel'): self.cyl(c,r,.014,mat,axis,6)
 def finish(self,name,origin=(0,0,0),parent=None,bevel=.009,seg=2,dest=healthy):
  me=bpy.data.meshes.new(name); me.from_pydata(self.v,[],self.f); me.update(); bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces)); bm.to_mesh(me); bm.free(); me.update(); ob=bpy.data.objects.new(name,me); sc.collection.objects.link(ob)
  for m in MATS.values(): me.materials.append(m)
  uv=me.uv_layers.new(name='UVMap')
  for p,ma,sm,custom in zip(me.polygons,self.mi,self.sm,self.uv):
   p.material_index=ma;p.use_smooth=sm
   # metre-scaled box projection: 1.4m photographic tiles
   normal=p.normal; drop=max(range(3),key=lambda j:abs(normal[j])); axes=[j for j in range(3) if j!=drop]
   for k,li in enumerate(p.loop_indices):
    co=me.vertices[me.loops[li].vertex_index].co
    uv.data[li].uv=custom[k] if custom else (co[axes[0]]/1.4,co[axes[1]]/1.4)
  bpy.context.view_layer.objects.active=ob; ob.select_set(True)
  if bevel:
   mod=ob.modifiers.new('Machined edge radii','BEVEL');mod.width=bevel;mod.segments=seg;mod.limit_method='ANGLE'
   bpy.ops.object.modifier_apply(modifier=mod.name)
  # set pivot by moving mesh data, never applying object transforms to moving nodes
  ob.data.transform(Matrix.Translation(-Vector(origin)));ob.location=origin
  if parent:
   bpy.context.view_layer.update(); ob.parent=parent; ob.matrix_parent_inverse=Matrix.Identity(4); ob.location=Vector(origin)-parent.matrix_world.translation
  ob.select_set(False);dest.append(ob);return ob

def empty(name,loc,parent=None,dest=healthy):
 ob=bpy.data.objects.new(name,None);sc.collection.objects.link(ob);ob.location=loc
 if parent:
  bpy.context.view_layer.update(); ob.parent=parent; ob.matrix_parent_inverse=Matrix.Identity(4); ob.location=Vector(loc)-parent.matrix_world.translation
 dest.append(ob);return ob

def cut_cylinder(ob,c,r,dep):
 bpy.ops.mesh.primitive_cylinder_add(vertices=48,radius=r,depth=dep,location=c); cutter=bpy.context.object
 bpy.context.view_layer.objects.active=ob; mod=ob.modifiers.new('Real access well','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter
 bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cutter,do_unlink=True)

def hull_ring(y,w,zlo,zhi): return [(-w,y,zlo),(w,y,zlo),(w,y,zhi),(-w,y,zhi)]
# Hull welded tub: inclined lower glacis and multi-break upper front.
m=Mesh();m.loft([hull_ring(-4.165,1.28,.82,1.02),hull_ring(-3.48,1.43,.43,1.40),hull_ring(-1.9,1.45,.42,1.52),hull_ring(3.45,1.42,.43,1.48),hull_ring(4.165,1.32,.65,.91)])
# suspension arms, complete skirt backing, return rail and mounting bosses
for s in [-1,1]:
 m.box((s*1.43,0,1.23),(.08,7.1,.31))
 for y in np.linspace(-2.68,2.67,6):
  m.tube((s*1.16,y-.2,.85),(s*1.53,y,.55),.075,'TrackSteel',12)
  m.cyl((s*1.43,y,1.28),.058,.08,axis=(1,0,0),n=12)
  m.bolt((s*1.483,y,1.28),(1,0,0),.028)
 # tow rope mounting keepers remain when cable removed
 for y in [-3,-2,-1,0,1,2,3]: m.box((s*1.49,y,1.46),(.12,.085,.15))
# seams on upper glacis and deck
for x in [-1.08,1.08]:m.path([(x,-3.65,1.30),(x,-2.05,1.53),(x,3.42,1.49)],.008,'TrackSteel',6)
for x in [-.99,.99]:
 m.box((x,-4.16,.88),(.16,.17,.15));m.ring((x,-4.26,.84),.095,.028,'TrackSteel',axis=(0,1,0),n=20,k=6)
 m.box((x,3.86,.82),(.16,.11,.2));m.ring((x,3.95,.72),.09,.022,'TrackSteel',axis=(0,1,0),n=20,k=6)
hull=m.finish('Hull',bevel=.025)
cut_cylinder(hull,(0,-2.65,1.55),.30,1.0)
cut_cylinder(hull,(0,-.2,1.5),1.01,1.05)
# Dark walls and bottoms under the access openings; separate geometry joined to hull.
m=Mesh()
for c,r in [((0,-2.65,1.16),.295),((0,-.2,.9),1.0)]:
 m.cyl(c,r,.08,'Rubber');m.ring((c[0],c[1],c[2]+.12),r-.03,.04,'TrackSteel')
inner=m.finish('_interior',bevel=0)
# join preserving Hull pivot

def join_into(target,objects):
 bpy.ops.object.select_all(action='DESELECT');target.select_set(True)
 for ob in objects:ob.select_set(True)
 bpy.context.view_layer.objects.active=target;bpy.ops.object.join()
 for ob in objects:
  if ob in healthy:healthy.remove(ob)
 return target
join_into(hull,[inner])
# Tracks: capsule perimeter, individual padded links, V = fractional loop distance.
TRACK_N=102
# Raised end wheels and sloping approaches, with a straight grounded lower run.
# Sample four cubic curves at uniform arc length so belt UVs and link spacing agree.
def bez(p0,p1,p2,p3,t):
 return (1-t)**3*np.array(p0)+3*(1-t)**2*t*np.array(p1)+3*(1-t)*t*t*np.array(p2)+t**3*np.array(p3)
segments=[((-2.63,.084),(-1,.084),(1,.084),(2.63,.084)),
 ((2.63,.084),(3.02,.084),(3.74,.40),(3.74,.78)),
 ((3.74,.78),(3.74,1.01),(3.57,1.18),(3.34,1.18)),
 ((3.34,1.18),(1,1.18),(-1,1.18),(-3.34,1.18)),
 ((-3.34,1.18),(-3.57,1.18),(-3.74,1.01),(-3.74,.78)),
 ((-3.74,.78),(-3.74,.40),(-3.02,.084),(-2.63,.084))]
path_samples=np.array([bez(*seg,t) for seg in segments for t in np.linspace(0,1,121)[:-1]]+[np.array((-2.63,.084))])
path_lengths=np.r_[0,np.cumsum(np.linalg.norm(np.diff(path_samples,axis=0),axis=1))];PER=float(path_lengths[-1])
def track_point(t):
 d=(t%1)*PER;i=min(len(path_samples)-2,int(np.searchsorted(path_lengths,d,side='right')-1));f=(d-path_lengths[i])/(path_lengths[i+1]-path_lengths[i]);v=path_samples[i+1]-path_samples[i];p=path_samples[i]+f*v
 return (float(p[0]),float(p[1]),math.atan2(v[1],v[0]))

def track(s,broken=False,name=None,dest=healthy,parent=None):
 m=Mesh();pitch=PER/TRACK_N
 for j in range(TRACK_N):
  t=j/TRACK_N;y,z,ang=track_point(t); lateral=0
  if broken and j>int(TRACK_N*.48):
   # Thrown upper run rolls outward off the wheel plane, feeding a grounded loose tail.
   lateral=min(.43,(j-TRACK_N*.48)*.04)
   if j<82: z-=.25+ .10*sin(j*.4)
   else: y=-3.4-(j-82)*pitch*.84;z=.0836;ang=0
  rot=Matrix.Rotation(ang,3,'X')
  start=len(m.f)
  m.box((s*(1.455+lateral),y,z),(.55,pitch*.91,.11),'TrackSteel',rot)
  m.box((s*(1.455+lateral),y,z-.065*cos(ang)),(.40,pitch*.69,.035),'Rubber',rot)
  # raised end connectors catch daylight; guide teeth face inward to belt
  for x in [s*(1.455+lateral)-.25,s*(1.455+lateral)+.25]:m.box((x,y,z),(.064,pitch*1.04,.135),'TrackSteel',rot)
  m.box((s*(1.455+lateral),y-.065*sin(ang),z+.065*cos(ang)),(.065,pitch*.48,.12),'TrackSteel',rot)
  for fidx in range(start,len(m.f)):
   m.uv[fidx]=[(0,t),(1,t),(1,t+1/TRACK_N),(0,t+1/TRACK_N)]
 return m.finish(name or ('Track_L' if s==1 else 'Track_R'),parent=parent,bevel=.006,seg=1,dest=dest)
for s,side in [(1,'L'),(-1,'R')]:
 track(s)
 # Front idler first; six road wheels; rear toothed drive sprocket; then return rollers.
 specs=[(-3.34,.78,.35,'idler')]+[(float(y),.55,.475,'road') for y in np.linspace(-2.63,2.63,6)]+[(3.34,.78,.365,'drive')]+[(y,1.02,.16,'return') for y in [-2.1,0,2.1]]
 for idx,(y,z,r,kind) in enumerate(sorted(specs,key=lambda a:a[0]),1):
  m=Mesh();x=s*1.48
  m.cyl((x,y,z),r,.36,'Rubber' if kind=='road' else 'TrackSteel',axis=(1,0,0),n=48)
  for sideface in [-1,1]:
   xx=x+sideface*.185
   m.cyl((xx,y,z),r*.86,.035,axis=(sideface,0,0),n=48,r2=r*.81)
   m.cyl((xx+sideface*.006,y,z),r*.71,.078,axis=(sideface,0,0),n=48,r2=r*.53)
   m.ring((xx+sideface*.016,y,z),r*.62,.013,'Paint',axis=(1,0,0),n=40,k=6)
   m.ring((xx+sideface*.025,y,z),r*.80,.016,'TrackSteel',axis=(1,0,0),n=48,k=6)
   m.cyl((xx+sideface*.026,y,z),r*.34,.135,axis=(sideface,0,0),n=32,r2=r*.26)
   m.cyl((xx+sideface*.085,y,z),r*.19,.026,'TrackSteel',axis=(1,0,0),n=24)
   for a in np.linspace(0,2*pi,10,endpoint=False):m.bolt((xx+sideface*.063,y+r*.49*cos(a),z+r*.49*sin(a)),(1,0,0),.022 if kind!='return' else .012)
  if kind=='drive':
   for a in np.linspace(0,2*pi,20,endpoint=False):m.box((x,y+r*cos(a),z+r*sin(a)),(.38,.09,.085),'TrackSteel',Matrix.Rotation(a,3,'X'))
  ob=m.finish(f'Wheel_{side}{idx}',(x,y,z),bevel=.005,seg=1);ob['role']=kind
 # Six independently removable polygon skirt slabs, with backing and attachment hardware.
 for i in range(6):
  yc=-2.96+i*1.16; xx=s*1.715; m=Mesh()
  bottom=.91 + (.10 if i in [0,5] else 0)
  m.loft([[(xx-.043,yc-.57,1.44),(xx+.043,yc-.57,1.44),(xx+.043,yc-.57,bottom+.05),(xx-.043,yc-.57,bottom+.05)],[(xx-.043,yc+.57,1.44),(xx+.043,yc+.57,1.44),(xx+.043,yc+.57,bottom),(xx-.043,yc+.57,bottom)]])
  for yy in [yc-.44,yc+.44]:
   m.box((s*1.72,yy,1.395),(.035,.10,.055));m.bolt((s*1.747,yy,1.40),(1,0,0),.014)
  m.finish(f'Part_Skirt_{side}{i+1}',(xx,yc,1.2),bevel=.012)
 # four fender corners
 for yc,tag in [(-3.76,'F'),(3.55,'R')]:
  m=Mesh();m.box((s*1.43,yc,1.35),(.65,.70,.08));m.box((s*1.44,yc+(-.3 if tag=='F' else .3),1.20),(.65,.10,.32),'Rubber',Matrix.Rotation(-.18 if tag=='F' else .18,3,'X'))
  m.finish(f'Part_Fender_{tag}{side}',(s*1.43,yc,1.27),bevel=.023)
 # cable follows skirt top, braided geometric strands
 m=Mesh()
 for strand in range(3):
  pts=[(s*(1.55+.012*cos(i*.8+strand*2*pi/3)),float(y),1.49+.014*sin(i*.8+strand*2*pi/3)-.035*sin(i*pi/25)**2) for i,y in enumerate(np.linspace(-3.0,3.1,50))]
  m.path(pts,.008,'TrackSteel',6)
 for y in [-3.04,3.15]:m.ring((s*1.55,y,1.49),.07,.016,'TrackSteel',n=20,k=6)
 m.finish(f'Part_TowCable_{side}',(s*1.55,0,1.49),bevel=0)
 # headlamp housings, guard frames as separate removable attachments
 m=Mesh();m.cyl((s*1.08,-3.60,1.42),.088,.10,'TrackSteel',axis=(0,1,0),n=24);m.cyl((s*1.08,-3.659,1.42),.065,.009,'Lamps',axis=(0,1,0),n=24)
 lamp=m.finish(f'Part_Headlamp_{side}',(s*1.08,-3.61,1.42),bevel=.006)
 m=Mesh();m.path([(s*1.08-.12,-3.68,1.36),(s*1.08-.12,-3.68,1.56),(s*1.08+.12,-3.68,1.56),(s*1.08+.12,-3.68,1.36)],.012,'Paint',8)
 m.finish(f'Part_LampGuard_{side}',(s*1.08,-3.68,1.46),bevel=0)
 empty(f'Light_Head_{side}',(s*1.08,-3.67,1.42))
 # aft stowage/tool chests
 m=Mesh();m.box((s*1.16,2.5,1.52),(.45,1.45,.20));m.box((s*1.16,2.5,1.63),(.47,1.48,.035))
 for y in [2,3]:m.box((s*1.40,y,1.55),(.025,.12,.08),'TrackSteel')
 m.finish(f'Part_Stowage_{side}',(s*1.16,2.5,1.55),bevel=.014)
# rear fuel drums, axes longitudinal. Mounted in cradles below the engine deck.
for s,side in [(1,'L'),(-1,'R')]:
 m=Mesh();c=(s*.80,3.78,1.22)
 m.cyl(c,.31,.68,axis=(0,1,0),n=48)
 for y in [3.46,3.60,3.96,4.10]:m.ring((s*.80,y,1.22),.306,.018,'TrackSteel' if y in [3.60,3.96] else 'Paint',axis=(0,1,0),n=48,k=6)
 m.cyl((s*.80,4.128,1.22),.29,.019,axis=(0,1,0),n=48);m.cyl((s*.80+.16,4.145,1.34),.028,.018,'TrackSteel',axis=(0,1,0),n=12)
 for y in [3.6,3.96]:m.box((s*.80,y,.96),(.61,.07,.07),'TrackSteel')
 m.finish(f'Part_FuelDrum_{side}',c,bevel=.006)
# engine radiator panels separate; dark full deck underneath
for s,side in [(1,'L'),(-1,'R')]:
 m=Mesh();m.box((s*.45,2.66,1.506),(.78,1.44,.035),'Rubber')
 for y in np.linspace(2.0,3.31,19):m.box((s*.45,y,1.548),(.75,.036,.055),'Paint',Matrix.Rotation(.25,3,'X'))
 for x in [s*.45-.40,s*.45+.40]:m.box((x,2.66,1.54),(.035,1.46,.07))
 m.finish(f'Part_EngineGrille_{side}',(s*.45,2.66,1.54),bevel=.005,seg=1)
m=Mesh();m.box((-1.40,3.20,1.28),(.25,.62,.30),'TrackSteel')
for y in np.linspace(2.94,3.46,8):m.box((-1.548,y,1.30),(.06,.025,.23))
m.finish('Part_ExhaustShroud',(-1.44,3.2,1.29),bevel=.014)
# front spare link set
m=Mesh()
for x in [-.60,-.30,.30,.60]:
 m.box((x,-3.70,1.26),(.25,.19,.045),'TrackSteel',Matrix.Rotation(.18,3,'X'));m.box((x,-3.70,1.289),(.20,.12,.025),'Rubber')
m.finish('Part_SpareLinks',(0,-3.70,1.27),bevel=.008,seg=1)
# turret layered form: smooth rounded nose corners, long flat sides and bustle.
def turret_ring(z,front,back,width,frontw):
 return [(-frontw,front,z),(frontw,front,z),(width,front+.50,z),(width,back-.18,z),(width-.20,back,z),(-width+.20,back,z),(-width,back-.18,z),(-width,front+.50,z)]
m=Mesh();m.cyl((0,-.20,1.53),1.07,.14,'TrackSteel',n=64)
m.loft([turret_ring(1.58,-2.45,2.18,1.48,.87),turret_ring(1.81,-2.39,2.20,1.49,.91),turret_ring(2.24,-1.95,2.10,1.37,.93),turret_ring(2.29,-1.70,1.70,1.16,.91)])
turret=m.finish('Turret',(0,-.20,1.54),bevel=.065,seg=3)
for x,y,r in [(.62,.06,.33),(-.60,.35,.30)]:cut_cylinder(turret,(x,y,2.25),r,.8)
# cupola coamings detachable; underlying roof opening has a dark deep tub.
for x,y,r,role in [(.62,.06,.33,'Commander'),(-.60,.35,.30,'Loader')]:
 m=Mesh();m.cyl((x,y,1.92),r-.014,.035,'Rubber');m.ring((x,y,2.25),r,.028,'TrackSteel')
 lining=m.finish('_lining'+role,parent=turret,bevel=0);join_into(turret,[lining])
 m=Mesh();m.ring((x,y,2.31),r+.035,.045,'Paint',n=48,k=8)
 if role=='Commander':
  for a in np.linspace(0,2*pi,8,endpoint=False):
   xx=x+(r+.035)*cos(a);yy=y+(r+.035)*sin(a);m.box((xx,yy,2.345),(.10,.07,.055),'Glass',Matrix.Rotation(a,3,'Z'))
 m.finish('Part_Turret_'+role+'Ring',(x,y,2.30),turret,bevel=.006)
 m=Mesh();m.cyl((x,y,2.35),r-.015,.045,n=48);m.ring((x,y,2.37),r-.035,.01,'TrackSteel',n=40,k=6)
 m.path([(x-.09,y+.02,2.376),(x-.09,y+.02,2.405),(x+.09,y+.02,2.405),(x+.09,y+.02,2.376)],.012,'Paint')
 for xx in [x-.14,x+.14]:m.cyl((xx,y+.29,2.35),.035,.11,'TrackSteel',axis=(1,0,0),n=12)
 m.finish('Hatch_'+role,(x,y+.29,2.35),turret,bevel=.006)
 empty('Seat_'+role,(x,y,2.49),turret)
# driver's oval hatch under turret front overhang
m=Mesh();m.cyl((0,-2.65,1.492),.284,.07,n=48);m.box((0,-2.80,1.54),(.29,.10,.07));m.box((0,-2.862,1.55),(.23,.014,.035),'Glass')
m.finish('Hatch_Driver',(0,-2.37,1.49),bevel=.008)
empty('Seat_Driver',(0,-2.65,1.72))
# Turret bustle bins, doors with formed ribs and catches
for s,side in [(1,'L'),(-1,'R')]:
 m=Mesh();m.box((s*1.43,1.20,1.98),(.22,1.63,.54));
 for yy in [.47,1.19,1.94]:m.box((s*1.556,yy,1.98),(.025,.023,.51),'TrackSteel')
 for yy in [.82,1.60]:
  m.path([(s*1.572,yy-.22,1.81),(s*1.572,yy+.22,2.15)],.017,'Paint');m.path([(s*1.574,yy-.22,2.15),(s*1.574,yy+.22,1.81)],.017,'Paint');m.box((s*1.57,yy,2.19),(.035,.16,.045),'TrackSteel')
 m.finish(f'Part_Turret_Bin_{side}',(s*1.45,1.20,1.98),turret,bevel=.018)
 # rectangular louvre assembly on cheeks; actual bars and deep opening frame
 m=Mesh();m.box((s*1.50,-.22,1.99),(.028,.77,.48),'Rubber')
 for y in [-.62,.18]:m.box((s*1.535,y,1.99),(.05,.035,.54))
 for z in [1.73,2.25]:m.box((s*1.535,-.22,z),(.05,.83,.035))
 for z in np.linspace(1.79,2.19,5):m.box((s*1.55,-.22,z),(.062,.76,.035),'Paint',Matrix.Rotation(s*.22,3,'Y'))
 m.finish(f'Part_Turret_Louvre_{side}',(s*1.52,-.22,2),turret,bevel=.006)
 # angled smoke discharge cluster at front cheek
 m=Mesh();m.box((s*1.02,-2.09,1.84),(.31,.11,.14),'TrackSteel',Matrix.Rotation(.32,3,'X'))
 for j in range(5):
  a=(s*(.90+j*.062),-2.12,1.88+(j%2)*.06);b=(a[0]+s*.035,-2.29,2.01+(j%2)*.06)
  m.tube(a,b,.041,'Paint',16);m.cyl(b,.032,.009,'Rubber',axis=Vector(b)-Vector(a),n=16)
 m.finish(f'Part_Turret_Smoke_{side}',(s*1.03,-2.18,1.95),turret,bevel=.004)
 # long radio aerials with sprung bases
 m=Mesh();m.cyl((s*1.00,1.15,2.27),.045,.12,'Rubber',n=20);m.tube((s*1,1.15,2.33),(s*1.025,1.18,3.12),.007,'TrackSteel',8)
 m.finish(f'Part_Turret_Antenna_{side}',(s*1,1.15,2.60),turret,bevel=0)
# bustle basket curved rail and slatted base
m=Mesh()
for z in [1.68,2.02]:m.path([(-1.23,2.10,z),(-1.23,2.36,z),(1.23,2.36,z),(1.23,2.10,z)],.019,'TrackSteel')
for x in np.linspace(-1.21,1.21,12):m.tube((x,2.34,1.69),(x,2.34,2.02),.012,'TrackSteel');m.tube((x,2.11,1.69),(x,2.34,1.69),.012,'TrackSteel')
m.finish('Part_Turret_BustleRack',(0,2.25,1.85),turret,bevel=0)
# The sheet's commander's panoramic sight, an armoured rotating head beside cupola.
m=Mesh();m.cyl((.30,-.75,2.30),.11,.10,'TrackSteel',n=32);m.box((.30,-.75,2.415),(.24,.28,.16));m.box((.30,-.905,2.424),(.17,.035,.105),'Rubber');m.box((.30,-.927,2.424),(.12,.012,.074),'Glass')
m.finish('Part_Turret_PanoramicSight',(.30,-.75,2.39),turret,bevel=.018)
m=Mesh();m.box((-.42,-2.10,2.23),(.42,.62,.32));m.box((-.42,-2.425,2.25),(.29,.025,.20),'TrackSteel');m.box((-.42,-2.442,2.25),(.21,.012,.13),'Glass');m.box((-.42,-2.10,2.405),(.46,.65,.035));m.box((-.42,-2.448,2.14),(.32,.025,.055));m.bolt((-.60,-2.45,2.30),(0,1,0),.018);m.bolt((-.24,-2.45,2.30),(0,1,0),.018)
m.finish('Part_Turret_GunnerSight',(-.42,-2.10,2.25),turret,bevel=.015)
# Trunnion/mantlet separate from recoil tube.
m=Mesh();m.loft([[(-.29,-2.45,1.72),(.29,-2.45,1.72),(.29,-2.45,2.17),(-.29,-2.45,2.17)],[(-.22,-2.66,1.79),(.22,-2.66,1.79),(.22,-2.66,2.10),(-.22,-2.66,2.10)]])
m.cyl((0,-2.62,1.945),.20,.25,'Rubber',axis=(0,1,0),n=48)
for x in [-.23,.23]:m.cyl((x,-2.34,1.945),.13,.06,'TrackSteel',axis=(1,0,0),n=24)
gun=m.finish('Gun',(0,-2.36,1.945),turret,bevel=.032,seg=3)
m=Mesh()
# Continuous bore core and segmented thermal jacket, prominent extractor around forward middle.
m.cyl((0,-4.94,1.945),.068,4.83,'TrackSteel',axis=(0,1,0),n=48)
for a,b,r in [(-2.76,-3.15,.145),(-3.15,-3.65,.104),(-3.65,-4.21,.112),(-4.21,-4.95,.113),(-4.95,-5.58,.165),(-5.58,-6.22,.108),(-6.22,-6.85,.10),(-6.85,-7.18,.094)]:
 m.cyl((0,(a+b)/2,1.945),r,abs(b-a),axis=(0,1,0),n=48)
 for yy in [a+.014,b-.014]:m.ring((0,yy,1.945),r+.004,.010,'TrackSteel',axis=(0,1,0),n=40,k=6)
for y in np.linspace(-2.78,-3.13,13):m.ring((0,y,1.945),.148,.007,'Rubber',axis=(0,1,0),n=40,k=6)
m.box((0,-7.23,2.063),(.105,.23,.075),'TrackSteel');m.box((0,-7.34,2.063),(.07,.015,.04),'Glass')
# open muzzle, dark rifled bore inset, no solid cylinder cap at the tip
m.ring((0,-7.361,1.945),.079,.024,'TrackSteel',axis=(0,1,0),n=48,k=10)
m.cyl((0,-7.19,1.945),.057,.008,'Rubber',axis=(0,1,0),n=32)
for a in np.linspace(0,2*pi,12,endpoint=False):m.tube((.058*cos(a),-7.35,1.945+.058*sin(a)),(.058*cos(a+.1),-7.20,1.945+.058*sin(a+.1)),.003,'TrackSteel',6)
barrel=m.finish('Barrel',(0,-2.74,1.945),gun,bevel=.004,seg=1)
empty('Muzzle',(0,-7.385,1.945),barrel)
# Loader's roof L37-style MG with receiver, perforated jacket and pintle.
m=Mesh();m.cyl((-.64,.68,2.34),.035,.22,'TrackSteel',n=16);m.box((-.64,.55,2.46),(.12,.36,.105),'TrackSteel');m.tube((-.64,.37,2.46),(-.64,-.27,2.46),.024,'TrackSteel',20)
for y in np.linspace(-.15,.30,8):m.ring((-.64,y,2.46),.026,.006,'Rubber',axis=(0,1,0),n=12,k=4)
m.box((-.78,.56,2.43),(.17,.21,.14),'TrackSteel');m.path([(-.70,.78,2.45),(-.70,.88,2.43),(-.58,.88,2.43),(-.58,.78,2.45)],.012,'Rubber')
m.finish('MG',(-.64,.68,2.34),turret,bevel=.005)
# pioneer tool rack; real spade blade and pick-head silhouette
m=Mesh();m.tube((1.03,1.64,1.59),(1.03,3.14,1.59),.023,'TrackSteel');m.box((1.03,3.15,1.59),(.24,.32,.028),'TrackSteel');m.tube((1.25,1.65,1.61),(1.25,2.90,1.61),.023,'TrackSteel');m.tube((1.04,1.74,1.63),(1.45,1.74,1.63),.025,'TrackSteel')
for y in [2,2.7]:m.box((1.15,y,1.64),(.34,.045,.045))
m.finish('Part_Tools',(1.15,2.40,1.61),bevel=.005)
for name,loc in [('Exhaust',(-1.55,3.20,1.30)),('Fx_Engine',(0,2.65,1.54)),('Fx_Ammo',(0,-.35,.90))]:empty(name,loc)
empty('Fx_Hatch',(.62,.06,2.40),turret)
# Resolve density before export. Collapse fine bevel rings while retaining hard silhouette.
for ob in healthy:
 if ob.type=='MESH' and len(ob.data.polygons)>100:
  bpy.context.view_layer.objects.active=ob
  mod=ob.modifiers.new('Game density','DECIMATE');mod.ratio=.35
  bpy.ops.object.modifier_apply(modifier=mod.name)
# Fasteners and support bosses under detachable assemblies are retained by the core mesh.
for core in [hull,turret]:
 mount=Mesh()
 for part in [o for o in healthy if o.name.startswith('Part_') and (o.parent==turret)==(core==turret)]:
  bpy.context.view_layer.update();c=part.matrix_world.translation.copy()
  if core==turret:
   if abs(c.x)>1.3:
    for dy in [-.20,.20]:
     mount.box((math.copysign(1.39,c.x),c.y+dy,min(2.14,c.z)),(.045,.09,.075),'Paint')
     mount.bolt((math.copysign(1.42,c.x),c.y+dy,min(2.14,c.z)),(1,0,0),.016)
   elif c.z>2.25:
    mount.cyl((c.x,c.y,2.292),.07,.035,'Paint',n=16)
  elif 'FuelDrum' in part.name:
   for dx in [-.16,.16]:mount.box((c.x+dx,3.85,1.03),(.07,.22,.16),'TrackSteel')
  elif 'Skirt' in part.name:
   for dy in [-.35,.35]:mount.tube((math.copysign(1.43,c.x),c.y+dy,1.38),(math.copysign(1.68,c.x),c.y+dy,1.38),.024,'TrackSteel',8)
  elif 'EngineGrille' in part.name:
   for yy in [c.y-.67,c.y+.67]:mount.box((c.x,yy,1.50),(.74,.025,.025),'TrackSteel')
 if mount.v:
  supports=mount.finish('_PermanentMounts',bevel=.004,seg=1);join_into(core,[supports])
# Detachable debris pivots are exact bounding-box centres; articulated pivots stay untouched.
bpy.context.view_layer.update()
for ob in healthy:
 if ob.type=='MESH' and ob.name.startswith('Part_'):
  centre=sum((Vector(v) for v in ob.bound_box),Vector())/8
  world=ob.matrix_world.copy();ob.data.transform(Matrix.Translation(-centre));ob.matrix_world=world@Matrix.Translation(centre)
bpy.context.view_layer.update()
print('GEOMETRY HEALTHY',len(healthy),flush=True)

# Export utility and contract manifest. All modifiers already resolved, identities at pivots.
def tris(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,obs):
 # Final evaluated topology gate: triangulate and remove zero-area slivers from bevel/decimation.
 for ob in obs:
  if ob.type!='MESH':continue
  bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
  bad=[f for f in bm.faces if f.calc_area()<5e-13]
  if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
  bm.to_mesh(ob.data);bm.free();ob.data.update()
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.hide_set(False);o.hide_render=False;o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(ROOT/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)
 print('EXPORTED',path,tris(obs),(ROOT/path).stat().st_size,flush=True)
export('challenger2-tank.glb',healthy)
# A small exported scene has now exercised bake + GPU + actual glTF writer.

# DAMAGE: copies of exact healthy meshes, keeping source origins.
def copy_obj(src,name,parent=None,dest=damage):
 bpy.context.view_layer.update(); world=src.matrix_world.copy(); ob=src.copy();ob.data=src.data.copy() if src.type=='MESH' else None;sc.collection.objects.link(ob);ob.name=name;ob.parent=parent;ob.matrix_parent_inverse=Matrix.Identity(4);ob.matrix_world=world
 dest.append(ob);return ob
hd=empty('Hull_Damage',(0,0,0),dest=damage);td=empty('Turret_Damage',tuple(turret.location),dest=damage)
parts=[o for o in healthy if o.name.startswith('Part_')];twins={}
chosen=[o for o in parts if o.name.startswith('Part_Skirt_') and o.name[-1] in '1234']+[o for o in parts if o.name in ['Part_FuelDrum_L','Part_Fender_FL','Part_EngineGrille_L','Part_Turret_Bin_L','Part_Turret_Bin_R','Part_Turret_Louvre_L','Part_Turret_BustleRack']]
for i,src in enumerate(chosen):
 ob=copy_obj(src,src.name.replace('Part_','Dmg_',1),td if src.parent==turret else hd);twins[src.name]=ob.name
 # bend around attachment line; tears are deleted localized faces, with exposed thickness already in source slabs
 for v in ob.data.vertices:
  p=v.co
  if 'Skirt' in src.name:
   side=1 if '_L' in src.name else -1;fall=max(0,.15-p.z);p.x+=side*fall*(.42+.22*sin(p.y*4+i));p.z-=max(0,p.y)*.13;p.y+=.055*sin(p.z*19+i)
  elif 'Drum' in src.name:p.x*=.80+.13*sin(p.y*9);p.z+=.055*cos(p.y*11)
  else:p.z+=.085*sin(p.x*8+p.y*3+i);p.x+=.06*sin(p.z*5+i)
 # material scorched subset is Burnt, the rest retains recolour contract
 for poly in ob.data.polygons:
  if random.random()<.29:poly.material_index=list(MATS).index('Burnt')
 # tear a triangular notch on skirt bottom (thin plate edge geometry is retained around it)
 if 'Skirt' in src.name:
  bm=bmesh.new();bm.from_mesh(ob.data);faces=[f for f in bm.faces if f.calc_center_median().z<-.12 and abs(f.calc_center_median().y-.17)<.13]
  bmesh.ops.delete(bm,geom=faces,context='FACES');bm.to_mesh(ob.data);bm.free()
# Impact clusters: soot halos, bright irregular petal rims and recessed black centre on each armour face.
scar_names=[]
def scars(name,positions,normal,parent):
 m=Mesh();normal=Vector(normal);u=normal.cross(Vector((0,0,1)) if abs(normal.z)<.9 else Vector((0,1,0))).normalized();v=normal.cross(u)
 for j,pos in enumerate(positions):
  c=Vector(pos);rad=.055 if j else .082
  surface=turret if 'Turret' in name else hull
  bpy.context.view_layer.update();inv=surface.matrix_world.inverted()
  hit,loc,norm,_=surface.ray_cast(inv@(c+normal*2),inv.to_3x3()@(-normal))
  if hit:
   c=surface.matrix_world@loc; normal=(surface.matrix_world.to_3x3()@norm).normalized()
   u=normal.cross(Vector((0,0,1)) if abs(normal.z)<.9 else Vector((0,1,0))).normalized();v=normal.cross(u)
  # irregular flat concentric rings simulate soot fan, hugging face 2-4 mm above
  for k,(rr,mat,off) in enumerate([(rad*1.7,'Burnt',.002),(rad,'Scars',.003),(rad*.50,'Rubber',.004)]):
   start=len(m.v);m.v.append(tuple(c+normal*off))
   for t in range(18):
    a=t*2*pi/18;r=rr*(1+random.uniform(-.18,.18));m.v.append(tuple(c+normal*off+(u*cos(a)+v*sin(a))*r))
   for t in range(18):m.face((start,start+1+t,start+1+(t+1)%18),mat)
  if j==0:
   for t in range(9):
    a=t*2*pi/9;radial=u*cos(a)+v*sin(a);tangent=-u*sin(a)+v*cos(a);p=c+radial*rad*.65+normal*.004
    start=len(m.v);m.v.extend([tuple(p-tangent*.019),tuple(p+tangent*.019),tuple(p+radial*.039+normal*.038)]);m.face((start,start+1,start+2),'Scars')
  else:
   # thin ricochet steel furrow
   m.tube(c+u*.035+normal*.004,c+u*.19+v*.045+normal*.004,.006,'Scars',6)
 ob=m.finish(name,origin=tuple(parent.location),parent=parent,bevel=0,dest=damage);scar_names.append(name)
scars('Scar_Hull_Front',[(-.65,-3.96,1.068),(0,-3.96,1.068),(.52,-3.96,1.068),(.9,-3.96,1.068)],(0,-.4,.92),hd)
for s,side in [(1,'L'),(-1,'R')]:scars('Scar_Hull_'+side,[(s*1.4556,y,1.29) for y in [-2.2,-.8,.7,2.0]],(s,0,0),hd)
scars('Scar_Hull_Rear',[(x,3.909,1.17) for x in [-.85,-.30,.30,.88]],(0,1,0),hd)
scars('Scar_Turret_Front',[(x,-2.28,1.92) for x in [-.76,-.48,.48,.76]],(0,-.8,.6),td)
for s,side in [(1,'L'),(-1,'R')]:scars('Scar_Turret_'+side,[(s*1.456,y,1.94) for y in [-1.45,-.7,.1,.9]],(s,0,.25),td)
scars('Scar_Turret_Rear',[(x,2.179,1.95) for x in [-.88,-.33,.33,.88]],(0,1,.2),td)
track(1,True,'Track_L_Broken',damage,hd);track(-1,True,'Track_R_Broken',damage,hd)
# Bent, burst barrel built from the exact healthy barrel mesh, cut at middle.
gb=copy_obj(gun,'Gun_Broken',td);bc=copy_obj(barrel,'_burst',None)
for v in bc.data.vertices:
 world=bc.matrix_world@v.co
 if world.y<-4.6:
  dist=-4.6-world.y;world.z-=dist*.23;world.x+=.05*sin(dist*2)
  if world.y<-5.0:world.y=-5.0+(world.y+5.0)*.08;world.x+=.16*sin(v.co.x*45);world.z+=.12*cos(v.co.z*35)
  v.co=bc.matrix_world.inverted()@world
join_into(gb,[bc]);damage.remove(bc)
# Wreck assemblies are reduced copies of healthy geometry, excluding a number of lost accessories.
def wreck(name,source,origin,isturret):
 copies=[]
 for src in source:
  if src.type!='MESH':continue
  if src.name.startswith('Part_Skirt_') and src.name[-1] in '123':continue
  if any(t in src.name for t in ['FuelDrum_R','Antenna','Louvre','PanoramicSight','MG']):continue
  wreck_source=next(o for o in damage if o.name=='Track_L_Broken') if src.name=='Track_L' else src
  ob=copy_obj(wreck_source,'_wreck_'+src.name,None,dest=[])
  world=ob.matrix_world.copy()
  if src.name.startswith('Wheel_') or src.name.startswith('Track_'):
   bm=bmesh.new();bm.from_mesh(ob.data)
   burnt_rubber=[f for f in bm.faces if ob.data.materials[f.material_index].name=='Rubber']
   bmesh.ops.delete(bm,geom=burnt_rubber,context='FACES');bm.to_mesh(ob.data);bm.free()
  for vert in ob.data.vertices:
   p=world@vert.co
   if not isturret:
    if src.name.startswith('Wheel'):p.z-=.05
    if src.name.startswith('Part_Skirt_') and src.name.endswith('4'):
     p.x+=math.copysign(.22,p.x)*max(0,1.5-p.z);p.z-=.09
    if src.name.startswith('Track') and p.z>.8:p.z-=.15+.07*sin(p.y*3)
    if 'EngineGrille' in src.name:p.z+=.15+.23*sin(p.y*2.8)
    if src.name=='Hull' and p.z>1.35 and p.y>2:p.z+=.045*sin(p.x*11+p.y*5)
   else:
    if src.name in ['Gun','Barrel'] and p.y<-2.36:p.z+=.20*(p.y+2.36)
   if src.name.startswith('Hatch'):
    pivot=world.translation;p=pivot+Matrix.Rotation(math.radians(-78),3,'X')@(p-pivot)
   vert.co=p
  ob.matrix_world=Matrix.Identity(4)
  ob.data.materials.clear();ob.data.materials.append(MATS['Burnt'])
  for p in ob.data.polygons:p.material_index=0
  copies.append(ob)
 # entire closed turret underside + basket stub; real open hull ring already boolean cut.
 if isturret:
  m=Mesh();m.cyl((0,-.2,1.53),1.04,.1,'Burnt',n=48);m.cyl((0,-.2,1.29),.69,.42,'Burnt',n=32)
  stub=m.finish('_basket',bevel=.008,dest=[]);copies.append(stub)
 target=copies.pop(0);join_into(target,copies);target.name=name
 bpy.context.view_layer.objects.active=target;mod=target.modifiers.new('Wreck budget','DECIMATE');mod.ratio=.39;bpy.ops.object.modifier_apply(modifier=mod.name)
 target.data.transform(Matrix.Translation(-Vector(origin)));target.location=origin;damage.append(target);return target
# descendants determined through parent chain, including Gun/Barrel.
def under(ob,root):
 p=ob.parent
 while p:
  if p==root:return True
  p=p.parent
 return False
wh=wreck('Wreck_Hull',[o for o in healthy if o!=turret and not under(o,turret)],(0,0,0),False)
wt=wreck('Wreck_Turret',[o for o in healthy if o==turret or under(o,turret)],tuple(turret.location),True)
for ob in damage:
 if ob.name.startswith('Track_'):
  bpy.context.view_layer.objects.active=ob;mod=ob.modifiers.new('Broken belt budget','DECIMATE');mod.ratio=.43;bpy.ops.object.modifier_apply(modifier=mod.name)
for ob in damage:
 if ob.type=='MESH':
  for i,mat in enumerate(ob.data.materials):
   if mat and mat.name in ['Glass','Lamps']:ob.data.materials[i]=MATS['Scars']
export('challenger2-tank-damage.glb',damage)

# Manifest uses exported coordinate system and evaluated world positions.
def glpos(p):return [round(p.x,5),round(p.z,5),round(-p.y,5)]
def bounds(obs):
 pts=[o.matrix_world@Vector(v) for o in obs if o.type=='MESH' for v in o.bound_box]
 return {'min':glpos(Vector((min(p.x for p in pts),max(p.y for p in pts),min(p.z for p in pts)))),'max':glpos(Vector((max(p.x for p in pts),min(p.y for p in pts),max(p.z for p in pts))))}
def nodes(obs):return [{'name':o.name,'position':glpos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type,'role':o.get('role',None)} for o in obs]
manifest={'generator_sha256':BUILD_SOURCE_SHA256,'units':'metres','forward':'+Z','up':'+Y','left':'+X','files':{'challenger2-tank.glb':{'nodes':nodes(healthy),'triangles':tris(healthy),'bounds':bounds(healthy)},'challenger2-tank-damage.glb':{'nodes':nodes(damage),'triangles':tris(damage),'bounds':bounds(damage)}},'paints':[{'name':n,'linearRGB':c} for n,c in PAINTS],'damage':{'contract':'tank-damage/v1','parts':[{'part':o.name,'twin':twins.get(o.name)} for o in parts],'scars':scar_names,'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'pivots':{'Turret':'Y','Gun':'X','Barrel':'local Z recoil','Wheel_*':'X','Hatch_*':'X'},'source':'sheet.jpg reconstruction; Poly Haven CC0 tile sources','bakes':{'engine':'Cycles GPU','samples':24,'passes':['EMIT base colour','tangent NORMAL','EMIT packed ORM'],'tile_scale_m':1.4}}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join(["Challenger 2 style game tank, articulated healthy asset and tank-damage/v1 companion; metres, +Z gun forward.",f"Triangles: healthy {tris(healthy):,}; damage {tris(damage):,} (wreck {tris([wh,wt]):,}).","Materials: Paint, Rubber, TrackSteel, Glass, Lamps, Burnt, Scars; neutral Paint tile multiplied by desert tan [0.42,0.36,0.24].","Cycles baked shared tiles: Paint base 1024, normal 512; all ORM 512; other base/normal 256–512 pixels.","Poly Haven CC0 ids: rusty_metal_03, rusty_metal_04, metal_plate; https://polyhaven.com/license (downloaded official diff, nor_gl, arm maps).","Differences: reference-inspired reconstruction, not factory CAD; fine suspension internals simplified; aerials exceed nominal roof height; damage is authored, not simulated."])+ '\n')
print('MANIFEST',tris(healthy),tris(damage),flush=True)
# Save only a recoverable build, then proofs use fresh imports of BOTH delivered files.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'build.blend'))
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
bpy.data.node_groups.remove(bpy.data.node_groups['glTF Material Output'],do_unlink=True)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'challenger2-tank.glb'))
H=list(sc.objects);bpy.ops.import_scene.gltf(filepath=str(ROOT/'challenger2-tank-damage.glb'));D=[o for o in sc.objects if o not in H]
for o in H+D:
 if o.rotation_mode=='QUATERNION':
  rot=o.rotation_quaternion.to_euler('XYZ');o.rotation_mode='XYZ';o.rotation_euler=rot
by={o.name:o for o in H+D}
# Stage and lighting
floor=bpy.data.materials.new('Stage');floor.diffuse_color=(.18,.195,.21,1);floor.use_nodes=True;floor.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.86
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.045));ground=bpy.context.object;ground.name='_Stage';ground.data.materials.append(floor)
sc.world.color=(.24,.24,.24);sc.world.use_nodes=True;sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.45,.52,.65,1);sc.world.node_tree.nodes['Background'].inputs[1].default_value=.45
for name,loc,energy,size in [('Key',(2,-8,11),1900,7),('Fill',(-8,-2,6),1250,8),('Rim',(3,7,9),2000,6)]:
 bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.name=name;o.data.energy=energy;o.data.shape='DISK';o.data.size=size;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO'
def view(side=False,wide=False):
 cam.location=(13,-17,7.4) if not side else (15,-1.4,1.3);target=Vector((0,-1.22,1.0)) if not side else Vector((0,-1.4,1.3));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=14.2 if not wide else 17.2
 if side:cam.data.ortho_scale=13.5

def show(obs,on):
 for o in obs:o.hide_render=not on

def reset():show(H,True);show(D,False)
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)
reset();view();render('final.png')
view(True);render('side.png',1920,1080,24)
view();clay=bpy.data.materials.new('Clay');clay.diffuse_color=(.36,.36,.36,1);sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);sc.view_layers[0].material_override=None
# 30-degree articulation proof, every required moving role tested simultaneously.
saved={}
for o in H:
 if o.name in ['Turret','Gun','Barrel','MG'] or o.name.startswith('Wheel_') or o.name.startswith('Hatch_'):
  saved[o.name]=o.rotation_euler.copy();o.rotation_euler[2 if o.name in ['Turret','MG'] else 0]+=(-pi/6 if o.name in ['Gun','Barrel'] or o.name.startswith('Hatch_') else pi/6)
render('pivots.png',1280,720,16)
for name,rot in saved.items():by[name].rotation_euler=rot
# Select imported Paint, inspect supported exporter multiply representation after roundtrip.
paint=next(m for m in bpy.data.materials if m.name.startswith('Paint') and m.users and m!=MATS['Paint'])
bs=next(n for n in paint.node_tree.nodes if n.type=='BSDF_PRINCIPLED')
def recolor(rgb):
 # glTF importer reconstructs baseColorFactor as a multiply node
 for n in paint.node_tree.nodes:
  if n.type=='MIX' and n.data_type=='RGBA':n.inputs[7].default_value=(*rgb,1);return
  if n.type=='MIX_RGB' and n.blend_type=='MULTIPLY':n.inputs[2].default_value=(*rgb,1);return
 # fallback insert standard image * factor
 sock=bs.inputs['Base Color']
 if sock.is_linked:
  source=sock.links[0].from_socket;mix=paint.node_tree.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*rgb,1);paint.node_tree.links.new(source,mix.inputs[1]);paint.node_tree.links.new(mix.outputs[0],sock)
 else:sock.default_value=(*rgb,1)

def grid(paths,out):
 ims=[bpy.data.images.load(str(ROOT/p),check_existing=False) for p in paths];w,h=ims[0].size;arr=np.zeros((h*2,w*3,4),dtype=np.float32)
 for i,im in enumerate(ims):
  p=np.empty(w*h*4,dtype=np.float32);im.pixels.foreach_get(p);row=1-i//3;arr[row*h:(row+1)*h,(i%3)*w:(i%3+1)*w]=p.reshape(h,w,4)
 result=bpy.data.images.new(out,width=w*3,height=h*2);result.pixels.foreach_set(arr.ravel());result.filepath_raw=str(ROOT/out);result.file_format='PNG';result.save()
 for im in ims:bpy.data.images.remove(im)
reset();view()
for i,(name,rgb) in enumerate(PAINTS):recolor(rgb);render(f'_paint{i}.png',960,540,20)
recolor(PAINTS[0][1]);grid([f'_paint{i}.png' for i in range(6)],'paints.png')
# Six actual imported-export damage states.
reset();view(wide=True);render('_state0.png',960,540,24)
for o in D:
 if o.name.startswith('Scar_'):o.hide_render=False
for name in list(twins)[:6]:by[name].hide_render=True;by[twins[name]].hide_render=False
render('_state1.png',960,540,24)
reset()
for o in H:
 if o.name.startswith('Part_'):o.hide_render=True
render('_state2.png',960,540,24)
reset();by['Track_L'].hide_render=True;by['Track_L_Broken'].hide_render=False
for o in H:
 if o.name in ['Gun','Barrel','Muzzle']:o.hide_render=True
by['Gun_Broken'].hide_render=False;render('_state3.png',960,540,24)
show(H,False);show(D,False);by['Wreck_Hull'].hide_render=False;by['Wreck_Turret'].hide_render=False;render('_state4.png',960,540,24)
by['Wreck_Turret'].location+=(Vector((3.9,-.2,-.90)));by['Wreck_Turret'].rotation_euler=(0,1.15,-.55)
bpy.context.view_layer.update();fallen=by['Wreck_Turret'];lowest=min((fallen.matrix_world@v.co).z for v in fallen.data.vertices);fallen.location.z+=.008-lowest
render('_state5.png',960,540,24)
grid([f'_state{i}.png' for i in range(6)],'states.png')
print('ALL COMPLETE',flush=True)
