"""Panther Ausf.G library asset. Blender 5.2; run headless. All geometry in metres.
Clean build: blender -b --python-exit-code 1 --python build_panther-tank.py
Optional -- render-only reimports previously exported GLBs for proof renders.
"""
import bpy, bmesh, math, json, os, sys, random, urllib.request, subprocess, struct
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin,cos,pi
import numpy as np
import hashlib
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
random.seed(41)
PAINTS=[('dark yellow',[.47,.42,.28]),('field grey',[.22,.27,.23]),('panzer grey',[.105,.125,.14]),('winter white',[.78,.80,.76]),('forest green',[.105,.19,.09]),('khaki brown',[.28,.20,.115])]
MATS={};healthy=[];damage=[];parts={};pivots={}
def setup():
 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
 p=bpy.context.preferences.addons['cycles'].preferences
 p.compute_device_type='OPTIX';p.get_devices()
 for d in p.devices:d.use=d.type!='CPU'
 s.cycles.device='GPU';s.render.image_settings.file_format='PNG';s.view_settings.view_transform='AgX';s.view_settings.look='AgX - Medium High Contrast';s.view_settings.exposure=-.35
 s.world=bpy.data.worlds.new('Daylight');s.world.use_nodes=True;s.world.node_tree.nodes['Background'].inputs[0].default_value=(.55,.65,.78,1);s.world.node_tree.nodes['Background'].inputs[1].default_value=.45
 return s
S=setup()
def dl(id,map):
 p=TEX/f'{id}_{map}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
  subprocess.run(['curl','-fL','--retry','2','-s',url,'-o',str(p)],check=True)
 return p
def node(nt,t):return nt.nodes.new(t)
def material(name,id,color,rough,metal,res=512):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs[0])
 uv=node(nt,'ShaderNodeTexCoord');im=node(nt,'ShaderNodeTexImage');im.image=bpy.data.images.load(str(dl(id,'diff')),check_existing=True);nt.links.new(uv.outputs['UV'],im.inputs[0])
 bw=node(nt,'ShaderNodeRGBToBW');nt.links.new(im.outputs[0],bw.inputs[0]);r=node(nt,'ShaderNodeValToRGB');nt.links.new(bw.outputs[0],r.inputs[0])
 if name=='Paint':a,b=(.17,.17,.17,1),(.96,.96,.96,1)
 else:a=tuple(v*.45 for v in color)+(1,);b=tuple(v*1.25 for v in color)+(1,)
 r.color_ramp.elements[0].position=.08;r.color_ramp.elements[0].color=a;r.color_ramp.elements[1].position=.65;r.color_ramp.elements[1].color=b
 if name=='Paint':
  r.color_ramp.elements[0].position=.008;r.color_ramp.elements[1].position=.095;e=r.color_ramp.elements.new(.028);e.color=(.79,.79,.79,1)
 noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=85;noise.inputs['Detail'].default_value=3;nt.links.new(uv.outputs['UV'],noise.inputs[0])
 mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.17;nt.links.new(r.outputs[0],mix.inputs[1]);nt.links.new(noise.outputs['Fac'],mix.inputs[2]);nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
 # Shared armour trim: neutral rubbed edges and dirt accumulate around each plate.
 if name=='Paint':
  sepUV=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],sepUV.inputs[0])
  invs=[]
  for channel in [0,1]:
   inv=node(nt,'ShaderNodeMath');inv.operation='SUBTRACT';inv.inputs[0].default_value=1;nt.links.new(sepUV.outputs[channel],inv.inputs[1]);mi=node(nt,'ShaderNodeMath');mi.operation='MINIMUM';nt.links.new(sepUV.outputs[channel],mi.inputs[0]);nt.links.new(inv.outputs[0],mi.inputs[1]);invs.append(mi)
  edge=node(nt,'ShaderNodeMath');edge.operation='MINIMUM';nt.links.new(invs[0].outputs[0],edge.inputs[0]);nt.links.new(invs[1].outputs[0],edge.inputs[1])
  ramp=node(nt,'ShaderNodeMapRange');nt.links.new(edge.outputs[0],ramp.inputs['Value']);ramp.inputs['From Min'].default_value=.004;ramp.inputs['From Max'].default_value=.05;ramp.inputs['To Min'].default_value=.43;ramp.inputs['To Max'].default_value=1
  broad=node(nt,'ShaderNodeTexNoise');broad.inputs['Scale'].default_value=9;broad.inputs['Detail'].default_value=5;broad.inputs['Roughness'].default_value=.7;nt.links.new(uv.outputs['UV'],broad.inputs[0])
  aging=node(nt,'ShaderNodeValToRGB');nt.links.new(broad.outputs['Fac'],aging.inputs[0]);aging.color_ramp.elements[0].position=.22;aging.color_ramp.elements[0].color=(.28,.28,.28,1);aging.color_ramp.elements[1].position=.62;aging.color_ramp.elements[1].color=(1,1,1,1)
  patina=node(nt,'ShaderNodeMixRGB');patina.blend_type='MULTIPLY';patina.inputs[0].default_value=.67;nt.links.new(mix.outputs[0],patina.inputs[1]);nt.links.new(aging.outputs[0],patina.inputs[2]);mix=patina
  mapping=node(nt,'ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(160,5,1);nt.links.new(uv.outputs['UV'],mapping.inputs[0]);rain=node(nt,'ShaderNodeTexNoise');rain.inputs['Scale'].default_value=1;rain.inputs['Detail'].default_value=2;nt.links.new(mapping.outputs[0],rain.inputs[0]);rr=node(nt,'ShaderNodeValToRGB');nt.links.new(rain.outputs[0],rr.inputs[0]);rr.color_ramp.elements[0].position=.30;rr.color_ramp.elements[0].color=(.56,.56,.56,1);rr.color_ramp.elements[1].position=.58;rr.color_ramp.elements[1].color=(1,1,1,1)
  streak=node(nt,'ShaderNodeMixRGB');streak.blend_type='MULTIPLY';streak.inputs[0].default_value=.44;nt.links.new(mix.outputs[0],streak.inputs[1]);nt.links.new(rr.outputs[0],streak.inputs[2]);mix=streak
  dirt=node(nt,'ShaderNodeMixRGB');dirt.blend_type='MULTIPLY';dirt.inputs[0].default_value=1;nt.links.new(mix.outputs[0],dirt.inputs[1]);nt.links.new(ramp.outputs[0],dirt.inputs[2]);mix=dirt;nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
 if name=='Scars':
  coords=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(uv.outputs['UV'],coords.inputs[0]);threshold=node(nt,'ShaderNodeMath');threshold.operation='GREATER_THAN';threshold.inputs[1].default_value=.5;nt.links.new(coords.outputs[0],threshold.inputs[0]);atlas=node(nt,'ShaderNodeMixRGB');nt.links.new(threshold.outputs[0],atlas.inputs[0]);atlas.inputs[1].default_value=(.012,.010,.008,1);atlas.inputs[2].default_value=(.36,.37,.35,1);mix=atlas;nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
 norm=node(nt,'ShaderNodeTexImage');norm.image=bpy.data.images.load(str(dl(id,'nor_gl')),check_existing=True);norm.image.colorspace_settings.name='Non-Color';nt.links.new(uv.outputs['UV'],norm.inputs[0]);nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.25 if name=='Paint' else .45;nt.links.new(norm.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
 arm=node(nt,'ShaderNodeTexImage');arm.image=bpy.data.images.load(str(dl(id,'arm')),check_existing=True);arm.image.colorspace_settings.name='Non-Color';sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(arm.outputs[0],sep.inputs[0]);rm=node(nt,'ShaderNodeMath');rm.operation='MULTIPLY_ADD';rm.inputs[1].default_value=.22;rm.inputs[2].default_value=rough-.11;nt.links.new(sep.outputs[1],rm.inputs[0]);nt.links.new(rm.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=2);plane=bpy.context.object;plane.data.materials.append(m)
 target=node(nt,'ShaderNodeTexImage');emit=node(nt,'ShaderNodeEmission');comb=node(nt,'ShaderNodeCombineColor');nt.links.new(sep.outputs[0],comb.inputs[0]);nt.links.new(rm.outputs[0],comb.inputs[1]);comb.inputs[2].default_value=metal
 maps={}
 for kind,size in [('base',res),('normal',res),('orm',512)]:
  img=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False);img.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color';target.image=img;nt.nodes.active=target
  if kind=='normal':nt.links.new(bs.outputs[0],out.inputs[0]);typ='NORMAL'
  else:nt.links.new(mix.outputs[0] if kind=='base' else comb.outputs[0],emit.inputs[0]);nt.links.new(emit.outputs[0],out.inputs[0]);typ='EMIT'
  S.cycles.samples=4;bpy.ops.object.bake(type=typ,margin=4);img.filepath_raw=str(TEX/(name+'_'+kind+'.jpg'));img.file_format='JPEG';img.save();maps[kind]=img
 if name=='Scars':
  src=maps['base'];px=np.array(src.pixels[:],dtype=np.float32).reshape(res,res,4);yy,xx=np.mgrid[0:res,0:res]/float(res-1);rad=np.sqrt(((xx-.25)/.24)**2+((yy-.5)/.48)**2);px[:,:,3]=np.where(xx<.5,np.clip((1-rad)*2.4,0,1),1);img=bpy.data.images.new('Scars_base_alpha',width=res,height=res,alpha=True);img.pixels.foreach_set(px.ravel());img.filepath_raw=str(TEX/'Scars_base.png');img.file_format='PNG';img.save();maps['base']=img
 bpy.data.objects.remove(plane,do_unlink=True)
 nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs[0]);im=node(nt,'ShaderNodeTexImage');im.image=maps['base'];nt.links.new(im.outputs[0],bs.inputs['Base Color'])
 if name=='Scars':nt.links.new(im.outputs['Alpha'],bs.inputs['Alpha']);m.surface_render_method='DITHERED'
 nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=1;im=node(nt,'ShaderNodeTexImage');im.image=maps['normal'];nt.links.new(im.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
 im=node(nt,'ShaderNodeTexImage');im.image=maps['orm'];sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(im.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs[1],bs.inputs['Roughness']);nt.links.new(sep.outputs[2],bs.inputs['Metallic'])
 grp=bpy.data.node_groups.get('glTF Material Output')
 if not grp:
  grp=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');grp.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 oc=node(nt,'ShaderNodeGroup');oc.node_tree=grp;nt.links.new(sep.outputs[0],oc.inputs['Occlusion'])
 if name=='Lamps':bs.inputs['Emission Color'].default_value=(.18,.14,.08,1);bs.inputs['Emission Strength'].default_value=.2
 MATS[name]=m;return m
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):
  off=len(self.v);self.v.extend(v)
  for face in f:
   self.f.append(tuple(off+i for i in face));self.mi.append(mat);self.sm.append(smooth)
  self.uv.extend(uv if uv else [None]*len(f))
 def box(self,c,d,mat='Paint',rot=None):
  c=Vector(c);v=[Vector((x*d[0]/2,y*d[1]/2,z*d[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)]]
  self.add([c+(rot@p if rot else p) for p in v],[(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3),(0,2,3,1),(4,5,7,6)],mat)
 def cyl(self,c,r,depth,mat='Paint',axis='Z',n=32,r2=None):
  self.lathe(c,[(-depth/2,r),(depth/2,r if r2 is None else r2)],mat,axis,n)
 def lathe(self,c,prof,mat='Paint',axis='Z',n=48,cap=True):
  c=Vector(c);v=[]
  for a,r in prof:
   for j in range(n):
    b=2*pi*j/n;p=(r*cos(b),r*sin(b),a)
    if axis=='X':p=(a,p[0],p[1])
    if axis=='Y':p=(p[0],a,p[1])
    v.append(c+Vector(p))
  f=[]
  for i in range(len(prof)-1):
   for j in range(n):f.append((i*n+j,i*n+(j+1)%n,(i+1)*n+(j+1)%n,(i+1)*n+j))
  if cap:f.extend([tuple(reversed(range(n))),tuple((len(prof)-1)*n+j for j in range(n))])
  self.add(v,f,mat,True)
 def rod(self,a,b,r,mat='Paint',n=12):
  a,b=Vector(a),Vector(b);d=b-a;z=d.normalized();x=z.cross(Vector((0,0,1)))
  if x.length<.01:x=z.cross(Vector((0,1,0)))
  x.normalize();y=z.cross(x);v=[p+r*(x*cos(j*2*pi/n)+y*sin(j*2*pi/n)) for p in [a,b] for j in range(n)]
  self.add(v,[(j,(j+1)%n,n+(j+1)%n,n+j) for j in range(n)]+[tuple(reversed(range(n))),tuple(range(n,2*n))],mat,True)
 def path(self,pts,r,mat='Steel',n=8):
  for a,b in zip(pts,pts[1:]):self.rod(a,b,r,mat,n)
 def ring(self,c,ro,ri,depth,mat='Paint',axis='Z',n=48):self.lathe(c,[(-depth/2,ri),(-depth/2,ro),(depth/2,ro),(depth/2,ri),(-depth/2,ri)],mat,axis,n,False)
 def bolt(self,c,axis='Z',r=.016,mat='Steel'):self.cyl(c,r,.016,mat,axis,6)
 def obj(self,name,pivot=(0,0,0),parent=None,bevel=.004,collection=healthy):
  me=bpy.data.meshes.new(name);me.from_pydata([Vector(v)-Vector(pivot) for v in self.v],[],self.f);me.update();bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free();o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);o.location=pivot
  boundslo=[min(v[j] for v in self.v) for j in range(3)];boundshi=[max(v[j] for v in self.v) for j in range(3)]
  mats=list(dict.fromkeys(self.mi))
  for m in mats:me.materials.append(MATS[m])
  uv=me.uv_layers.new(name='UVMap')
  for i,p in enumerate(me.polygons):
   p.material_index=mats.index(self.mi[i]);p.use_smooth=self.sm[i];norm=p.normal;axis=max(range(3),key=lambda j:abs(norm[j]));ax=[j for j in range(3) if j!=axis]
   for k,li in enumerate(p.loop_indices):
    v=Vector(self.v[me.loops[li].vertex_index]);coords=(v[ax[0]]*.65,v[ax[1]]*.65)
    if self.mi[i]=='Paint':coords=tuple(.10+.80*(v[j]-boundslo[j])/max(boundshi[j]-boundslo[j],.001) for j in ax)
    if self.mi[i]=='Paint' and p.area>.20 and len(p.vertices)>=4:
     pp=[Vector(self.v[vi]) for vi in p.vertices];lo=[min(q[j] for q in pp) for j in ax];hi=[max(q[j] for q in pp) for j in ax];coords=tuple(.006+.988*(v[j]-lo[t])/max(hi[t]-lo[t],.001) for t,j in enumerate(ax))
    if self.mi[i]=='Paint' and name.startswith('Wheel_'):
     q=v-Vector(pivot);rad=math.hypot(q.y,q.z);coords=(.012+.18*max(0,1-rad/.445),.12+.76*((math.atan2(q.z,q.y)+pi)/(2*pi)))
    uv.data[li].uv=self.uv[i][k] if self.uv[i] else coords
  if bevel:
   bpy.context.view_layer.objects.active=o;o.select_set(True);mod=o.modifiers.new('Machined edge chamfers','BEVEL');mod.width=bevel;mod.segments=1;mod.affect='EDGES';mod.limit_method='ANGLE';mod.angle_limit=.6
   bpy.ops.object.modifier_apply(modifier=mod.name);o.select_set(False)
  if parent:par(o,parent)
  collection.append(o)
  if name.startswith('Part_'):parts[name]=o
  return o
def par(o,p):
 bpy.context.view_layer.update();w=o.matrix_world.copy();o.parent=p;o.matrix_world=w
 defnothing=0
def empty(name,loc,parent=None,col=healthy):
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=loc
 if parent:par(o,parent)
 col.append(o);return o
def hullshape(g):
 # Panther upper side armour slopes inward; lower glacis slopes back below its nose.
 sections=[(-3.435,1.02,.64,1.35,1.13,1.35,1.13),(-2.38,1.03,.49,1.47,1.36,1.25,1.88),(2.84,1.03,.49,1.47,1.36,1.25,1.88),(3.40,1.03,.73,1.36,1.42,1.23,1.48)]
 v=[]
 for i,(y,wb,zb,wm,zm,wt,zt) in enumerate(sections):
  by=y+.42 if i==0 else y
  v.extend([(-wb,by,zb),(wb,by,zb),(wm,y,zm),(wt,y,zt),(-wt,y,zt),(-wm,y,zm)])
 # Front station's upper two points form a thin deliberate armour lip.
 v[3]=Vector(v[3])+Vector((0,.012,.008));v[4]=Vector(v[4])+Vector((0,.012,.008))
 f=[tuple(reversed(range(6))),tuple(range(18,24))]
 for i in range(3):
  for j in range(6):f.append((i*6+j,i*6+(j+1)%6,(i+1)*6+(j+1)%6,(i+1)*6+j))
 g.add(v,f)
def cut(o,c,r,depth,axis='Z',segments=48):
 bpy.ops.mesh.primitive_cylinder_add(vertices=segments,radius=r,depth=depth,location=c);cutter=bpy.context.object
 if axis=='Y':cutter.rotation_euler[0]=pi/2
 if axis=='X':cutter.rotation_euler[1]=pi/2
 bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('True opening','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cutter,do_unlink=True)
 for sl in o.material_slots:
  if sl.material is None:sl.material=MATS['Steel']
def build_healthy():
 g=Geo();hullshape(g)
 # Weld seams, side mounts, torsion bar anchors and uninterrupted armour beneath skirts.
 for sign in [-1,1]:
  g.box((sign*1.37,.18,1.70),(.31,5.28,.035));
  for y in np.linspace(-2.8,2.9,12):g.box((sign*1.438,y,1.63),(.06,.11,.12));g.bolt((sign*1.477,y,1.64),'X')
  g.rod((sign*1.25,-2.38,1.884),(sign*1.25,2.82,1.884),.012)
  for y in np.linspace(-2.20,2.0,8):g.rod((sign*1.06,y+.18,.84),(sign*1.30,y,.55),.07,'Steel')
  for y in [-3.15,3.20]:g.ring((sign*1.07,y,.94),.11,.064,.10,'Steel','Y',24)
 # turret ring, bow MG ball and its tube
 g.ring((0,-.25,1.91),.91,.78,.10,n=64);g.lathe((-.63,-2.73,1.66),[(-.13,.035),(-.115,.10),(-.07,.16),(0,.18),(.07,.17)],axis='Y',n=40);g.rod((-.63,-2.80,1.66),(-.63,-3.12,1.66),.027,'Steel')
 for x in [-.65,.65]:g.ring((x,-1.96,1.891),.275,.235,.04);g.cyl((x,-1.96,1.66),.235,.025,'Interior');g.ring((x,-1.96,1.77),.24,.225,.23,'Interior')
 # engine deck access plates and permanent underlying vent cavities
 for x in [-.77,.77]:
  g.cyl((x,2.10,1.888),.45,.025,'Interior',n=48);g.ring((x,2.10,1.904),.46,.425,.025)
  for y in [1.35,2.8]:g.box((x,y,1.899),(.86,.42,.025),'Interior')
 for x,y in [(0,1.45),(0,2.1),(0,2.7)]:g.cyl((x,y,1.91),.15,.025);g.bolt((x,y,1.934))
 hull=g.obj('Hull',bevel=.009)
 for x in [-.65,.65]:cut(hull,(x,-1.96,1.9),.225,.24)
 cut(hull,(0,-.25,1.67),.76,.98)
 # armoured turret loft with clipped corners
 g=Geo();low=[(-.99,-1.35),( .99,-1.35),(1.13,-.95),(1.06,.88),(.76,1.18),(-.76,1.18),(-1.06,.88),(-1.13,-.95)];high=[(-.79,-1.16),(.79,-1.16),(.87,-.86),(.79,.74),(.58,.95),(-.58,.95),(-.79,.74),(-.87,-.86)]
 v=[(x,y,1.98) for x,y in low]+[(x,y,2.75) for x,y in high];g.add(v,[tuple(reversed(range(8))),tuple(range(8,16))]+[(i,(i+1)%8,(i+1)%8+8,i+8) for i in range(8)])
 for pts in [low,high]:g.path([(x,y,1.992 if pts==low else 2.759) for x,y in pts+[pts[0]]],.009,'Steel')
 # roof opening coamings and lifting eyes
 g.ring((.48,.39,2.78),.36,.285,.08);g.ring((.48,.39,2.86),.294,.274,.18);g.ring((-.43,-.49,2.765),.24,.205,.025)
 for x,y in [(-.68,-1.0),(.68,-1.0),(-.63,.65)]:g.ring((x,y,2.79),.045,.026,.025,'Steel','Y',16)
 turret=g.obj('Turret',(0,-.25,1.94),bevel=.012)
 cut(turret,(.48,.39,2.75),.282,.3);cut(turret,(-.43,-.49,2.75),.20,.3)
 # dark crew wells
 g=Geo();g.ring((.48,.39,2.55),.282,.26,.37,'Interior');g.cyl((.48,.39,2.36),.28,.02,'Interior');g.ring((-.43,-.49,2.56),.2,.18,.32,'Interior');g.cyl((-.43,-.49,2.39),.2,.02,'Interior')
 inner=g.obj('_turret_interior',parent=turret,bevel=0);join_into(turret,[inner])
 # Saukopf style rounded transverse cast mantlet
 g=Geo();g.lathe((0,-1.40,2.37),[(-.82,.24),(-.76,.34),(-.57,.365),(.57,.365),(.76,.34),(.82,.24)],axis='X',n=40);g.cyl((0,-1.77,2.37),.18,.17,axis='Y');g.cyl((-.51,-1.744,2.43),.041,.015,'Interior','Y',20)
 for x in [-.785,.785]:g.ring((x,-1.40,2.37),.303,.285,.024,axis='X',n=40)
 g.cyl((-.51,-1.695,2.43),.031,.012,'Interior','Y',24);g.cyl((.47,-1.76,2.39),.027,.12,'Steel','Y',24)
 gun=g.obj('Gun',(0,-1.39,2.37),parent=turret,bevel=.006)
 cut(gun,(-.51,-1.76,2.43),.033,.115,'Y')
 g=Geo();g.lathe((0,0,2.37),[(-5.12,.039),(-5.12,.068),(-4.98,.072),(-2.0,.105),(-1.83,.135),(-1.80,.135),(-1.80,.039),(-5.12,.039)],'Paint','Y',48,False)
 # double-baffle muzzle brake: actual lateral ports and open bore
 g.ring((0,-5.125,2.37),.123,.040,.040,'Steel','Y',40);g.ring((0,-5.410,2.37),.123,.041,.030,'Steel','Y',40);g.ring((0,-5.27,2.37),.128,.045,.030,'Steel','Y',40)
 for z in [-1,1]:g.box((0,-5.2675,2.37+z*.106),(.105,.285,.030),'Steel')
 g.cyl((0,-5.02,2.37),.039,.02,'Interior','Y',24)
 barrel=g.obj('Barrel',(0,-1.82,2.37),parent=gun,bevel=.002);empty('Muzzle',(0,-5.425,2.37),barrel)
 # periscopic cupola, removable armoured ring, seven visor windows
 g=Geo();g.lathe((.48,.39,0),[(2.785,.35),(2.83,.35),(2.927,.331),(2.927,.281),(2.785,.281),(2.785,.35)],n=64,cap=False);g.ring((.48,.39,2.925),.338,.28,.023,n=64)
 for a in np.linspace(0,2*pi,8)[:-1]:
  c=(.48+.344*cos(a),.39+.344*sin(a),2.878);g.box(c,(.108,.010,.044),'Interior',Matrix.Rotation(a-pi/2,3,'Z'));g.box((c[0],c[1],2.908),(.14,.030,.014),rot=Matrix.Rotation(a-pi/2,3,'Z'))
 cup=g.obj('Part_Turret_Cupola',( .48,.39,2.84),turret)
 # three hinged closed hatches + radio operator hatch as additional node
 for name,c,r,parent in [('Hatch_Commander',(.48,.39,2.952),.278,turret),('Hatch_Loader',(-.43,-.49,2.79),.203,turret),('Hatch_Driver',(.65,-1.96,1.924),.233,hull),('Hatch_Radio',(-.65,-1.96,1.924),.233,hull)]:
  g=Geo();g.cyl(c,r,.045,n=48);g.rod((c[0]-.07,c[1],c[2]+.025),(c[0]+.07,c[1],c[2]+.025),.012,'Steel');g.cyl((c[0],c[1]+r,c[2]),.033,.19,axis='X',n=20)
  g.obj(name,(c[0],c[1]+r,c[2]),parent,bevel=.004)
 # Low stowed MG34: round receiver, vented shroud, drum feed and shaped butt.
 g=Geo();g.rod((.76,.50,2.86),(.76,.50,2.923),.019,'Steel');g.cyl((.76,.35,2.94),.035,.35,'Steel','Y',24)
 g.lathe((.76,0,2.94),[(-.58,.014),(-.55,.024),(-.48,.024),(-.48,.019),(.16,.019),(.17,.031),(.19,.031)],'Steel','Y',24)
 for yy in np.linspace(-.43,.10,10):
  g.ring((.76,yy,2.94),.023,.017,.012,'Steel','Y',16)
  for xx in [-1,1]:g.box((.76+xx*.019,yy,2.94),(.004,.024,.011),'Interior')
 g.cyl((.817,.29,2.925),.052,.08,'Steel','X',24);g.ring((.858,.29,2.925),.044,.029,.008,'Steel','X',24)
 g.box((.76,.40,2.892),(.032,.036,.055),'Wood');g.ring((.76,.354,2.895),.025,.019,.012,'Steel','X',16)
 g.lathe((.76,0,2.935),[(.50,.019),(.56,.019),(.66,.036),(.76,.033)],'Wood','Y',16)
 g.ring((.76,.756,2.935),.034,.029,.012,'Steel','Y',16);g.rod((.76,-.51,2.955),(.76,-.51,2.983),.004,'Steel',8)
 g.obj('MG',(.76,.50,2.923),turret,.001)
 for name,c,d in [('Sight',(-.58,-.96,2.78),(.18,.16,.075)),('RearEscape', (0,1.117,2.38),(.48,.05,.43)),('Vent',(-.22,.51,2.785),(.22,.25,.055))]:
  g=Geo()
  if name=='RearEscape':
   g.cyl(c,.249,.067,axis='Y',n=48);g.ring(c,.253,.238,.071,'Steel','Y',48);g.rod((-.11,c[1]+.043,c[2]+.1),(.11,c[1]+.043,c[2]+.1),.019,'Steel');g.cyl((0,c[1],c[2]+.238),.04,.23,axis='X',n=16)
  else:g.box(c,d);g.bolt((c[0],c[1],c[2]+d[2]/2+.006))
  g.obj('Part_Turret_'+name,c,turret)
 # Wheels: eight interleaved stations each side plus front sprocket and rear idler.
 for sign,side in [(1,'L'),(-1,'R')]:
  stations=[(-2.75,.67,.43,'sprocket')]+[(y,.53,.445,'road') for y in np.linspace(-2.06,2.02,8)]+[(2.76,.65,.36,'idler')]
  for i,(y,z,r,kind) in enumerate(stations,1):
   x=sign*(1.32+(.095 if i%2 else -.075));g=Geo()
   if kind=='road':g.ring((x,y,z),r,r-.045,.21,'Rubber','X',48)
   g.lathe((x,y,z),[(-.155,.095),(-.080,.15),(-.085,r-.14),(-.145,r-.08),(-.145,r-.044),(.145,r-.044),(.145,r-.08),(.085,r-.14),(.080,.15),(.155,.095)],'Paint','X',48)
   outer=x+sign*.158;g.ring((outer,y,z),r-.046,r-.067,.012,'Steel','X',48);g.cyl((outer+sign*.025,y,z),.105,.068,axis='X',n=32);g.cyl((outer+sign*.065,y,z),.049,.025,'Steel','X',24)
   for a in np.linspace(0,2*pi,9)[:-1]:g.bolt((outer+sign*.047,y+.142*cos(a),z+.142*sin(a)),'X',.018)
   if kind in ['sprocket','idler']:
    for a in np.linspace(0,2*pi,9)[:-1]:g.cyl((outer+sign*.004,y+r*.63*cos(a),z+r*.63*sin(a)),r*.14,.016,'Interior','X',16)
   if kind=='sprocket':
    for a in np.linspace(0,2*pi,21)[:-1]:g.box((x,y+r*cos(a),z+r*sin(a)),(.26,.084,.062),'Steel',Matrix.Rotation(a,3,'X'))
   wheel=g.obj(f'Wheel_{side}{i}',(x,y,z),bevel=.002)
  # Panther has no independent return rollers; the upper track is supported by the overlapping wheels.
  track(side,sign)
  # five thin Schurzen plates each side
  for i,y in enumerate(np.linspace(-2.15,2.31,5),1):
   g=Geo();g.box((sign*1.685,y,1.365),(.025,1.10,.63));
   for dy in [-.40,.40]:g.bolt((sign*1.701,y+dy,1.61),'X',.022);g.box((sign*1.67,y+dy,1.68),(.08,.055,.08),'Steel')
   g.obj(f'Part_Skirt_{side}{i}',(sign*1.685,y,1.365),bevel=.004)
  # curved sheet fenders, clipped around front/rear track wraps
  for front in [True,False]:
   yy=-2.94 if front else 2.97;g=Geo();verts=[];profile=[]
   for q in np.linspace(0,1,12):
    y=-3.36+.96*q if front else 2.48+.86*q;z=1.24+.39*sin(q*pi/2) if front else 1.59-.30*q*q;profile.append((y,z))
    verts.extend([(sign*1.08,y,z),(sign*1.70,y,z),(sign*1.08,y,z-.022),(sign*1.70,y,z-.022)])
   faces=[]
   for j in range(11):
    b=j*4;faces.extend([(b,b+1,b+5,b+4),(b+2,b+6,b+7,b+3),(b,b+4,b+6,b+2),(b+1,b+3,b+7,b+5)])
   faces.extend([(0,2,3,1),(44,45,47,46)]);g.add(verts,faces)
   for xx in [1.10,1.40,1.68]:g.path([(sign*xx,y,z+.006) for y,z in profile],.009)
   y,z=profile[7];g.rod((sign*1.09,y,z+.008),(sign*1.69,y,z+.008),.010,'Steel',8)
   g.obj('Part_Fender_'+('F' if front else 'R')+side,(sign*1.4,yy,1.50))
  # spare links, tools, cylindrical cleaning-rod tube, steel brackets
  g=Geo()
  for y in np.linspace(1.5,2.75,6):
   g.box((sign*1.46,y-.073,1.91),(.075,.042,.23),'TrackSteel');g.box((sign*1.46,y+.073,1.91),(.075,.042,.23),'TrackSteel');g.box((sign*1.46,y,1.805),(.075,.19,.026),'TrackSteel');g.box((sign*1.46,y,2.015),(.075,.19,.026),'TrackSteel');g.box((sign*1.505,y,1.92),(.025,.035,.20),'Steel');g.bolt((sign*1.529,y,1.94),'X')
  g.obj('Part_SpareTracks_'+side,(sign*1.46,2.12,1.92))
  g=Geo()
  for k,y in enumerate([-1.50,-1.26]):
   zz=1.86+k*.12;xx=sign*1.49
   g.rod((xx,y-.47,zz),(xx,y+.56,zz),.016,'Wood',12)
   if k==0:
    shape=[(-.13,-.05),(-.17,.03),(-.14,.14),(.14,.14),(.17,.03),(.13,-.05),(0,-.14)];vv=[(xx+dx,y-.53+dy,zz+dz) for dx in [-.010,.010] for dy,dz in shape];N=len(shape);g.add(vv,[tuple(reversed(range(N))),tuple(range(N,2*N))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)],'Steel')
   else:
    g.rod((xx,y-.43,zz-.12),(xx,y-.43,zz+.14),.028,'Steel',8);g.box((xx,y-.43,zz+.08),(.032,.15,.065),'Steel')
   g.ring((xx,y+.54,zz),.050,.036,.021,'Steel','X',16)
  for y in [-2.1,-1.3,-.65]:g.box((sign*1.48,y,1.90),(.10,.05,.18),'Steel');g.bolt((sign*1.54,y,1.95),'X')
  g.obj('Part_Tools_'+side,(sign*1.48,-1.5,1.91))
  g=Geo();g.cyl((sign*1.49,.41,1.94),.092,1.68,axis='Y',n=32)
  for y in [-.36,1.18]:g.ring((sign*1.49,y,1.94),.10,.085,.045,'Steel','Y',32)
  g.obj('Part_RodTube_'+side,(sign*1.49,.41,1.94))
  g=Geo();pts=[(sign*1.48,y,1.78-.07*sin((y+2)*pi/4)) for y in np.linspace(-2.2,2.95,36)];g.path(pts,.023,'Steel',10)
  for y in [-2.20,2.95]:g.ring((sign*1.48,y,1.79),.075,.047,.035,'Steel','X',20)
  g.obj('Part_TowCable_'+side,(sign*1.48,.37,1.77),bevel=0)
 # rear bins, exhausts and deck grills
 for sign,side in [(1,'L'),(-1,'R')]:
  g=Geo();g.box((sign*1.02,3.255,1.50),(.64,.27,.50));g.box((sign*1.02,3.27,1.76),(.66,.29,.035))
  for k in [-1,1]:g.rod((sign*1.02+k*.22,3.40,1.33),(sign*1.02-k*.22,3.40,1.66),.012);g.bolt((sign*1.02+k*.22,3.426,1.7),'Y')
  g.obj('Part_Stowage_'+side,(sign*1.02,3.255,1.50))
  g=Geo();g.cyl((sign*.42,3.47,1.17),.116,.42,'Steel',n=32);g.rod((sign*.42,3.47,1.34),(sign*.42,3.47,1.91),.043,'Steel');g.rod((sign*.42,3.47,1.91),(sign*.42,3.38,1.97),.043,'Steel');g.ring((sign*.42,3.37,1.97),.044,.029,.04,'Steel','Y',24)
  # exhaust remains inside the specified hull length envelope
  for idx,v in enumerate(g.v):g.v[idx]=Vector(v)+Vector((0,-.15112,0))
  g.obj('Part_Exhaust_'+side,(sign*.42,3.32,1.44))
  g=Geo();x=sign*.77;g.ring((x,2.10,1.935),.445,.41,.045,n=48)
  for rr in [.1,.18,.27,.36]:g.ring((x,2.10,1.94),rr+.009,rr-.009,.015,'Steel',n=48)
  for a in np.linspace(0,2*pi,17)[:-1]:g.rod((x,2.1,1.948),(x+.41*cos(a),2.1+.41*sin(a),1.948),.008,'Steel',6)
  for y in [1.35,2.8]:
   for yy in np.linspace(y-.18,y+.18,9):g.box((x,yy,1.93),(.82,.018,.05),'Steel')
   for xx in [-.425,.425]:g.box((x+xx,y,1.94),(.022,.43,.06))
  g.obj('Part_EngineGrille_'+side,(x,2.1,1.94),bevel=.002)
  g=Geo();g.rod((sign*1.05,-2.48,1.87),(sign*1.05,-2.48,2.015),.025,'Steel');g.cyl((sign*1.05,-2.51,2.025),.074,.09,axis='Y',n=32);g.cyl((sign*1.05,-2.561,2.025),.062,.014,'Lamps','Y',32);g.box((sign*1.05,-2.57,2.025),(.124,.01,.069))
  g.obj('Part_LampGuard_'+side,(sign*1.05,-2.5,1.98))
 g=Geo();g.cyl((-1.18,2.25,1.96),.065,.14,'Steel');g.rod((-1.18,2.25,2.02),(-1.18,2.25,2.97),.005,'Steel',8);g.obj('Part_Antenna',(-1.18,2.25,2.35),bevel=0)
 for name,loc,parent in [('Seat_Commander',(.48,.39,2.97),turret),('Seat_Driver',(.65,-1.96,1.96),hull),('Exhaust',(0,3.31,1.98),hull),('Light_Head_L',(1.05,-2.58,2.025),hull),('Light_Head_R',(-1.05,-2.58,2.025),hull),('Fx_Engine',(0,2.1,1.94),hull),('Fx_Ammo',(0,.3,1.2),hull),('Fx_Hatch',(.48,.39,2.99),turret)]:empty(name,loc,parent)
 return hull,turret,gun,barrel

def chipped_edges():
 # Small flakes expose real scanned steel. They sit 1.5 mm above the armour.
 def flakes(o,positions,u,v,n):
  g=Geo();u,v,n=Vector(u),Vector(v),Vector(n);inv=o.matrix_world.inverted()
  for pos in positions:
   p=Vector(pos);w=random.uniform(.008,.045);h=random.uniform(.004,.024);vv=[]
   for k in range(7):
    a=2*pi*k/7;rad=random.uniform(.65,1.1);q=p+u*cos(a)*w*rad+v*sin(a)*h*rad;hit,loc,_,_=o.ray_cast(inv@(q+n),inv.to_3x3()@(-n))
    if hit:vv.append(o.matrix_world@loc+n*.0015)
   if len(vv)>3:g.add(vv,[tuple(range(len(vv)))],'TrackSteel' if random.random()<.8 else 'Steel')
  if g.v:
   tmp=g.obj('_edge_flakes',collection=[],bevel=0);join_into(o,[tmp])
 for side,sign in [('L',1),('R',-1)]:
  for i in range(1,6):
   o=parts[f'Part_Skirt_{side}{i}'];cy=o.location.y;positions=[(sign*1.70,cy+random.uniform(-.52,.52),1.061+random.uniform(0,.045)) for _ in range(random.randint(9,21))];flakes(o,positions,(0,1,0),(0,0,1),(sign,0,0))
  o=bpy.data.objects['Turret'];positions=[(sign*1.12,random.uniform(-.85,.78),2.01+random.uniform(0,.035)) for _ in range(17)];flakes(o,positions,(0,1,0),(-sign*.3,0,.95),(sign*.95,0,.3))
 o=bpy.data.objects['Hull'];positions=[(random.uniform(-1.23,1.23),-3.36+random.uniform(0,.055),1.19) for _ in range(25)];flakes(o,positions,(1,0,0),(0,.815,.58),(0,-.58,.815))

def join_into(target,obs):
 bpy.ops.object.select_all(action='DESELECT');target.select_set(True)
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=target;bpy.ops.object.join()
 for o in obs:
  if o in healthy:healthy.remove(o)

# Convex support envelope of sprocket, first/last road wheel and idler.
# Arc-length sampling keeps link pitch uniform while fitting the actual running gear.
track_samples=[]
for cy,cz,r in [(-2.75,.67,.482),(-2.06,.53,.4705),(2.02,.53,.4705),(2.76,.65,.407)]:
 for k in range(96):track_samples.append((cy+r*cos(k*2*pi/96),cz+r*sin(k*2*pi/96)))
def cross2(o,a,b):return (a[0]-o[0])*(b[1]-o[1])-(a[1]-o[1])*(b[0]-o[0])
pp=sorted(set(track_samples));lo=[];hi=[]
for p in pp:
 while len(lo)>=2 and cross2(lo[-2],lo[-1],p)<=0:lo.pop()
 lo.append(p)
for p in reversed(pp):
 while len(hi)>=2 and cross2(hi[-2],hi[-1],p)<=0:hi.pop()
 hi.append(p)
TRACK_PATH=lo[:-1]+hi[:-1];start=min(range(len(TRACK_PATH)),key=lambda i:abs(TRACK_PATH[i][0]+2.06)+abs(TRACK_PATH[i][1]-.0595));TRACK_PATH=TRACK_PATH[start:]+TRACK_PATH[:start];TRACK_PATH.append(TRACK_PATH[0]);TRACK_LENGTH=sum(math.dist(a,b) for a,b in zip(TRACK_PATH,TRACK_PATH[1:]))
def loop_point(t):
 d=(t%1)*TRACK_LENGTH
 for a,b in zip(TRACK_PATH,TRACK_PATH[1:]):
  length=math.dist(a,b)
  if d<=length:
   f=d/length;return (a[0]+f*(b[0]-a[0]),a[1]+f*(b[1]-a[1])),math.atan2(b[1]-a[1],b[0]-a[0])
  d-=length
 return TRACK_PATH[0],0

def track(side,sign,broken=False,col=healthy,parent=None):
 g=Geo();N=108
 for j in range(N):
  t=j/N;(y,z),a=loop_point(t)
  if broken and t>.52:
   q=(t-.52)/.48;y=2.55-q*7.0;z=.0595+1.0*max(0,1-q*3)**2;a=math.atan2(-6.0*max(0,1-q*3),-7.0)+pi
  rot=Matrix.Rotation(a,3,'X');xx=sign*(1.36+(.68*sin(min(q*3,1)*pi/2) if broken and t>.52 else 0));center=Vector((xx,y,z));before=len(g.f)
  def bx(dx,dy,dz,d,mat):g.box(center+rot@Vector((dx,dy,dz)),d,mat,rot)
  for dx in [-.288,.288]:bx(dx,0,0,(.075,.124,.065),'TrackSteel')
  for dx in [-.155,.155]:bx(dx,0,.014,(.208,.068,.014),'TrackSteel')
  bx(0,0,0,(.070,.122,.064),'TrackSteel')
  bx(0,-.046,0,(.62,.025,.064),'TrackSteel');bx(0,.046,0,(.62,.025,.064),'TrackSteel');bx(0,0,-.043,(.64,.033,.033),'Steel');bx(0,0,.066,(.065,.075,.091),'TrackSteel')
  for dx in [-.323,.323]:g.cyl(center+rot@Vector((dx,0,0)),.025,.025,'Steel','X',10)
  for fidx in range(before,len(g.f)):
   face=g.f[fidx];tangent=rot@Vector((0,1,0));g.uv[fidx]=[((g.v[v][0]-sign*1.36)/.66+.5,t+(Vector(g.v[v])-center).dot(tangent)/TRACK_LENGTH) for v in face]
 o=g.obj('Track_'+side+('_Broken' if broken else ''),(0,0,0),parent,bevel=.0015,collection=col)
 if broken:
  decimate(o,.23)
  for ve in o.data.vertices:ve.co.z=max(0,ve.co.z)
 return o

def copyobj(o,name,col=damage,parent=None):
 c=o.copy();c.data=o.data.copy() if o.type=='MESH' else None;bpy.context.collection.objects.link(c);c.name=name;c.parent=None;c.matrix_world=o.matrix_world.copy();bpy.context.view_layer.update();col.append(c)
 if parent:par(c,parent)
 return c

def decimate(o,ratio):
 bpy.context.view_layer.objects.active=o;m=o.modifiers.new('Game mesh reduction','DECIMATE');m.ratio=ratio;bpy.ops.object.modifier_apply(modifier=m.name)
def burn(o):
 o.data.materials.clear();o.data.materials.append(MATS['Burnt'])
 for p in o.data.polygons:p.material_index=0

def build_damage(hull,turret,gun,barrel):
 hd=empty('Hull_Damage',(0,0,0),col=damage);td=empty('Turret_Damage',tuple(turret.matrix_world.translation),col=damage)
 twins={}
 for name,o in parts.items():
  if any(k in name for k in ['Skirt','Fender','Stowage','EngineGrille','Turret_Cupola','Turret_RearEscape','Turret_Sight','Turret_Vent']):
   c=copyobj(o,name.replace('Part_','Dmg_',1),parent=td if 'Turret_' in name else hd);twins[name]=c.name
   # bent lower edges and twisted sheet corners; preserve swap origin exactly
   for v in c.data.vertices:
    p=v.co;factor=max(0,min(1,(-p.z+.10)*2));p.x+=.16*sin(p.y*5+1)*factor;p.z+=.12*sin(p.y*7)*factor;p.y+=.035*sin(p.x*11+p.z*4)
   if 'Skirt' in name:
    cut(c,tuple(c.matrix_world@Vector((0,.15,-.10))),.13,.80,'X')
    # Tear a V out of the bottom edge with an irregular cutter.
    tg=Geo();xx=c.matrix_world.translation.x;yy=c.matrix_world.translation.y;tg.add([(xx+dx,yy+dy,zz) for dx in [-.5,.5] for dy,zz in [(-.28,.88),(.12,.88),(-.06,1.29)]],[(0,2,1),(3,4,5),(0,1,4,3),(1,2,5,4),(2,0,3,5)],'Scars');co=tg.obj('_tear',collection=[],bevel=0);bpy.context.view_layer.objects.active=c;mod=c.modifiers.new('Torn away corner','BOOLEAN');mod.operation='DIFFERENCE';mod.object=co;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(co,do_unlink=True)
   if 'Fender' in name:
    for ve in c.data.vertices:
     if ve.co.y<-.06:ve.co.z+=.24*(-ve.co.y/.5);ve.co.x+=.12*sin(ve.co.y*8)
   for slot in c.material_slots:
    if slot.material is None or slot.material.name!='Paint':slot.material=MATS['TrackSteel']
 # armour-face impact clusters: flattened soot fans + jagged raised penetration lips
 scar_names=[]
 def scars(name,center,u,v,normal,parent):
  g=Geo();c=Vector(center);u=Vector(u);v=Vector(v);n=Vector(normal)
  for j,(a,b) in enumerate([(-.28,.06),(.13,-.11),(.37,.17),(-.04,.24)]):
   p=c+u*a+v*b;verts=[p+n*.003];N=18
   for k in range(N):
    ang=k*2*pi/N;r=(.11 if j else .145)*(1+random.uniform(-.22,.22));verts.append(p+u*cos(ang)*r+v*sin(ang)*r+n*.003)
   g.add(verts,[(0,k+1,(k+1)%N+1) for k in range(N)],'Scars')
   for k,fi in enumerate(range(len(g.f)-N,len(g.f))):g.uv[fi]=[(.25,.5),(.25+.24*cos(k*2*pi/N),.5+.48*sin(k*2*pi/N)),(.25+.24*cos(((k+1)%N)*2*pi/N),.5+.48*sin(((k+1)%N)*2*pi/N))]
   if j==0:
    vv=[]
    for r,d in [(.030,.004),(.045,.015),(.070,.003)]:
     for k in range(N):
      ang=k*2*pi/N;rr=r*(1+random.uniform(-.18,.18));vv.append(p+u*cos(ang)*rr+v*sin(ang)*rr+n*d)
    start=len(g.f);g.add(vv,[(i*N+k,i*N+(k+1)%N,(i+1)*N+(k+1)%N,(i+1)*N+k) for i in range(2) for k in range(N)],'Scars')
    for fi in range(start,len(g.f)):g.uv[fi]=[(.76,.5)]*len(g.f[fi])
    g.add([p+n*.004]+[p+u*cos(k*2*pi/N)*.030+v*sin(k*2*pi/N)*.030+n*.004 for k in range(N)],[(0,k+1,(k+1)%N+1) for k in range(N)],'Rubber')
   else:
    start=len(g.f)
    g.add([p+u*x+v*y+n*z for x,y,z in [(-.10,-.005,.003),(-.035,-.024,.005),(.12,-.008,.003),(.06,.018,.008),(-.04,.027,.004)]],[(0,1,2),(0,2,3),(0,3,4)],'Scars')
    for fi in range(start,len(g.f)):g.uv[fi]=[(.76,.5)]*3
  target=gun if name=='Scar_Turret_Front' else turret if 'Turret' in name else hull;inv=target.matrix_world.inverted()
  for idx,q in enumerate(g.v):
   q=Vector(q);offset=max(.003,(q-c).dot(n));hit,loc,normal,index=target.ray_cast(inv@(q+n*2),inv.to_3x3()@(-n))
   if hit:g.v[idx]=target.matrix_world@loc+n*offset
  o=g.obj(name,center,parent,bevel=0,collection=damage);scar_names.append(name)
 scars('Scar_Hull_Front',(0,-2.85,1.544),(1,0,0),(0,.816,.578),(0,-.578,.816),hd)
 scars('Scar_Hull_L',(1.40,0,1.67),(0,1,0),(0,0,1),(1,0,0),hd)
 scars('Scar_Hull_R',(-1.40,0,1.67),(0,-1,0),(0,0,1),(-1,0,0),hd)
 scars('Scar_Hull_Rear',(0,3.25,1.32),(1,0,0),(0,-.84,.54),(0,.54,.84),hd)
 scars('Scar_Turret_Front',(0,-1.28,2.58),(1,0,0),(0,.24,.97),(0,-.97,.24),td)
 scars('Scar_Turret_L',(1.01,-.14,2.37),(0,1,0),(-.30,0,.954),(.954,0,.30),td)
 scars('Scar_Turret_R',(-1.01,-.14,2.37),(0,-1,0),(.30,0,.954),(-.954,0,.30),td)
 scars('Scar_Turret_Rear',(0,1.08,2.37),(1,0,0),(0,-.285,.959),(0,.959,.285),td)
 bl=track('L',1,True,damage,hd);br=track('R',-1,True,damage,hd)
 gb=copyobj(gun,'Gun_Broken',parent=td);bc=copyobj(barrel,'_burst_barrel')
 # Bend forward barrel sharply down and split the muzzle into twisted petals.
 inv=bc.matrix_world.inverted()
 for ve in bc.data.vertices:
  w=bc.matrix_world@ve.co
  if w.y<-3.1:w.z-=(-3.1-w.y)*.30;w.x+=.07*sin((-3.1-w.y)*2)
  ve.co=inv@w
 join_damage(gb,[bc]);
 for sl in gb.material_slots:
  if sl.material is None or sl.material.name not in ['Paint','TrackSteel','Rubber']:sl.material=MATS['TrackSteel']
 # Burnt wrecks built solely from healthy mesh copies, same pivots.
 hullcopies=[];turcopies=[]
 for o in list(healthy):
  if o.type!='MESH':continue
  is_t=o==turret or o in turret.children_recursive
  if o.name.startswith('Part_Skirt') and o.name[-1] in ['1','3','5']:continue
  if o.name in ['Track_L','Part_Fender_FL','Part_EngineGrille_L']:continue
  c=copyobj(o,'_wreck_'+o.name)
  if o.name.startswith('Wheel_'):
   rubber=[i for i,m in enumerate(c.data.materials) if m is not None and m.name=='Rubber'];bm=bmesh.new();bm.from_mesh(c.data);bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.material_index in rubber],context='FACES');bm.to_mesh(c.data);bm.free()
  burn(c)
  if o.name.startswith('Wheel_'):
   c.location.z-=.05
   for ve in c.data.vertices:
    if abs(ve.co.x)<.125:ve.co.y*=.97;ve.co.z*=.97
  if o.name=='Hull':
   for ve in c.data.vertices:
    w=c.matrix_world@ve.co
    if w.y>1.15 and w.z>1.86:ve.co.z+=.12*sin((w.y-1.15)*2.1)*max(0,1-abs(w.x)/1.3)
  if o.name=='Track_R':
   for ve in c.data.vertices:
    if ve.co.z>1.16 and abs(ve.co.y)<2.6:ve.co.z-=.07*cos(ve.co.y*pi/5.2)
  if o.name.startswith('Hatch_'):
   c.data.transform(Matrix.Rotation(-1.4,4,'X'))
  if 'EngineGrille' in o.name:
   c.data.transform(Matrix.Rotation(.4,4,'Y'));c.location.z+=.14
  if o.name in ['Gun','Barrel']:
   pivot=gun.matrix_world.translation;rot=Matrix.Rotation(math.radians(12),4,'X');c.matrix_world=Matrix.Translation(pivot)@rot@Matrix.Translation(-pivot)@c.matrix_world
  (turcopies if is_t else hullcopies).append(c)
 c=copyobj(bl,'_wreck_track');burn(c);hullcopies.append(c)
 wh=hullcopies.pop(0);wh.name='Wreck_Hull';join_damage(wh,hullcopies);setorigin(wh,(0,0,0));decimate(wh,.36)
 wt=turcopies.pop(0);wt.name='Wreck_Turret';join_damage(wt,turcopies);setorigin(wt,tuple(turret.matrix_world.translation));decimate(wt,.55)
 g=Geo();g.ring((0,-.25,1.85),.77,.67,.22,'Burnt',n=48);g.cyl((0,-.25,1.68),.67,.08,'Burnt',n=48);g.cyl((0,-.25,1.43),.48,.5,'Burnt',n=32)
 basket=g.obj('_basket',collection=damage);join_damage(wt,[basket])
 # dark open turret well inside wreck, closed bottom
 g=Geo();g.ring((0,-.25,1.52),.755,.70,.65,'Burnt',n=48);g.cyl((0,-.25,1.18),.70,.04,'Burnt',n=48)
 well=g.obj('_well',collection=damage);join_damage(wh,[well])
 return {'parts':[{'part':k,'twin':twins.get(k)} for k in parts],'scars':scar_names,'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']}
def join_damage(target,obs):
 bpy.context.view_layer.update()
 bpy.ops.object.select_all(action='DESELECT');target.select_set(True)
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=target;bpy.ops.object.join()
 for o in obs:
  if o in damage:damage.remove(o)
def setorigin(o,p):
 old=o.matrix_world.copy();new=Matrix.Translation(Vector(p));o.data.transform(new.inverted()@old);o.matrix_world=new

def export(file,objs):
 # Remove zero-area triangles introduced by bevel/reduction on thin sheet seams.
 for o in objs:
  if o.type!='MESH':continue
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bad=[f for f in bm.faces if f.calc_area()<1e-12]
  if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
  bm.to_mesh(o.data);bm.free();o.data.update()
 bpy.ops.object.select_all(action='DESELECT')
 for o in objs:o.hide_set(False);o.hide_render=False;o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(ROOT/file),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_image_format='AUTO',export_jpeg_quality=88,export_materials='EXPORT',export_extras=True)
 # glTF baseColorFactor is the one authoritative game paint control.
 p=ROOT/file;raw=p.read_bytes();ln=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+ln]);tail=raw[20+ln:]
 for m in doc.get('materials',[]):
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
 data=json.dumps(doc,separators=(',',':')).encode();data+=b' '*((-len(data))%4);p.write_bytes(struct.pack('<III',0x46546c67,2,20+len(data)+len(tail))+struct.pack('<II',len(data),0x4e4f534a)+data+tail)
 print('EXPORTED',file,p.stat().st_size,flush=True)
def coords(v):return [round(v[0],5),round(v[2],5),round(-v[1],5)]
def manifest(objs):
 bpy.context.view_layer.update();out=[];points=[]
 for o in objs:
  d={'name':o.name,'position':coords(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type}
  if o.type=='MESH':
   ps=[o.matrix_world@Vector(p) for p in o.bound_box];points+=ps;d['triangles']=sum(len(p.vertices)-2 for p in o.data.polygons)
  out.append(d)
 pp=[coords(p) for p in points];return {'nodes':out,'bounding_box':{'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':sum(o.get('triangles',0) for o in out)}
def hide(o,value=True,recursive=False):
 o.hide_render=value
 if recursive:
  for c in o.children_recursive:c.hide_render=value

def render_setup():
 global S
 S=bpy.context.scene;S.render.engine='CYCLES';S.cycles.samples=24;S.cycles.use_denoising=True
 gmat=bpy.data.materials.new('Backdrop');gmat.diffuse_color=(.19,.205,.21,1);gmat.use_nodes=True;gmat.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.19,.205,.21,1);gmat.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.83
 bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));bpy.context.object.name='StudioGround';bpy.context.object.data.materials.append(gmat)
 for name,loc,power,size in [('Softbox',( -5,-6,10),1900,7),('Rim',(6,4,8),1600,6),('Front',(5,-8,5),800,5)]:
  data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;o=bpy.data.objects.new(name,data);bpy.context.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
 data=bpy.data.cameras.new('ProofCamera');cam=bpy.data.objects.new('ProofCamera',data);bpy.context.collection.objects.link(cam);S.camera=cam;data.type='ORTHO';data.ortho_scale=11.6
 setcam((10,-14,7),(0,-.9,1.2));return cam

def setcam(loc,target,scale=11.6):
 c=S.camera;c.location=loc;c.rotation_euler=(Vector(target)-c.location).to_track_quat('-Z','Y').to_euler();c.data.ortho_scale=scale

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);print('RENDER',name,flush=True)
def grid(files,out):
 # Assemble rendered pixels inside Blender (no Pillow dependency).
 imgs=[bpy.data.images.load(str(ROOT/f),check_existing=False) for f in files];w,h=imgs[0].size;canvas=np.ones((h*2,w*3,4),dtype=np.float32)
 for i,img in enumerate(imgs):
  p=np.array(img.pixels[:],dtype=np.float32).reshape(h,w,4);row=1-i//3;canvas[row*h:(row+1)*h,(i%3)*w:(i%3+1)*w]=p
 im=bpy.data.images.new(out,width=w*3,height=h*2,alpha=False);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/out);im.file_format='PNG';im.save()
 for im in imgs:bpy.data.images.remove(im)
def render_proofs():
 global S
 S=setup();bpy.ops.import_scene.gltf(filepath=str(ROOT/'panther-tank.glb'));hs=list(S.objects);bpy.ops.import_scene.gltf(filepath=str(ROOT/'panther-tank-damage.glb'));ds=[o for o in S.objects if o not in hs];H={o.name:o for o in hs};D={o.name:o for o in ds}
 for o in ds:hide(o)
 render_setup();render('final.png');setcam((11,-.95,1.5),(0,-.95,1.5),10.4);render('side.png',1920,1080);setcam((10,-14,7),(0,-.9,1.2))
 clay=bpy.data.materials.new('Clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.37,.37,.37,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.7
 S.view_layers[0].material_override=clay;render('clay.png');S.view_layers[0].material_override=None
 paints=[m for m in bpy.data.materials if m.name.split('.')[0]=='Paint'];saved=[]
 for m in paints:
  nt=m.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');sock=bs.inputs['Base Color'];link=sock.links[0] if sock.links else None
  if link:
   src=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image and 'Paint_base' in n.image.name).outputs['Color'];nt.links.remove(link);mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(src,mix.inputs[1]);nt.links.new(mix.outputs[0],sock);saved.append((mix,m))
 for i,(_,c) in enumerate(PAINTS):
  for mix,m in saved:mix.inputs[2].default_value=c+[1]
  render(f'_paint{i}.png',640,400,12)
 grid([f'_paint{i}.png' for i in range(6)],'paints.png')
 for mix,m in saved:mix.inputs[2].default_value=PAINTS[0][1]+[1]
 info=json.loads((ROOT/'nodes.json').read_text());setcam((11,-14,7),(.5,-.8,1.1),13.0)
 for k in range(6):
  for o in hs:hide(o,k>=4)
  for o in ds:hide(o)
  if k==1:
   for name in info['damage']['scars']:hide(D[name],False)
   for p in [p for p in info['damage']['parts'] if p['part'] in ['Part_Skirt_L1','Part_Skirt_L2','Part_Skirt_L3','Part_Fender_FL','Part_EngineGrille_L','Part_Turret_Cupola']]:hide(H[p['part']]);hide(D[p['twin']],False)
  if k==2:
   for name,o in H.items():
    if name.startswith('Part_'):hide(o,True,True)
  if k==3:
   hide(H['Track_L']);hide(D['Track_L_Broken'],False);hide(H['Gun'],True,True);hide(D['Gun_Broken'],False)
  if k>=4:hide(D['Wreck_Hull'],False);hide(D['Wreck_Turret'],False)
  if k==5:
   D['Wreck_Turret'].rotation_mode='XYZ';D['Wreck_Turret'].location=(3.2,-.2,.68);D['Wreck_Turret'].rotation_euler=(0,math.radians(110),math.radians(-25));bpy.context.view_layer.update();D['Wreck_Turret'].location.z+=.012-min((D['Wreck_Turret'].matrix_world@v.co).z for v in D['Wreck_Turret'].data.vertices)
  render(f'_state{k}.png',640,400,16)
 grid([f'_state{i}.png' for i in range(6)],'states.png')
 # Explicit pivot exercise: all mechanical groups move 30 degrees at once.
 for o in ds:hide(o)
 for o in hs:hide(o,False)
 setcam((10,-14,7),(0,-.9,1.2))
 for name,o in H.items():
  o.rotation_mode='XYZ'
  if name=='Turret':o.rotation_euler.z+=pi/6
  elif name=='Barrel':o.rotation_euler.y+=pi/6
  elif name in ['Gun','MG'] or name.startswith('Hatch_'):o.rotation_euler.x-=pi/6
  elif name.startswith('Wheel_'):o.rotation_euler.x+=pi/6
 render('rig-check.png',1280,800,16)
 for p in ROOT.glob('_paint*.png'):p.unlink()
 for p in ROOT.glob('_state*.png'):p.unlink()

if 'render-only' not in sys.argv:
 for args in [('Paint','green_metal_rust',(1,1,1),.76,.12,1024),('TrackSteel','rusty_metal_03',(.19,.16,.12),.72,.72),('Steel','green_metal_rust',(.24,.25,.24),.48,.8),('Rubber','rubberized_track',(.026,.027,.024),.86,0),('Wood','rusty_painted_metal',(.17,.105,.046),.86,0),('Interior','green_metal_rust',(.016,.018,.017),.9,.15),('Lamps','green_metal_rust',(.37,.34,.23),.28,.3),('Burnt','rusty_metal_03',(.12,.105,.087),.92,.3),('Scars','rusty_metal_03',(.024,.021,.018),.88,.4)]:material(*args)
 hull,turret,gun,barrel=build_healthy();bpy.context.view_layer.update();chipped_edges();bpy.context.view_layer.update()
 for o in parts.values():
  pp=[o.matrix_world@Vector(p) for p in o.bound_box];center=Vector(tuple((min(p[j] for p in pp)+max(p[j] for p in pp))/2 for j in range(3)));setorigin(o,center)
 for o in healthy:
  if o.type=='MESH':decimate(o,.36 if o.name.startswith('Track_') else .53)
 bpy.context.view_layer.update()
 info={'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'contract':'tank-damage/v1','units':'metres','forward':'+Z','up':'+Y','paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS]}
 export('panther-tank.glb',healthy);info['healthy']=manifest(healthy)
 info['damage']=build_damage(hull,turret,gun,barrel)
 # Damage palette is constrained to the five contract materials.
 for o in damage:
  if o.type=='MESH':
   for sl in o.material_slots:
    if sl.material is None or sl.material.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:sl.material=MATS['TrackSteel']
 export('panther-tank-damage.glb',damage);info['damaged']=manifest(damage);(ROOT/'nodes.json').write_text(json.dumps(info,indent=2))
 (ROOT/'NOTES.md').write_text('Panther Ausf. G style, articulated neutral-faction tank and tank-damage/v1 swaps; metre scale, +Z forward.\nTriangles: healthy '+str(info['healthy']['triangles'])+'; damage '+str(info['damaged']['triangles'])+'.\nMaterials: Paint, TrackSteel, Steel, Rubber, Wood, Interior, Lamps; damage adds Burnt and Scars.\nTextures: Paint base/normal 1024; all others and all ORM 512; Cycles baked shared tiles.\nPoly Haven CC0 ids: green_metal_rust, rusty_painted_metal, rusty_metal_03, rubberized_track; https://polyhaven.com/license .\nDifferences: default dark yellow instead of reference field grey; no insignia; simplified small fittings and damage approximations.\n')
render_proofs()
print('BUILD_COMPLETE',flush=True)
