#!/usr/bin/env python3
"""Type 99A-style tank. Self-contained Blender 5.2 build, bake, export and proof.
Author in metres: (x,forward,up) converts to Blender (x,-forward,up).
Run blender -b --python-exit-code 1 --python build_type99-tank.py
Optional --preflight exercises a Cycles bake and glTF roundtrip.
"""
import bpy, bmesh, math, random, json, os, sys, subprocess, struct, time
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin,cos,pi
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(99)
START=time.time()
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
SC=bpy.context.scene
SC.render.engine='CYCLES'; SC.cycles.samples=24; SC.cycles.use_denoising=True
pref=bpy.context.preferences.addons['cycles'].preferences
try:
 pref.compute_device_type='OPTIX'; pref.get_devices()
 for d in pref.devices: d.use=d.type!='CPU'
 SC.cycles.device='GPU'
except Exception:
 pref.compute_device_type='CUDA'; pref.get_devices()
 for d in pref.devices: d.use=d.type!='CPU'
 SC.cycles.device='GPU'
SC.render.image_settings.file_format='PNG'
SC.view_settings.view_transform='AgX'
print('DEVICE',[(d.name,d.type,d.use) for d in pref.devices],flush=True)
PAINTS=[('Olive drab',[.12,.15,.09]),('NATO green',[.075,.12,.065]),('Desert tan',[.48,.36,.20]),('Field grey',[.18,.21,.18]),('Winter white',[.72,.75,.70]),('Dark green-black',[.035,.05,.034])]
MATS={}; BAKE_LOG=[]
def active(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def download(asset,kind):
 p=TEX/f'{asset}_{kind}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
  subprocess.run(['curl','-fL','--retry','2','-A','Mozilla/5.0',url,'-o',str(p)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
 return p

def image_node(nt,path,noncolor=False):
 n=nt.nodes.new('ShaderNodeTexImage'); n.image=bpy.data.images.load(str(path),check_existing=True)
 if noncolor:n.image.colorspace_settings.name='Non-Color'
 return n

def bake_material(name,asset,tint,metal,rough,size=512):
 paths=[TEX/f'{name}_{k}.png' for k in ['base','normal','orm']]
 if not all(p.exists() for p in paths) or '--rebake' in sys.argv:
  source=bpy.data.materials.new(name+'_SOURCE'); source.use_nodes=True
  nt=source.node_tree; nt.nodes.clear(); L=nt.links
  out=nt.nodes.new('ShaderNodeOutputMaterial'); bs=nt.nodes.new('ShaderNodeBsdfPrincipled'); L.new(bs.outputs['BSDF'],out.inputs['Surface'])
  uv=nt.nodes.new('ShaderNodeTexCoord')
  diff=image_node(nt,download(asset,'diff')); nor=image_node(nt,download(asset,'nor_gl'),True); arm=image_node(nt,download(asset,'arm'),True)
  for n in [diff,nor,arm]:L.new(uv.outputs['UV'],n.inputs['Vector'])
  bw=nt.nodes.new('ShaderNodeRGBToBW'); L.new(diff.outputs['Color'],bw.inputs[0])
  ramp=nt.nodes.new('ShaderNodeValToRGB'); L.new(bw.outputs[0],ramp.inputs[0])
  if name=='Paint':
   ramp.color_ramp.elements[0].position=.004; ramp.color_ramp.elements[0].color=(.18,.18,.18,1)
   ramp.color_ramp.elements[1].position=.12; ramp.color_ramp.elements[1].color=(.98,.98,.98,1)
  else:
   ramp.color_ramp.elements[0].color=tuple(c*.38 for c in tint)+(1,)
   ramp.color_ramp.elements[1].color=tuple(min(1,c*1.8) for c in tint)+(1,)
  noise=nt.nodes.new('ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=95; noise.inputs['Detail'].default_value=3
  L.new(uv.outputs['UV'],noise.inputs['Vector'])
  mix=nt.nodes.new('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=.18
  L.new(ramp.outputs['Color'],mix.inputs[1]); L.new(noise.outputs['Fac'],mix.inputs[2]); L.new(mix.outputs[0],bs.inputs['Base Color'])
  if name=='Paint':
   mapping=nt.nodes.new('ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(32,2.2,1);L.new(uv.outputs['UV'],mapping.inputs[0])
   streak=nt.nodes.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=2.5;streak.inputs['Detail'].default_value=5;streak.inputs['Roughness'].default_value=.7;L.new(mapping.outputs[0],streak.inputs['Vector'])
   sr=nt.nodes.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.29;sr.color_ramp.elements[0].color=(.30,.30,.30,1);sr.color_ramp.elements[1].position=.65;sr.color_ramp.elements[1].color=(1,1,1,1);L.new(streak.outputs['Fac'],sr.inputs[0])
   oldmix=mix;mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.47;L.new(oldmix.outputs[0],mix.inputs[1]);L.new(sr.outputs[0],mix.inputs[2]);L.new(mix.outputs[0],bs.inputs['Base Color'])

  if name=='Burnt':
   soot=nt.nodes.new('ShaderNodeTexNoise');soot.inputs['Scale'].default_value=4;soot.inputs['Detail'].default_value=4;L.new(uv.outputs['UV'],soot.inputs['Vector'])
   heat=nt.nodes.new('ShaderNodeValToRGB');heat.color_ramp.elements[0].position=.30;heat.color_ramp.elements[0].color=(.035,.035,.035,1);heat.color_ramp.elements[1].position=.7;heat.color_ramp.elements[1].color=(1,1,1,1);L.new(soot.outputs['Fac'],heat.inputs[0])
   oldmix=mix;mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.85;L.new(oldmix.outputs[0],mix.inputs[1]);L.new(heat.outputs[0],mix.inputs[2]);L.new(mix.outputs[0],bs.inputs['Base Color'])
  sep=nt.nodes.new('ShaderNodeSeparateColor'); L.new(arm.outputs['Color'],sep.inputs[0])
  combine=nt.nodes.new('ShaderNodeCombineColor'); combine.inputs[0].default_value=1
  rm=nt.nodes.new('ShaderNodeMath'); rm.operation='MULTIPLY_ADD'; rm.inputs[1].default_value=.23; rm.inputs[2].default_value=rough-.115;L.new(sep.outputs[1],rm.inputs[0])
  L.new(sep.outputs[0],combine.inputs[0]);L.new(rm.outputs[0],combine.inputs[1]);combine.inputs[2].default_value=metal
  L.new(rm.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
  normal=nt.nodes.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.32 if name=='Paint' else .7;L.new(nor.outputs['Color'],normal.inputs['Color']);L.new(normal.outputs[0],bs.inputs['Normal'])
  bpy.ops.mesh.primitive_plane_add(size=2); plane=bpy.context.object;plane.data.materials.append(source);active(plane)
  SC.cycles.samples=8;SC.render.bake.margin=4
  em=nt.nodes.new('ShaderNodeEmission')
  for k,path in zip(['base','normal','orm'],paths):
   res=size if k!='orm' else min(size,512)
   img=bpy.data.images.new(name+'_'+k,width=res,height=res,alpha=False)
   if k!='base':img.colorspace_settings.name='Non-Color'
   target=nt.nodes.new('ShaderNodeTexImage');target.image=img;nt.nodes.active=target
   if k=='normal':
    L.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
   else:
    L.new(mix.outputs[0] if k=='base' else combine.outputs[0],em.inputs[0]);L.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
   img.filepath_raw=str(path);img.file_format='PNG';img.save();nt.nodes.remove(target)
  bpy.data.objects.remove(plane,do_unlink=True);bpy.data.materials.remove(source)
  BAKE_LOG.append({'material':name,'source':asset,'passes':['Cycles EMIT base','Cycles NORMAL','Cycles EMIT ORM'],'samples':8,'size':size})
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;L=nt.links;bs=nt.nodes.get('Principled BSDF')
 base=image_node(nt,paths[0]);normal=image_node(nt,paths[1],True);orm=image_node(nt,paths[2],True)
 if name=='Paint':
  mult=nt.nodes.new('ShaderNodeMixRGB');mult.name='PaintFactor';mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;mult.inputs[2].default_value=tuple(PAINTS[0][1])+(1,)
  L.new(base.outputs[0],mult.inputs[1]);L.new(mult.outputs[0],bs.inputs['Base Color'])
 else:L.new(base.outputs[0],bs.inputs['Base Color'])
 nm=nt.nodes.new('ShaderNodeNormalMap');L.new(normal.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
 sep=nt.nodes.new('ShaderNodeSeparateColor');L.new(orm.outputs[0],sep.inputs[0]);L.new(sep.outputs[1],bs.inputs['Roughness']);L.new(sep.outputs[2],bs.inputs['Metallic'])
 if name=='Lamps':bs.inputs['Emission Color'].default_value=(.35,.29,.18,1);bs.inputs['Emission Strength'].default_value=.25
 grp=bpy.data.node_groups.get('glTF Material Output')
 if not grp:
  grp=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');grp.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=nt.nodes.new('ShaderNodeGroup');gn.node_tree=grp;L.new(sep.outputs[0],gn.inputs['Occlusion'])
 m.diffuse_color=tuple(PAINTS[0][1] if name=='Paint' else tint)+(1,);MATS[name]=m
 print('MATERIAL',name,flush=True)
 return m

# All photographic inputs are CC0 Poly Haven. Neutral paint remains recolourable.
for spec in [('Paint','blue_metal_plate',(.8,.8,.8),.12,.78,1024),('TrackSteel','rusty_metal_02',(.19,.135,.09),.7,.78,512),('Rubber','rubberized_track',(.029,.031,.027),0,.89,512),('Steel','blue_metal_plate',(.15,.17,.18),.82,.42,512),('Weapon','blue_metal_plate',(.12,.15,.09),.16,.76,512),('Glass','blue_metal_plate',(.035,.075,.09),.6,.2,256),('Lamps','blue_metal_plate',(.7,.65,.48),.1,.28,128),('Burnt','rusty_metal_02',(.025,.022,.019),.35,.92,512),('Scars','rusty_metal_02',(.24,.25,.24),.8,.63,512)]:
 bake_material(*spec)
 if '--preflight' in sys.argv:break
if '--preflight' in sys.argv:
 bpy.ops.mesh.primitive_cube_add();o=bpy.context.object;o.data.materials.append(MATS['Paint'])
 bpy.ops.export_scene.gltf(filepath=str(ROOT/'preflight.glb'),export_format='GLB')
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False);bpy.ops.import_scene.gltf(filepath=str(ROOT/'preflight.glb'))
 print('PREFLIGHT_OK',flush=True);sys.exit(0)

# Mesh assemblers build all vertices relative to their final pivot. Moving objects
# never undergo object transform_apply. Solid underside surfaces are retained.
def V(p):return Vector((p[0],-p[1],p[2]))
def logical(p):return (p[0],-p[1],p[2])
class Build:
 def __init__(self,name,pivot=(0,0,0),parent=None,bevel=.006):
  self.name=name;self.pivot=Vector(pivot);self.parent=parent;self.v=[];self.f=[];self.mi=[];self.sm=[];self.bevel=bevel;self.trackuv=False
 def mesh(self,verts,faces,mat='Paint',smooth=False):
  off=len(self.v);self.v.extend([tuple(V(Vector(p)-self.pivot)) for p in verts]);self.f.extend([tuple(off+i for i in f) for f in faces]);self.mi.extend([list(MATS).index(mat)]*len(faces));self.sm.extend([smooth]*len(faces))
 def box(self,c,d,mat='Paint',rot=None):
  c=Vector(c);vs=[]
  for x,y,z in [(-1,-1,-1),(-1,-1,1),(-1,1,-1),(-1,1,1),(1,-1,-1),(1,-1,1),(1,1,-1),(1,1,1)]:
   q=Vector((x*d[0]/2,y*d[1]/2,z*d[2]/2));vs.append(c+(rot@q if rot else q))
  self.mesh(vs,[(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3),(0,2,3,1),(4,5,7,6)],mat)
 def cyl(self,a,b,r,mat='Paint',n=24,r2=None):
  a=Vector(a);b=Vector(b);w=(b-a).normalized();u=w.cross(Vector((0,0,1)))
  if u.length<.01:u=w.cross(Vector((0,1,0)))
  u.normalize();v=w.cross(u);r2=r if r2 is None else r2
  vs=[p+rad*(cos(2*pi*i/n)*u+sin(2*pi*i/n)*v) for p,rad in [(a,r),(b,r2)] for i in range(n)]
  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)]
  self.mesh(vs,fs,mat);self.sm[-len(fs):]=[False,False]+[True]*(len(fs)-2)
 def line(self,points,r=.014,mat='Paint',n=8):
  for a,b in zip(points,points[1:]):self.cyl(a,b,r,mat,n)
 def lathe(self,center,profile,axis='X',mat='Paint',n=48):
  # profile = (axial offset,radius), closed outline forms wheel tyre/rim/tube.
  c=Vector(center);vs=[]
  for q,r in profile:
   for i in range(n):
    a=2*pi*i/n
    p=(q,r*cos(a),r*sin(a)) if axis=='X' else ((r*cos(a),q,r*sin(a)) if axis=='F' else (r*cos(a),r*sin(a),q))
    vs.append(c+Vector(p))
  fs=[]
  for j in range(len(profile)):
   k=(j+1)%len(profile)
   for i in range(n):fs.append((j*n+i,j*n+(i+1)%n,k*n+(i+1)%n,k*n+i))
  self.mesh(vs,fs,mat,smooth=True)
 def prism(self,poly,z0,z1,mat='Paint'):
  n=len(poly);vs=[(x,y,z) for z in [z0,z1] for x,y in poly];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)];self.mesh(vs,fs,mat)
 def loft(self,rings,mat='Paint'):
  n=len(rings[0]);vs=sum(rings,[]);fs=[tuple(range(n-1,-1,-1)),tuple(range((len(rings)-1)*n,len(rings)*n))]
  for j in range(len(rings)-1):
   for i in range(n):fs.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
  self.mesh(vs,fs,mat)
 def finish(self):
  me=bpy.data.meshes.new(self.name);me.from_pydata(self.v,[],self.f);me.update()
  for m in MATS.values():me.materials.append(m)
  for f,idx,sm in zip(me.polygons,self.mi,self.sm):f.material_index=idx;f.use_smooth=sm
  bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free()
  o=bpy.data.objects.new(self.name,me);SC.collection.objects.link(o);o.location=V(self.pivot)
  if self.bevel:
   mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=self.bevel;mod.segments=1;mod.affect='EDGES';mod.limit_method='ANGLE';mod.angle_limit=.60
   active(o);bpy.ops.object.modifier_apply(modifier=mod.name)
  # Planar metre-scale tiling; face normal chooses axes. Repeated materials share maps.
  uv=o.data.uv_layers.new(name='UVMap')
  for p in o.data.polygons:
   n=p.normal;axis=max(range(3),key=lambda i:abs(n[i]));axes=[i for i in range(3) if i!=axis]
   for li in p.loop_indices:
    co=o.data.vertices[o.data.loops[li].vertex_index].co+o.location
    uv.data[li].uv=(co[axes[0]]*.72,co[axes[1]]*.72)
   # Preserve flat armour faces and smooth cylindrical side faces.
  # Smooth only nearly coplanar round faces; use auto smooth angle via bmesh split flag.
  bm=bmesh.new();bm.from_mesh(o.data)
  for e in bm.edges:
   if len(e.link_faces)==2 and e.calc_face_angle(0)<.45:e.smooth=True
   else:e.smooth=False
  # Smooth flags come from the generating primitive, never a face-area guess.
  bm.to_mesh(o.data);bm.free()
  if self.parent:parent_keep(o,self.parent)
  return o

def parent_keep(o,p):
 bpy.context.view_layer.update();m=o.matrix_world.copy();o.parent=p;o.matrix_parent_inverse=Matrix.Identity(4);o.matrix_world=m

def empty(name,p=(0,0,0),parent=None):
 o=bpy.data.objects.new(name,None);SC.collection.objects.link(o);o.location=V(p)
 if parent:parent_keep(o,parent)
 return o

def bolt(g,p,axis='Z',r=.018):
 q=Vector(p);d=Vector((.009,0,0) if axis=='X' else (0,.009,0) if axis=='F' else (0,0,.009));g.cyl(q-d,q+d,r,'Paint',6)
def handle(g,c,w=.18,axis='F'):
 x,y,z=c
 if axis=='F':g.line([(x-w/2,y,z),(x-w/2,y,z+.055),(x+w/2,y,z+.055),(x+w/2,y,z)],.012)
 else:g.line([(x,y-w/2,z),(x+.06,y-w/2,z),(x+.06,y+w/2,z),(x,y+w/2,z)],.012)
def mesh_tri(o):return sum(len(p.vertices)-2 for p in o.data.polygons) if o.type=='MESH' else 0

# HULL: a sloped, multi-section welded shell with full suspension sidewalls.
h=Build('Hull')
sections=[(3.80,1.55,.65,1.00),(3.20,1.65,.52,1.38),(1.70,1.65,.45,1.48),(-2.90,1.65,.45,1.46),(-3.80,1.53,.65,1.36)]
rings=[]
for f,w,z0,z1 in sections:rings.append([(-w,f,z0),(w,f,z0),(w,f,z1),(-w,f,z1)])
h.loft(rings)
# Welded nose reinforcing bar, towing bosses, lifting lugs, concealed skirt mounts.
h.box((0,3.59,.79),(2.75,.12,.16));h.box((0,-3.72,.92),(2.9,.12,.30))
for s in [-1,1]:
 for f in np.linspace(-3.25,3.1,10):
  h.box((s*1.67,float(f),1.30),(.12,.14,.13));bolt(h,(s*1.737,float(f),1.33),'X')
 for f in [3.43,-3.66]:
  h.lathe((s*1.06,f,.77),[(-.045,.13),(.045,.13),(.045,.075),(-.045,.075)],axis='F',n=20)
 # suspension arms under skirts, stay behind removable wheels
 for f in [2.47,1.48,.49,-.50,-1.49,-2.48]:
  h.cyl((s*1.25,f+.22,.79),(s*1.40,f,.52),.075,'Steel',16)
  h.cyl((s*1.22,f,.52),(s*1.52,f,.52),.105,'Steel',20)
 # complete track shelf
 h.box((s*1.45,-.05,1.37),(.60,7.1,.075))
 for f in [2.9,1.8,.7,-.4,-1.5,-2.6]:h.line([(s*1.2,f,1.49),(s*1.61,f,1.49)],.008,'Steel',6)
# Front driver position and engine deck permanent seams.
for f in [-2.0,-2.7,-3.35]:h.box((0,f,1.469),(2.4,.013,.008),'Steel')
for x in [-1.15,1.15]:h.box((x,-2.45,1.475),(.012,2.3,.012),'Steel')
# Turret ring cavity cut before attachment; cutter real opening.
hull=h.finish()
def boolean_cylinder(o,c,r,depth):
 # Boolean only the connected armour shell; overlapping fittings are preserved
 # separately. Applying a Boolean to the complete assembly creates cutter caps.
 bm=bmesh.new();bm.from_mesh(o.data);bm.verts.ensure_lookup_table();remaining=set(bm.verts);groups=[]
 while remaining:
  seed=remaining.pop();group={seed};todo=[seed]
  while todo:
   v=todo.pop()
   for edge in v.link_edges:
    v2=edge.other_vert(v)
    if v2 in remaining:remaining.remove(v2);group.add(v2);todo.append(v2)
  groups.append(group)
 def volume(g):
  return math.prod(max(v.co[i] for v in g)-min(v.co[i] for v in g) for i in range(3))
 shell=max(groups,key=volume);ids={v.index for v in shell}
 rest=bpy.data.objects.new('_savedFittings',o.data.copy());SC.collection.objects.link(rest);rest.matrix_world=o.matrix_world.copy()
 br=bmesh.new();br.from_mesh(rest.data);br.verts.ensure_lookup_table();bmesh.ops.delete(br,geom=[v for v in br.verts if v.index in ids],context='VERTS');br.to_mesh(rest.data);br.free()
 bmesh.ops.delete(bm,geom=[v for v in bm.verts if v.index not in ids],context='VERTS');bm.to_mesh(o.data);bm.free()
 bpy.ops.mesh.primitive_cylinder_add(vertices=64,radius=r,depth=depth,location=V(c));cut=bpy.context.object
 mod=o.modifiers.new('Open crew well','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cut;active(o);bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
 active(o);rest.select_set(True);bpy.ops.object.join()

boolean_cylinder(hull,(0,.15,1.5),.94,.6)
# Driver hatch true cut and dark well
boolean_cylinder(hull,(-.62,2.12,1.46),.27,.75)
g=Build('_hullRing',bevel=.004)
g.lathe((0,.15,1.43),[(-.16,.96),(.10,.96),(.10,1.06),(-.16,1.06)],'Z')
g.lathe((0,.15,1.24),[(-.26,.94),(.07,.94),(.07,.88),(-.26,.88)],'Z','Rubber')
g.cyl((0,.15,.87),(0,.15,.89),.9,'Rubber',64)
g.lathe((-.62,2.12,1.39),[(-.38,.275),(.10,.275),(.10,.30),(-.38,.30)],'Z','Rubber',32)
g.cyl((-.62,2.12,1.0),(-.62,2.12,1.02),.29,'Rubber')
# merge only this static hull structure
ob=g.finish()
def join_objs(obs,name,pivot=(0,0,0),parent=None):
 obs=[o for o in obs if o and o.type=='MESH'];bpy.ops.object.select_all(action='DESELECT')
 for o in obs: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();target=V(pivot);delta=mw.inverted()@target
 for v in o.data.vertices:v.co-=delta
 o.location=target
 if parent:parent_keep(o,parent)
 return o
hull=join_objs([hull,ob],'Hull')
# Running gear: six double road wheels, end wheels, three return rollers per side.
wheel_specs=[]
for side,s in [('L',1),('R',-1)]:
 count=0
 for f,z,r,kind in sorted([(3.28,.91,.32,'idler')]+[(v,.54,.455,'road') for v in [2.47,1.48,.49,-.50,-1.49,-2.48]]+[(-3.28,.90,.35,'sprocket')]+[(v,1.16,.17,'return') for v in [1.9,0,-1.9]],reverse=True):
  count+=1;c=(s*1.49,f,z);g=Build(f'Wheel_{side}{count}',c,bevel=.0025)
  for off in [-.13,.13]:
   if kind=='road':
    g.lathe((s*1.49+off,f,z),[(-.09,r-.055),(-.09,r-.015),(-.07,r),(.07,r),(.09,r-.015),(.09,r-.055)],mat='Rubber',n=48)
   # deep dished steel wheel and stamped ridges
   g.lathe((s*1.49+off,f,z),[(-.075,.075),(-.075,r-.058),(-.055,r-.04),(.045,r-.04),(.075,r-.065),(.075,r-.12),(.03,r-.16),(.025,.14),(.09,.10),(.09,.075)],mat='Paint',n=48 if kind=='road' else 32)
  outer=s*1.74
  g.cyl((outer-s*.07,f,z),(outer,f,z),.115 if kind=='road' else .095,'Paint',24)
  for i in range(8):
   a=i*2*pi/8
   bolt(g,(outer+s*.007,f+.15*cos(a),z+.15*sin(a)),'X',.016)
   if kind=='road':
    # radial pressed recesses and spoke embossing
    rot=Matrix.Rotation(-a,3,'X');g.box((outer-s*.07,f+.275*cos(a),z+.275*sin(a)),(.036,.13,.055),'Paint',rot)
  if kind=='sprocket':
   for i in range(14):
    a=i*2*pi/14;g.box((s*1.49,f+(r+.015)*cos(a),z+(r+.015)*sin(a)),(.40,.075,.068),'Steel',Matrix.Rotation(a,3,'X'))
  o=g.finish();wheel_specs.append(o)

# Track path is a capsule with slightly sloping end runs; individually modelled
# shoes, twin rubber pads, guide horns and lateral pin heads.
path=[(3.32,.94),(3.13,1.19),(2.80,1.28),(-2.95,1.28),(-3.35,1.12),(-3.54,.83),(-3.35,.53),(-2.66,.065),(2.65,.065),(3.31,.58)]
def sample_path(path,spacing=.145,closed=True):
 pp=path+([path[0]] if closed else []);lengths=[math.dist(a,b) for a,b in zip(pp,pp[1:])];total=sum(lengths);n=round(total/spacing);out=[]
 for i in range(n):
  dist=(i+.5)*total/n;acc=0
  for j,l in enumerate(lengths):
   if dist<=acc+l:
    t=(dist-acc)/l;a=pp[j];b=pp[j+1];out.append((a[0]+t*(b[0]-a[0]),a[1]+t*(b[1]-a[1]),math.atan2(b[1]-a[1],b[0]-a[0]),i/n,1/n));break
   acc+=l
 return out
track_samples=sample_path(path)
track_objects={}
def make_track(name,s,samples):
 g=Build(name,bevel=.003)
 for f,z,a,v,dv in samples:
  rot=Matrix.Rotation(a,3,'X')
  g.box((s*1.51,f,z),(.59,.135,.082),'TrackSteel',rot)
  for x in [-.165,.165]:g.box((s*1.51+x,f+sin(a)*.054,z-cos(a)*.054),(.26,.093,.022),'Rubber',rot)
  # Guide horn faces inward on the loop (left side of traversal)
  g.box((s*1.51,f-sin(a)*.09,z+cos(a)*.09),(.07,.065,.09),'TrackSteel',rot)
  for x in [-.301,.301]:g.cyl((s*1.51+x-.008,f,z),(s*1.51+x+.008,f,z),.030,'TrackSteel',10)
 o=g.finish()
 # Exact continuous belt UV: U across track, V follows closest path location.
 uv=o.data.uv_layers.active
 for p in o.data.polygons:
  cen=p.center;f=-cen.y;z=cen.z;j=min(range(len(samples)),key=lambda i:(samples[i][0]-f)**2+(samples[i][1]-z)**2);ff,zz,a,v,dv=samples[j]
  for li in p.loop_indices:
   q=o.data.vertices[o.data.loops[li].vertex_index].co
   along=(-q.y-ff)*cos(a)+(q.z-zz)*sin(a)
   uv.data[li].uv=((q.x-s*1.51)/.65+.5,v+along/.145*dv)
 return o
for side,s in [('L',1),('R',-1)]:track_objects[side]=make_track('Track_'+side,s,track_samples)

# Detachable skirts, fenders, frontal ERA, rear equipment.
parts=[]
def part(g):
 o=g.finish();bpy.context.view_layer.update()
 pts=[o.matrix_world@Vector(c) for c in o.bound_box];center=sum(pts,Vector())/8;delta=o.matrix_world.inverted()@center
 for v in o.data.vertices:v.co-=delta
 mw=o.matrix_world.copy();mw.translation=center;o.matrix_world=mw;parts.append(o);return o
for side,s in [('L',1),('R',-1)]:
 for i,f in enumerate([2.69,1.56,.43,-.70,-1.83,-2.94],1):
  g=Build(f'Part_Skirt_{side}{i}',(s*1.80,f,1.06),bevel=.009)
  # tapered lower lip, not rectangular slabs
  top=1.41;bottom=.79 if i<5 else .87
  rings=[[(s*1.757,f-.55,top),(s*1.827,f-.55,top),(s*1.827,f-.55,bottom+.1),(s*1.757,f-.55,bottom+.1)],[(s*1.757,f+.55,top),(s*1.827,f+.55,top),(s*1.827,f+.55,bottom),(s*1.757,f+.55,bottom)]]
  g.loft(rings,'Paint' if i<=4 else 'Rubber')
  for ff in [f-.42,f,f+.42]:bolt(g,(s*1.835,ff,1.35),'X',.018)
  g.box((s*1.829,f,.93),(.012,.15,.055),'Rubber')
  if i<=2:
   g.box((s*1.829,f,1.18),(.025,1.035,.32))
   for ff in [f-.48,f+.48]:g.box((s*1.844,ff,1.18),(.01,.015,.3),'Steel')
  part(g)
 for front,f in [('F',3.39),('R',-3.49)]:
  g=Build(f'Part_Fender_{front}{side}',(s*1.48,f,1.23),bevel=.014)
  if front=='F':
   profile=[(3.10,1.405),(3.29,1.40),(3.49,1.34),(3.64,1.22),(3.73,1.10)]
  else:profile=[(-3.12,1.405),(-3.34,1.39),(-3.54,1.31),(-3.70,1.20)]
  for (f0,z0),(f1,z1) in zip(profile,profile[1:]):
   g.loft([[(s*1.47-.35,f0,z0),(s*1.47+.35,f0,z0),(s*1.47+.35,f0,z0-.04),(s*1.47-.35,f0,z0-.04)],[(s*1.47-.35,f1,z1),(s*1.47+.35,f1,z1),(s*1.47+.35,f1,z1-.04),(s*1.47-.35,f1,z1-.04)]])
  for x in [s*1.20,s*1.45,s*1.70]:g.line([(x,ff,zz+.012) for ff,zz in profile],.010)
  edgef,edgez=profile[-1];g.box((s*1.48,edgef,edgez-.09),(.72,.032,.18),'Rubber')
  part(g)
 # full length hull storage bins on track shoulders
 for i,f in enumerate([.95,-.15,-1.25,-2.38]):
  g=Build(f'Part_Stowage_{side}{i+1}',(s*1.42,f,1.50),bevel=.011)
  g.box((s*1.42,f,1.51),(.49,.97,.16));g.box((s*1.42,f,1.60),(.51,.98,.025))
  handle(g,(s*1.42,f,1.62),.18)
  for ff in [f-.33,f+.33]:g.box((s*1.68,ff,1.52),(.02,.07,.09),'Steel')
  part(g)
 # lights on front plate with separate guard
 g=Build('_lamp_'+side,bevel=.004)
 g.cyl((s*1.12,3.04,1.50),(s*1.12,3.20,1.50),.112,'Paint',32)
 g.cyl((s*1.12,3.202,1.50),(s*1.12,3.215,1.50),.081,'Lamps',32)
 o=g.finish();hull=join_objs([hull,o],'Hull')
 g=Build('Part_LampGuard_'+side,(s*1.12,3.13,1.53),bevel=.002)
 for x in [s*1.12-.14,s*1.12+.14]:g.line([(x,3.0,1.40),(x,3.22,1.41),(x,3.24,1.65),(x,2.98,1.65)],.018)
 g.line([(s*1.12-.14,3.24,1.65),(s*1.12+.14,3.24,1.65)],.018);part(g)
# Nose ERA tiles slope with glacis; each removable bank.
for k,x in enumerate([-.62,0,.62]):
 g=Build(f'Part_GlacisERA_{k+1}',(x,2.82,1.32),bevel=.006)
 for j in range(3):
  f=2.40+j*.28;z=1.48-(f-1.7)*.06667+.057
  g.box((x,f,z),(.59,.265,.083),rot=Matrix.Rotation(-.0666,3,'X'))
  for xx in [x-.23,x+.23]:bolt(g,(xx,f,z+.047))
 part(g)
for k,x in enumerate([-.61,.61]):
 g=Build(f'Part_EngineGrille_{k+1}',(x,-2.68,1.49),bevel=.003)
 g.box((x,-2.68,1.49),(1.05,1.45,.036),'Rubber')
 for j in range(19):g.box((x,-3.35+j*.075,1.535),(1.03,.033,.035),'Paint',Matrix.Rotation(.25,3,'X'))
 for xx in [x-.55,x+.55]:g.box((xx,-2.68,1.55),(.032,1.50,.045))
 for ff in [-3.43,-1.93]:g.box((x,ff,1.55),(1.13,.035,.045))
 part(g)
g=Build('Part_ExhaustShroud',(0,-3.73,1.06),bevel=.009)
g.box((0,-3.735,1.09),(1.36,.075,.35),'Rubber')
for j in range(8):g.box((0,-3.777,.94+j*.043),(1.40,.038,.025),'Paint')
part(g)
g=Build('Part_TowCable',(0,-3.765,.77),bevel=.001)
points=[(-1.15,-3.75,.9),(-.95,-3.760,.66),(-.4,-3.765,.60),(.4,-3.765,.60),(.95,-3.760,.66),(1.15,-3.75,.9)]
g.line(points,.027,'Steel',12)
for x in [-1.15,1.15]:g.lathe((x,-3.76,.92),[(-.018,.105),(.018,.105),(.018,.068),(-.018,.068)],'F','Steel',20)
part(g)
g=Build('Part_Tools_L',(1.70,-2.9,1.63),bevel=.003)
g.cyl((1.68,-3.38,1.64),(1.68,-2.34,1.64),.023,'Steel',12);g.box((1.68,-3.44,1.64),(.19,.23,.035),'Steel')
for f in [-3.1,-2.6]:g.box((1.68,f,1.66),(.13,.035,.07))
part(g)

# Turret: polygon loft, broad arrow front cheek modules, complete welded shell.
TP=(0,.18,1.55)
g=Build('Turret',TP,bevel=.014)
# octagonal perimeter front along +forward
outline=[(-.54,1.60),(.54,1.60),(1.21,.99),(1.40,-.15),(1.17,-1.72),(-1.17,-1.72),(-1.40,-.15),(-1.21,.99)]
g.loft([[(x*.93,f,1.57) for x,f in outline],[(x,f,1.77) for x,f in outline],[(x*.89,f-.06,2.19-max(0,f-.20)*.125) for x,f in outline]])
g.lathe((0,.18,1.54),[(-.03,.93),(.06,.93),(.06,1.03),(-.03,1.03)],'Z','Steel',64)
# roof seam lines, lifting hooks and sensor supports
for x in [-.92,.92]:
 g.line([(x,-1.28,2.19),(x,-.60,2.20),(x,.1,2.21)],.008,'Steel',6)
 for f in [-1.35,.7]:g.lathe((x,f,2.22),[(-.018,.060),(.018,.060),(.018,.035),(-.018,.035)],'X',n=16)
turret=g.finish()
g=Build('Part_Turret_RoofService',(0,.57,2.174),parent=turret,bevel=.004)
g.box((0,.57,2.172),(.60,.65,.026),rot=Matrix.Rotation(-.125,3,'X'))
for x in [-.25,.25]:
 for f in [.31,.57,.83]:bolt(g,(x,f,2.197-(f-.57)*.125),'Z',.009)
part(g)
# true commander and loader wells
for x,f in [(.64,-.40),(-.63,-.58)]:boolean_cylinder(turret,(x,f,2.18),.295,.65)
g=Build('_turretWells',TP,bevel=.003)
for x,f in [(.64,-.40),(-.63,-.58)]:
 g.lathe((x,f,2.10),[(-.28,.298),(.15,.298),(.15,.335),(-.28,.335)],'Z','Rubber',40)
 g.cyl((x,f,1.80),(x,f,1.82),.30,'Rubber',40)
ob=g.finish();turret=join_objs([turret,ob],'Turret',TP)
for side,s in [('L',1),('R',-1)]:
 # wedge front: highest silhouette fidelity priority
 g=Build('Part_Turret_Cheek_'+side,(s*.91,1.27,1.91),parent=turret,bevel=.008)
 # shaped ring quad cross sections around cheek, sloping top and chamfered base
 verts=[(s*.29,1.94,1.72),(s*1.12,1.67,1.65),(s*1.49,.72,1.72),(s*.49,.87,1.73),
        (s*.38,1.81,2.08),(s*1.08,1.35,2.19),(s*1.43,.62,2.19),(s*.49,.77,2.22)]
 g.mesh(verts,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)])
 # Weld bead follows the actual faceted plate perimeter, not an invented plane.
 for a,b in [(4,5),(5,6),(6,7)]:
  va=Vector(verts[a]);vb=Vector(verts[b]);va.z+=.004;vb.z+=.004;g.line([va,vb],.004,'Steel',6)
 part(g)
 for i,f in enumerate([.35,-.23,-.81,-1.39]):
  g=Build(f'Part_Turret_ERA_{side}{i+1}',(s*1.42,f,1.95),parent=turret,bevel=.006)
  g.box((s*1.425,f,1.945),(.16,.55,.49))
  for ff in [f-.22,f+.22]:
   for z in [1.76,2.14]:bolt(g,(s*1.512,ff,z),'X',.015)
  part(g)
 # smoke launchers, three inclined tubes
 g=Build('Part_Turret_Smoke_'+side,(s*1.52,.72,1.94),parent=turret,bevel=.003)
 g.box((s*1.49,.64,1.92),(.10,.44,.38))
 for j in range(3):
  a=(s*1.52,.77-j*.13,1.83+j*.10);b=(s*1.76,1.02-j*.13,1.93+j*.10)
  g.cyl(a,b,.060,'Paint',20);g.cyl(b,Vector(b)+(Vector(b)-Vector(a)).normalized()*.012,.043,'Rubber',20)
 part(g)
# Rear basket: open tubular frame, closely spaced mesh wires, no floating solid box.
g=Build('Part_Turret_BustleRack',(0,-2.02,1.96),parent=turret,bevel=.001)
for z in [1.66,1.86,2.12]:
 g.line([(-1.30,-1.61,z),(-1.27,-2.16,z),(1.27,-2.16,z),(1.30,-1.61,z)],.022)
for x in np.linspace(-1.25,1.25,19):g.line([(float(x),-2.15,1.66),(float(x),-2.15,2.12)],.0065,'Steel',6)
for z in np.linspace(1.70,2.08,7):g.line([(-1.27,-2.153,float(z)),(1.27,-2.153,float(z))],.0065,'Steel',6)
for x in [-1.27,1.27]:
 for f in np.linspace(-2.12,-1.65,9):g.line([(x,float(f),1.66),(x,float(f),2.12)],.005,'Steel',6)
 for z in np.linspace(1.69,2.09,8):g.line([(x,-2.12,float(z)),(x,-1.65,float(z))],.005,'Steel',6)
for x in [-1.0,-.5,0,.5,1.0]:g.line([(x,-1.68,1.63),(x,-2.16,1.66),(x,-2.16,2.12)],.018)
part(g)
# Laser dazzler with square head and circular optic matches reference roof box.
g=Build('Part_Turret_LaserDazzler',(-.88,-1.12,2.29),parent=turret,bevel=.009)
g.cyl((-.88,-1.12,2.16),(-.88,-1.12,2.27),.11,n=24)
g.box((-.88,-1.12,2.36),(.28,.30,.24));g.box((-.88,-.963,2.36),(.205,.013,.16),'Rubber')
g.cyl((-.88,-.95,2.36),(-.88,-.936,2.36),.064,'Glass',24)
part(g)
g=Build('Part_Turret_GunnerSight',(-.55,.62,2.25),parent=turret,bevel=.008)
g.box((-.55,.62,2.25),(.37,.38,.19));g.box((-.55,.815,2.27),(.28,.014,.105),'Glass');g.box((-.55,.79,2.37),(.40,.16,.022));part(g)
for side,s in [('L',1),('R',-1)]:
 g=Build('Part_Turret_Antenna_'+side,(s*1.03,-1.38,2.22),parent=turret,bevel=.001)
 g.cyl((s*1.03,-1.38,2.19),(s*1.03,-1.38,2.36),.040,n=20)
 g.cyl((s*1.03,-1.38,2.36),(s*1.03,-1.38,2.94 if s==1 else 2.73),.009,'Steel',10,r2=.004)
 part(g)
# Hatches with hinge pivots, closed; cupola is detachable.
def hatch(name,x,f,z,r,parent):
 g=Build(name,(x,f-r,z),parent=parent,bevel=.005)
 g.cyl((x,f,z),(x,f,z+.045),r,n=40);g.lathe((x,f,z+.04),[(0,r-.025),(.014,r-.025),(.014,r-.06),(0,r-.06)],'Z',n=40)
 for i in range(8):a=i*pi/4;bolt(g,(x+(r-.055)*cos(a),f+(r-.055)*sin(a),z+.06),'Z',.012)
 handle(g,(x,f,z+.06),.16)
 g.cyl((x-.16,f-r,z),(x+.16,f-r,z),.037,'Steel',20)
 return g.finish()
hatch('Hatch_Commander',.64,-.40,2.25,.30,turret)
hatch('Hatch_Loader',-.63,-.58,2.23,.30,turret)
hatch('Hatch_Driver',-.62,2.12,1.48,.275,None)
g=Build('Part_Turret_CupolaRing',(.64,-.40,2.24),parent=turret,bevel=.003)
g.lathe((.64,-.40,2.22),[(-.025,.30),(.026,.30),(.026,.365),(-.025,.365)],'Z',n=48)
for i in range(6):a=i*pi/3;g.box((.64+.33*cos(a),-.40+.33*sin(a),2.27),(.09,.065,.05),'Glass',Matrix.Rotation(a,3,'Z'))
part(g)
# Mantlet and thermal-sleeved 125 mm barrel, separate trunnion and recoil pivots.
GP=(0,1.62,1.91);BP=(0,1.96,1.91)
g=Build('Gun',GP,parent=turret,bevel=.013)
g.cyl((-.24,1.60,1.91),(.24,1.60,1.91),.245,'Steel',40)
g.box((0,1.77,1.91),(.46,.35,.38));g.lathe((0,1.97,1.91),[(-.12,.17),(.02,.17),(.035,.135),(-.12,.135)],'F','Rubber',40)
gun=g.finish()
g=Build('Barrel',BP,parent=gun,bevel=.002)
# overall length 11.00: rear -3.8, muzzle +7.2
prof=[(0,.130),(.18,.130),(.20,.116),(1.05,.116),(1.08,.127),(1.13,.127),(1.16,.111),(2.00,.111),(2.02,.122),(2.08,.122),(2.11,.109),(2.86,.109),(2.89,.14),(3.57,.14),(3.60,.105),(4.43,.105),(4.47,.10),(5.08,.10),(5.12,.112),(5.24,.112),(5.24,.064),(5.08,.064),(5.07,.09),(.0,.09)]
g.lathe(BP,prof,'F','Paint',48)
# Sleeve clamps have fastening lugs; exposed muzzle steel ring and dark bore.
for f in [2.17,3.06,3.97,4.86,5.53,6.43]:
 g.lathe((0,f,1.91),[(-.018,.119),(.018,.119),(.018,.126),(-.018,.126)],'F','Steel',40)
 g.box((0,f,2.039),(.054,.045,.027));bolt(g,(0,f,2.06),'Z',.013)
g.lathe((0,7.16,1.91),[(-.04,.112),(.04,.112),(.04,.063),(-.22,.063),(-.22,.068),(-.04,.068)],'F','Steel',48)
g.cyl((0,6.85,1.91),(0,6.86,1.91),.062,'Rubber',32)
g.box((0,7.02,2.038),(.065,.15,.048));barrel=g.finish();empty('Muzzle',(0,7.20,1.91),barrel)
# Remote roof MG and cradle, all one movable group at actual pintle.
g=Build('MG',(.62,-.11,2.30),parent=turret,bevel=.003)
g.cyl((.62,-.11,2.24),(.62,-.11,2.48),.045,'Steel',20)
g.box((.62,.03,2.50),(.14,.42,.13),'Steel');g.box((.79,-.02,2.45),(.23,.25,.24))
g.cyl((.62,.21,2.51),(.62,1.00,2.51),.024,'Steel',24)
g.cyl((.62,.24,2.51),(.62,.59,2.51),.040,'Steel',24)
for f in [.32,.40,.48,.56]:g.lathe((.62,f,2.51),[(-.009,.04),(.009,.04),(.009,.045),(-.009,.045)],'F','Steel',16)
g.box((.62,.82,2.56),(.02,.04,.10),'Steel');g.box((.62,-.25,2.52),(.14,.12,.09),'Steel')
g.box((.62,-.09,2.68),(.11,.10,.10));g.cyl((.62,-.09,2.54),(.62,-.09,2.64),.018,'Steel',12)
mg=g.finish()
for name,p,pa in [('Seat_Commander',(.64,-.40,2.31),turret),('Seat_Driver',(-.62,2.12,1.55),None),('Exhaust',(0,-3.78,1.08),None),('Light_Head_L',(1.12,3.23,1.50),None),('Light_Head_R',(-1.12,3.23,1.50),None),('Fx_Engine',(0,-2.65,1.57),None),('Fx_Ammo',(0,.1,.8),None),('Fx_Hatch',(.64,-.4,2.32),turret)]:empty(name,p,pa)
# Extra structural seams and service fittings are part of the permanent hull.
g=Build('_finalHullDetail',bevel=.001)
for x in [-1.02,1.02]:
 g.line([(x,3.49,1.185),(x,3.20,1.392),(x,1.74,1.49)],.006,'Steel',6)
for x in [-.87,.87]:
 g.box((x,3.56,.94),(.14,.20,.09));g.lathe((x,3.60,.92),[(-.025,.08),(.025,.08),(.025,.046),(-.025,.046)],'F','Steel',20)
for j in range(15):bolt(g,(-1.30+j*.185,3.48,1.205),'Z',.011)
hull=join_objs([hull,g.finish()],'Hull')
for weapon in [gun,barrel,mg]:
 for slot in weapon.material_slots:
  if slot.material==MATS['Paint']:slot.material=MATS['Weapon']
healthy=list(SC.objects)
print('HEALTHY_GEOMETRY',sum(mesh_tri(o) for o in healthy),len(healthy),flush=True)
# Cap triangulation without collapsing the characteristic silhouette.
def reduce_objects(obs,target):
 total=sum(mesh_tri(o) for o in obs)
 if total>target:
  ratio=target/total*.98
  for o in obs:
   if o.type!='MESH' or mesh_tri(o)<180:continue
   active(o);mod=o.modifiers.new('Game mesh budget','DECIMATE');mod.ratio=ratio;bpy.ops.object.modifier_apply(modifier=mod.name)
 for o in obs:
  if o.type!='MESH':continue
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
  bad=[f for f in bm.faces if f.calc_area()<5e-13]
  if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
  bm.to_mesh(o.data);bm.free()
 return sum(mesh_tri(o) for o in obs)
healthy_tri=reduce_objects(healthy,116000)

def patch_paint(path):
 # Explicit paint-factor contract; retain standard core glTF embedded PNGs.
 data=path.read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);tail=data[20+jl:]
 for m in doc.get('materials',[]):
  if m['name'].split('.')[0]=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
 raw=json.dumps(doc,separators=(',',':')).encode();raw+=b' ' *((-len(raw))%4)
 path.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(raw)+len(tail))+struct.pack('<I4s',len(raw),b'JSON')+raw+tail)

def export(obs,filename):
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.hide_set(False);o.hide_render=False;o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(ROOT/filename),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True,export_image_format='AUTO')
 patch_paint(ROOT/filename)
 if (ROOT/filename).stat().st_size>12_000_000:
  import shutil, ctypes
  bundled=Path(bpy.app.binary_path).parent/'lib'/'libdraco.so.9'
  if bundled.exists():ctypes.CDLL(str(bundled),mode=ctypes.RTLD_GLOBAL)
  shutil.copy2(ROOT/filename,Path('/tmp')/('type99-audit-'+filename))
  bpy.ops.export_scene.gltf(filepath=str(ROOT/filename),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_cameras=False,export_lights=False,export_draco_mesh_compression_enable=True,export_draco_mesh_compression_level=6,export_draco_position_quantization=16,export_draco_normal_quantization=12,export_draco_texcoord_quantization=14)
  patch_paint(ROOT/filename)
 print('EXPORTED',filename,(ROOT/filename).stat().st_size,flush=True)
export(healthy,'type99-tank.glb')

# Rendering helpers are deliberately outside all exported selections.
studio=[]
def studio_setup():
 world=bpy.data.worlds.new('Soft daylight');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.66,.73,.82,1);world.node_tree.nodes['Background'].inputs[1].default_value=.5;SC.world=world
 bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));floor=bpy.context.object;floor.name='_StudioGround';m=bpy.data.materials.new('Studio ground');m.diffuse_color=(.24,.26,.28,1);m.use_nodes=True;m.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.24,.26,.28,1);m.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.82;floor.data.materials.append(m);studio.append(floor)
 for name,loc,power,size in [('Key',(3,-6,10),2000,7),('Fill',(-6,-2,6),1100,6),('Rim',(1,7,8),1800,5)]:
  data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;o=bpy.data.objects.new(name,data);SC.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler();studio.append(o)
 data=bpy.data.cameras.new('ProofCamera');cam=bpy.data.objects.new('ProofCamera',data);SC.collection.objects.link(cam);SC.camera=cam;data.type='ORTHO';studio.append(cam)
 return cam
cam=studio_setup()
def camera(view='hero',wide=False):
 if view=='side':pos=Vector((17,0,3.8));target=Vector((0,-1.4,1.1));scale=12.7
 else:pos=Vector((11,-15,8.7));target=Vector((0,-1.25,1.13));scale=12.5 if not wide else 15.5
 cam.location=pos;cam.rotation_euler=(target-pos).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale
 if view=='side':cam.location=(17,-1.4,1.35);cam.rotation_euler=(Vector((0,-1.4,1.35))-cam.location).to_track_quat('-Z','Y').to_euler()
def render(filename,w=1920,h=1080,samples=32):
 SC.render.resolution_x=w;SC.render.resolution_y=h;SC.render.resolution_percentage=100;SC.cycles.samples=samples;SC.render.filepath=str(ROOT/filename);bpy.ops.render.render(write_still=True)
 print('RENDERED',filename,flush=True)
camera();render('final.png')
camera('side');render('side.png',1920,1080,24)
camera();clay=bpy.data.materials.new('Clay');clay.diffuse_color=(.42,.42,.42,1);clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.42,.42,.42,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.72
SC.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);SC.view_layers[0].material_override=None
# Required 30-degree pivot proof. Origins never move when rotations change.
rotated=[]
for o in healthy:
 if o.name in ['Turret','Gun','MG'] or o.name.startswith(('Wheel_','Hatch_')):
  axis=2 if o.name in ['Turret','MG'] else 0
  rotated.append((o,o.rotation_euler.copy(),o.matrix_world.translation.copy()));o.rotation_euler[axis]+=(-pi/6 if o.name=='Gun' or o.name.startswith('Hatch_') else pi/6)
# Recoil node uses the bore axis: a 30 degree roll tests its own origin without translation.
rotated.append((barrel,barrel.rotation_euler.copy(),barrel.matrix_world.translation.copy()));barrel.rotation_euler[1]+=pi/6
render('pivots.png',1280,720,16)
for o,r,p in rotated:o.rotation_euler=r
bpy.context.view_layer.update()
if '--healthy-only' in sys.argv:
 print('HEALTHY_COMPLETE',healthy_tri,flush=True);sys.exit(0)

# DAMAGE twins, surface impact overlays, thrown belts and two closed wreck assemblies.
# Every replacement is derived from its corresponding healthy geometry.
damage=[];twins={};scar_names=[]
hd=empty('Hull_Damage');td=empty('Turret_Damage',TP);damage.extend([hd,td])
def copy_mesh(source,name,parent=None):
 o=source.copy();o.data=source.data.copy();SC.collection.objects.link(o);o.name=name
 mw=source.matrix_world.copy();o.parent=None;o.matrix_world=mw
 if parent:parent_keep(o,parent)
 return o

def remap(o,mode='damage'):
 for slot in o.material_slots:
  if not slot.material:continue
  n=slot.material.name
  if mode=='burnt':slot.material=MATS['Burnt']
  elif n not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:slot.material=MATS['Scars']

def damage_twin(source):
 name='Dmg_'+source.name[5:];o=copy_mesh(source,name,td if source.name.startswith('Part_Turret') else hd);remap(o)
 # Subdivide for convincing torn/bowed plate silhouettes, then deform locally.
 active(o);bm=bmesh.new();bm.from_mesh(o.data)
 edges=[e for e in bm.edges if e.calc_length()>.22]
 if edges:bmesh.ops.subdivide_edges(bm,edges=edges,cuts=2,use_grid_fill=True)
 bm.to_mesh(o.data);bm.free()
 for v in o.data.vertices:
  q=v.co;w=.045*sin(q.y*4.3+q.z*6.1)
  if 'Skirt' in name:
   low=max(0,-q.z+.05);q.x+=low*.55*sin(q.y*5+1);q.z-=max(0,q.y)*.22;q.y+=low*.09
  else:q.z+=w;q.x+=.025*sin(q.y*5)
 # Actual projectile cut in broad plates; through-hole is empty with rough rim.
 if 'Skirt' in name or 'ERA' in name or 'Cheek' in name:
  world=o.matrix_world@Vector((0,.05,-.03));bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,radius=.11,location=world);cut=bpy.context.object;cut.scale=(3,.9,1)
  mod=o.modifiers.new('Ragged penetration','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;active(o)
  try:bpy.ops.object.modifier_apply(modifier=mod.name)
  except Exception:o.modifiers.remove(mod)
  bpy.data.objects.remove(cut,do_unlink=True)
  # curled exposed-metal petals built around the cut in world coordinates
  g=Build('_petals',logical(o.matrix_world.translation),bevel=.001)
  center=logical(world)
  for i in range(9):
   a=i*2*pi/9;b=a+pi/7;r=.115
   vv=[(center[0],center[1]+r*cos(a),center[2]+r*sin(a)),(center[0],center[1]+r*cos(b),center[2]+r*sin(b)),(center[0]+(.035 if center[0]>0 else -.035),center[1]+.155*cos((a+b)/2),center[2]+.155*sin((a+b)/2))]
   g.mesh(vv,[(0,1,2)],'Scars')
  petals=g.finish();pivot=logical(o.matrix_world.translation);pa=o.parent;o.parent=None;o.matrix_world.translation=V(pivot);o=join_objs([o,petals],name,pivot,pa)
 damage.append(o);twins[source.name]=name;return o
chosen=[p for p in parts if p.name.startswith('Part_Skirt_')][:8]
chosen += [next(p for p in parts if p.name==n) for n in ['Part_Fender_FL','Part_Fender_RR','Part_EngineGrille_1','Part_ExhaustShroud','Part_Turret_Cheek_L','Part_Turret_ERA_R2','Part_Turret_BustleRack']]
for p in chosen:damage_twin(p)
# Surface scars: layered irregular soot, excavated star gouge and raised torn lip.
def scars(name,points,normal,parent):
 g=Build(name,logical(parent.matrix_world.translation),parent=parent,bevel=0)
 n=Vector(normal);u=n.cross(Vector((0,0,1)))
 if u.length<.05:u=n.cross(Vector((0,1,0)))
 u.normalize();v=n.cross(u)
 for idx,c in enumerate(points):
  c=Vector(c)
  # Project each impact onto the actual exported armour surface.
  raystart=V(c+n*.65);raydir=V(-n).normalized();hits=[]
  for surf in healthy:
   if surf.type!='MESH':continue
   is_t=(surf==turret or surf in turret.children_recursive)
   if ('Turret' in name)!=is_t:continue
   if is_t and surf.name not in ['Turret'] and not surf.name.startswith('Part_Turret_'):continue
   inv=surf.matrix_world.inverted();ok,loc,nor,face_idx=surf.ray_cast(inv@raystart,(inv.to_3x3()@raydir).normalized())
   if ok:
    world=surf.matrix_world@loc;dist=(world-raystart).length
    if dist<1.2:hits.append((dist,world,(surf.matrix_world.to_3x3().inverted().transposed()@nor).normalized()))
  if hits:
   _,pworld,nworld=min(hits,key=lambda a:a[0]);c=Vector(logical(pworld));n=Vector(logical(nworld))
   u=n.cross(Vector((0,0,1)))
   if u.length<.05:u=n.cross(Vector((0,1,0)))
   u.normalize();v=n.cross(u)
  # Asymmetric blast soot fan, jagged bare-metal gouge, irregular dark core.
  layers=[(.17,'Burnt',.002),(.060,'Scars',.003),(.026,'Rubber',.004)] if idx==0 else [(.14,'Burnt',.002),(.030,'Scars',.003)]
  for layer,(r,mat,offset) in enumerate(layers):
   vs=[c+n*offset]
   for j in range(13):
    a=j*2*pi/13;rr=r*(.58+.38*sin(j*17+idx*5)**2+.23*cos(j*9))
    stretch=2.5 if idx>0 and layer==1 else (1.4 if layer==0 else 1)
    fan=max(0,cos(a))*.09 if layer==0 else 0
    vs.append(c+n*offset+u*(rr*cos(a)*stretch+fan)+v*(rr*sin(a)*(.60 if idx>0 else .9)))
   g.mesh(vs,[(0,j+1,(j+1)%13+1) for j in range(13)],mat)
  if idx==0:
   vs=[]
   for j in range(16):
    a=j*2*pi/16
    for rr,dd in [(.023,.004),(.038,.020+(.012 if j%2 else 0)),(.066*(.8+.3*sin(j*5)**2),.003)]:vs.append(c+u*(rr*cos(a))+v*(rr*sin(a))+n*dd)
   fs=[]
   for j in range(16):
    for k in [0,1]:fs.append((j*3+k,((j+1)%16)*3+k,((j+1)%16)*3+k+1,j*3+k+1))
   g.mesh(vs,fs,'Scars')
  else:
   g.line([c+u*.04+n*.004,c+u*.20+v*.035+n*.004],.005,'Scars',6)
 o=g.finish();damage.append(o);scar_names.append(name)
# Hits lie on full underlying hull, not on removable skirt surfaces.
scars('Scar_Hull_Front',[(-.75,3.47,1.225),(-.18,3.37,1.285),(.57,3.52,1.19)],(0,.53,.85),hd)
scars('Scar_Hull_Rear',[(-.88,-3.806,1.07),(0,-3.806,1.22),(.87,-3.806,.91)],(0,-1,0),hd)
for side,s in [('L',1),('R',-1)]:scars('Scar_Hull_'+side,[(s*1.855,f,1.1) for f in [1.6,.2,-1.6]],(s,0,0),hd)
scars('Scar_Turret_Front',[(-.76,1.72,1.91),(.78,1.72,1.83),(.50,1.74,2.04)],(0,1,.3),td)
scars('Scar_Turret_Rear',[(-.68,-1.73,1.85),(0,-1.73,1.91),(.69,-1.73,1.82)],(0,-1,0),td)
for side,s in [('L',1),('R',-1)]:scars('Scar_Turret_'+side,[(s*1.509,f,1.84) for f in [.3,-.1,-.45]],(s,0,0),td)
# Thrown track retains rear loop and lower run, loose upper run drapes onto ground.
for side,s in [('L',1),('R',-1)]:
 broken_path=[(4.40,.065),(3.55,.065),(2.65,.065),(-2.66,.065),(-3.35,.53),(-3.54,.83),(-3.35,1.12),(-2.8,1.19),(-1.9,1.02),(-.9,.77),(.2,.46),(1.3,.15),(2.6,.065),(3.7,.065)]
 ob=make_track('Track_'+side+'_Broken',s,sample_path(broken_path,closed=False));parent_keep(ob,hd);remap(ob);damage.append(ob)
# Broken weapon derives the real mantlet and actual barrel. A broken-off mouth
# with splayed longitudinal steel strips makes the failure visible at game distance.
bg=copy_mesh(gun,'_brokenMantlet');bb=copy_mesh(barrel,'_brokenTube');bb.parent=None
# deform and remove tube beyond midpoint with bmesh plane cut in world coordinates
bm=bmesh.new();bm.from_mesh(bb.data)
# local Blender negative Y goes forward; keep only first 2.7 m of tube
bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),dist=.0001,plane_co=(0,-2.75,0),plane_no=(0,-1,0),clear_outer=True,clear_inner=False)
bm.to_mesh(bb.data);bm.free()
for v in bb.data.vertices:
 t=max(0,-v.co.y-1.4);v.co.z-=.17*t*t
remap(bb);remap(bg)
g=Build('_burst',GP,bevel=.002)
for i in range(8):
 a=i*pi/4;x=.13*cos(a);z=.13*sin(a)
 g.line([(x,4.44,1.62+z),(x*1.8,4.77,1.54+z*1.8),(x*2.35,4.94,1.49+z*2.35)],.022,'Scars',6)
burst=g.finish();broken_gun=join_objs([bg,bb,burst],'Gun_Broken',GP,td);damage.append(broken_gun)

# Wreck derives healthy hull/running gear and damaged panels. Complete turret well
# remains open; burned-off rubber, sagged suspension, ruptured engine deck.
hull_copies=[];turret_copies=[]
turret_desc=set([turret]+list(turret.children_recursive))
for o in healthy:
 if o.type!='MESH':continue
 is_t=o in turret_desc
 if o.name.startswith('Part_'):
  # Missing panels and tools leave mounting infrastructure visible.
  if any(k in o.name for k in ['Skirt_L1','Skirt_L3','Skirt_L5','Skirt_R2','Skirt_R4','LampGuard','Tools','Antenna','GlacisERA_2','Turret_ERA_L2','Turret_ERA_R3']):continue
 src=bpy.data.objects.get(twins.get(o.name,'')) or o
 c=copy_mesh(src,'_wreck_'+o.name)
 if not is_t and o.name.startswith('Wheel_'):
  # Tyres are physically absent, exposing the underlying wheel rims.
  rubber_idx=[i for i,m in enumerate(c.data.materials) if m and m.name=='Rubber']
  bm=bmesh.new();bm.from_mesh(c.data);bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.material_index in rubber_idx],context='FACES');bm.to_mesh(c.data);bm.free();c.location.z-=.05
 if o.name.startswith('Hatch_'):
  c.rotation_euler.x=math.radians(-108 if 'Driver' not in o.name else -100)
 if 'EngineGrille' in o.name:
  for v in c.data.vertices:v.co.z+=.15+.22*cos(v.co.x*2.5)*cos(v.co.y*2)
 if o.name=='Track_L':
  bpy.data.objects.remove(c,do_unlink=True);c=copy_mesh(bpy.data.objects['Track_L_Broken'],'_wreckTrack')
 if o.name in ['Gun','Barrel']:
  # Gun and barrel both rotate about the same world trunnion for full depression.
  mat=Matrix.Translation(V(GP))@Matrix.Rotation(math.radians(14),4,'X')@Matrix.Translation(-V(GP));c.matrix_world=mat@c.matrix_world
 remap(c,'burnt');(turret_copies if is_t else hull_copies).append(c)
# Closed underside and exposed short turret basket stub when the turret is blown off.
g=Build('_basketStub',TP,bevel=.006)
g.cyl((0,.18,1.43),(0,.18,1.51),.96,'Burnt',48)
g.lathe((0,.18,1.24),[(-.12,.71),(.18,.71),(.18,.77),(-.12,.77)],'Z','Burnt',40)
for i in range(8):
 a=i*pi/4;g.cyl((.73*cos(a),.18+.73*sin(a),1.10),(.88*cos(a),.18+.88*sin(a),1.48),.025,'Burnt',10)
turret_copies.append(g.finish())
wh=join_objs(hull_copies,'Wreck_Hull',(0,0,0));wt=join_objs(turret_copies,'Wreck_Turret',TP)
reduce_objects([wh,wt],int(healthy_tri*.40));damage.extend([wh,wt])
damage_tri=reduce_objects(damage,108000)
export(damage,'type99-tank-damage.glb')
# Metadata in glTF coordinates: positions, parentage, world bounds, material and rig contract.
def bbox(obs):
 bpy.context.view_layer.update();pts=[o.matrix_world@Vector(c) for o in obs if o.type=='MESH' for c in o.bound_box]
 lo=[min(logical(p)[i] for p in pts) for i in range(3)];hi=[max(logical(p)[i] for p in pts) for i in range(3)]
 return {'min':[lo[0],lo[2],lo[1]],'max':[hi[0],hi[2],hi[1]],'size':[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]]}
def node_info(o):
 p=o.matrix_world.translation;return {'name':o.name,'position':[round(p.x,5),round(p.z,5),round(-p.y,5)],'parent':o.parent.name if o.parent else None,'triangles':mesh_tri(o)}
def exported_counts(filename):
 raw=(ROOT/filename).read_bytes();n=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+n]);counts={}
 for node in doc['nodes']:
  counts[node['name']]=sum(doc['accessors'][p['indices']]['count']//3 for p in doc['meshes'][node['mesh']]['primitives']) if 'mesh' in node else 0
 return counts
export_counts={f:exported_counts(f) for f in ['type99-tank.glb','type99-tank-damage.glb']}
healthy_tri=sum(export_counts['type99-tank.glb'].values());damage_tri=sum(export_counts['type99-tank-damage.glb'].values())
manifest={'schema':'tank-damage/v1','units':'metres','axes':{'+X':'left','+Y':'up','+Z':'forward'},'bbox':bbox(healthy),'files':{'type99-tank.glb':{'nodes':[node_info(o) for o in healthy],'triangles':healthy_tri},'type99-tank-damage.glb':{'nodes':[node_info(o) for o in damage],'triangles':damage_tri}},'paints':[{'name':n,'linearRGB':rgb} for n,rgb in PAINTS],'damage':{'parts':[{'part':o.name,'twin':twins.get(o.name)} for o in parts],'scars':scar_names,'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'rig':{'Turret':'Y','Gun':'X','Barrel':'local Z recoil','wheels':'X','hatches':'X','MG':'Y'},'bakes':BAKE_LOG}
for filename,info in manifest['files'].items():
 info['bytes']=(ROOT/filename).stat().st_size
 for node in info['nodes']:node['triangles']=export_counts[filename][node['name']]
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text(f'Type 99A-style modern MBT, metre-scale articulated rig, removable armour and tank-damage/v1 variants.\nTriangles: healthy {healthy_tri:,}; damage {damage_tri:,}; wreck {mesh_tri(wh)+mesh_tri(wt):,}.\nMaterials: Paint, TrackSteel, Rubber, Steel, Weapon, Glass, Lamps; damage adds Burnt and Scars (5-material subset).\nTextures: Paint base/normal 1024; all ORM 512 or below; other base/normal 512 or below; Cycles baked shared tiling sets.\nPoly Haven CC0 ids: blue_metal_plate, rusty_metal_02, rubberized_track; https://polyhaven.com/license (source JPEGs downloaded from dl.polyhaven.org).\nReference differences: simplified hidden mechanics and basket weave; no insignia; maximum height with roof equipment and antenna tips is 2.94 m, above the requested 2.35 m; length and width match within 2 mm.\n')

# Mandatory consumer proof: delete authored asset, import BOTH actual deliverables
# into the empty scene. Render visible state combinations only from imported meshes.
for o in list(SC.objects):bpy.data.objects.remove(o,do_unlink=True)
studio.clear();cam=studio_setup()
# Let the importer create its complete current-version settings group.
old_group=bpy.data.node_groups.get('glTF Material Output')
if old_group:old_group.name='Authoring occlusion output'
bpy.ops.import_scene.gltf(filepath=str(ROOT/'type99-tank.glb'));ih={o.name:o for o in bpy.context.selected_objects}
bpy.ops.import_scene.gltf(filepath=str(ROOT/'type99-tank-damage.glb'));idd={o.name:o for o in bpy.context.selected_objects}
# glTF importer may append suffix to repeated material names, not node names.
def show(o,value):o.hide_render=not value;o.hide_set(not value)
def hierarchy(o,value):
 show(o,value)
 for c in o.children_recursive:show(c,value)
def state_base():
 for o in ih.values():show(o,True)
 for o in idd.values():show(o,False)
# healthy final render is repeated from the exported file, certifying material portability.
state_base();camera();render('final.png')
camera('side');render('side.png',1920,1080,24);camera()
SC.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);SC.view_layers[0].material_override=None
state_paths=[]
for st in range(6):
 state_base()
 if st==1:
  for n in scar_names:show(idd[n],True)
  for n in list(twins)[:6]:show(ih[n],False);show(idd[twins[n]],True)
 elif st==2:
  for n,o in ih.items():
   if n.startswith('Part_'):show(o,False)
 elif st==3:
  show(ih['Track_L'],False);show(idd['Track_L_Broken'],True);hierarchy(ih['Gun'],False);show(idd['Gun_Broken'],True)
 elif st>=4:
  for o in ih.values():show(o,False)
  show(idd['Wreck_Hull'],True);show(idd['Wreck_Turret'],True)
  if st==5:
   wt2=idd['Wreck_Turret'];saved=wt2.matrix_world.copy();wt2.location=(4.0,1.3,0);wt2.rotation_mode='XYZ';wt2.rotation_euler=(-.24,1.15,-.40);bpy.context.view_layer.update();wt2.location.z-=min((wt2.matrix_world@v.co).z for v in wt2.data.vertices);wt2.location.z+=.008
 camera(wide=True);fname=f'_state_{st}.png';render(fname,960,600,16);state_paths.append(ROOT/fname)
 if st==5:wt2.matrix_world=saved

def grid(paths,out,w=960,h=600):
 canvas=np.ones((h*2,w*3,4),dtype=np.float32)
 for i,p in enumerate(paths):
  im=bpy.data.images.load(str(p),check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape(h,w,4);row=1-i//3;col=i%3;canvas[row*h:(row+1)*h,col*w:(col+1)*w]=arr;bpy.data.images.remove(im)
 img=bpy.data.images.new(out,width=w*3,height=h*2,alpha=False);img.pixels.foreach_set(canvas.ravel());img.filepath_raw=str(ROOT/out);img.file_format='PNG';img.save()
grid(state_paths,'states.png')
state_base();camera();paintpaths=[]
# Change standard glTF material factor after import, retaining its neutral base map.
paintmat=next(m for m in ih['Hull'].data.materials if m and m.name.split('.')[0]=='Paint');nt=paintmat.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');socket=bs.inputs['Base Color'];original_link=socket.links[0].from_socket if socket.is_linked else None
mult=nt.nodes.new('ShaderNodeMixRGB');mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1
# Importer commonly creates its own multiplier; route direct neutral texture to ours.
texnode=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image and 'base' in n.image.name.lower())
nt.links.new(texnode.outputs[0],mult.inputs[1]);nt.links.new(mult.outputs[0],socket)
for i,(name,rgb) in enumerate(PAINTS):
 mult.inputs[2].default_value=tuple(rgb)+(1,);fname=f'_paint_{i}.png';render(fname,960,600,16);paintpaths.append(ROOT/fname)
grid(paintpaths,'paints.png')
print('COMPLETE',{'seconds':time.time()-START,'healthy_tri':healthy_tri,'damage_tri':damage_tri,'bytes':[(p.name,p.stat().st_size) for p in ROOT.glob('*.glb')]},flush=True)
