"""Churchill Mk VII style game asset. Clean run: blender -b --python-exit-code 1 --python this.py.
Authored X left, -Y forward, Z up; exporter converts to glTF Y up/+Z forward.
CC0 inputs: Poly Haven green_metal_rust and rusty_metal_02. No external code dependencies.
"""
import bpy, bmesh, math, random, json, os, sys, struct, urllib.request, time
import numpy as np
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin,cos,pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(71)
START=time.time()
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.unit_settings.system='METRIC';sc.render.engine='CYCLES';sc.cycles.samples=20
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
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.world.color=(.35,.35,.35)
sc.view_settings.view_transform='AgX'
MATS=[];MAT={};OB={};HEALTH=[];DAMAGE=[]
PAINTS=[('Olive drab',[.13,.15,.08]),('NATO green',[.075,.12,.062]),('Desert tan',[.48,.36,.20]),('Field grey',[.19,.22,.19]),('Winter white',[.76,.78,.72]),('Dark green-black',[.025,.037,.025])]
def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

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'
  req=urllib.request.Request(u,headers={'User-Agent':'Mozilla/5.0'})
  with urllib.request.urlopen(req,timeout=60) as r:p.write_bytes(r.read())
 return str(p)

def bake_material(name,base,metal,rough,source='green_metal_rust',size=512):
 if '--rebake' not in sys.argv and (TEX/'recipe-v3.txt').exists() and all((TEX/f'{name}_{k}.png').exists() for k in ['base','normal','orm']):
  m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;bs=nt.nodes.get('Principled BSDF')
  for k in ['base','normal','orm']:
   t=nt.nodes.new('ShaderNodeTexImage');t.image=bpy.data.images.load(str(TEX/f'{name}_{k}.png'));t.label=k
   if k!='base':t.image.colorspace_settings.name='Non-Color'
   if k=='base':nt.links.new(t.outputs[0],bs.inputs['Base Color'])
   elif k=='normal':
    nm=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(t.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
   else:
    se=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(t.outputs[0],se.inputs[0]);nt.links.new(se.outputs[1],bs.inputs['Roughness']);nt.links.new(se.outputs[2],bs.inputs['Metallic'])
    ng=bpy.data.node_groups.get('glTF Material Output')
    if not ng:ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=nt.nodes.new('ShaderNodeGroup');gn.node_tree=ng;nt.links.new(se.outputs[0],gn.inputs['Occlusion'])
  m.diffuse_color=tuple(base)+(1,);MAT[name]=len(MATS);MATS.append(m);return m
 print('BAKING',name,flush=True)
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();n=nt.nodes;l=nt.links
 out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs['Surface'])
 tc=n.new('ShaderNodeTexCoord')
 def tex(map):
  t=n.new('ShaderNodeTexImage');t.image=bpy.data.images.load(download(source,map),check_existing=True)
  if map!='diff':t.image.colorspace_settings.name='Non-Color'
  l.new(tc.outputs['UV'],t.inputs['Vector']);return t
 diff=tex('diff');normal=tex('nor_gl');arm=tex('arm')
 bw=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],bw.inputs[0])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=37;noise.inputs['Detail'].default_value=4;l.new(tc.outputs['UV'],noise.inputs['Vector'])
 mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.18;l.new(bw.outputs[0],mix.inputs[1]);l.new(noise.outputs['Fac'],mix.inputs[2])
 ramp=n.new('ShaderNodeValToRGB');l.new(mix.outputs[0],ramp.inputs[0])
 ramp.color_ramp.elements[0].position=.005 if source=='green_metal_rust' else .08;ramp.color_ramp.elements[1].position=.11 if source=='green_metal_rust' else .72
 if name=='Paint':
  ramp.color_ramp.elements[0].color=(.25,.25,.25,1);ramp.color_ramp.elements[1].color=(.98,.98,.98,1)
 else:
  ramp.color_ramp.elements[0].color=tuple(v*.38 for v in base)+(1,);ramp.color_ramp.elements[1].color=tuple(base)+(1,)
 l.new(ramp.outputs[0],bs.inputs['Base Color'])
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.38 if name=='Paint' else .28;l.new(normal.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
 rr=n.new('ShaderNodeMath');rr.operation='MULTIPLY_ADD';rr.inputs[1].default_value=.19;rr.inputs[2].default_value=rough-.1;l.new(sep.outputs[1],rr.inputs[0]);l.new(rr.outputs[0],bs.inputs['Roughness'])
 bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,-20));plane=bpy.context.object;plane.data.materials.append(m);active(plane)
 target=n.new('ShaderNodeTexImage');maps={}
 for kind,res in [('base',size),('normal',size),('orm',min(size,512))]:
  im=bpy.data.images.new(f'{name}_{kind}',width=res,height=res,alpha=False)
  if kind!='base':im.colorspace_settings.name='Non-Color'
  target.image=im;nt.nodes.active=target
  if kind=='base':bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
  elif kind=='normal':bpy.ops.object.bake(type='NORMAL')
  else:
   comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=.93;comb.inputs[2].default_value=metal;l.new(rr.outputs[0],comb.inputs[1])
   em=n.new('ShaderNodeEmission');l.new(comb.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');l.new(bs.outputs[0],out.inputs[0])
  im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save();maps[kind]=im
 bpy.data.objects.remove(plane,do_unlink=True)
 nt.nodes.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 for k in ['base','normal','orm']:
  t=n.new('ShaderNodeTexImage');t.image=maps[k];t.label=k
  if k=='base':l.new(t.outputs[0],bs.inputs['Base Color'])
  elif k=='normal':
   nm=n.new('ShaderNodeNormalMap');l.new(t.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   se=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],se.inputs[0]);l.new(se.outputs[1],bs.inputs['Roughness']);l.new(se.outputs[2],bs.inputs['Metallic'])
   g=bpy.data.node_groups.get('glTF Material Output')
   if not g:
    g=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');g.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   gn=n.new('ShaderNodeGroup');gn.node_tree=g;l.new(se.outputs[0],gn.inputs['Occlusion'])
 m.diffuse_color=tuple(base)+(1,);bs.inputs['Roughness'].default_value=rough
 if name=='Lamps':bs.inputs['Emission Color'].default_value=(.3,.23,.1,1);bs.inputs['Emission Strength'].default_value=.15
 MAT[name]=len(MATS);MATS.append(m);return m

for a in [('Paint',(1,1,1),.03,.78,'green_metal_rust',1024),('TrackSteel',(.19,.17,.13),.72,.72,'rusty_metal_02',512),('Rubber',(.047,.045,.037),0,.89,'green_metal_rust',512),('Steel',(.28,.29,.27),.83,.52,'green_metal_rust',512),('GunSteel',(.13,.15,.08),.52,.62,'green_metal_rust',512),('Interior',(.023,.021,.017),0,.95,'green_metal_rust',256),('Lamps',(.65,.57,.37),.15,.23,'green_metal_rust',128),('Burnt',(.058,.055,.047),.27,.94,'rusty_metal_02',512),('Scars',(.31,.29,.25),.75,.52,'rusty_metal_02',512)]:bake_material(*a)
(TEX/'recipe-v3.txt').write_text('green_metal_rust + rusty_metal_02; neutral ramp 0.005..0.11; strength 0.20')
print('BAKES_DONE',time.time()-START,flush=True)

class Geo:
 def __init__(self):self.v=[];self.f=[];self.mi=[];self.sm=[];self.uv=[]
 def add(self,v,f,mat='Paint',smooth=False,uv=None):
  idx=len(self.v);self.v.extend([tuple(x) for x in v]);self.f.extend([tuple(idx+i for i in face) for face in f]);self.mi.extend([MAT[mat]]*len(f));self.sm.extend([smooth]*len(f));self.uv.extend(uv if uv else [None]*len(f))
 def box(self,c,s,mat='Paint',rot=None):
  v=[Vector((x*s[0]/2,y*s[1]/2,z*s[2]/2)) for x,y,z in [(-1,-1,-1),(1,-1,-1),(1,1,-1),(-1,1,-1),(-1,-1,1),(1,-1,1),(1,1,1),(-1,1,1)]]
  if rot:v=[rot@p for p in v]
  v=[p+Vector(c) for p in v]
  self.add(v,[(3,2,1,0),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)],mat)
 def cyl(self,c,r,depth,mat='Paint',axis='Z',n=24,r2=None):
  if r2 is None:r2=r
  v=[]
  for z,rad in [(-depth/2,r),(depth/2,r2)]:
   for i in range(n):
    a=2*pi*i/n;p=(rad*cos(a),rad*sin(a),z)
    if axis=='X':p=(z,p[0],p[1])
    elif axis=='Y':p=(p[0],z,-p[1])
    v.append(Vector(c)+Vector(p))
  self.add(v,[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],mat,True)
  self.add(v,[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))],mat)
 def sphere(self,c,r,mat='Paint',n=24,m=12):
  v=[Vector(c)+Vector((r*sin(j*pi/m)*cos(i*2*pi/n),r*sin(j*pi/m)*sin(i*2*pi/n),r*cos(j*pi/m))) for j in range(m+1) for i in range(n)]
  self.add(v,[(j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i) for j in range(m) for i in range(n)],mat,True)
 def rod(self,a,b,r,mat='Paint',n=10):
  a=Vector(a);b=Vector(b);d=b-a;z=d.normalized();t=z.cross(Vector((0,0,1)))
  if t.length<.01:t=z.cross(Vector((0,1,0)))
  t.normalize();u=z.cross(t);v=[p+r*(t*cos(i*2*pi/n)+u*sin(i*2*pi/n)) for p in [a,b] for i in range(n)]
  self.add(v,[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],mat,True)
  self.add(v,[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))],mat)
 def tube(self,pts,r,mat='Paint',n=8):
  for a,b in zip(pts,pts[1:]):self.rod(a,b,r,mat,n)
 def ring(self,c,outer,inner,depth,mat='Paint',axis='Z',n=48):
  v=[]
  for z,r in [(-depth/2,outer),(depth/2,outer),(depth/2,inner),(-depth/2,inner)]:
   for i in range(n):
    a=2*pi*i/n;p=(r*cos(a),r*sin(a),z)
    if axis=='X':p=(z,p[0],p[1])
    elif axis=='Y':p=(p[0],z,-p[1])
    v.append(Vector(c)+Vector(p))
  faces=[]
  for k in range(4):
   for i in range(n):faces.append((k*n+i,k*n+(i+1)%n,((k+1)%4)*n+(i+1)%n,((k+1)%4)*n+i))
  self.add(v,faces,mat,True)
 def extrude(self,poly,x0,x1,mat='Paint'):
  n=len(poly);v=[(x,y,z) for x in [x0,x1] for y,z in poly]
  self.add(v,[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],mat)
 def lathe_x(self,c,profile,mat='Paint',n=40):
  v=[(c[0]+x,c[1]+r*cos(i*2*pi/n),c[2]+r*sin(i*2*pi/n)) for x,r in profile for i in range(n)]
  self.add(v,[(k*n+i,k*n+(i+1)%n,(k+1)*n+(i+1)%n,(k+1)*n+i) for k in range(len(profile)-1) for i in range(n)],mat,True)
 def obj(self,name,pivot=(0,0,0),parent=None,bevel=0,segments=2,healthy=True):
  me=bpy.data.meshes.new(name);me.from_pydata([Vector(v)-Vector(pivot) for v in self.v],[],self.f);me.update()
  o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);o.location=pivot
  for m in MATS:me.materials.append(m)
  uv=me.uv_layers.new(name='UVMap')
  for p,mi,sm,custom in zip(me.polygons,self.mi,self.sm,self.uv):
   p.material_index=mi;p.use_smooth=sm
   norm=p.normal;ax=max(range(3),key=lambda i:abs(norm[i]));axes=[i for i in range(3) if i!=ax]
   for j,li in enumerate(p.loop_indices):
    co=Vector(self.v[me.loops[li].vertex_index]);uv.data[li].uv=custom[j] if custom else (co[axes[0]]*.8,co[axes[1]]*.8)
  # Consistent outward normals on all closed components.
  bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
  if bevel:
   active(o);mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=bevel;mod.segments=segments;mod.limit_method='ANGLE';mod.angle_limit=.6
   bpy.ops.object.modifier_apply(modifier=mod.name)
   mod=o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=40
   bpy.ops.object.modifier_apply(modifier=mod.name)
  if parent:
   bpy.context.view_layer.update();world=o.matrix_world.copy();o.parent=parent;bpy.context.view_layer.update();o.matrix_world=world
  OB[name]=o
  if healthy:HEALTH.append(o)
  return o

def empty(name,p,parent=None,healthy=True):
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=p
 if parent:
  bpy.context.view_layer.update();w=o.matrix_world.copy();o.parent=parent;o.matrix_world=w
 bpy.context.view_layer.update()
 OB[name]=o
 if healthy:HEALTH.append(o)
 return o

def bolts(g,points,axis='Z',r=.014,mat='Steel'):
 for p in points:g.cyl(p,r,.014,mat,axis,6)
def handle(g,a,b,up=(0,0,.035),mat='Paint'):
 aa=Vector(a)+Vector(up);bb=Vector(b)+Vector(up);g.tube([a,aa,bb,b],.009,mat)
def cut(o,cutter):
 active(o);mod=o.modifiers.new('Machined opening','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cutter,do_unlink=True)
def cutter_cyl(c,r,d):
 bpy.ops.mesh.primitive_cylinder_add(vertices=48,radius=r,depth=d,location=c);return bpy.context.object

def crew_well(g,c,r,depth):
 g.ring((c[0],c[1],c[2]-depth/2),r,r-.025,depth,'Interior');g.cyl((c[0],c[1],c[2]-depth),r,.035,'Interior');g.box((c[0],c[1],c[2]-depth+.07),(.27,.3,.10),'Interior')

# Long welded lower hull and full pannier carcasses, independent of removable armour.
g=Geo();g.extrude([(-3.39,.43),(-3.46,.95),(-3.18,1.04),(-2.71,1.29),(-2.03,1.42),(2.99,1.42),(3.43,1.18),(3.43,.61),(2.91,.43)],-1.0,1.0)
g.box((0,.40,1.42),(1.95,4.95,.13))
for side in [-1,1]:
 x=side*1.24
 g.extrude([(-3.28,.61),(-3.32,1.0),(-2.83,1.43),(2.83,1.43),(3.32,1.03),(3.24,.61)],x-.25,x+.25)
 # Long structural rails and riveted frames, visible in stripped mode.
 for z in [.65,1.35]:g.box((side*1.50,0,z),(.045,5.88,.043))
 for y in [-2.73,-2.0,-1.23,-.46,.31,1.08,1.85,2.62]:
  g.box((side*1.495,y,1.01),(.046,.035,.60));bolts(g,[(side*1.522,y,z) for z in [.70,1.30]],'X',.013,'Paint')
 # suspension arms
 for i in range(11):
  y=-2.40+i*.48;g.rod((side*1.24,y-.11,.64),(side*1.27,y,.26),.05,'Paint',12)
  g.cyl((side*1.25,y-.1,.62),.065,.12,'Steel','X',16)
 # upper supporting ledge; these remain below guards
 g.box((side*1.26,0,1.40),(.55,5.9,.047))
 # tow cable attachment bosses
 for y in [-2.6,2.6]:g.box((side*1.515,y,1.08),(.06,.16,.12))
# Fixed armoured turret race, seated into the hull and overlapping the rotating race.
g.ring((0,-.05,1.565),1.02,.76,.25,'Paint',n=64)
# front driver's vertical upper plate and hull gun mount
g.box((0,-2.63,1.32),(1.94,.16,.37));g.box((0,-2.32,1.51),(1.95,.70,.10))
g.box((-.70,-2.731,1.34),(.26,.055,.29),'Paint');g.box((-.70,-2.767,1.35),(.17,.035,.20),'Interior');g.box((-.70,-2.788,1.35),(.135,.022,.17),'Paint');g.box((-.34,-2.745,1.34),(.28,.046,.26),'Paint')
for z in [1.27,1.33,1.39]:g.box((-.34,-2.779,z),(.23,.025,.035),'Steel')
for x in [-.84,-.56,-.47,-.21]:bolts(g,[(x,-2.79,1.46)],'Y',.011,'Paint')
g.rod((-.91,-2.727,1.12),(.92,-2.727,1.12),.009,'Paint',8)
g.cyl((.53,-2.74,1.29),.17,.12,'Paint','Y',32);g.sphere((.53,-2.82,1.29),.115,'Paint');g.cyl((.53,-2.92,1.29),.053,.048,'Steel','Y',24);g.cyl((.53,-2.99,1.29),.024,.16,'Steel','Y',16);g.ring((.53,-3.077,1.29),.027,.013,.019,'Steel','Y',16)
bolts(g,[(.53+.132*cos(i*pi/4),-2.82,1.29+.132*sin(i*pi/4)) for i in range(8)],'Y',.013)
# Front sloped welds and tow lugs
for x in [-.76,.76]:
 g.box((x,-3.41,.64),(.12,.08,.20));g.ring((x,-3.47,.67),.065,.033,.065,'Steel','Y',20)
for x in [-.6,.6]:
 g.box((x,3.42,.70),(.17,.09,.22));g.ring((x,3.49,.69),.07,.037,.06,'Steel','Y',20)
# engine deck base intact behind removable grilles
g.box((0,2.15,1.47),(1.9,2.2,.08))
for x in [-.55,.55]:
 for y in [1.62,2.12,2.62]:g.box((x,y,1.525),(.82,.44,.035));handle(g,(x-.1,y,1.546),(x+.1,y,1.546))
# Driver hatch coaming and deep seat recess will be cut in deck.
g.ring((-.45,-2.28,1.56),.28,.235,.055)
crew_well(g,(-.45,-2.28,1.54),.24,.43)
hull=g.obj('Hull',bevel=.013)
cut(hull,cutter_cyl((-.45,-2.28,1.53),.232,.20))
# Reinsert well after cut so dark floor remains.
g=Geo();crew_well(g,(-.45,-2.28,1.44),.233,.32);well=g.obj('_DriverWell',healthy=False)
active(hull);well.select_set(True);bpy.ops.object.join()

# Catmull-Rom closed track path, sampled by physical arc length.
P=[(-2.75,.065),(2.75,.065),(3.22,.20),(3.56,.52),(3.62,.98),(3.38,1.38),(3.02,1.49),(-3.02,1.49),(-3.38,1.38),(-3.62,.98),(-3.56,.52),(-3.22,.20)]
def catmull(p0,p1,p2,p3,t):return .5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t)
path=[]
for j in range(120):path.append(Vector((-2.7+5.4*j/120,.05)))
for j in range(80):
 a=-pi/2+pi*j/80;path.append(Vector((2.7+.88*cos(a),.75+.70*sin(a))))
for j in range(120):path.append(Vector((2.7-5.4*j/120,1.45)))
for j in range(80):
 a=pi/2+pi*j/80;path.append(Vector((-2.7+.88*cos(a),.75+.70*sin(a))))
path.append(path[0]);lens=[0.]
for a,b in zip(path,path[1:]):lens.append(lens[-1]+(b-a).length)
def sample(t):
 d=t*lens[-1];i=np.searchsorted(lens,d,side='right')-1;i=min(i,len(path)-2);f=(d-lens[i])/(lens[i+1]-lens[i]);p=path[i].lerp(path[i+1],f);tang=(path[i+1]-path[i]).normalized();return p,tang
LINKS=116
for side,label in [(1,'L'),(-1,'R')]:
 g=Geo();x=side*1.30
 for i in range(LINKS):
  p,t=sample((i+.5)/LINKS);a=math.atan2(t[1],t[0]);rot=Matrix.Rotation(a,3,'X');c=(x,p[0],p[1]);start=len(g.f)
  g.box(c,(.59,.139,.055),'TrackSteel',rot)
  # cleats and raised central shoe: linked geometry, never a smooth belt.
  n=Vector((0,t[1],-t[0]));c0=Vector(c)+n*.039
  g.box(c0,(.53,.036,.027),'TrackSteel',rot)
  for dx in [-.225,.225]:g.box(c0+Vector((dx,0,0)),(.044,.131,.031),'TrackSteel',rot)
  # Hinge pin ends on the side face.
  g.cyl((x+side*.295,p[0]+t[0]*.067,p[1]+t[1]*.067),.022,.024,'Steel','X',8)
  for fi in range(start,len(g.f)):
   f=g.f[fi];g.uv[fi]=[((g.v[j][0]-x)/.59+.5,(i+(.5+(Vector(g.v[j])-Vector(c)).dot(Vector((0,t[0],t[1])))/.135))/LINKS) for j in f]
 track=g.obj('Track_'+label,bevel=.005,segments=1)
 # Eleven small road wheels. Individual hub-centred nodes.
 wheel_specs=[(-2.4+i*.48,.274,.205,'road') for i in range(11)]
 wheel_specs+=[(-3.07,.42,.275,'idler'),(3.07,.42,.275,'sprocket')]
 wheel_specs+=[(-2.1+i*.84,1.255,.14,'return') for i in range(6)]
 wheel_specs.sort(key=lambda q:q[0])
 for j,(y,z,r,kind) in enumerate(wheel_specs):
  wx=x if kind!='return' else side*1.22;c=(wx,y,z);g=Geo()
  # dished stamped wheel, steel tyre with narrow rubber contact ring
  prof=[(-.22,r*.70),(-.22,r*.94),(-.19,r),(-.15,r),(-.13,r*.77),(.13,r*.77),(.15,r),(.19,r),(.22,r*.94),(.22,r*.64),(.19,r*.48),(.20,r*.31),(.235,r*.25)]
  g.lathe_x(c,prof,'Paint',24)
  for dx in [-.166,.166]:g.ring((wx+dx,y,z),r*1.025,r*.94,.055,'Rubber','X',24)
  for dx in [-.222,.222]:
   g.ring((wx+dx,y,z),r*.72,r*.62,.012,'Steel','X',24);g.cyl((wx+dx,y,z),r*.28,.028,'Paint','X',24)
  for i in range(6):
   a=i*pi/3;g.cyl((wx+side*.241,y+r*.43*cos(a),z+r*.43*sin(a)),.012,.016,'Steel','X',6)
  if kind=='sprocket':
   for k in range(14):
    a=k*2*pi/14;g.box((x,y+cos(a)*(r+.015),z+sin(a)*(r+.015)),(.34,.063,.065),'TrackSteel',Matrix.Rotation(a,3,'X'))
  o=g.obj(f'Wheel_{label}{j+1}',c,bevel=.003,segments=1);o['wheel_role']=kind;o['spin_axis']='X'
 # Broad square air intake set over the rear pannier.
 g=Geo();g.box((side*1.51,1.51,1.16),(.17,1.31,.47));g.box((side*1.603,1.51,1.34),(.012,1.19,.095),'Interior')
 for z in [1.325,1.36]:g.box((side*1.617,1.51,z),(.018,1.19,.014))
 g.extrude([( .80,1.41),(1.08,1.49),(1.91,1.49),(2.20,1.40),(2.20,1.37),(.80,1.37)],side*1.35,side*1.65)
 bolts(g,[(side*1.624,y,z) for y in [.87,2.15] for z in [.98,1.36]],'X')
 g.obj('Part_Intake_'+label,(side*1.53,1.51,1.20),bevel=.012)
 # Escape door, complete continuous pannier behind it, outward two hinges.
 g=Geo();g.box((side*1.53,-1.16,.98),(.065,.64,.50));g.box((side*1.57,-1.16,.98),(.025,.54,.41))
 for z in [.83,1.13]:
  g.box((side*1.601,-1.38,z),(.05,.21,.035));g.cyl((side*1.62,-1.46,z),.021,.075,'Steel','Z',12)
 handle(g,(side*1.59,-.96,.94),(side*1.59,-.96,1.06),(side*.045,0,0))
 bolts(g,[(side*1.589,y,z) for y in [-1.38,-.94] for z in [.80,1.16]],'X',.012)
 g.obj('Part_Door_'+label,(side*1.56,-1.16,.98),bevel=.018)
 # Four independent top guard sections with pressed transverse ribs.
 for k in range(4):
  y=-2.25+k*1.50;g=Geo();g.box((side*1.30,y,1.54),(.64,1.48,.045))
  for yy in [y-.69,y-.35,y,y+.35,y+.69]:
   g.box((side*1.30,yy,1.575),(.63,.022,.038))
  g.box((side*1.62,y,1.465),(.026,1.48,.20))
  bolts(g,[(side*1.61,yy,1.56) for yy in [y-.63,y+.63]],'Z',.014,'Paint')
  g.obj(f'Part_Skirt_{label}{k+1}',(side*1.30,y,1.53),bevel=.007)
 # Curved mudguards front/rear, thick formed sheet profile.
 for front,suff in [(True,'F'),(False,'R')]:
  sign=-1 if front else 1;g=Geo();profile=[]
  for j in range(33):
   a=j*pi/2/32;profile.append((sign*(3.0+.70*sin(a)),.82+.76*cos(a)))
  poly=profile+[(y,z-.036) for y,z in reversed(profile)]
  g.extrude(poly,side*1.00,side*1.625)
  g.sm[-len(poly):]=[True]*len(poly)
  # Broad quarter-round side cheek, with real sheet thickness and a folded lower edge.
  cheek=profile+[(sign*3.70,.79),(sign*3.0,.79)]
  g.extrude(cheek,side*1.600,side*1.628)
  g.rod((side*1.639,sign*3.03,.795),(side*1.639,sign*3.68,.795),.011,'Paint',8)
  g.box((side*1.32,sign*3.64,.81),(.60,.035,.20),'Rubber')
  for yy,zz in profile[::8]:g.cyl((side*1.624,yy,zz-.025),.016,.022,'Steel','X',8)
  g.obj('Part_Fender_'+suff+label,(side*1.31,sign*3.40,1.16),bevel=.006)
 # spare track bracket at pannier midpoint
 g=Geo()
 for yy in [-.34,-.18,-.02,.14]:
  g.box((side*1.558,yy,1.0),(.10,.11,.35),'TrackSteel');g.box((side*1.623,yy,1.0),(.03,.031,.33),'Steel')
 for z in [.86,1.14]:g.box((side*1.657,-.1,z),(.032,.66,.025),'Steel')
 g.obj('Part_SpareTracks_'+label,(side*1.57,-.1,1.0),bevel=.006)
 # tow cable along side, anchored to carcass, heavy eye ends
 g=Geo();pts=[(side*1.54,-2.6,1.09),(side*1.565,-2.20,1.27),(side*1.565,-1.80,1.36),(side*1.565,-.60,1.39),(side*1.565,.40,1.29),(side*1.565,1.0,.83),(side*1.55,2.6,.73)]
 g.tube(pts,.018,'Steel',10)
 for p in [pts[0],pts[-1]]:g.ring(p,.047,.023,.025,'Steel','X',20)
 g.obj('Part_TowCable_'+label,(side*1.55,0,1.10),bevel=0)
# Back exhaust mufflers and shroud, grilles and tools.
for x,label in [(-.56,'R'),(.56,'L')]:
 g=Geo();g.cyl((x,3.42,1.16),.12,.63,'TrackSteel','X',24);g.rod((x+.26,3.42,1.16),(x+.26,3.58,1.05),.055,'TrackSteel',16)
 for xx in [x-.22,x+.22]:g.box((xx,3.44,1.17),(.036,.26,.25))
 g.obj('Part_Exhaust_'+label,(x,3.44,1.16),bevel=.008)
 g=Geo();g.box((x,2.85,1.54),(.78,.69,.045));g.box((x,2.85,1.569),(.67,.56,.012),'Interior')
 for i in range(16):g.box((x-.31+i*.041,2.85,1.59),(.018,.57,.022))
 g.obj('Part_EngineGrille_'+label,(x,2.85,1.565),bevel=.003)
g=Geo();g.box((0,3.55,1.30),(1.64,.24,.18));
for x in [-.65,-.3,0,.3,.65]:bolts(g,[(x,3.682,1.29)],'Y')
g.obj('Part_ExhaustShroud',(0,3.55,1.30),bevel=.018)
g=Geo();g.rod((-.60,.85,1.58),(-.60,2.6,1.58),.025,'TrackSteel',12);g.box((-.60,2.63,1.58),(.20,.30,.05),'Steel');g.rod((-.30,1.03,1.59),(-.30,2.4,1.59),.027,'TrackSteel',12);g.box((-.30,1.00,1.59),(.32,.1,.07),'Steel')
for y in [1.35,2.25]:g.box((-.45,y,1.60),(.47,.06,.075))
g.obj('Part_Tools_Engine',(-.45,1.84,1.58),bevel=.009)
# Lamps and substantial protective cage assemblies.
for x,label in [(1.01,'L'),(-1.01,'R')]:
 g=Geo();g.cyl((x,-2.92,1.25),.069,.13,'Paint','Y',24);g.cyl((x,-3.0,1.25),.055,.014,'Lamps','Y',24)
 for xx in [x-.085,x+.085]:g.tube([(xx,-2.84,1.18),(xx,-3.04,1.18),(xx,-3.04,1.37),(xx,-2.84,1.37)],.012,'Steel')
 g.rod((x-.085,-3.04,1.37),(x+.085,-3.04,1.37),.012,'Steel');g.obj('Part_LampGuard_'+label,(x,-2.94,1.27),bevel=.003)

# Cast/welded squarish turret: layered rounded octagonal perimeter, sloped cheeks.
TUR=(0,-.47,1.61)
def rounded_rect(w,l,r,n=8):
 pts=[]
 for cx,cy,a in [(w/2-r,l/2-r,0),(-w/2+r,l/2-r,90),(-w/2+r,-l/2+r,180),(w/2-r,-l/2+r,270)]:
  for j in range(n):
   ang=math.radians(a+j*90/(n-1));pts.append((cx+r*cos(ang),cy+r*sin(ang)))
 return pts
g=Geo();g.ring((0,-.47,1.64),1.0,.77,.105,'Paint',n=64)
levels=[(1.69,1.97,1.90,.31),(1.74,2.06,1.96,.33),(2.27,2.00,1.90,.30),(2.39,1.87,1.78,.28),(2.42,1.75,1.66,.25)]
v=[]
for z,w,l,r in levels:v.extend([(x,y-.47,z) for x,y in rounded_rect(w,l,r)])
n=32;f=[tuple(range(n-1,-1,-1)),tuple(range((len(levels)-1)*n,len(levels)*n))]
for k in range(len(levels)-1):
 for i in range(n):f.append((k*n+i,k*n+(i+1)%n,(k+1)*n+(i+1)%n,(k+1)*n+i))
g.add(v,f,'Paint',True)
# weld bead around lower shell and roof seam
for z,w,l,r in [(1.74,2.05,1.955,.33),(2.397,1.85,1.765,.28)]:
 p=[(x,y-.47,z) for x,y in rounded_rect(w,l,r)];g.tube(p+[p[0]],.004,'Paint',6)
# Base mounting brackets for removable turret furniture
for x in [-.88,.88]:
 for y in [-.68,.03]:g.box((x,y,2.34),(.11,.12,.065))
# Roof hatch coamings, actual openings later.
g.ring((.46,-.12,2.445),.285,.228,.055,n=40)
g.ring((-.38,-.62,2.443),.23,.188,.047,n=40)
# Closed underside and basket stub
crew_well(g,(.46,-.12,2.37),.23,.42);crew_well(g,(-.38,-.62,2.37),.19,.40)
# Lifting eyes, pistol-port bosses, weld junctions and roof fasteners.
for x in [-.82,.82]:
 for y in [-.91,.03]:
  g.box((x,y,2.40),(.085,.13,.035));g.ring((x,y,2.454),.045,.023,.025,'Paint','Y',20)
 g.cyl((x/abs(x)*1.008,-.16,2.03),.051,.024,'Paint','X',20)
for x,y in rounded_rect(1.70,1.61,.22,4):g.cyl((x,y-.47,2.425),.011,.013,'Paint','Z',6)
tur=g.obj('Turret',TUR,bevel=.009,segments=2)
for c,r in [((.46,-.12,2.43),.226),((-.38,-.62,2.43),.186)]:cut(tur,cutter_cyl(c,r,.23))
tur['rotate_axis']='Y (glTF)'
# Main gun mantlet with a curved cylindrical shield and a recessed collar.
GUN=(0,-1.41,2.045);g=Geo();g.box((0,-1.405,2.04),(.59,.21,.43),'Interior');g.cyl((0,-1.48,2.045),.256,.43,'Paint','X',40)
g.cyl((0,-1.62,2.045),.16,.20,'Paint','Y',40);g.ring((0,-1.737,2.045),.117,.080,.045,'Steel','Y',40)
# Coaxial BESA port, recoil housing bolts and lifting bosses on the mantlet.
g.cyl((.175,-1.669,2.135),.039,.083,'Steel','Y',20);g.ring((.175,-1.718,2.135),.032,.014,.032,'Steel','Y',20)
for x in [-.19,.19]:
 for z in [1.88,2.21]:g.cyl((x,-1.635,z),.018,.015,'Steel','Y',6)
gun=g.obj('Gun',GUN,tur,bevel=.013);gun['elevate_axis']='X'
BORE=(0,-1.76,2.045);g=Geo();g.cyl((0,-2.44,2.045),.080,1.36,'Paint','Y',48,r2=.067)
g.ring((0,-3.14,2.045),.078,.040,.085,'Steel','Y',48);g.ring((0,-3.16,2.045),.040,.033,.14,'Interior','Y',32)
g.ring((0,-1.80,2.045),.092,.075,.09,'Paint','Y',40)
barrel=g.obj('Barrel',BORE,gun,bevel=.004);
for o in [gun,barrel]:
 for polygon in o.data.polygons:
  if polygon.material_index==MAT['Paint']:polygon.material_index=MAT['GunSteel']
barrel['recoil_axis']='local +Z (glTF)';empty('Muzzle',(0,-3.205,2.045),barrel)
# rear turret stowage bin: distinctive broad square bustle
for label,x in [('Bin',0)]:
 g=Geo();g.box((x,.68,2.055),(1.15,.43,.52));g.box((x,.68,2.326),(1.18,.47,.035));
 for xx in [-.42,.42]:g.box((xx,.916,2.08),(.045,.027,.32),'Steel');handle(g,(xx-.07,.923,2.14),(xx+.07,.923,2.14),(0,.027,0))
 g.obj('Part_Turret_'+label,(x,.68,2.09),tur,bevel=.025)
g=Geo();g.ring((.46,-.12,2.469),.303,.278,.042,n=48)
for i in range(8):
 a=i*pi/4;g.box((.46+.27*cos(a),-.12+.27*sin(a),2.485),(.07,.045,.04),'Steel',Matrix.Rotation(a,3,'Z'))
g.obj('Part_Turret_Cupola',(.46,-.12,2.472),tur,bevel=.005)
g=Geo();g.box((-.08,-1.12,2.45),(.16,.12,.052));g.box((-.08,-1.16,2.481),(.115,.03,.018),'Interior');g.box((.68,-.72,2.45),(.12,.14,.06));g.obj('Part_Turret_Sights',(.3,-.94,2.46),tur,bevel=.007)
g=Geo();g.cyl((-.71,.13,2.46),.053,.075,'Steel',n=20);g.cyl((-.71,.13,2.75),.0055,.53,'Steel',n=10);g.obj('Part_Turret_Antenna',(-.71,.13,2.70),tur)
# No roof MG: the Mk VII in this reference has no roof machine gun.
# Individual closed crew lids with hinge pivots and handles.
for name,c,r,pivot,parent in [('Hatch_Commander',(.46,-.12,2.482),.272,(.46,.135,2.482),tur),('Hatch_Loader',(-.38,-.62,2.47),.218,(-.38,-.41,2.47),tur),('Hatch_Driver',(-.45,-2.28,1.588),.266,(-.45,-2.03,1.588),hull)]:
 g=Geo()
 if name=='Hatch_Commander':g.cyl(c,r,.033,'Paint',n=48);g.ring((c[0],c[1],c[2]+.019),r*.89,r*.86,.009,'Steel',n=48)
 else:
  g.box(c,(r*1.95,r*1.90,.045));bolts(g,[(c[0]+x*r*.76,c[1]+y*r*.72,c[2]+.026) for x in [-1,1] for y in [-1,1]],'Z',.010,'Paint')
 g.rod((pivot[0]-.15,pivot[1],pivot[2]),(pivot[0]+.15,pivot[1],pivot[2]),.025,'Steel',16)
 handle(g,(c[0]-.08,c[1],c[2]+.02),(c[0]+.08,c[1],c[2]+.02));g.obj(name,pivot,parent,bevel=.006)
for name,p,parent in [('Seat_Commander',(.46,-.12,2.55),tur),('Seat_Driver',(-.45,-2.28,1.64),hull),('Exhaust',(0,3.59,1.20),None),('Light_Head_L',(1.01,-3.02,1.25),None),('Light_Head_R',(-1.01,-3.02,1.25),None),('Fx_Engine',(0,2.1,1.57),None),('Fx_Ammo',(0,-.7,.85),None),('Fx_Hatch',(.46,-.12,2.54),tur)]:empty(name,p,parent)
# Move turret ring and all its children 0.42m aft to the reference position.
tur.location.y+=.42;bpy.context.view_layer.update()
TUR=tuple(tur.matrix_world.translation);GUN=tuple(gun.matrix_world.translation);BORE=tuple(barrel.matrix_world.translation)
# Sparse chipped edge fragments are geometry sharing the Steel material. Their small
# irregular glints supplement the neutral photographic wear baked in Paint.
for obj in list(HEALTH):
 if obj.type!='MESH' or not (obj.name.startswith('Part_') or obj.name=='Turret'):continue
 if any(k in obj.name for k in ['Antenna','TowCable','Tools','SpareTracks']):continue
 bm=bmesh.new();bm.from_mesh(obj.data);gg=Geo()
 for e in bm.edges:
  if len(e.link_faces)!=2 or e.calc_length()<.10:continue
  if e.calc_face_angle()<.65:continue
  if random.random()>.36:continue
  p0=obj.matrix_world@e.verts[0].co;p1=obj.matrix_world@e.verts[1].co
  normal=(obj.matrix_world.to_3x3()@e.link_faces[0].normal).normalized();direction=(p1-p0).normalized();across=normal.cross(direction).normalized()
  for k in range(random.randint(1,3)):
   c=p0.lerp(p1,random.uniform(.1,.9))+normal*.0015
   length=random.uniform(.009,.04);width=random.uniform(.0015,.0035)
   vv=[c-direction*length,c+direction*length*.7,c+direction*length*.4+across*width,c-direction*length*.8+across*width*.5]
   gg.add(vv,[(0,1,2,3)],'Steel')
 bm.free()
 if gg.f:
  extra=gg.obj('_Wear',healthy=False);active(obj);extra.select_set(True);bpy.ops.object.join()
for o in HEALTH:
 if o.type!='MESH' or not o.name.startswith('Part_'):continue
 bpy.context.view_layer.update();centre=sum((Vector(c) for c in o.bound_box),Vector())/8
 worldcentre=o.matrix_world@centre
 for v in o.data.vertices:v.co-=centre
 world=o.matrix_world.copy();world.translation=worldcentre;o.matrix_world=world
print('HEALTH_GEOMETRY_DONE',time.time()-START,flush=True)

# Helpers: export in the agreed frame; patch glTF paint factor rather than depend on shader-node heuristics.
def tris(objects):return sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in objects if o.type=='MESH')
def glb_factor(path):
 raw=path.read_bytes();jlen=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jlen]);rest=raw[20+jlen:]
 for m in doc.get('materials',[]):
  if m['name'].split('.')[0]=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.13,.15,.08,1]
 payload=json.dumps(doc,separators=(',',':')).encode();payload+=b' '*((-len(payload))%4)
 path.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(payload)+len(rest))+struct.pack('<I4s',len(payload),b'JSON')+payload+rest)
 return doc

def export(objects,name):
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.hide_set(False);o.hide_render=False;o.select_set(True)
 bpy.context.view_layer.update()
 bpy.ops.export_scene.gltf(filepath=str(ROOT/name),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_animations=False,export_cameras=False,export_lights=False,export_extras=True,export_image_format='AUTO',export_materials='EXPORT',export_texcoords=True,export_normals=True)
 doc=glb_factor(ROOT/name);print('EXPORTED',name,tris(objects),(ROOT/name).stat().st_size,flush=True);return doc

def paint_display(on=True,color=None):
 m=MATS[MAT['Paint']];nt=m.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');t=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.label=='base');mix=nt.nodes.get('PaintFactor')
 if not mix:mix=nt.nodes.new('ShaderNodeMixRGB');mix.name='PaintFactor';mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(t.outputs[0],mix.inputs[1])
 mix.inputs[2].default_value=tuple(color or PAINTS[0][1])+(1,);nt.links.new(mix.outputs[0] if on else t.outputs[0],bs.inputs['Base Color'])

def cleanup_mesh(o):
 if o.type!='MESH':return
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.dissolve_degenerate(bm,dist=0.000001,edges=list(bm.edges))
 tiny=[f for f in bm.faces if f.calc_area()<0.00000001]
 if tiny:bmesh.ops.delete(bm,geom=tiny,context='FACES')
 bm.to_mesh(o.data);bm.free();o.data.update()
def budget(objects,target):
 total=tris(objects);ratio=min(1,target/total)
 for o in objects:
  if o.type!='MESH':continue
  if ratio<1:
   active(o);mod=o.modifiers.new('Game mesh density','DECIMATE');mod.ratio=ratio;bpy.ops.object.modifier_apply(modifier=mod.name)
  cleanup_mesh(o)
budget(HEALTH,138000)
healthy_doc=export(HEALTH,'churchill-tank.glb')
print('HEALTHY_CHECKPOINT',time.time()-START,flush=True)

# Damage copies retain source pivots. The intact meshes remain authoritative.
def copy_mesh(o,name,parent=None):
 bpy.context.view_layer.update();w=o.matrix_world.copy();c=o.copy();c.data=o.data.copy();c.name=name;bpy.context.collection.objects.link(c);c.parent=parent;c.matrix_parent_inverse=Matrix.Identity(4);c.matrix_world=w;bpy.context.view_layer.update()
 return c
HD=empty('Hull_Damage',(0,0,0),healthy=False);TD=empty('Turret_Damage',TUR,healthy=False);DAMAGE.extend([HD,TD])
PARTS=[o for o in HEALTH if o.name.startswith('Part_')];twins={}
for o in PARTS:
 if not any(s in o.name for s in ['Skirt','Intake','Door','Fender','EngineGrille','ExhaustShroud','Turret_Bin','Turret_Cupola','Turret_Sights']):continue
 name=o.name.replace('Part_','Dmg_',1);c=copy_mesh(o,name,TD if 'Turret_' in name else HD);DAMAGE.append(c);twins[o.name]=name
 # A heavy twist/crease driven by local position gives a recognizable source twin.
 for v in c.data.vertices:
  p=v.co;fac=max(0,min(1,(p.y+.55)/1.1));p.z+=.11*fac*fac*sin(p.y*3.1+1.3);p.x+=.07*fac*sin(p.z*8+p.y*4)
 # Jagged missing edge in sheet-metal guard pieces, open tear with thickness in source.
 if any(k in name for k in ['Skirt','Fender','EngineGrille']):
  bm=bmesh.new();bm.from_mesh(c.data);ys=[v.co.y for v in bm.verts];mx=max(ys);faces=[f for f in bm.faces if f.calc_center_median().y>mx-.10 and f.calc_center_median().x>.05]
  bmesh.ops.delete(bm,geom=faces,context='FACES');bm.to_mesh(c.data);bm.free()
 for p in c.data.polygons:
  if p.center.y>.2 or (p.center.x>.035 and p.center.z<0):p.material_index=MAT['Burnt']

# Thin scar assemblies lie on the actual armour, with irregular petalled rims and dark cores.
def scars(name,centres,normal,parent):
 g=Geo();n=Vector(normal);a=n.cross(Vector((0,0,1)))
 if a.length<.01:a=Vector((1,0,0))
 a.normalize();b=n.cross(a)
 for k,(p,r) in enumerate(centres):
  p=Vector(p)
  # soot fan of low relief chipped lobes, 2-4 mm above face
  verts=[p+n*.002]
  for j in range(20):
   ang=j*2*pi/20;rr=r*(1.7+.5*sin(j*5.3+k));verts.append(p+(a*cos(ang)+b*sin(ang))*rr+n*.0025)
  g.add(verts,[(0,j+1,(j+1)%20+1) for j in range(20)],'Burnt')
  # depression dark centre and steel splash petals; raised but not floating
  verts=[]
  for ring in [0,1,2]:
   for j in range(16):
    ang=j*2*pi/16;rr=[r*.39,r*.70,r][ring]*(1+.16*sin(j*7+k));h=[.003,.016+.01*(j%3),.0035][ring]
    verts.append(p+(a*cos(ang)+b*sin(ang))*rr+n*h)
  g.add(verts,[(i,(i+1)%16,16+(i+1)%16,16+i) for i in range(16)]+[(16+i,16+(i+1)%16,32+(i+1)%16,32+i) for i in range(16)],'Scars',True)
  g.add([p+n*.003]+[p+(a*cos(j*2*pi/16)+b*sin(j*2*pi/16))*r*.40+n*.003 for j in range(16)],[(0,j+1,(j+1)%16+1) for j in range(16)],'Burnt')
  # short grazing furrows
  for j in range(2):g.rod(p+a*(r*.85)+b*(j-.5)*r*.45+n*.004,p+a*r*2.1+b*(j-.5)*r*.22+n*.004,.005,'Scars',6)
 o=g.obj(name,tuple(parent.matrix_world.translation),parent,healthy=False);DAMAGE.append(o)
scars('Scar_Hull_Front',[((x,-2.721,z),.045) for x,z in [(-.79,1.29),(-.08,1.36),(.28,1.22),(.82,1.41)]],(0,-1,0),HD)
scars('Scar_Hull_Rear',[((x,3.438,z),.054) for x,z in [(-.65,.85),(-.25,1.05),(.29,.9),(.71,1.1)]],(0,1,0),HD)
for side,label in [(1,'L'),(-1,'R')]:
 scars('Scar_Hull_'+label,[((side*1.492,y,z),.051) for y,z in [(-2.2,.91),(-.74,.84),(.51,1.11),(2.5,.90)]],(side,0,0),HD)
 scars('Scar_Turret_'+label,[((side*1.008,y+.42,z),.045) for y,z in [(-.85,1.95),(-.53,2.20),(-.21,1.92),(.08,2.13)]],(side,0,0),TD)
scars('Scar_Turret_Front',[((x,-1.008,z),.039) for x,z in [(-.59,2.00),(-.38,2.22),(.40,2.21),(.63,1.88)]],(0,-1,0),TD)
scars('Scar_Turret_Rear',[((x,.92,z),.039) for x,z in [(-.57,2.05),(-.27,2.27),(.28,2.26),(.57,2.04)]],(0,1,0),TD)
# Thrown track: same belt links mapped onto a slack path, front snapped and unrolled on ground.
for label,side in [('L',1),('R',-1)]:
 o=OB['Track_'+label];c=copy_mesh(o,'Track_'+label+'_Broken',HD)
 # Repose the original individual rigid shoes onto an open, fallen chain.
 # The bottom run remains under the wheels, the rear return falls sideways,
 # and the former upper run now lies on the ground alongside and ahead.
 control=[(side*1.30,-2.70,.050),(side*1.30,2.70,.050),(side*1.30,3.12,.14),(side*1.30,3.45,.35),(side*1.30,3.58,.72),(side*1.36,3.45,1.05),(side*1.62,3.04,.72),(side*1.84,2.64,.08),(side*1.88,-4.9,.05)]
 route=[Vector(q) for q in control];arc=[0.]
 for a,b in zip(route,route[1:]):arc.append(arc[-1]+(b-a).length)
 def broken_sample(u):
  d=u*arc[-1];k=min(len(route)-2,max(0,int(np.searchsorted(arc,d,side='right')-1)));t=(d-arc[k])/(arc[k+1]-arc[k]);v=route[k].lerp(route[k+1],t);direction=route[k+1]-route[k];return v,math.atan2(direction.z,direction.y)
 oldcentres=[];oldangles=[]
 for j in range(LINKS):
  q,t=sample((j+.5)/LINKS);oldcentres.append(Vector((side*1.30,q.x,q.y)));oldangles.append(math.atan2(t.y,t.x))
 # Connected components preserve the shoe, cleat and hinge pin shapes.
 adj=[[] for v in c.data.vertices]
 for e in c.data.edges:
  a,b=e.vertices;adj[a].append(b);adj[b].append(a)
 todo=set(range(len(adj)))
 while todo:
  seed=todo.pop();component=[seed];stack=[seed]
  while stack:
   for j in adj[stack.pop()]:
    if j in todo:todo.remove(j);stack.append(j);component.append(j)
  centre=sum((o.matrix_world@c.data.vertices[j].co for j in component),Vector())/len(component)
  idx=min(range(LINKS),key=lambda j:(centre.y-oldcentres[j].y)**2+(centre.z-oldcentres[j].z)**2)
  newcentre,angle=broken_sample((idx+.5)/LINKS);rot=Matrix.Rotation(angle-oldangles[idx],3,'X')
  for j in component:
   old=o.matrix_world@c.data.vertices[j].co;c.data.vertices[j].co=c.matrix_world.inverted()@(newcentre+rot@(old-oldcentres[idx]))
 c.name='Track_'+label+'_Broken';DAMAGE.append(c)
# Broken gun built by copying mantlet, and deforming the actual tube downward.
gunbroken=copy_mesh(gun,'Gun_Broken',TD);brokenbarrel=copy_mesh(barrel,'_BrokenTube')
for v in brokenbarrel.data.vertices:
 p=brokenbarrel.matrix_world@v.co;d=max(0,-p.y-1.68);p.z-=d*d*.46
 if p.y<-2.28:p.x+=.035*sin(p.z*87)
 v.co=brokenbarrel.matrix_world.inverted()@p
active(gunbroken);brokenbarrel.select_set(True);bpy.ops.object.join();DAMAGE.append(gunbroken)
# Wreck hull: copied full assembly; suspension sag, open hatches, torn-up engine deck.
turset={tur,*tur.children_recursive}
hcopies=[];tcopies=[]
for o in HEALTH:
 if o.type!='MESH':continue
 is_tur=o in turset
 if o.name.startswith('Part_') and any(k in o.name for k in ['Skirt_L1','Skirt_R3','Fender_FL','Intake_R','Tools','Antenna','Turret_Bin']):continue
 src=next((d for d in DAMAGE if d.name==o.name.replace('Part_','Dmg_',1)),o)
 if o.name=='Track_L':src=next(d for d in DAMAGE if d.name=='Track_L_Broken')
 c=copy_mesh(src,'_Wreck_'+o.name)
 for v in c.data.vertices:
  w=c.matrix_world@v.co
  if not is_tur:
   if o.name.startswith('Wheel_'):w.z=max(.02,w.z-.05)
   elif o.name.startswith('Track_'):w.z=max(.018,w.z-.035*(1+sin(w.y*2)))
   if o.name.startswith('Part_EngineGrille'):w.z+=.27*(1-abs(w.x)/1.1)+.13*sin(w.y*8)
  v.co=c.matrix_world.inverted()@w
 if o.name.startswith('Wheel_'):
  bm=bmesh.new();bm.from_mesh(c.data);ff=[f for f in bm.faces if c.data.materials[f.material_index].name=='Rubber'];bmesh.ops.delete(bm,geom=ff,context='FACES');bm.to_mesh(c.data);bm.free()
 c.data.materials.clear();c.data.materials.append(MATS[MAT['Burnt']])
 for p in c.data.polygons:p.material_index=0
 if o.name.startswith('Hatch_'):c.rotation_euler.x=math.radians(-100)
 if is_tur and o.name in ['Gun','Barrel']:
  mat=Matrix.Translation(Vector(GUN))@Matrix.Rotation(math.radians(18),4,'X')@Matrix.Translation(-Vector(GUN));c.matrix_world=mat@c.matrix_world
 (tcopies if is_tur else hcopies).append(c)

def join_wreck(obs,name,pivot):
 active(obs[0])
 for o in obs:o.select_set(True)
 bpy.ops.object.join();o=bpy.context.object;o.name=name
 # change data origin only, preserving placement
 delta=Vector(pivot)-o.matrix_world.translation;local=o.matrix_world.to_3x3().inverted()@delta
 for v in o.data.vertices:v.co-=local
 o.location=pivot
 # Separate wreck has around 40 percent of healthy geometry.
 mod=o.modifiers.new('Wreck density budget','DECIMATE');mod.ratio=.39;active(o);bpy.ops.object.modifier_apply(modifier=mod.name)
 return o
wh=join_wreck(hcopies,'Wreck_Hull',(0,0,0));wt=join_wreck(tcopies,'Wreck_Turret',TUR)
# Open turret ring well, dark burnt interior; closed turret underside with basket stub.
cut(wh,cutter_cyl((0,-.05,1.49),.79,.62))
g=Geo();g.ring((0,-.05,1.40),.87,.79,.30,'Burnt',n=48);g.cyl((0,-.05,1.13),.79,.025,'Burnt',n=48);g.box((0,-.05,1.19),(.44,.40,.10),'Burnt');extra=g.obj('_RingWell',healthy=False)
active(wh);extra.select_set(True);bpy.ops.object.join()
g=Geo();g.ring((0,-.05,1.58),.78,.65,.16,'Burnt',n=48);g.cyl((0,-.05,1.51),.66,.04,'Burnt',n=48)
for a in range(4):g.rod((.51*cos(a*pi/2),-.05+.51*sin(a*pi/2),1.51),(.47*cos(a*pi/2),-.05+.47*sin(a*pi/2),1.14),.029,'Burnt',10)
g.ring((0,-.05,1.14),.52,.47,.055,'Burnt',n=32);extra=g.obj('_Basket',healthy=False)
active(wt);extra.select_set(True);bpy.ops.object.join();DAMAGE.extend([wh,wt])
# Damage contract limits material vocabulary to five names.
for o in DAMAGE:
 if o.type!='MESH':continue
 for i,m in enumerate(o.data.materials):
  if m and m.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:o.data.materials[i]=MATS[MAT['Scars' if m.name=='Steel' else 'Burnt']]
for o in HEALTH:o.hide_render=True;o.hide_set(True)
# Normalize the complete pair in mesh data, preserving all moving-node rotations/scales.
bpy.context.view_layer.update()
pp=[o.matrix_world@v.co for o in HEALTH if o.type=='MESH' for v in o.data.vertices]
lo=Vector([min(p[i] for p in pp) for i in range(3)]);hi=Vector([max(p[i] for p in pp) for i in range(3)])
roofmax=max((o.matrix_world@v.co).z for o in HEALTH if o.type=='MESH' and o.name!='Part_Turret_Antenna' for v in o.data.vertices)
S=Vector((3.25/(hi.x-lo.x),7.44/(hi.y-lo.y),2.49/(roofmax-lo.z)));C=Vector(((hi.x+lo.x)/2,(hi.y+lo.y)/2,lo.z))
def remap(p):return Vector(((p.x-C.x)*S.x,(p.y-C.y)*S.y,(p.z-C.z)*S.z))
oldworld={o:o.matrix_world.copy() for o in HEALTH+DAMAGE}
for o,w in oldworld.items():
 if o.type=='MESH':
  neworigin=remap(w.translation)
  for v in o.data.vertices:v.co=remap(w@v.co)-neworigin
# Parent-first hierarchy update; no transform-apply operation touches a rig node.
def depth(o):return 0 if not o.parent else 1+depth(o.parent)
for o in sorted(HEALTH+DAMAGE,key=depth):
 w=Matrix.Identity(4);w.translation=remap(oldworld[o].translation);o.matrix_world=w;bpy.context.view_layer.update()
# Logical root origins stay on the footprint centre ground plane; data stays in place.
for o in [hull,HD,wh]:
 delta=o.matrix_world.translation.copy()
 if o.type=='MESH':
  for v in o.data.vertices:v.co+=delta
 children={c:c.matrix_world.copy() for c in o.children};o.location=(0,0,0);bpy.context.view_layer.update()
 for c,w in children.items():c.matrix_world=w
healthy_doc=export(HEALTH,'churchill-tank.glb')
for o in HEALTH:o.hide_render=True;o.hide_set(True)
budget(DAMAGE,104000)
damage_doc=export(DAMAGE,'churchill-tank-damage.glb')
print('DAMAGE_DONE',time.time()-START,flush=True)

# Exported-node metadata in game coordinates, including precise pivots and hierarchy.
def game(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
def manifest(objects):
 out=[];points=[]
 bpy.context.view_layer.update()
 for o in objects:
  row={'name':o.name,'position':game(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type}
  if o.type=='MESH':
   pts=[o.matrix_world@Vector(c) for c in o.bound_box];points+=pts;row['triangles']=sum(len(p.vertices)-2 for p in o.data.polygons)
  if 'wheel_role' in o:row['role']=o['wheel_role']
  out.append(row)
 pp=[game(p) for p in points];bounds={'min':[min(p[i] for p in pp) for i in range(3)],'max':[max(p[i] for p in pp) for i in range(3)]}
 return {'nodes':out,'bounds':bounds,'triangles':tris(objects)}
meta={'contract':'tank-damage/v1','units':'metres','forward':'+Z','up':'+Y','left':'+X','healthy':manifest(HEALTH),'damage_file':manifest(DAMAGE),'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS], 'damage':{'parts':[{'part':o.name,'twin':twins.get(o.name)} for o in PARTS],'scars':[o.name for o in DAMAGE if o.name.startswith('Scar_')],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'rig':{'Turret':'Y','Gun':'X','Barrel':'Z recoil','wheels':'X','hatches':'X'},'source':{'ids':['green_metal_rust','rusty_metal_02'],'license':'CC0','url':'https://polyhaven.com/license'},'dimensions_nominal':[3.25,2.49,7.44]}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))

# Everything below is proof from the exported assets, imported into an empty scene.
bpy.ops.object.select_all(action='SELECT');
for o in bpy.context.scene.objects:o.hide_set(False);o.select_set(True)
bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials):bpy.data.materials.remove(m)
for ng in list(bpy.data.node_groups):
 if ng.name.startswith('glTF Material Output'):bpy.data.node_groups.remove(ng)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'churchill-tank.glb'));healthy_import=list(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'churchill-tank-damage.glb'));damage_import=[o for o in bpy.context.scene.objects if o not in healthy_import]
IM={o.name:o for o in bpy.context.scene.objects}
for o in damage_import:o.hide_render=True
# material factor is imported as a multiplier. Find its RGB factor input robustly.
paint=bpy.data.materials.get('Paint')
def recolor(rgb):
 bs=next(n for n in paint.node_tree.nodes if n.type=='BSDF_PRINCIPLED');nt=paint.node_tree
 link=bs.inputs['Base Color'].links[0] if bs.inputs['Base Color'].is_linked else None
 if link and link.from_node.type=='MIX':
  node=link.from_node
  for inp in node.inputs:
   if inp.type=='RGBA' and not inp.is_linked:inp.default_value=tuple(rgb)+(1,)
 elif link and link.from_node.type=='MIX_RGB':
  node=link.from_node
  for i in [1,2]:
   if not node.inputs[i].is_linked:node.inputs[i].default_value=tuple(rgb)+(1,)
 else:
  tex=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and ('base' in n.image.name.lower() or n.image.colorspace_settings.name=='sRGB'))
  mx=nt.nodes.get('RecolourProof') or nt.nodes.new('ShaderNodeMixRGB');mx.name='RecolourProof';mx.blend_type='MULTIPLY';mx.inputs[0].default_value=1;mx.inputs[2].default_value=tuple(rgb)+(1,);nt.links.new(tex.outputs[0],mx.inputs[1]);nt.links.new(mx.outputs[0],bs.inputs['Base Color'])
# Floor and soft daylight; no scene extras entered either export.
mat=bpy.data.materials.new('Studio floor');mat.diffuse_color=(.19,.21,.22,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.19,.21,.22,1);mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.95
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.033));floor=bpy.context.object;floor.name='_StudioFloor';floor.data.materials.append(mat)
world=sc.world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs['Color'].default_value=(.55,.65,.78,1);world.node_tree.nodes.get('Background').inputs['Strength'].default_value=.38
for name,loc,power,size,col in [('Key',(2,-5,9),1400,6,(1,.91,.77)),('Fill',(-6,-2,5),850,5,(.75,.85,1)),('Rim',(2,6,7),1400,5,(1,1,1))]:
 data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;data.color=col;o=bpy.data.objects.new(name,data);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam
# Camera based on asset bounds, same across all state comparisons.
pts=[o.matrix_world@Vector(c) for o in healthy_import if o.type=='MESH' for c in o.bound_box];lo=Vector([min(p[i] for p in pts) for i in range(3)]);hi=Vector([max(p[i] for p in pts) for i in range(3)]);target=(lo+hi)/2;target.z=1.20
cam.data.type='ORTHO';cam.data.ortho_scale=9.6
cam.location=target+Vector((8,-11,5.4));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()
hero_loc=cam.location.copy();hero_rot=cam.rotation_euler.copy()
def render(name,w=1920,h=1080,samples=24):
 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('RENDERED',name,flush=True)
render('final.png')
cam.location=(12,0,1.3);cam.rotation_euler=(Vector((0,0,1.3))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=8.45;render('side.png',1920,1080,20)
cam.location=hero_loc;cam.rotation_euler=hero_rot;cam.data.ortho_scale=9.6
clay=bpy.data.materials.new('Clay proof');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.38,.38,.38,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.72
sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,20);sc.view_layers[0].material_override=None
# Rig test: all moving nodes rotated 30 degrees around their exact local pivots.
rotations={};pivot_errors={}
for o in healthy_import:
 if o.name in ['Turret','Gun','Barrel'] or o.name.startswith('Wheel_') or o.name.startswith('Hatch_'):
  o.rotation_mode='XYZ';rotations[o.name]=o.rotation_euler.copy();before=o.matrix_world.translation.copy();axis=2 if o.name=='Turret' else 0;o.rotation_euler[axis]+=pi/6;bpy.context.view_layer.update();pivot_errors[o.name]=(o.matrix_world.translation-before).length
render('rig-test.png',1280,720,12)
for name,r in rotations.items():IM[name].rotation_euler=r
(ROOT/'rig-check.json').write_text(json.dumps({'degrees':30,'pivot_translation_errors_metres':pivot_errors,'maximum':max(pivot_errors.values()),'note':'Turret Z-up source axis exports to Y; gun/wheels/hinges X. Barrel 30-degree roll test plus recoil socket.'},indent=2))
# Contact sheet assembled using Blender image buffers; images generated by real exported geometry only.
def grid(paths,out):
 ims=[bpy.data.images.load(str(ROOT/p),check_existing=False) for p in paths];w,h=ims[0].size;canvas=np.zeros((h*2,w*3,4),dtype=np.float32)
 for i,im in enumerate(ims):
  pix=np.array(im.pixels[:],dtype=np.float32).reshape(h,w,4);row=1-i//3;col=i%3;canvas[row*h:(row+1)*h,col*w:(col+1)*w]=pix
 im=bpy.data.images.new(out,width=w*3,height=h*2);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/out);im.file_format='PNG';im.save()
 for im0 in ims:bpy.data.images.remove(im0)
paintpaths=[]
for i,(name,c) in enumerate(PAINTS):
 recolor(c);p=f'_paint_{i}.png';render(p,640,400,12);paintpaths.append(p)
grid(paintpaths,'paints.png');recolor(PAINTS[0][1])
# Make damage Paint use exactly the same image/factor as healthy Paint after import.
for o in damage_import:
 if o.type=='MESH':
  for j,m in enumerate(o.data.materials):
   if m and m.name.startswith('Paint'):o.data.materials[j]=paint

def reset():
 for o in healthy_import:o.hide_render=False
 for o in damage_import:o.hide_render=True

def showtree(o,show):
 o.hide_render=not show
 for c in o.children_recursive:c.hide_render=not show
paths=[]
for state in range(6):
 reset();cam.location=hero_loc;cam.rotation_euler=hero_rot;cam.data.ortho_scale=10.6
 if state==1:
  for o in damage_import:
   if o.name.startswith('Scar_'):o.hide_render=False
  for name in ['Part_Skirt_L1','Part_Skirt_L2','Part_Intake_L','Part_Fender_FL','Part_Turret_Bin','Part_Turret_Cupola']:
   IM[name].hide_render=True;IM[twins[name]].hide_render=False
 elif state==2:
  for o in healthy_import:
   if o.name.startswith('Part_'):o.hide_render=True
 elif state==3:
  IM['Track_L'].hide_render=True;IM['Track_L_Broken'].hide_render=False;showtree(IM['Gun'],False);IM['Gun_Broken'].hide_render=False
 elif state>=4:
  for o in healthy_import:o.hide_render=True
  IM['Wreck_Hull'].hide_render=False;IM['Wreck_Turret'].hide_render=False
  if state==5:
   t=IM['Wreck_Turret'];t.rotation_mode='XYZ';t.location=(2.8,.2,.94);t.rotation_euler=(.2,1.95,-.6);bpy.context.view_layer.update();minz=min((t.matrix_world@v.co).z for v in t.data.vertices);t.location.z-=minz;cam.data.ortho_scale=11.7
 p=f'_state_{state}.png';render(p,640,400,14);paths.append(p)
grid(paths,'states.png')
# Six-line final notes: exact measured counts and explicit remaining differences.
notes=[
'Churchill Mk VII style British infantry tank; rigged healthy and tank-damage/v1 assets, +Z forward, metres, no insignia.',
f'Triangles: healthy {meta["healthy"]["triangles"]:,}; damage {meta["damage_file"]["triangles"]:,}; both measured before glTF triangulation; see nodes.json.',
'Materials: Paint, TrackSteel, Rubber, Steel, GunSteel, Interior, Lamps; damage additionally Burnt and Scars (five material vocabulary in damage file).',
'Textures: Paint base/normal 1024; other base/normal 128–512; every ORM at most 512; Cycles OptiX baked shared tiling sets.',
'Poly Haven CC0 ids: green_metal_rust, rusty_metal_02; https://polyhaven.com/license ; downloaded diff, nor_gl, arm maps from dl.polyhaven.org.',
'Differences: reconstructed from the supplied sheet, not a surveyed restoration; antenna exceeds nominal roof height; internal mechanisms simplified, no roof MG as on reference.'
]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
for p in paintpaths+paths:(ROOT/p).unlink(missing_ok=True)
print('COMPLETE',time.time()-START,flush=True)
