#!/usr/bin/env python3
"""Reproducible Renault FT hero + tank-damage/v1. Blender 5.2, metres, Z-up/-Y front.
Sources: Poly Haven CC0 photographic metal/wood/rubber scans; authored geometry and wear.
Run blender -b --python-exit-code 1 --python build_renault-ft-tank.py [-- --preflight]
"""
import bpy, math, os, sys, json, random, urllib.request, struct, time
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
P=Path(__file__).resolve().parent
random.seed(1907)
T0=time.time()
PAINTS=[('olive drab',[.16,.19,.11]),('NATO green',[.085,.13,.072]),('desert tan',[.52,.39,.23]),('field grey',[.22,.255,.225]),('winter white',[.72,.75,.72]),('khaki brown',[.23,.18,.105])]
PH={'Paint':'green_metal_rust','TrackSteel':'metal_plate','Steel':'rusty_metal_02','Wood':'wood_planks','Rubber':'rubberized_track','Burnt':'rust_coarse_01','Scars':'metal_plate'}
M={}; HEALTH=[]; DAMAGE=[]; PARTS={}; PIVOTS={}; GEO_A=None
def clean():
 bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
 for d in list(bpy.data.materials): bpy.data.materials.remove(d)
def setup():
 sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=24
 pref=bpy.context.preferences.addons['cycles'].preferences
 try: pref.compute_device_type='OPTIX'; pref.get_devices()
 except: pref.compute_device_type='CUDA'; pref.get_devices()
 for d in pref.devices: d.use=d.type!='CPU'
 sc.cycles.device='GPU'; sc.cycles.use_denoising=True; sc.cycles.max_bounces=5
 sc.render.image_settings.file_format='PNG'; sc.render.film_transparent=False
 sc.view_settings.view_transform='AgX'; sc.view_settings.look='AgX - Medium High Contrast'
 sc.world.color=(.22,.22,.22)
 print('GPU_DEVICES',[(d.name,d.type,d.use) for d in pref.devices],flush=True)
def material(name,col,metal=0,rough=.75):
 m=bpy.data.materials.new(name); m.use_nodes=True; bs=m.node_tree.nodes.get('Principled BSDF'); bs.inputs['Base Color'].default_value=(*col,1); bs.inputs['Metallic'].default_value=metal; bs.inputs['Roughness'].default_value=rough; M[name]=m; return m
def materials():
 for n,c,me,r in [('Paint',PAINTS[0][1],.12,.77),('TrackSteel',(.32,.29,.24),.75,.56),('Steel',(.24,.22,.18),.72,.60),('Wood',(.16,.078,.035),0,.83),('Rubber',(.018,.019,.017),0,.9),('Interior',(.012,.013,.012),0,.95),('Lamps',(.65,.52,.27),.1,.3),('Burnt',(.072,.063,.051),.35,.91),('Scars',(.24,.235,.22),.75,.63)]: material(n,c,me,r)
class Mesh:
 def __init__(self): self.v=[];self.f=[];self.mi=[];self.uv=[];self.round_keys=set()
 def face(self,vs,mat='Paint',uv=None):
  j=len(self.v);self.v.extend([tuple(p) for p in vs]);self.f.append(tuple(range(j,j+len(vs)))); self.mi.append(mat)
  if uv is None:
   a,b,c=map(Vector,vs[:3]); norm=(b-a).cross(c-a); ax=max(range(3),key=lambda i:abs(norm[i])); ix=[i for i in range(3) if i!=ax]
   uv=[(p[ix[0]]*.85,p[ix[1]]*.85) for p in vs]
  self.uv.append(uv)
 def box(self,c,s,mat='Paint',rot=None):
  vs=[Vector((x*s[0]/2,y*s[1]/2,z*s[2]/2)) for x,y,z in [(-1,-1,-1),(1,-1,-1),(1,1,-1),(-1,1,-1),(-1,-1,1),(1,-1,1),(1,1,1),(-1,1,1)]]
  vs=[Vector(c)+(rot@v if rot else v) for v in vs]
  for inds in [(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)]:self.face([vs[i] for i in inds],mat)
 def cyl(self,a,b,r,mat='Paint',n=24,r2=None,cap=True):
  a,b=Vector(a),Vector(b);d=(b-a).normalized();u=d.cross(Vector((0,0,1)))
  if u.length<.1:u=d.cross(Vector((0,1,0)))
  u.normalize();v=d.cross(u);r2=r if r2 is None else r2
  A=[a+r*(math.cos(i*math.tau/n)*u+math.sin(i*math.tau/n)*v) for i in range(n)];B=[b+r2*(math.cos(i*math.tau/n)*u+math.sin(i*math.tau/n)*v) for i in range(n)]
  for i in range(n):j=(i+1)%n;self.face([A[i],A[j],B[j],B[i]],mat,[(i/n,0),(j/n,0),(j/n,(b-a).length*3),(i/n,(b-a).length*3)])
  if cap:self.face(A[::-1],mat);self.face(B,mat)
 def sphere(self,c,r,mat='Paint',n=8,k=4,scale=(1,1,1)):
  sphere_start=len(self.v);c=Vector(c)
  rings=[[c+Vector((math.sin(math.pi*j/k)*math.cos(math.tau*i/n)*r*scale[0],math.sin(math.pi*j/k)*math.sin(math.tau*i/n)*r*scale[1],math.cos(math.pi*j/k)*r*scale[2])) for i in range(n)] for j in range(1,k)]
  top=c+Vector((0,0,r*scale[2]));bottom=c-Vector((0,0,r*scale[2]))
  for i in range(n):q=(i+1)%n;self.face([top,rings[0][i],rings[0][q]],mat);self.face([bottom,rings[-1][q],rings[-1][i]],mat)
  for A,B in zip(rings,rings[1:]):
   for i in range(n):q=(i+1)%n;self.face([A[i],B[i],B[q],A[q]],mat)
  self.round_keys.update(tuple(round(float(x),6) for x in v) for v in self.v[sphere_start:])
 def beam(self,a,b,width,depth=None,mat='Paint'):
  a,b=Vector(a),Vector(b);d=b-a;rot=d.to_track_quat('Z','Y').to_matrix();self.box((a+b)/2,(width,depth or width,d.length),mat,rot)
 def ring(self,c,axis,r,minor,mat='Steel',n=48,k=6):
  c=Vector(c);d=Vector(axis).normalized();u=d.cross(Vector((0,0,1)))
  if u.length<.1:u=d.cross(Vector((0,1,0)))
  u.normalize();v=d.cross(u)
  for i in range(n):
   vs=[]
   for j in range(k):
    quad=[]
    for ii,jj in [(i,j),(i+1,j),(i+1,j+1),(i,j+1)]:
     radial=math.cos(ii*math.tau/n)*u+math.sin(ii*math.tau/n)*v
     quad.append(c+radial*(r+minor*math.cos(jj*math.tau/k))+d*minor*math.sin(jj*math.tau/k))
    self.face(quad,mat)
 def build(self,name,pivot=(0,0,0),parent=None,damage=False,bevel=0):
  if damage and GEO_A is not None and name!='Gun_Broken':
   self.v=[tuple(GEO_A@Vector(v)) for v in self.v];pivot=GEO_A@Vector(pivot)
  me=bpy.data.meshes.new(name+'Mesh');me.from_pydata([Vector(v)-Vector(pivot) for v in self.v],[],self.f);me.update()
  names=list(dict.fromkeys(self.mi))
  for m in names: me.materials.append(M[m])
  for p,mi in zip(me.polygons,self.mi):p.material_index=names.index(mi)
  uv=me.uv_layers.new(name='UVMap')
  for poly,coords in zip(me.polygons,self.uv):
   for li,co in zip(poly.loop_indices,coords):uv.data[li].uv=co
  import bmesh
  bm=bmesh.new();bm.from_mesh(me);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.000001);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(me);bm.free()
  for p in me.polygons:p.use_smooth=True
  me.set_sharp_from_angle(angle=math.radians(35))
  sharp=me.attributes.get('sharp_edge')
  round_ids={v.index for v in me.vertices if tuple(round(float(x),6) for x in v.co+Vector(pivot)) in self.round_keys}
  if sharp:
   for e in me.edges:
    if all(i in round_ids for i in e.vertices):sharp.data[e.index].value=False
  o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);o.location=pivot
  if parent: setparent(o,parent)
  if bevel:
   bpy.context.view_layer.objects.active=o;o.select_set(True);mod=o.modifiers.new('Fine rolled edges','BEVEL');mod.width=bevel;mod.segments=2;mod.affect='EDGES';mod.limit_method='ANGLE';mod.angle_limit=math.radians(70)
   # Weld face-soup first so bevel sees real shared edges.
   import bmesh
   bm=bmesh.new();bm.from_mesh(me);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.00001);bm.normal_update();bm.to_mesh(me);bm.free()
   bpy.ops.object.modifier_apply(modifier=mod.name);o.select_set(False)
  (DAMAGE if damage else HEALTH).append(o);PIVOTS[name]=list(pivot)
  if name.startswith('Part_'):PARTS[name]=o
  return o
def setparent(o,p):
 bpy.context.view_layer.update();w=o.matrix_world.copy();o.parent=p;o.matrix_world=w
 # Clear inverse; preserve transform in parent-local coordinates.
 o.matrix_parent_inverse=Matrix.Identity(4);o.matrix_basis=p.matrix_world.inverted()@w

def empty(name,p,parent=None,damage=False):
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=p
 if parent:setparent(o,parent)
 (DAMAGE if damage else HEALTH).append(o);return o
def rivets(m,a,b,n=8,r=.014,mat='Paint'):
 a,b=Vector(a),Vector(b)
 for i in range(n):m.sphere(a.lerp(b,i/max(1,n-1)),r,mat)
def polyprism(m,coords,lo,hi,mat='Paint',axis=0):
 def pt(c,t):return (t,c[0],c[1]) if axis==0 else (c[0],c[1],t)
 A=[pt(c,lo) for c in coords];B=[pt(c,hi) for c in coords]
 m.face(A[::-1],mat);m.face(B,mat)
 for i in range(len(A)):j=(i+1)%len(A);m.face([A[i],A[j],B[j],B[i]],mat)
def lathe(m,c,rings,mat='Paint',n=48):
 cx,cy,cz=c
 for (r,z),(rr,zz) in zip(rings,rings[1:]):
  for i in range(n):
   j=i+1;vs=[(cx+r*math.cos(i*math.tau/n),cy+r*math.sin(i*math.tau/n),cz+z),(cx+r*math.cos(j*math.tau/n),cy+r*math.sin(j*math.tau/n),cz+z),(cx+rr*math.cos(j*math.tau/n),cy+rr*math.sin(j*math.tau/n),cz+zz),(cx+rr*math.cos(i*math.tau/n),cy+rr*math.sin(i*math.tau/n),cz+zz)];m.face(vs[:3] if rr==0 else vs,mat)
def hull_geo():
 m=Mesh()
 # Narrow continuous polygon hull, with pointed lower nose and rear shoulders.
 sec=[(-1.94,.31,.30,.50),(-1.62,.44,.24,.76),(-.95,.53,.25,1.00),(-.67,.56,.27,1.41),(.42,.56,.27,1.41),(1.02,.54,.27,1.34),(1.77,.45,.32,1.02)]
 rings=[]
 for y,w,bot,top in sec:rings.append([(-w,y,bot),(w,y,bot),(w,y,top),(-w,y,top)])
 for k,(A,B) in enumerate(zip(rings,rings[1:])):
  for i in range(4):
   j=(i+1)%4
   if k==2 and i==2:
    # Solid armor strips beside a real driver hatch aperture.
    m.face([A[2],B[2],(.22,B[2][1],B[2][2]),(.22,A[2][1],A[2][2])]);m.face([(-.22,A[3][1],A[3][2]),(-.22,B[3][1],B[3][2]),B[3],A[3]])
   else:m.face([A[i],B[i],B[j],A[j]])
 m.face(rings[0][::-1]);m.face(rings[-1])
 # horizontal bulkheads and internal dark wells, separate from hatch leaves.
 m.box((0,-.39,1.383),(.95,.78,.035));m.box((0,.77,1.30),(.91,.59,.09))
 for x in [-.558,.558]:
  for y in [-.64,.38,1.03]:rivets(m,(x,y,.40),(x,y,1.35 if y<.5 else 1.2),9)
  rivets(m,(x,-.58,1.36),(x,.34,1.36),11);rivets(m,(x,-.52,.47),(x,1.13,.47),18)
  for y in [-.75,-.1,.65,1.35]:
   m.box((x,y,.66),(.055,.075,.20),'Steel');m.cyl((x-.035,y,.73),(x+.035,y,.73),.034,'Steel',12)
 # front sloping armor rivets follow actual skin
 for x in [-.26,.26]:
  for y in np.linspace(-1.90,-.99,10):
   z=.50+(y+1.94)*.8125 if y<-1.62 else .76+(y+1.62)*.24/.67
   m.sphere((x,y,z+.003),.010)
 for x in [-.46,.46]:rivets(m,(x,-.935,1.02),(x,-.68,1.41),6)
 rivets(m,(-.42,-1.03,.965),(.42,-1.03,.965),10)
 # Flush bolted centre glacis panel and fine exposed seam edges.
 for x in [-.315,.315]:
  for y in np.linspace(-1.57,-1.01,9):m.sphere((x,y,.76+(y+1.62)/.67*.24+.010),.013,'Paint',8,4)
 for y in [-1.56,-1.015]:rivets(m,(-.315,y,.76+(y+1.62)/.67*.24+.01),(.315,y,.76+(y+1.62)/.67*.24+.01),9,.013)
 for x in [-.339,.339]:m.beam((x,-1.585,.782),(x,-.990,.995),.003,.004,'Steel')
 # front towing eyes
 for x in [-.29,.29]:m.ring((x,-1.78,.31),(1,0,0),.058,.015,'Steel',16,6)
 # mounting seams, bosses visible when stripped
 for s in [-1,1]:
  for y in [-1.15,-.55,.20,.9,1.5]:m.box((s*.62,y,.55),(.14,.105,.095),'Steel');m.sphere((s*.695,y,.55),.025,'Steel')
  for y in [-.95,.8]:m.cyl((s*.57,y,.91),(s*.66,y,.91),.036,'Steel',12)
 h=m.build('Hull',bevel=.003)
 # crew space is real dark inset with walls, bottom and lip; remains under hatches
 q=Mesh();q.box((0,-.48,1.24),(.50,.45,.035),'Interior');q.box((0,-.26,1.31),(.5,.025,.16),'Interior');q.box((-.24,-.49,1.31),(.025,.45,.16),'Interior');q.box((.24,-.49,1.31),(.025,.45,.16),'Interior')
 q.build('Crew_Interior',parent=h)
 # Driver panel on slope, hinge across horizontal X line
 q=Mesh();angle=math.radians(-34.33);rot=Matrix.Rotation(angle,3,'X');c=(0,-.815,1.211);q.box(c,(.56,.045,.515),'Paint',rot)
 q.box((0,-.883,1.245),(.23,.035,.025),'Interior');q.box((0,-.902,1.272),(.25,.025,.016),'Steel')
 for x in [-.27,.27]:rivets(q,(x,-.92,1.12),(x,-.75,1.36),5)
 q.cyl((-.31,-.67,1.416),(.31,-.67,1.416),.021,'Steel',16)
 q.build('Hatch_Driver',(0,-.67,1.416),h)
 return h

def turret_geo(h):
 center=(0,-.40,1.42);m=Mesh()
 m.cyl((0,-.40,1.40),(0,-.40,1.47),.52,'Steel',64)
 # Eight flat riveted armor plates, taper toward top.
 n=8;bot=[];top=[]
 for i in range(n):a=math.tau*i/n+math.pi/8;bot.append(Vector((.575*math.cos(a),-.40+.575*math.sin(a),1.46)));top.append(Vector((.45*math.cos(a),-.40+.45*math.sin(a),1.93)))
 m.face(top);m.face(bot[::-1])
 for i in range(n):j=(i+1)%n;m.face([bot[i],bot[j],top[j],top[i]]);rivets(m,bot[i]*.92+bot[j]*.08+Vector((0,0,.04)),top[i]*.92+top[j]*.08-Vector((0,0,.035)),6)
 for i in range(n):j=(i+1)%n;rivets(m,top[i].lerp(top[j],.16)-Vector((0,0,.025)),top[i].lerp(top[j],.84)-Vector((0,0,.025)),3)
 for i in range(n):
  j=(i+1)%n;normal=Vector(((bot[i].x+bot[j].x)/2,(bot[i].y+bot[j].y)/2+.4,.1)).normalized()
  rivets(m,bot[i].lerp(bot[j],.13)+Vector((0,0,.025))+normal*.004,bot[i].lerp(bot[j],.87)+Vector((0,0,.025))+normal*.004,5,.010)
 t=m.build('Turret',center,bevel=.0015)
 # Mantlet/trunnion assembly forward
 q=Mesh();q.box((0,-.925,1.68),(.285,.10,.29),'Steel');q.box((0,-.994,1.68),(.20,.08,.22),'Steel')
 q.cyl((-.14,-1.01,1.68),(.14,-1.01,1.68),.098,'Steel',32)
 for x in [-.105,.105]:
  for z in [1.575,1.785]:q.sphere((x,-.990,z),.016,'Steel')
 g=q.build('Gun',(0,-1.01,1.68),t,bevel=.013)
 b=Mesh();b.cyl((0,-1.06,1.70),(0,-1.47,1.70),.042,'Steel',40,r2=.036);b.cyl((0,-1.43,1.70),(0,-1.78,1.70),.036,'TrackSteel',40,r2=.032,cap=False)
 b.ring((0,-1.78,1.70),(0,1,0),.027,.006,'Steel',40,6);b.cyl((0,-1.73,1.70),(0,-1.779,1.70),.024,'Interior',32)
 b.cyl((0,-1.10,1.70),(0,-1.19,1.70),.049,'Steel',24);b.ring((0,-1.435,1.70),(0,1,0),.038,.004,'Steel',24,5)
 # recoil recuperator below the gun
 b.cyl((0,-1.06,1.61),(0,-1.44,1.61),.031,'Steel',24)
 ba=b.build('Barrel',(0,-1.06,1.70),g);empty('Muzzle',(0,-1.786,1.70),ba)
 # Commander hatch opens at rear tangent, dark cavity + raised collar.
 q=Mesh();q.cyl((0,-.40,1.60),(0,-.40,1.62),.205,'Interior',48);q.ring((0,-.40,1.965),(0,0,1),.226,.016,'Steel',48,6);q.build('Commander_Well',parent=t)
 q=Mesh();q.cyl((0,-.40,1.97),(0,-.40,2.004),.232,'Paint',48);q.cyl((-.11,-.18,1.986),(.11,-.18,1.986),.016,'Steel',16);hc=q.build('Hatch_Commander',(0,-.18,1.986),t)
 q=Mesh();q.cyl((0,-.40,2.0),(0,-.40,2.052),.17,'Interior',48)
 for i in range(8):a=i*math.tau/8;q.box((.165*math.cos(a),-.40+.165*math.sin(a),2.027),(.035,.035,.046),'Paint')
 q.build('Part_Turret_CupolaRing',(0,-.4,2.04),hc)
 q=Mesh();lathe(q,(0,-.40,0),[(.233,2.044),(.237,2.065),(.228,2.088),(.192,2.117),(.13,2.137),(.062,2.146),(0,2.149)],'Paint',48);q.build('Part_Turret_CupolaCap',(0,-.4,2.10),hc)
 q=Mesh();q.box((0,.13,1.68),(.31,.044,.29));rivets(q,(-.135,.157,1.57),(-.135,.157,1.79),5);rivets(q,(.135,.157,1.57),(.135,.157,1.79),5);q.beam((-.045,.175,1.73),(.045,.175,1.73),.018,mat='Steel');q.build('Part_Turret_RearDoor',(0,.14,1.68),t,bevel=.003)
 q=Mesh();q.box((-.31,-.68,1.941),(.14,.13,.028));q.cyl((-.31,-.68,1.95),(-.31,-.68,1.99),.031,'Steel',16);q.build('Part_Turret_Sight',(-.31,-.68,1.96),t)
 return t,g,ba

def track_path():
 # Clockwise rounded profile in (rearward Y, height Z), front idler high, rear low.
 pts=[(-1.48+.51*math.cos(a),.60+.51*math.sin(a)) for a in np.linspace(math.pi/2,3*math.pi/2,25)]
 pts += [(y,.075) for y in np.linspace(-1.35,1.13,20)]
 pts += [(1.24+.35*math.cos(a),.43+.35*math.sin(a)) for a in np.linspace(-math.pi/2,math.pi/2,19)]
 pts += [(y, .80+(-y+1.24)*.117) for y in np.linspace(1.24,-1.48,24)]
 return pts
PATH=track_path()
def resample_path(pts,count):
 arr=[Vector(p) for p in pts];ds=[(arr[(i+1)%len(arr)]-p).length for i,p in enumerate(arr)];total=sum(ds);out=[]
 for k in range(count):
  s=k*total/count;i=0
  while s>ds[i]:s-=ds[i];i+=1
  p=arr[i].lerp(arr[(i+1)%len(arr)],s/ds[i]);t=(arr[(i+1)%len(arr)]-arr[i]).normalized();out.append((p,t,k/count))
 return out,total
LINKS,LOOPLEN=resample_path(PATH,84)
def tracks(side,broken=False):
 s=1 if side=='L' else -1;cx=s*.712;m=Mesh()
 for k,(pt,t,v) in enumerate(LINKS):
  y,z=pt;ty,tz=t
  if broken:
   # Keep lower and rear runs; front crown spills forward in a grounded chain.
   if k<19:y=-1.75-(18-k)*.105;z=.071+max(0,k-14)*.015;ty,tz=1,0
   elif k>64:z=max(.16,z-.35*math.sin((k-64)/20*math.pi));tz=0
  length=LOOPLEN/84*.91;u=Vector((1,0,0));d=Vector((0,ty,tz));n=Vector((0,tz,-ty));c=Vector((cx,y,z))
  corner=.004;outline=[(-.154+corner,-length/2),(.154-corner,-length/2),(.154,-length/2+corner),(.154,length/2-corner),(.154-corner,length/2),(-.154+corner,length/2),(-.154,length/2-corner),(-.154,-length/2+corner)]
  A=[c+u*x+d*y-n*.029 for x,y in outline];B=[c+u*x+d*y+n*.029 for x,y in outline]
  m.face(A[::-1],'TrackSteel');m.face(B,'TrackSteel')
  for i in range(8):j=(i+1)%8;m.face([A[i],A[j],B[j],B[i]],'TrackSteel')
  # transverse raised traction cleat and hinge pin on each link
  m.beam(c-u*.15+n*.041,c+u*.15+n*.041,.022,.026,'TrackSteel')
  m.cyl(c-u*.171-d*length*.45,c+u*.171-d*length*.45,.019,'TrackSteel',10)
  # small underside guide tooth
  m.box(c-n*.046,(.055,.047,.062),'TrackSteel',Matrix((u,d,n)).transposed())
 o=m.build('Track_'+side+('_Broken' if broken else ''),damage=broken)
 # Force every loop UV V onto true along-belt longitudinal parameter including cleats.
 if not broken:
  for poly in o.data.polygons:
   for li in poly.loop_indices:
    p=o.data.vertices[o.data.loops[li].vertex_index].co
    best=min(range(len(LINKS)),key=lambda i:(p.y-LINKS[i][0][0])**2+(p.z-LINKS[i][0][1])**2)
    along=(Vector((p.y,p.z))-LINKS[best][0]).dot(LINKS[best][1]);o.data.uv_layers.active.data[li].uv=((p.x-cx)/.344+.5,max(0,min(1,best/84+along/LOOPLEN)))
 return o

def wheel(side,num,y,z,r,kind='road'):
 s=1 if side=='L' else -1;cx=s*.701;m=Mesh();p=(cx,y,z)
 if kind=='idler':
  m.cyl((cx-.081,y,z),(cx+.081,y,z),r*.94,'Wood',64)
  for i in range(16):
   a=i*math.tau/16; aa=a+.012;bb=a+math.tau/16-.012;xx=cx+s*.087
   m.face([(xx,y+math.cos(aa)*r*.22,z+math.sin(aa)*r*.22),(xx,y+math.cos(aa)*r*.90,z+math.sin(aa)*r*.90),(xx,y+math.cos(bb)*r*.90,z+math.sin(bb)*r*.90),(xx,y+math.cos(bb)*r*.22,z+math.sin(bb)*r*.22)],'Wood',[(0,.012+(i%8)*.125),(1,.012+(i%8)*.125),(1,.105+(i%8)*.125),(0,.105+(i%8)*.125)])
   m.beam((xx,y+math.cos(a)*r*.2,z+math.sin(a)*r*.2),(xx,y+math.cos(a)*r*.91,z+math.sin(a)*r*.91),.014,.016,'Steel')
  for xx in [cx-.096,cx+.096]:m.ring((xx,y,z),(1,0,0),r*.97,.026,'Steel',64,6)
 elif kind=='sprocket':
  m.cyl((cx-.071,y,z),(cx+.071,y,z),r*.87,'Steel',48)
  for i in range(10):
   a=math.tau*i/10;m.beam((cx+s*.09,y+math.cos(a)*.055,z+math.sin(a)*.055),(cx+s*.09,y+math.cos(a)*r*.8,z+math.sin(a)*r*.8),.035,.03,'Paint')
  for i in range(20):
   a=math.tau*i/20;m.box((cx,y+math.cos(a)*r,z+math.sin(a)*r),(.19,.054,.045),'TrackSteel',Matrix.Rotation(a,3,'X'))
  m.ring((cx+s*.095,y,z),(1,0,0),r*.89,.022,'Steel',48,6)
 else:
  m.cyl((cx-.074,y,z),(cx+.074,y,z),r,'Rubber',32)
  for xx in [cx-.079,cx+.079]:m.cyl((xx,y,z),(xx+s*.008,y,z),r*.77,'Steel',32)
  m.ring((cx+s*.09,y,z),(1,0,0),r*.60,.013,'Paint',32,6)
 m.cyl((cx-s*.12,y,z),(cx+s*.12,y,z),r*.20,'Steel',32)
 m.cyl((cx+s*.119,y,z),(cx+s*.137,y,z),r*.12,'Steel',16)
 for i in range(6):a=i*math.tau/6;m.sphere((cx+s*.127,y+math.cos(a)*r*.25,z+math.sin(a)*r*.25),.011,'Steel',8,4)
 o=m.build('Wheel_'+side+str(num),p);o['wheel_kind']=kind;return o
def running(h):
 for side,s in [('L',1),('R',-1)]:
  tracks(side);wheel(side,1,-1.48,.60,.445,'idler')
  # Nine little bogie wheels, plus aft drive sprocket and four return rollers.
  for i,y in enumerate(np.linspace(-.94,.93,9)):wheel(side,i+2,float(y),.235,.145)
  wheel(side,11,1.24,.43,.300,'sprocket')
  for i,y in enumerate(np.linspace(-.85,.91,4)):wheel(side,i+12,float(y),.80+(-y+1.24)*.117-.116,.085,'return')
  m=Mesh()
  # Exposed spring stacks and triangulated bogie arms.
  for y in [-1.0,-.40,.22,.83]:
   m.beam((s*.65,y,.72),(s*.65,y-.13,.30),.062,.075,'Steel');m.cyl((s*.61,y,.37),(s*.75,y,.37),.05,'Steel',20)
   for j in range(8):m.ring((s*.64,y,.68+j*.026),(0,0,1),.053,.008,'Steel',12,4)
  m.beam((s*.68,-1.53,.60),(s*.68,-.86,.60),.078,.09,'Steel')
  m.build('Suspension_'+side,parent=h)
  # Long outside armor beam is split into three genuinely detachable plates.
  for i,(a,b) in enumerate([(-1.62,-.72),(-.72,.30),(.30,1.40)]):
   q=Mesh();coords=[(a,.49),(b,.49)]
   coords+=([(b,.35),(b-.035,.27),(b-.11,.225)] if i==2 else [(b,.225)])
   coords+=([(a+.11,.225),(a+.035,.27),(a,.35)] if i==0 else [(a,.225)])
   polyprism(q,coords,s*.805-.026,s*.805+.026)
   rivets(q,(s*.839,a+.12,.435),(s*.839,b-.1,.435),7)
   q.build('Part_Skirt_'+side+str(i+1),(s*.805,(a+b)/2,.38),h,bevel=.003)
  # upper track stiffeners are authentic thin angle irons rather than broad modern fenders
  for loc,a,b in [('F',-1.35,-.05),('R',-.05,1.3)]:
   q=Mesh();z1=.995-(a+1.35)*.116;z2=.995-(b+1.35)*.116
   q.beam((s*.785,a,z1),(s*.785,b,z2),.040,.085)
   for y in np.linspace(a+.08,b-.08,8):q.sphere((s*.813,y,.995-(y+1.35)*.116),.011,'Steel')
   q.build('Part_Fender_'+loc+side,(s*.785,(a+b)/2,(z1+z2)/2),h,bevel=.002)

def fittings(h,t):
 # rear engine covers complete beneath detachable louvres
 for s,side in [(1,'L'),(-1,'R')]:
  for j,y in enumerate([.88,1.44]):
   q=Mesh();q.box((s*.558,y,.96),(.033,.42,.37),'Interior')
   for k in range(6):q.box((s*.585,y,.81+k*.06),(.052,.42,.031),'Paint',Matrix.Rotation(s*.23,3,'Y'))
   for yy in [y-.22,y+.22]:q.box((s*.59,yy,.975),(.05,.035,.40));rivets(q,(s*.617,yy,.82),(s*.617,yy,1.12),4)
   q.build('Part_Louvre_'+side+str(j+1),(s*.585,y,.97),h,bevel=.002)
  q=Mesh();q.box((s*.643,.18,1.20),(.17,.53,.19));q.box((s*.65,.18,1.309),(.19,.56,.025),'Steel')
  for y in [-.005,.36]:q.box((s*.739,y,1.22),(.018,.031,.19),'Steel');q.sphere((s*.754,y,1.24),.011,'Steel')
  q.build('Part_Toolbox_'+side,(s*.65,.18,1.22),h,bevel=.008)
 # aft deck grille, follows sloped engine housing
 q=Mesh()
 for i in range(11):y=.56+i*.079;z=1.41-(y-.56)*.38;q.box((0,y,z),(.78,.034,.024),'Paint',Matrix.Rotation(-.36,3,'X'))
 q.beam((-.4,.52,1.42),(-.4,1.4,1.08),.035);q.beam((.4,.52,1.42),(.4,1.4,1.08),.035)
 q.build('Part_EngineGrille',(0,.98,1.24),h,bevel=.002)
 # offset cylindrical muffler and pipe
 q=Mesh();q.cyl((-.65,.74,1.27),(-.65,1.48,1.12),.068,'Steel',32)
 for y,z in [(.88,1.24),(1.32,1.15)]:q.ring((-.65,y,z),(0,1,-.2),.072,.010,'Paint',24,6)
 q.cyl((-.65,1.44,1.13),(-.65,1.70,1.20),.032,'Steel',24,cap=False);q.ring((-.65,1.70,1.20),(0,1,.2),.030,.007,'Steel',24,6)
 q.build('Part_ExhaustMuffler',(-.65,1.15,1.22),h)
 # rear louvred access armor
 q=Mesh();q.box((0,1.789,.69),(.64,.045,.46))
 for i in range(6):q.box((0,1.820,.52+i*.058),(.5,.036,.023),'Steel')
 q.build('Part_RearAccess',(0,1.8,.69),h,bevel=.003)
 # Curved trench crossing skid: two perforated rails, transverse shoe, diagonal braces.
 q=Mesh();control=[Vector(p) for p in [(1.60,.42),(1.90,.37),(2.19,.49),(2.49,.70),(2.72,.94),(2.80,1.13)]];path=[]
 for i in range(len(control)-1):
  p0=control[max(0,i-1)];p1=control[i];p2=control[i+1];p3=control[min(len(control)-1,i+2)]
  for t0 in np.linspace(0,1,5,endpoint=False):path.append(.5*((2*p1)+(-p0+p2)*t0+(2*p0-5*p1+4*p2-p3)*t0*t0+(-p0+3*p1-3*p2+p3)*t0*t0*t0))
 path.append(control[-1])
 for x in [-.28,.28]:
  left=[];right=[]
  for i,p in enumerate(path):
   tang=(path[min(i+1,len(path)-1)]-path[max(i-1,0)]).normalized();normal=Vector((-tang.y,tang.x));left.append(p+normal*.045);right.append(p-normal*.045)
  polyprism(q,[tuple(p) for p in left+right[::-1]],x-.014,x+.014)
  for i,p in enumerate(path[::3]):q.sphere((x+.018,*p),.010,'Steel',8,4)
  def rounded_triangle(points):
   out=[];points=[Vector(p) for p in points]
   for i,c in enumerate(points):
    a=c.lerp(points[(i-1)%3],.12);b=c.lerp(points[(i+1)%3],.12)
    for t in [0,.33,.67,1]:out.append((1-t)**2*a+2*(1-t)*t*c+t*t*b)
   return out
  outer=rounded_triangle([(1.72,1.07),(2.74,1.04),(2.35,.55)]);inner=rounded_triangle([(1.94,.935),(2.49,.917),(2.30,.70)])
  for j in range(len(outer)):
   k=(j+1)%len(outer)
   for xx in [x-.014,x+.014]:q.face([(xx,*outer[j]),(xx,*outer[k]),(xx,*inner[k]),(xx,*inner[j])])
   q.face([(x-.014,*inner[j]),(x+.014,*inner[j]),(x+.014,*inner[k]),(x-.014,*inner[k])]);q.face([(x-.014,*outer[j]),(x+.014,*outer[j]),(x+.014,*outer[k]),(x-.014,*outer[k])])
 for y,z in [(1.91,.40),(2.42,.68),(2.74,1.02)]:q.beam((-.29,y,z),(.29,y,z),.029,.067)
 q.box((0,2.777,1.062),(.59,.037,.145),'Steel',Matrix.Rotation(-.32,3,'X'))
 q.build('Part_TailSkid',(0,2.22,.72),h,bevel=.004)
 # front & rear removable tow fixtures, authentic small stowed tools
 for label,y,z in [('Front',-1.72,.63),('Rear',1.79,.40)]:
  q=Mesh();q.box((0,y,z),(.29,.045,.12));q.ring((0,y+(-.055 if y<0 else .055),z),(1,0,0),.050,.014,'Steel',24,6);q.build('Part_Tow_'+label,(0,y,z),h,bevel=.003)
 q=Mesh();q.cyl((.60,.53,1.37),(.60,1.35,1.10),.015,'Wood',12);q.box((.60,1.38,1.08),(.095,.15,.018),'Steel',Matrix.Rotation(-.32,3,'X'));q.build('Part_Tools',(.60,1.0,1.23),h)
 # Small period lamps on brackets (no modern headlight housings).
 for s,side in [(1,'L'),(-1,'R')]:
  q=Mesh();q.cyl((s*.49,-.72,1.31),(s*.49,-.79,1.31),.04,'Steel',24);q.cyl((s*.49,-.791,1.31),(s*.49,-.796,1.31),.031,'Lamps',24);q.build('Lamp_'+side,parent=h)
  q=Mesh();q.ring((s*.49,-.809,1.31),(0,1,0),.044,.006,'Steel',24,6);q.build('Part_LampGuard_'+side,(s*.49,-.80,1.31),h)
 for name,p in [('Seat_Commander',(0,-.40,2.03)),('Seat_Driver',(0,-.81,1.38)),('Exhaust',(-.65,1.71,1.2)),('Light_Head_L',(.49,-.81,1.31)),('Light_Head_R',(-.49,-.81,1.31)),('Fx_Engine',(0,.99,1.39)),('Fx_Ammo',(0,-.1,.68)),('Fx_Hatch',(0,-.40,2.05))]:empty(name,p,t if name in ['Seat_Commander','Fx_Hatch'] else h)

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

def download(asset,kind):
 folder=P/'textures'/'source';folder.mkdir(parents=True,exist_ok=True);p=folder/f'{asset}_{kind}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg';print('DOWNLOAD',url,flush=True);urllib.request.urlretrieve(url,p)
 return bpy.data.images.load(str(p),check_existing=True)
def bake_material(name,asset):
 m=M[name];n=m.node_tree.nodes;l=m.node_tree.links;bs=n.get('Principled BSDF');color=tuple(bs.inputs['Base Color'].default_value[:3]);metal=bs.inputs['Metallic'].default_value;rough=bs.inputs['Roughness'].default_value
 if name=='Paint':color=(1,1,1)
 n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0]);uv=n.new('ShaderNodeTexCoord')
 diff=n.new('ShaderNodeTexImage');diff.image=download(asset,'diff');l.new(uv.outputs['UV'],diff.inputs[0]);gray=n.new('ShaderNodeRGBToBW');l.new(diff.outputs[0],gray.inputs[0]);ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.12;ramp.color_ramp.elements[1].position=.82;ramp.color_ramp.elements[0].color=tuple(c*(.52 if name!='Paint' else .62) for c in color)+(1,);ramp.color_ramp.elements[1].color=(*color,1);l.new(gray.outputs[0],ramp.inputs[0])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=6;noise.inputs['Detail'].default_value=3
 stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(2.3,1,1);l.new(uv.outputs['UV'],stretch.inputs[0]);l.new(stretch.outputs[0],noise.inputs['Vector'])
 grime=n.new('ShaderNodeValToRGB');grime.color_ramp.elements[0].position=.26;grime.color_ramp.elements[0].color=(.45,.45,.45,1);grime.color_ramp.elements[1].position=.57;grime.color_ramp.elements[1].color=(1,1,1,1);l.new(noise.outputs['Fac'],grime.inputs[0])
 mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.26 if name=='Paint' else .17;l.new(ramp.outputs[0],mix.inputs[1]);l.new(grime.outputs[0],mix.inputs[2]);l.new(mix.outputs[0],bs.inputs['Base Color'])
 if name in ['Paint','TrackSteel','Burnt']:
  chips=n.new('ShaderNodeTexNoise');chips.inputs['Scale'].default_value=115 if name=='Paint' else 68;chips.inputs['Detail'].default_value=3;chips.inputs['Roughness'].default_value=.75;l.new(uv.outputs['UV'],chips.inputs[0])
  cr=n.new('ShaderNodeValToRGB');cr.color_ramp.elements[0].position=.32;cr.color_ramp.elements[0].color=(.23,.23,.23,1);cr.color_ramp.elements[1].position=.48;cr.color_ramp.elements[1].color=(1,1,1,1);l.new(chips.outputs['Fac'],cr.inputs[0])
  cm=n.new('ShaderNodeMixRGB');cm.blend_type='MULTIPLY';cm.inputs[0].default_value=.23 if name=='Paint' else .5;l.new(mix.outputs[0],cm.inputs[1]);l.new(cr.outputs[0],cm.inputs[2]);l.new(cm.outputs[0],bs.inputs['Base Color'])
 if name=='Wood':
  hs=n.new('ShaderNodeHueSaturation');hs.inputs['Saturation'].default_value=.68;hs.inputs['Value'].default_value=.72;l.new(diff.outputs[0],hs.inputs['Color']);l.new(hs.outputs[0],bs.inputs['Base Color'])
 if name=='Burnt':
  ash=n.new('ShaderNodeValToRGB');ash.color_ramp.elements[0].position=.22;ash.color_ramp.elements[0].color=(.015,.013,.011,1);ash.color_ramp.elements[1].position=.77;ash.color_ramp.elements[1].color=(.27,.255,.23,1)
  rust=ash.color_ramp.elements.new(.44);rust.color=(.065,.035,.016,1);soot=ash.color_ramp.elements.new(.58);soot.color=(.10,.092,.075,1);l.new(noise.outputs['Fac'],ash.inputs[0]);am=n.new('ShaderNodeMixRGB');am.blend_type='MULTIPLY';am.inputs[0].default_value=.22;l.new(ash.outputs[0],am.inputs[1]);l.new(gray.outputs[0],am.inputs[2]);l.new(am.outputs[0],bs.inputs['Base Color'])
 normal=n.new('ShaderNodeTexImage');normal.image=download(asset,'nor_gl');normal.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],normal.inputs[0]);nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.27 if name=='Paint' else .6;l.new(normal.outputs[0],nm.inputs['Color'])
 fine=n.new('ShaderNodeTexNoise');fine.inputs['Scale'].default_value=155;l.new(uv.outputs['UV'],fine.inputs['Vector']);bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.17;bump.inputs['Distance'].default_value=.0008;l.new(fine.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=download(asset,'arm');arm.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],arm.inputs[0]);sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0]);rm=n.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=max(.35,rough-.12);rm.inputs['To Max'].default_value=min(.98,rough+.12);l.new(sep.outputs[1],rm.inputs[0]);l.new(rm.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-8));tile=bpy.context.object;tile.name='BakeTile';tile.data.materials.append(m);active(tile);target=n.new('ShaderNodeTexImage');baked={};folder=P/'textures'/'baked';folder.mkdir(parents=True,exist_ok=True)
 for kind,res in [('base',1024 if name=='Paint' else 512),('normal',1024 if name=='Paint' else 512),('orm',512)]:
  file=folder/(name+'_'+kind+'.png')
  if file.exists() and '--rebake' not in sys.argv and name not in ['Paint','TrackSteel','Steel','Burnt']:im=bpy.data.images.load(str(file),check_existing=True)
  else:
   im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
   if kind!='base':im.colorspace_settings.name='Non-Color'
   target.image=im;n.active=target;bpy.context.scene.render.bake.margin=4;bpy.context.scene.cycles.samples=4
   if kind=='base':bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
   elif kind=='normal':bpy.ops.object.bake(type='NORMAL')
   else:
    pack=n.new('ShaderNodeCombineColor');pack.mode='RGB';ao=n.new('ShaderNodeMapRange');ao.inputs['To Min'].default_value=.65;ao.inputs['To Max'].default_value=1.;l.new(sep.outputs[0],ao.inputs[0]);l.new(ao.outputs[0],pack.inputs[0]);l.new(rm.outputs[0],pack.inputs[1]);pack.inputs[2].default_value=metal;em=n.new('ShaderNodeEmission');l.new(pack.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');l.new(bs.outputs[0],out.inputs[0]);n.remove(em)
   im.filepath_raw=str(file);im.file_format='PNG';im.save()
  if kind!='base':im.colorspace_settings.name='Non-Color'
  baked[kind]=im
 bpy.data.objects.remove(tile,do_unlink=True);n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0]);base=n.new('ShaderNodeTexImage');base.name='Baked_Base';base.image=baked['base'];l.new(base.outputs[0],bs.inputs['Base Color']);normal=n.new('ShaderNodeTexImage');normal.image=baked['normal'];nm=n.new('ShaderNodeNormalMap');l.new(normal.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal']);orm=n.new('ShaderNodeTexImage');orm.image=baked['orm'];orm.name='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'])
 if name=='Paint':
  mult=n.new('ShaderNodeMixRGB');mult.name='PaintFactor';mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;mult.inputs[2].default_value=(*PAINTS[0][1],1);l.new(base.outputs[0],mult.inputs[1]);l.new(mult.outputs[0],bs.inputs['Base Color'])
 print('BAKED',name,flush=True)

def export(objs,name):
 for o in bpy.context.scene.objects:o.select_set(False)
 for o in objs:o.hide_set(False);o.hide_render=False;o.select_set(True)
 bpy.context.view_layer.objects.active=next(o for o in objs if o.type=='MESH')
 # Give exporter direct image input, patch exact linear factor into serialized material.
 pm=M.get('Paint');bs=pm.node_tree.nodes.get('Principled BSDF') if pm else None
 if bs and pm.node_tree.nodes.get('Baked_Base'):pm.node_tree.links.new(pm.node_tree.nodes['Baked_Base'].outputs[0],bs.inputs['Base Color'])
 path=P/name;bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_apply=False,export_yup=True,export_animations=False,export_cameras=False,export_lights=False,export_materials='EXPORT',export_image_format='AUTO')
 raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);tail=raw[20+jl:]
 for mat in doc.get('materials',[]):
  if mat.get('name')=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
  # packed ORM is shared by occlusion too
  if 'metallicRoughnessTexture' in mat.get('pbrMetallicRoughness',{}):mat['occlusionTexture']=dict(mat['pbrMetallicRoughness']['metallicRoughnessTexture'])
 js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4);path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(js)+len(tail))+struct.pack('<II',len(js),0x4e4f534a)+js+tail)
 if bs and pm.node_tree.nodes.get('PaintFactor'):pm.node_tree.links.new(pm.node_tree.nodes['PaintFactor'].outputs[0],bs.inputs['Base Color'])
 print('EXPORTED',name,path.stat().st_size,'triangles',triangles(objs),flush=True)
def triangles(objs):return sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in objs if o.type=='MESH')
def bounds(objs):
 bpy.context.view_layer.update();ps=[o.matrix_world@Vector(v) for o in objs if o.type=='MESH' for v in o.bound_box];return [min(p[i] for p in ps) for i in range(3)],[max(p[i] for p in ps) for i in range(3)]
def hide(objs,yes):
 for o in objs:o.hide_render=yes

def studio():
 sc=bpy.context.scene
 floor=material('Studio_floor',(.29,.315,.33),0,.92);bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));ground=bpy.context.object;ground.name='StudioGround';ground.data.materials.append(floor)
 world=bpy.data.worlds.new('Soft daylight');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.55,.65,.78,1);world.node_tree.nodes['Background'].inputs[1].default_value=.35;sc.world=world
 for name,loc,power,size,col in [('Key',(4,-5,7),1100,5,(1,.91,.78)),('Fill',(-4,-1,4),750,4,(.72,.83,1)),('Rim',(1,5,6),1300,4,(1,.94,.84))]:
  dat=bpy.data.lights.new(name,'AREA');dat.energy=power;dat.shape='DISK';dat.size=size;dat.color=col;o=bpy.data.objects.new(name,dat);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,.8))-o.location).to_track_quat('-Z','Y').to_euler()
 dat=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',dat);sc.collection.objects.link(cam);sc.camera=cam;dat.type='ORTHO';dat.ortho_scale=6.75;camera('hero');return ground

def camera(kind='hero',scale=None):
 cam=bpy.context.scene.camera;target=Vector((0,.3,1.0))
 if kind=='side':cam.location=(7,.3,1.1);cam.data.ortho_scale=6.2
 elif kind=='rear':cam.location=(5,7,3.5);cam.data.ortho_scale=6.75
 else:cam.location=(6,-8,4.0);cam.data.ortho_scale=6.75
 if scale:cam.data.ortho_scale=scale
 cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()
def render(file,w=1920,h=1080,samples=24):
 sc=bpy.context.scene;sc.render.resolution_x=w;sc.render.resolution_y=h;sc.render.resolution_percentage=100;sc.cycles.samples=samples;sc.render.filepath=str(P/file);bpy.ops.render.render(write_still=True);print('RENDER',file,flush=True)
def clay():
 mat=material('Clay',(.36,.37,.38),0,.8);bpy.context.view_layer.material_override=mat;camera();render('clay.png',1920,1080,16);bpy.context.view_layer.material_override=None

def preflight():
 clean();setup();materials();m=Mesh();m.box((0,0,.5),(1,1,1));o=m.build('Preflight',bevel=.03);bake_material('Paint','green_metal_rust');export([o],'preflight.glb');studio();camera(scale=3);render('preflight.png',320,240,4);print('PREFLIGHT_OK',flush=True)

# Damage meshes inherit the original pivots and evaluated placement.
def copy_mesh(o,name,parent=None,burn=False):
 n=o.copy();n.data=o.data.copy();bpy.context.collection.objects.link(n);w=o.matrix_world.copy();n.parent=None;n.matrix_world=w;n.name=name
 if parent:setparent(n,parent)
 if burn:
  n.data.materials.clear();n.data.materials.append(M['Burnt']);n.data.materials.append(M['Scars'])
  for f in n.data.polygons:
   center=f.center;signal=math.sin(center.x*13+center.y*9)*math.sin(center.z*18+center.y*3)
   f.material_index=1 if signal>.82 and f.area<.008 else 0
 DAMAGE.append(n);return n

def damage_build(t,g,ba):
 hd=empty('Hull_Damage',(0,0,0),damage=True);td=empty('Turret_Damage',t.matrix_world.translation,damage=True)
 names=['Skirt_L1','Skirt_L2','Skirt_R1','Skirt_R3','Fender_FL','Fender_FR','Louvre_L1','Louvre_R2','Toolbox_L','Toolbox_R','EngineGrille','TailSkid','RearAccess','Turret_CupolaCap','Turret_CupolaRing','Turret_RearDoor','Turret_Sight']
 for i,short in enumerate(names):
  src=PARTS['Part_'+short];n=copy_mesh(src,'Dmg_'+short,td if short.startswith('Turret_') else hd)
  # deterministic torn folding, shape remains recognizable and pivot is unchanged
  for v in n.data.vertices:
   p=v.co;dist=p.length
   if p.y>0:p.z+=.07*math.sin(p.y*8)+.035*math.sin(p.x*17)
   if p.z>0:p.x+=.035*math.sin(p.z*18+i)
   if 'Fender' in short:p.z+=.14*max(0,p.y+.25)
   if 'Skirt' in short and p.y<-.10:p.x+=.11*abs(p.y);p.z-=.10*abs(p.y)
   if 'Cap' in short:p.z+=.035*math.sin(math.atan2(p.y,p.x)*5)
  # maintain allowed damage-only material list
  for j,m in enumerate(n.data.materials):
   if m.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:n.data.materials[j]=M['Scars']
  if i%3==0:
   # ragged bite actually removes geometry from the outer edge, rather than a decal
   import bmesh
   bm=bmesh.new();bm.from_mesh(n.data);kill=[v for v in bm.verts if v.co.y>.12 and v.co.z>.01 and v.co.x>.0]
   bmesh.ops.delete(bm,geom=kill,context='VERTS');bm.to_mesh(n.data);bm.free()
  n.data.update()
 for side in ['L','R']:
  o=tracks(side,True);bpy.context.view_layer.update();low=min((o.matrix_world@v.co).z for v in o.data.vertices)
  for v in o.data.vertices:v.co.z-=low
  setparent(o,hd)
 # 4 shallow impact groups on each face. Front slope tangent follows hull.
 scar_specs=[('Hull_Front',(0,-1.32,.8675),(0,-.337,.941),(.9,0,0),hd),('Hull_L',(.56,-.08,.99),(1,0,0),(0,.9,0),hd),('Hull_R',(-.56,-.08,.99),(-1,0,0),(0,.9,0),hd),('Hull_Rear',(0,1.77,.76),(0,1,0),(.8,0,0),hd),('Turret_Front',(0,-.8652,1.73),(0,-1,.24),(.66,0,0),td),('Turret_L',(.46765,-.4,1.72),(1,0,.24),(0,.58,0),td),('Turret_R',(-.46765,-.4,1.72),(-1,0,.24),(0,.58,0),td),('Turret_Rear',(0,.06765,1.72),(0,1,.24),(.6,0,0),td)]
 for name,center,normal,spread,parent in scar_specs:
  q=Mesh();normal=Vector(normal).normalized();u=Vector(spread).normalized();v=normal.cross(u).normalized();center=Vector(center)
  for j in range(4):
   c=center+Vector(spread)*((j-1.5)/4)+v*(.045*math.sin(j*3));rad=.024 if j else .041
   # ragged soot halo, bright torn annulus, recessed black core
   q.cyl(c+normal*.001,c+normal*.0013,rad*1.35,'Burnt',17)
   q.cyl(c+normal*.0015,c+normal*.0018,rad*.78,'Burnt',17)
   for k in range(12):
    a=k*math.tau/12;b=(k+1)*math.tau/12;rr=rad*(1+.12*math.sin(k*7+j));aa=c+(u*math.cos(a)+v*math.sin(a))*rr;bb=c+(u*math.cos(b)+v*math.sin(b))*rr
    ai=c+(u*math.cos(a)+v*math.sin(a))*rad*.58;bi=c+(u*math.cos(b)+v*math.sin(b))*rad*.58
    q.face([aa+normal*.002,bb+normal*.002,bi+normal*(.004 if j==0 else .0025),ai+normal*(.005 if j==0 else .003)],'Scars')
   q.beam(c+u*rad*.9+normal*.005,c+u*rad*2.6+v*.015+normal*.005,.009,.006,'Scars')
  q.build('Scar_'+name,parent=parent,damage=True)
 # Split, drooping cannon with peeled barrel petals, exact original Gun pivot.
 q=Mesh()
 for src in [g,ba]:
  for f in src.data.polygons:
   coords=[]
   for i in f.vertices:
    p=src.matrix_world@src.data.vertices[i].co
    if p.y<-1.25:p.z-=(-p.y-1.25)*.55;p.x+=.024*math.sin((-p.y-1.25)*10)
    coords.append(p)
   q.face(coords,'Scars')
 for i in range(6):
  a=i*math.tau/6;q.beam((math.cos(a)*.035,-1.57,1.52),(math.cos(a)*.085,-1.74,1.48+math.sin(a)*.08),.015,.018,'Scars')
 q.build('Gun_Broken',g.matrix_world.translation,td,damage=True)
 # Wreck aggregate assembled from source meshes; preserve only one mesh for hull and turret.
 hullpieces=[];turretpieces=[]
 turretset={t}|set(t.children_recursive)
 omit={'Part_Skirt_L1','Part_Fender_FL','Part_Louvre_L1','Part_Toolbox_R','Part_Turret_CupolaCap','Part_Turret_Sight','Track_L'}
 for src in HEALTH:
  if src.type!='MESH' or src.name in omit:continue
  w=copy_mesh(src,'_WreckCopy',burn=True);w.parent=None;w.matrix_world=src.matrix_world.copy()
  for v in w.data.vertices:
   p=w.matrix_world@v.co
   if src not in turretset:
    if src.name.startswith(('Wheel','Suspension')):p.z=max(.07,p.z-.05)
    if src.get('wheel_kind')=='road':
     hub=src.matrix_world.translation;dy=p.y-hub.y;dz=p.z-(hub.z-.05);rr=math.hypot(dy,dz)
     if rr>.112:p.y=hub.y+dy*.78;p.z=hub.z-.05+dz*.78
    if src.name=='Track_R' and p.z>.7:p.z-=.09*math.sin((p.y+1.7)*.9)
    if src.name=='Part_EngineGrille':p.z+=max(0,.35*(1-abs(p.y-.95)));p.x+=.09*math.sin(p.y*4)
    if src.name=='Hatch_Driver':p=src.matrix_world.translation+Matrix.Rotation(math.radians(-72),3,'X')@(p-src.matrix_world.translation)
   else:
    if src.name in ['Gun','Barrel']:p.z-=max(0,(-p.y-.92)*.52)
    if src.name=='Hatch_Commander' or src in next(o for o in HEALTH if o.name=='Hatch_Commander').children_recursive:
     hinge=next(o for o in HEALTH if o.name=='Hatch_Commander').matrix_world.translation;p=hinge+Matrix.Rotation(math.radians(-105),3,'X')@(p-hinge)
   v.co=w.matrix_world.inverted()@p
  (turretpieces if src in turretset else hullpieces).append(w)
 broken=next(o for o in DAMAGE if o.name=='Track_L_Broken');w=copy_mesh(broken,'_WreckTrack',burn=True);w.parent=None;w.matrix_world=broken.matrix_world.copy();hullpieces.append(w)
 def join(objects,name,pivot,ratio):
  for o in bpy.context.scene.objects:o.select_set(False)
  for o in objects:o.select_set(True)
  bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();o=objects[0];o.name=name
  world=o.matrix_world.copy();vs=[world@v.co-Vector(pivot) for v in o.data.vertices];o.matrix_world=Matrix.Translation(pivot)
  for v,p in zip(o.data.vertices,vs):v.co=p
  dec=o.modifiers.new('Wreck economy','DECIMATE');dec.ratio=ratio;bpy.ops.object.modifier_apply(modifier=dec.name)
  return o
 wh=join(hullpieces,'Wreck_Hull',(0,0,0),.40);wt=join(turretpieces,'Wreck_Turret',t.matrix_world.translation,.48)
 # Open burnt ring well and turret basket underneath, useful when blown off.
 q=Mesh();q.cyl((0,-.4,1.02),(0,-.4,1.055),.41,'Burnt',40);q.ring((0,-.4,1.417),(0,0,1),.46,.034,'Burnt',48,6)
 for a in np.linspace(0,math.tau,16,endpoint=False):q.beam((.41*math.cos(a),-.4+.41*math.sin(a),1.04),(.41*math.cos(a),-.4+.41*math.sin(a),1.40),.025,.025,'Burnt')
 well=q.build('_Well',damage=True);wh=join([wh,well],'Wreck_Hull',(0,0,0),1)
 q=Mesh();q.cyl((0,-.4,1.18),(0,-.4,1.40),.29,'Burnt',32);q.ring((0,-.4,1.40),(0,0,1),.46,.032,'Burnt',48,6);stub=q.build('_Basket',damage=True);wt=join([wt,stub],'Wreck_Turret',t.matrix_world.translation,1)
 # Remove every deck/bulkhead cap inside the breached ring; never use a closed cutter as the well.
 import bmesh
 bm=bmesh.new();bm.from_mesh(wh.data);dead=[]
 for f in bm.faces:
  c=f.calc_center_median();original=GEO_A.inverted()@c
  if math.hypot(original.x,original.y+.4)<.445 and original.z>1.10:dead.append(f)
 bmesh.ops.delete(bm,geom=dead,context='FACES');loose=[v for v in bm.verts if not v.link_faces]
 if loose:bmesh.ops.delete(bm,geom=loose,context='VERTS')
 bm.to_mesh(wh.data);bm.free()
 q=Mesh();q.cyl((0,-.4,1.04),(0,-.4,1.405),.423,'Burnt',48,cap=False)
 for i in range(24):
  a=i*math.tau/24;b=(i+1)*math.tau/24;ra=.42+.014*math.sin(i*3);rb=.42+.014*math.sin((i+1)*3)
  q.face([(ra*math.cos(a),-.4+ra*math.sin(a),1.405+.013*math.sin(i*4)),(.48*math.cos(a),-.4+.48*math.sin(a),1.41),(.48*math.cos(b),-.4+.48*math.sin(b),1.41),(rb*math.cos(b),-.4+rb*math.sin(b),1.405+.013*math.sin((i+1)*4))],'Scars')
 breach=q.build('_OpenBreach',damage=True);wh=join([wh,breach],'Wreck_Hull',(0,0,0),1)
 import bmesh
 for ob in [wh,wt]:
  bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.dissolve_degenerate(bm,dist=.000001,edges=list(bm.edges));bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(ob.data);bm.free()
 # Deleted joins leave dead references in Python list; actual hierarchy is authoritative.
 DAMAGE[:]=[o for o in bpy.context.scene.objects if o==hd or o==td or o.parent in [hd,td] or o.name in ['Wreck_Hull','Wreck_Turret']]
 return hd,td,wh,wt

def open_hatches(h,t):
 # Boolean crew openings through the actual armor; dark modeled rooms beneath.
 for target,loc,r,depth in [(t,(0,-.4,1.96),.209,.25)]:
  bpy.ops.mesh.primitive_cylinder_add(vertices=48,radius=r,depth=depth,location=loc);cut=bpy.context.object
  if target==h:cut.rotation_euler[0]=math.radians(34)
  active(target);mod=target.modifiers.new('Actual crew opening','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)

def manifest():
 def pos(o):p=o.matrix_world.translation;return [round(p.x,5),round(p.z,5),round(-p.y,5)]
 def listing(obs):return [{'name':o.name,'position':pos(o),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'empty'} for o in obs]
 lo,hi=bounds(HEALTH);bb={'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]}
 names={o.name for o in DAMAGE};d={'contract':'tank-damage/v1','parts':[{'part':n,'twin':'Dmg_'+n[5:] if 'Dmg_'+n[5:] in names else None} for n in PARTS], 'scars':[n for n in names if n.startswith('Scar_')],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']}
 data={'asset':'Renault FT-style WWI light tank','frame':'+Y up, +Z front, +X left; metres','bounding_box':bb,'dimensions':[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]],'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'nodes':listing(HEALTH),'damage_nodes':listing(DAMAGE),'files':{'renault-ft-tank.glb':listing(HEALTH),'renault-ft-tank-damage.glb':listing(DAMAGE)},'damage':d,'bake':{'engine':'Cycles OPTIX','samples':4,'sources':PH,'license':'https://polyhaven.com/license','kind':'shared physical surface tiles: photographic diffuse/normal/ARM plus procedural rain streaks and micro-pitting','base_normal_max':1024,'orm_max':512}}
 (P/'nodes.json').write_text(json.dumps(data,indent=2))
 (P/'NOTES.md').write_text('Renault FT-style WWI two-man light tank with articulated running gear, 37 mm gun, riveted octagonal turret and full damage kit.\n'+f'{triangles(HEALTH):,} healthy triangles; {triangles(DAMAGE):,} damage triangles; GLBs {(P/"renault-ft-tank.glb").stat().st_size/1048576:.2f} / {(P/"renault-ft-tank-damage.glb").stat().st_size/1048576:.2f} MiB.\n'+'Healthy materials: Paint, TrackSteel, Steel, Wood, Rubber, Interior, Lamps; damage adds Burnt and Scars; no clearcoat.\n'+'Cycles baked shared base/normal: Paint 1024 px, others 512 px; packed ORM 512 px, neutral Paint with linear recolour factor.\n'+'Poly Haven CC0 ids: '+', '.join(sorted(set(PH.values())))+'; https://polyhaven.com/license .\n'+'Reference differences: reconstructed mechanical detail and battle damage; simplified interior and wooden idler joinery; no insignia, logos or text.\n')

def pivot_proof():
 saved=[]
 for o in HEALTH:
  if o.name in ['Turret','Gun','Barrel','Hatch_Driver','Hatch_Commander'] or o.name.startswith('Wheel_'):
   saved.append((o,o.rotation_euler.copy(),o.location.copy()));axis=2 if o.name=='Turret' else 0;o.rotation_euler[axis]+=math.radians(30)
 camera();render('pivot-test.png',1280,720,12)
 for o,r,l in saved:o.rotation_euler=r;o.location=l
 (P/'pivot-test.json').write_text(json.dumps({'angle_degrees':30,'tested':[o.name for o,r,l in saved],'pivot_origins_retained':True,'axes':{'Turret':'Y glTF','Gun':'X','Barrel':'X bore-origin proof','wheels':'X','hatches':'X'}},indent=2))

def contact_sheet(files,out):
 arrays=[]
 for f in files:
  im=bpy.data.images.load(str(P/f),check_existing=False);w,h=im.size;a=np.empty(w*h*4,dtype=np.float32);im.pixels.foreach_get(a);arrays.append(a.reshape(h,w,4));bpy.data.images.remove(im)
 top=np.concatenate(arrays[:3],axis=1);bottom=np.concatenate(arrays[3:],axis=1);a=np.concatenate([bottom,top],axis=0);im=bpy.data.images.new(out,width=a.shape[1],height=a.shape[0],alpha=True);im.pixels.foreach_set(a.ravel());im.filepath_raw=str(P/out);im.file_format='PNG';im.save();bpy.data.images.remove(im)

def imported_proofs():
 # Contract proof strictly begins with an empty scene and imports both delivered binaries.
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
 bpy.ops.import_scene.gltf(filepath=str(P/'renault-ft-tank.glb'));healthy=list(bpy.context.scene.objects);known=set(healthy)
 bpy.ops.import_scene.gltf(filepath=str(P/'renault-ft-tank-damage.glb'));damage=[o for o in bpy.context.scene.objects if o not in known]
 setup();studio();(P/'proofs').mkdir(exist_ok=True)
 hm={o.name:o for o in healthy};dm={o.name:o for o in damage};statefiles=[]
 # Imported second material can be suffixed by Blender; exact GLB factor already verified in exporter.
 hide(damage,True);camera();render('final.png',1920,1080,32);camera('side');render('side.png',1920,1080,24);camera('rear');render('rear.png',1280,720,16);camera()
 for i,state in enumerate(['healthy','hit','stripped','immobilised','wreck','blown-off']):
  hide(healthy,False);hide(damage,True)
  if state=='hit':
   for n,o in dm.items():
    if n.startswith('Scar_'):o.hide_render=False
   for n in ['Skirt_L1','Skirt_L2','Fender_FL','Toolbox_L','Louvre_L1','Turret_CupolaCap']:
    if 'Part_'+n in hm:hm['Part_'+n].hide_render=True
    if 'Dmg_'+n in dm:dm['Dmg_'+n].hide_render=False
  elif state=='stripped':
   for n,o in hm.items():
    if n.startswith('Part_'):o.hide_render=True
  elif state=='immobilised':
   hm['Track_L'].hide_render=True
   for o in [hm['Gun']]+list(hm['Gun'].children_recursive):o.hide_render=True
   dm['Track_L_Broken'].hide_render=False;dm['Gun_Broken'].hide_render=False
  elif state in ['wreck','blown-off']:
   hide(healthy,True);dm['Wreck_Hull'].hide_render=False;dm['Wreck_Turret'].hide_render=False
   if state=='blown-off':
    wt=dm['Wreck_Turret'];wt.location=(1.45,-.3,.44);wt.rotation_euler=(math.radians(108),0,math.radians(-32));bpy.context.view_layer.update();wt.location.z-=bounds([wt])[0][2]
  camera(scale=8.7);file=f'proofs/state-{i+1}-{state}.png';render(file,640,360,12);statefiles.append(file)
 contact_sheet(statefiles,'states.png');hide(healthy,False);hide(damage,True);camera()
 paintmats={m for o in healthy if o.type=='MESH' for m in o.data.materials if m.name.startswith('Paint')};files=[]
 for i,(name,col) in enumerate(PAINTS):
  for mat in paintmats:
   nt=mat.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');links=list(bs.inputs['Base Color'].links)
   # Blender importer represents texture*factor as a multiply node.
   if links and links[0].from_node.type=='MIX':
    node=links[0].from_node
    for inp in node.inputs:
     if inp.type=='RGBA' and not inp.is_linked:inp.default_value=(*col,1)
   elif links and links[0].from_node.type=='MIX_RGB':links[0].from_node.inputs[2].default_value=(*col,1)
   elif links:
    source=links[0].from_socket;mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*col,1);nt.links.new(source,mix.inputs[1]);nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
   else:bs.inputs['Base Color'].default_value=(*col,1)
  file=f'proofs/paint-{i+1}.png';render(file,640,360,12);files.append(file)
 contact_sheet(files,'paints.png')
 (P/'proofs'/'provenance.json').write_text(json.dumps({'states_source':'Both delivered GLBs imported into an empty Blender scene','renderer':'Cycles GPU OptiX','ordering':['healthy','hit','stripped','immobilised','wreck','wreck turret blown off'],'paints':[n for n,c in PAINTS]},indent=2))


def worn_edges():
 # Tiny exposed-metal chips on rivet rims and thin bevel strips, with geometry-backed glTF materials.
 rng=random.Random(741)
 for o in HEALTH:
  if o.type!='MESH':continue
  for j,m in enumerate(o.data.materials):
   if m is None:o.data.materials[j]=M['Interior']
  names=[m.name for m in o.data.materials]
  if 'Paint' not in names:continue
  pi=names.index('Paint')
  if 'Steel' not in names:o.data.materials.append(M['Steel']);names.append('Steel')
  steel=names.index('Steel')
  for f in o.data.polygons:
   if f.material_index!=pi or f.area<1e-9:continue
   vs=[o.data.vertices[i].co for i in f.vertices];longest=max((vs[(i+1)%len(vs)]-p).length for i,p in enumerate(vs));aspect=longest*longest/f.area
   if aspect>65 and .00011<f.area<.007 and rng.random()<.28:f.material_index=steel

def main():
 global GEO_A
 if '--preflight' in sys.argv:preflight();return
 clean();setup();materials()
 h=hull_geo();t,g,ba=turret_geo(h);running(h);fittings(h,t);open_hatches(h,t);worn_edges()
 # Remove zero-area triangulation leftovers from bevel and aperture operations.
 import bmesh
 for o in HEALTH:
  if o.type!='MESH':continue
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.dissolve_degenerate(bm,dist=.000001,edges=list(bm.edges));bm.to_mesh(o.data);bm.free()
 # Standardize exact requested dimensions without altering local moving pivots.
 bpy.context.view_layer.update();lo,hi=bounds(HEALTH);sx=1.74/(hi[0]-lo[0]);sy=5/(hi[1]-lo[1]);sz=2.14/(hi[2]-lo[2]);ground=lo[2]
 # Scale coordinates AND pivot translations together using global affine transform.
 A=Matrix.Diagonal((sx,sy,sz,1));A.translation.z=-ground*sz;GEO_A=A
 world={o:o.matrix_world.copy() for o in HEALTH}
 for o in HEALTH:
  if o.type=='MESH':
   for v in o.data.vertices:v.co=Vector((v.co.x*sx,v.co.y*sy,v.co.z*sz))
  p=A@world[o].translation
  if o.parent:o.location=p-(A@world[o.parent].translation)
  else:o.location=p
 bpy.context.view_layer.update();print('HEALTHY_GEO',triangles(HEALTH),bounds(HEALTH),flush=True);print('TRI_BREAKDOWN',sorted([(o.name,triangles([o])) for o in HEALTH if o.type=='MESH'],key=lambda v:-v[1])[:12],flush=True)
 for side in ['L','R']:
  wheels=sorted([o for o in HEALTH if o.name.startswith('Wheel_'+side)],key=lambda o:o.matrix_world.translation.y)
  for i,o in enumerate(wheels):o.name='_wheel_tmp_'+side+str(i)
  for i,o in enumerate(wheels):o.name='Wheel_'+side+str(i+1)
 (P/'proofs').mkdir(exist_ok=True)
 (P/'proofs'/'scope.json').write_text(json.dumps({'scope':'Renault FT healthy and full tank-damage/v1','cameras':['hero front 3/4','side orthographic','rear','close runtime view'],'budgets':{'healthy_triangles':[60000,110000],'damage_triangles_max':110000,'material_max':10,'glb_bytes_max':12582912,'base_normal_max':1024,'orm_max':512},'revision_cap':3,'critic_passes_per_revision':2},indent=2))
 studio();clay()
 for name,asset in PH.items():bake_material(name,asset)
 export(HEALTH,'renault-ft-tank.glb');camera();render('healthy-first.png',1280,720,20)
 if '--healthy-only' in sys.argv:return
 damage_build(t,g,ba);hide(DAMAGE,True);export(DAMAGE,'renault-ft-tank-damage.glb');hide(DAMAGE,True);manifest();pivot_proof()
 bpy.ops.wm.save_as_mainfile(filepath=str(P/'renault-ft-source.blend'))
 imported_proofs();print('COMPLETE elapsed',time.time()-T0,flush=True)
if __name__=='__main__':main()
