import bpy, bmesh, math, os, json, random, urllib.request, struct, sys
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT); random.seed(55)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
S=bpy.context.scene
S.render.engine='CYCLES'; S.cycles.samples=24; S.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices: d.use=d.type!='CPU'
S.cycles.device='GPU'; S.render.image_settings.file_format='PNG'
S.world.color=(.25,.25,.25)
texdir=ROOT/'textures'; texdir.mkdir(exist_ok=True)
IDS=['green_metal_rust','rusty_metal','rubberized_track','bark_brown_02']
def download(id,mp):
 p=texdir/f'{id}_{mp}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
  with urllib.request.urlopen(req) as r: p.write_bytes(r.read())
 return str(p)
for id in IDS:
 for mp in ['diff','nor_gl','arm']: download(id,mp)

def node(mat,typ):return mat.node_tree.nodes.new(typ)
def link(mat,a,out,b,inp):mat.node_tree.links.new(a.outputs[out],b.inputs[inp])
def image_node(m,path,non=False):
 n=node(m,'ShaderNodeTexImage'); n.image=bpy.data.images.load(path,check_existing=True)
 if non:n.image.colorspace_settings.name='Non-Color'
 return n
# Bake reusable tile materials on a UV plane. No runtime procedural nodes.
M={}
recipes=[('Paint','green_metal_rust',(.87,.87,.87),.12,.78),('Rubber','rubberized_track',(.012,.013,.012),.0,.9),('TrackSteel','rusty_metal',(.075,.063,.046),.65,.79),('Metal','rusty_metal',(.032,.035,.03),.68,.69),('Canvas','rubberized_track',(.19,.15,.10),0,.95),('Wood','bark_brown_02',(.24,.16,.09),0,.88),('Lamps','green_metal_rust',(.16,.19,.16),.3,.24),('Interior','rusty_metal',(.012,.015,.013),0,.98),('Burnt','green_metal_rust',(.055,.055,.052),.25,.94),('Scars','rusty_metal',(.19,.18,.16),.7,.65)]
for name,id,col,metal,rough in recipes:
 m=bpy.data.materials.new(name+'_source');m.use_nodes=True
 nt=m.node_tree;nt.nodes.clear();out=node(m,'ShaderNodeOutputMaterial'); bs=node(m,'ShaderNodeBsdfPrincipled');link(m,bs,'BSDF',out,'Surface')
 diff=image_node(m,download(id,'diff'));bw=node(m,'ShaderNodeRGBToBW');link(m,diff,'Color',bw,'Color')
 ramp=node(m,'ShaderNodeValToRGB');link(m,bw,'Val',ramp,'Fac')
 ramp.color_ramp.elements[0].position=.12;ramp.color_ramp.elements[1].position=.38
 if name=='Paint':
  ramp.color_ramp.elements[0].color=(.55,.55,.55,1);ramp.color_ramp.elements[1].color=(.97,.97,.97,1)
 else:
  ramp.color_ramp.elements[0].color=tuple(c*.35 for c in col)+(1,);ramp.color_ramp.elements[1].color=tuple(min(1,c*1.65) for c in col)+(1,)
 arm=image_node(m,download(id,'arm'),True);arm_sep=node(m,'ShaderNodeSeparateColor');link(m,arm,'Color',arm_sep,'Color')
 noise=node(m,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=145;noise.inputs['Detail'].default_value=3
 mix=node(m,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.18;link(m,ramp,'Color',mix,1);link(m,noise,'Fac',mix,2)
 # Broad worn patches and rain streaks are baked into the reusable neutral paint tile.
 grain=node(m,'ShaderNodeTexNoise');grain.inputs['Scale'].default_value=9;grain.inputs['Detail'].default_value=5;grain.inputs['Roughness'].default_value=.78
 dirt=node(m,'ShaderNodeValToRGB');link(m,grain,'Fac',dirt,'Fac');dirt.color_ramp.elements[0].position=.30;dirt.color_ramp.elements[0].color=(.09,.09,.09,1);dirt.color_ramp.elements[1].position=.64;dirt.color_ramp.elements[1].color=(1,1,1,1)
 weather=node(m,'ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=.56 if name=='Paint' else .72;link(m,mix,'Color',weather,1);link(m,dirt,'Color',weather,2)
 tc=node(m,'ShaderNodeTexCoord');mapping=node(m,'ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(55,3,1);link(m,tc,'UV',mapping,0)
 streak=node(m,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=1;streak.inputs['Detail'].default_value=3;link(m,mapping,'Vector',streak,'Vector')
 sr=node(m,'ShaderNodeValToRGB');link(m,streak,'Fac',sr,'Fac');sr.color_ramp.elements[0].position=.32;sr.color_ramp.elements[0].color=(.15,.15,.15,1);sr.color_ramp.elements[1].position=.48
 finalmix=node(m,'ShaderNodeMixRGB');finalmix.blend_type='MULTIPLY';finalmix.inputs[0].default_value=.09 if name=='Paint' else (0 if name=='Burnt' else .2);link(m,weather,'Color',finalmix,1);link(m,sr,'Color',finalmix,2)
 link(m,finalmix,'Color',bs,'Base Color');bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 norm=image_node(m,download(id,'nor_gl'),True);nn=node(m,'ShaderNodeNormalMap');nn.inputs['Strength'].default_value=.10 if name=='Burnt' else .55;link(m,norm,'Color',nn,'Color')
 bump=node(m,'ShaderNodeBump');bump.inputs['Strength'].default_value=.45;bump.inputs['Distance'].default_value=.004;link(m,noise,'Fac',bump,'Height');link(m,nn,'Normal',bump,'Normal');link(m,bump,'Normal',bs,'Normal')
 bpy.ops.mesh.primitive_plane_add(size=2);p=bpy.context.object;p.data.materials.append(m)
 maps={}
 for mode,size in [('Base',1024 if name=='Paint' else 512),('Normal',512),('ORM',512)]:
  im=bpy.data.images.new(name+'_'+mode,width=size,height=size,alpha=False);im.filepath_raw=str(texdir/(name+'_'+mode+'.png'));im.file_format='PNG'
  if mode!='Base':im.colorspace_settings.name='Non-Color'
  target=node(m,'ShaderNodeTexImage');target.image=im;nt.nodes.active=target
  S.cycles.samples=8
  if mode=='Normal':
   link(m,bs,'BSDF',out,'Surface');bpy.ops.object.bake(type='NORMAL',margin=8)
  else:
   em=node(m,'ShaderNodeEmission')
   if mode=='Base':link(m,finalmix,'Color',em,'Color')
   else:
    comb=node(m,'ShaderNodeCombineColor');comb.inputs[0].default_value=1;comb.inputs[1].default_value=rough;comb.inputs[2].default_value=metal
    rr=node(m,'ShaderNodeMapRange');rr.inputs['From Min'].default_value=0;rr.inputs['From Max'].default_value=1;rr.inputs['To Min'].default_value=max(.1,rough-.18);rr.inputs['To Max'].default_value=min(1,rough+.08);link(m,arm_sep,'Green',rr,'Value');link(m,rr,'Result',comb,1)
    aa=node(m,'ShaderNodeMapRange');aa.inputs['To Min'].default_value=.72;aa.inputs['To Max'].default_value=1;link(m,arm_sep,'Red',aa,'Value');link(m,aa,'Result',comb,0);link(m,comb,'Color',em,'Color')
   link(m,em,'Emission',out,'Surface');bpy.ops.object.bake(type='EMIT',margin=8)
  im.save();maps[mode]=im
 bpy.data.objects.remove(p,do_unlink=True);bpy.data.materials.remove(m)
 m=bpy.data.materials.new(name);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF')
 n=node(m,'ShaderNodeTexImage');n.image=maps['Base'];link(m,n,'Color',bs,'Base Color')
 n=node(m,'ShaderNodeTexImage');n.image=maps['Normal'];nn=node(m,'ShaderNodeNormalMap');link(m,n,'Color',nn,'Color');link(m,nn,'Normal',bs,'Normal')
 n=node(m,'ShaderNodeTexImage');n.image=maps['ORM'];sep=node(m,'ShaderNodeSeparateColor');link(m,n,'Color',sep,'Color');link(m,sep,'Green',bs,'Roughness');link(m,sep,'Blue',bs,'Metallic')
 # glTF's occlusion socket convention
 group=bpy.data.node_groups.get('glTF Material Output')
 if not group:
  group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=node(m,'ShaderNodeGroup');gn.node_tree=group;link(m,sep,'Red',gn,'Occlusion')
 M[name]=m
 print('BAKED',name,flush=True)
S.cycles.samples=24

# Geometry created in Z-up: front is -Y, tank left +X. Mesh origins are set without applying moving-node transforms.
def matobj(o,mat):o.data.materials.append(M[mat]);return o
def apply(o):
 bpy.context.view_layer.objects.active=o;o.select_set(True)
 for mod in list(o.modifiers):
  before=len(o.data.polygons) if o.type=='MESH' else 0;edgewear=mod.type=='BEVEL' and any(m and m.name=='Paint' for m in o.data.materials)
  bpy.ops.object.modifier_apply(modifier=mod.name)
  if edgewear:
   indices=[i for i,m in enumerate(o.data.materials) if m==M['Scars']]
   if not indices:o.data.materials.append(M['Scars']);indices=[len(o.data.materials)-1]
   for poly in o.data.polygons:
    if poly.index>=before and poly.area<.008 and max(abs(v) for v in poly.normal)<.93 and random.random()<.24:poly.material_index=indices[0]

 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);o.select_set(False)
 return o
def bevel(o,w=.012,seg=1):
 mod=o.modifiers.new('Machined edge radius','BEVEL');mod.width=w;mod.segments=seg
 return o
def box(loc,dim,mat='Paint',bev=.012):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.dimensions=dim
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if bev:bevel(o,bev,3 if bev>=.05 else 1)
 return matobj(o,mat)
def cyl(loc,r,depth,mat='Paint',axis='Z',verts=32,r2=None):
 if r2 is None:bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=r,depth=depth,location=loc)
 else:bpy.ops.mesh.primitive_cone_add(vertices=verts,radius1=r,radius2=r2,depth=depth,location=loc)
 o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 bevel(o,.005,1);matobj(o,mat)
 for f in o.data.polygons:f.use_smooth=len(f.vertices)==4
 return o
def uvball(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
 for f in o.data.polygons:f.use_smooth=True
 return matobj(o,mat)
def mesh(name,vs,fs,mat='Paint',smooth=False):
 me=bpy.data.meshes.new(name);me.from_pydata(vs,[],fs);me.update();o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);matobj(o,mat)
 for f in me.polygons:f.use_smooth=smooth
 return o
def tube(points,r=.012,mat='Paint',n=8):
 cu=bpy.data.curves.new('formed tube','CURVE');cu.dimensions='3D';cu.resolution_u=1;cu.bevel_depth=r;cu.bevel_resolution=1;cu.resolution_u=1
 sp=cu.splines.new('POLY');sp.points.add(len(points)-1)
 for v,p in zip(sp.points,points):v.co=(*p,1)
 o=bpy.data.objects.new('tube',cu);bpy.context.collection.objects.link(o);o.data.materials.append(M[mat]);bpy.context.view_layer.objects.active=o;o.select_set(True);bpy.ops.object.convert(target='MESH');o.select_set(False);return o
def ring(loc,r,minor=.014,mat='Paint',axis='Z',seg=48):
 bpy.ops.mesh.primitive_torus_add(major_segments=seg,minor_segments=6,location=loc,major_radius=r,minor_radius=minor);o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 for f in o.data.polygons:f.use_smooth=True
 return matobj(o,mat)
def bolt(loc,axis='Z',r=.018,mat='Metal'):return cyl(loc,r,.014,mat,axis,6)
def join(objs,name,pivot=(0,0,0),parent=None):
 objs=[o for o in objs if o]
 bpy.ops.object.select_all(action='DESELECT')
 for o in objs:apply(o)
 for o in objs:o.select_set(True)
 bpy.context.view_layer.objects.active=objs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 # Move mesh to desired pivot while retaining world coordinates.
 wm=o.matrix_world.copy(); inv=Matrix.Translation(Vector(pivot)).inverted()
 o.data.transform(inv@wm);o.matrix_world=Matrix.Translation(Vector(pivot))
 # World-scale planar UVs on each face, preserving existing UVs is not needed except track override below.
 for old_uv in list(o.data.uv_layers):o.data.uv_layers.remove(old_uv)
 uv=o.data.uv_layers.new(name='UVMap');uv.active_render=True
 for f in o.data.polygons:
  no=f.normal;ax=max(range(3),key=lambda i:abs(no[i]));ij=[i for i in range(3) if i!=ax]
  for li in f.loop_indices:
   co=o.data.vertices[o.data.loops[li].vertex_index].co+Vector(pivot);uv.data[li].uv=(co[ij[0]]*.75,co[ij[1]]*.75)
 if parent:wm=o.matrix_world.copy();o.parent=parent;o.matrix_world=wm
 o.select_set(False);return o
def empty(name,pos,parent=None):
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.matrix_world=Matrix.Translation(Vector(pos))
 if parent:o.parent=parent;o.matrix_world=Matrix.Translation(Vector(pos))
 return o
parts=[];wheels=[];hatches=[]
def part(name,objs,parent=None):
 # Surface-volume centroid approximation defines throwable item's independent origin.
 bpy.context.view_layer.update();corners=[o.matrix_world@Vector(v) for o in objs for v in o.bound_box]
 cen=Vector(tuple((min(v[i] for v in corners)+max(v[i] for v in corners))*.5 for i in range(3)))
 o=join(objs,'Part_'+name,cen,parent);parts.append(o);return o
# Low welded hull with genuine sloping armour.
profile=[(-3.225,.65,1.10),(-3.12,1.20,1.35),(-2.24,1.48,1.37),(2.75,1.48,1.37),(3.225,1.15,1.15),(3.1,.43,1.04),(-2.82,.43,1.04)]
vs=[(s*w,y,z) for s in [-1,1] for y,z,w in profile];N=len(profile)
fs=[tuple(range(N-1,-1,-1)),tuple(range(N,2*N))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
hullbits=[bevel(mesh('armoured monocoque',vs,fs),.022),cyl((0,-.3,1.485),1.025,.075,'Metal',verts=64)]
# Weld seams, towing lugs, final-drive housings, suspension arms.
for s in [-1,1]:
 hullbits+=[tube([(s*1.31,-2.34,1.475),(s*1.31,2.7,1.475)],.009,'Metal')]
 for y in [-2.05,-.79,.2,1.19,2.18]:
  hullbits+=[cyl((s*1.075,y,.69),.13,.23,'Metal','X'),tube([(s*1.13,y+.16,.81),(s*1.25,y,.61)],.065,'Metal')]
 for y in [-3.19,3.16]:
  hullbits+=[ring((s*.8,y,.91),.072,.024,'Metal','Y'),box((s*.8,y,.9),(.16,.07,.2))]
 for y in [-2.4,-1.3,0,1.3,2.5]:
  hullbits+=[box((s*1.34,y,1.4),(.12,.17,.12)),bolt((s*1.415,y,1.42),'X')]
# Front spare mounting bosses and driving lamps.
for x in [-.95,.95]:
 for y,z in [(-2.88,1.285),(-2.45,1.44)]:hullbits.append(bolt((x,y,z)))
for x in [-.99,.99]:
 hullbits += [cyl((x,-2.40,1.56),.095,.15,'Metal','Y'),cyl((x,-2.485,1.56),.076,.008,'Lamps','Y')]
hull=join(hullbits,'Hull')
# Cast dome: elliptical rings with asymmetric fore-aft radii, thick closed base.
T=(0,-.28,1.51);v=[];f=[]
levels=[(0,1.13,1.24),(.06,1.20,1.32),(.18,1.205,1.31),(.39,1.14,1.24),(.61,1.01,1.10),(.77,.80,.85),(.86,.49,.56),(.885,.08,.12)]
for j,(z,rx,ry) in enumerate(levels):
 for k in range(64):
  a=2*pi*k/64;v.append((rx*cos(a),T[1]+ry*sin(a)+.075*z,T[2]+z))
for j in range(len(levels)-1):
 for k in range(64):a=j*64+k;b=j*64+(k+1)%64;f.append((a,b,b+64,a+64))
f += [tuple(range(63,-1,-1)),tuple((len(levels)-1)*64+k for k in range(64))]
tbits=[mesh('cast turret',v,f,smooth=True),ring((0,-.28,1.53),1.10,.028,'Metal',seg=64)]
# Cast seam, welded grab rails, lifting eyes.
for s in [-1,1]:
 tbits += [tube([(s*1.07,-.88,1.96),(s*1.18,-.60,1.91),(s*1.21,-.1,1.90),(s*1.18,.12,1.94)],.018),tube([(s*1.1,.3,1.91),(s*1.08,.58,1.94),(s*.87,.83,2.00)],.018)]
 for y in [-.8,.62]:tbits += [ring((s*.84,y,2.11),.046,.015,'Metal','X')]
turret=join(tbits,'Turret',T)
G=(0,-1.44,1.94)
mantle=[uvball((0,-1.42,1.95),(.34,.20,.27),'Canvas',40,20),cyl((0,-1.62,1.94),.205,.18,'Canvas','Y'),ring((0,-1.721,1.94),.16,.014,'Canvas','Y')]
for k in range(14):
 a=2*pi*k/14
 mantle.append(tube([(.29*cos(a),-1.48,1.95+.24*sin(a)),(.24*cos(a),-1.59-.02*sin(a*3),1.95+.20*sin(a)),(.17*cos(a),-1.71,1.94+.17*sin(a))],.009,'Canvas'))
gun=join(mantle,'Gun',G,turret)
# Hollow open-bore barrel as revolved profile; bore cavity continues 0.5m deep.
B=(0,-1.72,1.94)
barprof=[(-1.72,.118),(-1.92,.114),(-4.86,.070),(-4.89,.099),(-5.39,.099),(-5.43,.073),(-5.73,.073),(-5.775,.071),(-5.775,.050),(-5.25,.050)]
v=[];f=[]
for y,r in barprof:
 for k in range(48):a=2*pi*k/48;v.append((r*cos(a),y,1.94+r*sin(a)))
for j in range(len(barprof)-1):
 for k in range(48):a=j*48+k;b=j*48+(k+1)%48;f.append((a,b,b+48,a+48))
barrel=join([mesh('barrel turned steel',v,f,smooth=True),ring((0,-5.41,1.94),.098,.008,'Metal','Y'),ring((0,-4.88,1.94),.099,.006,'Metal','Y')],'Barrel',B,gun)
empty('Muzzle',(0,-5.775,1.94),barrel)
# Wheels: rubber tyres, dished hub, ten recessed windows separated by tapered radial webs.
for s,side in [(1,'L'),(-1,'R')]:
 for wi,y in enumerate([-2.06,-.80,.19,1.18,2.17]):
  z=.555;bits=[]
  for off in [-.115,.115]:
   x=s*(1.32+off);bits += [cyl((x,y,z),.488,.10,'Rubber','X',64),cyl((x+s*.053,y,z),.402,.025,'Paint','X',64),cyl((x+s*.069,y,z),.343,.008,'Interior','X',48),ring((x+s*.073,y,z),.367,.035,'Paint','X',64)]
  x=s*1.52
  # Continuous dished wheel casting pierced by five rounded lightening holes.
  vv=[];ff=[];stations=[(.10,1.58),(.22,1.555),(.35,1.51),(.40,1.505),(.40,1.475),(.35,1.48),(.22,1.525),(.10,1.55)]
  for rr,xx in stations:
   for k in range(64):
    aa=2*pi*k/64;vv.append((s*xx,y+rr*sin(aa),z+rr*cos(aa)))
  for j in range(len(stations)):
   for k in range(64):ff.append((j*64+k,j*64+(k+1)%64,((j+1)%len(stations))*64+(k+1)%64,((j+1)%len(stations))*64+k))
  disc=mesh('perforated dished casting',vv,ff,smooth=True)
  # Recalculate winding before boolean holes on the mirrored side.
  bm=bmesh.new();bm.from_mesh(disc.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(disc.data);bm.free()
  for k in range(5):
   aa=2*pi*k/5;cut=cyl((s*1.54,y+.283*sin(aa),z+.283*cos(aa)),.075,.3,'Interior','X',20);apply(cut)
   bpy.context.view_layer.objects.active=disc;mod=disc.modifiers.new('Rounded cast window','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
  bevel(disc,.006,2);bits.append(disc)
  bits += [cyl((s*1.53,y,z),.145,.105,'Paint','X',32),cyl((s*1.59,y,z),.088,.012,'Metal','X',24)]
  for k in range(6):a=2*pi*k/6;bits += [bolt((s*1.592,y+.112*sin(a),z+.112*cos(a)),'X',.013)]
  wheels.append(join(bits,f'Wheel_{side}{wi+1}',(s*1.32,y,z)))
 for ind,y in [(6,-2.79),(7,2.79)]:
  z=.73;bits=[cyl((s*1.33,y,z),.345,.26,'Metal','X',48),cyl((s*1.51,y,z),.25,.038,'Interior','X',40),ring((s*1.55,y,z),.31,.030,'Paint','X'),cyl((s*1.56,y,z),.105,.065,'Paint','X')]
  for k in range(12):
   a=2*pi*k/12;ob=box((s*1.54,y+.222*sin(a),z+.222*cos(a)),(.045,.055,.19),'Paint',.009);ob.rotation_euler[0]=-a;bits.append(ob)
   if ind==7:
    ob=box((s*1.34,y+.36*sin(a),z+.36*cos(a)),(.30,.095,.10),'Metal',.012);ob.rotation_euler[0]=-a;bits.append(ob)
  wheels.append(join(bits,f'Wheel_{side}{ind}',(s*1.32,y,z)))
# Tread path: five-wheel bottom contact and wrap around front/rear idler.
path=[]
for i in range(48):
 t=i/48;path.append((-2.17+4.34*t,.052))
path.append((2.36,.14));path.append((2.99,.405))
for i in range(26):
 a=-pi/3+(5*pi/6)*i/26;path.append((2.79+.39*cos(a),.73+.39*sin(a)))
for i in range(55):
 t=i/55;path.append((2.79-5.58*t,1.12-.065*sin(pi*t)))
for i in range(26):
 a=pi/2+(5*pi/6)*i/26;path.append((-2.79+.39*cos(a),.73+.39*sin(a)))
path.append((-2.36,.14))
# Resample equal distance link pitch.
lens=[0]
for i in range(len(path)):a=Vector(path[i]);b=Vector(path[(i+1)%len(path)]);lens.append(lens[-1]+(b-a).length)
def sample(t):
 d=(t%1)*lens[-1]
 for i in range(len(path)):
  if lens[i+1]>=d:
   a=Vector(path[i]);b=Vector(path[(i+1)%len(path)]);p=a.lerp(b,(d-lens[i])/(lens[i+1]-lens[i]));q=(b-a).normalized();return p,q
from mathutils.kdtree import KDTree
track_samples=[sample(j/96) for j in range(96)]
track_kd=KDTree(96)
for j,(p,q) in enumerate(track_samples):track_kd.insert((p[0],p[1],0),j)
track_kd.balance()
tracks=[]
for s,side in [(1,'L'),(-1,'R')]:
 bits=[]
 for i in range(96):
  p,q=sample(i/96);y,z=p;ang=math.atan2(q[1],q[0]);x=s*1.321
  ob=box((x,y,z),(.59,.141,.054),'TrackSteel',.008);ob.rotation_euler[0]=ang;bits.append(ob)
  normal=Vector((-q[1],q[0]))
  for off in [-.18,.18]:
   ob=box((x+off,y-normal[0]*.035,z-normal[1]*.035),(.20,.045,.023),'TrackSteel',.005);ob.rotation_euler[0]=ang;bits.append(ob)
  # Center guide horn faces inwards.
  ob=box((x,y+normal[0]*.046,z+normal[1]*.046),(.045,.065,.07),'Metal',.01);ob.rotation_euler[0]=ang;bits.append(ob)
  bits += [cyl((x+s*.30,y,z),.023,.028,'Metal','X',8)]
 tr=join(bits,'Track_'+side)
 uv=tr.data.uv_layers.active
 # V increases monotonically along nearest track-loop station; width is U.
 for f in tr.data.polygons:
  for li in f.loop_indices:
   co=tr.data.vertices[tr.data.loops[li].vertex_index].co
   idx=track_kd.find((co.y,co.z,0))[1]
   sp,sq=track_samples[idx]
   vv=idx/96+(Vector((co.y,co.z))-sp).dot(sq)/lens[-1]
   uv.data[li].uv=((co.x-s*1.321)/.59+.5,vv)
 low=min(v.co.z for v in tr.data.vertices)
 for v in tr.data.vertices:v.co.z-=low
 tracks.append(tr)
# Sectioned fenders, rolled lips and pressed ribs. No skirts on the T-55.
for s,side in [(1,'L'),(-1,'R')]:
 for end,ya,yb in [('F',-2.76,-.05),('R',-.02,2.76)]:
  bits=[box((s*1.38,(ya+yb)/2,1.31),(.51,yb-ya,.052),'Paint',.022),tube([(s*1.617,ya,1.30),(s*1.617,yb,1.30)],.018)]
  # sloping curved mudguard at end
  yz=[(-2.76-.49*sin(i*pi/24),.88+.44*cos(i*pi/24)) for i in range(13)]
  if end=='R':yz=[(-y,z) for y,z in reversed(yz)]
  ys=[p[0] for p in yz];zs=[p[1] for p in yz]
  v=[(s*x,y,z) for x in [1.11,1.635] for y,z in zip(ys,zs)]
  ob=mesh('pressed mudguard',v,[(i,i+1,i+14,i+13) for i in range(12)])
  so=ob.modifiers.new('sheet thickness','SOLIDIFY');so.thickness=.022;bevel(ob,.012);bits.append(ob)
  for x in [1.20,1.48,1.57]:bits.append(tube([(s*x,y,z+.015) for y,z in zip(ys,zs)],.009))
  part('Fender_'+end+side,bits)
 # brackets remain behind removed stowage.
 for y in [-1.65,.1,1.85]:
  bs=[box((s*1.41,y,1.445),(.42,.91,.25),'Paint',.022),box((s*1.41,y,1.59),(.445,.935,.035),'Paint',.01)]
  for yy in [y-.32,y+.32]:bs += [box((s*1.615,yy,1.49),(.024,.055,.12),'Metal',.004),bolt((s*1.617,yy,1.49),'X',.013)]
  part(('FuelCell_R' if s==-1 else 'Stowage_L')+str(round((y+2)*10)),bs)
  # retain mounting shoes on actual hull
  pad=join([box((s*1.27,y,1.34),(.19,.76,.048)),bolt((s*1.31,y,1.38))],'mount')
  hull=join([hull,pad],'Hull')
 for x in [s*.99]:
  part('LampGuard_'+side,[tube([(x-.125,-2.56,1.49),(x-.125,-2.55,1.71),(x+.125,-2.55,1.71),(x+.125,-2.56,1.49)],.012),tube([(x,-2.56,1.49),(x,-2.55,1.71)],.008)])
# Rear fuel drums lie transversely, matching the circular end visible in the side reference.
for i,x in enumerate([-.66,.66]):
 bits=[cyl((x,2.77,1.66),.29,.80,'Paint','X',48)]
 for off in [-.40,-.34,-.14,.14,.34,.40]:bits.append(ring((x+off,2.77,1.66),.29,.012,'Metal' if abs(off)==.14 else 'Paint','X'))
 for off in [-.408,.408]:bits.append(cyl((x+off,2.77,1.66),.263,.015,'Paint','X',48))
 bits += [cyl((x,2.73,1.956),.025,.022,'Metal','Z',12)]
 part('FuelDrum_'+str(i+1),bits)
 hull=join([hull,box((x,2.7,1.385),(.65,.45,.10)),tube([(x-.24,2.46,1.43),(x-.30,2.46,1.66)],.018)],'Hull')
part('UnditchingBeam',[cyl((0,3.115,1.08),.11,2.72,'Wood','X',24),box((-.93,3.09,1.08),(.07,.245,.26),'Metal'),box((.93,3.09,1.08),(.07,.245,.26),'Metal')])
part('Snorkel',[cyl((0,2.38,1.76),.09,2.60,'Paint','X'),ring((-.93,2.38,1.76),.096,.012,'Metal','X'),ring((.93,2.38,1.76),.096,.012,'Metal','X')])
# Radiator shutters and deck panel borders.
for x in [-.64,.64]:
 bits=[box((x,1.61,1.502),(.94,.90,.045),'Metal')]
 for j in range(12):bits.append(box((x,1.22+j*.068,1.533),(.85,.034,.034),'Paint',.006))
 for xx in [x-.44,x+.44]:
  for yy in [1.19,2.04]:bits.append(bolt((xx,yy,1.55)))
 part('EngineGrille_'+('L' if x>0 else 'R'),bits)
part('ExhaustShroud',[box((1.41,2.45,1.48),(.44,.57,.28),'Metal',.025),box((1.625,2.45,1.47),(.020,.41,.13),'Interior',.008),tube([(1.63,2.14,1.58),(1.62,2.14,1.63),(1.62,2.75,1.63),(1.63,2.75,1.58)],.012)])
# Tow cable laid across frontal glacis, loop eyes at the ends.
part('TowCable',[tube([(-.90,-2.83,1.28),(-1.1,-2.64,1.4),(-.98,-2.39,1.5),(-.4,-2.3,1.51),(.35,-2.3,1.51),(.94,-2.45,1.43),(1.02,-2.74,1.32)],.021,'Metal'),ring((-.88,-2.88,1.28),.068,.02,'Metal'),ring((1.0,-2.83,1.28),.068,.02,'Metal')])
part('Tools',[tube([(1.4,-.72,1.66),(1.4,.55,1.66)],.02,'Wood'),box((1.4,.65,1.65),(.23,.29,.042),'Metal',.04),tube([(1.52,-.67,1.66),(1.52,.4,1.66)],.018,'Metal')])
part('SpareLinks',[box((x,-3.08,1.10),(.32,.09,.23),'TrackSteel',.01) for x in [-.5,-.16,.18,.52]])
# Hatches with dark cupped wells, raised coamings and hinge pins.
for name,pos,r,parent in [('Commander',(.49,-.12,2.35),.295,turret),('Loader',(-.47,-.05,2.33),.29,turret),('Driver',(.58,-2.14,1.48),.245,None)]:
 x,y,z=pos
 # True recessed crew hatch aperture with a dark closed well below the hinge.
 base=turret if parent else hull
 cutter=cyl((x,y,z+.08),r*.86,.48,'Interior',verts=48);apply(cutter)
 bpy.context.view_layer.objects.active=base
 mod=base.modifiers.new('Crew hatch opening','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter
 bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cutter,do_unlink=True)
 # visible coaming encloses a dark cup with its floor below the armoured deck.
 well=join([cyl((x,y,z-.16),r*.86,.035,'Interior'),ring((x,y,z),r,.022,'Paint')],name+'_well',pos,parent)
 if parent:turretbits=None
 else:hull=join([hull,well],'Hull')
 if parent:
  # merge well into turret without disturbing pivot or existing children
  children=list(turret.children); mats=[(c,c.matrix_world.copy()) for c in children if c != well]
  turret=join([turret,well],'Turret',T)
  for c,wm in mats:c.parent=turret;c.matrix_world=wm
 bits=[cyl((x,y,z+.025),r,.045,'Paint',verts=48),tube([(x-.07,y,z+.064),(x-.07,y+.045,z+.105),(x+.07,y+.045,z+.105),(x+.07,y,z+.064)],.012),cyl((x,y+.9*r,z+.018),.032,r*1.3,'Metal','X')]
 h=join(bits,'Hatch_'+name,(x,y+.9*r,z+.018),parent);hatches.append(h)
 if name=='Commander':
  part('Turret_Cupola',[ring((x,y,z-.015),r+.055,.035),*[bolt((x+(r+.055)*cos(k*pi/8),y+(r+.055)*sin(k*pi/8),z+.012),r=.012) for k in range(16)]],turret)
# IR lamp beside gun, periscopes and antenna.
part('Turret_IRSearchlight',[cyl((.66,-1.2,2.22),.175,.17,'Paint','Y',48),cyl((.66,-1.291,2.22),.147,.012,'Lamps','Y',48),ring((.66,-1.30,2.22),.165,.014,'Metal','Y'),box((.66,-1.15,2.04),(.12,.10,.16))],turret)
for side,x in [('L',.62),('R',-.62)]:
 part('Turret_Sight_'+side,[box((x,-.55,2.34),(.22,.18,.14),'Paint',.025),box((x,-.651,2.35),(.15,.012,.053),'Lamps',.007)],turret)
part('Turret_Antenna',[cyl((-.82,.40,2.18),.065,.10,'Rubber'),tube([(-.82,.40,2.22),(-.82,.40,3.10)],.006,'Metal')],turret)
# DShK receiver, finned barrel, gas tube, spade grips, ammo box, articulated pintle.
mgp=(.50,-.1,2.40)
bits=[cyl(mgp,.037,.22,'Metal'),box((.5,-.22,2.58),(.15,.42,.14),'Metal',.012),cyl((.5,-.74,2.60),.028,.65,'Metal','Y'),cyl((.5,-1.105,2.60),.043,.10,'Metal','Y'),cyl((.5,-.70,2.535),.018,.44,'Metal','Y'),box((.34,-.22,2.54),(.19,.23,.22),'Metal'),tube([(.43,-.06,2.58),(.40,.13,2.61),(.4,.19,2.54)],.015,'Metal'),tube([(.57,-.06,2.58),(.60,.13,2.61),(.6,.19,2.54)],.015,'Metal'),ring((.5,-.92,2.67),.036,.008,'Metal','Y')]
for y in [-.46-i*.028 for i in range(15)]:bits.append(ring((.5,y,2.60),.029,.006,'Metal','Y',24))
mg=join(bits,'MG',mgp,turret)
for name,pos,par in [('Seat_Commander',(.49,-.12,2.45),turret),('Seat_Driver',(.58,-2.14,1.68),None),('Exhaust',(1.64,2.45,1.47),None),('Light_Head_L',(.99,-2.49,1.56),None),('Light_Head_R',(-.99,-2.49,1.56),None),('Fx_Engine',(0,1.6,1.55),None),('Fx_Ammo',(0,0,.85),None),('Fx_Hatch',(.49,-.12,2.42),turret)]:empty(name,pos,par)
# Sort wheel nodes front to rear (idler is foremost, then 5 roadwheels, then sprocket).
for side in ['L','R']:
 ww=sorted([o for o in wheels if o.name.startswith('Wheel_'+side)],key=lambda o:o.location.y)
 for i,o in enumerate(ww):o.name='tmp_'+side+str(i)
 for i,o in enumerate(ww):o.name='Wheel_'+side+str(i+1)
# Spend geometry on silhouettes; simplify hidden bevel transitions on wheel/tread assemblies.
for o in wheels+tracks:
 bpy.context.view_layer.objects.active=o
 mod=o.modifiers.new('Game geometry budget','DECIMATE');mod.ratio=.60 if o in wheels else .72
 bpy.ops.object.modifier_apply(modifier=mod.name)
# Enforce the healthy game budget after boolean windows and bevels have resolved.
def mesh_tri(o):return sum(len(p.vertices)-2 for p in o.data.polygons) if o.type=='MESH' else 0
budget_total=sum(mesh_tri(o) for o in bpy.context.scene.objects)
if budget_total>138000:
 eligible=wheels+tracks;subtotal=sum(mesh_tri(o) for o in eligible);ratio=max(.1,(136000-(budget_total-subtotal))/subtotal)
 for o in eligible:
  bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Final measured triangle budget','DECIMATE');mod.ratio=ratio;bpy.ops.object.modifier_apply(modifier=mod.name)
for track in tracks:
 floor=min(v.co.z for v in track.data.vertices)
 for v in track.data.vertices:v.co.z-=floor
healthy=list(bpy.context.scene.objects)
# Ensure parent matrices evaluated before geometry copies.
bpy.context.view_layer.update()
def tri_count(objs):return sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in objs if o.type=='MESH')
print('HEALTHY_TRIANGLES',tri_count(healthy),flush=True)
# Export supports external Paint factor, patched at serialization for strict glTF compliance.
PAINTS=[('Olive drab',[.11,.14,.08]),('NATO green',[.075,.115,.068]),('Desert tan',[.48,.36,.21]),('Field grey',[.18,.21,.18]),('Winter white',[.73,.76,.72]),('Dark green-black',[.028,.043,.030])]
def patch_glb(path):
 b=Path(path).read_bytes();n,typ=struct.unpack_from('<II',b,12);j=json.loads(b[20:20+n]);tail=b[20+n:]
 for m in j.get('materials',[]):
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
 raw=json.dumps(j,separators=(',',':')).encode();raw+=b' '*((-len(raw))%4)
 Path(path).write_bytes(struct.pack('<4sII',b'glTF',2,20+len(raw)+len(tail))+struct.pack('<II',len(raw),0x4E4F534A)+raw+tail)
 return j
def export(objs,path):
 # Remove collapsed bevel triangles without changing any articulated object transform.
 for ob in objs:
  if ob.type!='MESH':continue
  bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
  dead=[f for f in bm.faces if f.calc_area()<1e-12]
  if dead:bmesh.ops.delete(bm,geom=dead,context='FACES')
  if ob.name in ['Track_L','Track_R']:
   used={v for f in bm.faces for v in f.verts};floor=min(v.co.z for v in used)
   for v in bm.verts:v.co.z-=floor
  bm.to_mesh(ob.data);bm.free();ob.data.update()
 bpy.ops.object.select_all(action='DESELECT')
 for o in objs:o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(ROOT/path),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='JPEG',export_jpeg_quality=90,export_cameras=False,export_lights=False,export_extras=True)
 return patch_glb(ROOT/path)
ja=export(healthy,'t55-tank.glb')
assert 80000<=tri_count(healthy)<=140000,tri_count(healthy)
print('HEALTHY_EXPORTED',flush=True)
# Damage twins retain exactly the healthy part origins and the same local frame.
def copy_obj(o,name=None):
 n=o.copy();n.data=o.data.copy() if o.data else None;bpy.context.collection.objects.link(n);n.parent=None;n.matrix_world=o.matrix_world.copy()
 if name:n.name=name
 return n
def parent_world(o,p):
 bpy.context.view_layer.update();wm=o.matrix_world.copy();o.parent=p;o.matrix_world=wm;bpy.context.view_layer.update()
HD=empty('Hull_Damage',(0,0,0));TD=empty('Turret_Damage',T)
damage=[HD,TD];twins={}
for p in parts:
 if any(k in p.name for k in ['Fender','Stowage','FuelCell','FuelDrum','EngineGrille','ExhaustShroud','Turret_IR','Turret_Sight','Turret_Cupola']):
  d=copy_obj(p,p.name.replace('Part_','Dmg_'));vlist=d.data.vertices
  # Bend sheet extremities, crush barrels and wrench mounted fixtures from their seats.
  extent=max((v.co.length for v in vlist),default=1)
  for v in vlist:
   x,y,z=v.co;fall=max(0,(abs(y)/max(.01,extent)-.15))
   if 'Fender' in p.name:v.co.z+=.34*fall*sin(y*2.1);v.co.x+=.13*fall
   elif 'FuelDrum' in p.name:v.co.x+=.085*sin(y*10+z*8);v.co.z-=.075*(1+sin(y*8))
   elif 'Grille' in p.name:v.co.z+=.16*max(0,sin(x*4+y*5))
   else:v.co.z+=.09*sin(y*7+x*4);v.co.x+=.055*sin(z*12+y*5)
  # torn black/steel section carried on twin itself
  for poly in d.data.polygons:
   if (poly.center.y>0 and poly.center.x<.15) or random.random()<.035:
    if M['Burnt'].name not in [m.name for m in d.data.materials]:d.data.materials.append(M['Burnt'])
    poly.material_index=len(d.data.materials)-1
  if not p.name.startswith('Part_Turret') and any(k in p.name for k in ['Fender','Stowage','FuelCell','FuelDrum']):
   # Ragged through-tear cut into the actual replaceable panel, not a painted decal.
   bounds=[(min(v.co[i] for v in d.data.vertices)-.005,max(v.co[i] for v in d.data.vertices)+.005) for i in range(3)]
   pc=d.matrix_world.translation;cv=[]
   for zz in [-.65,.65]:
    for k in range(10):
     aa=k*2*pi/10;rr=.105*(1+.24*sin(k*13.3));cv.append((pc.x+.06+rr*cos(aa),pc.y+.12+rr*1.6*sin(aa),pc.z+zz))
   cf=[tuple(range(9,-1,-1)),tuple(range(10,20))]+[(k,(k+1)%10,(k+1)%10+10,k+10) for k in range(10)]
   cut=mesh('ragged tear tool',cv,cf,'Burnt');apply(cut)
   bpy.context.view_layer.objects.active=d;mod=d.modifiers.new('Torn equipment shell','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cut
   bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
   # Boolean against overlapping equipment shells can retain cutter walls: remove anything outside the original deformed shell.
   bm=bmesh.new();bm.from_mesh(d.data);escaped=[v for v in bm.verts if any(v.co[i]<bounds[i][0] or v.co[i]>bounds[i][1] for i in range(3))]
   if escaped:bmesh.ops.delete(bm,geom=escaped,context='VERTS')
   bm.to_mesh(d.data);bm.free()
  parent_world(d,TD if p.name.startswith('Part_Turret') else HD);damage.append(d);twins[p.name]=d.name
# Low-profile impact dishes on actual armour planes, including a raised petalled penetration lip.
def impact(center,normal,r=.082,penetration=False,project=None):
 n=Vector(normal).normalized();q=Vector((0,0,1)).rotation_difference(n);v=[];f=[]
 # flat outer soot rim, scraped silver rim, dark inward-facing crater floor
 radii=[r*1.5,r*.95,r*.70,r*.44,0.001];heights=[.0023,.003,.004,.003,.003]
 phase=random.random()*6.28
 for j,rr in enumerate(radii):
  for k in range(20):
   a=k*2*pi/20;rr2=rr*(1+.30*sin(k*7.13+phase)+.15*cos(k*2.71+phase+j*.8));h=heights[j]
   point=Vector(center)+q@Vector((rr2*cos(a),rr2*sin(a),0))
   if project:
    point,pn=project(point);point+=pn*h
   else:point+=n*h
   v.append(tuple(point))
 for j in range(4):
  for k in range(20):f.append((j*20+k,j*20+(k+1)%20,(j+1)*20+(k+1)%20,(j+1)*20+k))
 o=mesh('impact',v,f,'Scars');o.data.materials.append(M['Burnt'])
 for p in o.data.polygons:p.material_index=0 if p.index//20==2 and (p.index%20)%5 in [0,1] else 1
 # ricochet furrow raised only millimetres from plate
 a=Vector(center)+q@Vector((r*.5,.01,.004));b=Vector(center)+q@Vector((r*(1.3+random.random()),.025,.004))
 return [o,tube([a,b],.004,'Scars')]
scar_names=[]
for face in ['Front','L','R','Rear']:
 bits=[]
 for i in range(4):
  if face=='Front':
   x=-.7+i*.43;y=-2.70+.05*sin(i);z=1.20+(y+3.12)*(.28/.88);c=(x,y,z);normal=(0,-.304,.953)
  elif face=='Rear':
   zz=.72+.10*sin(i);c=(-.72+i*.46,3.1+(zz-.43)/.72*.125,zz);normal=(0,1,-.174)
  else:
   s=1 if face=='L' else -1;c=(s*(1.04+(1.20-.43)/1.05*.33),-1.8+i*1.1,1.20);normal=(s,0,-.314)
  bits+=impact(c,normal,.065 if i else .09,i==0)
 o=join(bits,'Scar_Hull_'+face);parent_world(o,HD);damage.append(o);scar_names.append(o.name)
def turret_surface(point):
 z=point.z-T[2]
 for j in range(len(levels)-1):
  a,rx,ry=levels[j];b,rx2,ry2=levels[j+1]
  if a<=z<=b:
   t=(z-a)/(b-a);rx=rx+(rx2-rx)*t;ry=ry+(ry2-ry)*t
   ang=math.atan2((point.y-T[1]-.075*z)/ry,point.x/rx)
   p=Vector((rx*cos(ang),T[1]+ry*sin(ang)+.075*z,point.z));n=Vector((cos(ang),sin(ang),.4)).normalized();return p,n
 return point,Vector((1,0,0))
for face in ['Front','L','R','Rear']:
 bits=[]
 for i in range(4):
  angle={'Front':-pi/2,'Rear':pi/2,'L':0,'R':pi}[face]+(i-1.5)*.17
  z=1.88+.07*(i%2);rx=1.16 if i%2==0 else 1.13;ry=1.26 if i%2==0 else 1.23
  c=(rx*cos(angle),T[1]+ry*sin(angle)+.075*(z-T[2]),z);normal=(cos(angle),sin(angle),.40)
  bits+=impact(c,normal,.066,i==0,turret_surface)
 o=join(bits,'Scar_Turret_'+face,T);parent_world(o,TD);damage.append(o);scar_names.append(o.name)
broken=[]
for tr in tracks:
 d=copy_obj(tr,tr.name+'_Broken');side=1 if tr.name=='Track_L' else -1
 # Rigid connected islands prevent individual vertices from stretching between path stations.
 bm=bmesh.new();bm.from_mesh(d.data);todo=set(bm.verts)
 while todo:
  seed=todo.pop();island={seed};stack=[seed]
  while stack:
   v=stack.pop()
   for edge in v.link_edges:
    other=edge.other_vert(v)
    if other in todo:todo.remove(other);island.add(other);stack.append(other)
  center=sum((v.co for v in island),Vector())/len(island);p,q=track_samples[track_kd.find((center.y,center.z,0))[1]];y,z=p
  if z>.93 or (y<-2.7 and z>.2):
   angle=math.atan2(q[1],q[0]);rot=Matrix.Rotation(-angle,3,'X');dest=Vector((center.x+side*.56,center.y-.5,.065))
   if y<-2.7:dest.y=-3.15-(z-.2)*1.3
   for v in island:v.co=dest+rot@(v.co-center)
 bm.to_mesh(d.data);bm.free()
 low=min(v.co.z for v in d.data.vertices)
 for v in d.data.vertices:v.co.z-=low
 mod=d.modifiers.new('Broken tread game mesh','DECIMATE');mod.ratio=.65;apply(d)
 parent_world(d,HD);damage.append(d);broken.append(d)
# Destroyed gun uses the actual mantlet and tube, bent at the trunnion with a ripped muzzle.
bits=[copy_obj(gun),copy_obj(barrel)]
rot=Matrix.Translation(Vector(G))@Matrix.Rotation(math.radians(16),4,'X')@Matrix.Translation(-Vector(G))
for o in bits:o.matrix_world=rot@o.matrix_world
bg=join(bits,'Gun_Broken',G)
for v in bg.data.vertices:
 if v.co.y<-2.15:
  v.co.x+=.12*(v.co.y+2.15)**2;v.co.z-=.08*abs(v.co.y+2.15)
parent_world(bg,TD);damage.append(bg)
# Wreck copies originate from the healthy assemblies. Decimation only affects these static wreck meshes.
whbits=[];wtbits=[]
turret_set={turret,gun,barrel,mg,*[o for o in healthy if o.parent in [turret,gun,barrel]]}
for o in healthy:
 if o.type!='MESH' or o in tracks:continue
 ist=o in turret_set
 if o.name.startswith('Part_') and (sum(o.name.encode())%5 in [0,1,2]):continue
 if o.name.startswith('Part_EngineGrille'):continue
 c=copy_obj(o)
 if o.name.startswith('Hatch_'):
  pivot=o.matrix_world.translation;c.matrix_world=Matrix.Translation(pivot)@Matrix.Rotation(math.radians(-72),4,'X')@Matrix.Translation(-pivot)@c.matrix_world
 if o in [gun,barrel]:c.matrix_world=rot@c.matrix_world
 if o in wheels:
  c.location.z-=.05
  # Remove tyre geometry entirely: burnt wheel exposes the bare metal flange.
  bm=bmesh.new();bm.from_mesh(c.data);dead=[f for f in bm.faces if c.data.materials[f.material_index].name=='Rubber'];bmesh.ops.delete(bm,geom=dead,context='FACES');bm.to_mesh(c.data);bm.free()
 if not ist and not o.name.startswith('Hatch_'):c.location.z-=.015
 c.data.materials.clear();c.data.materials.append(M['Burnt'])
 for p in c.data.polygons:p.material_index=0
 (wtbits if ist else whbits).append(c)
for tr in [broken[0],tracks[1]]:
 c=copy_obj(tr);c.data.materials.clear();c.data.materials.append(M['Burnt']);
 for p in c.data.polygons:p.material_index=0
 whbits.append(c)
# Ruptured engine deck plates and dark exposed compartment.
for s in [-1,1]:
 o=box((s*.45,1.65,1.64),(.88,.95,.045),'Burnt',.018);o.rotation_euler[1]=s*.4;whbits.append(o)
whbits.append(cyl((0,-.28,1.13),.96,.09,'Burnt',verts=64))
wh=join(whbits,'Wreck_Hull')
# Cut ring well right through deck geometry, leaving a bottom and thick rim.
cut=cyl((0,-.28,1.69),.98,.96,'Interior',verts=64);apply(cut)
bpy.context.view_layer.objects.active=wh
mod=wh.modifiers.new('Open turret well','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cut
bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
wtbits += [cyl((0,-.28,1.475),1.05,.085,'Burnt',verts=64),cyl((0,-.28,1.27),.62,.37,'Burnt',verts=48),ring((0,-.28,1.09),.61,.045,'Burnt')]
wt=join(wtbits,'Wreck_Turret',T)
for o in [wh,wt]:
 mod=o.modifiers.new('Wreck LOD','DECIMATE');mod.ratio=.36;apply(o)
damage += [wh,wt]
# Damage only allows these five materials. Convert incidental Metal/Canvas/etc to burnt metal.
allowed={'Paint','Burnt','Scars','TrackSteel','Rubber'}
for o in damage:
 if o.type=='MESH':
  for i,m in enumerate(o.data.materials):
   if m.name not in allowed:o.data.materials[i]=M['Burnt']
jb=export(damage,'t55-tank-damage.glb')
print('DAMAGE_EXPORTED',tri_count(damage),flush=True)
# Measure the serialized scene graph in the target Y-up coordinate frame.
def node_manifest(objects):
 bpy.context.view_layer.update();result=[]
 for o in objects:
  p=o.matrix_world.translation;result.append({'name':o.name,'world_position':[round(p.x,5),round(p.z,5),round(-p.y,5)],'parent':o.parent.name if o.parent else None})
 return result
coords=[o.matrix_world@Vector(v) for o in healthy if o.type=='MESH' for v in o.bound_box]
bb={'min':[min(v.x for v in coords),min(v.z for v in coords),min(-v.y for v in coords)],'max':[max(v.x for v in coords),max(v.z for v in coords),max(-v.y for v in coords)]}
meta={'units':'metres','axes':{'up':'+Y','forward':'+Z','left':'+X'},'contract':'tank-damage/v1','bounds':bb,'nodes':node_manifest(healthy),'damage_nodes':node_manifest(damage),'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'triangles':{'healthy':tri_count(healthy),'damage':tri_count(damage),'wreck':tri_count([wh,wt])},'damage':{'parts':[{'part':p.name,'twin':twins.get(p.name)} for p in parts],'scars':scar_names,'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']}}
healthy_positions={n['name']:Vector(n['world_position']) for n in meta['nodes']}
damage_positions={n['name']:Vector(n['world_position']) for n in meta['damage_nodes']}
for pp in meta['damage']['parts']:
 if pp['twin']:assert (healthy_positions[pp['part']]-damage_positions[pp['twin']]).length<.0001,pp
assert (healthy_positions['Muzzle']-Vector((0,1.94,5.775))).length<.0001
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
# Verify and render solely re-imported GLBs from an empty scene.
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for material in list(bpy.data.materials):bpy.data.materials.remove(material)
for ng in list(bpy.data.node_groups):
 if ng.name.startswith('glTF Material Output'):bpy.data.node_groups.remove(ng)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'t55-tank.glb'));A=list(S.objects)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'t55-tank-damage.glb'));D=[o for o in S.objects if o not in A]
# Blender may suffix the second Paint material; imports faithfully carry the same factor.
def hide_group(objs,hide):
 for o in objs:o.hide_render=hide
hide_group(D,True)
# studio ground, sky and large soft daylight sources
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.032));ground=bpy.context.object;ground.name='Studio_Ground'
mat=bpy.data.materials.new('Studio');mat.diffuse_color=(.19,.205,.215,1);ground.data.materials.append(mat)
world=S.world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.75,.78,.78,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
for name,loc,power,size in [('Daylight',(-4,-6,9),2100,7),('Fill',(5,-1,6),850,6),('Rim',(0,6,7),2400,5)]:
 data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;o=bpy.data.objects.new(name,data);S.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
camdata=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camdata);S.collection.objects.link(cam);S.camera=cam
cam.location=(9,-12,5.9);target=Vector((0,-.65,1.1));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camdata.type='ORTHO';camdata.ortho_scale=10.8;camdata.lens=48
S.view_settings.view_transform='AgX';S.render.resolution_percentage=100;S.render.film_transparent=False
S.render.image_settings.color_mode='RGB'
def render(name,w=1920,h=1080,samples=24):
 S.render.resolution_x=w;S.render.resolution_y=h;S.cycles.samples=samples;S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
render('final.png')
cam.location=(10,0,3);cam.rotation_euler=(Vector((0,-.85,1.2))-cam.location).to_track_quat('-Z','Y').to_euler()
# exact orthographic side along X
cam.location=(12,-.85,1.3);cam.rotation_euler=(Vector((0,-.85,1.3))-cam.location).to_track_quat('-Z','Y').to_euler();camdata.ortho_scale=10.8
render('side.png',1920,1080)
cam.location=(9,-12,5.9);cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()
clay=bpy.data.materials.new('Clay');clay.diffuse_color=(.32,.32,.32,1);clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
S.view_layers[0].material_override=clay;render('clay.png');S.view_layers[0].material_override=None
# Exercise every articulated node at 30 degrees, retain a visual evidence render and reset.
moving=[o for o in A if o.name in ['Turret','Gun','Barrel','MG'] or o.name.startswith(('Wheel_','Hatch_'))]
old={o:o.matrix_basis.copy() for o in moving}
for o in moving:
 o.rotation_mode='XYZ'
 if o.name=='Barrel':o.location.y+=.13
 else:o.rotation_euler[2 if o.name in ['Turret','MG'] else 0]+=(-pi/6 if o.name.startswith('Hatch_') or o.name=='Gun' else pi/6)
render('pivots.png',1280,720,16)
for o,m in old.items():o.matrix_basis=m
# Contact-sheet composition entirely in Blender images (no external renderer).
def sheet(paths,out):
 images=[bpy.data.images.load(str(ROOT/p),check_existing=False) for p in paths];w,h=images[0].size
 import numpy as np
 arr=np.zeros((h*2,w*3,4),dtype=np.float32)
 for i,im in enumerate(images):
  a=np.array(im.pixels[:],dtype=np.float32).reshape(h,w,4);row=1-i//3;col=i%3;arr[row*h:(row+1)*h,col*w:(col+1)*w]=a
 im=bpy.data.images.new(out,width=w*3,height=h*2);im.pixels.foreach_set(arr.ravel());im.filepath_raw=str(ROOT/out);im.file_format='PNG';im.save()
 for im in images:bpy.data.images.remove(im)
# Imported glTF uses a base texture multiply for paint: update the factor socket discovered from links.
pm=[m for m in bpy.data.materials if m.name.startswith('Paint') and m.users]
def set_paint(col):
 for m in pm:
  bs=m.node_tree.nodes.get('Principled BSDF')
  if not bs:continue
  sock=bs.inputs['Base Color']
  if sock.is_linked:
   n=sock.links[0].from_node
   if n.type=='MIX':
    for inp in n.inputs:
     if inp.type=='RGBA' and not inp.is_linked:inp.default_value=(*col,1)
   elif n.type=='MIX_RGB':
    for inp in n.inputs[1:]:
     if not inp.is_linked:inp.default_value=(*col,1)
  else:sock.default_value=(*col,1)
paths=[]
for i,(name,col) in enumerate(PAINTS):
 set_paint(col);p=f'_paint_{i}.png';render(p,640,360,12);paths.append(p)
sheet(paths,'paints.png');set_paint(PAINTS[0][1])
An={o.name:o for o in A};Dn={o.name:o for o in D};statepaths=[]
def reset():hide_group(A,False);hide_group(D,True)
def show(name):
 if name in Dn:Dn[name].hide_render=False
for state in range(6):
 reset()
 if state==1:
  for n in scar_names:show(n)
  chosen=list(twins)[:6]
  for p in chosen:An[p].hide_render=True;show(twins[p])
 if state==2:
  for o in A:
   if o.name.startswith('Part_'):o.hide_render=True
 if state==3:
  An['Track_L'].hide_render=True;show('Track_L_Broken');An['Gun'].hide_render=True;An['Barrel'].hide_render=True;show('Gun_Broken')
 if state>=4:
  hide_group(A,True);show('Wreck_Hull');show('Wreck_Turret')
 if state==5:
  w=Dn['Wreck_Turret'];wm=w.matrix_world.copy();w.rotation_mode='XYZ';w.location=(3.25,.4,.90);w.rotation_euler=(.30,2.50,.4);bpy.context.view_layer.update();w.location.z-=min((w.matrix_world@Vector(c)).z for c in w.bound_box)
 p=f'_state_{state}.png';render(p,640,360,16);statepaths.append(p)
 if state==5:w.matrix_world=wm
sheet(statepaths,'states.png')
reset()
# Keep user-facing directory concise: intermediate frame tiles are disposable.
for p in paths+statepaths:(ROOT/p).unlink(missing_ok=True)
(ROOT/'NOTES.md').write_text('\n'.join([
'T-55-style Cold War tank, independently pivoted rig and removable equipment; matching tank-damage/v1 damage kit.',
f"Triangles: healthy {meta['triangles']['healthy']:,}; damage {meta['triangles']['damage']:,}; wreck {meta['triangles']['wreck']:,}.",
'Materials: Paint, Rubber, TrackSteel, Metal, Canvas, Wood, Lamps, Interior, Scars; damage uses Paint, Burnt, Scars, TrackSteel, Rubber.',
'Textures: Cycles-baked shared tiles; Paint base 1024; all other base/normal/ORM maps 512; ORM = AO/roughness/metallic.',
'Poly Haven CC0 IDs: '+', '.join(IDS)+'; https://polyhaven.com/license ; source maps https://polyhaven.com/a/<id>.',
'Reference differences: simplified cast-surface variation and battle weathering; five road wheels follow the written brief; MG and antenna exceed the 2.40 m turret envelope.'])+'\n')
print('DONE',json.dumps(meta['triangles']),flush=True)
