#!/usr/bin/env python3
"""Char B1 bis game asset. Run with Blender 5.2 --background --python-exit-code 1 --python this_file.
Clean deterministic build, Cycles material bakes, healthy/damage exports and reimported proof renders.
Coordinates while authoring: X left, -Y forward, Z up. glTF conversion: X left,Y up,Z forward.
"""
import bpy, bmesh, math, random, json, os, sys, struct, urllib.request, shutil, hashlib
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin,cos,pi
random.seed(41)
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
OUT=ROOT
DRAFT='--draft' in sys.argv
BUILD_ONLY='--build-only' in sys.argv
PAINTS=[('Olive drab',[.20,.24,.12]),('NATO green',[.105,.16,.095]),('Desert tan',[.54,.41,.235]),('Field grey',[.23,.27,.245]),('Winter white',[.78,.80,.75]),('Dark green-black',[.045,.065,.045])]
PH_IDS=['metal_plate','rusty_metal_03']
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)
scene=bpy.context.scene
scene.render.engine='CYCLES';scene.cycles.samples=24;scene.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'
 scene.cycles.device='GPU'
except Exception as e: print('GPU ERROR',e);raise
scene.render.image_settings.file_format='PNG'
scene.view_settings.view_transform='AgX';scene.view_settings.exposure=-.65
scene.render.film_transparent=False
scene.world.color=(.25,.25,.25)
# Required CC0 photographic maps, preserved locally for offline rebuilds.
for asset in PH_IDS:
 for mp in ['diff','nor_gl','arm']:
  p=TEX/f'{asset}_{mp}_1k.jpg'
  if not p.exists():
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mp}_1k.jpg'
   req=urllib.request.Request(url,headers={'User-Agent':'CharB1Asset/1.0'})
   with urllib.request.urlopen(req,timeout=90) as r:p.write_bytes(r.read())
print('PH_TEXTURES_READY',flush=True)

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,color,rough,metal,source='metal_plate',size=512):
 """Bake a shared repeating photographic/procedural finish on a UV tile in Cycles."""
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;n=nt.nodes;l=nt.links;n.clear()
 out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=n.new('ShaderNodeTexCoord')
 diff=image_node(nt,TEX/f'{source}_diff_1k.jpg');l.new(uv.outputs['UV'],diff.inputs['Vector'])
 normal=image_node(nt,TEX/f'{source}_nor_gl_1k.jpg',True);l.new(uv.outputs['UV'],normal.inputs['Vector'])
 arm=image_node(nt,TEX/f'{source}_arm_1k.jpg',True);l.new(uv.outputs['UV'],arm.inputs['Vector'])
 bw=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');l.new(bw.outputs[0],ramp.inputs[0])
 if name=='Paint':
  ramp.color_ramp.elements[0].color=(.61,.61,.61,1);ramp.color_ramp.elements[0].position=.012
  ramp.color_ramp.elements[1].color=(.95,.95,.95,1);ramp.color_ramp.elements[1].position=.25
 elif name=='Burnt':
  ramp.color_ramp.elements[0].color=(.006,.007,.006,1);ramp.color_ramp.elements[0].position=.02
  ramp.color_ramp.elements[1].color=(.060,.058,.054,1);ramp.color_ramp.elements[1].position=.43
 else:
  ramp.color_ramp.elements[0].color=tuple(c*.38 for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(color)+(1,)
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=210;noise.inputs['Detail'].default_value=3
 l.new(uv.outputs['UV'],noise.inputs['Vector'])
 fine=n.new('ShaderNodeTexNoise');fine.inputs['Scale'].default_value=360;fine.inputs['Detail'].default_value=2;l.new(uv.outputs['UV'],fine.inputs[0])
 # Directional rain/grime streaks, subtle and neutral so variant paint remains recolourable.
 vm=n.new('ShaderNodeVectorMath');vm.operation='MULTIPLY';vm.inputs[1].default_value=(32,2,1);l.new(uv.outputs['UV'],vm.inputs[0])
 streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=3;streak.inputs['Detail'].default_value=3;l.new(vm.outputs[0],streak.inputs[0])
 mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.12 if name=='Paint' else .20;l.new(ramp.outputs[0],mix.inputs[1]);l.new(streak.outputs['Fac'],mix.inputs[2])
 l.new(mix.outputs[0],bs.inputs['Base Color']);bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.055 if name=='Paint' else .13;l.new(normal.outputs[0],nm.inputs['Color'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.27;bump.inputs['Distance'].default_value=.0012
 l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-20));plane=bpy.context.object;plane.data.materials.append(m)
 scene.cycles.samples=4;scene.render.bake.margin=8
 baked={}
 def do_bake(key,sz,typ,link=None):
  im=bpy.data.images.new(f'{name}_{key}',sz,sz,alpha=False)
  if key!='BaseColor':im.colorspace_settings.name='Non-Color'
  target=n.new('ShaderNodeTexImage');target.image=im;n.active=target
  if link is not None:
   em=n.new('ShaderNodeEmission');l.new(link,em.inputs[0]);l.new(em.outputs[0],out.inputs[0])
  else:l.new(bs.outputs[0],out.inputs[0])
  bpy.ops.object.bake(type=typ)
  im.filepath_raw=str(TEX/f'{name}_{key}.png');im.file_format='PNG';im.save()
  baked[key]=im
 do_bake('BaseColor',size,'EMIT',mix.outputs[0])
 do_bake('Normal',size,'NORMAL')
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
 mul=n.new('ShaderNodeMath');mul.operation='MULTIPLY_ADD';mul.inputs[1].default_value=.20;mul.inputs[2].default_value=max(0,rough-.15);l.new(sep.outputs[1],mul.inputs[0])
 combine=n.new('ShaderNodeCombineColor');l.new(sep.outputs[0],combine.inputs[0]);l.new(mul.outputs[0],combine.inputs[1]);combine.inputs[2].default_value=metal
 do_bake('ORM',min(size,512),'EMIT',combine.outputs[0])
 bpy.data.objects.remove(plane,do_unlink=True)
 n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0]);bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 tex=n.new('ShaderNodeTexImage');tex.name='Baked_BaseColor';tex.image=baked['BaseColor']
 if name=='Paint':
  mul=n.new('ShaderNodeMixRGB');mul.name='PaintFactor';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=tuple(PAINTS[0][1])+(1,);l.new(tex.outputs[0],mul.inputs[1]);l.new(mul.outputs[0],bs.inputs['Base Color'])
 else:l.new(tex.outputs[0],bs.inputs['Base Color'])
 tn=n.new('ShaderNodeTexImage');tn.image=baked['Normal'];nm=n.new('ShaderNodeNormalMap');l.new(tn.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 orm=n.new('ShaderNodeTexImage');orm.image=baked['ORM'];sep=n.new('ShaderNodeSeparateColor');l.new(orm.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],bs.inputs['Roughness']);l.new(sep.outputs[2],bs.inputs['Metallic'])
 # Recognized glTF occlusion socket carries R of the shared ORM texture.
 group=bpy.data.node_groups.get('glTF Material Output')
 if not group:
  group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=n.new('ShaderNodeGroup');gn.node_tree=group;l.new(sep.outputs[0],gn.inputs['Occlusion'])
 if name=='Lamps':bs.inputs['Emission Color'].default_value=(.3,.24,.13,1);bs.inputs['Emission Strength'].default_value=.3
 m.diffuse_color=tuple(PAINTS[0][1] if name=='Paint' else color)+(1,)
 print('BAKED',name,flush=True);return m

MATS=[]
for args in [('Paint',(1,1,1),.80,.03,'rusty_metal_03',1024),('TrackSteel',(.23,.215,.19),.78,.72,'rusty_metal_03',512),('Rubber',(.036,.039,.034),.90,0,'metal_plate',256),('Steel',(.14,.15,.14),.64,.8,'rusty_metal_03',512),('Interior',(.023,.026,.02),.96,0,'metal_plate',256),('Lamps',(.64,.58,.4),.24,.25,'metal_plate',256),('Burnt',(.15,.125,.095),.95,.25,'rusty_metal_03',512),('Scars',(.32,.30,.26),.65,.7,'rusty_metal_03',512)]:
 MATS.append(bake_material(*args))
MI={m.name:i for i,m in enumerate(MATS)}
scene.cycles.samples=24

# Geometry batching keeps all authored surface detail but consolidates each functional node.
CACHE={}
def box_template(dims,bevel=.008):
 key=('box',tuple(round(x,5) for x in dims),round(bevel,5))
 if key in CACHE:return CACHE[key]
 bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
 for v in bm.verts:v.co.x*=dims[0];v.co.y*=dims[1];v.co.z*=dims[2]
 if bevel:
  bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(bevel,min(dims)*.43),segments=2,affect='EDGES')
 bm.verts.ensure_lookup_table();bm.verts.index_update()
 ans=([tuple(v.co) for v in bm.verts],[[v.index for v in f.verts] for f in bm.faces])
 bm.free();CACHE[key]=ans;return ans

class Batch:
 def __init__(self,name,pivot=(0,0,0),parent=None):self.name=name;self.pivot=Vector(pivot);self.parent=parent;self.v=[];self.f=[];self.mi=[];self.sm=[];self.uv=[]
 def mesh(self,vs,fs,mat='Paint',smooth=False,uv=None):
  k=len(self.v);self.v.extend(tuple(x) for x in vs);self.f.extend([tuple(k+i for i in f) for f in fs]);self.mi.extend([MI[mat]]*len(fs));self.sm.extend([smooth]*len(fs));self.uv.extend(uv if uv else [None]*len(fs))
 def box(self,c,d,b=.008,mat='Paint',rot=None):
  vs,fs=box_template(d,b);R=rot if rot is not None else Matrix.Identity(3);c=Vector(c)
  self.mesh([R@Vector(v)+c for v in vs],fs,mat)
 def cyl(self,c,r,depth,mat='Paint',axis=(0,0,1),n=24,r2=None,cap=True):
  axis=Vector(axis).normalized();R=Vector((0,0,1)).rotation_difference(axis).to_matrix();c=Vector(c);r2=r if r2 is None else r2
  # Four rings give physically rounded edge highlights without modifier-dependent pivots.
  edge=min(.008,depth*.12,r*.10)
  rings=[(-depth/2,r-edge),(-depth/2+edge,r),(depth/2-edge,r2),(depth/2,r2-edge)]
  vs=[c+R@Vector((rr*cos(i*2*pi/n),rr*sin(i*2*pi/n),z)) for z,rr in rings for i in range(n)]
  fs=[]
  for j in range(3):
   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))
  if cap:fs.extend([tuple(reversed(range(n))),tuple(range(3*n,4*n))])
  self.mesh(vs,fs,mat,True)
 def tube(self,points,r=.02,mat='Paint',n=8):
  vs=[]
  for j,p in enumerate(points):
   tangent=Vector(points[min(j+1,len(points)-1)])-Vector(points[max(0,j-1)])
   R=Vector((0,0,1)).rotation_difference(tangent.normalized()).to_matrix()
   vs.extend(Vector(p)+R@Vector((r*cos(i*2*pi/n),r*sin(i*2*pi/n),0)) for i in range(n))
  fs=[]
  for j in range(len(points)-1):
   for i in range(n):fs.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
  fs.extend([tuple(reversed(range(n))),tuple(range((len(points)-1)*n,len(points)*n))]);self.mesh(vs,fs,mat,True)
 def rivet(self,c,axis=(1,0,0),r=.016,mat='Paint'):
  R=Vector((0,0,1)).rotation_difference(Vector(axis)).to_matrix();c=Vector(c);n=8
  vs=[c+R@Vector((rr*cos(i*2*pi/n),rr*sin(i*2*pi/n),z)) for z,rr in [(-.005,r*.85),(.002,r),(.009,r*.65)] for i in range(n)]
  fs=[(j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i) for j in range(2) for i in range(n)]+[tuple(range(2*n,3*n))]
  self.mesh(vs,fs,mat,True)
 def finish(self):
  me=bpy.data.meshes.new(self.name);me.from_pydata([Vector(v)-self.pivot for v in self.v],[],self.f);me.update()
  o=bpy.data.objects.new(self.name,me);bpy.context.collection.objects.link(o);o.location=self.pivot
  for m in MATS:me.materials.append(m)
  uv=me.uv_layers.new(name='UVMap')
  for p,mi,sm,us in zip(me.polygons,self.mi,self.sm,self.uv):
   p.material_index=mi;p.use_smooth=sm
   # Physical scale planar mapping; repeating shared tile, independent of part pivot.
   norm=p.normal;ax=max(range(3),key=lambda a:abs(norm[a]));axes={0:(1,2),1:(0,2),2:(0,1)}[ax]
   for j,li in enumerate(p.loop_indices):
    v=self.v[me.loops[li].vertex_index]
    uv.data[li].uv=us[j] if us else (v[axes[0]]*.85,v[axes[1]]*.85)
  if self.parent:
   par=OBJECTS[self.parent];o.parent=par;o.matrix_parent_inverse=par.matrix_world.inverted()
  # Custom split normals keep cast/cylinder surfaces smooth and broad armour faces flat.
  OBJECTS[self.name]=o;return o
OBJECTS={};BATCHES={}
def B(name,p=(0,0,0),parent=None):
 b=Batch(name,p,parent);BATCHES[name]=b;return b

def aperture_solid(vs,fs,kind,loc,size):
 me=bpy.data.meshes.new('_solid');me.from_pydata(vs,[],fs);me.update()
 target=bpy.data.objects.new('_solid',me);bpy.context.collection.objects.link(target)
 if kind=='cylinder':bpy.ops.mesh.primitive_cylinder_add(vertices=32,radius=size[0],depth=size[1],location=loc)
 else:
  bpy.ops.mesh.primitive_cube_add(size=1,location=loc);bpy.context.object.scale=size;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 cutter=bpy.context.object;bpy.context.view_layer.objects.active=target
 mod=target.modifiers.new('Aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter;mod.solver='EXACT'
 bpy.ops.object.modifier_apply(modifier=mod.name)
 result=([tuple(v.co) for v in target.data.vertices],[tuple(f.vertices) for f in target.data.polygons])
 bpy.data.objects.remove(cutter,do_unlink=True);bpy.data.objects.remove(target,do_unlink=True)
 return result

def prism_side(b,profile,x0,x1,mat='Paint',bevel=.012):
 vs=[(x,y,z) for x in (x0,x1) for y,z in profile];n=len(profile)
 fs=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 # Welded watertight armour plate, bevel in bmesh before insertion.
 bm=bmesh.new();vv=[bm.verts.new(v) for v in vs]
 for f in fs:bm.faces.new([vv[i] for i in f])
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
 if bevel:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=bevel,segments=2,affect='EDGES')
 bm.verts.index_update();sv=[tuple(v.co) for v in bm.verts];sf=[[v.index for v in f.verts] for f in bm.faces];bm.free()
 if b.name=='Hull' and (abs(x0+.83)<.001 or abs(x0-.05)<.001):sv,sf=aperture_solid(sv,sf,'box',(.42,-1.73,1.90),(.40,.36,.55))
 if b.name=='Hull' and abs(x0+.83)<.001:sv,sf=aperture_solid(sv,sf,'cylinder',(0,-.73,1.92),(.53,.8))
 b.mesh(sv,sf,mat)

def ring(b,c,outer,inner,depth,mat='Paint',axis=(0,0,1),n=48):
 c=Vector(c);R=Vector((0,0,1)).rotation_difference(Vector(axis)).to_matrix();vs=[]
 for z,r in [(-depth/2,outer),(depth/2,outer),(depth/2,inner),(-depth/2,inner)]:
  vs.extend(c+R@Vector((r*cos(i*2*pi/n),r*sin(i*2*pi/n),z)) for i in range(n))
 fs=[]
 for j in range(4):
  for i in range(n):fs.append((j*n+i,j*n+(i+1)%n,((j+1)%4)*n+(i+1)%n,((j+1)%4)*n+i))
 b.mesh(vs,fs,mat,True)

def empty(name,pos,parent=None):
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=pos
 if parent:o.parent=OBJECTS[parent];o.matrix_parent_inverse=o.parent.matrix_world.inverted()
 OBJECTS[name]=o;return o

def arc_points(x,y,z,r,a0,a1,n=16,plane='YZ'):
 return [(x,y+r*cos(a0+(a1-a0)*i/n),z+r*sin(a0+(a1-a0)*i/n)) if plane=='YZ' else (x+r*cos(a0+(a1-a0)*i/n),y+r*sin(a0+(a1-a0)*i/n),z) for i in range(n+1)]

# HULL: tall, narrow riveted chassis and bevelled sloping glacis.
hull=B('Hull')
prof=[(-2.78,.56),(-2.78,1.03),(-2.30,1.32),(-1.96,1.91),(1.88,1.91),(2.68,1.63),(2.75,.47),(2.38,.29),(-2.13,.29)]
prism_side(hull,prof,-.83,.83,bevel=.025)
hull.box((0,.2,.38),(1.67,4.5,.3),.045,'Paint')
# Turret ring socket is a real well; deck under turret is replaced with the ring lip/dark bowl.
ring(hull,(0,-.73,1.935),.67,.55,.085)
hull.cyl((0,-.73,1.50),.55,.04,'Interior',n=48)
ring(hull,(0,-.73,1.73),.545,.53,.44,'Interior')
for s in [-1,1]:
 x=s*.82
 # inner suspension skin behind removable armour; geometric reinforcement and mounting bosses.
 prism_side(hull,[(-2.55,.44),(-2.67,1.04),(-2.23,1.30),(-.98,1.65),(1.58,1.65),(2.55,1.26),(2.59,.55),(2.14,.34),(-2.18,.34)],x-s*.025,x+s*.038,bevel=.012)
 for y in [-2.12,-1.4,-.68,.04,.76,1.48,2.13]:
  hull.box((x+s*.042,y,.94),(.035,.045,1.06),.005)
  for z in [.47,.7,1.18,1.42]:hull.rivet((x+s*.069,y,z),(s,0,0),.024)
 for y in [-2.18,-1.75,-1.32,-.89,-.46,-.03,.4,.83,1.26,1.69,2.12]:
  hull.box((s*.87,y,.41),(.18,.10,.27),.012,'Steel')
  hull.cyl((s*.92,y,.38),.075,.18,'Steel',(1,0,0),16)
 # Road-wheel trolley bogies and exposed longitudinal rail.
 hull.box((s*.93,0,.36),(.095,4.45,.10),.015,'Steel')
 for yy in [-1.6,-.52,.56,1.64]:
  hull.box((s*.91,yy,.50),(.13,.80,.13),.022,'Paint')
  for zz in [.57,.60,.63]:hull.box((s*.91,yy,zz),(.10,.75,.018),.004,'Steel')
# Nose fittings, towing shackles, riveted glacis perimeter.
def nose_y(z):
 return -2.78 if z<=1.03 else (-2.78+(z-1.03)*(.48/.29) if z<=1.32 else -2.30+(z-1.32)*(.34/.59))
for x in [-.73,.73]:
 for j in range(15):
  z=.64+j*.082;hull.rivet((x,nose_y(z)-.012,z),(0,-1,0),.015)
for z in [.60,1.27,1.86]:
 for j in range(13):
  x=-.70+j*.115;hull.rivet((x,nose_y(z)-.014,z),(0,-1,0),.014)
for x in [-.53,.52]:
 hull.box((x,-2.75,.57),(.16,.12,.19),.018,'Steel')
 ring(hull,(x,-2.835,.49),.086,.051,.068,'Steel',axis=(1,0,0),n=20)
hull.cyl((0,-2.70,.49),.066,1.30,'Steel',(1,0,0),24)
# driver's projecting cabin to tank LEFT, with inset vision slit and closed hatch.
prism_side(hull,[(-2.38,1.47),(-2.04,1.97),(-1.40,1.97),(-1.44,1.71)],.05,.76,bevel=.035)
hull.box((.40,-2.054,1.825),(.42,.035,.18),.012,'Interior')
hull.box((.4,-2.078,1.815),(.30,.025,.052),.009,'Steel')
for x in [.14,.66]:hull.box((x,-2.07,1.82),(.052,.045,.20),.008)
for s in [-1,1]:
 # rear towing lug, tail hook, cable anchors
 hull.box((s*.55,2.69,.67),(.17,.16,.19),.012)
 ring(hull,(s*.55,2.79,.65),.095,.058,.066,'Steel',(1,0,0),20)
 for y in [-1.95,-.9,.2,1.35,2.2]:
  hull.rivet((s*.75,y,1.934),(0,0,1),.017)
# Rear armour panel and engine service recess.
hull.box((0,2.727,1.03),(1.55,.04,.94),.014)
for x in [-.73,.73]:
 for k in range(9):hull.rivet((x,2.759,.62+k*.1),(0,1,0),.015)
for x in [-.60,-.4,-.2,0,.2,.4,.6]:hull.rivet((x,2.76,.60),(0,1,0))
# closed crew-space wells below hatch, visible when swung open.
# Driver well has an open throat and a dark floor below.
# main cast turret, the recognisable small APX one-man casting.
tur=B('Turret',(0,-.73,1.96))
ring(tur,(0,-.73,1.975),.63,.50,.075,'Steel')
# chamfered polygon cross section with asymmetric nose and bulged cheeks.
outline=[(-.42,-.80),(.42,-.80),(.66,-.55),(.73,-.18),(.70,.38),(.49,.65),(-.49,.65),(-.70,.38),(-.73,-.18),(-.66,-.55)]
vs=[]
for z,scale in [(2.01,.91),(2.065,1),(2.16,1),(2.53,.79),(2.565,.70)]:
 for x,y in outline:vs.append((x*scale,-.73+y*scale,z))
fs=[];N=len(outline)
for k in range(4):
 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+=[tuple(reversed(range(N))),tuple(range(4*N,5*N))]
# Two bevelled segments soften the casting without toy-like spherical shoulders.
bm=bmesh.new();vv=[bm.verts.new(v) for v in vs]
for f in fs:bm.faces.new([vv[i] for i in f])
bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bmesh.ops.bevel(bm,geom=list(bm.edges),offset=.045,segments=3,affect='EDGES');bm.verts.index_update()
sv=[tuple(v.co) for v in bm.verts];sf=[[v.index for v in f.verts] for f in bm.faces];bm.free()
sv,sf=aperture_solid(sv,sf,'cylinder',(0,-.60,2.57),(.185,.60));tur.mesh(sv,sf,'Paint',True)
# Cast mantlet and the coaxial aperture.
gun=B('Gun',(0,-1.423,2.285),'Turret')
gun.box((0,-1.455,2.285),(.45,.23,.39),.07)
gun.cyl((0,-1.59,2.285),.142,.10,'Steel',(0,1,0),32)
gun.cyl((.165,-1.59,2.27),.035,.035,'Interior',(0,1,0),16)
bar=B('Barrel',(0,-1.63,2.285),'Gun')
bar.cyl((0,-1.735,2.285),.073,.23,'Steel',(0,1,0),32,r2=.066)
bar.cyl((0,-2.125,2.285),.045,.58,'Steel',(0,1,0),32,r2=.056)
ring(bar,(0,-2.435,2.285),.049,.024,.09,'Steel',(0,1,0),32)
bar.cyl((0,-2.397,2.285),.024,.01,'Interior',(0,1,0),24)
# hull-mounted 75 mm, offset to the tank RIGHT beside the driver.
gh=B('Gun_Hull',(-.45,-2.65,1.075))
gv=[];gf=[]
for j in range(1,12):
 a=pi*j/12
 for k in range(32):
  t=2*pi*k/32;gv.append((-.45+.245*sin(a)*cos(t),-2.62+.17*cos(a),1.075+.27*sin(a)*sin(t)))
for j in range(10):
 for k in range(32):gf.append((j*32+k,j*32+(k+1)%32,(j+1)*32+(k+1)%32,(j+1)*32+k))
gf.extend([tuple(reversed(range(32))),tuple(range(320,352))]);gh.mesh(gv,gf,'Paint',True)
gh.cyl((-.45,-2.81,1.075),.104,.40,'Steel',(0,1,0),32,r2=.115)
ring(gh,(-.45,-3.065,1.075),.106,.038,.17,'Steel',(0,1,0),32)
gh.cyl((-.45,-3.00,1.075),.037,.012,'Interior',(0,1,0),24)
# Cupola detachable casting, a closed rear opening hatch on its proper hinge.
cp=B('Part_Turret_Cupola',(0,-.60,2.65),'Turret')
ring(cp,(0,-.60,2.645),.277,.19,.16,'Paint',n=32)
ring(cp,(0,-.60,2.723),.253,.19,.018,'Paint')
for a in [0,pi/2,pi,3*pi/2]:
 c=(.267*cos(a),-.60+.267*sin(a),2.659)
 cp.box(c,(.11,.028,.052),.008,'Interior',Matrix.Rotation(a-pi/2,3,'Z'))
 # cast brow above each vision slot
 cp.box((c[0],c[1],c[2]+.038),(.135,.041,.035),.009,'Paint',Matrix.Rotation(a-pi/2,3,'Z'))
# Hatch top at 2.79 m exactly.
hc=B('Hatch_Commander',(0,-.38,2.738),'Turret')
hc.cyl((0,-.60,2.746),.233,.073,'Paint',n=32)
hc.box((0,-.52,2.781),(.14,.045,.018),.008,'Paint')
for x in [-.15,.15]:hc.cyl((x,-.378,2.735),.025,.095,'Steel',(1,0,0),16)
# Open cupola throat descends into the dark well below.
ring(tur,(0,-.60,2.65),.209,.187,.19,'Steel',n=32)
hd=B('Hatch_Driver',(.42,-1.48,1.975))
hd.box((.42,-1.73,1.988),(.49,.47,.032),.015)
for x in [.24,.60]:hd.cyl((x,-1.484,1.986),.026,.075,'Steel',(1,0,0),16)
hd.tube([(.34,-1.8,2.011),(.34,-1.8,2.052),(.5,-1.8,2.052),(.5,-1.8,2.011)],.012,'Steel')
# Turret bolt-on view ports and rear service hatch; no fictional roof MG on this type.
for s in [-1,1]:
 p=B('Part_Turret_Sight_'+('L' if s==1 else 'R'),(s*.68,-.74,2.34),'Turret')
 p.box((s*.689,-.74,2.335),(.055,.24,.13),.025)
 p.box((s*.72,-.74,2.334),(.012,.14,.025),.004,'Interior')
 for yy in [-.81,-.67]:tur.rivet((s*.695,yy,2.43),(s,0,0),.010)
rear=B('Part_Turret_RearHatch',(0,-.145,2.32),'Turret')
rear.cyl((0,-.134,2.32),.16,.045,'Paint',(0,1,0),24)
rear.box((0,-.106,2.28),(.16,.025,.029),.006,'Steel')
# complete turret underparts including a basket stub, important when turret becomes debris.
ring(tur,(0,-.73,1.96),.58,.43,.07,'Steel')
tur.cyl((0,-.73,1.855),.42,.17,'Interior',n=32)

# Track paths: long full-height Char B1 loops, sloping over the front and rear horns.
control=[(-2.38,.12),(-2.73,.30),(-2.98,.64),(-3.01,.94),(-2.83,1.23),(-2.39,1.40),(-1.07,1.76),(.72,1.77),(1.73,1.74),(2.64,1.43),(2.94,1.17),(3.015,.84),(2.85,.48),(2.40,.18),(1.83,.09),(-1.88,.09)]
def smooth_path(ctrl,steps=10):
 pts=[]
 for i in range(len(ctrl)):
  p0,p1,p2,p3=[Vector(ctrl[j%len(ctrl)]) for j in [i-1,i,i+1,i+2]]
  for j in range(steps):
   t=j/steps;pts.append(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t))
 return pts
path=smooth_path(control)
lengths=[0]
for i in range(len(path)):lengths.append(lengths[-1]+(path[(i+1)%len(path)]-path[i]).length)
TOTAL=lengths[-1]
def sample_path(t):
 d=t%1*TOTAL
 for i in range(len(path)):
  if lengths[i+1]>=d:
   f=(d-lengths[i])/(lengths[i+1]-lengths[i]);p=path[i].lerp(path[(i+1)%len(path)],f);tan=(path[(i+1)%len(path)]-path[i]).normalized();return p,tan
NTRACK=104
TRACK_LINKS={}
def track_link(b,x,y,z,angle,t,width=.46):
 # local link: X transverse, Y along track, Z outwards; UV V follows belt arc once.
 R=Matrix.Rotation(angle,3,'X');start=len(b.f)
 b.box((x,y,z),(width,TOTAL/NTRACK*.97,.071),.015,'TrackSteel',R)
 ridge=R@Vector((0,0,.048))+Vector((x,y,z));b.box(ridge,(width*.92,.035,.035),.012,'TrackSteel',R)
 # Bare steel on the worn grouser crowns; photographic baked Scars finish, independent of Paint.
 vv=[R@Vector((xx,yy,.0665))+Vector((x,y,z)) for xx,yy in [(-width*.39,-.004),(-width*.18,-.008),(width*.36,-.006),(width*.39,.002),(width*.12,.006),(-width*.34,.007)]]
 b.mesh(vv,[tuple(range(6))],'Scars')
 # two pin bosses, the visible characteristic linked shoe shoulders.
 for xx in [-width*.38,width*.38]:
  c=R@Vector((xx,0,.054))+Vector((x,y,z));b.cyl(c,.023,.017,'Steel',R@Vector((0,0,1)),n=8)
 for fi in range(start,len(b.f)):
  us=[]
  for vi in b.f[fi]:
   v=Vector(b.v[vi])-Vector((x,y,z));local=R.inverted()@v;us.append((local.x/width+.5,t+local.y/TOTAL))
  b.uv[fi]=us
for side,s in [('L',1),('R',-1)]:
 belt=B('Track_'+side);LINKS=[]
 for i in range(NTRACK):
  t=i/NTRACK;p,tan=sample_path(t);angle=math.atan2(tan.y,tan.x)
  track_link(belt,s*1.012,p.x,p.y,angle,t);LINKS.append((s*1.012,p.x,p.y,angle,t))
 TRACK_LINKS[side]=LINKS
 # Wheel numbering is rigorously front to back; all centres are mechanical X spin pivots.
 specs=[(-2.51,.80,.466,'idler')]+[(y,.278,.184,'road') for y in [-2.20,-1.91,-1.62,-1.33,-1.04,-.75,-.46,-.17,.12,.41,.70,.99,1.28,1.57,1.86,2.15]]+[(2.50,.90,.494,'sprocket')]+[(y,1.50,.118,'return') for y in [-1.0,-.3,.4,1.10,1.70]]
 specs.sort(key=lambda a:a[0])
 for wi,(y,z,r,kind) in enumerate(specs,1):
  w=B(f'Wheel_{side}{wi}',(s*1.0,y,z))
  w.cyl((s*1.0,y,z),r,.26,'TrackSteel' if kind in ['idler','sprocket'] else 'Rubber',(1,0,0),n=32 if r>.3 else 20)
  w.cyl((s*1.153,y,z),r*.83,.043,'Paint',(s,0,0),n=32 if r>.3 else 20)
  ring(w,(s*1.178,y,z),r*.78,r*.70,.022,'Steel',(1,0,0),n=24)
  w.cyl((s*1.20,y,z),r*.29,.047,'Paint',(1,0,0),n=20)
  for a in range(8 if r>.3 else 5):
   ang=a*2*pi/(8 if r>.3 else 5);w.rivet((s*1.232,y+r*.37*cos(ang),z+r*.37*sin(ang)),(s,0,0),.014 if r>.3 else .009,'Steel')
  if kind=='sprocket':
   for j in range(18):
    a=j*2*pi/18;w.box((s*1.0,y+cos(a)*r,z+sin(a)*r),(.24,.075,.061),.008,'TrackSteel',Matrix.Rotation(a-pi/2,3,'X'))
# Removable armour plates; a full chassis and upper track remain behind them.
for side,s in [('L',1),('R',-1)]:
 bounds=[(-2.55,-1.72),(-1.72,-.80),(-.80,.20),(.20,1.20),(1.20,2.35)]
 for i,(a,b) in enumerate(bounds,1):
  topa=min(1.61,1.15+(a+2.55)*.39);topb=min(1.61,1.15+(b+2.55)*.39)
  if b>1.2:topb=1.61-(b-1.2)*.23
  p=B(f'Part_Skirt_{side}{i}',(s*1.205,(a+b)/2,.96))
  prism_side(p,([(a+.10,.30),(a-.22,.65),(a-.22,1.03),(a,topa),(b,topb),(b,.30)] if i==1 else [(a,.30),(a,topa),(b,topb),(b,.30)]),s*1.17,s*1.214,bevel=.013)
  for y in [a+.045,b-.045]:
   top=topa if y<a+.1 else topb
   for z in [.355,.56,.78,1.0,1.22,1.44]:
    if z<top-.04:p.rivet((s*1.227,y,z),(s,0,0),.016)
  for k in range(1,6):
   y=a+(b-a)*k/6;top=topa+(topb-topa)*k/6
   for z in [.355,top-.055]:p.rivet((s*1.227,y,z),(s,0,0),.016)
  # Lower rectangular access covers integral to each removable skirt.
  p.box((s*1.236,(a+b)/2,.48),(.036,min(.32,b-a-.16),.17),.012)
  for y in [(a+b)/2-.14,(a+b)/2+.14]:p.rivet((s*1.262,y,.535),(s,0,0),.01,'Steel')
 # lower rubbing strip, bolted to fixed chassis, not disappearing with panels.
 hull.box((s*1.17,.12,.259),(.07,4.85,.067),.018,'Steel')
 # Folded wide mudguards with thickness and rolled rim: four independent corner assemblies.
 for label,a,b in [('F',-2.22,.10),('R',.10,2.98)]:
  pp=B(f'Part_Fender_{label}{side}',(s*1.015,(a+b)/2,1.92))
  yz=[(a,1.54 if label=='F' else 1.94),(-.98,1.955),(1.72,1.955),(b,1.60 if label=='R' else 1.94)] if False else ([(a,1.56),(-1.02,1.958),(b,1.958)] if label=='F' else [(a,1.958),(1.72,1.958),(b,1.59)])
  for k in range(len(yz)-1):
   y0,z0=yz[k];y1,z1=yz[k+1];ang=math.atan2(z1-z0,y1-y0)
   pp.box((s*1.00,(y0+y1)/2,(z0+z1)/2),(.49,math.hypot(y1-y0,z1-z0)+.018,.025),.009,'Paint',Matrix.Rotation(ang,3,'X'))
  pp.tube([(s*1.248,y,z-.016) for y,z in yz],.018,'Steel',8)
  for y in ([a+.25,-1.0] if label=='F' else [.45,1.60,2.38]):
   z=1.942 if y<1.72 else 1.94-(y-1.72)*.285
   pp.box((s*.855,y,z+.018),(.14,.08,.06),.009)
 # Brackets stay exposed when fenders removed.
 for y in [-1.70,-.9,.3,1.25,2.05]:
  hull.box((s*.915,y,1.835),(.26,.065,.085),.010,'Paint')
# Large left-hand side radiator louvre panel.
rad=B('Part_Radiator_L',(1.26,.80,1.105))
rad.box((1.24,.80,1.12),(.053,1.31,.91),.009,'Interior')
for yy in [.12,1.48]:rad.box((1.28,yy,1.12),(.055,.044,.96),.008)
for zz in [.64,1.60]:rad.box((1.28,.80,zz),(.055,1.40,.045),.008)
for j in range(18):
 z=.687+j*.048
 rad.box((1.297,.80,z),(.061,1.30,.024),.005,'Paint',Matrix.Rotation(.30,3,'Y'))
for yy in [.55,1.02]:rad.box((1.334,yy,1.12),(.018,.021,.90),.004,'Steel')
for yy in [.18,.5,.8,1.10,1.42]:
 for z in [.66,1.58]:rad.rivet((1.321,yy,z),(1,0,0),.012)
# Fully modelled recessed radiator core beneath the detachable louvres.
for j in range(26):hull.box((.883,.17+j*.046,1.15),(.029,.014,.69),.002,'Steel')
# right side entry door, framing and hinges (B1 has no separate loader hatch).
door=B('Part_SideDoor_R',(-1.252,.02,1.065))
door.box((-1.25,.02,1.09),(.059,.68,.86),.035)
for y in [-.31,.35]:
 for z in [.74,.97,1.2,1.45]:door.rivet((-1.291,y,z),(-1,0,0),.015)
for z in [.84,1.36]:door.cyl((-1.30,.37,z),.031,.13,'Steel',(0,0,1),16)
door.tube([(-1.30,-.18,1.04),(-1.36,-.18,1.04),(-1.36,-.18,1.21),(-1.30,-.18,1.21)],.018,'Steel')
# engine deck grille, complete dark engine cover lies beneath it.
deck=B('Part_EngineGrille',(0,1.79,1.96))
deck.box((0,1.72,1.94),(1.30,.57,.055),.012,'Interior')
for x in [-.67,.67]:deck.box((x,1.72,1.979),(.042,.63,.06),.008)
for y in [1.40,2.04]:deck.box((0,y,1.979),(1.37,.043,.06),.008)
for j in range(15):deck.box((-.61+j*.087,1.72,1.98),(.035,.56,.037),.006,'Paint')
# twin cylindrical rear exhaust mufflers, with curved pipes and separate heat shields.
for side,s in [('L',1),('R',-1)]:
 ex=B('Part_Exhaust_'+side,(s*.70,2.18,1.99))
 ex.cyl((s*.70,2.15,2.015),.075,.99,'TrackSteel',(0,1,0),32)
 for y in [1.73,2.56]:ring(ex,(s*.70,y,2.015),.084,.075,.032,'Steel',(0,1,0),24)
 ex.tube([(s*.70,1.8,2.015),(s*.68,1.62,2.015),(s*.58,1.52,1.96),(s*.53,1.52,1.92)],.036,'TrackSteel',16)
 ex.tube([(s*.70,2.58,2.015),(s*.70,2.72,1.98),(s*.70,2.83,1.87)],.036,'TrackSteel',16)
 shield=B('Part_ExhaustShield_'+side,(s*.70,2.19,2.12))
 sv=[]
 for y in [1.66,2.65]:
  for rr in [.094,.101]:
   for k in range(13):
    ang=pi*k/12;sv.append((s*.70+rr*cos(ang),y,2.015+rr*sin(ang)))
 sf=[]
 for k in range(12):sf.extend([(k,k+1,26+k+1,26+k),(13+k,39+k,39+k+1,13+k+1),(k,13+k,13+k+1,k+1),(26+k,26+k+1,39+k+1,39+k)])
 sf.extend([(0,26,39,13),(12,25,51,38)]);shield.mesh(sv,sf,'Paint',True)
 for y in [1.92,2.47]:hull.box((s*.7,y,1.95-(y-1.85)*.29),(.18,.05,.14),.010,'Steel')
# small side stowage boxes and engine deck tool kit are appropriate to the B1.
for side,s in [('L',1),('R',-1)]:
 st=B('Part_Stowage_'+side,(s*.66,.72,2.01))
 st.box((s*.65,.67,1.995),(.26,.71,.14),.020)
 st.box((s*.65,.67,2.069),(.28,.73,.029),.010)
 for y in [.42,.93]:
  st.box((s*.79,y,2.02),(.026,.045,.076),.005,'Steel');st.rivet((s*.811,y,2.02),(s,0,0),.009,'Steel')
tools=B('Part_Tools_R',(-.53,.66,2.105))
tools.cyl((-.52,.62,2.105),.018,.91,'Steel',(0,1,0),12)
tools.box((-.52,1.12,2.108),(.17,.24,.035),.025,'Steel')
for y in [.35,.85]:tools.box((-.52,y,2.102),(.09,.04,.075),.008,'Paint')
# radio base and antenna (kept below the 2.79 m overall envelope).
a=B('Part_Antenna',(-.68,1.07,2.16))
a.cyl((-.68,1.07,2.055),.087,.17,'Paint',n=20)
a.cyl((-.68,1.07,2.185),.039,.14,'Rubber',n=16)
a.cyl((-.68,1.07,2.478),.005,.46,'Steel',n=8)
# Tail hook at rear, curved towing hook profile from the sheet.
tail=B('Part_TailHook',(0,2.88,.98))
tail.box((0,2.78,1.0),(.21,.22,.13),.025,'Steel')
tail.tube([(0,2.77,1.0),(0,2.91,.99),(0,3.08,.98),(0,3.13,1.05),(0,3.12,1.15),(0,3.06,1.18)],.045,'Steel',12)
# lamp bodies, lens and detachable guards.
for side,s in [('L',1),('R',-1)]:
 hull.box((s*.72,-2.075,1.42),(.11,.14,.13),.022)
 hull.cyl((s*.72,-2.16,1.46),.063,.10,'Steel',(0,1,0),24)
 hull.cyl((s*.72,-2.218,1.46),.051,.015,'Lamps',(0,1,0),24)
 guard=B('Part_LampGuard_'+side,(s*.72,-2.22,1.46))
 guard.tube([(s*.72-.09,-2.21,1.39),(s*.72-.09,-2.23,1.55),(s*.72+.09,-2.23,1.55),(s*.72+.09,-2.21,1.39)],.013,'Steel')
 for x in [-.034,.034]:guard.tube([(s*.72+x,-2.244,1.4),(s*.72+x,-2.244,1.52)],.006,'Steel',6)
# Tow cable routed across rear deck, with eyelets and saddles.
cable=B('Part_TowCable',(0,2.50,1.72))
pts=[(-.73,2.5,1.71),(-.6,2.70,1.72),(0,2.75,1.73),(.6,2.70,1.72),(.73,2.5,1.71)]
cable.tube(pts,.026,'TrackSteel',10)
for s in [-1,1]:ring(cable,(s*.73,2.5,1.71),.055,.030,.02,'Steel',(0,0,1),20)
# Cast texture alone is too uniform: sparse chips, scuffs and rain trails are fine physical decals.
# These neutral steel details leave the paint factor free; no marks, emblems or lettering.
for side,s in [('L',1),('R',-1)]:
 for i in range(95):
  y=random.uniform(-2.4,2.25);z=random.uniform(.51,1.48)
  # Keep tiny scratches on side panels; radiator/door naturally occludes them.
  x=s*1.216;le=random.uniform(.018,.085);wi=random.uniform(.001,.003)
  plate=next((BATCHES[f'Part_Skirt_{side}{j+1}'] for j,(a,b) in enumerate([(-2.55,-1.72),(-1.72,-.80),(-.80,.20),(.20,1.20),(1.20,2.35)]) if a<y<b),hull)
  plate.mesh([(x,y,z),(x,y+le,z+.014),(x,y+le*.78,z+.014+wi),(x,y,z+wi)],[(0,1,2,3)],'TrackSteel')
# Rear bustle handle and upper deck railing, not a fictional tank bustle rack.
rail=B('Part_DeckRail',(0,.46,2.04))
rail.tube([(.30,.40,1.96),(.30,.40,2.135),(.30,1.10,2.135),(.30,1.10,1.96)],.018,'Steel')
# Rear exhausts follow the sloped engine deck, with real contact at their mounting saddles.
for name,b in BATCHES.items():
 if name.startswith('Part_Exhaust'):
  pivot=Vector((b.pivot.x,1.82,2.00));rot=Matrix.Rotation(-.29,3,'X')
  b.v=[tuple(pivot+rot@(Vector(v)-pivot)) for v in b.v];b.pivot=pivot+rot@(b.pivot-pivot)
for name in list(BATCHES):BATCHES[name].finish()
bpy.context.view_layer.update()
# Set parent transforms after dependency graph update; authoring coordinates above are world-space.
for name,b in BATCHES.items():
 if b.parent:
  o=OBJECTS[name];o.parent=OBJECTS[b.parent];o.matrix_parent_inverse=Matrix.Identity(4);o.location=b.pivot-BATCHES[b.parent].pivot
bpy.context.view_layer.update()
# Real hatch openings: structural walls and dark floors give head-out crew spaces depth.
for target,centre,radius in [('Turret',(0,-.60,2.38),.183),('Hull',(.42,-1.73,1.64),.19)]:
 well=Batch('_crew_well_'+target)
 ring(well,(centre[0],centre[1],centre[2]+.09),radius+.017,radius,.20,'Interior',n=24)
 well.cyl(centre,radius,.025,'Interior',n=24)
 ob=well.finish();bpy.ops.object.select_all(action='DESELECT');ob.select_set(True);OBJECTS[target].select_set(True);bpy.context.view_layer.objects.active=OBJECTS[target];bpy.ops.object.join()
 # Batch scratch names are not delivery nodes.
 OBJECTS.pop('_crew_well_'+target,None)
bpy.context.view_layer.update()
empty('Muzzle',(0,-2.48,2.285),'Barrel');empty('Muzzle_Hull',(-.45,-3.151,1.075),'Gun_Hull')
for name,pos,par in [('Seat_Commander',(0,-.60,2.84),'Turret'),('Seat_Driver',(.41,-1.75,1.90),None),('Exhaust',(.70,2.75,1.586),None),('Light_Head_L',(.72,-2.23,1.46),None),('Light_Head_R',(-.72,-2.23,1.46),None),('Fx_Engine',(0,1.56,1.99),None),('Fx_Ammo',(0,-.17,1.15),None),('Fx_Hatch',(0,-.60,2.79),'Turret')]:empty(name,pos,par)
# Detachable pieces use their exact geometric bounds centre, while moving rig pivots stay untouched.
for name,o in OBJECTS.items():
 if name.startswith('Part_'):
  bpy.context.view_layer.update();w=o.matrix_world.copy();corners=[w@Vector(c) for c in o.bound_box];centre=Vector([(min(c[i] for c in corners)+max(c[i] for c in corners))/2 for i in range(3)])
  for v in o.data.vertices:v.co=w@v.co-centre
  o.matrix_world=Matrix.Translation(centre);o.data.update()
bpy.context.view_layer.update()
HEALTHY=list(OBJECTS.values())
# Preserve the shoe silhouettes and belt UVs while reducing hidden bevel tessellation.
for o in HEALTHY:
 if o.type=='MESH' and (o.name.startswith('Track_') or o.name.startswith('Wheel_')):
  bpy.context.view_layer.objects.active=o
  dec=o.modifiers.new('Game mesh optimisation','DECIMATE');dec.ratio=.72 if o.name.startswith('Track_') else .92
  bpy.ops.object.modifier_apply(modifier=dec.name)
print('GEOMETRY_READY',len(HEALTHY),flush=True)

def tris(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):
 pts=[o.matrix_world@Vector(c) for o in obs if o.type=='MESH' for c in o.bound_box]
 return [[min(v[i] for v in pts) for i in range(3)],[max(v[i] for v in pts) for i in range(3)]]
def glcoord(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]

def patch_glb(path):
 raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];j=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
 for mat in j.get('materials',[]):
  if mat['name']=='Paint':mat.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=PAINTS[0][1]+[1]
 enc=json.dumps(j,separators=(',',':')).encode();enc+=b' '*((-len(enc))%4)
 path.write_bytes(struct.pack('<4sII',b'glTF',2,12+8+len(enc)+len(binary))+struct.pack('<I4s',len(enc),b'JSON')+enc+binary)
 return j

def export(obs,path):
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:
  o.hide_set(False);o.hide_render=False;o.select_set(True)
  if o.type=='MESH':
   # Blender cannot generate MikkTSpace tangents for n-gons; triangulate in local
   # mesh space, preserving every mechanical object pivot and UV loop.
   bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free();o.data.update()
 # Export shader is direct image PBR; exact variant factor is written into serialized JSON.
 m=MATS[0];nt=m.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');nt.links.new(nt.nodes['Baked_BaseColor'].outputs[0],bs.inputs['Base Color'])
 bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_tangents=True,export_apply=False,export_extras=True,export_materials='EXPORT',export_image_format='JPEG',export_jpeg_quality=88,export_keep_originals=False)
 nt.links.new(nt.nodes['PaintFactor'].outputs[0],bs.inputs['Base Color'])
 j=patch_glb(path);print('EXPORTED',path.name,tris(obs),path.stat().st_size,flush=True);return j
healthy_json=export(HEALTHY,OUT/'char-b1-tank.glb')
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'healthy.blend'))
print('HEALTHY_COMPLETE',flush=True)

# Damage assets: deformed copies retain precisely the original Part origin.
DAMAGE=[]
def damage_copy(src,name,amount=.08):
 o=src.copy();o.data=src.data.copy();o.name=name;bpy.context.collection.objects.link(o);o.parent=None;o.matrix_world=src.matrix_world.copy()
 # A buckled end and rippled metal; normals and UVs preserved from the same healthy geometry.
 for v in o.data.vertices:
  p=v.co;f=max(0,(p.y+.15));p.x+=amount*sin(p.y*4.7+p.z*3)*f;p.z+=amount*cos(p.y*5.4)*f
 o.data.update();return o
hdroot=empty('Hull_Damage',(0,0,0));tdroot=empty('Turret_Damage',(0,-.73,1.96));DAMAGE += [hdroot,tdroot]
PARTS=[o for o in HEALTHY if o.name.startswith('Part_')]
TWIN={}
def attach(o,parent):
 bpy.context.view_layer.update();world=o.matrix_world.copy();o.parent=parent;o.matrix_parent_inverse=parent.matrix_world.inverted();o.matrix_world=world;bpy.context.view_layer.update()
for src in PARTS:
 if src.name.startswith('Part_Turret_') or src.name in ['Part_Skirt_L1','Part_Skirt_L3','Part_Skirt_R2','Part_Skirt_R5','Part_Fender_FL','Part_Fender_FR','Part_Fender_RL','Part_Fender_RR','Part_Radiator_L','Part_SideDoor_R','Part_Stowage_L','Part_Exhaust_R','Part_EngineGrille']:
  name=src.name.replace('Part_','Dmg_',1);o=damage_copy(src,name,.31 if 'Fender' in name else .10)
  for face in o.data.polygons:
   if face.center.y>.2 and random.random()<.65:face.material_index=MI['Burnt']
  # Torn open strip removes a subset of faces near the buckled corner.
  bm=bmesh.new();bm.from_mesh(o.data)
  cut=[f for f in bm.faces if f.calc_center_median().y>.13 and abs(f.calc_center_median().z)<.065 and random.random()<.4]
  if cut:bmesh.ops.delete(bm,geom=cut,context='FACES')
  bm.to_mesh(o.data);bm.free();attach(o,tdroot if src.name.startswith('Part_Turret_') else hdroot);DAMAGE.append(o);TWIN[src.name]=name
# Geometry marks hugging armour surfaces, black crater centre with torn raised steel petals.
def scar_batch(name,centres,normal):
 from mathutils.bvhtree import BVHTree
 normal=Vector(normal).normalized();bpy.context.view_layer.update()
 targets=[OBJECTS['Turret']] if name.startswith('Scar_Turret') else [OBJECTS['Hull']]+[o for o in PARTS if 'Skirt' in o.name or 'SideDoor' in o.name]
 bvhs=[BVHTree.FromPolygons([o.matrix_world@v.co for v in o.data.vertices],[list(p.vertices) for p in o.data.polygons]) for o in targets]
 projected=[]
 for centre in centres:
  c=Vector(centre);hits=[bh.ray_cast(c+normal,-normal,2) for bh in bvhs];hits=[h for h in hits if h[0] is not None]
  projected.append(min(hits,key=lambda h:h[3])[0]+normal*.001 if hits else c)
 centres=projected
 b=Batch(name);R=Vector((0,0,1)).rotation_difference(normal).to_matrix()
 for i,c in enumerate(centres):
  c=Vector(c);r=.046 if i else .072
  b.cyl(c+normal*.0015,r*1.72,.001,'Burnt',normal,n=18)
  b.cyl(c+normal*.002,r*.66,.001,'Interior',normal,n=16)
  vs=[]
  for rr,zz in [(r*1.05,.001),(r*.65,.0025),(r*.42,.0015)]:
   for k in range(12):
    a=k*2*pi/12;rrr=rr*(1+.19*sin(k*8.1+i));vs.append(c+R@Vector((rrr*cos(a),rrr*sin(a),zz+(k%3)*.0005)))
  fs=[]
  for j in range(2):
   for k in range(12):fs.append((j*12+k,j*12+(k+1)%12,(j+1)*12+(k+1)%12,(j+1)*12+k))
  b.mesh(vs,fs,'Scars')
  # directional ricochet scratches, thin tapering bright exposed steel.
  p=c+R@Vector((r*1.2,0,.004));b.mesh([p,p+R@Vector((.19,.025,0)),p+R@Vector((.10,.04,0))],[(0,1,2)],'Scars')
 return b.finish()
scars=[]
for face,centres,normal in [
 ('Front',[(-.14,-2.802,.83),(.22,-2.802,.72),(.63,-2.802,.93),(-.63,-2.802,.75)],(0,-1,0)),
 ('L',[(1.242,-1.38,1.05),(1.242,-.67,.88),(1.242,1.93,1.10),(1.242,1.63,.76)],(1,0,0)),
 ('R',[(-1.289,-1.45,.91),(-1.289,.13,1.14),(-1.289,1.7,.89),(-1.289,1.9,1.27)],(-1,0,0)),
 ('Rear',[(.25,2.752,.93),(-.41,2.752,1.21),(.52,2.752,.73),(-.20,2.752,.68)],(0,1,0))]:
 o=scar_batch('Scar_Hull_'+face,centres,normal);attach(o,hdroot);DAMAGE.append(o);scars.append(o.name)
for face,centres,normal in [
 ('Front',[(-.28,-1.40,2.34),(.28,-1.40,2.40),(0,-1.33,2.50)],(0,-1,0)),
 ('L',[(.690,-.72,2.20),(.658,-.48,2.29),(.63,-.96,2.31)],(1,0,.12)),
 ('R',[(-.690,-.70,2.20),(-.658,-.47,2.29),(-.63,-.96,2.31)],(-1,0,.12)),
 ('Rear',[(-.24,-.145,2.30),(.21,-.145,2.29),(.05,-.19,2.45)],(0,1,.1))]:
 o=scar_batch('Scar_Turret_'+face,centres,normal);attach(o,tdroot);DAMAGE.append(o);scars.append(o.name)
# Thrown tracks retain the bottom/rear segment. Front and upper links sag and snake onto ground.
for side,s in [('L',1),('R',-1)]:
 broken=Batch('Track_'+side+'_Broken')
 # An open continuous belt wraps the bottom/rear, then droops off the upper rollers to ground.
 raw=[(s*1.06,-3.85,.066),(s*1.03,-2.65,.077),(s*1.012,-1.8,.088),(s*1.012,1.95,.088),(s*1.012,2.68,.35),(s*1.02,2.98,.87),(s*1.07,2.70,1.30),(s*1.22,2.05,1.08),(s*1.40,1.56,.10),(s*1.49,.7,.067),(s*1.53,-1.70,.066),(s*1.65,-2.65,.066)]
 curve=[]
 for j in range(len(raw)-1):
  p0,p1,p2,p3=[Vector(raw[max(0,min(k,len(raw)-1))]) for k in [j-1,j,j+1,j+2]]
  for q in range(12):
   t=q/12;v=.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t);v.z=max(.065,v.z);curve.append(v)
 curve.append(Vector(raw[-1]));cum=[0]
 for j in range(len(curve)-1):cum.append(cum[-1]+(curve[j+1]-curve[j]).length)
 count=round(cum[-1]/(TOTAL/NTRACK))
 for i in range(count):
  dist=cum[-1]*i/(count-1)
  j=next((q for q in range(len(cum)-1) if cum[q+1]>=dist),len(cum)-2)
  frac=(dist-cum[j])/(cum[j+1]-cum[j]);v=curve[j].lerp(curve[j+1],frac);tan=(curve[j+1]-curve[j]).normalized();angle=math.atan2(tan.z,tan.y)
  track_link(broken,v.x,v.y,v.z,angle,i/count)
 o=broken.finish();offset=bounds(HEALTHY)[0][2]-min(v.co.z for v in o.data.vertices)
 for v in o.data.vertices:v.co.z+=offset
 bpy.context.view_layer.objects.active=o;dec=o.modifiers.new('Thrown belt optimisation','DECIMATE');dec.ratio=.58;bpy.ops.object.modifier_apply(modifier=dec.name);attach(o,hdroot);DAMAGE.append(o)
# Gun broken includes own mantlet and a physically peeled burst barrel.
gbroken=Batch('Gun_Broken',(0,-1.423,2.285))
for srcname in ['Gun','Barrel']:
 src=OBJECTS[srcname];mesh=src.data
 vs=[src.matrix_world@v.co for v in mesh.vertices]
 if srcname=='Barrel':
  for v in vs:
   if v.y<-1.83:v.z-=(-v.y-1.83)*.50
 for poly in mesh.polygons:
  if srcname=='Barrel' and sum(vs[i].y for i in poly.vertices)/len(poly.vertices)<-2.22:continue
  gbroken.mesh([vs[i] for i in poly.vertices],[tuple(range(len(poly.vertices)))],MATS[poly.material_index].name,poly.use_smooth)
for k in range(6):
 a=k*pi/3;pts=[(.046*cos(a),-2.17,2.12),(.09*cos(a),-2.30,2.06+.06*sin(a)),(.145*cos(a),-2.38,2.07+.10*sin(a))]
 gbroken.tube(pts,.017,'Scars',6)
o=gbroken.finish();attach(o,tdroot);DAMAGE.append(o)
# Wreck built only from healthy geometry copies. Joined and decimated to keep damage budget bounded.
def joined_wreck(name,sources,pivot,ratio):
 copies=[]
 for src in sources:
  if src.type!='MESH':continue
  o=src.copy();o.data=src.data.copy();o.parent=None;o.matrix_world=src.matrix_world.copy();bpy.context.collection.objects.link(o)
  # Structural devastation: missing removable plates, distorted fenders, hatches flung open.
  if src.name.startswith('Hatch_'):
   o.rotation_euler.x=math.radians(-105)
  if src.name.startswith('Wheel_'):o.location.z-=.045
  if src.name.startswith('Track_'):
   for v in o.data.vertices:v.co.z-=.045*max(0,min(1,(v.co.z-.15)/1.2))
  if 'Fender' in src.name or 'EngineGrille' in src.name:
   for v in o.data.vertices:v.co.z+=max(0,v.co.y+.2)*.22*sin(v.co.y*2.7+.5)
  if src.name in ['Gun','Barrel']:
   pivotg=Vector((0,-1.423,2.285));rot=Matrix.Rotation(math.radians(18),4,'X')
   o.matrix_world=Matrix.Translation(pivotg)@rot@Matrix.Translation(-pivotg)@o.matrix_world
  o.data.materials.clear();o.data.materials.append(MATS[MI['Burnt']])
  for p in o.data.polygons:p.material_index=0
  copies.append(o)
 bpy.ops.object.select_all(action='DESELECT')
 for o in copies:o.select_set(True)
 bpy.context.view_layer.objects.active=copies[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 bpy.context.scene.cursor.location=pivot;bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 dec=o.modifiers.new('Wreck geometry reduction','DECIMATE');dec.ratio=ratio;bpy.ops.object.modifier_apply(modifier=dec.name)
 return o
# Replace the unbroken left belt with its actual thrown twin in the wreck.
ths=[o for o in HEALTHY if o.type=='MESH' and (o.name=='Turret' or o.parent==OBJECTS['Turret'] or o.name in ['Gun','Barrel'])]
# Do not include hull bits in turret wreck. Commander hatch and turret attachments follow it.
ths=[o for o in HEALTHY if o.type=='MESH' and (o.name in ['Turret','Gun','Barrel','Hatch_Commander'] or o.name.startswith('Part_Turret_'))]
hs=[o for o in HEALTHY if o.type=='MESH' and o not in ths and o.name not in ['Part_Skirt_L2','Part_Skirt_L4','Part_Skirt_R3','Part_Stowage_L','Part_Radiator_L','Part_EngineGrille','Track_L']]
hs.append(OBJECTS['Track_L_Broken'])
wh=joined_wreck('Wreck_Hull',hs,(0,0,0),.40);wt=joined_wreck('Wreck_Turret',ths,(0,-.73,1.96),.52)
# Real open turret ring carved through wreck hull; dark well and torn engine aperture.
well=Batch('_wreck_well');ring(well,(0,-.73,1.74),.535,.48,.39,'Burnt');well.cyl((0,-.73,1.52),.48,.04,'Burnt',n=32)
for j in range(7):
 x=-.55+j*.17;well.box((x,1.6,2.06+random.uniform(0,.15)),(.14,.57,.026),.008,'Burnt',Matrix.Rotation(random.uniform(-.65,.65),3,'X'))
wellob=well.finish();bpy.ops.object.select_all(action='DESELECT');wh.select_set(True);wellob.select_set(True);bpy.context.view_layer.objects.active=wh;bpy.ops.object.join()
basket=Batch('_basket');ring(basket,(0,-.73,1.89),.49,.42,.17,'Burnt',n=32);basket.cyl((0,-.73,1.80),.43,.03,'Burnt',n=32)
basketob=basket.finish();bpy.ops.object.select_all(action='DESELECT');wt.select_set(True);basketob.select_set(True);bpy.context.view_layer.objects.active=wt;bpy.ops.object.join()
DAMAGE.extend([wh,wt])
# Restrict material vocabulary in file B while retaining equivalent finishes.
for o in DAMAGE:
 if o.type=='MESH':
  for i,m in enumerate(o.data.materials):
   if m and m.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:
    o.data.materials[i]=MATS[MI['Burnt' if m.name=='Interior' else 'Scars']]
DAMAGE=[o for o in DAMAGE if o.name in bpy.data.objects]
bpy.context.view_layer.update()
# Normalize vertex coordinates, not object transforms: moving nodes keep their true pivots.
# Include protruding radiator frame/door furniture in the specified overall dimensions.
raw_bounds=bounds(HEALTHY)
SX=2.50/(raw_bounds[1][0]-raw_bounds[0][0]);SY=6.37/(raw_bounds[1][1]-raw_bounds[0][1]);SZ=2.79/(raw_bounds[1][2]-raw_bounds[0][2]);Z0=raw_bounds[0][2]
def fit_all(obs):
    records=[(o,o.parent,o.matrix_world.copy()) for o in obs]
    def fit(v):return Vector((v.x*SX,v.y*SY,(v.z-Z0)*SZ))
    for o,parent,world in records:
        origin=fit(world.translation)
        if o.type=='MESH':
            for v in o.data.vertices:v.co=fit(world@v.co)-origin
            o.data.update()
        o.parent=None;o.location=origin;o.rotation_euler=(0,0,0);o.scale=(1,1,1)
    bpy.context.view_layer.update()
    for o,parent,world in records:
        if parent:
            pos=o.location.copy();o.parent=parent;o.matrix_parent_inverse=Matrix.Identity(4);o.location=pos-parent.matrix_world.translation
    bpy.context.view_layer.update()
fit_all(HEALTHY+DAMAGE)
# Assembly roots stay exactly at the specified world origin; children remain in place.
for root in [OBJECTS['Hull'],hdroot,wh]:
    old=root.location.copy()
    if root.type=='MESH':
        for v in root.data.vertices:v.co+=old
    else:
        for child in root.children:child.location+=old
    root.location=(0,0,0)
bpy.context.view_layer.update()
healthy_json=export(HEALTHY,OUT/'char-b1-tank.glb')
damage_json=export(DAMAGE,OUT/'char-b1-tank-damage.glb')
# Manifest includes serialized names/positions and explicit damage correspondence.
bbox=bounds(HEALTHY)
def nodeinfo(obs):
 return [{'name':o.name,'parent':o.parent.name if o.parent else None,'position':glcoord(o.matrix_world.translation)} for o in obs]
manifest={'build':{'script_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'blender':bpy.app.version_string},'model':'Char B1 bis style','units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'healthy':{'file':'char-b1-tank.glb','nodes':nodeinfo(HEALTHY),'triangles':tris(HEALTHY),'bytes':(OUT/'char-b1-tank.glb').stat().st_size},'damage_file':{'file':'char-b1-tank-damage.glb','nodes':nodeinfo(DAMAGE),'triangles':tris(DAMAGE),'bytes':(OUT/'char-b1-tank-damage.glb').stat().st_size},'bounding_box':{'min':[bbox[0][0],bbox[0][2],-bbox[1][1]],'max':[bbox[1][0],bbox[1][2],-bbox[0][1]]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'damage':{'contract':'tank-damage/v1','parts':[{'part':o.name,'twin':TWIN.get(o.name)} for o in PARTS],'scars':scars,'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'historical_notes':'No roof MG or separate loader hatch: one-man APX turret; hull 75 mm on right, radiator on left, right access door. Antenna shortened to requested height envelope.','material_bakes':{'engine':'Cycles','device':'OPTIX','samples':4,'channels':['BaseColor EMIT','tangent Normal NORMAL','ORM EMIT'],'source_ids':PH_IDS}}
(OUT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(OUT/'NOTES.md').write_text('\n'.join(["Char B1 bis style tank in metres; pivot rig, removable parts and tank-damage/v1 companion, no markings.",f"Triangles: healthy {tris(HEALTHY):,}; damage {tris(DAMAGE):,}; GLB bytes {(OUT/'char-b1-tank.glb').stat().st_size:,} / {(OUT/'char-b1-tank-damage.glb').stat().st_size:,}.",'Materials: Paint, TrackSteel, Rubber, Steel, Interior, Lamps, Scars; damage adds Burnt.','Textures: shared Cycles-baked tiles; Paint 1024; other colour/normal ≤512; ORM ≤512.', 'Poly Haven CC0 ids: metal_plate, rusty_metal_03; https://polyhaven.com/license ; source maps downloaded from dl.polyhaven.org.', 'Reference differences: shortened antenna, simplified concealed mechanical detail; procedural wear and reconstructed casting, not a museum scan.'])+'\n')
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'asset-source.blend'))
if BUILD_ONLY:sys.exit(0)

# ALL delivery beauty/proof renders use both EXPORTED glTFs 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 ng in list(bpy.data.node_groups):bpy.data.node_groups.remove(ng)
bpy.ops.import_scene.gltf(filepath=str(OUT/'char-b1-tank.glb'));H=list(scene.objects)
bpy.ops.import_scene.gltf(filepath=str(OUT/'char-b1-tank-damage.glb'));D=[o for o in scene.objects if o not in H]
HMAP={o.name:o for o in H};DMAP={o.name:o for o in D}
# Imported axes return to Blender's Z-up automatically. Parent pivots remain local and intact.
for o in D:o.hide_render=True

def hideall(obs,hide=True):
 for o in obs:o.hide_render=hide

def setup_render():
 bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.08));floor=bpy.context.object;floor.name='_StudioFloor'
 mat=bpy.data.materials.new('_Ground');mat.diffuse_color=(.22,.25,.26,1);mat.use_nodes=True;bs=mat.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(.22,.25,.26,1);bs.inputs['Roughness'].default_value=.85;floor.data.materials.append(mat)
 world=bpy.data.worlds.new('_Daylight');scene.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.53,.64,.78,1);world.node_tree.nodes['Background'].inputs[1].default_value=.42
 for name,pos,power,size in [('Key',(4,-5,8),1700,6),('Fill',(-4,-1,4),1100,5),('Rim',(2,6,6),2000,5)]:
  data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;o=bpy.data.objects.new(name,data);scene.collection.objects.link(o);o.location=pos;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
 data=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',data);scene.collection.objects.link(cam);scene.camera=cam
 return cam
cam=setup_render()
def view(side=False,wide=False):
 cam.location=(10,-13,8) if not side else (11,0,2.2)
 target=Vector((0,0,1.1));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.type='ORTHO';cam.data.ortho_scale=9.5 if wide else (8.5 if not side else 7.5);cam.data.lens=52

def render(path,w=1920,h=1080,samples=32):
 scene.render.resolution_x=w;scene.render.resolution_y=h;scene.render.resolution_percentage=100;scene.cycles.samples=samples;scene.render.filepath=str(path);bpy.ops.render.render(write_still=True);print('RENDERED',path.name,flush=True)
view();render(OUT/'final.png',samples=32)
view(True);render(OUT/'side.png',samples=24)
view();cam.location=(-10,13,7);cam.rotation_euler=(Vector((0,0,1.1))-cam.location).to_track_quat('-Z','Y').to_euler();render(OUT/'rear.png',samples=24)
view();clay=bpy.data.materials.new('_Clay');clay.diffuse_color=(.34,.34,.34,1);clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.34,.34,.34,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
scene.view_layers[0].material_override=clay;render(OUT/'clay.png',samples=24);scene.view_layers[0].material_override=None
# Verify every moving node retains its centre after a 30-degree transform.
pivot_evidence=[];saved=[(o,o.matrix_basis.copy()) for o in H]
for o in H:
 if o.name=='Barrel':
  o.location.y+=.08;bpy.context.view_layer.update();pivot_evidence.append({'name':o.name,'recoil_metres':.08,'axis':'-Z glTF / +Y Blender'});continue
 if o.name=='Turret' or o.name in ['Gun','Gun_Hull'] or o.name.startswith('Wheel_') or o.name.startswith('Hatch_'):
  loc=o.matrix_world.translation.copy();axis='Z' if o.name=='Turret' else 'X';angle=30 if o.name=='Turret' or o.name.startswith('Wheel_') else -30;o.matrix_basis=o.matrix_basis@Matrix.Rotation(math.radians(angle),4,axis);bpy.context.view_layer.update();pivot_evidence.append({'name':o.name,'rotation_degrees':angle,'origin_shift_m':(o.matrix_world.translation-loc).length})
render(OUT/'pivots.png',960,540,12)
for o,r in saved:o.matrix_basis=r
bpy.context.view_layer.update()
manifest['pivot_test']=pivot_evidence
# Contact sheets are composed from real independent renders in Blender using packed image pixels.
# No image editing package or external renderer is required.
def contact(paths,dest,w=640,h=360):
 import numpy as np
 arr=np.zeros((h*2,w*3,4),dtype=np.float32)
 for i,p in enumerate(paths):
  im=bpy.data.images.load(str(p),check_existing=False);a=np.empty(w*h*4,dtype=np.float32);im.pixels.foreach_get(a);a=a.reshape(h,w,4)
  row=1-i//3;col=i%3;arr[row*h:(row+1)*h,col*w:(col+1)*w]=a;bpy.data.images.remove(im)
 im=bpy.data.images.new(dest.stem,width=w*3,height=h*2,alpha=True);im.pixels.foreach_set(arr.ravel());im.filepath_raw=str(dest);im.file_format='PNG';im.save();bpy.data.images.remove(im)
# Recolour via glTF imported shader factor multiplier.
paintm=[]
for o in H:
 if o.type=='MESH':
  for m in o.data.materials:
   if m and m.name.startswith('Paint') and m not in paintm:paintm.append(m)
def setpaint(c):
 for m in paintm:
  nt=m.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED')
  # importer creates a glTF factor multiply; expose a stable node regardless of importer version.
  factor=nt.nodes.get('_VariantFactor')
  if factor is None:
   tex=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and ('BaseColor' in n.image.name or 'base' in n.label.lower())) if any(n.type=='TEX_IMAGE' and ('BaseColor' in n.image.name or 'base' in n.label.lower()) for n in nt.nodes) else next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image.colorspace_settings.name=='sRGB')
   factor=nt.nodes.new('ShaderNodeMixRGB');factor.name='_VariantFactor';factor.blend_type='MULTIPLY';factor.inputs[0].default_value=1;nt.links.new(tex.outputs[0],factor.inputs[1]);nt.links.new(factor.outputs[0],bs.inputs['Base Color'])
  factor.inputs[2].default_value=tuple(c)+(1,)
paths=[]
for i,(name,c) in enumerate(PAINTS):
 setpaint(c);p=OUT/f'_paint_{i}.png';render(p,640,360,12);paths.append(p)
contact(paths,OUT/'paints.png');setpaint(PAINTS[0][1])
paths=[]
for state in range(6):
 hideall(H,False);hideall(D,True);view(wide=True)
 if state==1:
  for o in D:
   if o.name.startswith('Scar_'):o.hide_render=False
  swaps=['Part_Fender_FL','Part_Fender_FR','Part_Radiator_L','Part_Skirt_L1','Part_Skirt_R2','Part_EngineGrille']
  for name in swaps:
   HMAP[name].hide_render=True;DMAP[TWIN[name]].hide_render=False
 elif state==2:
  for o in H:
   if o.name.startswith('Part_'):o.hide_render=True
 elif state==3:
  HMAP['Track_L'].hide_render=True;DMAP['Track_L_Broken'].hide_render=False
  for name in ['Gun','Barrel']:HMAP[name].hide_render=True
  DMAP['Gun_Broken'].hide_render=False
 elif state>=4:
  hideall(H,True);DMAP['Wreck_Hull'].hide_render=False;DMAP['Wreck_Turret'].hide_render=False
  if state==5:
   o=DMAP['Wreck_Turret'];o.matrix_world=Matrix.Translation((2.90,-.30,.70))@Matrix.Rotation(math.radians(155),4,'X')@Matrix.Rotation(math.radians(-25),4,'Z')@o.matrix_world.to_3x3().to_4x4();bpy.context.view_layer.update();o.location.z-=min((o.matrix_world@Vector(v)).z for v in o.bound_box);cam.data.ortho_scale=10
 p=OUT/f'_state_{state}.png';render(p,640,360,16);paths.append(p)
contact(paths,OUT/'states.png')
(OUT/'nodes.json').write_text(json.dumps(manifest,indent=2))
print('ALL_DELIVERABLES_COMPLETE',flush=True)
