"""Tiger I style game asset. Clean build: blender -b --python-exit-code 1 --python this_file.py
CC0 source maps downloaded from Poly Haven; all delivered material maps baked by Cycles.
Authored Z-up / forward -Y; glTF exporter converts to Y-up / forward +Z.
"""
import bpy, bmesh, math, os, sys, json, struct, subprocess, random, time
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin,cos,pi
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT)
random.seed(1943)
bpy.ops.wm.read_factory_settings(use_empty=True)
scene=bpy.context.scene
scene.render.engine='CYCLES';scene.cycles.samples=48
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
scene.cycles.device='GPU';scene.cycles.use_denoising=True
scene.render.image_settings.file_format='PNG'
scene.view_settings.view_transform='AgX'
scene.view_settings.look='AgX - Medium High Contrast'
scene.view_settings.exposure=-.65
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
SRC=ROOT/'sources';SRC.mkdir(exist_ok=True)
SOURCES=['green_metal_rust','rusty_metal_04','rubberized_track','dark_wood']
for asset in SOURCES:
 for mp in ['diff','nor_gl','arm']:
  fn=SRC/f'{asset}_{mp}_1k.jpg'
  if not fn.exists():
   url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mp}_1k.jpg'
   subprocess.run(['curl','-f','-L','--retry','2','-A','Mozilla/5.0',url,'-o',str(fn)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
print('SOURCES_READY',flush=True)
materials=[];base_nodes={}
# Bake seven reusable material tiles. This preserves texture density on long panels.
def bake_material(name,asset,color,metal,rough):
 m=bpy.data.materials.new(name);m.use_nodes=True;n=m.node_tree.nodes;l=m.node_tree.links;n.clear()
 out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=n.new('ShaderNodeTexCoord')
 def tex(mp,cs):
  q=n.new('ShaderNodeTexImage');q.image=bpy.data.images.load(str(SRC/f'{asset}_{mp}_1k.jpg'),check_existing=True);q.image.colorspace_settings.name=cs;l.new(uv.outputs['UV'],q.inputs['Vector']);return q
 diff=tex('diff','sRGB');nor=tex('nor_gl','Non-Color');arm=tex('arm','Non-Color')
 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].position=.045;ramp.color_ramp.elements[0].color=(.43,.43,.43,1)
  ramp.color_ramp.elements[1].position=.092;ramp.color_ramp.elements[1].color=(.97,.97,.97,1)
 else:
  ramp.color_ramp.elements[0].color=tuple(v*.48 for v in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(color)+(1,)
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=96;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 mul=n.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=.07;l.new(ramp.outputs['Color'],mul.inputs[1]);l.new(noise.outputs['Fac'],mul.inputs[2])
 if name=='Paint':
  stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(28,2,1);l.new(uv.outputs['UV'],stretch.inputs[0])
  streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=2;streak.inputs['Detail'].default_value=3;l.new(stretch.outputs[0],streak.inputs['Vector'])
  sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.27;sr.color_ramp.elements[0].color=(.35,.35,.35,1);sr.color_ramp.elements[1].position=.63;sr.color_ramp.elements[1].color=(1,1,1,1);l.new(streak.outputs['Fac'],sr.inputs[0])
  dirty=n.new('ShaderNodeMixRGB');dirty.blend_type='MULTIPLY';dirty.inputs[0].default_value=.07;l.new(mul.outputs[0],dirty.inputs[1]);l.new(sr.outputs[0],dirty.inputs[2]);mul=dirty
 l.new(mul.outputs[0],bs.inputs['Base Color']);bs.inputs['Metallic'].default_value=metal
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs['Color'],sep.inputs[0])
 rmath=n.new('ShaderNodeMath');rmath.operation='MULTIPLY_ADD';l.new(sep.outputs['Green'],rmath.inputs[0]);rmath.inputs[1].default_value=.17;rmath.inputs[2].default_value=rough-.10;l.new(rmath.outputs[0],bs.inputs['Roughness'])
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.32 if name=='Paint' else .38;l.new(nor.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=2);obj=bpy.context.object;obj.data.materials.append(m)
 target=n.new('ShaderNodeTexImage');n.active=target
 saved={}
 emit=n.new('ShaderNodeEmission')
 for kind,res in [('base',1024),('normal',1024),('orm',512)]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color';target.image=im;n.active=target
  if kind=='normal':
   l.new(bs.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL',margin=8)
  else:
   if kind=='base':l.new(mul.outputs[0],emit.inputs[0])
   else:
    comb=n.new('ShaderNodeCombineColor');comb.inputs['Red'].default_value=1;comb.inputs['Blue'].default_value=metal;l.new(rmath.outputs[0],comb.inputs['Green']);l.new(comb.outputs[0],emit.inputs[0])
   l.new(emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT',margin=8)
  im.filepath_raw=str(TEX/f'{name}_{kind}.jpg');im.file_format='JPEG';im.save();saved[kind]=im
 bpy.data.objects.remove(obj,do_unlink=True)
 n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 ts={}
 for kind in saved:
  q=n.new('ShaderNodeTexImage');q.image=saved[kind];q.interpolation='Linear';ts[kind]=q
 l.new(ts['base'].outputs[0],bs.inputs['Base Color'])
 nm=n.new('ShaderNodeNormalMap');l.new(ts['normal'].outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 sep=n.new('ShaderNodeSeparateColor');l.new(ts['orm'].outputs[0],sep.inputs[0]);l.new(sep.outputs['Green'],bs.inputs['Roughness']);l.new(sep.outputs['Blue'],bs.inputs['Metallic'])
 if name=='Paint':base_nodes[name]=(m,bs,ts['base'])
 if name=='Glass':bs.inputs['Roughness'].default_value=.22
 materials.append(m);print('BAKED',name,flush=True)
 return len(materials)-1
P=bake_material('Paint','green_metal_rust',(1,1,1),0,.77)
T=bake_material('TrackSteel','rusty_metal_04',(.19,.17,.135),.72,.69)
R=bake_material('Rubber','rubberized_track',(.045,.046,.043),0,.85)
S=bake_material('Steel','rusty_metal_04',(.23,.235,.22),.85,.48)
W=bake_material('Wood','dark_wood',(.25,.13,.055),0,.72)
G=bake_material('Glass','rusty_metal_04',(.06,.115,.13),.5,.25)
L=bake_material('Lamps','rusty_metal_04',(.7,.66,.5),.15,.24)
# Accumulated meshes avoid thousands of dependency-graph objects.
cache={};groups={}
class Part:
 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=[];groups[name]=self
 def add(self,v,f,mat,sm=False,uv=None):
  st=len(self.v);self.v.extend(v);self.f.extend([tuple(st+j for j in face) for face in f]);self.mi.extend([mat]*len(f));self.sm.extend([sm]*len(f))
  for face in f:
   if uv:self.uv.append([uv[j] for j in face]);continue
   a,b,c=[Vector(v[i]) for i in face[:3]];nn=(b-a).cross(c-a);axis=max(range(3),key=lambda k:abs(nn[k]));coords=[k for k in range(3) if k!=axis]
   self.uv.append([(v[i][coords[0]]*.75,v[i][coords[1]]*.75) for i in face])
 def build(self):
  me=bpy.data.meshes.new(self.name);me.from_pydata([Vector(v)-self.pivot for v in self.v],[],self.f);me.materials.clear()
  for m in materials:me.materials.append(m)
  me.update();uv=me.uv_layers.new(name='UVMap')
  for poly,mat,sm,coords in zip(me.polygons,self.mi,self.sm,self.uv):
   poly.material_index=mat;poly.use_smooth=sm
   for li,c in zip(poly.loop_indices,coords):uv.data[li].uv=c
  ob=bpy.data.objects.new(self.name,me);scene.collection.objects.link(ob);ob.location=self.pivot
  return ob

def box(g,c,d,mat=P,b=.009,rot=None):
 key=tuple(round(a,5) for a in d)+(round(b,5),)
 if key not in cache:
  bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
  for v in bm.verts:v.co.x*=d[0];v.co.y*=d[1];v.co.z*=d[2]
  if b>0:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(b,min(d)*.23),segments=2,affect='EDGES')
  bm.verts.ensure_lookup_table();bm.verts.index_update();cache[key]=([tuple(v.co) for v in bm.verts],[tuple(v.index for v in f.verts) for f in bm.faces]);bm.free()
 v,f=cache[key];c=Vector(c);g.add([tuple((rot@Vector(p) if rot else Vector(p))+c) for p in v],f,mat)
def cylinder(g,c,r,depth,mat=P,axis=(0,0,1),n=32,r2=None):
 axis=Vector(axis).normalized();a=axis.cross(Vector((0,0,1)))
 if a.length<.1:a=Vector((1,0,0))
 a.normalize();b=axis.cross(a);c=Vector(c);v=[]
 for z,rr in [(-depth/2,r),(depth/2,r if r2 is None else r2)]:
  for k in range(n):v.append(tuple(c+axis*z+rr*(a*cos(2*pi*k/n)+b*sin(2*pi*k/n))))
 f=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)];g.add(v,f,mat,True)
def lathe(g,c,profile,mat=P,axis=(0,0,1),n=48):
 axis=Vector(axis).normalized();a=axis.cross(Vector((0,0,1)))
 if a.length<.1:a=Vector((1,0,0))
 a.normalize();b=axis.cross(a);c=Vector(c);v=[];uv=[]
 distance=0.0;previous=profile[0]
 for z,r in profile:
  distance+=math.hypot(z-previous[0],r-previous[1]);previous=(z,r)
  for i in range(n):v.append(tuple(c+axis*z+r*(a*cos(2*pi*i/n)+b*sin(2*pi*i/n))));uv.append((i/n*2,distance*1.5))
 f=[]
 for j in range(len(profile)-1):
  for i in range(n):f.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
 if abs(axis.x)>.9:uv=[(p[1]*.95,p[2]*.95) for p in v]
 g.add(v,f,mat,True,uv)
def tube(g,pts,r,mat=S,n=8):
 pts=[Vector(p) for p in pts];v=[]
 for j,p in enumerate(pts):
  tangent=(pts[min(j+1,len(pts)-1)]-pts[max(j-1,0)]).normalized();a=tangent.cross(Vector((0,0,1)))
  if a.length<.1:a=Vector((1,0,0))
  a.normalize();b=tangent.cross(a)
  for k in range(n):v.append(tuple(p+r*(a*cos(2*pi*k/n)+b*sin(2*pi*k/n))))
 f=[]
 for j in range(len(pts)-1):
  for k in range(n):f.append((j*n+k,j*n+(k+1)%n,(j+1)*n+(k+1)%n,(j+1)*n+k))
 g.add(v,f,mat,True)
def bolt(g,c,axis=(0,0,1),r=.018,mat=S):cylinder(g,c,r,.013,mat,axis,6)
def between(g,a,b,r,mat=S,n=16):a=Vector(a);b=Vector(b);cylinder(g,(a+b)/2,r,(b-a).length,mat,b-a,n)
def prism(g,outline,z0,z1,mat=P,b=.014):
 v=[(x,y,z) for z in [z0,z1] for x,y in outline];N=len(outline);f=[tuple(reversed(range(N))),tuple(range(N,2*N))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
 bm=bmesh.new();vv=[bm.verts.new(p) for p in v]
 for face in f:bm.faces.new([vv[i] for i in face])
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bmesh.ops.bevel(bm,geom=list(bm.edges),offset=b,segments=3,affect='EDGES');bm.verts.index_update();g.add([tuple(v.co) for v in bm.verts],[tuple(v.index for v in f.verts) for f in bm.faces],mat);bm.free()
H=Part('Hull')
# Hull footprint; bevelled armour plates and genuine panel seams.
prism(H,[(-1.19,-2.78),(1.19,-2.78),(1.2,2.89),(-1.2,2.89)],.47,1.38)
prism(H,[(-1.45,-2.51),(-1.36,-2.79),(1.36,-2.79),(1.45,-2.51),(1.45,2.88),(-1.45,2.88)],1.35,1.88)
box(H,(0,.02,1.885),(2.90,5.72,.065),P,.018)
# Front glacis, lower nose and driver plate.
box(H,(0,-2.87,1.27),(2.62,.60,.09),P,.016,Matrix.Rotation(math.radians(15),3,'X'))
box(H,(0,-2.80,1.62),(2.79,.10,.53),P,.018)
box(H,(0,-2.83,.69),(2.37,.13,.30),P,.018)
# Fenders split into replaceable hinged panels.
for sign in [-1,1]:
 for j in range(6):
  y=-2.54+j*1.005
  box(H,(sign*1.58,y,1.31),(.40,.988,.055),P,.009,Matrix.Rotation(sign*.12,3,'Y'))
  box(H,(sign*1.756,y,1.279),(.035,.982,.078),P,.004)
  for yy in [y-.41,y+.41]:bolt(H,(sign*1.60,yy,1.35),r=.013)
 box(H,(sign*1.57,-2.99,1.26),(.43,.32,.047),P,.008,Matrix.Rotation(.18,3,'X'))
 box(H,(sign*1.55,2.94,1.22),(.43,.38,.055),P,.007,Matrix.Rotation(-.48,3,'X'))
 for y in [-2.3,-1.3,-.3,.7,1.7,2.65]:
  box(H,(sign*1.47,y,1.5),(.035,.055,.25),P,.004);bolt(H,(sign*1.50,y,1.6),(sign,0,0),.012)
# Track belts: capsule path, physically modelled links with U across / V along.
# Top run slopes down toward idler and has a controlled sag between wheels.
def path_track(t):
 # 4 spans: bottom, rear wrap, top, front wrap; continuous direction
 if t<5.12:return (-2.54+t,.067), (1,0)
 t-=5.12
 if t<pi*.49:
  a=-pi/2+t/.49;return (2.54+.49*cos(a),.557+.49*sin(a)),(-sin(a),cos(a))
 t-=pi*.49
 if t<5.12:
  u=t/5.12;return (2.54-t,1.047+.19*u-.07*sin(pi*u)),(-1,(.19-.07*pi*cos(pi*u))/5.12)
 t-=5.12;a=pi/2+t/.585
 return (-2.54+.585*cos(a),.652+.585*sin(a)),(-sin(a),cos(a))
TRACKLEN=10.24+pi*(.49+.585)
for sign,label in [(1,'L'),(-1,'R')]:
 tr=Part('Track_'+label)
 count=100
 for k in range(count):
  (y,z),(dy,dz)=path_track(k/count*TRACKLEN);angle=math.atan2(dz,dy);rot=Matrix.Rotation(angle,3,'X')
  start=len(tr.f);xc=sign*1.407
  box(tr,(xc,y,z),(.71,.133,.058),T,.009,rot)
  # Raised chevron-like cleats, strong moving silhouette.
  for sx in [-1,1]:
   cc=Vector((xc+sx*.166,y,z))+rot@Vector((0,0,-.048))
   box(tr,cc,(.326,.050,.041),T,.006,rot@Matrix.Rotation(sx*.08,3,'Z'))
  # central inner guide horn
  cc=Vector((xc,y,z))+rot@Vector((0,0,.037))
  vv=[(-.035,-.032,0),(.035,-.032,0),(.035,.032,0),(-.035,.032,0),(-.012,-.016,.070),(.012,-.016,.070),(.012,.016,.070),(-.012,.016,.070)]
  tr.add([tuple(cc+rot@Vector(p)) for p in vv],[(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)],S)
  # pins visible along outer edges
  cylinder(tr,(xc+sign*.35,y,z),.024,.023,S,(1,0,0),8)
  for fi in range(start,len(tr.f)):
   tr.uv[fi]=[((tr.v[idx][0]-xc)/.71+.5,k*.18+((tr.v[idx][1]-y)*dy+(tr.v[idx][2]-z)*dz)*1.33) for idx in tr.f[fi]]
 # Roadwheel station origins ordered front to rear, then idler.
 stations=[(-2.54,.652,'sprocket')]+[(-1.92+i*.55,.523,'road') for i in range(8)]+[(2.54,.557,'idler')]
 for wi,(y,z,typ) in enumerate(stations,1):
  x=sign*(1.47 if wi%2==0 else 1.32);g=Part(f'Wheel_{label}{wi}',(x,y,z));ax=(sign,0,0)
  if typ=='road':
   # twin discs where interleaved suspension shows through outer wheels
   for shift in [-.125,.12]:
    c=(x+sign*shift,y,z)
    lathe(g,c,[(-.042,.36),(-.047,.416),(-.027,.438),(.028,.438),(.048,.416),(.044,.36)],R,ax,48)
    lathe(g,c,[(-.04,.08),(-.04,.36),(-.025,.391),(.035,.397),(.048,.366),(.025,.31),(.002,.14),(.045,.105)],P,ax,48)
   lathe(g,(x+sign*.12,y,z),[(.044,.365),(.049,.382),(.045,.393)],S,ax,48)
   cylinder(g,(x+sign*.178,y,z),.105,.073,P,ax,32);cylinder(g,(x+sign*.219,y,z),.055,.012,S,ax,24)
   for a in range(8):bolt(g,(x+sign*.222,y+.079*cos(a*pi/4),z+.079*sin(a*pi/4)),ax,.011)
  else:
   rr=.51 if typ=='sprocket' else .455
   lathe(g,(x,y,z),[(-.16,rr-.08),(-.16,rr),(.16,rr),(.16,rr-.08),(-.16,rr-.08)],P,ax,56)
   cylinder(g,(x,y,z),.14,.35,P,ax,32)
   for a in range(8):
    an=a*2*pi/8
    box(g,(x+sign*.13,y+(rr*.57)*cos(an),z+(rr*.57)*sin(an)),(.09,rr*.68,.084),P,.015,Matrix.Rotation(an,3,'X'))
    bolt(g,(x+sign*.192,y+.10*cos(an),z+.10*sin(an)),ax,.016)
   if typ=='sprocket':
    for a in range(20):
     an=a*2*pi/20;box(g,(x,y+(rr+.018)*cos(an),z+(rr+.018)*sin(an)),(.29,.075,.056),S,.008,Matrix.Rotation(an,3,'X'))
  # Suspension visible behind wheels, fixed to hull.
  between(H,(sign*1.19,y-.13,.79),(sign*1.27,y,z),.073,S,16)
# Driver visor with a real dark slit, welded casing and top cover.
box(H,(-.69,-2.872,1.65),(.65,.06,.27),S,.008)
box(H,(-.69,-2.916,1.72),(.66,.072,.095),P,.011)
box(H,(-.69,-2.908,1.535),(.66,.066,.067),P,.008)
for x in [-1.0,-.38]:box(H,(x,-2.908,1.633),(.070,.068,.23),P,.008)
box(H,(-.69,-2.914,1.637),(.48,.016,.045),G,.002)
# Hull ball machine gun, separate steel barrel.
lathe(H,(.65,-2.89,1.64),[(-.025,.19),(0,.20),(.065,.177),(.10,.11),(.112,.064)],P,(0,-1,0),40)
cylinder(H,(.65,-3.05,1.64),.034,.23,S,(0,1,0),20)
# Lamps, towing eyes and front spare track rack.
for sign,label in [(1,'L'),(-1,'R')]:
 cylinder(H,(sign*1.25,-2.49,1.985),.084,.12,P,(0,1,0),32)
 cylinder(H,(sign*1.25,-2.556,1.985),.065,.008,L,(0,1,0),32)
 box(H,(sign*1.25,-2.569,1.99),(.135,.014,.065),P,.005)
 between(H,(sign*1.25,-2.48,1.9),(sign*1.25,-2.48,1.956),.027,P)
 for y in [-2.99,2.97]:
  box(H,(sign*1.05,y,.90),(.12,.12,.32),P,.014)
  pts=[(sign*1.05+.085*cos(a),y-.085,.81+.12*sin(a)) for a in [2*pi*k/20 for k in range(21)]];tube(H,pts,.025,S)
for j in range(12):
 x=-.91+j*.166
 box(H,(x,-3.099,1.025),(.15,.08,.37),T,.012)
 for z in [.94,1.15]:box(H,(x,-3.145,z),(.126,.026,.042),S,.006)
box(H,(0,-3.10,.823),(2.12,.09,.055),P,.008)
between(H,(-.74,-2.91,1.382),(.55,-2.90,1.385),.018,W)
box(H,(-.76,-2.91,1.384),(.16,.13,.065),S,.012)
for xx in [-.40,.38]:box(H,(xx,-2.905,1.385),(.045,.065,.06),P,.004)
# Deck hatches and ventilation grilles.
for x in [-.68,.68]:
 cylinder(H,(x,-1.79,1.941),.29,.046,P,n=48)
 tube(H,[(x-.10,-1.81,1.98),(x-.10,-1.81,2.018),(x+.1,-1.81,2.018),(x+.1,-1.81,1.98)],.012,S)
 box(H,(x,-2.02,1.965),(.18,.07,.05),P,.006)
for x in [-.81,.81]:
 for y in [1.49,2.35]:
  box(H,(x,y,1.933),(.96,.71,.027),S,.01)
  for j in range(14):box(H,(x-.433+j*.066,y,1.956),(.032,.66,.025),P,.003)
  for sy in [-1,1]:box(H,(x,y+sy*.35,1.966),(.98,.027,.024),P,.004)
box(H,(0,2.03,1.949),(.44,1.46,.05),P,.009)
for yy in [1.44,2.52]:tube(H,[(-.085,yy,1.98),(-.085,yy,2.02),(.085,yy,2.02),(.085,yy,1.98)],.012,S)
# Rear armour, exhaust stacks and protective sheet-metal shields.
box(H,(0,2.944,1.25),(2.6,.095,1.1),P,.012)
for x in [-.54,.54]:
 cylinder(H,(x,3.02,1.34),.116,.89,S,n=32)
 lathe(H,(x,2.965,1.28),[(-.43,.15),(-.40,.19),(.40,.19),(.43,.17)],P,n=40)
 cylinder(H,(x,2.965,1.80),.122,.035,S,n=32)
 for a in range(4):between(H,(x+.10*cos(a*pi/2),2.965+.10*sin(a*pi/2),1.80),(x+.10*cos(a*pi/2),2.965+.10*sin(a*pi/2),1.885),.009,S,8)
 cylinder(H,(x,2.965,1.89),.148,.023,P,n=32)
 for z in [.94,1.62]:box(H,(x,3.12,z),(.41,.04,.055),P,.007)
for x in [-1.11,1.11]:
 cylinder(H,(x,2.79,1.91),.185,.28,P,n=32)
 cylinder(H,(x,2.79,2.056),.189,.022,S,n=32)
 between(H,(x,2.76,1.78),(x*.6,2.7,1.97),.056,R)
box(H,(0,3.033,.72),(.65,.12,.14),P,.016)
# Braided tow cables follow sides and deck, with thimbles and mounting clamps.
for sign in [-1,1]:
 pts=[(sign*1.47,-2.22,1.73),(sign*1.49,-2.36,1.70),(sign*1.49,-2.26,1.65),(sign*1.49,-1.75,1.68),(sign*1.49,-.8,1.71),(sign*1.49,.3,1.70),(sign*1.49,1.4,1.71),(sign*1.48,2.40,1.69),(sign*1.48,2.56,1.76),(sign*1.47,2.4,1.79),(sign*1.47,1.0,1.79),(sign*1.47,-.5,1.78),(sign*1.47,-2.22,1.73)]
 tube(H,pts,.021,S,10)
 # subtle helical strand geometry on long cable run
 for strand in range(3):
  pp=[]
  for k in range(140):
   y=-2.12+k*4.5/139;ang=k*.9+strand*2*pi/3;pp.append((sign*1.49+.014*cos(ang),y,1.706+.014*sin(ang)))
  tube(H,pp,.005,S,4)
 for y in [-1.9,-.9,.2,1.4,2.3]:box(H,(sign*1.51,y,1.75),(.045,.045,.13),P,.004)
 # Shovel: wooden shaft and forged scoop; axe alongside.
 between(H,(sign*1.525,-.92,1.60),(sign*1.525,.92,1.60),.023,W)
 # Pressed spade with rounded shoulders, pointed cutting edge and raised centre rib.
 blade=[(-1.02,1.646),(-1.16,1.697),(-1.29,1.681),(-1.38,1.60),(-1.29,1.519),(-1.16,1.503),(-1.02,1.554)]
 vv=[(sign*(1.535+dx),yy,zz) for dx in [0,.009] for yy,zz in blade];nn=len(blade)
 ff=[tuple(reversed(range(nn))),tuple(range(nn,2*nn))]+[(i,(i+1)%nn,(i+1)%nn+nn,i+nn) for i in range(nn)]
 H.add(vv,ff,S)
 between(H,(sign*1.547,-.88,1.60),(sign*1.551,-1.25,1.60),.022,S)

 between(H,(sign*1.52,.0,1.55),(sign*1.52,1.25,1.48),.018,W)
 blade=[(1.15,1.43),(1.24,1.43),(1.40,1.375),(1.43,1.41),(1.44,1.49),(1.40,1.59),(1.27,1.53),(1.15,1.53)]
 vv=[(sign*(1.53+dx),yy,zz) for dx in [-.019,.019] for yy,zz in blade];nn=len(blade)
 H.add(vv,[tuple(reversed(range(nn))),tuple(range(nn,2*nn))]+[(i,(i+1)%nn,(i+1)%nn+nn,i+nn) for i in range(nn)],S)

 for y in [-.60,.6]:box(H,(sign*1.55,y,1.60),(.04,.065,.095),P,.005)
 # Deck crowbar and cleaning rods.
 between(H,(sign*1.26,.5,1.997),(sign*1.26,2.60,1.997),.019,S)
 for j in range(3):between(H,(sign*(1.10+j*.052),.61,1.99),(sign*(1.10+j*.052),2.28,1.99),.021,W)
# Turret: characteristic horseshoe outline and flat broad front.
TU=Part('Turret',(0,-.23,1.97))
cylinder(TU,(0,-.23,1.976),1.02,.10,S,n=96)
cylinder(TU,(0,-.23,2.014),1.085,.06,P,n=96)
outline=[(-1.04,-1.28),(1.04,-1.28),(1.145,-.88),(1.19,-.30)]
for i in range(1,26):
 a=i*pi/26;outline.append((1.19*cos(a),-.30+1.20*sin(a)))
outline +=[(-1.19,-.30),(-1.145,-.88)]
prism(TU,outline,2.04,2.694,P,.027)
# roof thin visible plate weld seam
prism(TU,[(x*.99,y) for x,y in outline],2.692,2.719,P,.008)
box(TU,(0,1.003,2.39),(1.47,.37,.61),P,.015)
box(TU,(0,1.017,2.704),(1.50,.39,.034),P,.008)
for x in [-.52,.52]:
 box(TU,(x,1.203,2.47),(.052,.024,.26),P,.006)
 bolt(TU,(x,1.22,2.42),(0,1,0),.016)
# Side escape hatch and pistol ports.
for sign in [-1,1]:
 cylinder(TU,(sign*1.193,-.02,2.40),.147,.033,P,(1,0,0),40)
 cylinder(TU,(sign*1.217,-.02,2.40),.118,.024,P,(1,0,0),40)
 box(TU,(sign*1.24,-.07,2.40),(.025,.041,.14),S,.003)
 bolt(TU,(sign*1.245,.055,2.39),(sign,0,0),.020)
 # three smoke launchers on each forward turret corner, hollow rims
 for j in range(3):
  c=(sign*1.08,-1.11+j*.113,2.52+j*.056)
  ax=(sign*.5,-.72,.48)
  lathe(TU,c,[(-.10,.036),(.10,.044),(.105,.032),(-.065,.029)],S,ax,20)
  between(TU,(sign*.99,-.95,2.40),c,.022,P,12)
 # roof lifting eyes
 tube(TU,[(sign*.88,-.60,2.72),(sign*.88,-.60,2.77),(sign*.81,-.60,2.80),(sign*.74,-.60,2.77),(sign*.74,-.60,2.72)],.015,S)
# Cylindrical early commander's cupola, five vision blocks and hinged hatch.
cup=(.51,.30)
lathe(TU,(cup[0],cup[1],2.72),[(0,.365),(.035,.38),(.075,.35),(.215,.35),(.235,.33)],P,n=64)
for j in range(6):
 a=j*2*pi/6;x=cup[0]+.352*cos(a);y=cup[1]+.352*sin(a)
 box(TU,(x,y,2.858),(.14,.012,.037),G,.003,Matrix.Rotation(a-pi/2,3,'Z'))
HC=Part('Hatch_Commander',(.51,.63,2.965),'Turret')
cylinder(HC,(.51,.30,2.969),.34,.035,P,n=64)
for xx in [.41,.51,.61]:cylinder(HC,(xx,.63,2.965),.026,.085,P,(1,0,0),20)
tube(HC,[(.42,.31,2.987),(.42,.31,2.991),(.60,.31,2.991),(.60,.31,2.987)],.009,S)
cylinder(TU,(-.50,-.38,2.739),.247,.029,P,n=48)
tube(TU,[(-.58,-.39,2.756),(-.58,-.39,2.789),(-.42,-.39,2.789),(-.42,-.39,2.756)],.01,S)
for x,y in [(-.51,.52),(.06,-.71)]:cylinder(TU,(x,y,2.743),.083,.044,P,n=32)
# Mantlet and long level 88mm gun. Pivot at actual trunnion axis.
GN=Part('Gun',(0,-1.31,2.36),'Turret')
box(GN,(0,-1.353,2.365),(1.88,.20,.57),P,.032)
box(GN,(0,-1.475,2.365),(1.72,.12,.48),P,.025)
for sign in [-1,1]:
 cylinder(GN,(sign*.81,-1.43,2.37),.094,.056,P,(1,0,0),32)
 for z in [2.19,2.54]:bolt(GN,(sign*.68,-1.548,z),(0,-1,0),.023)
for a in range(8):
 an=a*pi/4;bolt(GN,(.213*cos(an),-1.575,2.36+.213*sin(an)),(0,-1,0),.013)
# coax hole and sight aperture
for x,z in [(.51,2.41),(-.5,2.44)]:
 cylinder(GN,(x,-1.547,z),.036,.015,S,(0,1,0),24)
 cylinder(GN,(x,-1.558,z),.018,.014,R,(0,1,0),24)
lathe(GN,(0,-1.52,2.36),[(0,.245),(.055,.25),(.105,.217),(.21,.185),(.48,.178),(.52,.15),(1.19,.138),(1.26,.12),(2.91,.092),(3.22,.086),(3.25,.109)],P,(0,-1,0),64)
for t,r in [(.10,.231),(.49,.177),(1.21,.141)]:lathe(GN,(0,-1.52-t,2.36),[(-.016,r),(.014,r),(.017,r-.01)],S,(0,-1,0),48)
# Double-baffle muzzle brake: open side ports and blackened bore.
for yy in [-4.84,-5.04,-5.265]:
 lathe(GN,(0,yy,2.36),[(-.030,.061),(-.030,.141),(-.013,.157),(.025,.151),(.03,.061),(-.030,.061)],S,(0,-1,0),48)
# Upper/lower connecting shell sections leave the two side windows physically open.
for side in [1,-1]:
 v=[]
 for rr in [.146,.139]:
  for yy in [-4.82,-5.295]:
   for i in range(17):
    a=(pi*.10+i*pi*.80/16)+(0 if side==1 else pi);v.append((rr*cos(a),yy,2.36+rr*sin(a)))
 f=[]
 for j in range(16):f.extend([(j,j+1,j+18,j+17),(j+34,j+51,j+52,j+35)])
 for j in [0,16]:f.append((j,j+17,j+51,j+34))
 for k in [0,17]:
  for j in range(16):f.append((k+j,k+j+34,k+j+35,k+j+1))
 GN.add(v,f,S,True)
lathe(GN,(0,-5.265,2.36),[(0,.061),(-.30,.061)],R,(0,-1,0),32)
# Roof MG34 on a low anti-aircraft pintle, independently aimable.
MG=Part('MG',(.87,.27,2.88),'Turret')
between(TU,(.87,.27,2.72),(.87,.27,2.88),.022,S)
box(MG,(.87,.19,2.884),(.075,.31,.092),S,.012)
cylinder(MG,(.87,-.22,2.884),.035,.53,S,(0,1,0),24)
for j in range(9):
 lathe(MG,(.87,-.44+j*.05,2.884),[(-.008,.036),(.008,.036)],R,(0,1,0),20)
box(MG,(.87,.39,2.858),(.075,.20,.067),W,.012)
box(MG,(.87,.24,2.806),(.042,.06,.10),S,.005,Matrix.Rotation(-.27,3,'X'))
# Fasteners and welded seams, restrained to structural zones.
for sign in [-1,1]:
 for y in [-2.58,-1.8,-.9,0,.9,1.8,2.72]:bolt(H,(sign*1.39,y,1.928),r=.014)
 for y in [-2.3,-1.2,0,1.2,2.55]:bolt(H,(sign*1.461,y,1.46),(sign,0,0),.016)
 tube(H,[(sign*1.405,-2.58,1.896),(sign*1.414,-1.1,1.897),(sign*1.414,.7,1.897),(sign*1.414,2.8,1.897)],.009,P,6)
# Local chipped paint at exposed edges, brackets and wheel lips.
for sign in [-1,1]:
 for j in range(125):
  yy=random.uniform(-2.62,2.70);zz=random.choice([1.382,1.855])+random.uniform(-.013,.013)
  ll=random.uniform(.012,.062);hh=random.uniform(.002,.009)
  vv=[(sign*1.452,yy-ll/2,zz),(sign*1.453,yy+ll*.34,zz-hh),(sign*1.453,yy+ll/2,zz+hh*.2),(sign*1.452,yy-ll*.23,zz+hh)]
  H.add(vv,[(0,1,2,3) if sign==1 else (3,2,1,0)],S)
 for j in range(100):
  yy=random.uniform(-2.91,2.81);xx=sign*random.uniform(1.49,1.73);zz=1.337+(abs(xx)-1.58)*(-.12)
  ll=random.uniform(.007,.038);ww=random.uniform(.002,.009)
  H.add([(xx-ww,yy-ll,zz),(xx+ww,yy-ll*.4,zz+.001),(xx+ww*.4,yy+ll,zz),(xx-ww*.4,yy+ll*.6,zz)],[(0,1,2,3)],S)
 for j in range(80):
  yy=random.uniform(-1.18,.6);zz=random.choice([2.065,2.683])+random.uniform(-.014,.014)
  # side scratch only in front straight portion; arc chips follow actual horseshoe
  if yy<=-.30:xx=sign*(1.145+(.88+yy)/.58*.045)
  else:xx=sign*1.19*math.sqrt(max(0,1-((yy+.30)/1.20)**2))
  ll=random.uniform(.01,.045);hh=random.uniform(.002,.006)
  TU.add([(xx+sign*.002,yy-ll,zz),(xx+sign*.002,yy+ll,zz-hh),(xx+sign*.002,yy+ll*.5,zz+hh)],[(0,1,2) if sign==1 else (2,1,0)],S)
# Exportable objects and sockets.
# Exact game envelope: width 3.56 m, ground contact 0, height 3.00 m.
allv=[v for g in groups.values() for v in g.v]
xmax=max(abs(v[0]) for v in allv);zmin=min(v[2] for v in allv);zmax=max(v[2] for v in allv)
sx=1.78/xmax;sz=3.0/(zmax-zmin)
def envelope(v):return (v[0]*sx,v[1],(v[2]-zmin)*sz)
for g in groups.values():
 g.v=[envelope(v) for v in g.v];g.pivot=Vector(envelope(g.pivot))
 if g.name in ['Hull','Track_L','Track_R']:g.pivot=Vector((0,0,0))
objects={k:g.build() for k,g in groups.items()}
bpy.context.view_layer.update()
for k,g in groups.items():
 if g.parent:
  ob=objects[k];world=ob.matrix_world.copy();ob.parent=objects[g.parent];ob.matrix_world=world
sockets={'Muzzle':((0,-5.295,2.36),'Gun'),'Seat_Commander':((.51,.30,3.0),'Turret'),'Seat_Driver':((-.69,-1.80,1.92),'Hull'),'Exhaust':((0,2.965,1.89),'Hull'),'Light_Head_L':((1.25,-2.56,1.985),'Hull'),'Light_Head_R':((-1.25,-2.56,1.985),'Hull')}
for name,(pos,parent) in sockets.items():
 ob=bpy.data.objects.new(name,None);scene.collection.objects.link(ob);ob.location=envelope(pos);bpy.context.view_layer.update();world=ob.matrix_world.copy();ob.parent=objects[parent];ob.matrix_world=world;objects[name]=ob
bpy.context.view_layer.update()
# Triangulate explicitly, preserve clean spin axes, all scales identity.
for ob in objects.values():
 if ob.type=='MESH':
  mod=ob.modifiers.new('Triangles','TRIANGULATE');bpy.context.view_layer.objects.active=ob;bpy.ops.object.modifier_apply(modifier=mod.name)
tri=sum(len(o.data.polygons) for o in objects.values() if o.type=='MESH')
print('TRIANGLES',tri,flush=True)
paints=[{'name':'Dark yellow (default)','linear_rgb':[.47,.42,.28]},{'name':'Olive drab','linear_rgb':[.18,.205,.105]},{'name':'NATO green','linear_rgb':[.12,.18,.105]},{'name':'Desert tan','linear_rgb':[.59,.47,.30]},{'name':'Field grey','linear_rgb':[.24,.29,.265]},{'name':'Winter white','linear_rgb':[.79,.80,.76]}]
def cv(v):return [round(v.x,5),round(v.z,5),round(-v.y,5)]
verts=[o.matrix_world@v.co for o in objects.values() if o.type=='MESH' for v in o.data.vertices]
converted=[cv(v) for v in verts];bounds={'min':[min(v[k] for v in converted) for k in range(3)],'max':[max(v[k] for v in converted) for k in range(3)]}
manifest={'asset':'Tiger I style early heavy tank','units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'triangles':tri,'bounding_box':bounds,'paints':paints,'nodes':[{'name':n,'world_position':cv(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type} for n,o in objects.items()],'track_uv':'U across each belt, V along link progression; repeating tile','wheel_order':'sprocket, eight interleaved roadwheel stations, idler; front to rear','bake':'Cycles OptiX; 1024 base and tangent normal; 512 packed ORM; reusable tiling sets'}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
bpy.ops.object.select_all(action='DESELECT')
for o in objects.values():o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'tiger-heavy-tank.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_image_format='JPEG',export_jpeg_quality=86,export_cameras=False,export_lights=False)
# glTF material tint is intentionally independent from neutral Paint base texture.
fn=ROOT/'tiger-heavy-tank.glb';raw=fn.read_bytes();jlen=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jlen]);binary=raw[20+jlen:]
# MikkTSpace can emit a zero tangent at a collapsed UV corner; provide an
# orthogonal unit tangent there so all exported attributes satisfy glTF.
binary=bytearray(binary)
for mesh in doc['meshes']:
 for prim in mesh['primitives']:
  attrs=prim['attributes']
  if 'TANGENT' not in attrs:continue
  ta=doc['accessors'][attrs['TANGENT']];na=doc['accessors'][attrs['NORMAL']]
  tv=doc['bufferViews'][ta['bufferView']];nv=doc['bufferViews'][na['bufferView']]
  for i in range(ta['count']):
   to=8+tv.get('byteOffset',0)+ta.get('byteOffset',0)+i*tv.get('byteStride',16)
   vals=struct.unpack_from('<4f',binary,to)
   if sum(v*v for v in vals[:3])<1e-12:
    no=8+nv.get('byteOffset',0)+na.get('byteOffset',0)+i*nv.get('byteStride',12)
    normal=Vector(struct.unpack_from('<3f',binary,no));axis=Vector((1,0,0)) if abs(normal.x)<.9 else Vector((0,1,0));tangent=normal.cross(axis).normalized()
    struct.pack_into('<4f',binary,to,*tangent,1.0 if vals[3]>=0 else -1.0)
binary=bytes(binary)
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.47,.42,.28,1]
 # ORM red channel = 1; explicit occlusion binding for consumers.
 pbr=m.get('pbrMetallicRoughness',{})
 if 'metallicRoughnessTexture' in pbr:m['occlusionTexture']=dict(pbr['metallicRoughnessTexture'])
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);fn.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+binary)
print('GLB_READY',fn.stat().st_size,flush=True)
# Verify exported geometry/materials by using a fresh GLB import for every delivered render.
for ob in list(bpy.data.objects):bpy.data.objects.remove(ob,do_unlink=True)
for mat in list(bpy.data.materials):bpy.data.materials.remove(mat)
bpy.ops.import_scene.gltf(filepath=str(fn))
m=bpy.data.materials.get('Paint');bs=next(n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED');base=next(n for n in m.node_tree.nodes if n.type=='TEX_IMAGE' and 'Paint_base' in n.image.name)
mix=m.node_tree.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.47,.42,.28,1);m.node_tree.links.new(base.outputs[0],mix.inputs[1]);m.node_tree.links.new(mix.outputs[0],bs.inputs['Base Color'])
# Presentation ground and soft daylight, excluded from glTF.
ground=bpy.data.materials.new('Presentation ground');ground.diffuse_color=(.19,.205,.21,1);ground.use_nodes=True;ground.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.9
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.035));bpy.context.object.name='Studio ground';bpy.context.object.data.materials.append(ground)
world=bpy.data.worlds.new('Soft daylight');scene.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.64,.72,.83,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
for name,pos,power,size in [('Key', (4,-6,9),1700,6),('Fill',(-5,-2,5),450,6),('Rim',(1,5,7),1400,5)]:
 data=bpy.data.lights.new(name,'AREA');data.energy=power;data.color=(1,.94,.82) if name=='Key' else ((.82,.89,1) if name=='Fill' else (1,.97,.9));data.shape='DISK';data.size=size;ob=bpy.data.objects.new(name,data);scene.collection.objects.link(ob);ob.location=pos;ob.rotation_euler=(Vector((0,-.6,1.1))-ob.location).to_track_quat('-Z','Y').to_euler()
camd=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camd);scene.collection.objects.link(cam);scene.camera=cam
cam.location=(10,-13,7.0);target=Vector((0,-.8,1.40));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.type='ORTHO';camd.ortho_scale=11.7
scene.render.resolution_x=1920;scene.render.resolution_y=1080;scene.render.resolution_percentage=100
scene.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
# Save scene only on remote for low-cost inspection/recovery.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'tiger_work.blend'))
clay=bpy.data.materials.new('Clay preview');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.37,.37,.37,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
scene.view_layers[0].material_override=clay;scene.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True);scene.view_layers[0].material_override=None
cam.location=(11,-.8,1.50);cam.rotation_euler=(Vector((0,-.8,1.50))-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=10.1
scene.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
cam.location=(10,-13,7);cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=11.7
scene.cycles.samples=12;scene.render.resolution_x=640;scene.render.resolution_y=360
# Render six tints; assemble contact sheet with Blender image buffers (no dependencies).
import numpy as np
sheet=np.ones((720,1920,4),dtype=np.float32)
for k,p in enumerate(paints):
 mix.inputs[2].default_value=tuple(p['linear_rgb'])+(1,);scene.render.filepath=str(ROOT/f'paint_{k}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(str(ROOT/f'paint_{k}.png'),check_existing=False);a=np.asarray(im.pixels[:],dtype=np.float32).reshape((360,640,4));row=1-k//3;col=k%3;sheet[row*360:(row+1)*360,col*640:(col+1)*640]=a;bpy.data.images.remove(im)
out=bpy.data.images.new('Paint variants',1920,720,alpha=True);out.pixels.foreach_set(sheet.ravel());out.filepath_raw=str(ROOT/'paints.png');out.file_format='PNG';out.save()
for k in range(6):(ROOT/f'paint_{k}.png').unlink(missing_ok=True)
notes=["Tiger I style early heavy tank; articulated game mesh, gun forward +Z, metres; clean scripted Cycles bake/export.",f"Triangles: {tri:,}; GLB: {fn.stat().st_size/1048576:.2f} MiB.","Materials (7): Paint, TrackSteel, Rubber, Steel, Wood, Glass, Lamps; Paint neutral texture × linear tint.","Shared tiled Cycles bakes: base colour / tangent normal 1024²; packed ORM 512²; JPEG, all embedded.","Poly Haven CC0 ids: green_metal_rust, rusty_metal_04, rubberized_track, dark_wood; https://polyhaven.com/license ; source URLs in script.","Differences: no insignia; added low MG34 roof mount; simplified concealed running gear and interior; weathering uses shared tiles rather than unique damage."]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
print('COMPLETE',json.dumps(bounds),flush=True)
