"""M1114-style armoured HMMWV. Clean-run Blender 5.2 builder.
Run: blender -b --python-exit-code 1 --python build_humvee-armored.py
Coordinates while authoring: X left, -Y front, Z up; glTF conversion is Y up.
CC0 sources: https://polyhaven.com/license . No external Python dependencies.
"""
import bpy, bmesh, math, os, json, urllib.request, subprocess, sys
from mathutils import Vector
from math import sin, cos, pi
import numpy as np
ROOT=os.path.dirname(os.path.abspath(__file__))
TEX=os.path.join(ROOT,'textures');os.makedirs(TEX,exist_ok=True)
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)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES'
sc.cycles.samples=24;sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
sc.cycles.device='GPU';sc.render.bake.margin=4
sc.view_settings.view_transform='AgX'
# Registered photographic inputs, all maps from the requested CC0 endpoint.
SOURCES=['rusty_metal_03','rubberized_track','fabric_leather_02']
def download(id,mp):
 p=os.path.join(TEX,f'{id}_{mp}_1k.jpg')
 if not os.path.exists(p):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg'
  subprocess.run(['curl','--fail','-L','--retry','2','-sS',url,'-o',p],check=True)
 return p
for id in SOURCES:
 for mp in ['diff','nor_gl','arm']:download(id,mp)
M={}; groups={}; G='Body'
def node(nt,kind):return nt.nodes.new(kind)
def link(nt,a,b):nt.links.new(a,b)
def image_node(nt,path,non=False):
 n=node(nt,'ShaderNodeTexImage');n.image=bpy.data.images.load(path,check_existing=True)
 if non:n.image.colorspace_settings.name='Non-Color'
 return n
# Bake reusable 1 m surface tiles through Cycles, rather than a vehicle-wide atlas.
def bake_mat(name,source,color,rough,metal,normal_strength=.15):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
 out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');link(nt,bs.outputs['BSDF'],out.inputs['Surface'])
 uv=node(nt,'ShaderNodeTexCoord');diff=image_node(nt,download(source,'diff'));arm=image_node(nt,download(source,'arm'),True);nor=image_node(nt,download(source,'nor_gl'),True)
 for n in (diff,arm,nor):link(nt,uv.outputs['UV'],n.inputs['Vector'])
 if name=='Paint':
  scale=node(nt,'ShaderNodeVectorMath');scale.operation='SCALE';scale.inputs[3].default_value=3.0;link(nt,uv.outputs['UV'],scale.inputs[0])
  for n in (diff,arm,nor):link(nt,scale.outputs[0],n.inputs['Vector'])
 bw=node(nt,'ShaderNodeRGBToBW');link(nt,diff.outputs['Color'],bw.inputs[0])
 ramp=node(nt,'ShaderNodeValToRGB');link(nt,bw.outputs[0],ramp.inputs[0])
 if name=='Paint':lo,hi=.58,.98
 elif name=='Rubber':lo,hi=.016,.062
 elif name=='Weapon':lo,hi=.035,.10
 elif name=='Steel':lo,hi=.12,.30
 else:lo,hi=.09,.21
 ramp.color_ramp.elements[0].position=.045 if name=='Paint' else 0
 ramp.color_ramp.elements[1].position=.54 if name=='Paint' else 1
 ramp.color_ramp.elements[0].color=tuple(c*lo for c in color)+(1,)
 ramp.color_ramp.elements[1].color=tuple(c*hi for c in color)+(1,)
 noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=115;noise.inputs['Roughness'].default_value=.78;link(nt,uv.outputs['UV'],noise.inputs['Vector'])
 mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.14;link(nt,ramp.outputs[0],mix.inputs[1]);link(nt,noise.outputs['Fac'],mix.inputs[2])
 # Rain/abrasion streaks from stretched noise, fixed in the baked tile.
 mapping=node(nt,'ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(75,3,1);link(nt,uv.outputs['UV'],mapping.inputs[0])
 streak=node(nt,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=2;link(nt,mapping.outputs[0],streak.inputs[0])
 smix=node(nt,'ShaderNodeMixRGB');smix.blend_type='MULTIPLY';smix.inputs[0].default_value=.10;link(nt,mix.outputs[0],smix.inputs[1]);link(nt,streak.outputs['Fac'],smix.inputs[2])
 if name=='Paint':
  sepUV=node(nt,'ShaderNodeSeparateXYZ');link(nt,uv.outputs['UV'],sepUV.inputs[0])
  def mathnode(op,a,b):
   q=node(nt,'ShaderNodeMath');q.operation=op
   for k,v in enumerate([a,b]):
    if isinstance(v,(float,int)):q.inputs[k].default_value=v
    else:link(nt,v,q.inputs[k])
   return q.outputs[0]
  du=mathnode('MINIMUM',sepUV.outputs['X'],mathnode('SUBTRACT',1,sepUV.outputs['X']))
  dv=mathnode('MINIMUM',sepUV.outputs['Y'],mathnode('SUBTRACT',1,sepUV.outputs['Y']))
  edge=mathnode('MINIMUM',du,dv)
  grime=node(nt,'ShaderNodeMapRange');grime.inputs['From Min'].default_value=.003;grime.inputs['From Max'].default_value=.047;grime.inputs['To Min'].default_value=.67;grime.inputs['To Max'].default_value=1;link(nt,edge,grime.inputs['Value'])
  trim=node(nt,'ShaderNodeMixRGB');trim.blend_type='MULTIPLY';trim.inputs[0].default_value=1;link(nt,smix.outputs[0],trim.inputs[1]);link(nt,grime.outputs[0],trim.inputs[2]);smix=trim
 nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=normal_strength;link(nt,nor.outputs[0],nm.inputs['Color']);link(nt,nm.outputs[0],bs.inputs['Normal'])
 link(nt,smix.outputs[0],bs.inputs['Base Color']);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-10));plane=bpy.context.object;plane.data.materials.append(m)
 target=node(nt,'ShaderNodeTexImage');em=node(nt,'ShaderNodeEmission')
 def bake_image(suffix,res,typ,src=None):
  im=bpy.data.images.new(name+'_'+suffix,width=res,height=res,alpha=False)
  if suffix!='base':im.colorspace_settings.name='Non-Color'
  target.image=im;nt.nodes.active=target
  if src:
   link(nt,src,em.inputs['Color']);link(nt,em.outputs[0],out.inputs['Surface'])
  else:link(nt,bs.outputs[0],out.inputs['Surface'])
  sc.cycles.samples=4;bpy.ops.object.bake(type=typ)
  im.filepath_raw=os.path.join(TEX,name+'_'+suffix+('.jpg' if suffix=='base' else '.png'));im.file_format='JPEG' if suffix=='base' else 'PNG';im.save()
  return im
 base=bake_image('base',1024,'EMIT',smix.outputs[0])
 normal=bake_image('normal',1024 if name in ['Paint','Rubber'] else 512,'NORMAL')
 sep=node(nt,'ShaderNodeSeparateColor');link(nt,arm.outputs[0],sep.inputs[0])
 comb=node(nt,'ShaderNodeCombineColor');comb.inputs['Red'].default_value=1
 mul=node(nt,'ShaderNodeMath');mul.operation='MULTIPLY_ADD';mul.inputs[1].default_value=.20;mul.inputs[2].default_value=rough-.12;link(nt,sep.outputs['Green'],mul.inputs[0]);link(nt,mul.outputs[0],comb.inputs['Green']);comb.inputs['Blue'].default_value=metal
 link(nt,sep.outputs['Red'],comb.inputs['Red']);orm=bake_image('ORM',512 if name=='Paint' else 256,'EMIT',comb.outputs[0])
 bpy.data.objects.remove(plane,do_unlink=True)
 nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');link(nt,bs.outputs[0],out.inputs[0])
 tex=node(nt,'ShaderNodeTexImage');tex.image=base
 if name=='Paint':
  mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.45,.36,.22,1);mix.label='Runtime paint factor';link(nt,tex.outputs[0],mix.inputs[1]);link(nt,mix.outputs[0],bs.inputs['Base Color'])
 else:link(nt,tex.outputs[0],bs.inputs['Base Color'])
 n=node(nt,'ShaderNodeTexImage');n.image=normal;nm=node(nt,'ShaderNodeNormalMap');link(nt,n.outputs[0],nm.inputs['Color']);link(nt,nm.outputs[0],bs.inputs['Normal'])
 n=node(nt,'ShaderNodeTexImage');n.image=orm;s=node(nt,'ShaderNodeSeparateColor');link(nt,n.outputs[0],s.inputs[0]);link(nt,s.outputs['Green'],bs.inputs['Roughness']);link(nt,s.outputs['Blue'],bs.inputs['Metallic'])
 # Recognised glTF occlusion socket.
 ng=bpy.data.node_groups.get('glTF Material Output')
 if not ng:
  ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=node(nt,'ShaderNodeGroup');gn.node_tree=ng;link(nt,s.outputs['Red'],gn.inputs['Occlusion'])
 M[name]=m;print('BAKED',name,flush=True);return m
for args in [('Paint','rusty_metal_03',(1,1,1),.81,0,.12),('Rubber','rubberized_track',(1,1,1),.88,0,.24),('Steel','rusty_metal_03',(1,.98,.92),.57,.70,.12),('Weapon','rusty_metal_03',(.8,.88,1),.65,.65,.14),('Interior','fabric_leather_02',(.85,.86,.73),.86,0,.18),('Canvas','fabric_leather_02',(1,.9,.66),.91,0,.28)]:bake_mat(*args)
def simple(name,col,rough=.5,alpha=1,em=0):
 m=bpy.data.materials.new(name);m.use_nodes=True;p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*col,1);p.inputs['Roughness'].default_value=rough;p.inputs['Alpha'].default_value=alpha
 if em:p.inputs['Emission Color'].default_value=(*col,1);p.inputs['Emission Strength'].default_value=em
 M[name]=m;return m
simple('Glass',(.035,.052,.058),.13,.19);M['Glass'].surface_render_method='BLENDED';M['Glass'].use_transparent_shadow=True
simple('Lamps',(.68,.72,.75),.19,1,.12)
# Geometry helpers. Each piece gets real bevels and face-space UVs before joining.
def finish(o,name,mat,bev=0,seg=1,smooth=False):
 o.name=name;o.data.materials.append(M[mat]);bpy.context.view_layer.objects.active=o
 if bev:
  mod=o.modifiers.new('Manufactured edge radius','BEVEL');mod.width=bev;mod.segments=seg
  bpy.ops.object.modifier_apply(modifier=mod.name)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 else:
  mod=o.modifiers.new('Area weighted normals','WEIGHTED_NORMAL');mod.keep_sharp=True
  try:bpy.ops.object.modifier_apply(modifier=mod.name)
  except:pass
 # Deterministic local planar UVs, kept at useful density, shared between parts.
 uv=o.data.uv_layers.active or o.data.uv_layers.new(name='UVMap')
 for p in o.data.polygons:
  n=p.normal;ax=max(range(3),key=lambda a:abs(n[a]));axes=[a for a in range(3) if a!=ax]
  for li in p.loop_indices:
   v=o.data.vertices[o.data.loops[li].vertex_index].co
   uv.data[li].uv=(v[axes[0]]*1.8+.31,v[axes[1]]*1.8+.47)
   if mat=='Paint' and p.area>.025:
    coords=[o.data.vertices[k].co for k in p.vertices]
    mn=[min(c[a] for c in coords) for a in axes];mx=[max(c[a] for c in coords) for a in axes]
    uv.data[li].uv=tuple(.004+.992*(v[a]-mn[j])/max(mx[j]-mn[j],1e-6) for j,a in enumerate(axes))
 groups.setdefault(G,[]).append(o);return o
def box(name,loc,size,mat='Paint',bev=.008,rot=None,seg=1):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.dimensions=size;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if rot:o.rotation_euler=rot
 return finish(o,name,mat,bev,2 if name in ['Mirror case','Seat cushion','Seat back','Jerrycan','Front fascia','Cab roof'] else seg)
def mesh(name,verts,faces,mat='Paint',bev=.008):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);bpy.context.view_layer.objects.active=o;o.select_set(True)
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 return finish(o,name,mat,bev,2)
def prism(name,poly,axis,lo,hi,mat='Paint',bev=.008):
 axes=[a for a in range(3) if a!=axis];vs=[]
 for t in (lo,hi):
  for v in poly:
   p=[0,0,0];p[axis]=t;p[axes[0]]=v[0];p[axes[1]]=v[1];vs.append(p)
 n=len(poly);faces=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 return mesh(name,vs,faces,mat,bev)
def cyl(name,loc,r,depth,mat='Steel',axis='Z',n=24,bev=.003):
 bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=depth,location=loc);o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 return finish(o,name,mat,bev,1,True)
def rod(name,a,b,r,mat='Steel',n=12):
 a,b=Vector(a),Vector(b);o=cyl(name,(a+b)/2,r,(b-a).length,mat,n=n,bev=.0015);o.rotation_euler=(b-a).to_track_quat('Z','Y').to_euler();return o
def tube(name,pts,r,mat='Steel',n=10):
 for a,b in zip(pts,pts[1:]):rod(name,a,b,r,mat,n)
def torus(name,loc,major,minor,mat='Steel',axis='Z',seg=48,minorseg=10):
 bpy.ops.mesh.primitive_torus_add(major_radius=major,minor_radius=minor,major_segments=seg,minor_segments=minorseg,location=loc);o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);return finish(o,name,mat,0,smooth=True)
def bolt(loc,axis='X',r=.009,mat='Steel'):return cyl('Hex bolt',loc,r,.009,mat,axis,6,.001)
def ring(name,loc,outer,inner,h,mat='Paint',n=64):
 x,y,z=loc;vs=[]
 for zz,rr in [(z-h/2,outer),(z+h/2,outer),(z-h/2,inner),(z+h/2,inner)]:
  vs += [(x+rr*cos(i*2*pi/n),y+rr*sin(i*2*pi/n),zz) for i in range(n)]
 fs=[]
 for i in range(n):
  j=(i+1)%n;fs.extend([(i,j,n+j,n+i),(2*n+i,3*n+i,3*n+j,2*n+j),(n+i,n+j,3*n+j,3*n+i),(i,2*n+i,2*n+j,j)])
 return mesh(name,vs,fs,mat,.004)
# Chassis, belly protection, crossmembers, suspension.
box('Armoured centre floor',(0,.05,.65),(1.40,4.32,.13),'Paint',.024)
box('Cab floor',(0,.11,.65),(1.94,1.98,.13),'Paint',.018)
for x in [-.68,.68]:box('Box chassis rail',(x,.05,.45),(.15,4.35,.16),'Weapon',.012)
for y in [-1.9,-1.15,0,1.2,2.05]:box('Crossmember',(0,y,.45),(1.54,.13,.12),'Weapon')
for y in [-1.65,1.65]:
 cyl('Differential housing',(0,y,.46),.14,.29,'Weapon','X',32)
 cyl('Axle shaft',(0,y,.48),.052,1.88,'Steel','X',24)
 for s in [-1,1]:
  for dy in [-.19,.19]:rod('Wishbone',(.36*s,y+dy,.52),(.94*s,y,.46),.033,'Weapon')
  rod('Damper',(.81*s,y,.48),(.64*s,y-.14,.90),.035,'Steel')
  for k in range(6):torus('Suspension spring',(.65*s,y-.12,.58+k*.045),.065,.012,'Weapon',seg=16,minorseg=6)
tube('Exhaust pipe',[(-.48,.1,.43),(-.58,.95,.44),(-.70,1.8,.50),(-.70,2.26,.53)],.027,'Steel',16)
cyl('Exhaust outlet',(-.70,2.272,.53),.021,.004,'Weapon','Y',20,0)
print('CHASSIS COMPLETE',flush=True)
# Four wheels: swept sidewall/rim profile, chevron tread, bead locks and valve.
for s,label in [(1,'L'),(-1,'R')]:
 for y,end in [(-1.65,'F'),(1.65,'R')]:
  G='Wheel_'+end+label;xc=s*.96;zc=.49
  profile=[(-.165,.255),(-.176,.31),(-.165,.386),(-.126,.451),(-.09,.477),(.09,.477),(.126,.451),(.165,.386),(.176,.31),(.165,.255)]
  vs=[];N=64
  for xx,rr in profile:
   for i in range(N):a=i*2*pi/N;vs.append((xc+xx,y+rr*sin(a),zc+rr*cos(a)))
  fs=[]
  for j in range(len(profile)-1):
   for i in range(N):q=(i+1)%N;fs.append((j*N+i,j*N+q,(j+1)*N+q,(j+1)*N+i))
  o=mesh('Tyre carcass',vs,fs,'Rubber',0)
  for p in o.data.polygons:p.use_smooth=True
  for i in range(40):
   a=2*pi*i/40
   for j in [-1,0,1]:
    aa=a+(j%2)*.030;rr=.4785
    o=box('Tread lug',(xc+j*.094,y+rr*sin(aa),zc+rr*cos(aa)),(.086,.057,.025),'Rubber',.003,(-aa,0,j*.38),1)
  for rrr in [.327,.395]:torus('Moulded sidewall band',(xc+s*.158,y,zc),rrr,.0025,'Rubber','X',64,6)
  for ss in [-1,1]:
   torus('Tyre bead',(xc+ss*.167,y,zc),.273,.011,'Rubber','X',64,8)
  xx=xc+s*.116
  cyl('Steel wheel rim',(xc,y,zc),.269,.22,'Paint','X',64,.008)
  cyl('Recessed dish',(xx+s*.004,y,zc),.218,.015,'Weapon','X',48,.005)
  cyl('Wheel dish',(xx+s*.017,y,zc),.193,.027,'Paint','X',48,.012)
  torus('Rim rolled lip',(xx+s*.011,y,zc),.247,.017,'Paint','X',64,8)
  cyl('Portal hub',(xx+s*.028,y,zc),.083,.050,'Paint','X',32,.009)
  cyl('Hub end',(xx+s*.057,y,zc),.047,.016,'Steel','X',24)
  for i in range(12):
   a=2*pi*i/12
   bolt((xx+s*.033,y+.231*sin(a),zc+.231*cos(a)),'X',.010)
  for i in range(8):
   a=2*pi*i/8
   bolt((xx+s*.040,y+.112*sin(a),zc+.112*cos(a)),'X',.013)
   cyl('Dish vent',(xx+s*.033,y+.164*sin(a),zc+.164*cos(a)),.017,.009,'Weapon','X',16,.002)
  rod('Tyre valve',(xx,y+.20,zc-.105),(xx+s*.028,y+.20,zc-.105),.006,'Rubber')
G='Body'
print('WHEELS COMPLETE',flush=True)
# Side armour contours have true wheel-arch cutouts, not black circles over boxes.
for s in [-1,1]:
 lo,hi=sorted([s*.98,s*1.055])
 # Centre tub with chamfered sills.
 box('Lower rocker armour',(s*.967,.055,.615),(.20,1.96,.15),'Paint',.015)
 prism('Armoured sill',[(-1.01,.76),(-.84,.61),(.89,.61),(1.08,.83),(1.03,1.25),(-1.01,1.25)],0,lo,hi,'Paint',.018)
 # Arched front and rear fender bands, with inner liner and rolled flange.
 for yy in [-1.65,1.65]:
  pts=[]
  for i in range(17):
   a=pi*i/16;pts.append((yy+.565*cos(a),.52+.565*sin(a)))
  outline=[(yy+.63,.78),(yy+.63,1.23),(yy-.63,1.23),(yy-.63,.78)]+list(reversed(pts))
  # Correct boundary traces: top rectangle then arch from left to right.
  outline=[(yy-.63,.77),(yy-.63,1.22),(yy+.63,1.22),(yy+.63,.77)]+pts
  prism('Shaped fender',outline,0,min(s*.92,s*1.075),max(s*.92,s*1.075),'Paint',.016)
  arc=[(s*1.08,yy+.566*cos(pi*i/24),.52+.566*sin(pi*i/24)) for i in range(25)]
  tube('Fender folded lip',arc,.018,'Paint')
  # Solid inner wheelhouse closes the sightline through the arch.
  inner=[(yy-.565,.40),(yy+.565,.40)]+[(yy+.565*cos(pi*i/24),.52+.565*sin(pi*i/24)) for i in range(25)]
  prism('Wheelhouse inner wall',inner,0,min(s*.74,s*.77),max(s*.74,s*.77),'Rubber',.004)
  # dark wheelhouse follows arch inside skin
  for i in range(20):
   a=(i+.5)*pi/20
   box('Wheel well liner',(s*.94,yy+.548*cos(a),.52+.548*sin(a)),(.23,.095,.025),'Rubber',.004,(a-pi/2,0,0))
 # doors: lower plate with generous radii; top window aperture built as a ring.
 for ya,yb in [(-.79,.10),(.13,1.02)]:
  x=s*1.015;cy=(ya+yb)/2
  path=[(s*1.057,ya,.80),(s*1.057,ya+.08,.704),(s*1.057,yb-.08,.704),(s*1.057,yb,.80),(s*1.057,yb,1.885)]
  tube('Door perimeter seal',path,.005,'Rubber',8)
  rod('Door front seal',(s*1.057,ya,.80),(s*1.057,ya,1.88),.005,'Rubber',8)
  prism('Ballistic door lower',[(ya,.79),(ya+.08,.70),(yb-.08,.70),(yb,.78),(yb,1.393),(ya,1.393)],0,min(x-.039,x+.039),max(x-.039,x+.039),'Paint',.018)
  box('Door front stile',(x,ya+.0625,1.61),(.080,.125,.57),'Paint',.012)
  box('Door rear stile',(x,yb-.0625,1.61),(.080,.125,.57),'Paint',.012)
  box('Door top rail',(x,cy,1.873),(.080,yb-ya,.095),'Paint',.012)
  # Thick armoured window surround and black gasket, glass recessed 22 mm.
  wy=cy;w=.55;z=1.61;h=.375
  for zz in [z-h/2,z+h/2]:box('Window armour frame',(s*1.069,wy,zz),(.065,w+.12,.057),'Paint',.009)
  for yyy in [wy-w/2-.032,wy+w/2+.032]:box('Window armour frame',(s*1.069,yyy,z),(.065,.06,h),'Paint',.009)
  for zz in [z-h/2+.032,z+h/2-.032]:box('Window elastomer seal',(s*1.072,wy,zz),(.020,w,.017),'Rubber',.004)
  for yyy in [wy-w/2+.010,wy+w/2-.010]:box('Window elastomer seal',(s*1.072,yyy,z),(.020,.020,h-.05),'Rubber',.004)
  box('Thick ballistic glass',(s*1.052,wy,z),(.037,w-.02,h-.070),'Glass',.008)
  for yyy in [ya+.06,yb-.06]:
   for zz in [.85,1.30,1.86]:bolt((s*1.071,yyy,zz),'X',.010,'Steel')
  for yyy in [wy-.24,wy,wy+.24]:
   for zz in [z-h/2,z+h/2]:bolt((s*1.107,yyy,zz),'X',.008,'Steel')
  # pin hinges, exterior pull, recessed latch and inside handle
  for zz in [1.03,1.28]:
   box('Door hinge leaf',(s*1.074,ya+.025,zz),(.04,.105,.053),'Paint',.006)
   cyl('Hinge pin',(s*1.103,ya+.019,zz),.017,.09,'Steel','Z',12)
  box('Handle recess',(s*1.062,yb-.19,1.23),(.019,.22,.042),'Rubber',.011)
  rod('Door pull',(s*1.091,yb-.275,1.243),(s*1.091,yb-.12,1.243),.012,'Steel')
  for q in [yb-.275,yb-.12]:rod('Pull standoff',(s*1.052,q,1.24),(s*1.091,q,1.24),.011,'Steel')
  box('Door latch plate',(s*1.065,yb-.22,.83),(.023,.093,.11),'Weapon',.016)
  tube('Latch lever',[(s*1.09,yb-.25,.85),(s*1.09,yb-.25,.80),(s*1.09,yb-.19,.80),(s*1.09,yb-.19,.85)],.008,'Steel')
 # A/B/C pillars
 for yy in [-.84,.115,1.07]:box('Cab structural pillar',(s*.996,yy,1.36),(.10,.100,1.10),'Paint',.010)
 # Rear sloping armoured cargo cap
 prism('Sloped rear side',[(1.10,1.22),(2.29,1.22),(2.30,1.49),(2.14,1.61),(1.10,1.94)],0,min(s*.96,s*1.025),max(s*.96,s*1.025),'Paint',.018)
 # Rear ventilation panel inset, horizontal louvres
 box('Rear vent gasket',(s*1.045,1.73,1.31),(.026,.91,.155),'Rubber',.008)
 for zz in [1.262,1.288,1.314,1.340,1.366]:box('Rear cooling louvre',(s*1.063,1.73,zz),(.015,.85,.008),'Steel',.002)
 for yy in [1.29,2.17]:
  for zz in [1.255,1.365]:bolt((s*1.079,yy,zz),'X',.009)
 # fuel filler
 cyl('Filler flange',(s*1.070,1.12,1.12),.090,.030,'Weapon','X',32)
 cyl('Fuel cap',(s*1.090,1.12,1.12),.065,.022,'Steel','X',24)
 box('Fuel cap grip',(s*1.110,1.12,1.12),(.016,.076,.018),'Weapon',.004)
 # Lower armour fasteners.
 for yy in [-.80,-.48,-.16,.16,.48,.80]:bolt((s*1.052,yy,.656),'X',.009,'Steel')
# Roof plate with real hatch opening via boolean, then four side drip strips.
roof=box('Cab roof',(0,.19,1.953),(2.06,1.88,.085),'Paint',.022)
bpy.ops.mesh.primitive_cylinder_add(vertices=64,radius=.475,depth=.3,location=(0,.22,1.95));cut=bpy.context.object
bpy.context.view_layer.objects.active=roof;mod=roof.modifiers.new('Open gunner hatch','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
for x in [-1.035,1.035]:box('Roof rain gutter',(x,.18,1.925),(.038,1.92,.052),'Paint',.008)
# Rear top slope and end bulkhead.
prism('Cargo roof slope',[(1.11,1.90),(2.31,1.51),(2.31,1.57),(1.11,1.96)],0,-.98,.98,'Paint',.015)
box('Rear bulkhead',(0,2.295,1.095),(1.99,.085,.77),'Paint',.015)
box('Cargo access door',(0,2.348,1.30),(1.43,.043,.46),'Paint',.017)
for xx in [-.67,.67]:
 for zz in [1.11,1.48]:bolt((xx,2.378,zz),'Y',.011)
box('Cargo latch',(0,2.387,1.25),(.13,.03,.09),'Steel',.007)
for x in [-.48,.48]:
 box('Jerrycan support',(x,2.395,.94),(.39,.10,.13),'Weapon',.008)
 box('Jerrycan',(x,2.395,1.13),(.29,.15,.36),'Paint',.027)
 for a in [-1,1]:rod('Jerrycan pressed rib',(x-.10,2.483,1.13+a*.12),(x+.10,2.483,1.13-a*.12),.012,'Paint')
 tube('Jerrycan handle',[(x-.06,2.39,1.31),(x-.06,2.39,1.355),(x+.06,2.39,1.355),(x+.06,2.39,1.31)],.016,'Paint')
 box('Can retention strap',(x,2.491,1.12),(.03,.014,.38),'Weapon',.003)
# Hood loft, broad flat centre with sloping shoulders. Front is -Y.
sections=[(-2.30,1.22,.96),(-2.13,1.30,1.015),(-1.18,1.405,1.015),(-.84,1.41,1.00)]
vs=[]
for yy,zz,w in sections:vs += [(-w,yy,zz-.16),(-w,yy,zz-.045),(-w+.18,yy,zz), (w-.18,yy,zz),(w,yy,zz-.045),(w,yy,zz-.16)]
fs=[tuple(range(5,-1,-1)),tuple(range(18,24))]
for j in range(3):
 for i in range(6):fs.append((j*6+i,j*6+(i+1)%6,(j+1)*6+(i+1)%6,(j+1)*6+i))
mesh('Pressed hood',vs,fs,'Paint',.020)
# Hood grille recess with eleven slats.
box('Hood vent recess',(0,-1.53,1.366),(.64,.65,.010),'Rubber',.009,(.110,0,0))
for i in range(11):box('Hood vent slat',(0,-1.83+i*.056,1.341+i*.0062),(.625,.018,.021),'Paint',.004,(.110,0,0))
for s in [-1,1]:
 # hood perimeter crease, hold-down catch and lifting loops
 rod('Pressed fender crease',(s*1.054,-2.24,1.20),(s*1.065,-1.92,1.16),.010,'Paint',16)
 rod('Pressed fender crease',(s*1.065,-1.92,1.16),(s*1.065,-1.13,1.16),.010,'Paint',16)
 rod('Hood shoulder rib',(s*.77,-2.16,1.29),(s*.82,-.97,1.403),.015,'Paint')
 torus('Hood lift eye',(s*.52,-1.08,1.435),.043,.011,'Steel','Y',24,8)
 box('Bonnet catch base',(s*1.032,-1.07,1.22),(.025,.065,.19),'Weapon',.009,(-.28,0,0))
 rod('Bonnet catch lever',(s*1.058,-1.09,1.16),(s*1.058,-1.045,1.31),.016,'Steel')
# Front fascia seven vertical radiator slots between two round headlamps.
box('Front fascia',(0,-2.301,1.075),(2.04,.075,.36),'Paint',.019)
box('Radiator recess',(0,-2.346,1.085),(1.04,.022,.27),'Weapon',.015)
grille=box('Stamped grille',(0,-2.370,1.085),(1.09,.032,.30),'Paint',.008)
for i in range(7):
 cutter=box('Slot cutter',(-.42+i*.14,-2.370,1.085),(.076,.14,.237),'Weapon',.032,seg=6)
 bpy.context.view_layer.objects.active=grille;mod=grille.modifiers.new('Stamped ventilation aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
 groups[G].remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
for s in [-1,1]:
 cyl('Headlamp bezel',(s*.674,-2.353,1.075),.137,.050,'Steel','Y',48,.006)
 cyl('Headlamp reflector',(s*.674,-2.384,1.075),.110,.019,'Lamps','Y',48,.006)
 torus('Headlamp inner chrome ring',(s*.674,-2.397,1.075),.096,.005,'Steel','Y',48,8)
 cyl('Headlamp bulb',(s*.674,-2.400,1.075),.026,.009,'Lamps','Y',24,.003)
 # lens moulding lines in the same emissive lamp material
 for xoff in [-.054,-.027,0,.027,.054]:rod('Headlamp lens flute',(s*.674+xoff,-2.397,1.004),(s*.674+xoff,-2.397,1.146),.002,'Lamps',6)
 cyl('Front signal surround',(s*.919,-2.343,1.17),.062,.029,'Weapon','Y',32)
 cyl('Front signal',(s*.919,-2.365,1.17),.047,.019,'Lamps','Y',32)
 # round rear lamps share the material, vertex tint handled through baked lamp tile later
 cyl('Tail housing',(s*.85,2.36,1.09),.081,.04,'Weapon','Y',32)
 cyl('Tail lens',(s*.85,2.389,1.09),.062,.024,'Lamps','Y',32)
 box('Rear mud flap',(s*.92,2.14,.48),(.33,.033,.43),'Rubber',.009)
# Windscreen: divided panes inset into true angled surround.
def windshield_piece(name,x,z,w,h,mat,depth=.043):
 y=-.84+(z-1.395)*.18
 return box(name,(x,y,z),(w,depth,h),mat,.008,(-.178,0,0))
for x in [-.493,.493]:
 
 for xx in [x-.450,x+.450]:windshield_piece('Windshield side gasket',xx,1.669,.035,.515,'Rubber')
 for zz in [1.425,1.913]:windshield_piece('Windshield edge gasket',x,zz,.92,.031,'Rubber')
 windshield_piece('Ballistic windscreen',x,1.669,.865,.485,'Glass',.044)
for x in [-.982,0,.982]:windshield_piece('Windscreen upright',x,1.67,.066,.58,'Paint',.077)
for z in [1.39,1.946]:windshield_piece('Windscreen cross rail',0,z,2.035,.058,'Paint',.08)
for s in [-1,1]:
 tube('Wiper arm',[(s*.75,-.852,1.405),(s*.49,-.832,1.555),(s*.31,-.813,1.75)],.009,'Weapon')
 rod('Wiper blade',(s*.18,-.816,1.77),(s*.57,-.86,1.54),.014,'Rubber')
 # Mirror support stays tucked inside the stated 2.30 m width.
 tube('Mirror bracket',[(s*1.017,-.74,1.68),(s*1.125,-.82,1.66),(s*1.125,-.82,1.39),(s*1.024,-.73,1.38)],.012,'Weapon')
 box('Mirror case',(s*1.1185,-.832,1.55),(.063,.135,.30),'Weapon',.027)
 box('Mirror face',(s*1.116,-.754,1.55),(.049,.008,.255),'Steel',.015)
# Snorkel on vehicle right, antennas on rear.
cyl('Snorkel riser',(-.945,-.78,1.73),.049,.69,'Paint','Z',32,.009)
cyl('Snorkel cap',(-.945,-.78,2.065),.075,.051,'Paint','Z',32,.012)
for y in [1.80]:
 cyl('Antenna base',(.84,y,1.73),.033,.10,'Weapon','Z',20)
 rod('Short whip',(.84,y,1.77),(.84,y,2.12),.006,'Weapon')
# Bumper, brushguard, tow loops, winch fairlead.
box('Front bumper',(0,-2.390,.785),(2.08,.16,.135),'Weapon',.018)
box('Rear bumper',(0,2.397,.705),(2.12,.14,.145),'Weapon',.014)
tube('Brushguard outer',[(-1.04,-2.457,.86),(-1.04,-2.457,1.285),(-.96,-2.457,1.34),(.96,-2.457,1.34),(1.04,-2.457,1.285),(1.04,-2.457,.86)],.033,'Weapon',16)
rod('Brushguard bottom',(-1.04,-2.457,.875),(1.04,-2.457,.875),.023,'Weapon',16)
for x in [-.46,.46]:rod('Guard upright',(x,-2.457,.79),(x,-2.457,1.34),.022,'Weapon',16)
box('Winch frame',(0,-2.413,.745),(.50,.10,.16),'Weapon',.014)
cyl('Winch drum',(0,-2.427,.744),.061,.35,'Steel','X',32)
for x in [-.72,.72]:
 box('Tow bracket',(x,-2.445,.727),(.065,.072,.115),'Paint',.007)
 torus('Front shackle',(x,-2.462,.665),.047,.012,'Steel','Y',24,8)
 torus('Rear shackle',(x,2.462,.646),.045,.012,'Steel','Y',24,8)
# Cab interior: four independent bolstered seats, transmission tunnel, dash and controls.
box('Transmission tunnel',(0,-.04,.91),(.60,2.14,.40),'Interior',.07)
for s in [-1,1]:
 for yy in [-.28,.66]:
  box('Seat pedestal',(s*.65,yy,.83),(.37,.40,.23),'Weapon',.019)
  box('Seat cushion',(s*.65,yy,1.00),(.46,.45,.13),'Canvas',.046)
  box('Seat back',(s*.65,yy+.20,1.26),(.46,.12,.51),'Canvas',.044,(-.10,0,0))
  box('Seat headrest',(s*.65,yy+.225,1.55),(.25,.095,.15),'Canvas',.025)
  for xx in [s*.65-.19,s*.65+.19]:box('Seat bolster',(xx,yy+.13,1.27),(.073,.15,.40),'Interior',.027,(-.10,0,0))
  rod('Seat belt',(s*.86,yy+.14,1.47),(s*.48,yy-.15,1.04),.019,'Rubber')
box('Dashboard',(0,-.65,1.27),(1.82,.29,.22),'Interior',.037)
box('Instrument binnacle',(.53,-.484,1.335),(.46,.05,.19),'Weapon',.019)
for xx,rr in [(.40,.045),(.52,.055),(.66,.031)]:
 cyl('Instrument bezel',(xx,-.447,1.35),rr,.01,'Steel','Y',24)
 cyl('Instrument black dial',(xx,-.438,1.35),rr*.81,.007,'Rubber','Y',24)
 rod('Instrument needle',(xx,-.432,1.35),(xx+.017,-.432,1.37),.002,'Lamps',6)
for xx in [-.68,-.42,-.16,.17]:
 box('Dash vent',(xx,-.484,1.31),(.12,.012,.075),'Weapon',.006)
 for z in [1.29,1.31,1.33]:box('Vent vane',(xx,-.474,z),(.10,.008,.004),'Steel',.001)
rod('Steering column',(.57,-.61,1.14),(.57,-.315,1.30),.027,'Weapon')
G='SteeringWheel';torus('Steering rim',(.57,-.303,1.31),.163,.019,'Rubber','Y',48,10)
cyl('Steering hub',(.57,-.303,1.31),.044,.05,'Weapon','Y',24)
for a in [pi/2,pi/2+2*pi/3,pi/2+4*pi/3]:rod('Wheel spoke',(.57,-.30,1.31),(.57+.145*cos(a),-.30,1.31+.145*sin(a)),.011,'Weapon')
G='Body'
for x,y in [(.15,-.30),(.27,-.12)]:
 rod('Gear lever',(x,y,1.08),(x,y-.07,1.29),.011,'Steel');cyl('Gear knob',(x,y-.07,1.30),.026,.04,'Rubber','Z',16,.008)
box('Radio',(0,-.53,1.48),(.34,.18,.18),'Weapon',.012)
for xx in [-.10,.10]:cyl('Radio knob',(xx,-.419,1.48),.022,.028,'Rubber','Y',16)
# Turret ring and open topped three-sided ballistic shield.
G='Turret';ring('Bearing race',(0,.22,2.015),.60,.476,.062,'Weapon')
ring('Gunner ring',(0,.22,2.062),.57,.48,.05,'Paint')
for i in range(16):a=i*2*pi/16;bolt((.545*cos(a),.22+.545*sin(a),2.093),'Z',.011,'Steel')
for s in [-1,1]:
 prism('Turret side shield',[(-.32,2.13),(-.47,2.57),(.77,2.57),(.70,2.13)],0,min(s*.49,s*.535),max(s*.49,s*.535),'Paint',.012)
 for y in [-.34,.10,.62]:
  for z in [2.18,2.52]:bolt((s*.542,y,z),'X',.009,'Steel')
box('Turret rear shield',(0,.748,2.35),(1.02,.045,.43),'Paint',.011)
# Forward shield divided around gun slot, sloped to deflect rounds.
for s in [-1,1]:
 prism('Forward ballistic shield',[(s*.085,2.35),(s*.085,2.60),(s*.48,2.60),(s*.56,2.16),(s*.14,2.16)],1,-.438,-.393,'Paint',.010)
for shield in [o for o in groups['Turret'] if o.name.startswith('Forward ballistic shield')]:
 for v in shield.data.vertices:v.co.y += (v.co.z-2.16)*.18
box('Shield lower bridge',(0,-.416,2.18),(.38,.048,.07),'Paint',.008)
cyl('Weapon pedestal',(0,-.19,2.22),.053,.28,'Weapon','Z',24)
box('Elevation fork',(0,-.20,2.347),(.20,.13,.105),'Weapon',.011)
G='MG';pivot=(0,-.20,2.398)
box('M2 receiver',(0,-.18,2.422),(.142,.49,.116),'Weapon',.012)
box('Receiver top cover',(0,-.19,2.491),(.159,.34,.034),'Weapon',.006)
box('Feed tray',(.092,-.19,2.437),(.066,.20,.060),'Steel',.006)
cyl('Barrel jacket',(0,-.554,2.447),.041,.27,'Weapon','Y',32,.004)
cyl('Heavy barrel',(0,-1.019,2.447),.021,.68,'Weapon','Y',32,.002)
cyl('Muzzle collar',(0,-1.374,2.447),.028,.065,'Weapon','Y',24,.002)
cyl('Muzzle bore',(0,-1.409,2.447),.014,.003,'Rubber','Y',24,0)
for yy in [-.48,-.54,-.60]:
 for s in [-1,1]:cyl('Jacket cooling port',(s*.040,yy,2.448),.012,.004,'Rubber','X',12,0)
box('Rear sight',(0,-.06,2.524),(.046,.035,.049),'Weapon',.005)
box('Front sight',(0,-.664,2.483),(.020,.032,.073),'Weapon',.003)
for s in [-1,1]:
 rod('Spade grip support',(s*.035,.09,2.41),(s*.11,.15,2.41),.011,'Weapon')
 cyl('Spade handle',(s*.11,.15,2.424),.017,.11,'Rubber','Z',16,.006)
box('Ammo box mount',(.27,-.13,2.337),(.33,.20,.040),'Weapon',.006)
box('Ammunition box',(.31,-.13,2.442),(.21,.28,.19),'Weapon',.009)
box('Ammo lid',(.31,-.13,2.541),(.23,.30,.027),'Weapon',.005)
for i in range(7):cyl('Linked cartridge',(.09+i*.021,-.20,2.477),.008,.085,'Steel','Y',10,.001)
G='Body'
print('GEOMETRY COMPLETE',flush=True)
# Consolidate rigid assemblies; transforms are baked, origins at game pivots.
origins={'Body':(0,0,0),'Turret':(0,.22,2.02),'MG':pivot,'SteeringWheel':(.57,-.303,1.31)}
for s,l in [(1,'L'),(-1,'R')]:
 for y,e in [(-1.65,'F'),(1.65,'R')]:origins['Wheel_'+e+l]=(s*.96,y,.49)
objects={}
for name,obs in groups.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 sc.cursor.location=origins[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 # Triangulate now, remove any geometrically zero-area slivers.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bad=[f for f in bm.faces if f.calc_area()<1e-12]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free();objects[name]=o
# Collapse only when necessary: target the top of the hero-asset budget.
count=sum(len(o.data.polygons) for o in objects.values())
if count>108000:
 ratio=104000/count
 for o in objects.values():
  bpy.context.view_layer.objects.active=o
  dec=o.modifiers.new('Game topology budget','DECIMATE');dec.ratio=ratio;dec.use_collapse_triangulate=True
  bpy.ops.object.modifier_apply(modifier=dec.name)
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
  bad=[f for f in bm.faces if f.calc_area()<1e-12]
  if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
  bm.to_mesh(o.data);bm.free()
for name,o in objects.items():
 if name.startswith('Wheel_'):
  for v in o.data.vertices:
   if v.co.z+o.location.z<0:v.co.z=-o.location.z
mg=objects['MG'];mw=mg.matrix_world.copy();mg.parent=objects['Turret'];mg.matrix_world=mw
# Empty attachment nodes, local Blender coordinates converted by exporter.
sockets={'Seat_Driver':(.65,-.27,1.08),'Door_L':(1.04,-.76,1.13),'Door_R':(-1.04,-.76,1.13),'Exhaust':(-.70,2.26,.53),'Light_Head_L':(.674,-2.397,1.075),'Light_Head_R':(-.674,-2.397,1.075),'Light_Tail_L':(.85,2.411,1.09),'Light_Tail_R':(-.85,2.411,1.09),'Troop_1':(-.65,-.28,1.08),'Troop_2':(.65,.66,1.08),'Troop_3':(-.65,.66,1.08),'Muzzle':(0,-1.411,2.447)}
for name,loc in sockets.items():
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=loc
 parent=mg if name=='Muzzle' else objects['Body'];o.parent=parent;o.location=parent.matrix_world.inverted() @ Vector(loc);objects[name]=o
# Lamp atlas: same one material, white headlights / amber turns / red tail lenses.
# UV positions decide lens colour, keeping a single glTF Lamps material.
im=bpy.data.images.new('Lamps_base',width=128,height=128,alpha=False)
a=np.ones((128,128,4),dtype=np.float32);a[:,:,:3]=(.68,.72,.75);a[:,43:85,:3]=(1,.30,.015);a[:,85:,:3]=(.48,.009,.004);im.pixels.foreach_set(a.ravel());im.filepath_raw=os.path.join(TEX,'Lamps_base.png');im.file_format='PNG';im.save()
nt=M['Lamps'].node_tree;bs=nt.nodes.get('Principled BSDF');it=node(nt,'ShaderNodeTexImage');it.image=im;link(nt,it.outputs[0],bs.inputs['Base Color']);link(nt,it.outputs[0],bs.inputs['Emission Color'])
body=objects['Body'];body.data.update();uv=body.data.uv_layers.active
for p in body.data.polygons:
 if body.data.materials[p.material_index].name=='Lamps':
  c=body.matrix_world@p.center
  u=.83 if c.y>2 else (.50 if abs(c.x)>.82 else .16)
  for li in p.loop_indices:uv.data[li].uv=(u,.5)
# Flatten the two optical materials through the same Cycles bake path.
# These have no photographic macrotexture; lens relief is actual geometry.
for special in ['Glass','Lamps']:
 m=M[special];nt=m.node_tree;bs=nt.nodes.get('Principled BSDF');out=nt.nodes.get('Material Output')
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-10));pl=bpy.context.object;pl.data.materials.append(m)
 source=bs.inputs['Base Color'].links[0].from_socket if bs.inputs['Base Color'].is_linked else None
 em=node(nt,'ShaderNodeEmission')
 if source:link(nt,source,em.inputs['Color'])
 else:em.inputs['Color'].default_value=bs.inputs['Base Color'].default_value
 dst=node(nt,'ShaderNodeTexImage');results={}
 for suffix,kind in [('base','EMIT'),('normal','NORMAL'),('ORM','EMIT')]:
  im=bpy.data.images.new(special+'_'+suffix+'_baked',width=128,height=128,alpha=False)
  if suffix!='base':im.colorspace_settings.name='Non-Color'
  dst.image=im;nt.nodes.active=dst
  if suffix=='normal':link(nt,bs.outputs[0],out.inputs[0])
  else:
   if suffix=='ORM':
    for lk in list(em.inputs['Color'].links):nt.links.remove(lk)
    em.inputs['Color'].default_value=(1,bs.inputs['Roughness'].default_value,0,1)
   link(nt,em.outputs[0],out.inputs[0])
  bpy.ops.object.bake(type=kind);im.filepath_raw=os.path.join(TEX,special+'_'+suffix+'.png');im.file_format='PNG';im.save();results[suffix]=im
 bpy.data.objects.remove(pl,do_unlink=True);nt.nodes.remove(em);nt.nodes.remove(dst);link(nt,bs.outputs[0],out.inputs[0])
 it=node(nt,'ShaderNodeTexImage');it.image=results['base'];link(nt,it.outputs[0],bs.inputs['Base Color'])
 if special=='Lamps':link(nt,it.outputs[0],bs.inputs['Emission Color'])
 nn=node(nt,'ShaderNodeTexImage');nn.image=results['normal'];nm=node(nt,'ShaderNodeNormalMap');link(nt,nn.outputs[0],nm.inputs['Color']);link(nt,nm.outputs[0],bs.inputs['Normal'])
 oi=node(nt,'ShaderNodeTexImage');oi.image=results['ORM'];sep=node(nt,'ShaderNodeSeparateColor');link(nt,oi.outputs[0],sep.inputs[0]);link(nt,sep.outputs['Green'],bs.inputs['Roughness']);link(nt,sep.outputs['Blue'],bs.inputs['Metallic'])

paints=[{'name':'Desert tan','linear_rgb':[.45,.36,.22]},{'name':'Olive drab','linear_rgb':[.16,.18,.095]},{'name':'NATO green','linear_rgb':[.085,.13,.075]},{'name':'Field grey','linear_rgb':[.20,.235,.21]},{'name':'Winter white','linear_rgb':[.78,.80,.77]},{'name':'Dark green-black','linear_rgb':[.035,.050,.033]}]
def paint_set(c):
 for n in M['Paint'].node_tree.nodes:
  if n.type=='MIX_RGB':n.inputs[2].default_value=(*c,1)
paint_set(paints[0]['linear_rgb'])
# Save node manifest in glTF coordinates, including transformed world bounds.
bpy.context.view_layer.update()
def game(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
pts=[o.matrix_world@v.co for o in objects.values() if o.type=='MESH' for v in o.data.vertices]
lo=[min(game(v)[i] for v in pts) for i in range(3)];hi=[max(game(v)[i] for v in pts) for i in range(3)]
tri=sum(len(o.data.polygons) for o in objects.values() if o.type=='MESH')
manifest={'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','left':'+X','bounds':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'triangles':tri,'paints':paints,'nodes':[{'name':n,'type':'mesh' if o.type=='MESH' else 'empty','world_position':game(o.matrix_world.translation),'parent':o.parent.name if o.parent else None} for n,o in objects.items()]}
with open(os.path.join(ROOT,'nodes.json'),'w') as f:json.dump(manifest,f,indent=2)
bpy.ops.object.select_all(action='DESELECT')
for o in objects.values():o.select_set(True)
bpy.ops.export_scene.gltf(filepath=os.path.join(ROOT,'humvee-armored.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_cameras=False,export_lights=False,export_image_format='AUTO',export_materials='EXPORT')
# Preserve the neutral image and serialize the engine recolour factor explicitly.
import struct
path=os.path.join(ROOT,'humvee-armored.glb')
raw=open(path,'rb').read();jlen=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jlen]);rest=raw[20+jlen:]
for material in doc['materials']:
 if material.get('name')=='Paint':material['pbrMetallicRoughness']['baseColorFactor']=[.45,.36,.22,1.0]
payload=json.dumps(doc,separators=(',',':')).encode();payload+=b' '*((-len(payload))%4)
with open(path,'wb') as f:f.write(struct.pack('<4sII',b'glTF',2,20+len(payload)+len(rest))+struct.pack('<I4s',len(payload),b'JSON')+payload+rest)
print('EXPORTED',tri,'triangles',manifest['bounds'],flush=True)
# Neutral daylight photography stage; never exported.
groundmat=simple('Stage',(.19,.205,.215),.88)
G='STAGE';box('Ground',(0,0,-.045),(200,200,.075),'Stage',.0)
world=bpy.data.worlds.new('Soft daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.66,.73,.85,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
def area(name,loc,power,size,color):
 d=bpy.data.lights.new(name,'AREA');o=bpy.data.objects.new(name,d);bpy.context.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler();d.energy=power;d.shape='DISK';d.size=size;d.color=color
area('Daylight key',(4,-5,8),1800,5,(1,.94,.84));area('Sky fill',(-5,-1,5),1100,5,(.75,.84,1));area('Rear bounce',(2,5,6),1400,4,(1,.97,.90))
camera=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camera);bpy.context.collection.objects.link(cam);sc.camera=cam
ctr=Vector((0,0,1.25));radius=max((p-ctr).length for p in pts)
cam.data.type='PERSP';cam.data.lens=52;cam.data.ortho_scale=7.5
view=Vector((6,-8,3.3)).normalized();cam.location=ctr+view*(radius*4.65);cam.rotation_euler=(ctr-cam.location).to_track_quat('-Z','Y').to_euler()
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.render.image_settings.file_format='PNG';sc.cycles.samples=32
sc.render.filepath=os.path.join(ROOT,'final.png');bpy.ops.render.render(write_still=True)
# Plain grey silhouette and manufacture view.
clay=simple('Clay',(.35,.35,.35),.74)
sc.view_layers[0].material_override=clay;sc.render.filepath=os.path.join(ROOT,'clay.png');sc.cycles.samples=16;bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
saved=cam.matrix_world.copy();cam.data.type='ORTHO';cam.location=(10,0,1.50);cam.rotation_euler=(Vector((0,0,1.30))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=6.1
sc.render.filepath=os.path.join(ROOT,'side.png');bpy.ops.render.render(write_still=True)
cam.matrix_world=saved;cam.data.type='PERSP';cam.data.ortho_scale=7.5
# Six same-camera colour variants, combined with Blender image pixels.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
atlas=np.ones((720,1920,4),np.float32)
for i,p in enumerate(paints):
 paint_set(p['linear_rgb']);sc.render.filepath=os.path.join(TEX,f'paint_{i}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(sc.render.filepath,check_existing=False);data=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4)
 row=1-i//3;col=i%3;atlas[row*360:(row+1)*360,col*640:(col+1)*640]=data;bpy.data.images.remove(im)
im=bpy.data.images.new('Six paints',width=1920,height=720,alpha=True);im.pixels.foreach_set(atlas.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
paint_set(paints[0]['linear_rgb'])
notes=["M1114-style up-armoured HMMWV: open shielded turret, M2-style MG, four-seat cab and articulated game nodes; metres, +Z front, +Y up.",f"Triangles: {tri:,}; GLB: {os.path.getsize(os.path.join(ROOT,'humvee-armored.glb'))/1048576:.2f} MiB.","Materials: Paint, Rubber, Steel, Weapon, Interior, Canvas, Glass (BLEND), Lamps (emissive).", "Textures: Cycles-baked 1024 base, 512/1024 normal, 256/512 ORM; 128 optical maps; shared tile and panel-trim UVs.","Poly Haven CC0 IDs: rusty_metal_03, rubberized_track, fabric_leather_02; https://polyhaven.com/license ; sources downloaded from dl.polyhaven.org.","Differences: default desert tan rather than the sheet's olive; small fittings and interior reconstructed; rear cargo and M2 mechanisms simplified."]
with open(os.path.join(ROOT,'NOTES.md'),'w') as f:f.write('\n'.join(notes)+'\n')
print('BUILD_COMPLETE',flush=True)
