"""A7V game asset. Blender 5.2: -b --python-exit-code 1 --python build_a7v-tank.py
Clean build includes CC0 downloads, Cycles GPU material bakes, two exports and import proofs.
Authoring coordinates: X left, -Y forward, Z up. glTF conversion produces X left,Y up,Z forward.
"""
import bpy, bmesh, math, os, json, random, struct, urllib.request, sys, subprocess
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin,cos,pi
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(74)
# Reference dimensions refer to the armour envelope, excluding gun barrels.
DIM=Vector((3.07/3.038,7.34/6.871,1.0))
def scaled(p):return Vector((p[0]*DIM.x,p[1]*DIM.y,p[2]*DIM.z))
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene;S.unit_settings.system='METRIC';S.render.engine='CYCLES'
S.cycles.samples=24;S.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for dev in prefs.devices:dev.use=dev.type=='OPTIX'
S.cycles.device='GPU';S.cycles.bake_type='EMIT'
S.render.image_settings.file_format='PNG';S.view_settings.view_transform='AgX'
PAINTS=[('field grey',[.19,.22,.19]),('olive drab',[.20,.22,.10]),('desert tan',[.55,.42,.26]),('winter white',[.78,.79,.73]),('dark green-black',[.055,.075,.06]),('panzer grey',[.115,.135,.15])]
IDS=['metal_plate','rusty_metal_03']
def fetch(url,path):
 if not path.exists():
  req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
  with urllib.request.urlopen(req,timeout=90) as r:path.write_bytes(r.read())
for ident in IDS:
 for mp in ['diff','nor_gl','arm']:
  p=TEX/f'{ident}_{mp}_1k.jpg'
  fetch(f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{mp}_1k.jpg',p)
print('TEXTURES_READY',flush=True)
MATS={};BAKES=[]
def texture(nt,path,noncolor=False):
 n=nt.nodes.new('ShaderNodeTexImage');n.image=bpy.data.images.load(str(path),check_existing=True)
 if noncolor:n.image.colorspace_settings.name='Non-Color'
 return n

def make_mat(name,colour,metal,rough,ident='metal_plate',res=512):
 # Bake a shared 2 m tiling/trim swatch; geometry uses metre-scaled UVs.
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();lk=nt.links
 out=nt.nodes.new('ShaderNodeOutputMaterial');p=nt.nodes.new('ShaderNodeBsdfPrincipled');lk.new(p.outputs[0],out.inputs['Surface'])
 uv=nt.nodes.new('ShaderNodeTexCoord');diff=texture(nt,TEX/f'{ident}_diff_1k.jpg');norm=texture(nt,TEX/f'{ident}_nor_gl_1k.jpg',True);arm=texture(nt,TEX/f'{ident}_arm_1k.jpg',True)
 for n in [diff,norm,arm]:lk.new(uv.outputs['UV'],n.inputs['Vector'])
 bw=nt.nodes.new('ShaderNodeRGBToBW');lk.new(diff.outputs['Color'],bw.inputs[0])
 ramp=nt.nodes.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.08;ramp.color_ramp.elements[1].position=.8
 if name=='Paint':lo=(.46,.46,.45,1);hi=(.94,.95,.94,1)
 else:lo=(*[c*.40 for c in colour],1);hi=(*[min(1,c*1.6) for c in colour],1)
 ramp.color_ramp.elements[0].color=lo;ramp.color_ramp.elements[1].color=hi;lk.new(bw.outputs[0],ramp.inputs[0])
 mapping=nt.nodes.new('ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(48,2.3,1);lk.new(uv.outputs['UV'],mapping.inputs[0])
 noise=nt.nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=4;noise.inputs['Detail'].default_value=3;lk.new(mapping.outputs[0],noise.inputs['Vector'])
 grime=nt.nodes.new('ShaderNodeValToRGB');grime.color_ramp.elements[0].position=.25;grime.color_ramp.elements[0].color=(.45,.45,.44,1);grime.color_ramp.elements[1].position=.62;grime.color_ramp.elements[1].color=(1,1,1,1);lk.new(noise.outputs['Fac'],grime.inputs[0])
 mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.15 if name=='Paint' else .42;lk.new(ramp.outputs['Color'],mix.inputs[1]);lk.new(grime.outputs['Color'],mix.inputs[2]);lk.new(mix.outputs[0],p.inputs['Base Color'])
 if name=='Paint':
  coarse=nt.nodes.new('ShaderNodeTexNoise');coarse.inputs['Scale'].default_value=7;coarse.inputs['Detail'].default_value=5;coarse.inputs['Roughness'].default_value=.72;lk.new(uv.outputs['UV'],coarse.inputs['Vector'])
  cr=nt.nodes.new('ShaderNodeValToRGB');cr.color_ramp.elements[0].position=.27;cr.color_ramp.elements[0].color=(.48,.48,.47,1);cr.color_ramp.elements[1].position=.72;cr.color_ramp.elements[1].color=(1,1,1,1);lk.new(coarse.outputs['Fac'],cr.inputs[0])
  cm=nt.nodes.new('ShaderNodeMixRGB');cm.blend_type='MULTIPLY';cm.inputs[0].default_value=.5;lk.new(mix.outputs[0],cm.inputs[1]);lk.new(cr.outputs[0],cm.inputs[2]);mix=cm
  uvsep=nt.nodes.new('ShaderNodeSeparateXYZ');lk.new(uv.outputs['UV'],uvsep.inputs[0])
  mudedge=nt.nodes.new('ShaderNodeMath');mudedge.operation='MULTIPLY_ADD';mudedge.inputs[1].default_value=.16;lk.new(coarse.outputs['Fac'],mudedge.inputs[0]);lk.new(uvsep.outputs['Y'],mudedge.inputs[2])
  mudr=nt.nodes.new('ShaderNodeValToRGB');mudr.color_ramp.elements[0].position=.10;mudr.color_ramp.elements[0].color=(.20,.20,.19,1);mudr.color_ramp.elements[1].position=.38;mudr.color_ramp.elements[1].color=(1,1,1,1);lk.new(mudedge.outputs[0],mudr.inputs[0])
  dirty=nt.nodes.new('ShaderNodeMixRGB');dirty.blend_type='MULTIPLY';dirty.inputs[0].default_value=.80;lk.new(mix.outputs[0],dirty.inputs[1]);lk.new(mudr.outputs[0],dirty.inputs[2]);mix=dirty;lk.new(mix.outputs[0],p.inputs['Base Color'])
 nm=nt.nodes.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.055 if name=='Paint' else .30;lk.new(norm.outputs[0],nm.inputs['Color']);lk.new(nm.outputs[0],p.inputs['Normal'])
 sep=nt.nodes.new('ShaderNodeSeparateColor');lk.new(arm.outputs[0],sep.inputs[0])
 rr=nt.nodes.new('ShaderNodeMapRange');rr.inputs['To Min'].default_value=max(.42,rough-.12);rr.inputs['To Max'].default_value=min(.98,rough+.12);lk.new(sep.outputs[1],rr.inputs[0]);lk.new(rr.outputs[0],p.inputs['Roughness']);p.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=2);o=bpy.context.object;o.name='BakeFixture';o.data.materials.append(m)
 active=nt.nodes.new('ShaderNodeTexImage');nt.nodes.active=active
 emit=nt.nodes.new('ShaderNodeEmission')
 baked={}
 for mode,sz in [('base',res),('normal',res),('orm',512)]:
  im=bpy.data.images.new(name+'_'+mode,width=sz,height=sz,alpha=False)
  if mode!='base':im.colorspace_settings.name='Non-Color'
  active.image=im;nt.nodes.active=active
  if mode=='base':
   lk.new(mix.outputs[0],emit.inputs[0]);lk.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
  elif mode=='normal':
   lk.new(p.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
  else:
   comb=nt.nodes.new('ShaderNodeCombineColor');lk.new(sep.outputs[0],comb.inputs[0]);comb.inputs[2].default_value=metal;lk.new(rr.outputs[0],comb.inputs[1]);lk.new(comb.outputs[0],emit.inputs[0]);lk.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=str(TEX/f'{name}_{mode}.png');im.file_format='PNG';im.save();baked[mode]=im
 bpy.data.objects.remove(o,do_unlink=True)
 nt.nodes.clear();out=nt.nodes.new('ShaderNodeOutputMaterial');p=nt.nodes.new('ShaderNodeBsdfPrincipled');lk.new(p.outputs[0],out.inputs[0])
 bn=nt.nodes.new('ShaderNodeTexImage');bn.image=baked['base'];nn=nt.nodes.new('ShaderNodeTexImage');nn.image=baked['normal'];on=nt.nodes.new('ShaderNodeTexImage');on.image=baked['orm']
 if name=='Paint':
  mul=nt.nodes.new('ShaderNodeMixRGB');mul.name='PaintFactor';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(*PAINTS[0][1],1);lk.new(bn.outputs[0],mul.inputs[1]);lk.new(mul.outputs[0],p.inputs['Base Color'])
 else:lk.new(bn.outputs[0],p.inputs['Base Color'])
 normal=nt.nodes.new('ShaderNodeNormalMap');lk.new(nn.outputs[0],normal.inputs[0 if False else 'Color']);lk.new(normal.outputs[0],p.inputs['Normal'])
 sep=nt.nodes.new('ShaderNodeSeparateColor');lk.new(on.outputs[0],sep.inputs[0]);lk.new(sep.outputs[1],p.inputs['Roughness']);lk.new(sep.outputs[2],p.inputs['Metallic'])
 # Exporter-supported occlusion group.
 group=bpy.data.node_groups.get('glTF Material Output')
 if not group:
  group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=nt.nodes.new('ShaderNodeGroup');gn.node_tree=group;lk.new(sep.outputs[0],gn.inputs['Occlusion'])
 if name=='Lamps':p.inputs['Emission Color'].default_value=(.55,.39,.19,1);p.inputs['Emission Strength'].default_value=.1
 MATS[name]=m;BAKES.append({'material':name,'colour_normal':res,'ORM':512,'device':'OPTIX','passes':['EMIT base','NORMAL tangent','EMIT ORM'],'source':ident})
 print('BAKED',name,flush=True)
for args in [('Paint',(1,1,1),.12,.82,'metal_plate',1024),('TrackSteel',(.13,.12,.10),.78,.68,'rusty_metal_03',512),('Rubber',(.026,.025,.023),.03,.94,'metal_plate',512),('Weapons',(.11,.12,.115),.82,.43,'metal_plate',512),('Rust',(.19,.085,.047),.50,.79,'rusty_metal_03',512),('Interior',(.018,.02,.018),.1,.91,'metal_plate',512),('Lamps',(.65,.56,.37),.3,.32,'metal_plate',512),('Burnt',(.075,.07,.064),.35,.94,'rusty_metal_03',512),('Scars',(.31,.29,.25),.8,.63,'rusty_metal_03',512)]:make_mat(*args)
MATLIST=list(MATS.values());MI={m.name:i for i,m in enumerate(MATLIST)}
# Batched parametric geometry. Separate rigid/moving assemblies preserve pivots.
CACHE={}
def box_template(size,bev):
 key=(tuple(round(x,5) for x in size),round(bev,5))
 if key in CACHE:return CACHE[key]
 bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
 for v in bm.verts:v.co.x*=size[0];v.co.y*=size[1];v.co.z*=size[2]
 if bev:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=bev,segments=2,affect='EDGES',clamp_overlap=True)
 bm.verts.ensure_lookup_table();bm.verts.index_update();v=[tuple(v.co) for v in bm.verts];f=[[v.index for v in p.verts] for p in bm.faces];bm.free();CACHE[key]=(v,f);return v,f
class Build:
 def __init__(self):self.v=[];self.f=[];self.mi=[];self.smooth=[];self.uv=[]
 def add(self,verts,faces,mat='Paint',smooth=False,uv=None):
  off=len(self.v);self.v.extend([tuple(v) for v in verts]);self.f.extend([[off+i for i in f] for f in faces]);self.mi.extend([MI[mat]]*len(faces));self.smooth.extend([smooth]*len(faces));self.uv.extend(uv if uv else [None]*len(faces))
 def box(self,c,d,mat='Paint',bev=.009,rot=None):
  v,f=box_template(d,bev);R=rot or Matrix.Identity(3);self.add([R@Vector(p)+Vector(c) for p in v],f,mat)
 def cyl(self,a,b,r,mat='Weapons',n=16,r2=None):
  a,b=Vector(a),Vector(b);q=(b-a).to_track_quat('Z','Y');r2=r if r2 is None else r2
  verts=[a+q@Vector((r*cos(2*pi*i/n),r*sin(2*pi*i/n),0)) for i in range(n)]+[b+q@Vector((r2*cos(2*pi*i/n),r2*sin(2*pi*i/n),0)) for i in range(n)]
  fs=[list(reversed(range(n))),list(range(n,2*n))]+[[i,(i+1)%n,(i+1)%n+n,i+n] for i in range(n)];self.add(verts,fs,mat,True)
 def rivet(self,p,normal,r=.021,mat='Paint'):
  p=Vector(p);q=Vector(normal).to_track_quat('Z','Y');n=8
  vv=[p+q@Vector((r*cos(i*2*pi/n),r*sin(i*2*pi/n),.001)) for i in range(n)]
  vv += [p+q@Vector((r*.78*cos(i*2*pi/n),r*.78*sin(i*2*pi/n),r*.65)) for i in range(n)]
  vv += [p+q@Vector((0,0,r*.88))]
  ff=[[i,(i+1)%n,(i+1)%n+n,i+n] for i in range(n)]+[[i+n,(i+1)%n+n,2*n] for i in range(n)];self.add(vv,ff,mat,True)
 def tube(self,points,r,mat='Rust',n=12):
  points=[Vector(p) for p in points]
  if len(points)>3 and r>.04:
   src=points;points=[]
   for k in range(len(src)-1):
    p0=src[max(k-1,0)];p1=src[k];p2=src[k+1];p3=src[min(k+2,len(src)-1)]
    for j in range(5):
     t=j/5;points.append(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t))
   points.append(src[-1])
  vv=[]
  for i,p in enumerate(points):
   tangent=points[min(i+1,len(points)-1)]-points[max(0,i-1)];q=tangent.to_track_quat('Z','Y')
   vv += [p+q@Vector((r*cos(j*2*pi/n),r*sin(j*2*pi/n),0)) for j in range(n)]
  ff=[]
  for i in range(len(points)-1):
   for j in range(n):ff.append([i*n+j,i*n+(j+1)%n,(i+1)*n+(j+1)%n,(i+1)*n+j])
  ff += [list(reversed(range(n))),list(range((len(points)-1)*n,len(points)*n))];self.add(vv,ff,mat,True)
 def torus(self,c,major,minor,axis=(1,0,0),mat='TrackSteel',n=24,k=6):
  q=Vector(axis).to_track_quat('Z','Y');vv=[]
  for i in range(n):
   for j in range(k):
    a=i*2*pi/n;b=j*2*pi/k;vv.append(Vector(c)+q@Vector(((major+minor*cos(b))*cos(a),(major+minor*cos(b))*sin(a),minor*sin(b))))
  self.add(vv,[[i*k+j,((i+1)%n)*k+j,((i+1)%n)*k+(j+1)%k,i*k+(j+1)%k] for i in range(n) for j in range(k)],mat,True)
 def plate(self,points,thick=.035,mat='Paint'):
  ps=[Vector(p) for p in points];normal=(ps[1]-ps[0]).cross(ps[2]-ps[0]).normalized();n=len(ps)
  vv=[p+normal*thick*.5 for p in ps]+[p-normal*thick*.5 for p in ps];ff=[list(range(n)),list(reversed(range(n,2*n)))]+[[i,(i+1)%n,(i+1)%n+n,i+n] for i in range(n)]
  bm=bmesh.new();verts=[bm.verts.new(v) for v in vv]
  for face in ff:bm.faces.new([verts[i] for i in face])
  bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(.004,thick*.2),segments=2,affect='EDGES',clamp_overlap=True)
  bm.verts.ensure_lookup_table();bm.verts.index_update();self.add([v.co.copy() for v in bm.verts],[[v.index for v in f.verts] for f in bm.faces],mat);bm.free()
 def object(self,name,pivot=(0,0,0)):
  me=bpy.data.meshes.new(name);me.from_pydata([scaled(Vector(p)-Vector(pivot)) for p in self.v],[],self.f);me.update()
  ob=bpy.data.objects.new(name,me);S.collection.objects.link(ob);ob.location=scaled(pivot)
  for m in MATLIST:me.materials.append(m)
  uv=me.uv_layers.new(name='UVMap')
  for poly,mi,sm,custom in zip(me.polygons,self.mi,self.smooth,self.uv):
   poly.material_index=mi;poly.use_smooth=sm
   normal=poly.normal;axis=max(range(3),key=lambda i:abs(normal[i]))
   for j,li in enumerate(poly.loop_indices):
    v=Vector(self.v[me.loops[li].vertex_index])
    if custom:coord=custom[j]
    elif axis==0:coord=(v.y*.48,(v.z-.58)/2.25 if v.z<2.80 else .30+(v.z-2.77)*.9)
    elif axis==1:coord=(v.x*.48,(v.z-.58)/2.25 if v.z<2.80 else .30+(v.z-2.77)*.9)
    else:coord=(v.x*.48,v.y*.48)
    uv.data[li].uv=coord
  return ob

def empty(name,p,parent=None):
 o=bpy.data.objects.new(name,None);S.collection.objects.link(o);o.location=scaled(p)
 if parent:parent_keep(o,parent)
 return o
def parent_keep(o,p):
 bpy.context.view_layer.update();mw=o.matrix_world.copy();o.parent=p;o.matrix_world=mw

def origin_center(o):
 if o.type!='MESH':return
 c=sum((v.co for v in o.data.vertices),Vector())/len(o.data.vertices);o.data.transform(Matrix.Translation(-c));o.location+=c
 return o
H=Build();parts=[];healthy=[];wheels=[];hatches=[]
def part(b,name,pivot=None):
 o=b.object('Part_'+name,pivot or (0,0,0))
 if pivot is None:origin_center(o)
 parts.append(o);return o
# Armour: lower edge flares outward, upper shoulders taper inward.
def sx(z):return 1.48-(z-.65)*.10
# Main side plate strips with actual seam gaps over a complete interior underlayer.
sideYs=[-3.27,-2.23,-1.08,.02,1.10,2.22,3.27]
def roof(y):return 2.77-.065*max(0,abs(y)-.55)
for s in [-1,1]:
 H.plate([(s*(sx(1.10)-.06),-3.28,1.10),(s*(sx(1.10)-.06),3.28,1.10),(s*(sx(2.60)-.06),3.18,2.60),(s*(sx(2.60)-.06),-3.18,2.60)],.055)
 H.box((s*.80,0,.82),(.13,6.1,.52),'TrackSteel',.018)
 for j,(a,b) in enumerate(zip(sideYs,sideYs[1:])):
  # fitted upper panels and six detachable lower plates per side
  za,zb=roof(a),roof(b)
  H.plate([(s*(sx(1.10)+.009),a+.004,1.10),(s*(sx(1.10)+.009),b-.004,1.10),(s*(sx(zb)+.009),b-.004,zb),(s*(sx(za)+.009),a+.004,za)],.035)
  B=Build();B.plate([(s*(sx(.64)+.018),a+.008,.64),(s*(sx(.64)+.018),b-.008,.64),(s*(sx(1.11)+.022),b-.008,1.11),(s*(sx(1.11)+.022),a+.008,1.11)],.04)
  for yy in [a+.11,b-.11]:
   for zz in [.74,1.01]:B.rivet((s*(sx(zz)+.047),yy,zz),(s,0,.1),.022)
  part(B,f'Skirt_{"L" if s>0 else "R"}{j+1}')
  for yy in [a+.055,b-.055]:
   for k in range(11):
    z=1.14+k*(min(za,zb)-1.23)/10;H.rivet((s*(sx(z)+.027),yy,z),(s,0,.10),.019)
  for k in range(7):
   yy=a+.13+(b-a-.26)*k/6;z=roof(yy)-.075;H.rivet((s*(sx(z)+.023),yy,z),(s,0,.1),.019)
  # exposed mounting bosses remain underneath detachable skirts
  for yy in [a+.16,b-.16]:H.box((s*(sx(.99)+.02),yy,.99),(.075,.09,.105),'Weapons',.01)
# Small random abrasions expose steel; localised near seams rather than blanket noise.
for sg in [-1,1]:
 for i in range(95):
  yy=random.uniform(-3.0,3.0);zz=random.uniform(1.17,2.50);length=random.uniform(.025,.095);xx=sg*(sx(zz)+.0285)
  H.add([(xx,yy,zz),(xx,yy+.006,zz+.006),(sg*(sx(zz+length)+.0285),yy+.013,zz+length)],[[0,1,2]],'Weapons')
# floor and longitudinal chassis
H.box((0,0,.59),(2.66,6.50,.18),'TrackSteel',.035)
for x in [-1.05,1.05]:H.box((x,0,.46),(.16,5.9,.24),'TrackSteel',.018)
# End slopes: fronts are leaning inward; nose gun fixed recess and raised mantlet.
for end in [-1,1]:
 ybot=end*3.38;ytop=end*3.12
 for sg in [-1,1]:H.plate([(sg*sx(.64),end*3.28,.64),(sg*1.48,end*3.38,.68),(sg*1.275,end*3.12,2.60),(sg*sx(2.60),end*3.27,2.60)],.035)
 # complete face; 57mm socket raised on a dark circular seating
 H.plate([(-1.48,ybot,.68),(1.48,ybot,.68),(1.275,ytop,2.60),(-1.275,ytop,2.60)],.06)
 for x in [-1.36,-.87,0,.87,1.36]:
  for k in range(12):
   z=.79+k*.15;yy=end*(3.38-(z-.68)*.26/1.92);xx=x*(1-(z-.68)*.06)
   H.rivet((xx,yy+end*.040,z),(0,end,.14),.021)
 for z in [.78,2.51]:
  for i in range(17):
   x=-1.21+i*2.42/16;yy=end*(3.38-(z-.68)*.26/1.92);H.rivet((x,yy+end*.040,z),(0,end,.14),.021)
 # lower folded prow triangles give the distinctive central upsweep
 for s in [-1,1]:
  H.plate([(0,end*3.418,1.05),(s*1.47,end*3.418,.65),(s*1.47,end*3.418,.60),(0,end*3.418,.60)],.035,'TrackSteel')
  B=Build();B.box((s*1.36,end*3.25,.70),(.21,.41,.075),'Paint',.014);B.box((s*1.43,end*3.34,.58),(.06,.17,.27),'Paint',.013);part(B,f'Fender_{"F" if end<0 else "R"}{"L" if s>0 else "R"}')
  B=Build();B.torus((s*1.01,end*3.38,.93),.092,.025,axis=(1,0,0),mat='Weapons',n=20,k=6);B.box((s*1.01,end*3.32,.94),(.07,.15,.23),'Paint',.01);part(B,f'Tow_{"Front" if end<0 else "Rear"}_{s}')
# Roof fore/aft sloped armour; cab base sits in centre.
for a,b in [(-3.12,-.74),(.74,3.12)]:
 H.plate([(-sx(roof(a)),a,roof(a)),(sx(roof(a)),a,roof(a)),(sx(roof(b)),b,roof(b)),(-sx(roof(b)),b,roof(b))],.05)
 for yy in [a+.055,b-.055]:
  for i in range(17):H.rivet((-1.17+i*.146,yy,roof(yy)+.018),(0,0,1),.019)
for x in [-1.05,1.05]:H.box((x,0,2.74),(.42,1.55,.075),'Paint',.012)
# Engine deck grilles: separate louvres above solid dark fins and lined engine well.
for yy,label in [(-2.05,'Front'),(2.05,'Rear')]:
 slope=math.atan(.065 if yy<0 else -.065); rr=Matrix.Rotation(slope,3,'X')
 H.box((0,yy,roof(yy)+.006),(1.84,1.30,.045),'Interior',.015,rr)
 for k in range(15):H.box((-.80+k*.114,yy,roof(yy)+.030),(.032,1.18,.014),'Weapons',.003,rr)
 B=Build()
 for x in [-.91,.91]:B.box((x,yy,roof(yy)+.0425),(.045,1.36,.035),'Paint',.007,rr)
 for y in [yy-.67,yy+.67]:B.box((0,y,roof(y)+.0425),(1.86,.045,.035),'Paint',.007)
 for x in [-.62,0,.62]:
  for j in range(12):B.box((x,yy-.59+j*.108,roof(yy-.59+j*.108)+.0435),(.56,.058,.017),'Paint',.005,rr)
 for x in [-.90,.90]:
  for y in [yy-.60,yy+.60]:B.rivet((x,y,roof(y)+.061),(0,0,1),.022)
 part(B,'Grille_'+label)
# Cab: tapered square at roof centre, real recessed windows in surrounding armour.
# cab lower dimensions 1.66 x 1.50, top 1.46 x 1.28, top z3.28.
for end in [-1,1]:
 # face assembled around two deep vision ports, with sloped cheeks
 yy=end*.73
 H.box((0,end*.725,2.85),(1.65,.085,.16),'Paint',.01,Matrix.Rotation(end*.17,3,'X'))
 H.box((0,end*.652,3.235),(1.46,.085,.075),'Paint',.01)
 for x in [-.71,0,.71]:H.box((x, end*.69,3.05),(.15,.11,.35),'Paint',.012,Matrix.Rotation(end*.17,3,'X'))
 for x in [-.36,.36]:
  H.box((x,end*.645,3.07),(.51,.085,.23),'Interior',.014)
  # closeable armoured flap sits above a dark slit
  B=Build();B.box((x,end*.73,3.16),(.51,.055,.16),'Paint',.009,Matrix.Rotation(end*.13,3,'X'));B.cyl((x-.27,end*.75,3.25),(x+.27,end*.75,3.25),.022,'Weapons',12)
  for xx in [x-.20,x+.20]:B.rivet((xx,end*.77,3.18),(0,end,0),.017)
  part(B,f'CabFlap_{"Front" if end<0 else "Rear"}_{"L" if x>0 else "R"}')
for s in [-1,1]:
 H.plate([(s*.83,-.74,2.77),(s*.83,.74,2.77),(s*.73,.65,3.28),(s*.73,-.65,3.28)],.04)
 B=Build();B.box((s*.795,0,3.065),(.052,.45,.255),'Paint',.012);B.box((s*.833,0,3.08),(.035,.32,.035),'Weapons',.006)
 for yy in [-.17,.17]:B.cyl((s*.832,yy,2.99),(s*.832,yy,3.17),.022,'Weapons',10)
 part(B,'CabFlap_'+('L' if s>0 else 'R'))
 for yy in [-.66,.66]:
  for z in [2.83,2.94,3.05,3.17]:H.rivet((s*(.83-(z-2.77)*.196+.015),yy,z),(s,0,.2),.018)
# cab roof has actual hatch aperture and deep dark crew well.
for x in [-.60,.60]:H.box((x,0,3.27),(.25,1.31,.05),'Paint',.008)
for y in [-.56,.56]:H.box((0,y,3.27),(1.0,.20,.05),'Paint',.008)
H.box((0,0,2.69),(1.20,1.15,.08),'Interior',.009)
for x in [-.48,.48]:H.box((x,0,3.015),(.05,.90,.47),'Interior',.004)
for y in [-.44,.44]:H.box((0,y,3.015),(1.0,.05,.47),'Interior',.004)
B=Build();B.box((0,0,3.285),(.95,.88,.030),'Paint',.012)
for x in [-.30,.30]:B.cyl((x,.42,3.27),(x+.12,.42,3.27),.025,'Weapons',12)
B.tube([(-.12,-.03,3.28),(-.12,-.03,3.29),(.12,-.03,3.29),(.12,-.03,3.28)],.010,'Weapons',8)
hatches.append(B.object('Hatch_Commander',(0,.42,3.27)))
# two side access doors, with hinges and handle, detached door plates sit on full framed backing.
for s in [-1,1]:
 yy=-2.17;z=1.80;x=s*(sx(z)+.048)
 H.box((x-s*.016,yy,z),(.045,.67,1.02),'Paint',.025)
 for dy in [-.285,.285]:
  for dz in [-.44,.44]:H.rivet((x+s*.008,yy+dy,z+dz),(s,0,0),.018,'Weapons')
 B=Build();B.box((x+s*.03,yy,z),(.06,.61,.95),'Paint',.038)
 for zz in [1.46,2.12]:
  B.box((x+s*.072,yy-.25,zz),(.04,.13,.09),'Paint',.009);B.cyl((x+s*.093,yy-.28,zz-.065),(x+s*.093,yy-.28,zz+.065),.024,'Weapons',12)
 B.tube([(x+s*.08,yy+.19,1.7),(x+s*.13,yy+.19,1.7),(x+s*.13,yy+.19,1.91),(x+s*.08,yy+.19,1.91)],.016,'Weapons',8)
 part(B,'Door_'+('L' if s>0 else 'R'))
 # Long muffler + swept inlet elbow and outlet; proper fixing bands and mounts.
 B=Build();xx=s*1.50
 pts=[(s*1.41,-.73,1.03),(xx,-.48,1.07),(xx,-.25,1.26),(xx,.05,1.38),(xx,.26,1.40)]
 B.tube(pts,.068,'Rust',14);B.cyl((xx,.18,1.4),(xx,1.50,1.4),.105,'Rust',20)
 B.tube([(xx,1.50,1.4),(xx,1.66,1.39),(xx,1.84,1.31),(s*1.45,1.94,1.27)],.068,'Rust',14)
 for y in [.26,1.42]:
  B.cyl((xx,y-.025,1.4),(xx,y+.025,1.4),.117,'Weapons',18);H.box((s*1.42,y,1.42),(.13,.095,.28),'Paint',.01)
 part(B,'Exhaust_'+('L' if s>0 else 'R'))
# Gun trunnion / armoured ball, with barrel as a genuine child.
G=Build();G.box((0,-3.22,1.93),(.69,.20,.67),'Paint',.036)
G.cyl((0,-3.27,1.93),(0,-3.46,1.93),.265,'Weapons',32,r2=.23)
G.cyl((0,-3.38,1.93),(0,-3.51,1.93),.166,'Paint',24)
gun=G.object('Gun',(0,-3.27,1.93));gun['yaw_axis']='Y';gun['yaw_limits_degrees']=[-25,25];gun['elevation_axis']='X'
B=Build();B.cyl((0,-3.45,1.93),(0,-3.66,1.93),.108,'Weapons',24,r2=.091);B.cyl((0,-3.64,1.93),(0,-4.25,1.93),.072,'Weapons',24,r2=.065)
# muzzle visible annulus + dark bore, not capped silver rod
B.torus((0,-4.248,1.93),.049,.014,axis=(0,1,0),mat='Weapons',n=24,k=6);B.cyl((0,-4.245,1.93),(0,-4.249,1.93),.035,'Interior',20)
barrel=B.object('Barrel',(0,-3.43,1.93));parent_keep(barrel,gun);empty('Muzzle',(0,-4.265,1.93),barrel)
# Driver and loader visors are articulated armour with dark crew spaces behind.
for x,label in [(-.91,'Loader'),(.91,'Driver')]:
 H.box((x,-3.267,2.08),(.40,.10,.22),'Interior',.016)
 B=Build();B.box((x,-3.342,2.12),(.43,.075,.16),'Paint',.014);B.box((x,-3.385,2.10),(.30,.018,.027),'Interior',.004);B.cyl((x-.24,-3.31,2.21),(x+.24,-3.31,2.21),.021,'Weapons',12)
 hatches.append(B.object('Hatch_'+label,(x,-3.31,2.21)))
# Three side machineguns each, two aft. Water-cooled jackets and small muzzle detail.
mg_count=0
for s in [1,-1]:
 for yy in [-1.20,.48,2.26]:
  z=2.14;xx=s*(sx(z)+.065);H.box((xx,yy,z),(.065,.35,.33),'Interior',.012)
  for yedge in [yy-.19,yy+.19]:H.box((xx+s*.025,yedge,z),(.045,.035,.39),'Paint',.007)
  B=Build();B.cyl((xx,yy,z),(xx+s*.10,yy,z),.12,'Weapons',16,r2=.09);B.cyl((xx+s*.07,yy,z),(xx+s*.40,yy,z),.044,'Weapons',16);B.cyl((xx+s*.39,yy,z),(xx+s*.50,yy,z),.026,'Weapons',12)
  for dd in [.13,.35]:B.cyl((xx+s*(dd-.012),yy,z),(xx+s*(dd+.012),yy,z),.050,'Weapons',16)
  B.cyl((xx+s*.496,yy,z),(xx+s*.501,yy,z),.013,'Interior',12)
  mg_count+=1;B.object('MG_'+str(mg_count),(xx,yy,z))
for x in [-.70,.70]:
 yy=3.205;z=2.13;H.box((x,yy,z),(.31,.10,.31),'Interior',.013)
 B=Build();B.cyl((x,yy,z),(x,yy+.12,z),.10,'Weapons',16);B.cyl((x,yy+.10,z),(x,yy+.46,z),.044,'Weapons',16);B.cyl((x,yy+.45,z),(x,yy+.53,z),.023,'Weapons',12)
 mg_count+=1;B.object('MG_'+str(mg_count),(x,yy,z))
# Running gear: roadwheel bogies, idlers/sprockets, upper return rollers.
for s,side in [(1,'L'),(-1,'R')]:
 x=s*1.12
 ys=[-2.84]+[-2.20+i*.44 for i in range(11)]+[2.84]
 for idx,y in enumerate(ys):
  r=.31 if idx in [0,12] else .235;z=.43 if idx in [0,12] else .31
  B=Build();B.cyl((x-s*.22,y,z),(x+s*.22,y,z),r,'TrackSteel',24)
  for sideOff in [-.205,.205]:B.torus((x+sideOff,y,z),r-.035,.031,axis=(1,0,0),mat='Weapons',n=24,k=6)
  B.cyl((x+s*.22,y,z),(x+s*.248,y,z),r*.64,'TrackSteel',24);B.cyl((x+s*.248,y,z),(x+s*.28,y,z),r*.25,'Weapons',16)
  for a in range(6):B.rivet((x+s*.269,y+cos(a*pi/3)*r*.43,z+sin(a*pi/3)*r*.43),(s,0,0),.013,'Weapons')
  if idx in [0,12]:
   for a in range(16):
    aa=a*2*pi/16;B.box((x,y+cos(aa)*r,z+sin(aa)*r),(.30,.065,.060),'TrackSteel',.007,Matrix.Rotation(aa,3,'X'))
  wheels.append(B.object(f'Wheel_{side}{idx+1}',(x,y,z)))
 # three leaf sprung bogie assemblies
 for y in [-1.76,0,1.76]:
  H.box((x+s*.12,y,.42),(.13,1.35,.13),'TrackSteel',.015)
  for a in range(32):
   a0=a*2*pi/32;a1=(a+1)*2*pi/32
   H.cyl((x+s*.29,y+.66*cos(a0),.35+.20*sin(a0)),(x+s*.29,y+.66*cos(a1),.35+.20*sin(a1)),.027,'TrackSteel',8)
  for off in [-.37,0,.37]:
   H.box((x+s*.28,y+off,.35),(.075,.20,.25),'TrackSteel',.019)
   H.box((x+s*.327,y+off,.39),(.033,.13,.115),'Weapons',.009)
   H.rivet((x+s*.35,y+off,.38),(s,0,0),.026,'TrackSteel')
  for k in range(4):H.box((x+s*.12,y,.52+k*.018),(.12,1.00-k*.14,.017),'TrackSteel',.004)
  for yy in [y-.43,y+.43]:H.cyl((x+s*.12,yy,.35),(x+s*.12,yy,.67),.047,'Weapons',12)
 for i,y in enumerate([-2.10,-.70,.70,2.10]):
  B=Build();B.cyl((x-.19,y,.80),(x+.19,y,.80),.13,'TrackSteel',20);B.cyl((x+s*.19,y,.80),(x+s*.23,y,.80),.065,'Weapons',16);wheels.append(B.object(f'Wheel_{side}{14+i}',(x,y,.80)))
# All wheel nodes, including return rollers, are numbered front to back.
for side in ['L','R']:
 ordered=sorted([o for o in wheels if o.name.startswith('Wheel_'+side)],key=lambda o:o.location.y)
 for i,o in enumerate(ordered):o.name=f'_wheel_sort_{side}_{i}'
 for i,o in enumerate(ordered):o.name=f'Wheel_{side}{i+1}'
# Continuous track path, rectangular pads individually bevelled. UV V advances exactly once around loop.
# radius .40, straight length 5.56 => zmin=0 (pad thickness .07)
TRACK_N=104
track_nodes={};track_segments={}
def track_pt(t):
 r=.40;half=2.78;per=4*half+2*pi*r;d=(t%1)*per
 if d<2*half:return (-half+d,.0535,0)
 d-=2*half
 if d<pi*r:
  a=-pi/2+d/r;return (half+r*cos(a),.4535+r*sin(a),a+pi/2)
 d-=pi*r
 if d<2*half:return (half-d,.8535,pi)
 d-=2*half;a=pi/2+d/r;return (-half+r*cos(a),.4535+r*sin(a),a+pi/2)
for s,side in [(1,'L'),(-1,'R')]:
 B=Build();x=s*1.12;track_segments[side]=[]
 for i in range(TRACK_N):
  sv=len(B.v)
  y,z,angle=track_pt(i/TRACK_N);R=Matrix.Rotation(angle,3,'X')
  start=len(B.f);B.box((x,y,z),(.49,.117,.069),'TrackSteel',.012,R)
  B.box(Vector((x,y,z))+R@Vector((0,0,-.041)),(.51,.034,.025),'TrackSteel',.006,R)
  # cast hinge knuckles, not rubber caterpillar rectangles
  B.cyl(Vector((x-.25,y,z))+R@Vector((0,.053,0)),Vector((x+.25,y,z))+R@Vector((0,.053,0)),.023,'Weapons',8)
  for k in range(start,len(B.f)):
   coords=[]
   for vi in B.f[k]:
    local=R.transposed()@(Vector(B.v[vi])-Vector((x,y,z)));coords.append((local.x/.53+.5,(i+.5+local.y/.127)/TRACK_N))
   B.uv[k]=coords
  track_segments[side].append((sv,len(B.v),x,y,z,angle))
 track_nodes[side]=B.object('Track_'+side)
# Period-appropriate lamps tucked on rear brackets (small fittings, no invented modern equipment).
for s in [-1,1]:
 B=Build();B.box((s*1.15,3.22,1.37),(.12,.12,.21),'Weapons',.015);B.cyl((s*1.15,3.25,1.41),(s*1.15,3.31,1.41),.066,'Lamps',16)
 part(B,'Lamp_'+('L' if s>0 else 'R'))
hull=H.object('Hull')
for name,loc in [('Seat_Commander',(0,0,3.30)),('Seat_Driver',(.6,-2.6,2.0)),('Exhaust',(1.50,1.94,1.27)),('Light_Head_L',(1.10,-3.28,1.45)),('Light_Head_R',(-1.10,-3.28,1.45)),('Fx_Engine',(0,1.8,2.68)),('Fx_Ammo',(0,.8,1.2)),('Fx_Hatch',(0,0,3.30))]:empty(name,loc)
healthy=list(S.objects)
# Drop unused material slots so glTF exports only the materials used by each mesh.
def prune(o):
 if o.type!='MESH':return
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bad=[f for f in bm.faces if f.calc_area()<1e-10]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free()
 slots=list(o.data.materials);used=sorted(set(p.material_index for p in o.data.polygons));mapping={old:i for i,old in enumerate(used)};indices=[mapping[p.material_index] for p in o.data.polygons];o.data.materials.clear()
 for j in used:o.data.materials.append(slots[j])
 for p,i in zip(o.data.polygons,indices):p.material_index=i
for o in healthy:prune(o)
def triangles(obs):return sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in obs if o.type=='MESH')
print('HEALTHY_TRIANGLES',triangles(healthy),flush=True)

def export(obs,path):
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.hide_set(False);o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(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_cameras=False,export_lights=False,export_extras=True)
 # Set canonical linear tint explicitly (exporter's multiply recognition varies across versions).
 p=ROOT/path;data=p.read_bytes();length,kind=struct.unpack_from('<II',data,12);j=json.loads(data[20:20+length]);binary=data[20+length:]
 for m in j.get('materials',[]):
  if m['name']=='Paint':m.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=PAINTS[0][1]+[1]
 text=json.dumps(j,separators=(',',':')).encode();text+=b' '*((-len(text))%4);p.write_bytes(struct.pack('<III',0x46546c67,2,20+len(text)+len(binary))+struct.pack('<II',len(text),0x4e4f534a)+text+binary)
 print('EXPORTED',path,p.stat().st_size,flush=True)
export(healthy,'a7v-tank.glb')
# Damage twins derive directly from matching healthy parts, preserve their pivots.
hd=empty('Hull_Damage',(0,0,0));td=empty('Turret_Damage',(0,0,0));damage=[hd,td];twins=[]
def clone(o,name):
 q=o.copy();q.data=o.data.copy();q.name=name;S.collection.objects.link(q);q.parent=None;q.matrix_world=o.matrix_world.copy();return q
selected=[p for p in parts if any(k in p.name for k in ['Skirt_L2','Skirt_L3','Skirt_L5','Skirt_R2','Skirt_R4','Skirt_R6','Door_L','Door_R','Exhaust_L','Exhaust_R','Grille_Front','Grille_Rear','CabFlap_Front_L','CabFlap_Rear_R','CabFlap_L'])]
for idx,o in enumerate(selected):
 q=clone(o,o.name.replace('Part_','Dmg_Turret_' if 'CabFlap' in o.name else 'Dmg_'));bm=bmesh.new();bm.from_mesh(q.data)
 # Cut a ragged wedge away at the lower corner of armour plates, then peel the edge.
 if 'Skirt' in o.name or 'Door' in o.name:
  coords=[v.co for v in bm.verts];ymax=max(v.y for v in coords);zmin=min(v.z for v in coords)
  bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),dist=.0001,plane_co=(0,ymax-.11,zmin+.11),plane_no=(0,1,-1),clear_outer=True)
 for v in bm.verts:
  co=v.co
  if 'Exhaust' in o.name:co.x+=.12*sin(co.y*2);co.z-=.14*(.6+sin(co.y))
  elif 'Grille' in o.name:co.z+=.30*max(0,1-abs(co.x))*(.5+.5*sin(co.y*7))
  else:
   weight=max(0,(co.y+.6)/1.2);co.x+=(1 if o.location.x>=0 else -1)*(.11*weight+.045*sin(co.z*24));co.z-=.09*weight
 bm.to_mesh(q.data);bm.free()
 # Damage material darkened steel at torn edges is represented by strips below.
 par=td if 'CabFlap' in o.name else hd;parent_keep(q,par);twins.append((o,q));damage.append(q)
# Scar patches: disks sit 3mm above actual armour, metal petalled crater rims and black pits.
scar_names=[]
def scar_group(name,positions,normals):
 B=Build()
 for idx,(p,n) in enumerate(zip(positions,normals)):
  p=Vector(p);n=Vector(n).normalized();q=n.to_track_quat('Z','Y');r=.043 if idx else .083
  # asymmetric soot halo as a thin irregular disk
  count=16;vv=[p+n*.0025]
  for i in range(count):
   a=2*pi*i/count;rad=r*(1.5+.30*sin(i*4.2));vv.append(p+q@Vector((rad*cos(a),rad*sin(a),.003)))
  B.add(vv,[[0,i+1,(i+1)%count+1] for i in range(count)],'Burnt')
  # open rim with black interior inset; raised petals catch glancing light
  vv=[]
  for ring in [0,1,2]:
   for i in range(count):
    a=2*pi*i/count;rr=r*[1,.57,.44][ring];zz=[.003,.007+(.008 if idx==0 else .004)*(1+sin(i*3)),.004][ring];vv.append(p+q@Vector((rr*cos(a),rr*sin(a),zz)))
  B.add(vv,[[ring*count+i,ring*count+(i+1)%count,(ring+1)*count+(i+1)%count,(ring+1)*count+i] for ring in [0,1] for i in range(count)],'Scars')
  B.cyl(p+n*.003,p+n*.004,r*.45,'Interior',16)
  if idx>0:
   B.tube([p+q@Vector((r*.7,0,.004)),p+q@Vector((r*1.9,r*.3,.004)),p+q@Vector((r*2.7,r*.6,.004))],.008,'Scars',6)
 ob=B.object(name);prune(ob);parent_keep(ob,td if 'Turret' in name else hd);damage.append(ob);scar_names.append(name)
for s,side in [(1,'L'),(-1,'R')]:
 poses=[]
 for yy,zz in [(-1.65,1.70),(-.50,2.30),(1.30,1.85),(2.7,2.2)]:poses.append((s*(sx(zz)+.0265),yy,zz))
 scar_group('Scar_Hull_'+side,poses,[(s,0,.1)]*4)
for end,label in [(-1,'Front'),(1,'Rear')]:
 positions=[]
 for xx,zz in [(-.54,1.45),(.62,1.82),(.23,2.44),(-1.0,1.23)]:positions.append((xx,end*(3.38-(zz-.68)*.26/1.92+.030),zz))
 scar_group('Scar_Hull_'+label,positions,[(0,end,.14)]*4)
for s,label in [(1,'L'),(-1,'R')]:scar_group('Scar_Turret_'+label,[(s*(.83-(z-2.77)*(.1/.51)+.019626),y,z) for y,z in [(-.40,3.04),(.40,3.02),(.54,3.12)]],[(s,0,.2)]*3)
for end,label in [(-1,'Front'),(1,'Rear')]:scar_group('Scar_Turret_'+label,[(x,end*.76469,2.87) for x in [-.5,0,.5]],[(0,end,.1)]*3)
# Broken gun combines original mount with a bent, split barrel at the same pivot.
q=clone(gun,'Gun_Broken');pieces=[q]
bb=clone(barrel,'broken_barrel_temp')
for v in bb.data.vertices:
 world=bb.matrix_world@v.co
 if world.y<-3.66:
  d=-3.66-world.y;world.z-=.75*d;world.x+=.05*sin(d*8)
  if d>.22:world.x+=.045*sin(v.index*2.3);world.z+=.04*cos(v.index*2.1)
  v.co=bb.matrix_world.inverted()@world
pieces.append(bb)
bpy.ops.object.select_all(action='DESELECT')
for o in pieces:o.select_set(True)
bpy.context.view_layer.objects.active=q;bpy.ops.object.join();parent_keep(q,td);damage.append(q)
# Snapped/derailed track copies: unlink a front arc; upper run sags and loose front links lie on ground.
for side in ['L','R']:
 orig=track_nodes[side];q=clone(orig,'Track_'+side+'_Broken')
 for i,(sv,ev,x,y,z,angle) in enumerate(track_segments[side]):
  t=i/TRACK_N;newy,newz,newangle=y,z,angle;newx=x
  if t>.90:
   newy=-2.82-(t-.90)*14;newz=.0345;newangle=pi
  elif t>.50:
   u=(t-.50)/.40;newz=.8535-.40*sin(u*pi*.85)-.57*max(0,(u-.75)/.25);newz=max(.065,newz);newx+=.17*sin(u*pi)*(1 if side=='L' else -1);newangle=pi+.18*sin(u*pi*2)
  oldR=Matrix.Rotation(angle,3,'X');newR=Matrix.Rotation(newangle,3,'X')
  for vi in range(sv,ev):
   v=q.data.vertices[vi];raw=Vector((v.co.x/DIM.x,v.co.y/DIM.y,v.co.z));loc=oldR.transposed()@(raw-Vector((x,y,z)));v.co=scaled(newR@loc+Vector((newx,newy,newz)))
 parent_keep(q,hd);damage.append(q)
# Wreck: complete burnt hull, suspension sagged; cab roof blown open; no turret on this type.
wreckpieces=[]
omit={'Part_Skirt_L2','Part_Skirt_L3','Part_Skirt_R4','Part_Grille_Rear','Part_CabFlap_Front_L','Part_CabFlap_Rear_R'}
for o in healthy:
 if o.type!='MESH' or o.name in omit or o.name=='Barrel' or o.name=='Gun':continue
 if o.name.startswith('Track_'):
  if o.name=='Track_L':src=next(q for q in damage if q.name=='Track_L_Broken')
  else:src=o
 else:src=o
 q=clone(src,'wreck_'+o.name)
 if o.name.startswith('Hatch_'):
  q.rotation_euler.rotate_axis('X',-1.2 if o.name=='Hatch_Commander' else 1.2)
 if o.name.startswith('Wheel_'):q.location.z-=.05
 if o.name=='Part_Grille_Front':q.rotation_euler.x=.24;q.location.z+=.16
 if o.name.startswith('Part_CabFlap'):q.rotation_euler.y=.72;q.location.z+=.13
 if o.name=='Part_Door_L':q.rotation_euler.z=.65;q.location.x+=.14
 if o.name=='Hull':
  bm=bmesh.new();bm.from_mesh(q.data);bad=[f for f in bm.faces if f.calc_center_median().y>1.00 and 2.48<f.calc_center_median().z<2.82 and abs(f.normal.z)>.85]
  bmesh.ops.delete(bm,geom=bad,context='FACES');bm.to_mesh(q.data);bm.free()
  for v in q.data.vertices:
   if v.co.z>3.22:v.co.z+=.18*sin(v.co.y*10)+.08;v.co.x*=1.12
   if v.co.z>2.79 and v.co.x>.60:v.co.x+=.25*(v.co.z-2.79)/.5
 for v in q.data.vertices:
  if v.co.z>.65:v.co.x+=.006*sin(v.co.z*17+v.co.y*11)
 q.data.materials.clear();q.data.materials.append(MATS['Burnt'])
 for p in q.data.polygons:p.material_index=0
 wreckpieces.append(q)
B=Build()
# Jagged displaced engine-deck plates surround a dark engine cavity.
B.box((0,1.95,2.05),(1.9,1.9,.15),'Burnt',.015)
for sg in [-1,1]:
 B.plate([(sg*.15,.95,2.69),(sg*1.1,1.05,2.68),(sg*1.15,2.75,2.62),(sg*.37,2.35,3.03),(sg*.58,1.95,2.93),(sg*.22,1.5,3.13)],.032,'Burnt')
q=B.object('wreck_torn_deck');wreckpieces.append(q)
q=clone(next(o for o in damage if o.name=='Gun_Broken'),'wreck_gun');q.parent=None;q.data.materials.clear();q.data.materials.append(MATS['Burnt'])
for p in q.data.polygons:p.material_index=0
wreckpieces.append(q)
bpy.ops.object.select_all(action='DESELECT')
for o in wreckpieces:o.select_set(True)
bpy.context.view_layer.objects.active=wreckpieces[0];bpy.ops.object.join();wreck=bpy.context.object;wreck.name='Wreck_Hull'
# Move origin to ground centre without touching the mesh placement.
mw=wreck.matrix_world.copy();wreck.data.transform(mw);wreck.matrix_world=Matrix.Identity(4)
mod=wreck.modifiers.new('Wreck simplification','DECIMATE');mod.ratio=.44;bpy.context.view_layer.objects.active=wreck;bpy.ops.object.modifier_apply(modifier=mod.name)
damage.append(wreck)
# Damage materials conform to five-bucket contract, shared Paint preserved.
for o in damage:
 if o.type!='MESH':continue
 for i,m in enumerate(o.data.materials):
  if m.name in ['Weapons','Rust']:o.data.materials[i]=MATS['TrackSteel']
  elif m.name in ['Interior','Lamps']:o.data.materials[i]=MATS['Burnt']
 prune(o)
print('DAMAGE_TRIANGLES',triangles(damage),flush=True)
export(damage,'a7v-tank-damage.glb')
# Metadata uses glTF axes, node pivots (world) and actual transformed bounds.
def xyz(v):return [round(v.x,5),round(v.z,5),round(-v.y,5)]
def manifest(obs):
 bpy.context.view_layer.update();nodes=[];points=[]
 for o in obs:
  nodes.append({'name':o.name,'position':xyz(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'triangles':sum(len(p.vertices)-2 for p in o.data.polygons) if o.type=='MESH' else 0})
  if o.type=='MESH':points.extend([o.matrix_world@Vector(p) for p in o.bound_box])
 pp=[xyz(p) for p in points]
 return {'nodes':nodes,'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)]},'triangles':triangles(obs)}
meta={'contract':'tank-damage/v1','type':'A7V turretless','axes':{'forward':'+Z','up':'+Y','left':'+X'},'units':'metres','armour_dimensions_m':{'length':7.34,'width':3.07,'height':3.30},'files':{'a7v-tank.glb':manifest(healthy),'a7v-tank-damage.glb':manifest(damage)},'paints':[{'name':name,'linear_rgb':col} for name,col in PAINTS],'damage':{'parts':[{'part':p.name,'twin':next((q.name for o,q in twins if o==p),None)} for p in parts],'scars':scar_names,'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull'],'turretless':True},'rig':{'Gun':{'yaw_axis':'Y','yaw_degrees':[-25,25],'elevation_axis':'X'},'Barrel':{'recoil_axis':'Z'},'wheels':{'axis':'X'},'track_uv':'V runs once around the loop','machine_guns':8,'turret':None},'bakes':BAKES}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
# Save source scene then use clean glTF imports for all delivery render proofs.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'a7v-source.blend'))
twin_names=[(o.name,q.name) for o,q in twins]
for o in list(bpy.data.objects):bpy.data.objects.remove(o,do_unlink=True)
oldgroup=bpy.data.node_groups.get('glTF Material Output')
if oldgroup:oldgroup.name='Baked_Occlusion_Source'
bpy.ops.import_scene.gltf(filepath=str(ROOT/'a7v-tank.glb'));A=list(S.objects)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'a7v-tank-damage.glb'));D=[o for o in S.objects if o not in A]
# Exported material tint is the proof source. Names can be suffixed by the importer.
paintmats=[m for m in bpy.data.materials if m.name.startswith('Paint') and m.users]
# neutral studio floor and broad natural daylight
floorMat=bpy.data.materials.new('Studio');floorMat.diffuse_color=(.18,.195,.19,1);floorMat.use_nodes=True;floorMat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.18,.195,.19,1);floorMat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.87
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.035));floor=bpy.context.object;floor.name='StudioFloor';floor.data.materials.append(floorMat)
S.world=bpy.data.worlds.new('Soft daylight');S.world.use_nodes=True;S.world.node_tree.nodes['Background'].inputs[0].default_value=(.67,.74,.82,1);S.world.node_tree.nodes['Background'].inputs[1].default_value=.5
ld=bpy.data.lights.new('Daylight','AREA');lo=bpy.data.objects.new('Daylight',ld);S.collection.objects.link(lo);lo.location=(5,-6,9);ld.energy=2100;ld.shape='DISK';ld.size=7
lo.rotation_euler=(Vector((0,0,1.4))-lo.location).to_track_quat('-Z','Y').to_euler()
ld=bpy.data.lights.new('Rim','AREA');lo=bpy.data.objects.new('Rim',ld);S.collection.objects.link(lo);lo.location=(-4,3,6);ld.energy=1400;ld.size=6;lo.rotation_euler=(Vector((0,0,1.4))-lo.location).to_track_quat('-Z','Y').to_euler()
camd=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camd);S.collection.objects.link(cam);S.camera=cam
camd.type='ORTHO';camd.ortho_scale=10.6
CAM=(9,-12,7.2);TARGET=(0,-.20,1.45)
def camera(pos=CAM,target=TARGET,scale=10.6):cam.location=pos;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=scale
camera()
def show(obs,val):
 for o in obs:o.hide_render=not val

def render(name,w=1920,h=1080,samples=24):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
show(A,True);show(D,False)
render('final.png')
camera((10,0,1.65),(0,0,1.65),10.1);render('side.png',1920,1080,20)
camera()
clay=bpy.data.materials.new('ClayProof');clay.use_nodes=True;cp=clay.node_tree.nodes.get('Principled BSDF');cp.inputs['Base Color'].default_value=(.43,.43,.43,1);cp.inputs['Roughness'].default_value=.78
S.view_layers[0].material_override=clay;render('clay.png',1920,1080,16);S.view_layers[0].material_override=None
# Mechanical pivot evidence: rotate every moving node 30 degrees in place; restore afterwards.
moving=[o for o in A if o.name.startswith(('Wheel_','Hatch_','MG_')) or o.name in ['Gun','Barrel']]
transforms={o.name:o.matrix_basis.copy() for o in moving}
for o in moving:
 o.rotation_mode='XYZ';o.rotation_euler.rotate_axis('Z' if o.name=='Gun' or o.name.startswith('MG_') else 'X',-pi/6 if o.name=='Hatch_Commander' else pi/6)
render('pivots.png',1280,720,12)
for o in moving:o.matrix_basis=transforms[o.name]
# Grid tiles are assembled via Blender image buffers, no external image package required.
def grid(paths,out):
 tiles=[bpy.data.images.load(str(ROOT/p),check_existing=False) for p in paths];w,h=tiles[0].size
 canvas=np.ones((h*2,w*3,4),dtype=np.float32)
 for i,im in enumerate(tiles):canvas[(1-i//3)*h:(2-i//3)*h,(i%3)*w:(i%3+1)*w]=np.asarray(im.pixels[:],dtype=np.float32).reshape(h,w,4)
 im=bpy.data.images.new(out,w*3,h*2,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/out);im.file_format='PNG';im.save()
 for t in tiles:bpy.data.images.remove(t)
 labels=[name.upper() for name,col in PAINTS] if out=='paints.png' else ['01  HEALTHY','02  HIT / SIX PANEL SWAPS','03  STRIPPED','04  IMMOBILISED / GUN DISABLED','05  BURNT WRECK','06  TURRETLESS WRECK / NO SEPARATE TURRET']
 code='from PIL import Image,ImageDraw,ImageFont; import sys,json; p=sys.argv[1]; src=Image.open(p).convert("RGB"); labels=json.loads(sys.argv[2]); font=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",14); w=src.width//3; h=src.height//2; im=Image.new("RGB",(w*3,(h+31)*2),(36,40,39)); d=ImageDraw.Draw(im); [(im.paste(src.crop((i%3*w,i//3*h,(i%3+1)*w,(i//3+1)*h)),(i%3*w,i//3*(h+31))),d.text((i%3*w+15,i//3*(h+31)+h+8),s,font=font,fill=(224,229,225))) for i,s in enumerate(labels)]; im.save(p)'
 subprocess.run(['python3','-c',code,str(ROOT/out),json.dumps(labels)],check=True)
# recolour imported glTF materials via supported texture multiply sockets

def recolour(rgb):
 for o in A:
  if o.type!='MESH':continue
  for m in o.data.materials:
   if not m.name.startswith('Paint'):continue
   nt=m.node_tree;p=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');sock=p.inputs['Base Color']
   if sock.is_linked:
    node=sock.links[0].from_node
    if node.type=='MIX':
     # importer uses multiply Mix colour B at socket 7
     node.inputs[7].default_value=(*rgb,1)
    elif node.type=='MIX_RGB':node.inputs[2].default_value=(*rgb,1)
    elif node.type=='TEX_IMAGE':
     tex=node;mul=nt.nodes.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(*rgb,1);nt.links.new(tex.outputs['Color'],mul.inputs[1]);nt.links.new(mul.outputs[0],sock)
   else:sock.default_value=(*rgb,1)
for i,(name,rgb) in enumerate(PAINTS):
 recolour(rgb);render(f'paint-{i}.png',640,360,12)
grid([f'paint-{i}.png' for i in range(6)],'paints.png');recolour(PAINTS[0][1])
# Render exactly the imported exported nodes, turning states on/off only.
def named(name,objs):return next((o for o in objs if o.name==name or o.name.split('.')[0]==name),None)
for state in range(6):
 show(A,state<4);show(D,False)
 if state==1:
  for n in scar_names:
   o=named(n,D)
   if o:o.hide_render=False
  for pn,qn in twin_names[:6]:
   named(pn,A).hide_render=True;named(qn,D).hide_render=False
 elif state==2:
  for o in A:
   if o.name.startswith('Part_'):o.hide_render=True
 elif state==3:
  named('Track_L',A).hide_render=True;named('Track_L_Broken',D).hide_render=False
  for n in ['Gun','Barrel']:named(n,A).hide_render=True
  named('Gun_Broken',D).hide_render=False
 elif state>=4:named('Wreck_Hull',D).hide_render=False
 # State 6 equals turretless wreck: deliberately no fictitious detachable turret.
 render(f'state-{state}.png',640,360,16)
grid([f'state-{i}.png' for i in range(6)],'states.png')
notes=[
 'WWI German A7V-style turretless heavy tank reconstructed from sheet.jpg; movable gun, eight MGs, hatches, wheels, removable fittings and aligned damage/wreck assets.',
 f'Triangles: healthy {meta["files"]["a7v-tank.glb"]["triangles"]:,}; damage {meta["files"]["a7v-tank-damage.glb"]["triangles"]:,}; uncompressed GLB meshes with metre units.',
 'Materials: healthy Paint, TrackSteel, Weapons, Rust, Interior, Lamps; damage Paint, TrackSteel, Burnt, Scars; Paint neutral baked texture × linear field-grey factor [0.19,0.22,0.19].',
 'Textures: shared Cycles OptiX baked base/normal 1024px Paint, 512px others; ORM 512px; proof renders use re-imported GLBs.',
 'Poly Haven CC0 ids: metal_plate, rusty_metal_03; https://polyhaven.com/license ; source maps downloaded from dl.polyhaven.org.',
 'Differences: eight MGs follow written brief; no turret or Wreck_Turret (sixth state repeats turretless wreck); dimensions include protruding weapons beyond the armour footprint; weathering is shared tiling, not a unique historical vehicle.'
]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
print('BUILD_COMPLETE',flush=True)
