"""Open-top modern military patrol 4x4. Clean-run Blender 5.2 builder.
Run: blender -b --python-exit-code 1 --python build_patrol-4x4.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
print('PATROL_FINAL_REVISION',flush=True)
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','denim_fabric']
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=.82,.99
 elif name=='Rubber':lo,hi=.016,.062
 elif name=='Weapon':lo,hi=.035,.10
 elif name=='Steel':lo,hi=.065,.19
 else:lo,hi=.13,.30
 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=.035 if name=='Paint' else .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=.055 if name=='Paint' else .23;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=.034;grime.inputs['To Min'].default_value=.63;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
  # Restricted lower-edge road dust, plus tiny irregular exposed edge chips.
  dust=node(nt,'ShaderNodeMapRange');dust.inputs['From Min'].default_value=.01;dust.inputs['From Max'].default_value=.23;dust.inputs['To Min'].default_value=.78;dust.inputs['To Max'].default_value=1;link(nt,sepUV.outputs['Y'],dust.inputs['Value'])
  dm=node(nt,'ShaderNodeMixRGB');dm.blend_type='MULTIPLY';dm.inputs[0].default_value=1;link(nt,smix.outputs[0],dm.inputs[1]);link(nt,dust.outputs[0],dm.inputs[2]);smix=dm
  emask=mathnode('LESS_THAN',edge,.009);speck=mathnode('GREATER_THAN',noise.outputs['Fac'],.56);mask=mathnode('MULTIPLY',emask,speck)
  chips=node(nt,'ShaderNodeMixRGB');chips.blend_type='MIX';link(nt,mask,chips.inputs[0]);link(nt,smix.outputs[0],chips.inputs[1]);chips.inputs[2].default_value=(.91,.91,.91,1);smix=chips
 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=(.08,.12,.07,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,.045),('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','rusty_metal_03',(.68,.75,.43),.78,.06,.10),('Canvas','denim_fabric',(.40,.45,.28),.94,0,.30)]: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',(.42,.46,.49),.14,1,.035)
# 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 name in ['Seat cushion','Seat back','Head cushion']:
  sub=o.modifiers.new('Canvas cushion softness','SUBSURF');sub.levels=2;sub.render_levels=2
  bpy.ops.object.modifier_apply(modifier=sub.name)
  for v in o.data.vertices:
   q=v.co
   # Small seam tension wrinkles, with broad padded curvature from subdivision.
   v.co += v.normal*(.0014*sin(q.x*75+q.z*23)*sin(q.y*42+q.z*37))
   if name=='Seat cushion' and q.z>0:
    v.co.z-=.016*math.exp(-((q.x/.15)**2+(q.y/.16)**2))
   if name=='Seat back' and q.y<0:
    fold=.008*math.exp(-((q.z+.10-q.x*.37)/.035)**2)-.006*math.exp(-((q.z-.08+q.x*.30)/.04)**2)
    v.co.y+=fold*math.exp(-(q.x/.22)**4)
  smooth=True
 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))
 if name in ['Pressed bonnet','Front wing','Rear quarter','Seat cushion','Seat back','Head cushion']:
  vg=o.vertex_groups.new(name='Preserve sculpted surfaces');vg.add(list(range(len(o.data.vertices))),1.0,'REPLACE')
 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,3 if name in ['Front wing','Rear quarter','Pressed bonnet'] else 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):
 # Continuous swept round tube; bevelled polyline corners with short interpolated arcs.
 path=[]
 for i,p in enumerate(pts):
  p=Vector(p)
  if i==0 or i==len(pts)-1:path.append(p);continue
  a=Vector(pts[i-1]);b=Vector(pts[i+1]);d=min((p-a).length,(b-p).length,r*3)*.40
  aa=p+(a-p).normalized()*d;bb=p+(b-p).normalized()*d
  for j in range(5):
   t=j/4;path.append((1-t)**2*aa+2*t*(1-t)*p+t*t*bb)
 vs=[]
 for i,p in enumerate(path):
  tangent=(path[min(i+1,len(path)-1)]-path[max(0,i-1)]).normalized()
  u=tangent.cross(Vector((0,0,1)))
  if u.length<.1:u=tangent.cross(Vector((0,1,0)))
  u.normalize();v=tangent.cross(u).normalized()
  vs.extend([tuple(p+r*(cos(j*2*pi/n)*u+sin(j*2*pi/n)*v)) for j in range(n)])
 fs=[tuple(range(n-1,-1,-1)),tuple(range((len(path)-1)*n,len(path)*n))]
 for k in range(len(path)-1):
  for j in range(n):fs.append((k*n+j,k*n+(j+1)%n,(k+1)*n+(j+1)%n,(k+1)*n+j))
 o=mesh(name,vs,fs,mat,0)
 for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
 uv=o.data.uv_layers.active;lengths=[0.0]
 for k in range(1,len(path)):lengths.append(lengths[-1]+(path[k]-path[k-1]).length)
 for poly in o.data.polygons:
  if len(poly.vertices)!=4:continue
  for li in poly.loop_indices:
   vi=o.data.loops[li].vertex_index;k,j=divmod(vi,n)
   uv.data[li].uv=(.13+lengths[k]*.60,.35+j/n*.20)
 return o
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)
# Ladder chassis, crossmembers, differential housings and live-axle suspension.
box('Pressed floor pan',(0,.12,.57),(1.52,4.03,.105),'Paint',.015)
for x in [-.52,.52]:box('Chassis rail',(x,.02,.475),(.095,3.84,.11),'Weapon',.01)
for y in [-1.84,-1.05,.1,1.05,1.88]:box('Chassis crossmember',(0,y,.475),(1.12,.085,.095),'Weapon')
for y in [-1.395,1.395]:
 cyl('Differential',(0,y,.459),.115,.27,'Weapon','X',32,.012)
 cyl('Axle',(0,y,.459),.045,1.43,'Steel','X',24)
 for s in [-1,1]:
  for k in range(4):
   tube('Leaf spring',[(s*.48,y-.49,.42+k*.011),(s*.48,y-.22,.32+k*.011),(s*.48,y+.22,.32+k*.011),(s*.48,y+.49,.42+k*.011)],.013,'Weapon',8)
  rod('Shock absorber',(s*.58,y-.06,.37),(s*.60,y+.19,.74),.025,'Steel')
  for yy in [y-.37,y+.37]:box('Spring hanger',(s*.50,yy,.40),(.07,.09,.20),'Weapon',.007)
box('Fuel tank',(0,.83,.38),(.65,.70,.21),'Weapon',.04)
tube('Exhaust', [(-.35,-.65,.33),(-.40,.6,.31),(-.52,1.60,.34),(-.64,2.19,.40)],.024,'Steel',16)
cyl('Muffler',(-.42,.47,.32),.084,.46,'Steel','Y',24)
# 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.395,'F'),(1.395,'R')]:
  G='Wheel_'+end+label;xc=s*.729;zc=.459
  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)
  # Tapered shoulder blocks wrap onto the sidewall, with staggered radial ribs.
  for ss in [-1,1]:
   for i in range(40):
    a=2*pi*(i+.25*(ss+1))/40
    pts=[]
    for xx,rr,da in [(.166,.365,-.018),(.155,.407,0),(.119,.454,.019)]:
     pts.append((xc+ss*xx,y+rr*sin(a+da),zc+rr*cos(a+da)))
    tube('Tread shoulder rib',pts,.0075,'Rubber',6)
  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')

  for ob in groups[G]:
   for v in ob.data.vertices:
    world=ob.matrix_world@v.co
    world=Vector((xc+(world.x-xc)*.938,y+(world.y-y)*.9342,zc+(world.z-zc)*.9342))
    v.co=ob.matrix_world.inverted()@world
G='Body'
# Open tub: shaped sills trace the low door openings and climb over rear arches.
for s in [-1,1]:
 lo,hi=sorted([s*.751,s*.805])
 prism('Open cabin sill',[(-.85,.57),(-.70,.52),(.65,.52),(.98,.67),(.98,1.03),(.82,1.18),(.66,.78),(.48,.72),(-.47,.72),(-.64,.82),(-.66,1.13),(-.85,1.16)],0,lo,hi,'Paint',.014)
 # The wheel arches are cut into the sheet metal as a continuous boundary.
 for yy,front in [(-1.395,True),(1.395,False)]:
  r=.532;z=.478
  # front and rear vertical extremities blend into broad folded wings
  ya=-2.14 if front else .79;yb=-.77 if front else 2.17
  outline=[(ya,.68),(ya,1.10),(ya+.13,1.19),(yb-.10,1.22),(yb,1.15),(yb,.68),(yy+r,.68)]
  outline += [(yy+r*cos(pi*i/24),z+r*sin(pi*i/24)) for i in range(25)]
  outline += [(yy-r,.68)]
  prism('Front wing' if front else 'Rear quarter',outline,0,min(s*.69,s*.873),max(s*.69,s*.873),'Paint',.012)
  tube('Folded arch edge',[(s*.878,yy+r*cos(pi*i/28),z+r*sin(pi*i/28)) for i in range(29)],.014,'Paint',10)
  # Inner curved wheelhouse, behind the tyre, with depth over the tread.
  vs=[]
  for x in [s*.56,s*.854]:
   vs += [(x,yy+.539*cos(pi*i/24),z+.539*sin(pi*i/24)) for i in range(25)]
  mesh('Wheelhouse liner',vs,[(i,i+1,26+i,25+i) for i in range(24)],'Rubber',0)
  for y in [ya+.08,yb-.08]:
   for zz in [.78,1.10]:bolt((s*.883,y,zz),'X',.007)
 # Ribbed sill and step below the large open entry.
 for yy in [-.53,-.25,.04,.33,.59]:box('Sill pressed stiffener',(s*.81,yy,.632),(.010,.022,.103),'Paint',.005)
 tube('Tub rim',[(s*.816,-.69,1.12),(s*.816,-.63,.82),(s*.816,-.47,.735),(s*.816,.48,.735),(s*.816,.66,.79),(s*.816,.84,1.18)],.014,'Paint',12)
 box('Entry step',(s*.824,-.04,.49),(.13,1.10,.035),'Weapon',.009)
 for yy in [-.50,-.35,-.20,-.05,.10,.25,.40]:box('Step grip',(s*.83,yy,.512),(.11,.022,.013),'Steel',.003)
 # Rear quarter service pressing and fuel filler.
 prism('Quarter reinforcement',[(.85,.97),(.93,1.13),(1.15,1.13),(1.09,1.02)],0,min(s*.876,s*.887),max(s*.876,s*.887),'Paint',.004)
 cyl('Fuel filler flange',(s*.885,1.92,1.015),.063,.014,'Weapon','X',24)
 cyl('Fuel filler cap',(s*.886,1.92,1.015),.045,.013,'Steel','X',24)
 box('Fuel cap handle',(s*.893,1.92,1.015),(.012,.061,.012),'Weapon',.003)
 # Mudflaps with retaining bolt strips.
 box('Rear mudflap',(s*.729,1.94,.47),(.29,.025,.35),'Rubber',.007)
 box('Mudflap retainer',(s*.729,1.965,.62),(.28,.013,.024),'Steel',.003)
 for xx in [s*.729-.10,s*.729+.10]:bolt((xx,1.977,.62),'Y',.007)
# Rear cargo floor and rear bulkhead, recessed tailgate with hinge knuckles.
box('Cargo platform',(0,1.49,.84),(1.39,1.23,.055),'Paint',.009)
box('Rear bulkhead',(0,2.128,.88),(1.54,.065,.51),'Paint',.017)
box('Tailgate seal',(0,2.166,.95),(1.05,.010,.325),'Rubber',.012)
box('Tailgate',(0,2.18,.952),(1.02,.028,.30),'Paint',.014)
for x in [-.40,.40]:
 box('Tailgate hinge',(x,2.202,.804),(.095,.026,.065),'Paint',.004)
 cyl('Tailgate hinge pin',(x,2.218,.796),.012,.122,'Steel','X',16)
 for xx in [x-.025,x+.025]:bolt((xx,2.22,.823),'Y',.005)
box('Tailgate latch',(0,2.202,1.064),(.125,.027,.033),'Weapon',.004)
# Bonnet is a broad continuous pressed shell, tapering down toward the grille.
sections=[(-2.13,1.145,.78),(-1.97,1.20,.79),(-1.02,1.295,.78),(-.74,1.30,.76)]
vs=[]
for yy,zz,w in sections:
 vs += [(-w,yy,zz-.12),(-w,yy,zz-.050),(-w+.15,yy,zz),(w-.15,yy,zz),(w,yy,zz-.050),(w,yy,zz-.12)]
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 bonnet',vs,fs,'Paint',.013)
# Louvered hood intake inset follows the sloped top.
box('Bonnet vent recess',(0,-1.42,1.259),(.53,.55,.012),'Weapon',.012,(.10,0,0))
for i in range(10):box('Bonnet cooling louvre',(0,-1.66+i*.051,1.240+i*.0051),(.52,.014,.016),'Paint',.004,(.10,0,0))
for s in [-1,1]:
 tube('Bonnet edge seam',[(s*.756,-2.10,1.123),(s*.773,-1.93,1.169),(s*.764,-.84,1.261)],.004,'Rubber',8)
 torus('Bonnet lifting eye',(s*.43,-1.00,1.308),.035,.009,'Steel','Y',24,8)
 box('Bonnet catch base',(s*.795,-.91,1.165),(.022,.051,.125),'Weapon',.008,(-.25,0,0))
 rod('Bonnet catch',(s*.814,-.928,1.115),(s*.814,-.896,1.216),.011,'Steel')
 for zz in [1.12,1.21]:bolt((s*.824,-.91,zz),'X',.006)
# Recessed slotted front grille and circular optical units.
box('Front panel',(0,-2.136,.989),(1.57,.053,.305),'Paint',.018)
box('Radiator shadow',(0,-2.169,.983),(.92,.015,.245),'Weapon',.009)
for i in range(9):box('Grille vertical blade',(-.42+i*.105,-2.187,.987),(.046,.032,.245),'Paint',.018,seg=3)
for z in [.854,1.120]:box('Grille surround',(0,-2.182,z),(.95,.03,.024),'Paint',.006)
for s in [-1,1]:
 cyl('Headlamp bezel',(s*.60,-2.179,.993),.108,.045,'Steel','Y',48,.004)
 cyl('Headlamp reflector',(s*.60,-2.203,.993),.087,.012,'Steel','Y',48,.004)
 cyl('Headlamp lens',(s*.60,-2.213,.993),.072,.009,'Lamps','Y',48,.004)
 bpy.ops.mesh.primitive_uv_sphere_add(segments=32,ring_count=16,location=(s*.60,-2.211,.993));lens=bpy.context.object;lens.scale=(.085,.019,.085)
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);finish(lens,'Convex headlamp glass','Glass',0,smooth=True)
 torus('Headlamp reflector ring',(s*.60,-2.218,.993),.074,.004,'Steel','Y',40,8)
 for dx in [-.04,-.02,0,.02,.04]:rod('Lens fluting',(s*.60+dx,-2.219,.933),(s*.60+dx,-2.219,1.053),.0015,'Lamps',6)
 cyl('Front indicator housing',(s*.738,-2.176,1.090),.039,.021,'Weapon','Y',24)
 cyl('Front indicator',(s*.738,-2.19,1.090),.030,.014,'Lamps','Y',24)
 cyl('Rear light housing',(s*.661,2.181,1.017),.063,.025,'Weapon','Y',32)
 cyl('Rear red lens',(s*.661,2.199,1.017),.047,.022,'Lamps','Y',32)
 cyl('Rear lower marker',(s*.689,2.179,.848),.031,.022,'Lamps','Y',24)
# Bumpers determine the 4.60 m overall body length.
box('Front bumper',(0,-2.245,.690),(1.72,.110,.13),'Weapon',.012)
box('Rear bumper',(0,2.245,.625),(1.72,.110,.13),'Weapon',.012)
tube('Brush guard',[(-.82,-2.258,.81),(-.82,-2.258,1.16),(-.76,-2.258,1.205),(.76,-2.258,1.205),(.82,-2.258,1.16),(.82,-2.258,.81),(-.82,-2.258,.81)],.026,'Weapon',16)
for x in [-.43,.43]:rod('Brushguard stay',(x,-2.25,.72),(x,-2.258,1.205),.021,'Weapon',16)
box('Winch cradle',(0,-2.21,.693),(.46,.15,.16),'Weapon',.012)
cyl('Winch spool',(0,-2.245,.701),.045,.28,'Steel','X',32)
for i in range(14):torus('Wound winch cable',(-.124+i*.019,-2.245,.701),.045,.004,'Steel','X',20,6)
for s in [-1,1]:
 box('Tow eye bracket',(s*.59,-2.235,.627),(.07,.075,.095),'Paint',.006)
 torus('Tow shackle',(s*.59,-2.245,.590),.034,.009,'Steel','Y',24,8)
 torus('Rear tow shackle',(s*.55,2.265,.579),.033,.009,'Steel','Y',24,8)
# Angled divided windscreen with thick frame, rubber seals and wipers.
def wind(name,x,z,w,h,mat,depth=.030):
 y=-.733+(z-1.28)*.20
 return box(name,(x,y,z),(w,depth,h),mat,.006,(-.1974,0,0))
for x in [-.373,.373]:
 wind('Windscreen glass',x,1.526,.678,.400,'Glass',.010)
 for xx in [x-.346,x+.346]:wind('Windscreen gasket',xx,1.526,.018,.434,'Rubber')
 for zz in [1.315,1.737]:wind('Glass edge seal',x,zz,.70,.018,'Rubber')
for x in [-.748,0,.748]:wind('Windscreen vertical frame',x,1.527,.042,.494,'Paint',.051)
for z in [1.281,1.774]:wind('Windscreen horizontal frame',0,z,1.534,.050,'Paint',.055)
for s in [-1,1]:
 for z in [1.30,1.75]:
  for x in [s*.10,s*.70]:bolt((x,-.765+(z-1.28)*.2,z),'Y',.006)
 tube('Wiper arm',[(s*.61,-.754,1.316),(s*.49,-.715,1.53),(s*.32,-.687,1.69)],.007,'Weapon',10)
 rod('Wiper blade',(s*.19,-.696,1.67),(s*.49,-.731,1.48),.011,'Rubber',12)
 box('Windscreen hinge',(s*.62,-.755,1.276),(.16,.047,.052),'Paint',.006)
 cyl('Windscreen hinge pin',(s*.62,-.778,1.279),.012,.19,'Steel','X',16)
 # Narrow mirrors keep the complete vehicle within the 1.80 m track envelope.
 tube('Mirror stay',[(s*.751,-.65,1.46),(s*.851,-.76,1.48),(s*.851,-.77,1.58)],.011,'Steel',12)
 box('Mirror case',(s*.855,-.783,1.53),(.073,.062,.205),'Weapon',.024,seg=3)
 box('Mirror glass',(s*.855,-.749,1.53),(.056,.009,.181),'Steel',.014,seg=3)
# Continuous roll cage: three structural hoops, roof rails and diagonal braces.
for yy in [-.57,.43,1.39]:
 tube('Roll cage hoop',[(-.72,yy,.72),(-.72,yy,1.68),(-.67,yy,1.795),(.67,yy,1.795),(.72,yy,1.68),(.72,yy,.72)],.029,'Paint',16)
 for s in [-1,1]:
  box('Cage mounting foot',(s*.72,yy,.741),(.115,.12,.026),'Steel',.005)
  for dy in [-.039,.039]:bolt((s*.72,yy+dy,.76),'Z',.007)
for s in [-1,1]:
 tube('Roof side rail',[(s*.689,-.62,1.79),(s*.689,1.39,1.79),(s*.72,1.69,1.27)],.029,'Paint',16)
 rod('Rear cage brace',(s*.716,1.36,1.60),(s*.71,1.91,1.16),.024,'Paint',16)
 for yy in [-.57,.43,1.39]:cyl('Roll cage collar',(s*.72,yy,.84),.034,.064,'Weapon','Z',16)
# Snorkel follows the cowl and clears the windscreen frame.
tube('Snorkel pipe',[(-.68,-.98,1.09),(-.74,-.86,1.20),(-.79,-.78,1.25),(-.79,-.78,1.76)],.042,'Paint',20)
cyl('Snorkel intake mushroom',(-.79,-.78,1.80),.066,.045,'Paint','Z',32,.010)
cyl('Intake dark opening',(-.79,-.78,1.769),.055,.015,'Rubber','Z',24)
for z in [1.37,1.64]:
 torus('Snorkel clamp',(-.79,-.78,z),.044,.004,'Steel','Z',24,6)
 rod('Snorkel bracket',(-.79,-.78,z),(-.737,-.70,z),.01,'Steel')
# Two rear jerrycan racks and pressed cans with X ribs, caps, carry handles, straps.
for s in [-1,1]:
 for yy in [1.30,1.76]:
  prism('Jerrycan',[(yy-.16,1.195),(yy+.16,1.195),(yy+.16,1.50),(yy+.115,1.563),(yy-.095,1.578),(yy-.16,1.532)],0,s*.66-.10,s*.66+.10,'Interior',.019)
  for face in [-1,1]:
   xx=s*.66+face*.102
   tube('Can stamped panel rim',[(xx,yy-.12,1.235),(xx,yy+.12,1.235),(xx,yy+.12,1.475),(xx,yy+.09,1.51),(xx,yy-.12,1.51),(xx,yy-.12,1.235)],.0035,'Interior',6)
  for ss in [1]:
   tube('Jerrycan pressed X',[(s*.767,yy-.113,1.24),(s*.773,yy+.11,1.51)],.009,'Interior',8)
   tube('Jerrycan pressed X',[(s*.767,yy+.113,1.24),(s*.773,yy-.11,1.51)],.009,'Interior',8)
  for xx in [s*.66-.034,s*.66+.034]:tube('Can carry handle',[(xx,yy-.07,1.56),(xx,yy-.07,1.615),(xx,yy+.06,1.615),(xx,yy+.06,1.56)],.010,'Interior',10)
  cyl('Jerrycan filler',(s*.66,yy-.118,1.585),.025,.014,'Steel','Z',20)
  box('Can tie strap',(s*.783,yy,1.389),(.013,.025,.366),'Weapon',.003)
 box('Rack tray',(s*.66,1.53,1.189),(.33,1.03,.038),'Steel',.008)
 for zz in [1.235,1.468]:rod('Jerrycan rack rail',(s*.82,1.04,zz),(s*.82,2.04,zz),.018,'Steel',12)
 for yy in [1.05,1.52,2.03]:rod('Rack upright',(s*.82,yy,1.19),(s*.82,yy,1.49),.015,'Steel',12)
 # Perforated recovery sand ladders stowed on the quarter: open rungs, raised lips.
 for z in [1.045,1.159]:box('Sand ladder rail',(s*.878,1.46,z),(.023,.94,.019),'Steel',.003)
 for i in range(14):
  yy=1.015+i*.068
  box('Sand ladder rung',(s*.878,yy,1.102),(.022,.026,.10),'Steel',.004)
 for yy in [1.08,1.82]:box('Ladder retaining strap',(s*.8935,yy,1.104),(.013,.030,.16),'Weapon',.003)
 # Flexible tapered whip antenna; full accessory height is documented separately.
 cyl('Antenna mount',(s*.70,2.02,1.24),.035,.13,'Weapon','Z',20)
 for i in range(7):torus('Whip spring',(s*.70,2.02,1.29+i*.012),.018,.0035,'Steel','Z',16,6)
 rod('Antenna lower',(s*.70,2.02,1.36),(s*.70,2.034,1.83),.006,'Weapon',10)
 rod('Antenna tip',(s*.70,2.034,1.83),(s*.70,2.065,1.99985),.003,'Weapon',8)
# Four canvas seats, stitched cushions, belts and a fully readable open cab.
box('Transmission cover',(0,-.03,.739),(.36,1.66,.23),'Interior',.04,seg=3)
for s in [-1,1]:
 for yy in [-.12,.84]:
  box('Seat frame',(s*.425,yy,.70),(.38,.39,.14),'Weapon',.013)
  box('Seat cushion',(s*.425,yy,.811),(.44,.43,.135),'Canvas',.048,seg=4)
  box('Seat back',(s*.425,yy+.19,1.08),(.43,.12,.48),'Canvas',.052,(-.14,0,0),4)
  box('Head cushion',(s*.425,yy+.224,1.365),(.27,.11,.16),'Canvas',.035,seg=3)
  # Soft vertical seams and shallow cushion pleats, carried in geometry.
  tube('Cushion perimeter seam',[(s*.425-.19,yy-.19,.815),(s*.425+.19,yy-.19,.815),(s*.425+.213,yy-.155,.815),(s*.425+.213,yy+.155,.815),(s*.425+.18,yy+.197,.815),(s*.425-.18,yy+.197,.815),(s*.425-.213,yy+.155,.815),(s*.425-.213,yy-.155,.815),(s*.425-.19,yy-.19,.815)],.0025,'Canvas',6)
  pts=[(-.17,.88),(.17,.88),(.193,.92),(.193,1.25),(.165,1.285),(-.165,1.285),(-.193,1.25),(-.193,.92),(-.17,.88)]
  tube('Backrest perimeter seam',[(s*.425+dx,yy+.126+.14*(zz-1.08),zz) for dx,zz in pts],.0025,'Canvas',6)
  for dx in [-.09,.09]:box('Canvas pleat',(s*.425+dx,yy+.123,1.08),(.011,.014,.31),'Canvas',.006,(-.14,0,0))
  tube('Lap belt',[(s*.425-.19,yy-.06,.864),(s*.425,yy-.10,.896),(s*.425+.19,yy-.06,.864)],.016,'Rubber',8)
  box('Seatbelt buckle',(s*.425+.055,yy-.095,.897),(.054,.039,.019),'Steel',.004)
box('Dash panel',(0,-.565,1.172),(1.39,.26,.21),'Interior',.026,seg=3)
box('Instrument binnacle',(.407,-.413,1.225),(.44,.054,.19),'Weapon',.018)
for xx,rr in [(.29,.042),(.41,.053),(.54,.030)]:
 cyl('Instrument rim',(xx,-.379,1.235),rr,.012,'Steel','Y',32)
 cyl('Instrument dial',(xx,-.369,1.235),rr*.83,.008,'Rubber','Y',32)
 for i in range(10):
  a=2*pi*i/12;rod('Gauge graduation',(xx+rr*.62*sin(a),-.363,1.235+rr*.62*cos(a)),(xx+rr*.75*sin(a),-.363,1.235+rr*.75*cos(a)),.0012,'Lamps',6)
 rod('Gauge needle',(xx,-.36,1.235),(xx+rr*.5,-.36,1.235+rr*.3),.0018,'Lamps',6)
for xx in [-.48,-.20,.07]:
 box('Dash air vent',(xx,-.422,1.207),(.13,.015,.06),'Weapon',.006)
 for z in [1.19,1.208,1.225]:box('Dash vent vane',(xx,-.410,z),(.115,.010,.003),'Steel',.001)
for xx in [-.1,0,.1]:cyl('Control knob',(xx,-.419,1.136),.013,.024,'Rubber','Y',16,.003)
box('Glovebox lid',(-.44,-.419,1.13),(.38,.024,.075),'Interior',.005)
rod('Glovebox pull',(-.50,-.439,1.14),(-.39,-.439,1.14),.007,'Steel',10)
rod('Steering column',(.415,-.53,1.08),(.415,-.21,1.208),.024,'Weapon')
G='SteeringWheel';steer=(.415,-.205,1.214)
torus('Steering rim',steer,.155,.016,'Rubber','Y',48,10)
cyl('Steering centre',steer,.040,.04,'Weapon','Y',24)
for a in [pi/2,pi/2+2*pi/3,pi/2+4*pi/3]:rod('Steering spoke',steer,(.415+.14*cos(a),-.205,1.214+.14*sin(a)),.010,'Weapon',12)
G='Body'
for x,y in [(0,-.16),(.13,.04)]:
 rod('Gear lever',(x,y,.85),(x,y-.09,1.055),.008,'Steel',12)
 cyl('Gear knob',(x,y-.09,1.064),.021,.034,'Rubber','Z',20,.010)
for x in [.34,.46]:box('Pedal',(x,-.57,.71),(.068,.10,.018),'Rubber',.004,(.35,0,0))
box('Radio',(0,-.50,1.346),(.25,.17,.13),'Weapon',.011)
for x in [-.075,.075]:cyl('Radio selector',(x,-.403,1.352),.016,.018,'Rubber','Y',16)
# Pintle gun ring, braced to the roof structure; MG pivot separates from mount.
for x in [-.42,.42]:rod('Gun ring support',(x,-.35,1.797),(x,.63,1.797),.023,'Steel',16)
ring('Gun traverse ring',(0,.10,1.833),.455,.390,.035,'Steel',64)
for i in range(12):
 a=2*pi*i/12;bolt((.421*cos(a),.10+.421*sin(a),1.857),'Z',.007)
cyl('Pintle socket',(0,-.23,1.872),.046,.084,'Weapon','Z',24)
rod('Pintle post',(0,-.23,1.86),(0,-.23,1.884),.027,'Steel',20)
for s in [-1,1]:box('Pintle fork',(s*.056,-.23,1.888),(.023,.078,.058),'Weapon',.006)
G='MG';pivot=(0,-.23,2.034)
box('Machine gun receiver',(0,-.155,2.052),(.11,.395,.097),'Weapon',.007)
box('Gun top cover',(0,-.155,2.109),(.121,.34,.028),'Weapon',.005)
cyl('Barrel shroud',(0,-.457,2.068),.032,.215,'Weapon','Y',28,.003)
cyl('Gun barrel',(0,-.772,2.068),.017,.43,'Weapon','Y',24,.002)
cyl('Flash suppressor',(0,-1.010,2.068),.022,.060,'Weapon','Y',24,.002)
cyl('Muzzle opening',(0,-1.041,2.068),.011,.003,'Rubber','Y',20,0)
for yy in [-.392,-.439,-.486,-.533]:
 for s in [-1,1]:cyl('Shroud port',(s*.031,yy,2.07),.008,.003,'Rubber','X',12,0)
for yy in [-.08,-.51]:
 box('Gun sight',(0,yy,2.139),(.017,.025,.047),'Weapon',.003)
 box('Sight pedestal',(0,yy,2.099),(.022,.03,.065),'Weapon',.003)
box('Feed tray',(.077,-.16,2.069),(.062,.142,.04),'Steel',.004)
box('Ammo can',(.223,-.12,2.005),(.19,.235,.184),'Interior',.010)
box('Ammo can lid',(.223,-.12,2.103),(.207,.25,.018),'Steel',.004)
rod('Ammo support',(.06,-.20,1.958),(.22,-.12,1.915),.012,'Steel')
for i in range(6):cyl('Belt cartridge',(.072+i*.02,-.15,2.105),.006,.070,'Steel','Y',10,.001)
for s in [-1,1]:
 rod('Rear grip support',(s*.035,.054,2.039),(s*.088,.095,2.035),.010,'Weapon')
 cyl('Spade grip',(s*.088,.095,2.034),.013,.082,'Rubber','Z',16,.005)
rod('Charging handle',(-.054,-.13,2.051),(-.103,-.13,2.051),.007,'Steel')
# Mounted compactly inside the strict 2 m game silhouette.
bpy.context.view_layer.update()
for ob in groups['MG']:ob.location.z-=.15
pivot=(pivot[0],pivot[1],pivot[2]-.15)
G='Body'
print('GEOMETRY COMPLETE',flush=True)
# Consolidate rigid assemblies; transforms are baked, origins at game pivots.
origins={'Body':(0,0,0),'MG':pivot,'SteeringWheel':steer}
for s,l in [(1,'L'),(-1,'R')]:
 for y,e in [(-1.395,'F'),(1.395,'R')]:origins['Wheel_'+e+l]=(s*.729,y,.459)
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());print('PRE_BUDGET_TRIANGLES',count,flush=True)
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
  if o.vertex_groups.get('Preserve sculpted surfaces'):
   dec.vertex_group='Preserve sculpted surfaces';dec.invert_vertex_group=True;dec.vertex_group_factor=1000
  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()
# Restore exact silhouette extrema after budget decimation without moving pivots.
xextent=max(abs(v.co.x+o.location.x) for o in objects.values() for v in o.data.vertices)
for o in [objects['Body']]:
 for v in o.data.vertices:v.co.x=(v.co.x+o.location.x)*(.9/xextent)-o.location.x
for name,o in objects.items():
 if name.startswith('Wheel_'):
  bottom=min(v.co.z for v in o.data.vertices)
  zscale=o.location.z/(-bottom)
  for v in o.data.vertices:v.co.z*=zscale
  for v in o.data.vertices:
   if v.co.z+o.location.z<0:v.co.z=-o.location.z
top=max(v.co.z+o.location.z for o in objects.values() for v in o.data.vertices)
for o in objects.values():
 for v in o.data.vertices:
  wz=v.co.z+o.location.z
  if wz>1.99:v.co.z += (2.0-top)*((wz-1.99)/(top-1.99))
mg=objects['MG']
# Empty attachment nodes, local Blender coordinates converted by exporter.
sockets={'Seat_Driver':(.425,-.12,.89),'Door_L':(.81,-.60,1.03),'Door_R':(-.81,-.60,1.03),'Exhaust':(-.64,2.19,.40),'Light_Head_L':(.60,-2.22,.993),'Light_Head_R':(-.60,-2.22,.993),'Light_Tail_L':(.661,2.22,1.017),'Light_Tail_R':(-.661,2.22,1.017),'Troop_1':(-.425,-.12,.89),'Troop_2':(.425,.84,.89),'Troop_3':(-.425,.84,.89),'Muzzle':(0,-1.043,1.918)}
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]=(.42,.46,.49);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)>.70 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':'NATO green','linear_rgb':[.08,.12,.07]},{'name':'Olive drab','linear_rgb':[.16,.18,.095]},{'name':'Desert tan','linear_rgb':[.45,.36,.22]},{'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,'patrol-4x4.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,'patrol-4x4.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']=[.08,.12,.07,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';bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.005));bpy.context.object.name='Ground';bpy.context.object.data.materials.append(groundmat)
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.08));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.6)).normalized();cam.location=ctr+view*(radius*4.2);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.0);cam.rotation_euler=(Vector((0,0,1.0))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=5.5
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=["Modern open-top patrol 4x4: roll cage, pintle MG, four canvas seats, jerrycans, sand ladders, twin whips, snorkel and game nodes; metres, +Z front, +Y up.",f"Triangles: {tri:,}; GLB: {os.path.getsize(os.path.join(ROOT,'patrol-4x4.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, denim_fabric; https://polyhaven.com/license ; sources downloaded from dl.polyhaven.org.","Differences: NATO green default versus tan reference, cleaner wear and simplified seat/gun detailing; whips shortened and pintle lowered for the strict 2.00 m overall height."]
with open(os.path.join(ROOT,'NOTES.md'),'w') as f:f.write('\n'.join(notes)+'\n')
print('BUILD_COMPLETE',flush=True)
