"""Reproducible Sherman-style game asset. Blender 5.2, Cycles OptiX.
Run: blender -b --python-exit-code 1 --python build_sherman-tank.py
All authored coordinates are metres, Z-up, forward -Y; glTF converts to Y-up/+Z.
Only CC0 Poly Haven photographic sources are downloaded. No insignia.
"""
import bpy, bmesh, math, os, json, urllib.request, random, struct, time
import numpy as np
from mathutils import Vector
from mathutils import noise
from pathlib import Path
print('BUILD REVISION 7: PRESERVATION RETROFIT tank-damage/v1',flush=True)
ROOT=Path(__file__).resolve().parent
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
random.seed(41)
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=20; S.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
S.cycles.device='GPU';S.render.threads_mode='FIXED';S.render.threads=8
S.render.image_settings.file_format='PNG'; S.view_settings.view_transform='AgX'; S.view_settings.exposure=-.35
PAINTS=[('Olive drab',[.12,.13,.07]),('NATO green',[.075,.115,.065]),('Desert tan',[.46,.35,.20]),('Field grey',[.19,.22,.19]),('Winter white',[.72,.75,.69]),('Dark green-black',[.027,.038,.025])]
IDS=['rusty_metal_03','denim_fabric','wood_table_worn','rubberized_track']
GROUPS={};MATS={};paint_mix=None
RETRO=None; RETRO_IDS={}; RETRO_NAMES={}

def active(o):
 
 for selected in bpy.context.selected_objects:selected.select_set(False)
 o.select_set(True);bpy.context.view_layer.objects.active=o

def apply(o):
 active(o)
 for mod in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=mod.name)

def reg(o,g,mat):
 o.data.materials.append(MATS[mat]);GROUPS.setdefault(g,[]).append(o)
 if RETRO:o['retro_node']=RETRO
 return o

def bevel(o,w=.015,n=2):
 mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=w;mod.segments=4 if w>.04 else n;mod.harden_normals=True
 mod=o.modifiers.new('Corner normals','WEIGHTED_NORMAL');mod.keep_sharp=True
 return o

def box(g,loc,dim,mat='Paint',b=.015,rot=None):
 dx,dy,dz=[v/2 for v in dim]
 vs=[(-dx,-dy,-dz),(dx,-dy,-dz),(dx,dy,-dz),(-dx,dy,-dz),(-dx,-dy,dz),(dx,-dy,dz),(dx,dy,dz),(-dx,dy,dz)]
 o=mesh(g,vs,[(3,2,1,0),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],mat,False);o.location=loc
 if rot:o.rotation_euler=rot
 if b:
  for face in o.data.polygons:face.use_smooth=True
  bevel(o,b,2)
 return o

def cyl(g,loc,r,depth,mat='Paint',axis='Z',verts=32,r2=None):
 upper=r if r2 is None else r2
 vs=[(rad*math.cos(i*2*math.pi/verts),rad*math.sin(i*2*math.pi/verts),z) for z,rad in [(-depth/2,r),(depth/2,upper)] for i in range(verts)]
 fs=[tuple(reversed(range(verts))),tuple(range(verts,2*verts))]+[(i,(i+1)%verts,(i+1)%verts+verts,i+verts) for i in range(verts)]
 o=mesh(g,vs,fs,mat,False);o.location=loc
 if axis=='X':o.rotation_euler[1]=math.pi/2
 if axis=='Y':o.rotation_euler[0]=math.pi/2
 bevel(o,min(.008,r*.12,depth*.15),2)
 for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
 return o

def ball(g,loc,scale,mat='Paint',seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,radius=1,location=loc)
 o=bpy.context.object;o.scale=scale;active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 for p in o.data.polygons:p.use_smooth=True
 return reg(o,g,mat)

def mesh(g,vs,fs,mat='Paint',smooth=True):
 me=bpy.data.meshes.new(g);me.from_pydata(vs,[],fs);me.update();o=bpy.data.objects.new(g,me);S.collection.objects.link(o)
 for p in me.polygons:p.use_smooth=smooth
 return reg(o,g,mat)

def tube(g,pts,r,mat='Paint',res=8):
 c=bpy.data.curves.new('formed tube','CURVE');c.dimensions='3D';c.resolution_u=1;c.bevel_depth=r;c.bevel_resolution=1;c.resolution_u=2
 sp=c.splines.new('POLY');sp.points.add(len(pts)-1)
 for p,co in zip(sp.points,pts):p.co=(*co,1)
 o=bpy.data.objects.new('formed tube',c);S.collection.objects.link(o);active(o);bpy.ops.object.convert(target='MESH');return reg(bpy.context.object,g,mat)

def rod(g,a,b,r,mat='Paint',verts=12):
 a,b=Vector(a),Vector(b);o=cyl(g,(a+b)/2,r,(b-a).length,mat,verts=verts);o.rotation_euler=(b-a).to_track_quat('Z','Y').to_euler();return o

def torus(g,loc,major,minor,mat='Paint',axis='Z',majorseg=40,minorseg=8):
 bpy.ops.mesh.primitive_torus_add(major_segments=majorseg,minor_segments=minorseg,location=loc,major_radius=major,minor_radius=minor)
 o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=math.pi/2
 if axis=='Y':o.rotation_euler[0]=math.pi/2
 for p in o.data.polygons:p.use_smooth=True
 return reg(o,g,mat)

def bolt(g,loc,r=.018,axis='Z',mat='Paint'):
 return cyl(g,loc,r,.014,mat,axis,6)

def handle(g,loc,width=.16,height=.055,axis='Z',mat='Paint'):
 x,y,z=loc
 if axis=='Z':pts=[(x-width/2,y,z),(x-width/2,y,z+height),(x+width/2,y,z+height),(x+width/2,y,z)]
 elif axis=='X':pts=[(x,y-width/2,z),(x+height,y-width/2,z),(x+height,y+width/2,z),(x,y+width/2,z)]
 else:pts=[(x-width/2,y,z),(x-width/2,y-height,z),(x+width/2,y-height,z),(x+width/2,y,z)]
 tube(g,pts,.012,mat)

def download(id,kind):
 p=CACHE/f'{id}_{kind}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{kind}_1k.jpg'
  print('DOWNLOAD',url,flush=True);urllib.request.urlretrieve(url,p)
 return bpy.data.images.load(str(p),check_existing=True)

def make_material(name,id,color,rough=.8,metal=0.,size=512):
 global paint_mix
 m=bpy.data.materials.new(name);m.use_nodes=True;MATS[name]=m
 n=m.node_tree.nodes;l=m.node_tree.links;n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=n.new('ShaderNodeTexCoord')
 diff=n.new('ShaderNodeTexImage');diff.image=download(id,'diff');l.new(uv.outputs['UV'],diff.inputs['Vector'])
 gray=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],gray.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.05;ramp.color_ramp.elements[1].position=.75
 low=.49 if name=='Paint' else .52
 ramp.color_ramp.elements[0].color=tuple(v*low for v in color)+(1,);ramp.color_ramp.elements[1].color=tuple(color)+(1,)
 l.new(gray.outputs[0],ramp.inputs[0]);l.new(ramp.outputs['Color'],bs.inputs['Base Color'])
 if name=='Burnt':
  soot=n.new('ShaderNodeTexNoise');soot.inputs['Scale'].default_value=1.5;soot.inputs['Detail'].default_value=2;soot.inputs['Roughness'].default_value=.6;l.new(uv.outputs['UV'],soot.inputs['Vector'])
  heat=n.new('ShaderNodeValToRGB');cr=heat.color_ramp
  cr.elements[0].position=.24;cr.elements[0].color=(.012,.014,.016,1)
  cr.elements[1].position=.77;cr.elements[1].color=(.11,.062,.032,1)
  cr.elements.new(.49).color=(.035,.040,.043,1);cr.elements.new(.64).color=(.070,.075,.071,1)
  l.new(soot.outputs['Fac'],heat.inputs[0])
  burned=n.new('ShaderNodeMixRGB');burned.blend_type='MULTIPLY';burned.inputs[0].default_value=.10;l.new(heat.outputs[0],burned.inputs[1]);l.new(gray.outputs[0],burned.inputs[2]);l.new(burned.outputs[0],bs.inputs['Base Color'])
 if name=='Paint':
  stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(15,.65,1);l.new(uv.outputs['UV'],stretch.inputs[0])
  rain=n.new('ShaderNodeTexNoise');rain.inputs['Scale'].default_value=3;rain.inputs['Detail'].default_value=2;l.new(stretch.outputs[0],rain.inputs['Vector'])
  dirt=n.new('ShaderNodeValToRGB');dirt.color_ramp.elements[0].position=.34;dirt.color_ramp.elements[0].color=(.88,.88,.88,1);dirt.color_ramp.elements[1].position=.49;l.new(rain.outputs['Fac'],dirt.inputs[0])
  weather=n.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=1;l.new(ramp.outputs[0],weather.inputs[1]);l.new(dirt.outputs[0],weather.inputs[2]);l.new(weather.outputs[0],bs.inputs['Base Color'])
 nor=n.new('ShaderNodeTexImage');nor.image=download(id,'nor_gl');nor.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],nor.inputs['Vector'])
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.32 if name=='Paint' else (.08 if name=='Burnt' else .65);l.new(nor.outputs['Color'],nm.inputs['Color'])
 tex=n.new('ShaderNodeTexNoise');tex.inputs['Scale'].default_value=145;tex.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],tex.inputs['Vector'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.025 if name=='Burnt' else .19;bump.inputs['Distance'].default_value=.0007;l.new(tex.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=download(id,'arm');arm.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],arm.inputs['Vector'])
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
 rm=n.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=max(.3,rough-.12);rm.inputs['To Max'].default_value=min(1,rough+.12);l.new(sep.outputs[1],rm.inputs[0]);l.new(rm.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 # Bake a reusable physical surface tile; UVs on asset repeat at metre scale.
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-10));p=bpy.context.object;p.data.materials.append(m);active(p)
 target=n.new('ShaderNodeTexImage');n.active=target
 baked={}
 for kind,res in [('base',size),('normal',size),('orm',min(size,512))]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
  if kind!='base':im.colorspace_settings.name='Non-Color'
  target.image=im;n.active=target
  S.render.bake.margin=4;S.cycles.samples=4
  if kind=='base':bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
  elif kind=='normal':bpy.ops.object.bake(type='NORMAL')
  else:
   em=n.new('ShaderNodeEmission');pack=n.new('ShaderNodeCombineColor');pack.mode='RGB';pack.inputs[0].default_value=1.;l.new(rm.outputs[0],pack.inputs[1]);pack.inputs[2].default_value=metal;l.new(pack.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');l.new(bs.outputs[0],out.inputs[0]);n.remove(em);n.remove(pack)
  im.filepath_raw=str(CACHE/(name+'_'+kind+'.jpg'));im.file_format='JPEG';im.save();baked[kind]=im
 bpy.data.objects.remove(p,do_unlink=True)
 n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0]);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 base=n.new('ShaderNodeTexImage');base.image=baked['base'];base.name='Baked Base';l.new(base.outputs[0],bs.inputs['Base Color'])
 norm=n.new('ShaderNodeTexImage');norm.image=baked['normal'];nm=n.new('ShaderNodeNormalMap');l.new(norm.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 orm=n.new('ShaderNodeTexImage');orm.image=baked['orm'];sep=n.new('ShaderNodeSeparateColor');l.new(orm.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],bs.inputs['Roughness']);l.new(sep.outputs[2],bs.inputs['Metallic'])
 if name=='Paint':
  paint_mix=n.new('ShaderNodeMixRGB');paint_mix.blend_type='MULTIPLY';paint_mix.inputs[0].default_value=1;paint_mix.inputs[2].default_value=(*PAINTS[0][1],1);l.new(base.outputs[0],paint_mix.inputs[1]);l.new(paint_mix.outputs[0],bs.inputs['Base Color'])
 if name=='Lamps':bs.inputs['Emission Color'].default_value=(.15,.11,.05,1);bs.inputs['Emission Strength'].default_value=.2
 print('BAKED',name,flush=True)

_bake_material=make_material
def make_material(name,id,color,rough=.8,metal=0.,size=512):
 global paint_mix
 files={k:CACHE/(name+'_'+k+'.jpg') for k in ['base','normal','orm']}
 if not all(p.exists() for p in files.values()):return _bake_material(name,id,color,rough,metal,size)
 m=bpy.data.materials.new(name);m.use_nodes=True;MATS[name]=m
 n=m.node_tree.nodes;l=m.node_tree.links;n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 ts={}
 for k,p in files.items():
  t=n.new('ShaderNodeTexImage');t.image=bpy.data.images.load(str(p),check_existing=True);ts[k]=t
  if k!='base':t.image.colorspace_settings.name='Non-Color'
 ts['base'].name='Baked Base';l.new(ts['base'].outputs[0],bs.inputs['Base Color'])
 nm=n.new('ShaderNodeNormalMap');l.new(ts['normal'].outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 sep=n.new('ShaderNodeSeparateColor');l.new(ts['orm'].outputs[0],sep.inputs[0]);l.new(sep.outputs[1],bs.inputs['Roughness']);l.new(sep.outputs[2],bs.inputs['Metallic'])
 if name=='Paint':
  paint_mix=n.new('ShaderNodeMixRGB');paint_mix.blend_type='MULTIPLY';paint_mix.inputs[0].default_value=1;paint_mix.inputs[2].default_value=(*PAINTS[0][1],1);l.new(ts['base'].outputs[0],paint_mix.inputs[1]);l.new(paint_mix.outputs[0],bs.inputs['Base Color'])
 if name=='Lamps':bs.inputs['Emission Color'].default_value=(.15,.11,.05,1);bs.inputs['Emission Strength'].default_value=.2
 print('REUSED ORIGINAL BAKE',name,flush=True)

# Eight portable material sets, every one actually baked with Cycles.
make_material('Paint','rusty_metal_03',(1,1,1),.83,.08,1024)
make_material('TrackSteel','rusty_metal_03',(.19,.16,.12),.76,.72,1024)
make_material('Rubber','rubberized_track',(.047,.044,.038),.9,0,512)
make_material('Canvas','denim_fabric',(.25,.18,.115),.95,0,1024)
make_material('Wood','wood_table_worn',(.25,.12,.045),.76,0,512)
make_material('WeaponSteel','rusty_metal_03',(.065,.07,.072),.52,.78,512)
make_material('Lamps','wood_table_worn',(.68,.59,.39),.24,.18,128)
make_material('Glass','rubberized_track',(.035,.065,.07),.22,.3,128)
print('MATERIALS COMPLETE',flush=True)
# Rounded cast hull: longitudinal sections with curved shoulders, not a bevelled box.
sections=[(-2.81,1.63,1.16,1.37),(-2.65,2.12,1.16,1.46),(-2.23,2.43,1.16,1.72),(-1.72,2.49,1.16,1.92),(-1.2,2.49,1.16,1.97),(0,2.48,1.16,1.94),(1.35,2.47,1.16,1.84),(2.48,2.39,1.16,1.74),(2.81,2.25,1.16,1.61)]
profile=[(0,-.5),(.73,-.5),(.94,-.46),(1,-.3),(1,-.03),(.96,.22),(.84,.40),(.60,.49),(0,.5),(-.60,.49),(-.84,.4),(-.96,.22),(-1,-.03),(-1,-.3),(-.94,-.46),(-.73,-.5)]
vs=[];fs=[]
for y,w,b,t in sections:
 for x,z in profile:vs.append((x*w/2,y,(t+b)/2+z*(t-b)))
N=len(profile)
for j in range(len(sections)-1):
 for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,(j+1)*N+(i+1)%N,(j+1)*N+i))
fs.extend([tuple(reversed(range(N))),tuple((len(sections)-1)*N+i for i in range(N))])
o=mesh('Hull',vs,fs);su=o.modifiers.new('Cast hull fairing','SUBSURF');su.levels=2
# Transmission-to-upper-casting flange follows the actual cast surface.
bpy.context.view_layer.update();cast=o.evaluated_get(bpy.context.evaluated_depsgraph_get());joint=[]
for x in np.linspace(-.90,.90,31):
 hit,point,normal,face=cast.ray_cast(Vector((x,-2.44,3)),Vector((0,0,-1)))
 if hit:joint.append(tuple(point+normal*.005))
tube('Hull',joint,.011,'Paint')
for x in np.linspace(-.84,.84,17):
 hit,point,normal,face=cast.ray_cast(Vector((x,-2.44,3)),Vector((0,0,-1)))
 if hit:
  fastener=bolt('Hull',point+normal*.012,.014);fastener.rotation_euler=normal.to_track_quat('Z','Y').to_euler()

# Lower hull, final drive rounded nose, rear belly.
box('Hull',(0,0,.88),(1.67,4.65,.70),b=.14)
ball('Hull',(0,-2.40,.88),(.96,.52,.46),seg=48,rings=24)
box('Hull',(0,2.47,.84),(1.72,.36,.64),b=.065)
# Two cast transmission joints running around the nose.
for x in [-.70,.70]:
 pts=[(x,-2.42-.495*math.cos(a),.89+.425*math.sin(a)) for a in np.linspace(-1.1,1.1,24)]
 tube('Hull',pts,.012,'TrackSteel')
 for a in np.linspace(-.8,.9,7):bolt('Hull',(x,-2.43-.50*math.cos(a),.89+.43*math.sin(a)),.016,'Y')
# Sheet-steel track fenders, with a rolled rim and bolts.
for side in [-1,1]:
 RETRO='Part_Fender_'+('L' if side>0 else 'R')
 box('Hull',(side*1.075,.02,1.275),(.47,5.53,.055),b=.022)
 tube('Hull',[(side*1.305,-2.73,1.27),(side*1.305,2.78,1.27)],.012)
 for y in np.arange(-2.5,2.65,.31):bolt('Hull',(side*1.18,y,1.31),.013)
 for y in [-2.57,2.57]:box('Hull',(side*1.08,y,1.17),(.46,.35,.045),rot=(.25 if y<0 else -.3,0,0),b=.013)
RETRO=None
# Cast turret ring and custom asymmetrical cast turret shell, with rear bustle.
cyl('Hull',(0,-.20,1.93),.88,.085,verts=64)
torus('Turret',(0,-.20,1.99),.855,.035,majorseg=64)
rings=[(1.965,.76,.86,0),(2.04,.88,.97,.02),(2.26,.93,1.0,.05),(2.43,.86,.92,.07),(2.50,.69,.75,.10)]
vs=[];fs=[];N=64
for z,rx,ry,cy in rings:
 for i in range(N):
  a=2*math.pi*i/N;x=rx*math.cos(a);y=-.20+cy+ry*math.sin(a)
  # Flatten the front cheek, retain the rounded rear bustle.
  if y<-.99:y=-.99+(y+.99)*.48
  vs.append((x,y,z))
for j in range(len(rings)-1):
 for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,(j+1)*N+(i+1)%N,(j+1)*N+i))
fs += [tuple(reversed(range(N))),tuple((len(rings)-1)*N+i for i in range(N))]
o=mesh('Turret',vs,fs);turret_shell=o;mod=o.modifiers.new('Cast turret curvature','SUBSURF');mod.levels=2
box('Turret',(0,.70,2.27),(1.33,.65,.33),b=.14)
# Cast side pistol ports, hinges and lifting lugs.
for side in [-1,1]:
 box('Turret',(side*.875,.45,2.25),(.05,.23,.235),b=.027)
 handle('Turret',(side*.910,.45,2.35),.105,.023,axis='X')
 cyl('Turret',(side*.905,.45,2.365),.019,.14,'TrackSteel','Y',12)
 torus('Turret',(side*.60,-.77,2.43),.044,.014,'Paint','Y',24)
# Mantlet cast shield, trunnion collars and actual open bore.
box('Gun',(0,-1.065,2.205),(.89,.31,.48),b=.105)
for x in [-.39,.39]:cyl('Gun',(x,-1.08,2.205),.195,.08,'Paint','X',40)
cyl('Gun',(0,-1.30,2.205),.145,.27,'Paint','Y',48)
cyl('Gun',(0,-1.47,2.205),.101,.20,'Paint','Y',48)
# barrel rings along Y; tip includes an inner bore and a deep dark cap
ys=[(-1.50,.092),(-1.60,.078),(-2.80,.056),(-2.92,.056),(-2.92,.0375),(-2.76,.0375)]
vs=[];fs=[];N=48
for y,r in ys:
 for i in range(N):
  a=2*math.pi*i/N;vs.append((r*math.cos(a),y,2.205+r*math.sin(a)))
for j in range(len(ys)-1):
 for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,(j+1)*N+(i+1)%N,(j+1)*N+i))
RETRO='Barrel'
mesh('Gun',vs,fs,'WeaponSteel');cyl('Gun',(0,-2.755,2.205),.0375,.008,'Rubber','Y',32)
RETRO=None
for x in [-.32,.32]:
 for z in [2.10,2.31]:bolt('Gun',(x,-1.229,z),.014,'Y')
cyl('Gun',(.29,-1.24,2.25),.032,.03,'WeaponSteel','Y',20)
# Driver hatches raised into the cast deck, vision blocks, handles and hinges.
for x in [-.55,.55]:
 RETRO=None
 ball('Hull',(x,-1.74,1.74),(.33,.43,.19),seg=40,rings=20)
 RETRO='Hatch_Driver' if x<0 else 'Hatch_AssistantDriver'
 cyl('Hull',(x,-1.58,1.925),.245,.04,verts=40)
 torus('Hull',(x,-1.58,1.945),.226,.009,'TrackSteel')
 handle('Hull',(x,-1.63,1.97),.13,.035)
 box('Hull',(x,-1.74,1.98),(.17,.105,.085),b=.018)
 box('Hull',(x,-1.798,1.986),(.125,.013,.042),'Glass',.004)
 for xx in [-.15,.15]:cyl('Hull',(x+xx,-1.38,1.949),.025,.10,'Paint','X',12)
RETRO=None
# Bow machine gun cast ball and short jacket.
ball('Hull',(.48,-2.46,1.61),(.18,.115,.15),seg=40,rings=20)
cyl('Hull',(.48,-2.59,1.61),.055,.17,'WeaponSteel','Y',20)
cyl('Hull',(.48,-2.69,1.61),.025,.11,'WeaponSteel','Y',16)
# Front lamps with guard cages, towing eyes.
for side in [-1,1]:
 x=side*.95
 before_lamps=len(GROUPS['Hull'])
 rod('Hull',(x,-2.21,1.47),(x,-2.22,1.64),.03)
 cyl('Hull',(x,-2.24,1.64),.077,.092,'Paint','Y',32)
 cyl('Hull',(x,-2.292,1.64),.059,.009,'Lamps','Y',32)
 RETRO='Part_HeadlightGuard_'+('L' if side>0 else 'R')
 pts=[(x+.105*math.cos(a),-2.30,1.64+.13*math.sin(a)) for a in np.linspace(0,math.pi,18)]
 tube('Hull',[(x+.105,-2.29,1.53)]+pts+[(x-.105,-2.29,1.53)],.012)
 tube('Hull',[(x,-2.19,1.54),(x,-2.18,1.78),(x,-2.30,1.78),(x,-2.31,1.60)],.011)
 RETRO=None
 for ob in GROUPS['Hull'][before_lamps:]:ob.location.y-=.14;ob.location.z+=.07
 torus('Hull',(side*.67,-2.88,.70),.07,.021,'TrackSteel','Y',24)
 box('Hull',(side*.67,-2.82,.70),(.08,.10,.14),b=.02)
# Cupola, hinged commander's hatch, sight and periscopes.
RETRO='Part_Turret_CupolaRing'
cupola=cyl('Turret',(.27,.14,2.52),.34,.09,verts=48)
RETRO=None
# A real dark-lined hatch well, visible when the separate hatch opens.
bpy.ops.mesh.primitive_cylinder_add(vertices=64,radius=.270,depth=.70,location=(.27,.14,2.50))
hatch_cut=bpy.context.object;hatch_cut.name='Temporary hatch cutter'
hatch_cut.data.materials.append(MATS['Paint']);hatch_cut.data.materials.append(MATS['WeaponSteel'])
for face in hatch_cut.data.polygons:face.material_index=1
for target in [turret_shell,cupola]:
 target.data.materials.append(MATS['WeaponSteel'])
 mod=target.modifiers.new('Commander hatch well','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=hatch_cut
 apply(target)
bpy.data.objects.remove(hatch_cut,do_unlink=True)

torus('Turret',(.27,.14,2.56),.316,.019)
for a in np.linspace(0,2*math.pi,6,endpoint=False):
 x=.27+.30*math.cos(a);y=.14+.30*math.sin(a)
 box('Turret',(x,y,2.565),(.08,.07,.035),'Glass',.009,rot=(0,0,a))
cyl('Hatch_Commander',(.27,.14,2.594),.305,.045,verts=48)
handle('Hatch_Commander',(.27,.14,2.622),.14,.045)
for x in [.13,.41]:cyl('Hatch_Commander',(x,.415,2.599),.025,.09,'Paint','X',12)
RETRO='Part_Turret_Sight'
box('Turret',(-.33,-.44,2.51),(.20,.13,.075),b=.025)
box('Turret',(-.33,-.51,2.52),(.13,.017,.033),'Glass',.004)
RETRO=None
for x,y,z in [(-.70,-.6,2.38),(.70,-.6,2.38),(0,.88,2.40)]:
 torus('Turret',(x,y,z),.047,.013,'Paint','Y',24)
# Pistol port with hinge, welded roof fittings and aerial.
box('Turret',(-.895,.17,2.22),(.06,.31,.28),b=.03)
handle('Turret',(-.94,.17,2.29),.13,.04,'X')
RETRO='Part_Turret_Antenna'
cyl('Turret',(-.62,.62,2.47),.045,.12,verts=20)
rod('Turret',(-.62,.62,2.49),(-.62,.62,2.74),.005,'WeaponSteel',8)
RETRO='Part_Turret_MGMount'
# Browning-style roof MG: low travelling mount, separate pivoted assembly.
cyl('Turret',(-.43,.23,2.52),.036,.17,'WeaponSteel',verts=16)
RETRO=None
box('MG',(-.43,.03,2.655),(.095,.32,.09),'WeaponSteel',.012)
cyl('MG',(-.43,-.32,2.655),.028,.41,'WeaponSteel','Y',20)
cyl('MG',(-.43,-.62,2.655),.014,.19,'WeaponSteel','Y',16)
box('MG',(-.525,.065,2.64),(.10,.16,.15),'WeaponSteel',.014)
rod('MG',(-.475,.21,2.655),(-.475,.26,2.60),.011,'Wood',10)
rod('MG',(-.385,.21,2.655),(-.385,.26,2.60),.011,'Wood',10)
for y in [-.16,-.22,-.28,-.34,-.40]:
 for x in [-.459,-.401]:ball('MG',(x,y,2.657),(.003,.012,.011),'Rubber',12,8)
box('MG',(-.43,-.02,2.714),(.012,.03,.052),'WeaponSteel',.004)
RETRO='Part_EngineGrille'
# Rear engine deck: recessed cooling grilles and frames.
box('Hull',(0,1.72,1.793),(1.39,1.16,.055),'TrackSteel',.015)
for x in [-.72,.72]:box('Hull',(x,1.72,1.827),(.045,1.22,.037),b=.008)
for y in [1.12,2.32]:box('Hull',(0,y,1.827),(1.47,.045,.037),b=.008)
for y in np.arange(1.17,2.28,.055):box('Hull',(0,y,1.823),(1.38,.021,.032),b=.005)
for x in np.arange(-.65,.7,.115):box('Hull',(x,1.72,1.843),(.012,1.12,.016),'TrackSteel',.003)
RETRO=None
for x in [-.96,.96]:
 cyl('Hull',(x,2.26,1.756),.11,.045,verts=32);handle('Hull',(x,2.26,1.78),.08,.025)
RETRO='Part_RearStowageBox'
box('Hull',(0,2.79,1.30),(1.05,.20,.32),b=.025)
for x in [-.48,.48]:box('Hull',(x,2.91,1.3),(.035,.025,.36),'TrackSteel',.005)
RETRO=None
for side in [-1,1]:
 RETRO='Part_ExhaustDeflector_'+('L' if side>0 else 'R')
 box('Hull',(side*.89,2.79,1.34),(.30,.20,.27),b=.025)
 for z in [1.28,1.35,1.42]:box('Hull',(side*.89,2.903,z),(.22,.012,.025),'Rubber',.004)
 RETRO=None
 torus('Hull',(side*.65,2.89,.79),.063,.018,'TrackSteel','Y',24)
 cyl('Hull',(side*.52,2.68,.79),.09,.23,'WeaponSteel','Z',24)
# Tow rope following cast shoulders with retainers.
for side in [-1,1]:
 pts=[(side*.77,-2.28,1.61),(side*.90,-1.88,1.84),(side*1.02,-1.13,1.93),(side*1.035,.45,1.88),(side*1.02,1.7,1.78),(side*.93,2.47,1.67)]
 RETRO='Part_TowCable_'+('L' if side>0 else 'R')
 tube('Hull',pts,.017,'TrackSteel')
 RETRO=None
 for y,z in [(-1.1,1.935),(.5,1.88),(1.68,1.79)]:box('Hull',(side*1.033,y,z),(.07,.055,.04),b=.009)
 RETRO='Part_TowCable_'+('L' if side>0 else 'R')
 for p in [pts[0],pts[-1]]:torus('Hull',p,.044,.012,'TrackSteel','Z',20)
RETRO=None
# Onboard pioneer tools with wood handles, metal heads and clamps.
for side in [-1,1]:
 x=side*1.245
 RETRO='Part_Shovel_'+('L' if side>0 else 'R')
 # shovel horizontal along side, spade blade at rear
 rod('Hull',(x,-.20,1.52),(x,1.27,1.52),.022,'Wood',12)
 profile=[(1.14,1.57),(1.23,1.65),(1.39,1.65),(1.52,1.56),(1.53,1.50),(1.39,1.39),(1.23,1.39),(1.14,1.47)]
 vs=[(x+dx,y,z) for dx in [-.015,.015] for y,z in profile];n=len(profile)
 shovel=mesh('Hull',vs,[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],'WeaponSteel');bevel(shovel,.011,3)
 torus('Hull',(x,-.31,1.52),.066,.012,'WeaponSteel','X',24)
 RETRO='Part_Axe_'+('L' if side>0 else 'R')
 # axe above shovel
 rod('Hull',(x,-.22,1.72),(x,.75,1.72),.023,'Wood',12)
 profile=[(.74,1.75),(.83,1.78),(.87,1.89),(.94,1.89),(.98,1.59),(.90,1.57),(.82,1.68),(.74,1.69)]
 vs=[(x+dx,y,z) for dx in [-.025,.025] for y,z in profile];n=len(profile)
 axe=mesh('Hull',vs,[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],'WeaponSteel');bevel(axe,.009,3)
 RETRO='Part_Crowbar_'+('L' if side>0 else 'R')
 # crowbar and straps
 rod('Hull',(x,-.02,1.61),(x,.67,1.61),.012,'WeaponSteel',10)
 RETRO=None
 for y in [.03,.61]:
  box('Hull',(x+side*.025,y,1.52),(.025,.035,.07),'TrackSteel',.005)
  box('Hull',(x+side*.025,y,1.72),(.025,.035,.07),'TrackSteel',.005)
 RETRO='Part_Skirt_'+('L1' if side>0 else 'R1')
 # applique side plate with slightly radiused corners
 box('Hull',(side*1.238,-.91,1.57),(.035,.62,.36),b=.027)
RETRO=None
# Sandbags: irregular stitched sacks, pinched perimeter, tension wrinkles in geometry.
def bag(g,loc,scale,angle=0):
 o=ball(g,loc,scale,'Canvas',40,20);o.rotation_euler[2]=angle
 for v in o.data.vertices:
  q=v.co;nx=q.x/scale[0];ny=q.y/scale[1];nz=q.z/scale[2]
  q.x=scale[0]*math.copysign(abs(nx)**.70,nx);q.y=scale[1]*math.copysign(abs(ny)**.70,ny);q.z=scale[2]*math.copysign(abs(nz)**.85,nz)
  edge=max(abs(nx),abs(ny));q.z += .009*math.sin(nx*21+ny*9)*math.sin(ny*17)*max(0,edge-.2)
  q.x += .008*math.sin(ny*17+nz*7);q.y+=.008*math.sin(nx*15+nz*8)
 # Filled canvas gathers at its stitched ends and under the retaining tie.
  if scale[2]>.18:
   q.x+=math.copysign(1,nx)*(.011*math.sin(nz*28+ny*5)*abs(ny)**2-.013*math.exp(-(ny/.12)**2)*abs(nx))
  else:q.z+=.013*math.sin(ny*32+nx*6)*abs(nx)**2
 # cloth seam along the sack's equator
 pts=[]
 for a in np.linspace(0,2*math.pi,45):
  xx=scale[0]*math.copysign(abs(math.cos(a))**.70,math.cos(a));yy=scale[1]*math.copysign(abs(math.sin(a))**.70,math.sin(a))
  pts.append((loc[0]+xx*math.cos(angle)-yy*math.sin(angle),loc[1]+xx*math.sin(angle)+yy*math.cos(angle),loc[2]+.002))
 tube(g,pts,.005,'Canvas')
 return o
for side in [-1,1]:
 RETRO='Part_Sandbags_Rear_'+('L' if side>0 else 'R')
 for j,(yy,zz) in enumerate([(1.99,1.82),(2.40,1.76),(2.19,1.965)]):bag('Hull',(side*(.64+random.uniform(-.035,.035)),yy,zz),(.34,.245,.115),side*(.13-j*.2))
 RETRO='Part_Turret_Sandbags_'+('L' if side>0 else 'R')
 # turret side sandbags, vertical stack tied to the side
 for y in [-.35,.08]:bag('Turret',(side*.91,y,2.28),(.15,.245,.22),.10)
 for y in [-.35,.08]:
  tube('Turret',[(side*.91,y,2.485),(side*1.025,y,2.447),(side*1.065,y,2.32),(side*1.052,y,2.17),(side*.94,y,2.07),(side*.86,y,2.12)],.006,'Canvas')
RETRO=None
# Track belts: continuously indexed UV V coordinates follow belt travel.
# Path runs bottom rearward, around idler, top forward and around sprocket.
path=[]
path.extend([(-1.98,.049),(1.98,.049),(2.58,.35)])
for a in np.linspace(-.70,math.pi/2,20):path.append((2.31+.50*math.cos(a),.70+.50*math.sin(a)))
path.append((-2.31,1.20))
for a in np.linspace(math.pi/2,3.85,22):path.append((-2.31+.50*math.cos(a),.70+.50*math.sin(a)))
path.append((-1.98,.049))
lengths=[0]
for a,b in zip(path,path[1:]):lengths.append(lengths[-1]+math.dist(a,b))
length=lengths[-1];count=90;pitch=length/count

def sample(t):
 t=t%length
 for j in range(len(lengths)-1):
  if lengths[j]<=t<=lengths[j+1]:
   d=lengths[j+1]-lengths[j];f=(t-lengths[j])/d;a,b=path[j],path[j+1]
   return (a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f),math.atan2(b[1]-a[1],b[0]-a[0])
for side,label in [(1,'L'),(-1,'R')]:
 g='Track_'+label;x=side*1.08
 for i in range(count):
  (y,z),a=sample(i*pitch);before=len(GROUPS.get(g,[]))
  box(g,(x,y,z),(.46,pitch*.94,.072),'TrackSteel',.007,rot=(a,0,0))
  # Flat rubber shoe, steel double chevron catches grazing light.
  def world(xx,yy,zz):return (x+xx,y+yy*math.cos(a)-zz*math.sin(a),z+yy*math.sin(a)+zz*math.cos(a))
  box(g,world(0,0,-.041),(.355,pitch*.68,.022),'Rubber',.006,rot=(a,0,0))
  for ss in [-1,1]:
   rod(g,world(ss*.178,-pitch*.20,-.056),world(0,pitch*.17,-.056),.014,'TrackSteel',6)
  for xx in [-.214,.214]:cyl(g,world(xx,pitch*.46,0),.027,.048,'TrackSteel','X',10)
  # inward guide horn
  o=box(g,world(0,0,.062),(.055,.045,.055),'TrackSteel',.010,rot=(a,0,0))
  for ob in GROUPS[g][before:]:
   ob['track_v']=i*pitch/.40;ob['track_center']=(x,y,z);ob['track_tangent']=(0,math.cos(a),math.sin(a))
 # Individually articulated road wheels; X axis, front to back including drive and idler.
 wheel_positions=[(-2.36,.75,.39,'drive')]+[(y,.35,.27,'road') for y in [-1.75,-1.05,-.35,.35,1.05,1.75]]+[(2.36,.75,.405,'idler')]
 for idx,(y,z,r,kind) in enumerate(wheel_positions,1):
  w=f'Wheel_{label}{idx}';outer=side*1.265
  cyl(w,(x,y,z),r,.25,'Rubber' if kind=='road' else 'TrackSteel','X',48)
  cyl(w,(side*1.215,y,z),r*(.79 if kind=='road' else .82),.11,'TrackSteel','X',48)
  torus(w,(outer,y,z),r*.78,.018,'TrackSteel','X',40)
  cyl(w,(side*1.28,y,z),r*.28,.045,'Paint','X',32)
  cyl(w,(side*1.307,y,z),r*.12,.012,'TrackSteel','X',20)
  for a in np.linspace(0,2*math.pi,6 if kind=='road' else 8,endpoint=False):
   # radial stamped stiffeners, not solid disks
   p=(side*1.275,y+math.cos(a)*r*.32,z+math.sin(a)*r*.32)
   q=(side*1.275,y+math.cos(a)*r*.70,z+math.sin(a)*r*.70)
   rod(w,p,q,.018,'TrackSteel',8)
   bolt(w,(side*1.309,y+math.cos(a)*r*.19,z+math.sin(a)*r*.19),.011,'X','TrackSteel')
  if kind=='drive':
   for a in np.linspace(0,2*math.pi,18,endpoint=False):
    for xx in [side*.945,side*1.22]:
     box(w,(xx,y+math.cos(a)*(r+.004),z+math.sin(a)*(r+.004)),(.05,.093,.065),'TrackSteel',.01,rot=(a,0,0))
  GROUPS[w][0]['hub']=(x,y,z)
 # VVSS three bogies per side: triangular articulated arms and volute spring housing.
 for yc in [-1.40,0,1.40]:
  for dy in [-.35,.35]:
   rod('Hull',(side*1.30,yc+dy,.35),(side*1.30,yc,.46),.048,'Paint',12)
   rod('Hull',(side*1.21,yc+dy,.39),(side*1.21,yc+dy*.4,.76),.038,'Paint',12)
  # tapered cast housing with two proud vertical spring ribs
  vs=[(side*xx,yc+dy,z) for z,xx,dy in [( .43,1.15,-.16),(.43,1.15,.16),(.43,1.30,.16),(.43,1.30,-.16),(1.03,1.10,-.24),(1.03,1.10,.24),(1.03,1.29,.24),(1.03,1.29,-.24)]]
  o=mesh('Hull',vs,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],smooth=False);bevel(o,.028,3)
  for dy in [-.115,0,.115]:rod('Hull',(side*1.31,yc+dy,.47),(side*1.31,yc+dy,1.00),.023,'TrackSteel',10)
  for dy in [-.12,.12]:bolt('Hull',(side*1.325,yc+dy,.49),.021,'X')
  rod('Hull',(side*1.10,yc,1.00),(side*1.10,yc+.44,1.061),.045)
  RETRO=f'Wheel_{label}{9+[-1.40,0,1.40].index(yc)}'
  cyl('Hull',(side*1.08,yc+.44,1.061),.103,.21,'Rubber','X',28)
  cyl('Hull',(side*1.21,yc+.44,1.061),.073,.03,'Paint','X',24)
  RETRO=None
  tube('Hull',[(side*1.09,yc-.33,1.18),(side*1.09,yc-.30,1.14),(side*1.09,yc+.30,1.14)],.016)
print('GEOMETRY COMPLETE',len(bpy.data.objects),flush=True)
# Resolve geometry, metric tiling UVs; each node combines rigid attached parts.
# Project by dominant polygon normal. On tracks local V is an unwrapped arclength.
bpy.context.view_layer.update()
deps=bpy.context.evaluated_depsgraph_get()
resolved={o:bpy.data.meshes.new_from_object(o.evaluated_get(deps),preserve_all_data_layers=True,depsgraph=deps) for objects in GROUPS.values() for o in objects}
for o,me in resolved.items():
 o.modifiers.clear();o.data=me
 tag=o.get('retro_node')
 if tag:
  if tag not in RETRO_IDS:
   idx=len(RETRO_IDS)+1;RETRO_IDS[tag]=idx;RETRO_NAMES[idx]=tag
  attr=me.attributes.new(name='Retrofit',type='INT',domain='FACE')
  attr.data.foreach_set('value',np.full(len(me.polygons),RETRO_IDS[tag],dtype=np.int32))
for g,objects in GROUPS.items():
 for o in objects:
  o.data.transform(o.matrix_basis.to_3x3().to_4x4());o.rotation_euler=(0,0,0);o.scale=(1,1,1)
  if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
  uv=o.data.uv_layers.active.data
  for p in o.data.polygons:
   axis=max(range(3),key=lambda k:abs(p.normal[k]))
   for li in p.loop_indices:
    co=o.data.vertices[o.data.loops[li].vertex_index].co
    if g.startswith('Track_'):
     # Pin/chevron each shares the link's along-belt V coordinate.
     along=(co+o.location-Vector(o['track_center'])).dot(Vector(o['track_tangent']))
     uv[li].uv=(co.x/.46+.5,(float(o.get('track_v',0))*.40+along)/length)
    elif axis==0:uv[li].uv=(co.y*2,co.z*2)
    elif axis==1:uv[li].uv=(co.x*2,co.z*2)
    else:uv[li].uv=(co.x*2,co.y*2)
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.select_set(True)
 bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();o=bpy.context.object;o.name=g
 # Recalculate outward normals (also fixes mirrored bogie housings).
 active(o);bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.mesh.normals_make_consistent(inside=False);bpy.ops.object.mode_set(mode='OBJECT')
 GROUPS[g]=o
# Place articulation origins in world space; all scales and rotations applied.
def origin(o,pos):
 S.cursor.location=pos;active(o);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
def parent(o,p):
 bpy.context.view_layer.update();world=o.matrix_world.copy();o.parent=p;o.matrix_world=world;bpy.context.view_layer.update()
for name,o in GROUPS.items():
 if name.startswith('Wheel_'):pos=o.get('hub')
 elif name=='Turret':pos=(0,-.20,1.965)
 elif name=='Gun':pos=(0,-1.065,2.205)
 elif name=='Hatch_Commander':pos=(.27,.415,2.599)
 elif name=='MG':pos=(-.43,.23,2.52)
 else:pos=(0,0,0)
 origin(o,pos)
for name in ['Gun','MG','Hatch_Commander']:parent(GROUPS[name],GROUPS['Turret'])

def empty(name,loc,par=None):
 o=bpy.data.objects.new(name,None);S.collection.objects.link(o);o.location=loc
 if par:parent(o,GROUPS[par])
 return o
empty('Muzzle',(0,-2.92,2.205),'Gun')
empty('Seat_Commander',(.27,.14,2.64),'Turret')
empty('Seat_Driver',(-.55,-1.58,1.97),'Hull')
empty('Exhaust',(0,2.72,.83),'Hull')
empty('Light_Head_L',(.95,-2.435,1.71),'Hull');empty('Light_Head_R',(-.95,-2.435,1.71),'Hull')
asset=list(GROUPS.values())+[o for o in S.objects if o.type=='EMPTY']
# Count true triangle budget; reduce only if the detailed build exceeds the cap.
for o in GROUPS.values():o.data.calc_loop_triangles()
tri=sum(len(o.data.loop_triangles) for o in GROUPS.values())
if tri>157000:
 ratio=153000/tri
 for o in GROUPS.values():
  mod=o.modifiers.new('Game triangle budget','DECIMATE');mod.ratio=ratio;apply(o)
tri=sum(len(o.data.polygons) for o in GROUPS.values())
for o in GROUPS.values():o.data.calc_loop_triangles()
tri=sum(len(o.data.loop_triangles) for o in GROUPS.values())
# Remove collapsed bevel slivers before exporting actual triangles.
for o in GROUPS.values():
 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_ONLY')
 bm.to_mesh(o.data);bm.free();o.data.update();o.data.calc_loop_triangles()
tri=sum(len(o.data.loop_triangles) for o in GROUPS.values())
# Enforce the exact delivery envelope and ground contact without residual scales.
bpy.context.view_layer.update()
raw_points=[o.matrix_world@v.co for o in GROUPS.values() for v in o.data.vertices]
raw_lo=Vector(tuple(min(p[i] for p in raw_points) for i in range(3)))
raw_hi=Vector(tuple(max(p[i] for p in raw_points) for i in range(3)))
scale=Vector((2.62/(raw_hi.x-raw_lo.x),5.84/(raw_hi.y-raw_lo.y),2.74/(raw_hi.z-raw_lo.z)))
centre=Vector(((raw_hi.x+raw_lo.x)/2,(raw_hi.y+raw_lo.y)/2,raw_lo.z))
def fit(p):
 # Cast M4A1 deck is lower than its tall turret; preserve the overall envelope.
 q=p.copy();z=q.z
 if 1.2<z<=1.94:q.z=1.2+(z-1.2)*(.57/.74)
 elif 1.94<z<2.5:q.z=1.77+(z-1.94)*(.73/.56)
 return Vector(tuple((q[i]-centre[i])*scale[i] for i in range(3)))
old_world={o:o.matrix_world.copy() for o in asset};old_parents={o:o.parent for o in asset}
for o in asset:
 o.parent=None
 pos=fit(old_world[o].translation)
 if o.type=='MESH':
  for v in o.data.vertices:v.co=fit(old_world[o]@v.co)-pos
 o.location=pos;o.rotation_euler=(0,0,0);o.scale=(1,1,1)
bpy.context.view_layer.update()
for o in asset:
 if old_parents[o]:parent(o,old_parents[o])
# Fixed rigid assemblies use the hull-footprint ground origin.
for name in ['Hull','Track_L','Track_R']:origin(GROUPS[name],(0,0,0))
print('TRIANGLES',tri,flush=True)
# Cycles contact occlusion baked to shared neutral vertex detail, portable as COLOR_0.
# This complements the small repeating texture sets without a large unique atlas.
S.cycles.samples=12
for name,o in GROUPS.items():
 active(o)
 col=o.data.color_attributes.new(name='ContactDetail',type='FLOAT_COLOR',domain='CORNER')
 o.data.color_attributes.active_color=col
 values=np.ones((len(col.data),4),dtype=np.float32)
 col.data.foreach_set('color',values.ravel())
 if name in ['Hull','Turret','Gun']:
  bpy.ops.object.bake(type='AO',target='VERTEX_COLORS')
  col.data.foreach_get('color',values.ravel())
  values[:,:3]=.55+.45*np.clip(values[:,:3],0,1)
  values[:,3]=1;col.data.foreach_set('color',values.ravel())
print('CONTACT BAKE COMPLETE',flush=True)
# ---- Destructible retrofit: preserve legacy decimation, then split tagged faces. ----
import sys
from mathutils import Matrix
LEGACY=GROUPS.copy()
HEALTH=GROUPS.copy()
def subset(source,name,predicate):
 me=source.data.copy();bm=bmesh.new();bm.from_mesh(me)
 layer=bm.faces.layers.int.get('Retrofit')
 bad=[f for f in bm.faces if not predicate(f[layer] if layer else 0,f)]
 bmesh.ops.delete(bm,geom=bad,context='FACES')
 orphan=[v for v in bm.verts if not v.link_faces]
 if orphan:bmesh.ops.delete(bm,geom=orphan,context='VERTS')
 bm.to_mesh(me);bm.free();me.update()
 o=bpy.data.objects.new(name,me);S.collection.objects.link(o);o.matrix_world=source.matrix_world.copy()
 return o
for name,src in list(LEGACY.items()):
 attr=src.data.attributes.get('Retrofit')
 if not attr:continue
 ids=sorted(set(a.value for a in attr.data)-{0})
 for id in ids:
  tag=RETRO_NAMES[id]
  child=subset(src,tag,lambda v,f:v==id)
  HEALTH[tag]=child
 if ids:
  me=src.data;bm=bmesh.new();bm.from_mesh(me);layer=bm.faces.layers.int.get('Retrofit')
  bmesh.ops.delete(bm,geom=[f for f in bm.faces if f[layer]],context='FACES')
  loose=[v for v in bm.verts if not v.link_faces]
  if loose:bmesh.ops.delete(bm,geom=loose,context='VERTS')
  bm.to_mesh(me);bm.free();me.update()
# Split the two original continuous fenders exactly at mid-length into four plates.
for side in ['L','R']:
 src=HEALTH.pop('Part_Fender_'+side)
 for front in [True,False]:
  child=subset(src,'Part_Fender_'+('F' if front else 'R')+side,lambda v,f:True)
  bm=bmesh.new();bm.from_mesh(child.data)
  plane=child.matrix_world.inverted()@Vector((0,0,0))
  bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),dist=1e-6,plane_co=plane,plane_no=Vector((0,1,0)),clear_outer=front,clear_inner=not front)
  edges=[e for e in bm.edges if e.is_boundary and all(abs((child.matrix_world@v.co).y)<1e-4 for v in e.verts)]
  if edges:bmesh.ops.holes_fill(bm,edges=edges,sides=0)
  bm.to_mesh(child.data);bm.free();child.data.update();HEALTH[child.name]=child
 bpy.data.objects.remove(src,do_unlink=True)
# Origins and parents of every newly movable or detachable piece.
for name,o in HEALTH.items():
 if name in LEGACY:continue
 if name=='Barrel':pos=fit(Vector((0,-1.50,2.205)))
 elif name.startswith('Hatch_'):pos=fit(Vector((-.55 if name=='Hatch_Driver' else .55,-1.38,1.949)))
 elif name.startswith('Wheel_'):
  side=1 if '_L' in name else -1;idx=int(name.split('_')[1][1:])-9
  pos=fit(Vector((side*1.08,[-1.4,0,1.4][idx]+.44,1.061)))
 else:
  pts=[o.matrix_world@v.co for v in o.data.vertices];pos=sum(pts,Vector())/len(pts)
 origin(o,pos)
 if name=='Barrel':par=HEALTH['Gun']
 elif name.startswith('Part_Turret_'):par=HEALTH['Turret']
 else:par=HEALTH['Hull']
 parent(o,par)
parent(bpy.data.objects['Muzzle'],HEALTH['Barrel'])
# New construction only where opening an existing hatch needs real depth.
GROUPS={};RETRO=None

def finish(objects,name,pos=(0,0,0)):
 for ob in objects:
  apply(ob)
  if not ob.data.uv_layers:
   uv=ob.data.uv_layers.new(name='UVMap').data
   for poly in ob.data.polygons:
    ax=max(range(3),key=lambda i:abs(poly.normal[i]));axes=[i for i in range(3) if i!=ax]
    for li in poly.loop_indices:
     v=ob.matrix_world@ob.data.vertices[ob.data.loops[li].vertex_index].co
     uv[li].uv=(v[axes[0]]*2,v[axes[1]]*2)
  if not ob.data.color_attributes:
   a=ob.data.color_attributes.new(name='ContactDetail',type='FLOAT_COLOR',domain='CORNER');ob.data.color_attributes.active_color=a
   a.data.foreach_set('color',np.ones(len(a.data)*4,dtype=np.float32))
 bpy.ops.object.select_all(action='DESELECT')
 for ob in objects:ob.select_set(True)
 bpy.context.view_layer.objects.active=objects[0]
 if len(objects)>1:bpy.ops.object.join()
 ob=bpy.context.object;ob.name=name;origin(ob,pos)
 # bake static basis into mesh without changing the chosen articulation origin
 mat=ob.matrix_world.copy();loc=mat.translation.copy();ob.parent=None;ob.data.transform(Matrix.Translation(-loc)@mat);ob.matrix_world=Matrix.Translation(loc)
 return ob

def drill(target,centre,radius,depth):
 # Cut each intersecting closed casting separately: a joined hull contains
 # overlapping solids, which must not be globally re-tessellated by Boolean.
 bpy.ops.mesh.primitive_cylinder_add(vertices=48,radius=radius,depth=depth,location=centre)
 cut=bpy.context.object
 targets=[target]
 if target.name=='Hull':
  bm=bmesh.new();bm.from_mesh(target.data);bm.verts.ensure_lookup_table();bm.faces.ensure_lookup_table()
  seen=set();groups=[];cx,cy,cz=centre
  for seed in bm.verts:
   if seed in seen:continue
   stack=[seed];seen.add(seed);verts=[]
   while stack:
    v=stack.pop();verts.append(v)
    for edge in v.link_edges:
     other=edge.other_vert(v)
     if other not in seen:seen.add(other);stack.append(other)
   pts=[target.matrix_world@v.co for v in verts]
   lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
   dx=max(lo[0]-cx,0,cx-hi[0]);dy=max(lo[1]-cy,0,cy-hi[1])
   if dx*dx+dy*dy<radius*radius and hi[2]>cz-depth/2 and lo[2]<cz+depth/2:
    groups.append({f.index for v in verts for f in v.link_faces})
  bm.free();targets=[];selected=set()
  for k,faces in enumerate(groups):
   targets.append(subset(target,'Crew casting cut '+str(k),lambda tag,f,ids=faces:f.index in ids));selected.update(faces)
  bm=bmesh.new();bm.from_mesh(target.data)
  bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.index in selected],context='FACES')
  loose=[v for v in bm.verts if not v.link_faces]
  if loose:bmesh.ops.delete(bm,geom=loose,context='VERTS')
  bm.to_mesh(target.data);bm.free()
 for piece in targets:
  mod=piece.modifiers.new('Crew hatch opening','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.use_self=False;mod.use_hole_tolerant=False;mod.object=cut;apply(piece)
 bpy.data.objects.remove(cut,do_unlink=True)
 if targets!=[target]:
  bpy.ops.object.select_all(action='DESELECT');target.select_set(True)
  for piece in targets:piece.select_set(True)
  bpy.context.view_layer.objects.active=target;bpy.ops.object.join()

for x in [-.55,.55]:
 c=fit(Vector((x,-1.58,1.925)))
 drill(HEALTH['Hull'],(c.x,c.y,c.z-.12),.177,.40)
 # closed bottom and cylindrical walls below the movable hatch
 cyl('crew',(c.x,c.y,c.z-.285),.174,.015,'WeaponSteel',verts=48)
 torus('crew',(c.x,c.y,c.z-.21),.163,.017,'WeaponSteel',majorseg=40)
 # hinge supports stay fixed when the hatch is detached or open
 for dx in [-.145,.145]:cyl('crew',(c.x+dx,fit(Vector((x,-1.38,1.949))).y,c.z+.018),.031,.06,'Paint','X',12)
c=fit(Vector((.27,.14,2.594)))
cyl('turret_crew',(c.x,c.y,c.z-.33),.266,.018,'WeaponSteel',verts=48)
# Concealed fixed sleeves keep the hatch rim and MG supported after their
# detachable outer housings are removed. They sit inside the original surfaces.
vs=[];fs=[];steps=24
for z,r in [(2.49,.310),(2.555,.310),(2.49,.271),(2.555,.271)]:
 for i in range(steps):
  a=2*math.pi*i/steps;vs.append(tuple(fit(Vector((.27+r*math.cos(a),.14+r*math.sin(a),z)))))
for i in range(steps):
 j=(i+1)%steps
 fs.extend([(i,j,steps+j,steps+i),(2*steps+j,2*steps+i,3*steps+i,3*steps+j),(steps+i,steps+j,3*steps+j,3*steps+i),(j,i,2*steps+i,2*steps+j)])
mesh('turret_crew',vs,fs,'Paint',True)
c=fit(Vector((-.43,.23,2.52)))
cyl('turret_crew',c,.019,.168*scale.z,'WeaponSteel',verts=12)
# Small mounting bosses underneath the detachable side armour; the cast skin remains whole.
for side in [-1,1]:
 for y in [-1.13,-.69]:
  for z in [1.46,1.68]:
   q=fit(Vector((side*1.233,y,z)));bolt('crew',q,.017,'X')
extra=finish(GROUPS.pop('crew'),'fixed crew liners')
HEALTH['Hull']=finish([HEALTH['Hull'],extra],'Hull',(0,0,0))
extra=finish(GROUPS.pop('turret_crew'),'turret crew floor')
turret_pos=HEALTH['Turret'].matrix_world.translation.copy()
# joining an existing parent retains child world matrices explicitly
child_world={o:o.matrix_world.copy() for o in HEALTH['Turret'].children}
HEALTH['Turret']=finish([HEALTH['Turret'],extra],'Turret',turret_pos)
for o,m in child_world.items():o.matrix_world=m
GROUPS=HEALTH
for name,loc,par in [('Fx_Engine',(0,1.72,1.85),'Hull'),('Fx_Ammo',(.43,-.35,.91),'Hull'),('Fx_Hatch',(.27,.14,2.64),'Turret')]:
 empty(name,fit(Vector(loc)),par)
asset=list(HEALTH.values())+[o for o in S.objects if o.type=='EMPTY']
bpy.context.view_layer.update()
# Empties have +Z-forward identity orientation in glTF, not Blender's Z-up correction.
def export_asset(path,objects):
 pm=MATS['Paint'];nt=pm.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');nt.links.new(nt.nodes['Baked Base'].outputs[0],bs.inputs['Base Color'])
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_apply=False,export_yup=True,export_image_format='JPEG',export_jpeg_quality=88,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_vertex_color='ACTIVE',export_all_vertex_colors=False)
 data=path.read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);tail=data[20+jl:]
 for m in doc.get('materials',[]):
  if m['name']=='Paint':m.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=[*PAINTS[0][1],1]
  if m['name']=='Scars':m['alphaMode']='BLEND'
 for n in doc['nodes']:
  if n['name']=='Muzzle':n.pop('rotation',None)
 j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
 path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(tail))+struct.pack('<II',len(j),0x4e4f534a)+j+tail)
 nt.links.new(paint_mix.outputs[0],bs.inputs['Base Color'])
 return doc

def triangles(objects):
 total=0
 for o in objects:
  if o.type=='MESH':o.data.calc_loop_triangles();total+=len(o.data.loop_triangles)
 return total

def yup(p):return [round(float(p[0]),6),round(float(p[2]),6),round(float(-p[1]),6)]
healthy_doc=export_asset(ROOT/'sherman-tank.glb',asset)
assert triangles(asset)<=160000,triangles(asset)
print('HEALTHY RETROFIT EXPORTED',triangles(asset),len(HEALTH),'Parts',sum(n.startswith('Part_') for n in HEALTH),flush=True)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'healthy-checkpoint.blend'))
if '--healthy-only' in sys.argv:sys.exit(0)
# ---- FILE B: damage twins and wrecks built from copies of the healthy meshes. ----
GROUPS={};RETRO=None
make_material('Burnt','rusty_metal_03',(.12,.105,.085),.97,.35,512)
make_material('Scars','rusty_metal_03',(.48,.46,.41),.66,.8,512)
# Damage file's shared track maps are limited to 512; the healthy copies are already exported.
for matname in ['TrackSteel','Rubber']:
 for n in MATS[matname].node_tree.nodes:
  if n.type=='TEX_IMAGE' and max(n.image.size)>512:
   n.image=n.image.copy();n.image.scale(512,512)
DAMAGE=[];DMG={};TWINS={}
def damage_root(name,pos):
 o=bpy.data.objects.new(name,None);S.collection.objects.link(o);o.location=pos;bpy.context.view_layer.update();DAMAGE.append(o);return o
hroot=damage_root('Hull_Damage',(0,0,0));troot=damage_root('Turret_Damage',HEALTH['Turret'].matrix_world.translation)

def copy_mesh(src,name,par=None):
 o=bpy.data.objects.new(name,src.data.copy());S.collection.objects.link(o);o.matrix_world=src.matrix_world.copy()
 if par:parent(o,par)
 return o

def set_damage_materials(o,burn=False):
 for i,m in enumerate(o.data.materials):
  if burn:o.data.materials[i]=MATS['Burnt']
  elif m.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:
   o.data.materials[i]=MATS['Burnt'] if m.name=='Canvas' else MATS['TrackSteel']

def tint(o,fn):
 attr=o.data.color_attributes.get('ContactDetail')
 if not attr:attr=o.data.color_attributes.new(name='ContactDetail',type='FLOAT_COLOR',domain='CORNER')
 o.data.color_attributes.active_color=attr
 for poly in o.data.polygons:
  for li in poly.loop_indices:
   v=o.data.vertices[o.data.loops[li].vertex_index];c=fn(v.co)
   attr.data[li].color=(*c,1)

def reduce(o,ratio):
 mod=o.modifiers.new('Damage mesh budget','DECIMATE');mod.ratio=ratio;apply(o)

def add_damage(o,par):
 parent(o,par);DAMAGE.append(o);DMG[o.name]=o;return o

# Deform copies in local coordinates; every twin retains its Part origin exactly.
for name,src in HEALTH.items():
 if not name.startswith('Part_'):continue
 twin='Dmg_'+name[5:];o=copy_mesh(src,twin);set_damage_materials(o)
 extent=max(max(v.co.length for v in o.data.vertices),.05)
 grille_ymin=min(v.co.y for v in o.data.vertices);grille_ymax=max(v.co.y for v in o.data.vertices)
 metal=not any(t in name for t in ['Sandbags','TowCable','Antenna'])
 for v in o.data.vertices:
  p=v.co.copy();u=p.y/extent
  if 'Fender' in name:
   v.co.z+=.25*math.sin(u*2.5)*abs(u)**1.4;v.co.x+=.095*math.sin(u*3.5)
  elif 'Skirt' in name:
   v.co.x+=math.copysign(.08,name.endswith('L1') and 1 or -1)*(p.y/extent)**2
   v.co.z-=.08*(p.y/extent+.5)
  elif 'Sandbags' in name:
   v.co.z*=.65;v.co.z-=.06;v.co.y+=.10*math.sin(p.x*12)
  elif 'Grille' in name:
   v.co.z+=.26*(grille_ymax-p.y)/max(.01,grille_ymax-grille_ymin)
  elif 'TowCable' in name:v.co.z-=.20*max(0,math.sin(p.y*1.5))
  elif 'Antenna' in name:v.co.x+=p.z*.9;v.co.z-=abs(p.z)*.30
  elif 'Exhaust' in name or 'Stowage' in name:
   v.co.y+=.12*math.sin(p.x*9)*max(.15,p.z/extent+.5);v.co.z-=.07*(p.x/extent+.5)
  else:v.co.z+=.055*math.sin(p.y*11);v.co.x+=.035*math.sin(p.z*18)
 # Nonuniform char and exposed steel at the deformed edge.
 if metal:
  if MATS['Scars'].name not in [m.name for m in o.data.materials]:o.data.materials.append(MATS['Scars'])
  ix=len(o.data.materials)-1
  for poly in o.data.polygons:
   if poly.center.y>extent*.3 and random.random()<.35:poly.material_index=ix
 tint(o,lambda p:(max(.07,.65+.35*math.sin(p.y*5+p.z*9)),)*3)
 reduce(o,.58)
 add_damage(o,troot if name.startswith('Part_Turret_') else hroot);TWINS[name]=twin

# Physical impact overlays projected onto the real, curved cast armour.
# Each face has four impacts, including a petalled penetration and a long ricochet.
def make_scars(target,name,side):
 is_turret='Turret' in name;pts=[];faces=[];colors=[]
 if side in ['L','R']:
  normal=Vector((1 if side=='L' else -1,0,0));u=Vector((0,1,0));v=Vector((0,0,1))
  specs=[(-.65,2.19,.11),(-.10,2.34,.085),(.47,2.20,.10),(.12,2.08,.07)] if is_turret else [(-1.83,1.53,.12),(-.72,1.51,.10),(.66,1.54,.115),(1.80,1.46,.085)]
  centres=[Vector((0,y,z)) for y,z,r in specs]
 else:
  normal=Vector((0,-1 if side=='Front' else 1,0));u=Vector((1,0,0));v=Vector((0,0,1))
  specs=[(-.47,2.21,.11),(.38,2.29,.10),(.07,2.40,.07),(.55,2.09,.065)] if is_turret else [(-.44,1.46,.105),(.15,1.52,.12),(.55,1.30,.09),(-.11,1.25,.085)]
  centres=[Vector((x,0,z)) for x,z,r in specs]
 inv=target.matrix_world.inverted()
 for k,(centre,spec) in enumerate(zip(centres,specs)):
  radius=spec[2]
  def project(offset):
   start=centre+offset+normal*4
   hit,p,n,index=target.ray_cast(inv@start,inv.to_3x3()@(-normal),distance=8)
   if not hit:return None,None
   return target.matrix_world@p,(target.matrix_world.to_3x3()@n).normalized()
  cp,cn=project(Vector())
  if cp is None:continue
  tangent=(u-cn*u.dot(cn)).normalized();vertical=cn.cross(tangent).normalized()
  def project_tangent(offset):
   start=cp+offset+cn*.6
   hit,p,n,index=target.ray_cast(inv@start,inv.to_3x3()@(-cn),distance=1.2)
   if not hit:return cp+offset,cn
   return target.matrix_world@p,(target.matrix_world.to_3x3()@n).normalized()
  # Concentric, irregular soot perimeter / bare-steel rim / dark penetration cup.
  radius*=.67
  ringspec=[(2.05,.0022,.035,0),(1.24,.0026,.06,.25),(.94,.0030,.64,1),(.57,.0040,.90,1),(.35,.0022,.008 if k==0 else .48,1)]
  start=len(pts);N=24
  for ri,(rr,height,col,alpha) in enumerate(ringspec):
   for j in range(N):
    a=2*math.pi*j/N
    jag=1+.12*math.sin(a*3+k*1.9)+.045*math.sin(a*7+k)
    elong=2.8 if k==2 else (1.5 if k==1 else 1)
    offset=tangent*(math.cos(a)*radius*rr*jag*elong)+vertical*(math.sin(a)*radius*rr*jag*(.24 if k==2 else (.7 if k==1 else 1)))
    if ri<2:offset+=vertical*(radius*.65*max(0,math.sin(a))*(2-ri))
    point,norm=project_tangent(offset)
    if point is None:point,norm=cp+offset,cn
    petal=.001*math.sin(j*math.pi/3) if ri==3 else 0
    pts.append(tuple(point+norm*(height+petal)));colors.append((col,col,col,alpha))
  for ri in range(4):
   for j in range(N):faces.append((start+ri*N+j,start+ri*N+(j+1)%N,start+(ri+1)*N+(j+1)%N,start+(ri+1)*N+j))
  faces.append(tuple(start+4*N+j for j in range(N)))
 ob=mesh(name,pts,faces,'Scars',False);ob=finish([ob],name)
 col=ob.data.color_attributes['ContactDetail']
 for li,l in enumerate(ob.data.loops):col.data[li].color=colors[l.vertex_index]
 return add_damage(ob,troot if is_turret else hroot)
for target in ['Hull','Turret']:
 for side in ['Front','L','R','Rear']:make_scars(HEALTH[target],'Scar_'+target+'_'+side,side)

# Repose each copied track link as a rigid piece. The snapped front end lies flat.
def broken_track(side):
 src=HEALTH['Track_'+side];o=copy_mesh(src,'Track_'+side+'_Broken');set_damage_materials(o)
 sign=1 if side=='L' else -1
 centres=[];tangents=[]
 for i in range(count):
  (y,z),a=sample(i*pitch);centres.append(fit(Vector((sign*1.08,y,z))));tangents.append(a)
 centers=np.array([tuple(c) for c in centres])
 for vert in o.data.vertices:
  p=src.matrix_world@vert.co;ds=(centers[:,1]-p.y)**2+(centers[:,2]-p.z)**2;idx=int(np.argmin(ds));c=centres[idx];a=tangents[idx]
  q=p-c
  local_y=q.y*math.cos(a)+q.z*math.sin(a);local_z=-q.y*math.sin(a)+q.z*math.cos(a)
  nc=c.copy();newa=a
  # upper links fall outboard, clearing the road wheels; front arc unrolls.
  if idx>=48 and idx<=77:
   f=(idx-48)/29;nc.x+=sign*.43*min(1,f*3);nc.y=2.35-4.90*f;nc.z=.07+1.08*(1-f)**2;newa=math.atan2(-2.16*(1-f),-4.90)
  elif idx>=78:
   f=(idx-78)/(count-79);nc=Vector((sign*1.49,-2.72-1.30*f,.060));newa=math.pi
  else:
   nc.z=max(.058,nc.z)
  vert.co=o.matrix_world.inverted()@Vector((nc.x+q.x,nc.y+local_y*math.cos(newa)-local_z*math.sin(newa),nc.z+local_y*math.sin(newa)+local_z*math.cos(newa)))
 reduce(o,.42)
 # Exact ground contact of flat loose shoe ends.
 low=min((o.matrix_world@v.co).z for v in o.data.vertices)
 if low<0:
  for v in o.data.vertices:v.co.z-=low
 return add_damage(o,hroot)
for side in ['L','R']:broken_track(side)
# Broken gun = the original mantlet + original barrel, burst halfway down the tube.
gunpos=HEALTH['Gun'].matrix_world.translation.copy()
gunpieces=[copy_mesh(HEALTH['Gun'],'broken mantlet'),copy_mesh(HEALTH['Barrel'],'burst tube')]
for o in gunpieces:set_damage_materials(o)
bar=gunpieces[1];bm=bmesh.new();bm.from_mesh(bar.data)
cut_y=-2.15
plane=bar.matrix_world.inverted()@Vector((0,cut_y,2))
bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),plane_co=plane,plane_no=Vector((0,1,0)),clear_inner=True,dist=1e-6)
bm.to_mesh(bar.data);bm.free()
# Six torn steel petals extend from the severed barrel wall.
zc=HEALTH['Barrel'].matrix_world.translation.z
for j in range(6):
 a=2*math.pi*j/6;verts=[]
 for t in [0,.35,.7,1]:
  r=.073+.10*t*t;yy=cut_y-.25*t
  for da in [-.38,.38]:verts.append((r*math.cos(a+da*(1-.6*t)),yy,zc+r*math.sin(a+da*(1-.6*t))-.07*t))
 ob=mesh('burst_petals',verts,[(2*i,2*i+1,2*i+3,2*i+2) for i in range(3)],'Scars',True)
 sol=ob.modifiers.new('Steel wall thickness','SOLIDIFY');sol.thickness=.009;bevel(ob,.003,2);gunpieces.append(ob)
gbroken=finish(gunpieces,'Gun_Broken',gunpos);tint(gbroken,lambda p:(.27,.27,.27));add_damage(gbroken,troot)

# The two wreck objects contain copied vehicle geometry, never a separate approximation.
# Hull: sagged suspension, missing bolt-ons, burnt tyres, thrown left belt, ruptured grille.
wreckparts=[]
for name,src in HEALTH.items():
 if name in ['Turret','Gun','Barrel','MG','Hatch_Commander'] or name.startswith('Part_Turret_'):continue
 if name.startswith('Part_'):
  if any(t in name for t in ['Sandbags','TowCable','Shovel','Axe','Crowbar','Skirt','HeadlightGuard']):continue
  if name in ['Part_Fender_FL','Part_Fender_RR','Part_RearStowageBox']:continue
  source=DMG.get(TWINS.get(name,''),src)
 else:source=src
 if name=='Track_L':source=DMG['Track_L_Broken']
 ob=copy_mesh(source,'wreck_'+name);set_damage_materials(ob,True)
 if name.startswith('Wheel_'):
  # Burn rubber away to the smaller metal wheel core; preserve hub centre.
  for v in ob.data.vertices:
   q=v.co;rr=math.hypot(q.y,q.z)
   if rr>.20:q.y*=.90;q.z*=.90
  ob.location.z-=.05
 if name.startswith('Hatch_'):
  ob.rotation_euler.x=math.radians(-110)
 if name=='Hull':
  for vert in ob.data.vertices:
   q=ob.matrix_world@vert.co
   if q.z<1.2:vert.co.z-=.05*min(1,max(0,(q.z-.12)/.5))
 if name=='Track_R':
  for vert in ob.data.vertices:
   q=ob.matrix_world@vert.co
   if q.z>1.05:vert.co.z-=.07*(.5+.5*math.cos(q.y*4))
 if name=='Part_EngineGrille':
  ymin=min(v.co.y for v in ob.data.vertices);ymax=max(v.co.y for v in ob.data.vertices)
  for vert in ob.data.vertices:vert.co.z+=.25*(ymax-vert.co.y)/max(.01,ymax-ymin)
 # Nonuniform soot and heat-oxidized grey steel, in the portable vertex channel.
 tint(ob,lambda p:tuple((.48+.40*(.5+.5*math.sin(p.x*1.3+p.y*.8+p.z*2)))*c for c in (1,.94,.88)))
 wreckparts.append(ob)
wh=finish(wreckparts,'Wreck_Hull',(0,0,0))
# Open turret ring through the hull, with a real dark floor below the well.
ringpos=HEALTH['Turret'].matrix_world.translation.copy()
drill(wh,(ringpos.x,ringpos.y,1.48),.70,1.25)
cyl('wreck_interior',(ringpos.x,ringpos.y,.94),.70,.035,'Burnt',verts=48)
torus('wreck_interior',(ringpos.x,ringpos.y,1.765),.73,.048,'Burnt',majorseg=64)
wh=finish([wh]+GROUPS.pop('wreck_interior'),'Wreck_Hull',(0,0,0));reduce(wh,.44)
DAMAGE.append(wh);DMG[wh.name]=wh
# Turret underside stays closed, basket stub visible when it is blown off.
tparts=[]
for name,src in HEALTH.items():
 if name not in ['Turret','Gun','Barrel','MG','Hatch_Commander'] and not name.startswith('Part_Turret_'):continue
 if any(x in name for x in ['Sandbags','Antenna','Sight','MGMount']) or name=='MG':continue
 ob=copy_mesh(src,'wreck_'+name);set_damage_materials(ob,True)
 if name in ['Gun','Barrel']:
  transform=Matrix.Translation(gunpos)@Matrix.Rotation(math.radians(14),4,'X')@Matrix.Translation(-gunpos)
  ob.matrix_world=transform@ob.matrix_world
 if name=='Hatch_Commander':ob.rotation_euler.x=math.radians(-110)
 tint(ob,lambda p:tuple((.48+.40*(.5+.5*math.sin(p.x*1.7+p.y*.7+p.z*1.8)))*c for c in (1,.96,.9)))
 tparts.append(ob)
cyl('basket',(ringpos.x,ringpos.y,ringpos.z-.025),.78,.11,'Burnt',verts=64)
for a in np.linspace(0,2*math.pi,10,endpoint=False):
 x=ringpos.x+.60*math.cos(a);y=ringpos.y+.60*math.sin(a)
 rod('basket',(x,y,ringpos.z-.08),(x,y,ringpos.z-.45),.018,'Burnt',12)
torus('basket',(ringpos.x,ringpos.y,ringpos.z-.46),.60,.028,'Burnt',majorseg=48)
cyl('basket',(ringpos.x,ringpos.y,ringpos.z-.48),.60,.022,'Burnt',verts=48)
wt=finish(tparts+GROUPS.pop('basket'),'Wreck_Turret',ringpos);reduce(wt,.60);DAMAGE.append(wt);DMG[wt.name]=wt
# Enforce file-B budget while prioritising the original silhouette in each wreck.
if triangles(DAMAGE)>108000:
 ratio=107000/triangles(DAMAGE)
 for o in DAMAGE:
  if o.type=='MESH':reduce(o,ratio)
assert triangles(DAMAGE)<=110000,triangles(DAMAGE)
assert set(o.name for o in DAMAGE if o.parent is None)=={'Hull_Damage','Turret_Damage','Wreck_Hull','Wreck_Turret'}
damage_doc=export_asset(ROOT/'sherman-tank-damage.glb',DAMAGE)
print('DAMAGE EXPORTED',triangles(DAMAGE),'wreck',triangles([wh,wt]),flush=True)
# Contract manifest records the exported nodes of both assets with their world pivots.
bpy.context.view_layer.update()
def node_records(objects):return [{'name':o.name,'world_position':yup(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type} for o in objects]
points=[o.matrix_world@v.co for o in HEALTH.values() for v in o.data.vertices]
lo=[min(yup(p)[i] for p in points) for i in range(3)];hi=[max(yup(p)[i] for p in points) for i in range(3)]
meta={'asset':'Sherman cast-hull 75 mm','contract':'tank-damage/v1','units':'metres','forward':'+Z','up':'+Y','left':'+X','triangles':triangles(asset),'damage_triangles':triangles(DAMAGE),'wreck_triangles':triangles([wh,wt]),'bounds':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'nodes':node_records(asset),'damage_nodes':node_records(DAMAGE),'files':{'healthy':'sherman-tank.glb','damage':'sherman-tank-damage.glb'},'damage':{'parts':[{'part':n,'twin':TWINS.get(n)} for n in HEALTH if n.startswith('Part_')],'scars':[n for n in DMG if n.startswith('Scar_')],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'track_uv':'V runs once around the original belt; geometry links share the continuous arclength coordinate','wheel_order':'Legacy names 1 sprocket, 2-7 road wheels, 8 idler retained; 9-11 return rollers front to back','poly_haven':IDS,'retrofit':'Original geometry and materials retained. No new optional stowage invented; original early 75 mm turret has no separate loader hatch.'}
damage_points=[yup(o.matrix_world@v.co) for o in DAMAGE if o.type=='MESH' for v in o.data.vertices]
meta['damage_bounds']={'min':[min(p[i] for p in damage_points) for i in range(3)],'max':[max(p[i] for p in damage_points) for i in range(3)]}
import hashlib
meta['build_source']={'file':Path(__file__).name,'sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'clean_scene':True}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
(ROOT/'NOTES.md').write_text(f'Retrofit of the existing M4 cast-hull Sherman: detachable fittings, recoil barrel, crew hatches, sockets and tank-damage/v1 twins/wrecks.\nHealthy {triangles(asset):,} triangles; damage {triangles(DAMAGE):,}; wreck {triangles([wh,wt]):,}; GLBs {(ROOT/"sherman-tank.glb").stat().st_size/1048576:.2f}/{(ROOT/"sherman-tank-damage.glb").stat().st_size/1048576:.2f} MiB.\nHealthy: original 8 materials; damage: Paint, Burnt, Scars, TrackSteel, Rubber; original Paint factor and six paints preserved.\nOriginal Cycles tile bakes reused: base/normal ≤1024, ORM ≤512; new Burnt/Scars Cycles bakes and all non-Paint damage textures ≤512.\nPoly Haven CC0: {", ".join(IDS)}; https://polyhaven.com/license ; copied original baked maps, downloads only from dl.polyhaven.org.\nReference differences inherited: roof MG, short antenna and original stowage layout; no invented jerrycans, spare glacis links, loader hatch or turret bin; no insignia.\n')
# Save a recovery checkpoint before consumer round-trip proof rendering.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'damage-checkpoint.blend'))
# ---- Proof: import BOTH delivered binaries into an empty consumer scene. ----
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'sherman-tank.glb'))
A=list(bpy.context.scene.objects);AN={o.name:o for o in A}
before=set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'sherman-tank-damage.glb'))
B=[o for o in bpy.context.scene.objects if o not in before];BN={o.name:o for o in B}
S=bpy.context.scene;S.render.engine='CYCLES';S.cycles.device='GPU';S.cycles.samples=16;S.cycles.use_denoising=True
S.render.threads_mode='FIXED';S.render.threads=8
prefs=bpy.context.preferences.addons['cycles'].preferences;prefs.compute_device_type='OPTIX';prefs.get_devices()
for device in prefs.devices:device.use=device.type=='OPTIX'
S.view_settings.view_transform='AgX';S.view_settings.exposure=-.35
S.render.image_settings.file_format='PNG'
# Assertions are on the serialized and imported result, including exact twin pivots.
assert AN['Gun'].parent==AN['Turret'] and AN['Barrel'].parent==AN['Gun']
assert AN['Muzzle'].parent==AN['Barrel']
assert AN['Muzzle'].rotation_euler.to_matrix()==Matrix.Identity(3)
assert set(o.name for o in B if o.parent is None)=={'Hull_Damage','Turret_Damage','Wreck_Hull','Wreck_Turret'}
assert len([n for n in AN if n.startswith('Part_')])>=14
for part,twin in TWINS.items():assert (AN[part].matrix_world.translation-BN[twin].matrix_world.translation).length<1e-5,(part,twin)
assert (AN['Gun'].matrix_world.translation-BN['Gun_Broken'].matrix_world.translation).length<1e-5
assert (AN['Turret'].matrix_world.translation-BN['Wreck_Turret'].matrix_world.translation).length<1e-5
for o in A+B:
 if o.type=='MESH':assert len(o.modifiers)==0
# A mild studio environment matches the prior accepted presentation.
mat=bpy.data.materials.new('Studio ground');mat.diffuse_color=(.19,.205,.22,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.85
bpy.ops.mesh.primitive_plane_add(size=200);ground=bpy.context.object;ground.name='Studio ground';ground.data.materials.append(mat);ground.location.z=-.002
S.world=bpy.data.worlds.new('Soft daylight');S.world.use_nodes=True;S.world.node_tree.nodes['Background'].inputs[0].default_value=(.60,.68,.79,1);S.world.node_tree.nodes['Background'].inputs[1].default_value=.45
for name,loc,power,size,col in [('Key',(-4,-6,8),1700,6,(1,.91,.79)),('Fill',(5,-2,5),600,5,(.79,.87,1)),('Rim',(1,5,7),1800,4,(1,.95,.86))]:
 light=bpy.data.lights.new(name,'AREA');light.energy=power;light.shape='DISK';light.size=size;light.color=col;o=bpy.data.objects.new(name,light);S.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
cd=bpy.data.cameras.new('Presentation');cam=bpy.data.objects.new('Presentation',cd);S.collection.objects.link(cam);S.camera=cam

def view(loc=(7.7,-10,5.1),target=(0,-.10,1.21),ortho=8):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cd.type='ORTHO';cd.ortho_scale=ortho

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 visible(o,on,recursive=False):
 o.hide_render=not on
 if recursive:
  for c in o.children:visible(c,on,True)

def healthy_state():
 for o in A:visible(o,True)
 for o in B:visible(o,False)

healthy_state();view()
render('final.png',1920,1080,32)
view((9,0,1.34),(0,0,1.34),6.6);render('side.png',1920,1080,20)
view()
clay=bpy.data.materials.new('Inspection clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.33,.34,.35,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
S.view_settings.exposure=-1;S.view_layers[0].material_override=clay;render('clay.png',1920,1080,16);S.view_layers[0].material_override=None;S.view_settings.exposure=-.35
# Recolour the imported Paint without changing its baked neutral texture or vertex detail.
paints=[m for m in bpy.data.materials if m.name=='Paint' or m.name.startswith('Paint.')]
paint_factors=[]
for m in paints:
 n=m.node_tree.nodes;l=m.node_tree.links;bs=next(n for n in n if n.type=='BSDF_PRINCIPLED')
 incoming=bs.inputs['Base Color'].links[0].from_socket if bs.inputs['Base Color'].is_linked else None
 mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1
 if incoming:l.new(incoming,mix.inputs[1])
 else:mix.inputs[1].default_value=bs.inputs['Base Color'].default_value
 l.new(mix.outputs[0],bs.inputs['Base Color']);paint_factors.append(mix.inputs[2])

def recolor(col):
 for socket in paint_factors:socket.default_value=tuple(col[i]/PAINTS[0][1][i] for i in range(3))+(1,)

def grid(paths,filename,w=640,h=360):
 canvas=np.ones((h*2,w*3,4),dtype=np.float32)
 for i,path in enumerate(paths):
  im=bpy.data.images.load(str(path),check_existing=False);pixels=np.array(im.pixels[:],dtype=np.float32).reshape(h,w,4)
  row=1-i//3;column=i%3;canvas[row*h:(row+1)*h,column*w:(column+1)*w]=pixels;bpy.data.images.remove(im)
 im=bpy.data.images.new(filename,width=w*3,height=h*2,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/filename);im.file_format='PNG';im.save();bpy.data.images.remove(im)
 for path in paths:path.unlink()

paths=[]
for i,(name,col) in enumerate(PAINTS):
 recolor(col);path=ROOT/f'_paint-{i}.png';render(path.name,640,360,10);paths.append(path)
grid(paths,'paints.png');recolor(PAINTS[0][1])
# The requested six states all come from the same two exported files.
paths=[];wreck_matrix=BN['Wreck_Turret'].matrix_world.copy()
for i in range(6):
 healthy_state();BN['Wreck_Turret'].matrix_world=wreck_matrix.copy();view(ortho=9.7)
 if i==1:
  for n,o in BN.items():
   if n.startswith('Scar_'):visible(o,True)
  for part in ['Part_Fender_FL','Part_Fender_FR','Part_Skirt_L1','Part_EngineGrille','Part_Turret_Sandbags_L','Part_Turret_Sight']:
   visible(AN[part],False);visible(BN[TWINS[part]],True)
 elif i==2:
  for n,o in AN.items():
   if n.startswith('Part_'):visible(o,False)
 elif i==3:
  visible(AN['Track_L'],False);visible(BN['Track_L_Broken'],True)
  visible(AN['Gun'],False,True);visible(BN['Gun_Broken'],True)
 elif i>=4:
  for o in A:visible(o,False)
  visible(BN['Wreck_Hull'],True);visible(BN['Wreck_Turret'],True)
  if i==5:
   wt=BN['Wreck_Turret'];wt.rotation_mode='XYZ';wt.rotation_euler=(math.radians(128),math.radians(12),math.radians(-25));wt.location=(2.65,.25,1.0)
   bpy.context.view_layer.update();low=min((wt.matrix_world@v.co).z for v in wt.data.vertices);wt.location.z-=low
 path=ROOT/f'_state-{i}.png';render(path.name,960,540,16);paths.append(path)
grid(paths,'states.png',960,540)
# Every moving object is tested at 30 degrees; roots stay exactly on their pivots.
healthy_state();BN['Wreck_Turret'].matrix_world=wreck_matrix
moving=[o for n,o in AN.items() if n in ['Turret','Gun','Barrel','MG'] or n.startswith('Wheel_') or n.startswith('Hatch_')]
originals={o:o.matrix_basis.copy() for o in moving}
for o in moving:
 local_pivot=o.location.copy();o.rotation_mode='XYZ'
 o.rotation_euler.z=math.radians(30) if o.name in ['Turret','MG'] else 0
 if o.name not in ['Turret','MG']:o.rotation_euler.x=math.radians(30)
 assert (o.location-local_pivot).length<1e-8
view();render('articulation.png',1280,720,16)
for o,m in originals.items():o.matrix_basis=m
# Open crew hatches prove real recesses under all three existing hatches.
for n,o in AN.items():
 if n.startswith('Hatch_'):o.rotation_euler.x=math.radians(-105)
view((5,-7,8),(0,-.3,1.5),7.8);render('hatches.png',1280,720,16)
print('ROUNDTRIP, SIX STATES, PAINTS AND ALL MOVING PIVOTS VERIFIED',flush=True)
print('BUILD FINISHED',flush=True)
