"""Reproducible Type 97 Chi-Ha game asset. Blender 5.2; -- --preflight for device probe.
Authoring frame X left, -Y forward, Z up; glTF exporter converts to X left/Y up/Z forward.
Photo sources: Poly Haven CC0 green_metal_rust and rusty_metal_02.
"""
import bpy, bmesh, math, os, json, random, urllib.request, subprocess, sys, struct, hashlib
import numpy as np
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(97)
print("BUILD_VERSION_6_2",flush=True)
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=(.35,.35,.35)
S.view_settings.view_transform='AgX'
S.view_settings.look='AgX - Medium High Contrast'
S.render.bake.margin=8
M={}; groups={}; healthy=[]; damage=[]

def active(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def download(id,mp):
 p=TEX/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'
  subprocess.run(['curl','-fL','--retry','2','-A','ForgeAssetAuthor/1.0',url,'-o',str(p)],check=True)
 return p

def material(name,colour,metal,rough,photo='green_metal_rust',res=512):
 mat=bpy.data.materials.new(name); mat.use_nodes=True
 nt=mat.node_tree; n=nt.nodes; l=nt.links; n.clear()
 out=n.new('ShaderNodeOutputMaterial'); p=n.new('ShaderNodeBsdfPrincipled'); l.new(p.outputs['BSDF'],out.inputs['Surface'])
 uv=n.new('ShaderNodeTexCoord')
 imgs={}
 for mp in ['diff','nor_gl','arm']:
  im=bpy.data.images.load(str(download(photo,mp)),check_existing=True)
  if mp!='diff': im.colorspace_settings.name='Non-Color'
  node=n.new('ShaderNodeTexImage'); node.image=im; l.new(uv.outputs['UV'],node.inputs['Vector']); imgs[mp]=node
 bw=n.new('ShaderNodeRGBToBW'); l.new(imgs['diff'].outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB')
 if name=='Paint': a,b=(.16,.16,.16,1),(.96,.96,.96,1)
 else: a=tuple(c*.36 for c in colour)+(1,); b=tuple(c*1.28 for c in colour)+(1,)
 ramp.color_ramp.elements[0].position=.014 if name=='Paint' else .08; ramp.color_ramp.elements[0].color=a
 ramp.color_ramp.elements[1].position=.12 if name=='Paint' else .64; ramp.color_ramp.elements[1].color=b
 l.new(bw.outputs[0],ramp.inputs[0])
 mapping=n.new('ShaderNodeVectorMath'); mapping.operation='MULTIPLY'; mapping.inputs[1].default_value=(7,6,1); l.new(uv.outputs['UV'],mapping.inputs[0])
 noise=n.new('ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=2; noise.inputs['Detail'].default_value=3; l.new(mapping.outputs[0],noise.inputs['Vector'])
 dirt=n.new('ShaderNodeValToRGB'); dirt.color_ramp.elements[0].position=.28; dirt.color_ramp.elements[0].color=(.30,.30,.30,1); dirt.color_ramp.elements[1].position=.58; dirt.color_ramp.elements[1].color=(1,1,1,1); l.new(noise.outputs['Fac'],dirt.inputs[0])
 mix=n.new('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=.20 if name=='Paint' else .15; l.new(ramp.outputs['Color'],mix.inputs[1]); l.new(dirt.outputs[0],mix.inputs[2]); l.new(mix.outputs[0],p.inputs['Base Color'])
 normal=n.new('ShaderNodeNormalMap'); normal.inputs['Strength'].default_value=.24 if name=='Paint' else .12; l.new(imgs['nor_gl'].outputs[0],normal.inputs['Color'])
 bump=n.new('ShaderNodeBump'); bump.inputs['Strength'].default_value=.06; bump.inputs['Distance'].default_value=.001; l.new(noise.outputs['Fac'],bump.inputs['Height']); l.new(normal.outputs['Normal'],bump.inputs['Normal']); l.new(bump.outputs[0],p.inputs['Normal'])
 sep=n.new('ShaderNodeSeparateColor'); l.new(imgs['arm'].outputs[0],sep.inputs[0])
 rm=n.new('ShaderNodeMapRange'); rm.inputs['To Min'].default_value=rough-.10; rm.inputs['To Max'].default_value=min(1,rough+.10); l.new(sep.outputs[1],rm.inputs[0]); l.new(rm.outputs[0],p.inputs['Roughness']); p.inputs['Metallic'].default_value=metal
 # Bake the complete photo/procedural material on a reusable tiling coupon.
 bpy.ops.mesh.primitive_plane_add(size=1); plane=bpy.context.object; plane.data.materials.append(mat); active(plane)
 emit=n.new('ShaderNodeEmission'); combine=n.new('ShaderNodeCombineColor'); combine.inputs[0].default_value=1; combine.inputs[2].default_value=metal; l.new(rm.outputs[0],combine.inputs[1])
 baked={}
 for channel,size in [('base',res),('normal',res),('orm',512)]:
  target=bpy.data.images.new(name+'_'+channel,width=size,height=size,alpha=False)
  if channel!='base': target.colorspace_settings.name='Non-Color'
  tn=n.new('ShaderNodeTexImage'); tn.image=target; n.active=tn
  if channel=='normal': l.new(p.outputs[0],out.inputs[0]); typ='NORMAL'
  else:
   l.new((mix if channel=='base' else combine).outputs[0],emit.inputs[0]); l.new(emit.outputs[0],out.inputs[0]); typ='EMIT'
  S.cycles.samples=8; bpy.ops.object.bake(type=typ)
  target.filepath_raw=str(TEX/(name+'_'+channel+'.png')); target.file_format='PNG'; target.save(); baked[channel]=target
 bpy.data.objects.remove(plane,do_unlink=True)
 n.clear(); out=n.new('ShaderNodeOutputMaterial'); p=n.new('ShaderNodeBsdfPrincipled'); l.new(p.outputs[0],out.inputs[0])
 for channel in ['base','normal','orm']:
  node=n.new('ShaderNodeTexImage'); node.image=baked[channel]
  if channel=='base':
   if name=='Paint':
    mix=n.new('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=1; mix.inputs[2].default_value=(.24,.21,.11,1); l.new(node.outputs[0],mix.inputs[1]); l.new(mix.outputs[0],p.inputs['Base Color']); mix.name='PaintFactor'
   else: l.new(node.outputs[0],p.inputs['Base Color'])
  elif channel=='normal':
   norm=n.new('ShaderNodeNormalMap'); l.new(node.outputs[0],norm.inputs['Color']); l.new(norm.outputs[0],p.inputs['Normal'])
  else:
   sp=n.new('ShaderNodeSeparateColor'); l.new(node.outputs[0],sp.inputs[0]); l.new(sp.outputs[1],p.inputs['Roughness']); l.new(sp.outputs[2],p.inputs['Metallic'])
   g=bpy.data.node_groups.get('glTF Material Output')
   if not g:
    g=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); g.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   gn=n.new('ShaderNodeGroup'); gn.node_tree=g; l.new(sp.outputs[0],gn.inputs['Occlusion'])
 M[name]=mat; return mat

class Geo:
 def __init__(self): self.v=[]; self.f=[]; self.mi=[]; self.smooth=[]; self.uv=[]
 def add(self,vs,fs,mat='Paint',smooth=False,uv=None):
  k=len(self.v); self.v.extend([tuple(v) for v in vs]); self.f.extend([tuple(k+i for i in f) for f in fs]); self.mi.extend([list(M).index(mat)]*len(fs)); self.smooth.extend([smooth]*len(fs)); self.uv.extend(uv or [None]*len(fs))
 def box(self,c,d,mat='Paint',bev=.006,rot=None):
  bm=bmesh.new(); bmesh.ops.create_cube(bm,size=1)
  for v in bm.verts: v.co=Vector((v.co.x*d[0],v.co.y*d[1],v.co.z*d[2]))
  if bev: bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(bev,min(d)/3),segments=1,affect='EDGES')
  bm.verts.ensure_lookup_table(); bm.verts.index_update()
  vs=[(rot@v.co if rot else v.co)+Vector(c) for v in bm.verts]; fs=[[v.index for v in f.verts] for f in bm.faces]; self.add(vs,fs,mat); bm.free()
 def lathe(self,c,profile,axis=(0,0,1),mat='Paint',n=32,smooth=True,caps=True):
  w=Vector(axis).normalized(); u=w.cross(Vector((0,1,0)) if abs(w.y)<.9 else Vector((1,0,0))).normalized(); v=w.cross(u)
  vs=[Vector(c)+w*z+r*(cos(2*pi*j/n)*u+sin(2*pi*j/n)*v) for z,r in profile for j in range(n)]
  fs=[]
  for k in range(len(profile)-1):
   for j in range(n): fs.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
  if caps: fs.extend([tuple(reversed(range(n))),tuple((len(profile)-1)*n+j for j in range(n))])
  self.add(vs,fs,mat,smooth)
 def rod(self,a,b,r,mat='Paint',n=10):
  a=Vector(a); b=Vector(b); self.lathe((a+b)/2,[(-(b-a).length/2,r),((b-a).length/2,r)],b-a,mat,n)
 def tube(self,c,r,minor,axis=(0,0,1),mat='Paint',n=40,m=6):
  w=Vector(axis).normalized(); u=w.cross(Vector((0,1,0)) if abs(w.y)<.9 else Vector((1,0,0))).normalized(); v=w.cross(u)
  vs=[Vector(c)+(r+minor*cos(2*pi*k/m))*(u*cos(2*pi*j/n)+v*sin(2*pi*j/n))+w*minor*sin(2*pi*k/m) for j in range(n) for k in range(m)]
  fs=[(j*m+k,((j+1)%n)*m+k,((j+1)%n)*m+(k+1)%m,j*m+(k+1)%m) for j in range(n) for k in range(m)]; self.add(vs,fs,mat,True)
 def rivet(self,c,axis=(0,0,1),r=.012,mat='Paint'):
  self.lathe(c,[(0,r),(.007,r*.9),(.010,r*.45)],axis,mat,6)
 def line_rivets(self,a,b,count,axis=(0,0,1),r=.012):
  for i in range(count): self.rivet(Vector(a).lerp(Vector(b),i/max(1,count-1)),axis,r)
 def finish(self,name,origin=(0,0,0),parent=None):
  me=bpy.data.meshes.new(name); me.from_pydata([Vector(v)-Vector(origin) for v in self.v],[],self.f); me.update()
  for mat in M.values(): me.materials.append(mat)
  uv=me.uv_layers.new(name='UVMap')
  for p,mi,sm,custom in zip(me.polygons,self.mi,self.smooth,self.uv):
   p.material_index=mi; p.use_smooth=sm
   ax=max(range(3),key=lambda k:abs(p.normal[k])); axes=([1,2],[0,2],[0,1])[ax]
   for j,li in enumerate(p.loop_indices):
    co=self.v[me.loops[li].vertex_index]
    uv.data[li].uv=custom[j] if custom else (co[axes[0]]/.72,co[axes[1]]/.72)
  o=bpy.data.objects.new(name,me); S.collection.objects.link(o); o.location=origin
  if parent:
   bpy.context.view_layer.update(); mw=o.matrix_world.copy(); o.parent=parent; o.matrix_world=mw
  groups[name]=o; return o

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

def loft(g,rings,mat='Paint',caps=True):
 n=len(rings[0]); vs=[v for ring in rings for v in ring]; fs=[]
 for k in range(len(rings)-1):
  for j in range(n): fs.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
 if caps: fs.extend([tuple(reversed(range(n))),tuple((len(rings)-1)*n+j for j in range(n))])
 g.add(vs,fs,mat)

def octagon(x,y,w,l,z,c=.16):
 return [(x-w/2+c,y-l/2,z),(x+w/2-c,y-l/2,z),(x+w/2,y-l/2+c,z),(x+w/2,y+l/2-c,z),(x+w/2-c,y+l/2,z),(x-w/2+c,y+l/2,z),(x-w/2,y+l/2-c,z),(x-w/2,y-l/2+c,z)]

def setup_stage():
 ground=bpy.data.materials.new('Stage'); ground.diffuse_color=(.24,.27,.28,1); ground.use_nodes=True; ground.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.9
 bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.001)); floor=bpy.context.object; floor.name='STAGE'; floor.data.materials.append(ground)
 world=S.world; world.use_nodes=True; world.node_tree.nodes.get('Background').inputs[0].default_value=(.62,.70,.80,1); world.node_tree.nodes.get('Background').inputs[1].default_value=.25
 for name,loc,power,size in [('Key',(3,-5,8),1100,6),('Fill',(-5,-2,4),450,5),('Rim',(1,6,7),1300,5)]:
  data=bpy.data.lights.new(name,'AREA'); data.energy=power; data.shape='DISK'; data.size=size; ob=bpy.data.objects.new(name,data); S.collection.objects.link(ob); ob.location=loc; ob.rotation_euler=(Vector((0,0,1))-ob.location).to_track_quat('-Z','Y').to_euler()
 data=bpy.data.cameras.new('Camera'); cam=bpy.data.objects.new('Camera',data); S.collection.objects.link(cam); S.camera=cam; return cam

def camera(cam,side=False,wide=False):
 cam.data.type='ORTHO' if side else 'PERSP'; cam.data.lens=40 if wide else 60; cam.data.ortho_scale=6.5
 cam.location=(8,0,1.02) if side else (7,-9,5.0)
 cam.rotation_euler=(Vector((0,-.2 if wide else 0,.65 if wide else 1.02))-cam.location).to_track_quat('-Z','Y').to_euler()

def render(path,w=1280,h=720,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/path); bpy.ops.render.render(write_still=True)

def clean_mesh(o):
 if o.type!='MESH':return
 bm=bmesh.new();bm.from_mesh(o.data)
 bmesh.ops.triangulate(bm,faces=list(bm.faces),quad_method='BEAUTY',ngon_method='BEAUTY')
 deg=[f for f in bm.faces if f.calc_area()<5e-13]
 if deg:bmesh.ops.delete(bm,geom=deg,context='FACES')
 bm.to_mesh(o.data);bm.free();o.data.update()

def export(path,objects):
 for o in objects:clean_mesh(o)
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects: o.hide_set(False); o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(ROOT/path),export_format='GLB',use_selection=True,export_apply=False,export_yup=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_materials='EXPORT',export_animations=False,export_extras=True)
 # Blender's exporter drops a MixRGB constant; write the glTF-defined tint explicitly.
 raw=(ROOT/path).read_bytes();jslen=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jslen]);tail=raw[20+jslen:]
 for mat in doc.get('materials',[]):
  if mat.get('name')=='Paint':mat.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=[.24,.21,.11,1]
 encoded=json.dumps(doc,separators=(',',':')).encode();encoded+=b' '*((-len(encoded))%4)
 (ROOT/path).write_bytes(struct.pack('<4sII',b'glTF',2,20+len(encoded)+len(tail))+struct.pack('<I4s',len(encoded),b'JSON')+encoded+tail)


def preflight():
 material('Paint',(.24,.21,.11),.05,.80,res=1024)
 g=Geo(); g.box((0,0,.5),(1,1,1)); o=g.finish('Probe'); export('probe.glb',[o]); bpy.data.objects.remove(o,do_unlink=True); bpy.data.node_groups.get('glTF Material Output').name='Baked Settings'; bpy.ops.import_scene.gltf(filepath=str(ROOT/'probe.glb')); cam=setup_stage(); camera(cam); render('probe.png',320,180,4); print('PREFLIGHT_OK',flush=True)

if '--preflight' in sys.argv:
 preflight(); sys.exit(0)

# All material graph results are Cycles-baked to compact shared tile sets.
for args in [('Paint',(.24,.21,.11),.04,.82,'green_metal_rust',1024),('TrackSteel',(.17,.14,.105),.72,.71,'rusty_metal_02',512),('Rubber',(.032,.035,.032),0,.91,'green_metal_rust',512),('Scars',(.14,.155,.15),.85,.62,'rusty_metal_02',512),('Interior',(.013,.017,.014),.05,.95,'green_metal_rust',512),('Lamps',(.68,.66,.50),.15,.29,'green_metal_rust',512),('Burnt',(.042,.043,.040),.45,.92,'rusty_metal_02',512)]: material(*args)
print('MATERIAL_BAKES_COMPLETE',flush=True)

# Continuous armoured tub, with sloped nose, chamfered sides and real turret well.
h=Geo()
sections=[(-2.49,.72,.50,.72),(-2.26,.82,.42,1.04),(-1.80,.86,.40,1.16),(1.65,.83,.42,1.17),(2.47,.72,.55,1.10)]
rings=[]
for y,w,b,t in sections: rings.append([(-w+.08,y,b),(w-.08,y,b),(w,y,b+.08),(w,y,t-.06),(w-.06,y,t),(-w+.06,y,t),(-w,y,t-.06),(-w,y,b+.08)])
loft(h,rings)
outer0=octagon(0,-.48,1.88,2.65,1.10,.24); outer1=octagon(0,-.48,1.58,2.30,1.39,.22)
# Sloped shoulder with a constructed driver opening, avoiding booleans on rivet islands.
for j in range(1,8): h.add([outer0[j],outer0[(j+1)%8],outer1[(j+1)%8],outer1[j]],[(0,1,2,3)])
a,b,c,d=map(Vector,[outer0[0],outer0[1],outer1[1],outer1[0]])
def frontpoint(u,v,a=a.copy(),b=b.copy(),c=c.copy(),d=d.copy()):return a.lerp(b,u).lerp(d.lerp(c,u),v)
for u0,u1,v0,v1 in [(0,.075,0,1),(.40,1,0,1),(.075,.40,0,.08),(.075,.40,.88,1)]:
 h.add([frontpoint(u0,v0),frontpoint(u1,v0),frontpoint(u1,v1),frontpoint(u0,v1)],[(0,1,2,3)])
tc=Vector((.105,-.48,1.395)); inner=[]
for v in outer1:
 d=Vector((v[0]-tc.x,v[1]-tc.y,0)).normalized(); inner.append((tc.x+d.x*.65,tc.y+d.y*.65,1.39))
loft(h,[outer1,inner],caps=False)
loft(h,[inner,[(x,y,.85) for x,y,z in inner]],'Interior',False)
h.lathe((tc.x,tc.y,.85),[(0,.645),(.025,.645)],mat='Interior',n=48)
h.tube(tc,.65,.025,mat='Scars',n=48)
# Riveted plate boundaries across the fighting compartment.
for j in range(8):
 a=Vector(outer0[j]); b=Vector(outer0[(j+1)%8]); c=Vector(outer1[j]); d=Vector(outer1[(j+1)%8]); normal=(b-a).cross(c-a).normalized()
 if normal.z<0: normal=-normal
 for zt in [.13,.86]:
  aa=a.lerp(c,zt); bb=b.lerp(d,zt)
  h.line_rivets(aa.lerp(bb,.08)+normal*.004,aa.lerp(bb,.92)+normal*.004,max(3,int((bb-aa).length/.14)),normal)
 h.line_rivets(a.lerp(c,.12)+normal*.005,a.lerp(c,.88)+normal*.005,4,normal)
# Nose sheet seams, rivets and transmission access plate.
for x in [-.70,-.24,.24,.70]:
 h.line_rivets((x,-2.42,.75),(x,-1.86,1.155),6,(0,-.58,.82))
h.box((0,-2.18,.951),(1.28,.025,.07),bev=.008,rot=Matrix.Rotation(math.radians(-35),3,'X'))
for x in [-.67,.67]:
 h.box((x,-2.42,.67),(.075,.10,.12),'Scars',.012)
 h.tube((x,-2.48,.59),.074,.016,(1,0,0),'TrackSteel',18,6)
# Bell cranks, suspension shafts, springs and mounting brackets are fixed hull.
for side in [-1,1]:
 x=side*.895
 for y in [-1.38,-.02,1.34]:
  h.lathe((x,y,.54),[(-.10,.075),(.10,.075)],(1,0,0),'TrackSteel',20)
  for dy in [-.34,.34]:
   h.rod((x,y,.61),(x,y+dy,.36),.065,'Paint',10)
   h.lathe((x,y+dy,.36),[(-.08,.060),(.08,.060)],(1,0,0),'Scars',16)
 h.rod((x,-1.43,.82),(x,1.35,.82),.058,'TrackSteel',20)
 for y in [-1.1,1.05]:
  h.lathe((x,y,.82),[(-.28,.095),(-.24,.097),(.24,.097),(.28,.095)],(0,1,0),'Paint',20)
  for k in range(9): h.tube((x,y-.22+k*.055,.82),.092,.006,(0,1,0),'TrackSteel',16,4)
 for y in [-2,-1.1,0,1.1,2]:
  h.box((side*.84,y,1.10),(.17,.09,.09),bev=.005)
  h.rivet((side*.93,y,1.1),(side,0,0),.016)
# Engine deck: complete skin beneath removable grilles and boxes.
h.box((0,1.64,1.16),(1.52,1.45,.07),bev=.015)
for x in [-.73,.73]: h.line_rivets((x,1.0,1.203),(x,2.30,1.203),12)
for y in [1.0,2.30]: h.line_rivets((-.73,y,1.203),(.73,y,1.203),12)
for side in [-1,1]:
 # Rear final drive access housings.
 h.lathe((side*.55,2.39,.84),[(-.02,.15),(.06,.15),(.08,.12)],(0,1,0),'Paint',24)
 h.tube((side*.52,2.48,.59),.065,.015,(1,0,0),'TrackSteel',18,6)
# Transmission cover, towing clevis and paired access plates on the glacis.
for yy in [-2.435,-2.31]:
 zz=.72+(yy+2.49)/.23*.32
 h.line_rivets((-.61,yy,zz+.004),(.61,yy,zz+.004),11,(0,-.812,.584),.013)
# Top access plates follow the actual upper nose slope.
for xx in [-.43,.43]:
 yy=-2.03;zz=1.04+(yy+2.26)/.46*.12+.007;rot=Matrix.Rotation(math.atan(.12/.46),3,'X')
 h.box((xx,yy,zz),(.43,.40,.018),'Paint',.014,rot)
 for dx in [-.19,.19]:
  for dy in [-.16,.16]:
   pos=Vector((xx,yy,zz))+rot@Vector((dx,dy,.011));h.rivet(pos,(0,-.252,.968),.01)
 h.rod((xx-.055,yy-.015,zz+.035),(xx+.055,yy-.015,zz+.035),.008,'TrackSteel',8)
 for dx in [-.055,.055]:h.rod((xx+dx,yy-.015,zz+.01),(xx+dx,yy-.015,zz+.035),.009,'Paint',8)
h.box((0,-2.457,.68),(.15,.045,.17),'Paint',.012)
h.tube((0,-2.495,.57),.067,.015,(0,1,0),'TrackSteel',20,6)
# Fine exposed edges catch daylight on the bolted transmission plate.
h.rod((-.62,-2.453,.784),(.62,-2.453,.784),.004,'TrackSteel',6)
Hull=h.finish('Hull')

# Road wheels, front sprockets, rear perforated idlers and three return rollers.
wheel_nodes=[]
for side,label in [(1,'L'),(-1,'R')]:
 x=side*.969
 spec=[(-2.235,.735,.435,'sprocket')]+[(y,.355,.303,'road') for y in [-1.72,-1.035,-.35,.335,1.02,1.705]]+[(2.245,.685,.423,'idler')]+[(y,1.045,.144,'return') for y in [-1.30,0,1.30]]
 spec.sort(key=lambda q:q[0])
 for wi,(y,z,r,kind) in enumerate(spec,1):
  g=Geo(); c=Vector((x,y,z)); axis=(side,0,0)
  if kind in ['road','return']:
   g.lathe(c,[(-.115,r*.80),(-.115,r-.015),(-.095,r),(.095,r),(.115,r-.015),(.115,r*.80),(-.115,r*.80)],axis,'Rubber',32 if kind=='road' else 24,caps=False)
   g.lathe(c,[(-.116,r*.80),(-.106,r*.87),(.107,r*.87),(.121,r*.79),(.122,r*.63),(.105,r*.56)],axis,'Paint',32 if kind=='road' else 24)
   g.tube(c+Vector((side*.124,0,0)),r*.73,.009,axis,'TrackSteel',24,4)
   g.tube(c+Vector((side*.126,0,0)),r*.60,.006,axis,'TrackSteel',24,4)
  elif kind=='sprocket':
   # Cast toothed drive crown, two flanges with a dished centre.
   n=72; vs=[]
   for depth in [-.115,-.075,.075,.115]:
    for j in range(n):
     rr=r+(.025 if j%4 in [1,2] else -.012); a=2*pi*j/n; vs.append(c+Vector((side*depth,rr*cos(a),rr*sin(a))))
   fs=[]
   for k in range(3):
    for j in range(n): fs.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
   fs.extend([tuple(reversed(range(n))),tuple(3*n+j for j in range(n))]); g.add(vs,fs,'TrackSteel')
   g.lathe(c,[(-.125,r*.76),(.118,r*.76),(.145,r*.68),(.152,r*.47),(.17,r*.29)],axis,'Paint',48)
   g.tube(c+Vector((side*.151,0,0)),r*.69,.012,axis,'Scars',48,5)
  else:
   # Cast plate with eight genuine circular lightening holes and bevelled rims.
   g.lathe(c,[(-.10,r),(.10,r),(.12,r-.025),(.12,r-.060),(-.10,r-.060),(-.10,r)],axis,'TrackSteel',48,caps=False)
   N=12;vs=[];fs=[]
   # Eight sectors tile the solid disk; each has a circular void at its centre.
   for sector in range(8):
    a=2*pi*sector/8; center=np.array([.267*cos(a),.267*sin(a)])
    contours=[]
    for depth,rad in [(-.093,.057),(-.086,.052),(.108,.052),(.116,.057)]:
     contours.append([c+Vector((side*depth,*(center+rad*np.array([cos(a+2*pi*j/N),sin(a+2*pi*j/N)])))) for j in range(N)])
    outer=[]
    for j in range(N):
     ang=a+2*pi*j/N;direction=np.array([cos(ang),sin(ang)])
     # intersect ray from hole centre with annular-sector boundaries
     candidates=[]
     for rr in [.105,r-.048]:
      dot=float(center@direction);disc=dot*dot+rr*rr-float(center@center)
      if disc>=0:
       candidates.extend(t for t in [-dot-math.sqrt(disc),-dot+math.sqrt(disc)] if t>1e-5)
     for edge in [a-pi/8,a+pi/8]:
      normal=np.array([-sin(edge),cos(edge)]);den=float(direction@normal)
      if abs(den)>1e-6:
       t=-float(center@normal)/den
       if t>1e-5:candidates.append(t)
     outer.append(center+direction*min(candidates))
    k=len(vs);vs.extend([v for ring in contours for v in ring]);vs.extend([c+Vector((side*depth,*yz)) for depth in [-.093,.116] for yz in outer])
    for j in range(N):
     j1=(j+1)%N
     for rj in range(3):fs.append((k+rj*N+j,k+rj*N+j1,k+(rj+1)*N+j1,k+(rj+1)*N+j))
     fs.extend([(k+j,k+4*N+j,k+4*N+j1,k+j1),(k+3*N+j,k+3*N+j1,k+5*N+j1,k+5*N+j)])
   g.add(vs,fs,'Paint')
   g.tube(c+Vector((side*.12,0,0)),r-.044,.012,axis,'Paint',48,5)
  g.lathe(c,[(-.13,r*.27),(.13,r*.27),(.155,r*.23),(.18,r*.22),(.19,r*.14)],axis,'Paint',24)
  for j in range(8 if kind!='return' else 6):
   a=2*pi*j/(8 if kind!='return' else 6); rr=r*.36; g.rivet(c+Vector((side*.132,rr*cos(a),rr*sin(a))),axis,.011 if kind!='return' else .007,'Scars')
  o=g.finish(f'Wheel_{label}{wi}',c); o['role']=kind; o['spin_axis']='X'; wheel_nodes.append(o)

# Closed path follows the six-wheel contact run, raised front drive and rear idler.
def track_path():
 pts=[]
 # clockwise around YZ plane, bottom then rear end then upper run and nose.
 pts += [(y,.044) for y in np.linspace(-1.72,1.72,45)]
 pts += [(1.72+(2.54-1.72)*t,.044+(.325-.044)*t) for t in np.linspace(0,1,13)[1:]]
 pts += [(2.245+.484*cos(a),.685+.484*sin(a)) for a in np.linspace(-.84,pi/2,34)]
 pts += [(y,1.172-.046*sin(pi*t)) for t,y in [(t,2.245-4.48*t) for t in np.linspace(0,1,80)[1:]]]
 pts += [(-2.235+.484*cos(a),.735+.484*sin(a)) for a in np.linspace(pi/2,pi+1.00,38)[1:]]
 pts += [(-2.497+(.777)*t,.328-(.284)*t) for t in np.linspace(0,1,13)[1:]]
 return np.array(pts)
path=track_path(); dist=np.r_[0,np.cumsum(np.linalg.norm(np.diff(path,axis=0),axis=1))]; length=dist[-1]; LINK_N=104

def sample_path(t):
 s=t*length; j=min(len(path)-2,max(0,np.searchsorted(dist,s)-1)); f=(s-dist[j])/(dist[j+1]-dist[j]); pos=path[j]*(1-f)+path[j+1]*f; tangent=path[j+1]-path[j]; tangent/=np.linalg.norm(tangent); return pos,tangent

def track_geo(side,broken=False):
 g=Geo(); x=side*.972
 for i in range(LINK_N):
  t=(i+.5)/LINK_N; pos,tangent=sample_path(t);x=side*.972
  if broken and t>.53:
   # Upper run sags, breaks at nose; terminal links rest on the ground ahead.
   if t<.83:
    q=(t-.53)/.30;fall=min(1,q/.18);x=side*(.972+.40*sin(fall*pi/2));pos[1]=pos[1]*(1-fall)+.098*fall;tangent=np.array([-1.,-2*(1-fall)]);tangent/=np.linalg.norm(tangent)
   else:
    q=(t-.83)/.17;x=side*1.372;pos=np.array([-1.75-2.1*q,.098]);tangent=np.array([-1.,0.])
  y,z=pos; dy,dz=tangent; rot=Matrix(((1,0,0),(0,dy,-dz),(0,dz,dy)))
  start=len(g.f); vstart=len(g.v)
  g.box((x,y,z),(.374,.105,.051),'TrackSteel',.007,rot)
  for dx in [-.135,.135]:
   c=Vector((x,y,z))+rot@Vector((dx,0,-.034)); g.box(c,(.096,.076,.026),'TrackSteel',0,rot)
  # Raised transverse grouser and inner guide tooth.
  c=Vector((x,y,z))+rot@Vector((0,0,-.029)); g.box(c,(.35,.025,.028),'Scars',.004,rot)
  tooth=[(-.035,-.032,.024),(.035,-.032,.024),(.035,.032,.024),(-.035,.032,.024),(-.012,-.016,.096),(.012,-.016,.096),(.012,.016,.096),(-.012,.016,.096)]
  g.add([Vector((x,y,z))+rot@Vector(v) for v in tooth],[(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)],'TrackSteel')
  # Pin barrels at each outer edge.
  for dx in [-.191,.191]: g.lathe(Vector((x,y,z))+rot@Vector((dx,.043,0)),[(-.017,.019),(.017,.019)],(1,0,0),'TrackSteel',6)
  # UV V is arc length / loop length, one full loop, including side faces.
  inv=rot.transposed()
  for fi in range(start,len(g.f)):
   g.uv[fi]=[]
   for vi in g.f[fi]:
    local=inv@(Vector(g.v[vi])-Vector((x,y,z))); g.uv[fi].append(((local.x+.205)/.41,(i+.5+local.y/(length/LINK_N))/LINK_N))
 return g
for side,label in [(1,'L'),(-1,'R')]: track_geo(side).finish('Track_'+label)

# Four detachable fenders: rolled lip, braces and fasteners; armour underneath is complete.
for side,label in [(1,'L'),(-1,'R')]:
 for front,code in [(True,'F'),(False,'R')]:
  g=Geo(); ya,yb=(-2.50,.05) if front else (.07,2.48)
  # Shallow curved sheet, rolled front/rear edge.
  pts=[(ya,1.075),(ya+.12,1.19),(ya+.34,1.215),(yb-.16,1.215),(yb,1.17)] if front else [(ya,1.215),(yb-.25,1.195),(yb-.10,1.16),(yb,1.08)]
  vs=[]
  for y,z in pts: vs.extend([(side*.78,y,z),(side*1.165,y,z),(side*.78,y,z-.025),(side*1.165,y,z-.025)])
  fs=[]
  for j in range(len(pts)-1):
   a=j*4;b=(j+1)*4; fs.extend([(a,a+1,b+1,b),(a+2,b+2,b+3,a+3),(a,b,b+2,a+2),(a+1,a+3,b+3,b+1)])
  fs.extend([(0,2,3,1),tuple((len(pts)-1)*4+k for k in [0,1,3,2])]); g.add(vs,fs)
  for (y,z),(yy,zz) in zip(pts,pts[1:]): g.rod((side*1.165,y,z),(side*1.165,yy,zz),.009,'Paint',8)
  for y in np.linspace(ya+.3,yb-.2,5):
   g.line_rivets((side*.83,y,1.228),(side*1.10,y,1.228),3)
  g.finish(f'Part_Fender_{code}{label}',(side*.973,(ya+yb)/2,1.17))
 # Large side stowage box and clasps.
 g=Geo(); x=side*.964; y=1.15
 g.box((x,y,1.35),(.33,.85,.28),bev=.018); g.box((x,y,1.501),(.35,.88,.032),bev=.008)
 for yy in [y-.30,y+.30]:
  g.box((x+side*.173,yy,1.43),(.018,.045,.10),'TrackSteel',.004); g.rivet((x+side*.185,yy,1.41),(side,0,0),.009)
 g.rod((x-.07,y,1.53),(x+.07,y,1.53),.012,'TrackSteel'); g.finish('Part_Toolbox_'+label,(x,y,1.36))
 # Exhaust muffler and rounded mesh guard, each independently removable.
 g=Geo(); x=side*.961;y=2.005;z=1.34
 g.lathe((x,y,z),[(-.40,.057),(-.33,.125),(-.25,.133),(.25,.133),(.33,.11),(.37,.054)],(0,1,0),'TrackSteel',32)
 g.rod((x,1.62,z),(side*.69,1.46,z-.03),.047,'TrackSteel',16); g.rod((x,2.37,z),(x,2.51,z+.05),.049,'TrackSteel',16)
 g.finish('Part_Muffler_'+label,(x,y,z))
 g=Geo()
 for yy in [1.705,2.305]:
  g.tube((x,yy,z),.147,.012,(0,1,0),'TrackSteel',28,6)
  g.box((x,yy,z-.14),(.19,.035,.07),'Paint',.008)
 # Diamond wire lattice wraps around the outside of the muffler.
 for direction in [-1,1]:
  for k in range(23):
   y0=1.60+k*.038
   old=None
   for j in range(13):
    a=-.38+j*(pi+.76)/12; yy=y0+direction*(a/(pi+.76))*.24
    if 1.715<=yy<=2.295:
     p=(x+side*.151*sin(a),yy,z+.151*cos(a))
     if old:g.rod(old,p,.0032,'TrackSteel',4)
     old=p
    else:old=None
 g.finish('Part_MufflerGuard_'+label,(x,y,z))
 # Rear fender mounting foot remains after damage.
 # Headlights and detachable protective cages.
 g=Geo(); pos=(side*.72,-1.92,1.17)
 g.lathe(pos,[(-.052,.069),(.048,.069),(.055,.056)],(0,-1,0),'Paint',24)
 g.lathe((pos[0],pos[1]-.056,pos[2]),[(0,.053),(.008,.052)],(0,-1,0),'Lamps',24)
 g.finish('Part_Headlamp_'+label,pos)
 g=Geo()
 for dx in [-.081,.081]: g.rod((pos[0]+dx,pos[1]+.03,pos[2]-.04),(pos[0]+dx,pos[1]-.10,pos[2]+.075),.009,'Paint',8)
 g.rod((pos[0]-.081,pos[1]-.1,pos[2]+.075),(pos[0]+.081,pos[1]-.1,pos[2]+.075),.009,'Paint',8)
 g.finish('Part_LampGuard_'+label,pos)

# Engine-deck louvres: spaced, individually shaped slats above the sealed skin.
for side,label in [(1,'L'),(-1,'R')]:
 g=Geo(); x=side*.36;y=1.48
 for dx in [-.295,.295]:g.box((x+dx,y,1.239),(.022,1.13,.046),bev=.005)
 for yy in [y-.555,y+.555]:g.box((x,yy,1.239),(.61,.028,.046),bev=.005)
 for j in range(17):g.box((x,y-.5+j*.0625,1.245),(.565,.037,.034),'Paint',.006,Matrix.Rotation(.26,3,'X'))
 g.finish('Part_EngineGrille_'+label,(x,y,1.24))
# Rear jack, pioneer tools, spare track assembly and tow cable.
g=Geo(); g.box((0,2.38,1.21),(.19,.14,.10),'TrackSteel',.006); g.rod((-.42,2.38,1.22),(.42,2.38,1.22),.033,'TrackSteel',12)
g.box((.42,2.38,1.22),(.08,.16,.15),'Paint',.008); g.rod((-.08,2.38,1.23),(-.08,2.38,1.47),.021,'Scars',12); g.finish('Part_Jack',(0,2.38,1.24))
g=Geo()
for x in [-.46,-.22]:
 g.rod((x,.73,1.23),(x,2.08,1.23),.016,'TrackSteel',10)
 g.box((x,2.10,1.24),(.15,.23,.018),'Scars',.02)
 for yy in [1.0,1.83]:g.box((x,yy,1.254),(.07,.035,.025),'Paint',.004)
g.finish('Part_PioneerTools',(-.34,1.40,1.24))
g=Geo()
for j in range(4):
 g.box((.48,-2.12+j*.115,1.055+j*.063),(.34,.10,.04),'TrackSteel',.007)
 g.box((.48,-2.12+j*.115,1.08+j*.063),(.31,.028,.028),'Scars',.003)
g.finish('Part_SpareTracks',(.48,-1.95,1.16))
g=Geo(); pts=[]
for i in range(61):
 a=2*pi*i/60; pts.append((-.40+.28*cos(a),2.483,.89+.19*sin(a)))
for a,b in zip(pts,pts[1:]):g.rod(a,b,.014,'TrackSteel',6)
for x in [-.55,-.25]:g.box((x,2.46,1.02),(.04,.07,.09),'Paint',.004)
g.finish('Part_TowCable',(-.4,2.483,.89))

# Turret: asymmetric octagonal shell, riveted seams, full roof around hatch opening.
t=Geo(); cx,cy=tc.x,tc.y
lower=octagon(cx,cy,1.59,1.74,1.415,.25); upper=octagon(cx,cy,1.43,1.58,1.948,.22)
loft(t,[lower,upper],caps=False)
t.lathe(tc,[(0,.615),(.06,.615)],mat='Paint',n=48)
# Close floor, preserving a basket connection for the wreck variant.
loft(t,[lower,[(x,y,1.40) for x,y,z in lower]],caps=True)
hatchpos=Vector((cx,cy+.16,1.957)); hole=[]
for p in upper:
 d=Vector((p[0]-hatchpos.x,p[1]-hatchpos.y,0)).normalized(); hole.append((hatchpos.x+d.x*.265,hatchpos.y+d.y*.265,1.948))
loft(t,[upper,hole],caps=False); loft(t,[hole,[(x,y,1.57) for x,y,z in hole]],'Interior',False)
t.lathe((hatchpos.x,hatchpos.y,1.57),[(0,.26),(.015,.26)],mat='Interior',n=32)
for j in range(8):
 a,b=Vector(lower[j]),Vector(lower[(j+1)%8]); c,d=Vector(upper[j]),Vector(upper[(j+1)%8]); normal=(b-a).cross(c-a).normalized()
 for f in [.08,.91]:
  aa=a.lerp(c,f); bb=b.lerp(d,f); t.line_rivets(aa.lerp(bb,.07)+normal*.004,aa.lerp(bb,.93)+normal*.004,max(3,int((bb-aa).length/.135)),normal,.011)
 t.line_rivets(a.lerp(c,.12)+normal*.008,a.lerp(c,.88)+normal*.008,6,normal,.012)
# Overlapped side armour seam, with two parallel rows of dome rivets.
for side in [-1,1]:
 a=Vector((cx+side*.790,cy+.05,1.447));b=Vector((cx+side*.722,cy+.05,1.910));normal=Vector((side*.989,0,.148))
 t.rod(a,b,.0035,'TrackSteel',6)
 for dy in [-.019,.019]:t.line_rivets(a+Vector((0,dy,0))+normal*.004,b+Vector((0,dy,0))+normal*.004,7,normal,.010)
# Vision slits sunk into raised frames, one per side.
for side in [-1,1]:
 x=cx+side*.759
 t.box((x,cy-.22,1.735),(.018,.16,.056),'Paint',.006)
 t.box((x+side*.012,cy-.22,1.745),(.006,.117,.012),'Interior',.001)
# Rear-facing Type 97 machine gun (the tank has no roof MG).
t.lathe((cx+.20,cy+.821,1.70),[(-.03,.112),(.065,.096),(.095,.057)],(0,1,0),'Paint',24)
t.lathe((cx+.20,cy+.96,1.70),[(-.045,.029),(.23,.024),(.25,.019)],(0,1,0),'Scars',16)
for j in range(8):t.tube((cx+.20,cy+.965+j*.027,1.70),.027,.005,(0,1,0),'TrackSteel',12,4)
t.rod((cx+.2,cy+1.06,1.73),(cx+.2,cy+1.06,1.81),.008,'Scars',8)
Turret=t.finish('Turret',tc); Turret['rotation_axis']='Y in glTF'
# Mantlet trunnion and separate recoil tube.
g=Geo(); gp=Vector((cx,cy-.825,1.713)); g.box(gp,(.48,.22,.32),'Paint',.035)
for sx in [-1,1]:
 g.lathe(gp+Vector((sx*.235,0,0)),[(-.017,.12),(.017,.12)],(1,0,0),'Paint',24)
 for zz in [-.105,.105]:g.rivet(gp+Vector((sx*.174,-.116,zz)),(0,-1,0),.014)
g.lathe(gp+Vector((0,-.14,0)),[(-.02,.109),(.075,.096)],(0,-1,0),'Paint',32)
Gun=g.finish('Gun',gp,Turret)
g=Geo(); bp=gp+Vector((0,-.215,0)); barrel_len=.925
# Hollow bore with crown; inner bore continues deeply back from the muzzle.
g.lathe(bp,[(0,.075),(.055,.075),(.66,.055),(.88,.051),(.925,.058),(.938,.058),(.938,.029),(.64,.029)],(0,-1,0),'TrackSteel',32,caps=False)
g.lathe(bp+Vector((0,-.64,0)),[(0,.028),(.004,.028)],(0,-1,0),'Interior',24)
Barrel=g.finish('Barrel',bp,Gun); empty('Muzzle',bp+Vector((0,-.938,0)),Barrel)
# Horseshoe antenna is a characteristic detachable silhouette, with four supports.
g=Geo(); railpts=[]
for j in range(53):
 a=math.radians(-135)+math.radians(270)*j/52
 railpts.append((cx+.865*sin(a),cy+.81*cos(a),2.066))
# Continuous swept tube: shared rings avoid capped seams at every bend.
rv=[]; rf=[]
for j,pt in enumerate(railpts):
 tangent=(Vector(railpts[min(j+1,52)])-Vector(railpts[max(j-1,0)])).normalized()
 u=Vector((0,0,1)); v=tangent.cross(u).normalized()
 for k in range(10): rv.append(Vector(pt)+.016*(u*cos(2*pi*k/10)+v*sin(2*pi*k/10)))
for j in range(52):
 for k in range(10): rf.append((j*10+k,j*10+(k+1)%10,(j+1)*10+(k+1)%10,(j+1)*10+k))
rf.extend([tuple(reversed(range(10))),tuple(520+k for k in range(10))])
g.add(rv,rf,'Paint',True)
for a in [-100,-35,35,100]:
 a=math.radians(a); top=Vector((cx+.865*sin(a),cy+.81*cos(a),2.066)); base=Vector((cx+.66*sin(a),cy+.65*cos(a),1.943)); g.rod(base,top,.014,'Paint',10)
 g.lathe(base,[(0,.028),(.015,.028)],mat='TrackSteel',n=12)
g.finish('Part_Turret_RadioRail',(cx,cy,2.045),Turret)
# Loop antenna in the sheet, compact enough to preserve requested overall height.
g=Geo(); ap=(cx-.54,cy+.46,2.10)
g.tube(ap,.12,.008,(0,1,0),'TrackSteel',36,6); g.rod((ap[0],ap[1],1.94),(ap[0],ap[1],1.99),.018,'Scars',12)
for z in [1.951,1.97,1.989]:g.tube((ap[0],ap[1],z),.02,.004,mat='Rubber',n=12,m=4)
g.finish('Part_Turret_LoopAntenna',ap,Turret)
g=Geo(); g.lathe(hatchpos,[(0,.29),(.026,.29),(.053,.282),(.056,.265)],mat='Paint',n=48,caps=False)
for a in [0,pi/2,pi,3*pi/2]:
 g.box((hatchpos.x+.287*cos(a),hatchpos.y+.287*sin(a),1.995),(.055,.055,.025),'Interior',.004)
g.finish('Part_Turret_Cupola',hatchpos,Turret)
g=Geo(); g.box((cx-.35,cy-.59,1.968),(.13,.19,.035),'Paint',.006); g.box((cx-.35,cy-.62,1.996),(.082,.10,.027),'TrackSteel',.004); g.finish('Part_Turret_Sight',(cx-.35,cy-.59,1.985),Turret)
# Closed, hinged crew hatches and cavity wells.
g=Geo(); g.lathe((hatchpos.x,hatchpos.y,2.025),[(-.015,.278),(0,.29),(.027,.285),(.037,.263)],mat='Paint',n=48)
for x in [-.16,.16]:g.rod((hatchpos.x+x,hatchpos.y+.255,2.026),(hatchpos.x+x+.075,hatchpos.y+.255,2.026),.022,'Scars',12)
g.rod((hatchpos.x-.063,hatchpos.y,2.086),(hatchpos.x+.063,hatchpos.y,2.086),.012,'TrackSteel',10)
for x in [-.063,.063]:g.rod((hatchpos.x+x,hatchpos.y,2.055),(hatchpos.x+x,hatchpos.y,2.086),.012,'TrackSteel',10)
g.finish('Hatch_Commander',(hatchpos.x,hatchpos.y+.255,2.026),Turret)
# Driver's rectangular opening, rim and dark recessed crew space on the front shoulder.
g=Geo(); dp=Vector((-.40,-1.66,1.24)); g.box(dp,(.49,.045,.255),'Paint',.018)
g.box(dp+Vector((0,-.027,.025)),(.345,.013,.027),'Interior',.003)
g.box(dp+Vector((0,-.04,.065)),(.39,.034,.022),'Paint',.004)
for x in [-.22,.22]:g.rivet(dp+Vector((x,-.027,-.095)),(0,-1,0),.013)
driver=g.finish('Hatch_Driver',dp+Vector((0,0,.128)));driver.rotation_euler.x=math.radians(-26)
# A dark inset box remains inside the driver's access opening.
g=Geo()
# Recessed walls start at the shoulder plane and retreat into the crew space.
front=[frontpoint(.075,.08),frontpoint(.40,.08),frontpoint(.40,.88),frontpoint(.075,.88)]
front=[v+Vector((0,.006,0)) for v in front];back=[v+Vector((0,.23,-.055)) for v in front]
loft(g,[front,back],'Interior',False);g.add(back,[(0,1,2,3)],'Interior')
# Thin painted rim around the driver's access opening, flush with the armour.
for a,b in zip(front,front[1:]+front[:1]):g.rod(a+Vector((0,-.009,0)),b+Vector((0,-.009,0)),.007,'Paint',8)
driverwell=g.finish('DriverInterior')
# Hull bow machine gun fixed to the hull.
g=Geo(); mp=(.43,-1.59,1.25)
g.lathe(mp,[(-.03,.12),(.04,.115),(.08,.075)],(0,-1,0),'Paint',28); g.lathe((.43,-1.68,1.25),[(0,.028),(.28,.022),(.31,.018)],(0,-1,0),'Scars',16)
for k in range(6):g.tube((.43,-1.71-.033*k,1.25),.027,.005,(0,1,0),'TrackSteel',12,4)
bow=g.finish('BowMG')
# Merge fixed non-moving fragments into Hull, preserving every detachable item.
def join_objects(obs,name,origin):
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.hide_set(False);o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0]; bpy.ops.object.join(); o=obs[0]; o.name=name
 mw=o.matrix_world.copy(); delta=mw.inverted()@Vector(origin)
 for v in o.data.vertices:v.co-=delta
 o.matrix_world.translation=origin; return o
Hull=join_objects([Hull,driverwell,bow],'Hull',(0,0,0)); groups['Hull']=Hull
for name,pos,par in [('Seat_Commander',(hatchpos.x,hatchpos.y,2.055),Turret),('Seat_Driver',(-.4,-1.45,1.40),None),('Exhaust',(.961,2.51,1.39),None),('Light_Head_L',(.72,-1.976,1.17),None),('Light_Head_R',(-.72,-1.976,1.17),None),('Fx_Engine',(0,1.6,1.24),None),('Fx_Ammo',(0,-.15,.8),None),('Fx_Hatch',(hatchpos.x,hatchpos.y,2.075),Turret)]: empty(name,pos,par)
healthy=[o for o in S.objects if o.type in {'MESH','EMPTY'}]
# Recalculate winding once; preserve intentional smooth surfaces and local rig origins.
for o in healthy:
 if o.type=='MESH':
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
  if o.name in ['Hull','Turret']:
   edges=[e for e in bm.edges if len(e.link_faces)==2 and e.calc_length()>.30 and e.calc_face_angle()>.18]
   if edges:bmesh.ops.bevel(bm,geom=edges,offset=.007,segments=2,affect='EDGES',clamp_overlap=True)
  bm.to_mesh(o.data);bm.free()
# Small detail is retained, but collapse redundant guard wire facets and track bevel topology.
for o in healthy:
 if o.type!='MESH':continue
 ratio=.72 if 'MufflerGuard' in o.name else (.86 if o.name.startswith('Track_') else .94)
 active(o);mod=o.modifiers.new('Production triangle budget','DECIMATE');mod.ratio=ratio;bpy.ops.object.modifier_apply(modifier=mod.name)
total=sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in healthy if o.type=='MESH')
if total>108000:
 for o in healthy:
  if o.type=='MESH':
   active(o);mod=o.modifiers.new('Final budget guard','DECIMATE');mod.ratio=107500/total;bpy.ops.object.modifier_apply(modifier=mod.name)
S.view_layers.update() if hasattr(S.view_layers,'update') else bpy.context.view_layer.update()
export('chi-ha-tank.glb',healthy)
print('HEALTHY_EXPORTED',sum(len(o.data.polygons) for o in healthy if o.type=='MESH'),flush=True)
cam=setup_stage(); camera(cam); render('draft.png',1280,720,24)
# Asset checkpoint is helpful for recoverable rendering, but clean default runs rebuild everything.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'healthy.blend'))

# Damage library: swaps are made from the healthy meshes in the exact same frame.
def clone(o,name,parent=None):
 bpy.context.view_layer.update()
 q=o.copy();q.data=o.data.copy() if o.type=='MESH' else None;S.collection.objects.link(q);q.name=name
 mw=o.matrix_world.copy();q.parent=None;q.matrix_world=mw
 if parent:
  bpy.context.view_layer.update();q.parent=parent;q.matrix_world=mw
 bpy.context.view_layer.update()
 return q

def simplify(o,ratio):
 active(o); mod=o.modifiers.new('game budget','DECIMATE');mod.ratio=ratio; bpy.ops.object.modifier_apply(modifier=mod.name)

def replace_materials(o,burn=False):
 for i,m in enumerate(o.data.materials):
  if burn:o.data.materials[i]=M['Burnt']
  elif m.name in ['Lamps','Interior']:o.data.materials[i]=M['Burnt']

hd=empty('Hull_Damage',(0,0,0));td=empty('Turret_Damage',tc)
twins=[]
for o in healthy:
 if not o.name.startswith('Part_'):continue
 if any(k in o.name for k in ['Fender','Toolbox','Muffler','EngineGrille','Jack','RadioRail','LoopAntenna','Cupola','Sight']):
  name='Dmg_'+o.name[5:];parent=td if o.name.startswith('Part_Turret_') else hd
  q=clone(o,name,parent); replace_materials(q)
  # Fold a corner, twist sheet edges and introduce localized spall tears.
  coords=np.array([v.co[:] for v in q.data.vertices]); ext=np.maximum(np.max(np.abs(coords),axis=0),.02)
  for v in q.data.vertices:
   a=v.co.y/ext[1]; b=v.co.x/ext[0]
   v.co.z+=max(0,a-.1)**2*.21 + .025*sin(17*v.co.y)*max(0,abs(b)-.55)
   v.co.x+=.09*max(0,a)*sin(v.co.z*7+1)
  # Tear away a localized end triangle patch; remaining sheet has ragged real edges.
  if 'Fender' in name or 'Grille' in name:
   bm=bmesh.new();bm.from_mesh(q.data)
   faces=[f for f in bm.faces if f.calc_center_median().y>ext[1]*.57 and f.calc_center_median().x>ext[0]*.05]
   bmesh.ops.delete(bm,geom=faces,context='FACES');bm.to_mesh(q.data);bm.free()
  # Scorch only a subset of surfaces so damaged panels still accept paint variants.
  for poly in q.data.polygons:
   if random.random()<.22:poly.material_index=list(M).index('Burnt')
  twins.append(q)

# Impact overlays are thin shells on each armour face: soot fans, gouges and petalled rims.
def scar_group(name,centre,normal,span,parent):
 g=Geo(); n=Vector(normal).normalized();u=n.cross(Vector((0,0,1)))
 if u.length<.01:u=Vector((1,0,0))
 u.normalize();v=n.cross(u)
 for hit in range(4):
  c=Vector(centre)+u*((hit-1.5)*span/3)+v*((.045 if hit%2 else -.028));radius=.029 if hit==2 else .015+hit*.004
  # Irregular flattened soot halo, 2 mm over the armour.
  N=18;vs=[c+n*.002]
  for j in range(N):
   a=2*pi*j/N; r=radius*(2.2+random.random()*.8);vs.append(c+n*.0022+u*cos(a)*r+v*sin(a)*r*.73)
  g.add(vs,[(0,j+1,(j+1)%N+1) for j in range(N)],'Burnt')
  # Thin ricochet groove, aligned with the sheet, tapering to a bright exposed edge.
  if hit!=2:
   a=c-u*.04; b=c+u*.042+v*.018
   g.add([a-v*.006+n*.003,b+n*.003,c+v*.008+n*.004],[(0,1,2)],'Scars')
  else:
   # Dark bore centre with curled bare-metal petals around the penetration.
   g.lathe(c+n*.003,[(0,radius*.77),(.0004,radius*.77)],n,'Interior',18)
   vs=[]
   for rr,depth in [(radius*.73,.003),(radius, .004),(radius*1.45,.0025)]:
    for j in range(N):
     a=2*pi*j/N;off=.006*(j%2) if rr==radius else 0
     vs.append(c+n*(depth+off)+u*cos(a)*rr+v*sin(a)*rr)
   fs=[]
   for k in range(2):
    for j in range(N):fs.append((k*N+j,k*N+(j+1)%N,(k+1)*N+(j+1)%N,(k+1)*N+j))
   g.add(vs,fs,'Scars')
 q=g.finish(name,centre,parent);replace_materials(q);return q
scars=[]
for args in [('Scar_Hull_Front',(0,-1.715,1.25),(0,-.856,.517),1.16,hd),('Scar_Hull_L',(.87793,-.35,1.22),(1,0,.517),1.50,hd),('Scar_Hull_R',(-.87793,-.35,1.22),(-1,0,.517),1.50,hd),('Scar_Hull_Rear',(0,2.474,.86),(0,1,0),1.06,hd),('Scar_Turret_Front',(cx,cy-.830225,1.68),(0,-1,.15),.73,td),('Scar_Turret_L',(cx+.75372,cy,1.69),(1,0,.15),.83,td),('Scar_Turret_R',(cx-.75372,cy,1.69),(-1,0,.15),.83,td),('Scar_Turret_Rear',(cx,cy+.830225,1.68),(0,1,.15),.73,td)]: scars.append(scar_group(*args))
for side,label in [(1,'L'),(-1,'R')]:
 q=track_geo(side,True).finish('Track_'+label+'_Broken',(0,0,0),hd); simplify(q,.74)
# Gun knockout contains its own damaged mantlet and burst tube; the healthy Gun hierarchy is hidden.
gparts=[clone(Gun,'broken mantlet'),clone(Barrel,'broken barrel')]
q=gparts[1]
for vert in q.data.vertices:
 d=max(0,-vert.co.y-.37)
 vert.co.z-=d*.65
 if -vert.co.y>.64:
  vert.co.x*=1+(-vert.co.y-.64)*5;vert.co.z+=(.045 if vert.co.x>0 else -.055)*(-vert.co.y-.64)*3
q=join_objects(gparts,'Gun_Broken',gp);bpy.context.view_layer.update();mw=q.matrix_world.copy();q.parent=td;q.matrix_world=mw;replace_materials(q)
# Wreck copies omit selected fittings, expose wells and carry a single Burnt material.
wh=[];wt=[]
turret_family=[Turret]+list(Turret.children_recursive)
omit=['Part_Fender_FL','Part_Fender_RR','Part_Toolbox_R','Part_MufflerGuard_L','Part_EngineGrille_R','Part_Turret_RadioRail','Part_Turret_LoopAntenna']
for o in healthy:
 if o.type!='MESH' or o.name in omit:continue
 if o.name=='Track_L': source=bpy.data.objects['Track_L_Broken']
 else:source=o
 q=clone(source,'wreck_'+o.name);replace_materials(q,True)
 if o in turret_family:
  if o.name=='Hatch_Commander':q.rotation_euler.x-=math.radians(112)
  if o.name in ['Gun','Barrel']:
   mw=q.matrix_world.copy();q.matrix_world=Matrix.Translation(gp)@Matrix.Rotation(math.radians(18),4,'X')@Matrix.Translation(-gp)@mw
  wt.append(q)
 else:
  if o.name.startswith('Wheel_'):
   q.location.z-=.05
   if o.get('role')=='road':
    # Rubber burned away: smaller bare rims remain.
    for vert in q.data.vertices:
     rr=math.hypot(vert.co.y,vert.co.z)
     if rr>.25:vert.co.y*=.90;vert.co.z*=.90
  if o.name=='Hatch_Driver':q.rotation_euler.x=math.radians(-105)
  if o.name=='Part_EngineGrille_L':q.rotation_euler.y=.55;q.location.z+=.12
  if o.name.startswith('Part_Fender'):
   for vert in q.data.vertices:vert.co.z+=.18*sin(vert.co.y*2)*abs(vert.co.y)
  wh.append(q)
# Distorted engine cover shards and visible ruptured engine block.
g=Geo();g.box((.23,1.72,1.27),(.51,.65,.24),'Burnt',.022,Matrix.Rotation(-.16,3,'Y'))
for j in range(4):g.lathe((.23,1.48+j*.15,1.42),[(-.20,.07),(.20,.07)],(1,0,0),'Burnt',12)
wh.append(g.finish('wreck_engine'))
g=Geo();g.lathe((cx,cy,1.35),[(-.26,.40),(-.24,.45),(.01,.45),(.04,.61)],mat='Burnt',n=32)
wt.append(g.finish('wreck_basket'))
WreckHull=join_objects(wh,'Wreck_Hull',(0,0,0));WreckTurret=join_objects(wt,'Wreck_Turret',tc)
for q in [WreckHull,WreckTurret]:
 # Compact wreck to roughly 40 percent of healthy, all one material per assembly.
 q.data.materials.clear();q.data.materials.append(M['Burnt'])
 for p in q.data.polygons:p.material_index=0
 simplify(q,.39)
# Ensure the two wreck meshes are true top-level nodes, no inherited transforms.
WreckHull.parent=None;WreckTurret.parent=None
bpy.context.view_layer.update()
damage=[hd,td]+list(hd.children_recursive)+list(td.children_recursive)+[WreckHull,WreckTurret]
# Prune unused slots: no extraneous material is exported in the damage file.
for q in damage:
 if q.type=='MESH':
  used=sorted({p.material_index for p in q.data.polygons}); mats=[q.data.materials[i] for i in used];ids={v:k for k,v in enumerate(used)}
  old=[p.material_index for p in q.data.polygons];q.data.materials.clear()
  for m in mats:q.data.materials.append(m)
  for p,i in zip(q.data.polygons,old):p.material_index=ids[i]
# Normalize manufacturing dimensions without resetting any articulated origin or rotation.
# Vertex coordinates and pivot positions receive the same affine scale; object scales remain one.
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(c) for o in healthy if o.type=='MESH' for c in o.bound_box]
lo=Vector([min(v[k] for v in pts) for k in range(3)]);hi=Vector([max(v[k] for v in pts) for k in range(3)])
sc=Vector((2.33/(hi.x-lo.x),5.52/(hi.y-lo.y),2.23/(hi.z-lo.z)))
D=Matrix.Diagonal((*sc,1));D.translation=Vector((-((hi.x+lo.x)/2)*sc.x,-((hi.y+lo.y)/2)*sc.y,-lo.z*sc.z))
allasset=healthy+damage;oldworld={o:o.matrix_world.copy() for o in allasset}
def depth(o):return 0 if o.parent is None else depth(o.parent)+1
for o in sorted(allasset,key=depth):
 old=oldworld[o];new=old.copy();new.translation=D@old.translation;o.matrix_world=new;bpy.context.view_layer.update()
 if o.type=='MESH':o.data.transform(new.inverted()@D@old);o.data.update()
# Fixed roots are exactly at the library origin; keep geometry and child world positions.
for name in ['Hull','Hull_Damage','Wreck_Hull','Track_L','Track_R','Track_L_Broken','Track_R_Broken']:
 o=bpy.data.objects[name];bpy.context.view_layer.update();old=o.matrix_world.copy();children={q:q.matrix_world.copy() for q in o.children};new=old.copy();new.translation=(0,0,0);o.matrix_world=new
 if o.type=='MESH':o.data.transform(new.inverted()@old)
 bpy.context.view_layer.update()
 for q,mw in children.items():q.matrix_world=mw
# Slack wreck links rest on the ground rather than penetrating it.
for v in WreckHull.data.vertices:
 if v.co.z<0:v.co.z=0
bpy.context.view_layer.update()
export('chi-ha-tank.glb',healthy)
export('chi-ha-tank-damage.glb',damage)
print('DAMAGE_EXPORTED',flush=True)

# Authoritative manifest uses exported world coordinates, not Blender's Z-up frame.
def glpos(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
def triangles(obs):
 return sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in obs if o.type=='MESH')
def bounds(obs):
 ps=[o.matrix_world@Vector(v) for o in obs if o.type=='MESH' for v in o.bound_box]
 xyz=np.array([glpos(v) for v in ps]);return {'min':xyz.min(0).tolist(),'max':xyz.max(0).tolist(),'dimensions':(xyz.max(0)-xyz.min(0)).tolist()}
paints=[{'name':'khaki brown','linear_rgb':[.24,.21,.11]},{'name':'olive drab','linear_rgb':[.18,.20,.095]},{'name':'forest green','linear_rgb':[.075,.14,.065]},{'name':'desert tan','linear_rgb':[.49,.38,.22]},{'name':'field grey','linear_rgb':[.20,.235,.21]},{'name':'winter white','linear_rgb':[.78,.80,.76]}]
manifest={'asset':'Type 97 Chi-Ha style','contract':'tank-damage/v1','frame':'+X left, +Y up, +Z gun forward; metres','files':{},'paints':paints,'damage':{'parts':[{'part':o.name,'twin':('Dmg_'+o.name[5:]) if ('Dmg_'+o.name[5:]) in {q.name for q in twins} else None} for o in healthy if o.name.startswith('Part_')],'scars':[q.name for q in scars],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'proof_layout':{'states':['healthy','hit','stripped','immobilised','wreck','blown turret'],'paints':'row-major order of paints array'},'historical_rig_notes':'No roof MG or separate loader hatch on this turret. Rear turret MG fixed into Turret; bow MG fixed into Hull. Wheels sorted front to back including return rollers; role is stored as node extras.'}
for file,obs in [('chi-ha-tank.glb',healthy),('chi-ha-tank-damage.glb',damage)]:
 manifest['files'][file]={'triangles':triangles(obs),'bounds':bounds(obs),'nodes':[{'name':o.name,'parent':o.parent.name if o.parent else None,'world_position':glpos(o.matrix_world.translation),'type':o.type} for o in obs]}
manifest['build_script_sha256']=hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
for file in manifest['files']:
 data=(ROOT/file).read_bytes();manifest['files'][file]['bytes']=len(data);manifest['files'][file]['sha256']=hashlib.sha256(data).hexdigest()
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
# Drop all authored objects/materials before proof: both GLBs are imported into an empty scene.
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials):bpy.data.materials.remove(m)
for g in list(bpy.data.node_groups):
 if g.name.startswith('glTF Material Output'):bpy.data.node_groups.remove(g)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'chi-ha-tank.glb')); A=list(S.objects)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'chi-ha-tank-damage.glb')); B=[o for o in S.objects if o not in A]
for o in A+B:o.rotation_mode='XYZ'
# Blender adds numeric suffixes to the duplicate material names on import; all Paint copies share variants.
cam=setup_stage();camera(cam)
def vis(obs,value):
 for o in obs:o.hide_render=not value

def state(which):
 vis(A,which in ['healthy','hit','stripped','immobilised']);vis(B,False)
 if which=='hit':
  for o in B:
   if o.name.startswith('Scar_'):o.hide_render=False
  chosen=['Fender_FL','Fender_FR','Toolbox_L','MufflerGuard_L','EngineGrille_L','Turret_RadioRail']
  for name in chosen:
   bpy.data.objects['Part_'+name].hide_render=True;bpy.data.objects['Dmg_'+name].hide_render=False
 elif which=='stripped':
  for o in A:
   if o.name.startswith('Part_'):o.hide_render=True
 elif which=='immobilised':
  bpy.data.objects['Track_L'].hide_render=True;bpy.data.objects['Track_L_Broken'].hide_render=False
  gun=bpy.data.objects['Gun']
  for o in [gun]+list(gun.children_recursive):o.hide_render=True
  bpy.data.objects['Gun_Broken'].hide_render=False
 elif which in ['wreck','blown']:
  bpy.data.objects['Wreck_Hull'].hide_render=False;bpy.data.objects['Wreck_Turret'].hide_render=False

state('healthy');render('final.png',1920,1080,48)
camera(cam,True);render('side.png',1920,1080,32);camera(cam)
# Clay proof overrides materials only during this render.
clay=bpy.data.materials.new('Clay proof');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.37,.37,.37,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.72
S.view_layers[0].material_override=clay;render('clay.png',1920,1080,32);S.view_layers[0].material_override=None
# Pivot exercise: origin world positions must remain fixed under local rotations.
records=[]
for o in A:
 if o.name in ['Turret','Gun','Barrel','Hatch_Commander','Hatch_Driver'] or o.name.startswith('Wheel_'):
  old=o.rotation_euler.copy();before=o.matrix_world.translation.copy();axis=2 if o.name=='Turret' else 0;o.rotation_euler[axis]+=pi/6;bpy.context.view_layer.update();err=(o.matrix_world.translation-before).length
  records.append({'node':o.name,'axis_blender':'Z' if axis==2 else 'X','origin_drift_m':err});o.rotation_euler=old
# Simultaneous articulated view, visible evidence of local pivots.
rotated=[]
for name,axis in [('Turret',2),('Gun',0),('Hatch_Commander',0),('Hatch_Driver',0)]+[(o.name,0) for o in A if o.name.startswith('Wheel_')]:
 o=bpy.data.objects[name];rotated.append((o,o.rotation_euler.copy()));o.rotation_euler[axis]+=(-1 if name in ['Gun','Hatch_Commander','Hatch_Driver'] else 1)*pi/6
render('rig-check.png',1280,720,16)
for o,rot in rotated:o.rotation_euler=rot
manifest['pivot_checks']=records;(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
# Contact sheets are assembled from actual exported-asset renders with Blender image pixels.
def grid(files,out):
 ims=[bpy.data.images.load(str(ROOT/f),check_existing=False) for f in files];w,h=ims[0].size; arr=np.zeros((h*2,w*3,4),dtype=np.float32)
 for k,im in enumerate(ims):
  pix=np.array(im.pixels[:],dtype=np.float32).reshape(h,w,4);row=1-k//3;col=k%3;arr[row*h:(row+1)*h,col*w:(col+1)*w]=pix
 image=bpy.data.images.new(out,width=w*3,height=h*2,alpha=True);image.pixels.foreach_set(arr.ravel());image.filepath_raw=str(ROOT/out);image.file_format='PNG';image.save()
 for im in ims:bpy.data.images.remove(im)
paint_mats=[m for m in bpy.data.materials if m.name=='Paint' or m.name.startswith('Paint.')]
def set_paint(rgb):
 for m in paint_mats:
  nt=m.node_tree;p=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');link=p.inputs['Base Color'].links[0];src=link.from_socket;node=link.from_node
  if node.type=='MIX' and node.data_type=='RGBA':
   # Importer uses ShaderNodeMix for glTF baseColorFactor.
   node.inputs[7].default_value=(*rgb,1)
  elif node.type=='MIX_RGB':node.inputs[2].default_value=(*rgb,1)
  else:
   mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*rgb,1);nt.links.new(src,mix.inputs[1]);nt.links.new(mix.outputs[0],p.inputs['Base Color'])
state('healthy');camera(cam)
for k,paint in enumerate(paints):set_paint(paint['linear_rgb']);render(f'paint-{k}.png',640,360,16)
grid([f'paint-{k}.png' for k in range(6)],'paints.png');set_paint(paints[0]['linear_rgb'])
camera(cam,wide=True)
for k,st in enumerate(['healthy','hit','stripped','immobilised','wreck','blown']):
 state(st)
 if st=='blown':
  o=bpy.data.objects['Wreck_Turret'];o.location=(2.30,.45,.80);o.rotation_euler=(0,pi/2,.35);bpy.context.view_layer.update();o.location.z-=min((o.matrix_world@v.co).z for v in o.data.vertices)
 render(f'state-{k}.png',960,540,24)
grid([f'state-{k}.png' for k in range(6)],'states.png')
(ROOT/'NOTES.md').write_text('\n'.join([
 'Type 97 Chi-Ha style medium tank; articulated healthy asset plus tank-damage/v1 swaps and burnt wreck, no insignia.',
 f'Triangles: healthy {manifest["files"]["chi-ha-tank.glb"]["triangles"]:,}; damage {manifest["files"]["chi-ha-tank-damage.glb"]["triangles"]:,}.',
 'Materials: Paint (neutral baked detail × khaki factor), TrackSteel, Rubber, Scars, Interior, Lamps; damage adds Burnt.',
 'Textures: Paint base/normal 1024²; all other base/normal and every ORM 512²; Cycles GPU baked photographic/procedural tiles.',
 'Poly Haven CC0 IDs: green_metal_rust, rusty_metal_02; https://polyhaven.com/license ; downloaded diff/nor_gl/arm 1k JPGs.',
 'Reference differences: simplified suspension castings, lighter corrosion and a compact loop antenna to preserve 2.23 m height; no roof MG or separate loader hatch on this turret.'
])+'\n')
print('COMPLETE',json.dumps({f:{'triangles':v['triangles'],'bounds':v['bounds']} for f,v in manifest['files'].items()}),flush=True)
