"""HMMWV troop/cargo. Clean deterministic Blender 5.2 build.
Coordinates in authoring: X left, -Y forward, Z up; glTF +Y up/+Z front.
CC0 textures downloaded from Poly Haven; tile composites baked in Cycles.
Scope: revise v1 Humvee proportions, retain Garage v2 articulation and paint contract.
Views: front 3/4 hero, orthographic side/front, clay hero and six-paint contact sheet.
Budget: 50-110k triangles, <=8 materials, <=12 MiB GLB, 1024px base/normal,
512px ORM. Shared tiles and 22 material primitives; maximum three revisions.
Run: blender -b --python-exit-code 1 --python build_humvee-troop.py
"""
import bpy, bmesh, math, os, json, struct, urllib.request, random, sys, time
import numpy as np
from mathutils import Vector, Matrix
from pathlib import Path
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
random.seed(29)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
SC=bpy.context.scene; SC.unit_settings.system='METRIC'; SC.unit_settings.scale_length=1
SC.render.engine='CYCLES'; SC.cycles.samples=16; SC.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
for backend in ['OPTIX','CUDA']:
 try:
  prefs.compute_device_type=backend; prefs.get_devices()
  found=False
  for d in prefs.devices:
   d.use=d.type!='CPU'; found=found or d.use
  if found: break
 except Exception: pass
assert found, 'Cycles GPU required'
SC.cycles.device='GPU'; SC.render.bake.margin=8
SC.view_settings.view_transform='AgX'
PAINTS=[('Olive drab',[.10,.12,.06]),('NATO green',[.065,.10,.065]),('Desert tan',[.44,.32,.18]),('Field grey',[.19,.22,.19]),('Winter white',[.78,.80,.75]),('Dark green-black',[.023,.032,.025])]
MATS=[]; BAKED={}
def download(id,mp):
 p=CACHE/f'{id}_{mp}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg'
  req=urllib.request.Request(url,headers={'User-Agent':'ForgeAssetBuilder/1.0'})
  with urllib.request.urlopen(req,timeout=60) as r: p.write_bytes(r.read())
 return p

def newnode(nt,typ): return nt.nodes.new(typ)
def image_node(nt,path,noncolor=False):
 n=newnode(nt,'ShaderNodeTexImage'); n.image=bpy.data.images.load(str(path),check_existing=True)
 if noncolor:n.image.colorspace_settings.name='Non-Color'
 return n

def bake_material(name,source,tint,rough,metal,normalstrength=.15,res=1024):
 print('BAKE',name,flush=True)
 mat=bpy.data.materials.new(name); mat.use_nodes=True
 nt=mat.node_tree; nt.nodes.clear(); l=nt.links
 out=newnode(nt,'ShaderNodeOutputMaterial'); bs=newnode(nt,'ShaderNodeBsdfPrincipled'); l.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=newnode(nt,'ShaderNodeTexCoord')
 noise=newnode(nt,'ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=85; noise.inputs['Detail'].default_value=3; l.new(uv.outputs['UV'],noise.inputs['Vector'])
 ramp=newnode(nt,'ShaderNodeValToRGB')
 ramp.color_ramp.elements[0].position=.15; ramp.color_ramp.elements[0].color=tuple(c*.65 for c in tint)+(1,)
 ramp.color_ramp.elements[1].position=.85; ramp.color_ramp.elements[1].color=tuple(tint)+(1,)
 if source:
  diff=image_node(nt,download(source,'diff')); nor=image_node(nt,download(source,'nor_gl'),True); arm=image_node(nt,download(source,'arm'),True)
  bw=newnode(nt,'ShaderNodeRGBToBW'); l.new(diff.outputs['Color'],bw.inputs[0])
  mix=newnode(nt,'ShaderNodeMath'); mix.operation='MULTIPLY_ADD'; mix.inputs[1].default_value=.75; mix.inputs[2].default_value=.22; l.new(bw.outputs[0],mix.inputs[0]); l.new(mix.outputs[0],ramp.inputs[0])
  normal=newnode(nt,'ShaderNodeNormalMap'); normal.inputs['Strength'].default_value=normalstrength; l.new(nor.outputs['Color'],normal.inputs['Color']); l.new(normal.outputs['Normal'],bs.inputs['Normal'])
  sep=newnode(nt,'ShaderNodeSeparateColor'); l.new(arm.outputs['Color'],sep.inputs[0])
 else: l.new(noise.outputs['Fac'],ramp.inputs[0])
 col=ramp.outputs[0]
 if name in ['Rubber','Canvas']:
  broad=newnode(nt,'ShaderNodeTexNoise');broad.inputs['Scale'].default_value=5;broad.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],broad.inputs['Vector'])
  dust=newnode(nt,'ShaderNodeValToRGB');dust.color_ramp.elements[0].position=.26;dust.color_ramp.elements[0].color=(.27,.26,.24,1);dust.color_ramp.elements[1].position=.72;dust.color_ramp.elements[1].color=(1,1,1,1);l.new(broad.outputs['Fac'],dust.inputs[0])
  mm=newnode(nt,'ShaderNodeMixRGB');mm.blend_type='MULTIPLY';mm.inputs[0].default_value=.50;l.new(col,mm.inputs[1]);l.new(dust.outputs[0],mm.inputs[2]);col=mm.outputs[0]
 if name=='Paint':
  stretched=newnode(nt,'ShaderNodeVectorMath');stretched.operation='MULTIPLY';stretched.inputs[1].default_value=(3.0,.45,1);l.new(uv.outputs['UV'],stretched.inputs[0])
  grime=newnode(nt,'ShaderNodeTexNoise');grime.inputs['Scale'].default_value=5;grime.inputs['Detail'].default_value=5;grime.inputs['Roughness'].default_value=.78;l.new(stretched.outputs[0],grime.inputs['Vector'])
  dirt=newnode(nt,'ShaderNodeValToRGB');dirt.color_ramp.elements[0].position=.24;dirt.color_ramp.elements[0].color=(.24,.24,.24,1);dirt.color_ramp.elements[1].position=.63;dirt.color_ramp.elements[1].color=(1,1,1,1);l.new(grime.outputs['Fac'],dirt.inputs[0])
  multiply=newnode(nt,'ShaderNodeMixRGB');multiply.blend_type='MULTIPLY';multiply.inputs[0].default_value=.21;l.new(col,multiply.inputs[1]);l.new(dirt.outputs[0],multiply.inputs[2]);col=multiply.outputs[0]
 if name=='Lamps':
  sepUV=newnode(nt,'ShaderNodeSeparateXYZ'); l.new(uv.outputs['UV'],sepUV.inputs[0]); colors=newnode(nt,'ShaderNodeValToRGB'); colors.color_ramp.interpolation='CONSTANT'
  colors.color_ramp.elements[0].color=(.55,.64,.68,1); colors.color_ramp.elements[1].position=.67; colors.color_ramp.elements[1].color=(.45,.007,.006,1)
  colors.color_ramp.elements.new(.34).color=(.62,.16,.009,1); l.new(sepUV.outputs['X'],colors.inputs[0]); col=colors.outputs[0]
 l.new(col,bs.inputs['Base Color']); bs.inputs['Roughness'].default_value=rough; bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=1); plane=bpy.context.object; plane.name='BakeTile'; plane.data.materials.append(mat)
 target=newnode(nt,'ShaderNodeTexImage'); emit=newnode(nt,'ShaderNodeEmission')
 images={}
 for kind,size in [('base',res),('normal',res),('orm',min(res,512))]:
  im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
  if kind!='base': im.colorspace_settings.name='Non-Color'
  target.image=im; nt.nodes.active=target
  if kind=='normal':
   l.new(bs.outputs[0],out.inputs[0]); bpy.ops.object.bake(type='NORMAL')
  else:
   if kind=='base':l.new(col,emit.inputs[0])
   else:
    comb=newnode(nt,'ShaderNodeCombineColor'); comb.inputs['Red'].default_value=1; comb.inputs['Green'].default_value=rough; comb.inputs['Blue'].default_value=metal
    if source:
     rr=newnode(nt,'ShaderNodeMath'); rr.operation='MULTIPLY_ADD'; rr.inputs[1].default_value=.14; rr.inputs[2].default_value=rough-.07; l.new(sep.outputs['Green'],rr.inputs[0]); l.new(rr.outputs[0],comb.inputs['Green'])
     ao=newnode(nt,'ShaderNodeMath'); ao.operation='MULTIPLY_ADD'; ao.inputs[1].default_value=.25; ao.inputs[2].default_value=.75; l.new(sep.outputs['Red'],ao.inputs[0]); l.new(ao.outputs[0],comb.inputs['Red'])
    l.new(comb.outputs[0],emit.inputs[0])
   l.new(emit.outputs[0],out.inputs[0]); bpy.ops.object.bake(type='EMIT')
  ext='png' if kind=='orm' else 'jpg'
  im.filepath_raw=str(CACHE/f'{name}_{kind}.{ext}'); im.file_format='PNG' if kind=='orm' else 'JPEG'; SC.render.image_settings.quality=94; im.save(); images[kind]=im
 bpy.data.objects.remove(plane,do_unlink=True)
 nt.nodes.clear(); out=newnode(nt,'ShaderNodeOutputMaterial'); bs=newnode(nt,'ShaderNodeBsdfPrincipled'); l.new(bs.outputs[0],out.inputs[0])
 nodes={}
 for kind,im in images.items():
  n=newnode(nt,'ShaderNodeTexImage'); n.image=im; n.label=kind; nodes[kind]=n
 l.new(nodes['base'].outputs['Color'],bs.inputs['Base Color'])
 normal=newnode(nt,'ShaderNodeNormalMap'); l.new(nodes['normal'].outputs['Color'],normal.inputs['Color']); l.new(normal.outputs[0],bs.inputs['Normal'])
 sep=newnode(nt,'ShaderNodeSeparateColor'); l.new(nodes['orm'].outputs['Color'],sep.inputs[0]); l.new(sep.outputs['Green'],bs.inputs['Roughness']); l.new(sep.outputs['Blue'],bs.inputs['Metallic'])
 group=bpy.data.node_groups.get('glTF Material Output')
 if not group:
  group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=newnode(nt,'ShaderNodeGroup');gn.node_tree=group; l.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 if name=='Glass':
  bs.inputs['Alpha'].default_value=.16; bs.inputs['Specular IOR Level'].default_value=.25; bs.inputs['Roughness'].default_value=.12; mat.surface_render_method='DITHERED'; mat.use_transparency_overlap=False
 if name=='Lamps':l.new(nodes['base'].outputs[0],bs.inputs['Emission Color']); bs.inputs['Emission Strength'].default_value=.22
 MATS.append(mat); BAKED[name]=(bs,nodes)
 return len(MATS)-1

if '--preflight' in sys.argv:
 bake_material('Probe',None,(.7,.7,.7),.7,0,res=32)
 bpy.ops.mesh.primitive_cube_add(); bpy.context.object.data.materials.append(MATS[0]); bpy.ops.export_scene.gltf(filepath='/tmp/humvee_probe.glb',export_format='GLB')
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False);bpy.data.node_groups.remove(bpy.data.node_groups['glTF Material Output'],do_unlink=True);bpy.ops.import_scene.gltf(filepath='/tmp/humvee_probe.glb')
 print('GPU_BAKE_EXPORT_IMPORT_OK',flush=True);sys.exit()

# Each shared texture is a Cycles-baked photographic/procedural tile, not an atlas.
P=bake_material('Paint','metal_plate',(.97,.97,.97),.84,.05,.045)
R=bake_material('Rubber','rubberized_track',(.038,.040,.039),.88,0,.15)
M=bake_material('Metal','metal_plate',(.085,.094,.080),.66,.64,.12)
C=bake_material('Canvas','denim_fabric',(.063,.066,.045),.97,0,.22)
I=bake_material('Interior','fabric_leather_01',(.062,.072,.055),.93,0,.2)
G=bake_material('Glass',None,(.06,.085,.10),.10,0,0,64)
L=bake_material('Lamps',None,(1,1,1),.27,.12,0,256)
D=bake_material('Dust','rusty_metal',(.19,.15,.095),.95,.06,.12,512)

class Build:
 def __init__(self,name):self.name=name;self.v=[];self.f=[];self.mi=[];self.sm=[];self.uv=[]
 def add(self,v,f,mat=P,smooth=False,transform=None,uvkind=None):
  offset=len(self.v)
  if transform:v=[tuple(transform@Vector(p)) for p in v]
  self.v.extend(v); self.f.extend([tuple(offset+i for i in face) for face in f]);self.mi.extend([mat]*len(f));self.sm.extend([smooth]*len(f));self.uv.extend([uvkind]*len(f))
 def finish(self,origin=(0,0,0)):
  mesh=bpy.data.meshes.new(self.name);mesh.from_pydata(self.v,[],self.f);mesh.update()
  ob=bpy.data.objects.new(self.name,mesh);SC.collection.objects.link(ob)
  for mat in MATS:mesh.materials.append(mat)
  for p,mi,sm in zip(mesh.polygons,self.mi,self.sm):p.material_index=mi;p.use_smooth=sm
  uv=mesh.uv_layers.new(name='UVMap')
  scales=[1.5,3.4,2.2,3.0,3.0,1.,1.,2.5]
  for p,kind in zip(mesh.polygons,self.uv):
   n=p.normal; axis=max(range(3),key=lambda a:abs(n[a])); ij=[(1,2),(0,2),(0,1)][axis]
   for li in p.loop_indices:
    co=mesh.vertices[mesh.loops[li].vertex_index].co
    tex=(co[ij[0]]*scales[p.material_index],co[ij[1]]*scales[p.material_index])
    if kind is not None:tex=(kind,.5)
    uv.data[li].uv=tex
  # Keep smooth circular parts, hard sheet faces. Mesh is already bevelled and transformed.
  for v in mesh.vertices:v.co-=Vector(origin)
  ob.location=origin
  return ob
B=Build('Body'); current=B
BOXCACHE={}
def box(center,size,mat=P,bevel=.008,rot=None,uv=None):
 key=(tuple(size),bevel)
 if key not in BOXCACHE:
  bm=bmesh.new(); bmesh.ops.create_cube(bm,size=1)
  for v in bm.verts:v.co.x*=size[0];v.co.y*=size[1];v.co.z*=size[2]
  if bevel:
   bmesh.ops.bevel(bm,geom=list(bm.edges),offset=min(bevel,min(size)*.44),segments=(1 if min(size)<.035 else 2),affect='EDGES',clamp_overlap=True)
  bm.verts.ensure_lookup_table();bm.verts.index_update();verts=[tuple(v.co) for v in bm.verts];faces=[tuple(v.index for v in f.verts) for f in bm.faces];BOXCACHE[key]=(verts,faces);bm.free()
 v,f=BOXCACHE[key]
 tr=Matrix.Translation(Vector(center))
 if rot is not None:tr=tr@rot.to_4x4()
 current.add(v,f,mat,False,tr,uv)
def mesh(v,f,mat=P,smooth=False):current.add(v,f,mat,smooth)
def beam(a,b,width,mat=P,depth=None,bevel=.004):
 a=Vector(a);b=Vector(b);delta=b-a
 box((a+b)/2,(width,depth or width,delta.length),mat,bevel,delta.to_track_quat('Z','Y').to_matrix())
def cyl(center,r,depth,mat=M,axis=(0,0,1),n=24,uv=None):
 a=Vector(axis).normalized();q=Vector((0,0,1)).rotation_difference(a);verts=[]
 for z in [-depth/2,depth/2]:
  for j in range(n):verts.append(tuple(Vector(center)+q@Vector((r*cos(j*2*pi/n),r*sin(j*2*pi/n),z))))
 f=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(j,(j+1)%n,(j+1)%n+n,j+n) for j in range(n)]
 current.add(verts,f[:2],mat,False,uvkind=uv)
 current.add(verts,f[2:],mat,True,uvkind=uv)
def tube(points,r,mat=M,n=10,closed=False):
 pts=[Vector(p) for p in points];verts=[]
 for j,p in enumerate(pts):
  tangent=pts[(j+1)%len(pts)]-pts[j-1] if closed else pts[min(j+1,len(pts)-1)]-pts[max(0,j-1)]
  q=Vector((0,0,1)).rotation_difference(tangent.normalized())
  verts.extend([tuple(p+q@Vector((r*cos(k*2*pi/n),r*sin(k*2*pi/n),0))) for k in range(n)])
 f=[]
 for j in range(len(pts) if closed else len(pts)-1):
  j2=(j+1)%len(pts)
  f.extend([(j*n+k,j*n+(k+1)%n,j2*n+(k+1)%n,j2*n+k) for k in range(n)])
 if not closed:f.extend([tuple(reversed(range(n))),tuple((len(pts)-1)*n+k for k in range(n))])
 mesh(verts,f,mat,True)
def ring(center,r,thick,mat=M,axis=(1,0,0),n=48,m=8):
 q=Vector((0,0,1)).rotation_difference(Vector(axis).normalized());pts=[]
 for j in range(n):pts.append(tuple(Vector(center)+q@Vector((r*cos(2*pi*j/n),r*sin(2*pi*j/n),0))))
 tube(pts,thick,mat,m,True)
def prism_x(profile,x0,x1,mat=P,bev=.006):
 # Extruded side outline with real thickness and welded chamfer.
 v=[(x,y,z) for x in [x0,x1] for y,z in profile];n=len(profile)
 f=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(j,(j+1)%n,(j+1)%n+n,j+n) for j in range(n)]
 bm=bmesh.new();vs=[bm.verts.new(p) for p in v]
 for face in f:
  try:bm.faces.new([vs[i] for i in face])
  except:pass
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
 if bev:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=bev,segments=2,affect='EDGES',clamp_overlap=True)
 bm.verts.index_update();mesh([tuple(v.co) for v in bm.verts],[tuple(v.index for v in f.verts) for f in bm.faces],mat);bm.free()
def bolt(p,axis=(1,0,0),r=.009,mat=M):cyl(p,r,.006,mat,axis,6)
def rounded_rect_side(s,y0,y1,z0,z1,r=.05,x=.979,mat=R,width=.009):
 pts=[]
 for cy,cz,start in [(y0+r,z0+r,pi),(y1-r,z0+r,1.5*pi),(y1-r,z1-r,0),(y0+r,z1-r,.5*pi)]:
  for k in range(6):
   a=start+k*pi/2/5;pts.append((s*x,cy+r*cos(a),cz+r*sin(a)))
 tube(pts,width,mat,8,True)

# Frame, independent suspension, differentials and protected underbody.
for x in [-.60,.60]:box((x,0,.48),(.11,4.28,.145),M,.014)
for y in [-1.85,-1.10,0,.9,1.85]:box((0,y,.48),(1.32,.09,.10),M)
box((0,.0,.49),(1.72,2.16,.095),P,.018)
box((0,-.72,.455),(.72,.8,.09),M,.025)
for y in [-1.65,1.65]:
 cyl((0,y,.515),.088,.24,M,(0,1,0),24)
 for s in [-1,1]:
  beam((s*.13,y,.515),(s*.91,y,.47),.045,M)
  for dy in [-.14,.14]:
   beam((s*.37,y+dy,.49),(s*.91,y,.37),.045,M)
  beam((s*.55,y-.09,.71),(s*.87,y,.42),.060,M)
  for j in range(7):ring((s*(.61+j*.024),y-.06,.66-j*.027),.047,.009,M,(.55,0,-.7),16,6)
beam((0,-1.6,.515),(0,1.6,.515),.045,M)
# Lower cab sill: indented central belly, not a rectangular slab across the tyres.
for s in [-1,1]:
 prism_x([(-1.12,.72),(-.99,.47),(.90,.47),(1.17,.66),(1.10,1.08),(-.99,1.13)],s*.91,s*.975,P)
 box((s*.98,.02,.56),(.045,1.95,.08),P,.015)
 for y in [-.84,-.46,-.08,.30,.68,.94]:box((s*1.001,y,.60),(.012,.035,.08),D,.012)
# Hood loft across shoulder creases.
HOOD_START=len(B.v)
sections=[(-2.10,.93,1.06),(-1.91,.97,1.10),(-1.04,.93,1.23),(-.80,.87,1.25)]
v=[]
rows=[-2.10,-1.91,-1.64,-1.055,-1.04,-.80]
for y in rows:
 for j in range(len(sections)-1):
  ya,wa,za=sections[j];yb,wb,zb=sections[j+1]
  if ya-1e-6<=y<=yb+1e-6:
   t=(y-ya)/(yb-ya);w=wa+(wb-wa)*t;z=za+(zb-za)*t;break
 for x in [-w,-w+.15,-.35,.35,w-.15,w]:
  zz=z-.045 if abs(x)>w-.01 else z+.045-.033*abs(x)/(w-.15)
  # Keep the bonnet skin above the broad fender shoulder, avoiding coplanar crossings.
  zz=max(zz,1.091)
  v.append((x,y,zz))
f=[]
for j in range(len(rows)-1):
 for k in range(5):
  if j==2 and k==2:continue
  a=j*6+k;f.append((a,a+1,a+7,a+6))
mesh(v,f,P)
for sign in [-1,1]:tube([(sign*w,y,z-.044) for y,w,z in sections],.012,P,8)
# Recessed opening with spaced, angled sheet-metal louvers.
box((0,-1.34,1.191),(.71,.60,.015),R,.009,Matrix.Rotation(.148,3,'X'))
for j in range(12):box((0,-1.607+j*.047,1.180+j*.00702),(.687,.023,.010),P,.003,Matrix.Rotation(.32,3,'X'))
for s in [-1,1]:
 for y in [-1.03,-1.76]:
  z=1.245 if y> -1.1 else 1.15
  tube([(s*.45-.042,y,z),(s*.45-.026,y,z+.036),(s*.45+.026,y,z+.036),(s*.45+.042,y,z)],.009,M,8)
 # Actual front and rear arch panels, cut away around the wheel opening.
 for cy,front in [(-1.65,True),(1.65,False)]:
  ya,yb=(-2.16,-.94) if front else (1.00,2.19)
  top=1.08 if front else 1.14
  # arch radius with wide shoulders
  arc=[(cy+.525*cos(a),.455+.525*min(sin(a)*1.07,1.0)) for a in [.48+i*(pi-.96)/20 for i in range(21)]]
  prof=[(ya,.716),(ya,top),(yb,top),(yb,.716)]+arc
  # monotonic perimeter: right edge then right arch foot through crown to left foot
  prism_x(prof,s*.82,s*1.025,P,.008)
  tube([(s*1.028,y,z) for y,z in arc],.012,P,8)
  # Arch inner liners follow arch with width into engine bay.
  av=[]
  for x in [s*.79,s*1.012]:av.extend([(x,y,z+.009) for y,z in arc])
  mesh(av,[(k,k+1,k+1+len(arc),k+len(arc)) for k in range(len(arc)-1)],R)
 # Sloping side flanks close the gap between bonnet and wheel arch.
 prism_x([(-2.10,1.075),(-.80,1.075),(-.80,1.205),(-1.04,1.185),(-1.91,1.055)],s*.89,s*.962,P,.007)
 # Hood latches mounted at side shoulders.
 for y in [-1.10]:
  beam((s*1.032,y-.07,.96),(s*1.032,y-.13,1.14),.035,R,.026)
  for z in [.98,1.10]:bolt((s*1.051,y-.10,z),(s,0,0),.009)
HOOD_END=len(B.v)
# Cab front bulkhead and windscreen, slope back towards roof.
CAB_START=len(B.v)
box((0,-.77,1.10),(1.83,.105,.27),P,.025)
# Windscreen rectangle lies in plane y=-.805 + (z-1.25)*.235.
def wy(z):return -.805+(z-1.25)*.235
for x in [-.89,0,.89]:beam((x,wy(1.245),1.245),(x,wy(1.765),1.765),.037,P,.048)
for z in [1.245,1.765]:beam((-.90,wy(z),z),(.90,wy(z),z),.044,P,.052)
for s in [-1,1]:
 x0,x1=sorted([s*.025,s*.864])
 mesh([(x0,wy(1.276),1.276),(x1,wy(1.276),1.276),(x1,wy(1.73),1.73),(x0,wy(1.73),1.73)],[(0,1,2,3)],G)
 # Rubber windshield gasket rails.
 for x in [x0,x1]:beam((x,wy(1.278)-.012,1.278),(x,wy(1.733)-.012,1.733),.011,R)
 for z in [1.278,1.733]:beam((x0,wy(z)-.012,z),(x1,wy(z)-.012,z),.011,R)
 beam((s*.68,wy(1.74)-.025,1.74),(s*.33,wy(1.42)-.027,1.42),.012,M)
 beam((s*.31,wy(1.39)-.035,1.39),(s*.57,wy(1.64)-.035,1.64),.016,R)
 for z in [1.30,1.43,1.57,1.70]:bolt((s*.887,wy(z)-.033,z),(0,-1,0),.006)
# Four separate framed doors, lower stamped X pressed ribs and framed windows.
for s in [-1,1]:
 for y0,y1 in [(-.755,.115),(.15,1.03)]:
  # Lower door panel with rounded lower corners.
  prof=[(y0+.055,.65),(y1-.08,.65),(y1,.74),(y1,1.20),(y0,1.20),(y0,.72)]
  prism_x(prof,s*.938,s*.971,P,.017)
  rounded_rect_side(s,y0+.012,y1-.013,.648,1.207,.048,mat=R,width=.008)
  rounded_rect_side(s,y0+.06,y1-.06,.70,1.155,.040,x=.981,mat=P,width=.012)
  cy=(y0+y1)/2
  # A single welded stamped X avoids intersecting coplanar strip faces.
  hy=(y1-y0-.23)/2;hz=.1785;cz=.9335
  xp=[(-hy,-hz+.020),(-hy+.020,-hz),(0,-.023),(hy-.020,-hz),(hy,-hz+.020),(.038,0),(hy,hz-.020),(hy-.020,hz),(0,.023),(-hy+.020,hz),(-hy,hz-.020),(-.038,0)]
  prism_x([(cy+yy,cz+zz) for yy,zz in xp],s*.967,s*.982,P,.003)
  # Substantial upper door panels surrounding rounded inset windows.
  for y in [y0+.055,y1-.055]:
   beam((s*.947,y,1.205),(s*.893,y,1.733),.097,P,.040,.009)
  for z,h in [(1.258,.104),(1.693,.073)]:
   box((s*(.946-(z-1.2)*.10),cy,z),(.045,y1-y0-.035,h),P,.011)
  ya,yb=y0+.111,y1-.111;za,zb=1.310,1.654;rr=.027
  yz=[]
  for yy,zz,start in [(yb-rr,zb-rr,0),(ya+rr,zb-rr,90),(ya+rr,za+rr,180),(yb-rr,za+rr,270)]:
   for j in range(5):
    a=math.radians(start+j*90/4);yz.append((yy+rr*cos(a),zz+rr*sin(a)))
  verts=[(s*(.946-(z-1.2)*.10),y,z) for y,z in yz]
  mesh(verts,[tuple(range(len(verts)))],G)
  # Continuous sheet-metal sash annulus closes the rounded window corners.
  outer=[]
  for yy,zz,start in [(y1-.027,1.706,0),(y0+.027,1.706,90),(y0+.027,1.242,180),(y1-.027,1.242,270)]:
   for j in range(5):
    a=math.radians(start+j*90/4);y=yy+.019*cos(a);z=zz+.019*sin(a);outer.append((s*(.952-(z-1.2)*.10),y,z))
  mesh(verts+outer,[(j,(j+1)%20,(j+1)%20+20,j+20) for j in range(20)],P)
  tube(verts,.009,R,8,True)
  tube([(x+s*.010,y,z) for x,y,z in verts],.009,P,8,True)
  for z in [.76,1.145]:
   box((s*.993,y0+.019,z),(.045,.105,.045),P,.008);cyl((s*1.017,y0+.031,z),.015,.060,M,(0,0,1),12)
   for y in [y0-.009,y0+.058]:bolt((s*1.019,y,z),(s,0,0),.006)
  box((s*.997,y1-.094,1.04),(.018,.070,.10),R,.008)
  box((s*1.008,y1-.094,1.058),(.020,.049,.043),P,.007)
  beam((s*1.024,y1-.111,1.016),(s*1.024,y1-.073,1.016),.009,M)
 # Closed front A-pillar and dark structural B-pillar behind door seams.
 prism_x([(-.805,1.218),(-.680,1.750),(-.742,1.750),(-.778,1.218)],s*.881,s*.944,P,.005)
 beam((s*.925,.133,1.19),(s*.872,.133,1.747),.065,R,.055,.006)
 # Continuous roof rail and sloped rear closing post: no daylight gaps.
 box((s*.912,.205,1.750),(.062,1.86,.055),P,.010)
 prism_x([(1.032,1.16),(1.177,1.16),(1.103,1.754),(1.033,1.754)],s*.882,s*.941,P,.008)
 # B-pillar, rear pillar
 for y in [.132,1.055]:beam((s*.945,y,.62),(s*.887,y,1.74),.035,P,.032)
# Rear cab wall, canvas rear screen with clear rear window.
box((0,1.077,.97),(1.87,.06,.43),P,.012)
# rear canvas wall shaped with window gap
box((0,1.102,1.32),(1.84,.030,.19),C,.022)
box((0,1.102,1.714),(1.78,.034,.13),C,.025)
for s in [-1,1]:box((s*.663,1.102,1.51),(.46,.032,.30),C,.04)
box((0,1.103,1.523),(.89,.012,.247),G,.025)
for z in [1.397,1.650]:beam((-.45,1.122,z),(.45,1.122,z),.020,C)
for x in [-.451,.451]:beam((x,1.123,1.414),(x,1.123,1.637),.020,C)
# Canvas roof: tensioned cloth sagging between bows with real rolled hems.
nx,ny=24,32; v=[]
for j in range(ny+1):
 y=-.72+1.87*j/ny
 for i in range(nx+1):
  x=-.936+1.872*i/nx
  cross=max(0,1-(abs(x)/.94)**4)
  sag=.024*(sin((y+.72)*pi/.64)**2)*cross
  wrinkle=.003*sin(24*y+5*sin(x*8))*abs(x/.94)**5
  z=1.775+.037*cross-sag+wrinkle
  v.append((x,y,z))
f=[]
for j in range(ny):
 for i in range(nx):a=j*(nx+1)+i;f.append((a,a+1,a+nx+2,a+nx+1))
mesh(v,f,C,True)
# Canvas has a separate inner skin and stitched hanging edge, not a zero-thickness plane.
mesh([(x,y,z-.004) for x,y,z in v],[tuple(reversed(face)) for face in f],C,True)
for sign in [-1,1]:
 ev=[]
 for j in range(ny+1):
  y=-.72+1.87*j/ny;z=1.775+.003*sin(24*y+5*sin(sign*.936*8))
  ev.extend([(sign*.936,y,z),(sign*.936,y,z-.036-.004*sin(j*1.8))])
 mesh(ev,[(2*j,2*j+1,2*j+3,2*j+2) for j in range(ny)],C,True)
 tube([(sign*.939,-.72+1.87*j/32,1.739-.004*sin(j*1.8)) for j in range(33)],.004,C,6)
for s in [-1,1]:
 pts=[(s*.936,-.72+1.87*j/40,1.776+.006*sin(24*(-.72+1.87*j/40)+5*sin(s*.936*8))) for j in range(41)]
 tube(pts,.019,C,10)
 for j in range(13):
  y=-.68+j*.145;bolt((s*.949,y,1.763),(s,0,0),.007,M)
for y in [-.717,1.147]:tube([(x,y,1.775+.037*max(0,1-(abs(x)/.94)**4)) for x in [-.936+i*1.872/40 for i in range(41)]],.018,C,10)
CAB_END=len(B.v)
# Low cargo tray and tensioned detachable canvas cover.
# Closed front bulkhead supports the cover hem against the rear cab.
box((0,1.044,1.107),(1.86,.12,.094),P,.008)
box((0,1.59,.765),(1.82,1.18,.080),P,.012)
for x in [-.74+i*.125 for i in range(13)]:box((x,1.64,.812),(.035,1.01,.016),P,.005)
for s in [-1,1]:
 # Folded steel wheel housings close the cargo tray above the rear tyres.
 box((s*.812,1.65,1.043),(.344,1.02,.026),P,.008)
 box((s*.648,1.65,.921),(.018,1.02,.244),P,.006)
 for y in [1.149,2.151]:box((s*.812,y,.921),(.344,.018,.244),P,.006)
 box((s*.983,1.6025,1.127),(.056,1.195,.047),P,.008)
 for y in [1.16,1.51,1.93,2.15]:
  bolt((s*1.031,y,1.073),(s,0,0),.007)
  # tiedown loops
  tube([(s*1.035,y-.023,1.065),(s*1.049,y-.023,1.023),(s*1.049,y+.023,1.023),(s*1.035,y+.023,1.065)],.006,M,8)
 box((s*1.025,2.01,1.005),(.018,.20,.13),P,.015)
# Tailgate and pressed inset, hinges and latches.
box((0,2.17,.965),(1.98,.055,.365),P,.016)
box((0,2.204,.971),(1.22,.022,.237),P,.020)
for x in [-.69,.69]:
 box((x,2.214,.814),(.17,.035,.047),P,.008);cyl((x,2.23,.809),.015,.19,M,(1,0,0),16)
 box((x,2.213,1.08),(.052,.037,.09),M,.008)
for x in [-.92,-.78,.78,.92]:bolt((x,2.21,.95),(0,1,0),.008)
NOSE_START=len(B.v)
# Short stamped radiator face: nine capsule apertures with bevelled rolled edges.
box((0,-2.088,.938),(1.94,.055,.325),P,.025)
box((0,-2.125,.954),(.97,.021,.279),R,.014)
for k in range(9):
 cx=(k-4)*.105;cz=.954;slot=[]
 for centerz,start in [(cz+.087,0),(cz-.087,180)]:
  for j in range(9):
   a=math.radians(start+j*180/8);slot.append((cx+.028*cos(a),centerz+.028*sin(a)))
 # Capsule follows clockwise around top and bottom. Rectangular outer cell
 # is ray-projected, leaving continuous metal webs between actual openings.
 outer=[]
 for sign in [1,-1]:
  for dx,dz in [(.0525,.04),(.0525,.139),(.035,.139),(.0175,.139),(0,.139),(-.0175,.139),(-.035,.139),(-.0525,.139),(-.0525,.04)]:
   outer.append((cx+dx*sign,-2.146,cz+dz*sign))
 n=len(slot)
 front=[(x,-2.146,z) for x,z in slot]
 inset=[(cx+(x-cx)*.88,-2.135,cz+(z-cz)*.97) for x,z in slot]
 mesh(outer+front+inset,[(j,(j+1)%n,(j+1)%n+n,j+n) for j in range(n)]+[(j+n,(j+1)%n+n,(j+1)%n+2*n,j+2*n) for j in range(n)],P)
# Ribbed headlight lenses, blackout marker hoods and rear lights.
for s in [-1,1]:
 cyl((s*.647,-2.143,.947),.112,.058,R,(0,1,0),40)
 ring((s*.647,-2.179,.947),.092,.009,M,(0,1,0),40,8)
 cyl((s*.647,-2.180,.947),.083,.014,L,(0,1,0),40,.17)
 for i in range(-5,6):
  xx=s*.647+i*.012; half=math.sqrt(max(0,.077**2-(i*.012)**2))
  beam((xx,-2.19,.947-half),(xx,-2.19,.947+half),.0025,G,bevel=.001)
 cyl((s*.882,-2.171,1.024),.050,.045,R,(0,1,0),24)
 cyl((s*.882,-2.198,1.024),.037,.007,L,(0,1,0),24,.50)
 # Blackout hood, with small lower aperture
 box((s*.89,-2.201,1.055),(.105,.070,.028),P,.011)
 box((s*.896,-2.211,.994),(.046,.012,.012),R,.003)
 cyl((s*1.037,-1.981,1.009),.030,.012,L,(s,0,0),20,.5)
 # rear stop/blackout clusters
 cyl((s*.84,2.215,.955),.058,.025,R,(0,1,0),24)
 cyl((s*.84,2.233,.955),.043,.008,L,(0,1,0),24,.82)
 box((s*.84,2.23,.851),(.089,.028,.040),R,.012)
 box((s*.84,2.248,.853),(.042,.005,.010),L,.004,uv=.82)
NOSE_END=len(B.v)
# Heavy bumpers, perforated front mounting plate and shackles.
box((0,2.241,.65),(2.00,.088,.135),M,.012)
box((0,-2.19,.664),(1.19,.11,.142),P,.010)
# dark round recesses on front bumper, raised outer ring catches thickness
for x in [-.27,-.09,.09,.27]:
 cyl((x,-2.249,.661),.034,.009,R,(0,1,0),24)
 ring((x,-2.254,.661),.035,.004,M,(0,1,0),24,6)
for y in [-2.245,2.285-.0175]:
 for s in [-1,1]:
  box((s*.50,y,.665),(.055,.045,.105),M,.008)
  tube([(s*.50-.040,y-.011,.663),(s*.50-.040,y-.013,.583),(s*.50,y-.016,.565),(s*.50+.040,y-.013,.583),(s*.50+.040,y-.011,.663)],.013,M,10)
  cyl((s*.50,y-.009,.66),.016,.102,M,(1,0,0),16)
# Brush guard: continuous bent tubing with welded stanchions, conforms to nose.
tube([(-1.004,-2.256,.80),(-1.033,-2.256,.83),(-1.033,-2.256,1.10),(-1.006,-2.256,1.125),(1.006,-2.256,1.125),(1.033,-2.256,1.10),(1.033,-2.256,.83),(1.004,-2.256,.80),(-1.004,-2.256,.80)],.024,M,12)
for x in [-.49,.49]:beam((x,-2.257,.72),(x,-2.257,1.124),.032,M)
for s in [-1,1]:beam((s*.88,-2.25,.80),(s*.88,-2.12,.77),.036,M)
# Mirrors: brackets within 2.16 m width, metal shell and actual glass inset.
for s in [-1,1]:
 tube([(s*.94,-.61,1.24),(s*1.037,-.72,1.26),(s*1.041,-.75,1.52)],.012,M,10)
 box((s*1.005,-.758,1.435),(.150,.055,.247),P,.025)
 box((s*1.002,-.725,1.435),(.131,.008,.219),G,.018)
 # side rivets and sill weather speckles
 for y in [-.91+i*.105 for i in range(20)]:bolt((s*.988,y,.505),(s,0,0),.0045)
 for j in range(80):
  y=random.uniform(-.91,1.02);z=random.uniform(.49,.68);r=random.uniform(.0015,.0040)
  cyl((s*.999,y,z),r,.002,D,(s,0,0),6)
# Exhaust side exit, muffler and bent end pipe.
cyl((-.61,.72,.485),.074,.57,M,(0,1,0),24)
tube([(-.61,.97,.49),(-.72,1.18,.46),(-.86,1.23,.45),(-1.014,1.25,.47)],.023,M,12)
cyl((-1.026,1.253,.471),.019,.006,R,(1,0,0),20)
INTERIOR_START=len(B.v)
# Cab interior: footwells, transmission doghouse, four upholstered seats.
box((0,.05,.65),(.43,1.8,.30),P,.055)
box((0,-.48,.91),(.46,.60,.24),P,.038)
box((0,-.704,1.13),(1.70,.21,.17),I,.025)
box((.42,-.588,1.144),(.54,.025,.14),M,.007)
for x,z,r in [(.41,1.15,.046),(.29,1.14,.028),(.53,1.14,.028)]:
 cyl((x,-.565,z),r,.012,R,(0,1,0),24)
 ring((x,-.555,z),r,.003,M,(0,1,0),24,6)
 beam((x,-.547,z),(x+r*.40,-.547,z+r*.5),.002,L,bevel=.0005)
for x in [-.55,-.40,-.25,.65]:cyl((x,-.57,1.12),.009,.020,R,(0,1,0),12)
for s in [-1,1]:
 for y in [-.20,.66]:
  for x in [s*.47,s*.78]:beam((x,y-.20,.56),(x,y+.19,.76),.028,M)
  box((s*.61,y,.805),(.44,.46,.105),I,.045)
  box((s*.61,y+.215,1.052),(.43,.10,.43),I,.047,Matrix.Rotation(.10,3,'X'))
  for xx in [-.18,.18]:tube([(s*.61+xx,y-.17,.861),(s*.61+xx,y+.17,.861)],.0035,C,6)
  # webbing lap belt and buckles
  beam((s*.40,y+.15,.85),(s*.73,y+.18,.88),.031,R,.006,.002)
  box((s*.55,y+.175,.884),(.045,.032,.013),M,.003)
  for z in [.96,1.14]:tube([(s*.43,y+.16,z),(s*.78,y+.16,z)],.003,I,6)
# Gear/transfer levers with rubber bellows.
for x,y,h in [(.13,-.22,.24),(-.06,-.16,.17)]:
 for k in range(4):box((x,y,.811+k*.013),(.10-k*.014,.10-k*.014,.016),R,.008)
 beam((x,y,.84),(x,y-.06,.84+h),.014,M)
 cyl((x,y-.06,.84+h),.028,.040,R,(0,0,1),16)
# Steering column and separate three spoke steering wheel at driver's left (+X).
steerhub=(.59,-.414,1.204); steeraxis=Vector((0,.67,.74)).normalized()
beam((.59,-.67,1.07),steerhub,.036,M)
# Proportion revision from v1: compact cab, broad shoulders, shallow bonnet.
# Work on complete construction groups so frames, glass, seals and fittings stay aligned.
def cabpoint(p):
 x,y,z=p
 y=.13+(y-.13)*.90
 if z>1.20: z=1.28+(z-1.20)*(.535/.615)
 elif z>.65: z=.65+(z-.65)*(.63/.55)
 return (x,y,z)
for i in range(CAB_START,CAB_END): B.v[i]=cabpoint(B.v[i])
for i in range(INTERIOR_START,len(B.v)): B.v[i]=cabpoint(B.v[i])
for i in range(HOOD_START,HOOD_END):
 x,y,z=B.v[i]
 # Only upper bonnet skin/fittings; the arches retain axle-centred geometry.
 if z>.98 and y<-.79:
  t=max(0,min(1,(y+2.10)/1.30));w=max(0,min(1,(z-.98)/.10));z+=.050*(1-t)*w
 if y>-.82: y+=.090
 B.v[i]=(x,y,z)
for i in range(NOSE_START,NOSE_END):
 x,y,z=B.v[i]
 if y<0:
  # A short raked radiator face, leaning back into the bonnet.
  y+=(z-.79)*.115;z+=.040
 B.v[i]=(x,y,z)
# Tensioned low cargo cover: retains the reference's low bed outline.
# Real double skin, shallow bow crown, draped hems, grommets and lacing.
nx2,ny2=24,18;cv=[]
for j in range(ny2+1):
 y=1.005+1.19*j/ny2
 for i in range(nx2+1):
  x=-.995+1.99*i/nx2;u=x/.995
  z=1.161+.035*(1-u*u)-.010*sin(pi*j/ny2)**2+.004*sin(j*2.3+i*.5)*abs(u)**6
  cv.append((x,y,z))
cf=[]
for j in range(ny2):
 for i in range(nx2):
  a=j*(nx2+1)+i;cf.append((a,a+1,a+nx2+2,a+nx2+1))
mesh(cv,cf,C,True);mesh([(x,y,z-.004) for x,y,z in cv],[tuple(reversed(f)) for f in cf],C,True)
for side in [-1,1]:
 pts=[(side*.997,1.005+1.19*j/36,1.161+.004*sin(j*1.15)) for j in range(37)]
 tube(pts,.008,C,8)
 vv=[]
 for j,p in enumerate(pts):vv.extend([p,(p[0],p[1],1.103+.005*sin(j*.9))])
 mesh(vv,[(j*2,j*2+1,j*2+3,j*2+2) for j in range(36)],C,True)
 for y in [1.16,1.51,1.93,2.15]:
  ring((side*1.001,y,1.114),.008,.0025,M,(1,0,0),12,6)
  tube([(side*1.008,y,1.115),(side*1.049,y,1.025),(side*1.012,y+.021,1.11)],.003,C,6)
for y in [1.007,2.191]:tube([(x,y,1.162+.035*(1-(x/.995)**2)) for x in [-.995+i*1.99/24 for i in range(25)]],.008,C,8)
# Widen the entire fixed assembly while keeping mirrors within 2.16 m.
def widepoint(p):
 x,y,z=p;a=abs(x)
 if a<=.94:a*=1.064
 elif a<=1.04:a=1.00016+(a-.94)*.7484
 else:a=1.075+(a-1.04)*.125
 return (math.copysign(a,x),y,z)
B.v=[widepoint(p) for p in B.v]
roof_top=max(p[2] for p in B.v)
B.v=[(x,y,1.28+(z-1.28)*(.55/(roof_top-1.28))) if z>1.28 else (x,y,z) for x,y,z in B.v]
BODY=B.finish()
steerhub=widepoint(cabpoint(steerhub))

current=Build('SteeringWheel')
ring(steerhub,.159,.012,R,steeraxis,48,10)
q=Vector((0,0,1)).rotation_difference(steeraxis)
for a in [pi/2,pi/2+2*pi/3,pi/2+4*pi/3]:
 beam(steerhub,Vector(steerhub)+q@Vector((.15*cos(a),.15*sin(a),0)),.018,M,.010)
cyl(steerhub,.043,.025,R,steeraxis,24)
STEER=current.finish(steerhub)
# Wheels: profiled radial tyre carcass, tread blocks, sidewall mould ribs,
# recessed steel rim, 12 split-rim bolts and 8 lug nuts. All belong to spinning node.
WHEELS=[]
for s,side in [(1,'L'),(-1,'R')]:
 for cy,ax in [(-1.65,'F'),(1.65,'R')]:
  center=Vector((s*.891,cy,.448));current=Build('Wheel_'+ax+side)
  profile=[(-.159,.246),(-.176,.29),(-.177,.351),(-.152,.403),(-.114,.43),(-.07,.437),(.07,.437),(.114,.43),(.152,.403),(.177,.351),(.176,.29),(.159,.246)]
  verts=[];N=64
  for dx,r in profile:
   for j in range(N):a=2*pi*j/N;verts.append(tuple(center+Vector((dx,r*sin(a),r*cos(a)))))
  faces=[]
  for k in range(len(profile)):
   k2=(k+1)%len(profile)
   for j in range(N):faces.append((k*N+j,k*N+(j+1)%N,k2*N+(j+1)%N,k2*N+j))
  mesh(verts,faces,R,True)
  # Three interlocked chevron rows, rounded tread blocks.
  for j in range(36):
   for row in [-1,0,1]:
    a=2*pi*(j+(row%2)*.45)/36
    pos=center+Vector((row*.095,.440*sin(a),.440*cos(a)))
    rot=Matrix.Rotation(-a,3,'X')@Matrix.Rotation((.38 if row<0 else -.38 if row>0 else .1),3,'Z')
    box(pos,(.084,.048,.016),R,.004,rot)
  outer=center+Vector((s*.177,0,0))
  for radius,dx in [(.277,.172),(.298,.177),(.364,.172)]:ring(center+Vector((s*dx,0,0)),radius,.0026,R,(1,0,0),48,6)
  for j in range(40):
   a=2*pi*j/40
   beam(center+Vector((s*.165,.377*sin(a),.377*cos(a))),center+Vector((s*.154,.400*sin(a+.019),.400*cos(a+.019))),.006,R,bevel=.001)
  # olive rim belongs to Paint, tyre does not.
  # Concave steel dish: outer lip rolls inward to recessed bolt face.
  rv=[];rf=[];profile=[(.154,.254),(.163,.243),(.149,.223),(.101,.184),(.091,.104)]
  for dx,radius in profile:
   for j in range(48):
    a=j*2*pi/48;rv.append(tuple(center+Vector((s*dx,radius*sin(a),radius*cos(a)))))
  for k in range(len(profile)-1):
   for j in range(48):rf.append((k*48+j,k*48+(j+1)%48,(k+1)*48+(j+1)%48,(k+1)*48+j))
  mesh(rv,rf,P,True)
  cyl(center,.251,.165,M,(1,0,0),48)
  ring(center+Vector((s*.157,0,0)),.248,.009,M,(1,0,0),48,8)
  cyl(center+Vector((s*.102,0,0)),.104,.030,M,(1,0,0),32)
  cyl(center+Vector((s*.128,0,0)),.065,.031,P,(1,0,0),24)
  for j in range(12):
   a=2*pi*j/12;p=center+Vector((s*.151,.224*sin(a),.224*cos(a)));bolt(p,(s,0,0),.011,M)
  for j in range(8):
   a=2*pi*j/8;p=center+Vector((s*.110,.135*sin(a),.135*cos(a)));bolt(p,(s,0,0),.013,M)
   a+=pi/8;p=center+Vector((s*.106,.174*sin(a),.174*cos(a)));cyl(p,.018,.004,R,(1,0,0),16)
  # valve stem
  beam(center+Vector((s*.129,.19,.04)),center+Vector((s*.150,.19,.04)),.009,R)
  wheel=current.finish(center)
  if ax=='R':
   for v in wheel.data.vertices:v.co=Matrix.Rotation(.071,3,'X')@v.co
  WHEELS.append(wheel)

ASSET=[BODY,STEER]+WHEELS
# Keep assembled bounds exact; tyres' lowest tread vertices determine their real radius.
for wheel in WHEELS:
 lo=min((wheel.matrix_world@v.co).z for v in wheel.data.vertices)
 # matrix may not have updated yet, use local + location
 lo=min(v.co.z+wheel.location.z for v in wheel.data.vertices)
 wheel.location.z-=lo
# Gameplay attachment points, expressed as seated hip points where applicable.
SOCKETS={'Seat_Driver':(.61,-.20,.87),'Door_L':(.98,-.73,1.0),'Door_R':(-.98,-.73,1.0),'Exhaust':(-1.026,1.253,.471),'Light_Head_L':(.647,-2.195,.947),'Light_Head_R':(-.647,-2.195,.947),'Light_Tail_L':(.84,2.244,.955),'Light_Tail_R':(-.84,2.244,.955),'Troop_1':(-.61,-.20,.87),'Troop_2':(.61,.66,.87),'Troop_3':(-.61,.66,.87)}
for name,loc in SOCKETS.items():
 if name.startswith('Light_Head'):loc=(loc[0],loc[1]+(loc[2]-.79)*.115,loc[2]+.040)
 ob=bpy.data.objects.new(name,None);SC.collection.objects.link(ob);ob.location=widepoint(cabpoint(loc)) if name.startswith(('Seat','Door','Troop')) else widepoint(loc);ob.empty_display_size=.07;ASSET.append(ob)
bpy.context.view_layer.update()
# Real mesh geometry, no remaining modifiers; explicit triangulation for identical budget.
for ob in ASSET:
 if ob.type!='MESH':continue
 bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bmesh.ops.triangulate(bm,faces=list(bm.faces));bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.calc_area()<1e-10],context='FACES');bm.to_mesh(ob.data);bm.free()
triangles=sum(len(o.data.polygons) for o in ASSET if o.type=='MESH')
print('TRIANGLES',triangles,flush=True)
assert 50000<=triangles<=110000, f'Triangle budget exceeded: {triangles}'
# Save clean glTF with neutral paint then set the exact linear colour factor.
bpy.ops.object.select_all(action='DESELECT')
for o in ASSET:o.select_set(True)
GLB=ROOT/'humvee-troop.glb'
bpy.ops.export_scene.gltf(filepath=str(GLB),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False)
raw=GLB.read_bytes();jlen=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jlen]);rest=raw[20+jlen:]
for mat in doc['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1.0]
 if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
GLB.write_bytes(struct.pack('<III',0x46546c67,2,20+len(js)+len(rest))+struct.pack('<II',len(js),0x4e4f534a)+js+rest)
# world-position metadata is glTF/Y-up, with matching exact geometry bounds.
def game(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
coords=[o.matrix_world@v.co for o in ASSET if o.type=='MESH' for v in o.data.vertices]
gc=[game(p) for p in coords];lo=[min(p[i] for p in gc) for i in range(3)];hi=[max(p[i] for p in gc) for i in range(3)]
metadata={'contract':'Forge Garage v2','units':'metres','axes':{'front':'+Z','up':'+Y','left':'+X'},'origin':'ground midway between axles','nodes':[{'name':o.name,'type':'mesh' if o.type=='MESH' else 'empty','world_position':game(o.matrix_world.translation)} for o in ASSET],'bounding_box':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'wheelbase':3.30,'triangles':triangles,'materials':[m.name for m in MATS],'paints':[{'name':name,'linear_rgb':rgb} for name,rgb in PAINTS],'texture_policy':'Shared Cycles-baked tiling sets; base/normal <=1024; ORM <=512; no clearcoat','provenance':{'reference':'sheet.jpg','poly_haven_ids':['metal_plate','rubberized_track','denim_fabric','fabric_leather_01','rusty_metal'],'license':'CC0 https://polyhaven.com/license'}}
(ROOT/'nodes.json').write_text(json.dumps(metadata,indent=2))
(ROOT/'NOTES.md').write_text('Four-door HMMWV troop/cargo, low canvas cab roof and cargo cover, detailed interior; Forge Garage v2.\n'+f'{triangles:,} triangles; {GLB.stat().st_size/1048576:.2f} MiB GLB.\n'+'8 materials: Paint, Rubber, Metal, Canvas, Interior, Glass (BLEND), Lamps (emissive), Dust.\n'+'Cycles GPU tile bakes: base/normal 1024 maximum, ORM 512 maximum; smaller glass/lamp maps.\n'+'Poly Haven CC0: metal_plate, rubberized_track, denim_fabric, fabric_leather_01, rusty_metal; https://polyhaven.com/license\n'+'Reference differences: cargo bed has a removable low canvas cover per brief (sheet shows an open tray); simplified underbody; no insignia.\n')
# Render from the actual export, proving factor and transparency survive serialization.
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
bpy.data.node_groups.remove(bpy.data.node_groups['glTF Material Output'],do_unlink=True)
bpy.ops.import_scene.gltf(filepath=str(GLB))
render_asset=[o for o in SC.objects if o.type=='MESH']
# Import camera convention is back to Blender Z-up.
paint=next(m for o in render_asset for m in o.data.materials if m.name.startswith('Paint'))
# The imported shader has a multiply node for the exported baseColorFactor.
pbs=next(n for n in paint.node_tree.nodes if n.type=='BSDF_PRINCIPLED')
def set_paint(rgb):
 nt=paint.node_tree; bs=pbs
 # Preserve original neutral bitmap, rebuild only the scalar multiply.
 tex=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and ('base' in n.image.name.lower()))
 mix=nt.nodes.get('VariantFactor')
 if mix is None:mix=nt.nodes.new('ShaderNodeMixRGB');mix.name='VariantFactor';mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(tex.outputs['Color'],mix.inputs[1]);nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
 mix.inputs[2].default_value=rgb+[1.]
set_paint(PAINTS[0][1])
# neutral daylight presentation only, excluded from exported file
floor=bpy.data.materials.new('StudioFloor');floor.diffuse_color=(.23,.255,.26,1);floor.use_nodes=True
floor.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.23,.255,.26,1);floor.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.85
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.004));ground=bpy.context.object;ground.name='RenderGround';ground.data.materials.append(floor)
SC.world.color=(.3,.3,.3);SC.world.use_nodes=True;SC.world.node_tree.nodes['Background'].inputs[0].default_value=(.68,.77,.88,1);SC.world.node_tree.nodes['Background'].inputs[1].default_value=.55
for name,loc,power,size,color in [('Softbox',(2.6,-4.5,7),1600,5.0,(1,.93,.83)),('Fill',(-4,-1,4),950,4,(.79,.87,1)),('Rim',(1,4.0,5.0),1900,3.0,(1,1,.95))]:
 data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;data.color=color;ob=bpy.data.objects.new(name,data);SC.collection.objects.link(ob);ob.location=loc;ob.rotation_euler=(Vector((0,0,.7))-ob.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.camera_add();cam=bpy.context.object;SC.camera=cam;cam.data.type='ORTHO';cam.data.ortho_scale=6.25
cam.location=(5.6,-7.4,3.05);target=Vector((0,0,.87));cam.rotation_euler=(target-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=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
# Orthographic front for width/cab/track verification.
cam.location=(0,-8,.94);cam.rotation_euler=(Vector((0,0,.94))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=3.9
SC.render.filepath=str(ROOT/'front.png');bpy.ops.render.render(write_still=True)
# Orthographic side, matching sheet.
cam.location=(7,0,.94);cam.rotation_euler=(Vector((0,0,.94))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=5.5
SC.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
cam.location=(5.6,-7.4,3.05);cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=6.25
clay=bpy.data.materials.new('Clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.34,.34,.34,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.75
saved=[]
for o in render_asset:
 saved.append((o,list(o.data.materials)))
 for j in range(len(o.data.materials)):o.data.materials[j]=clay
SC.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True)
for o,mats in saved:
 for j,m in enumerate(mats):o.data.materials[j]=m
# Six low-sample colour views in one 3x2 contact sheet, composed from pixels.
SC.render.resolution_x=640;SC.render.resolution_y=360;SC.cycles.samples=12
canvas=np.zeros((720,1920,4),dtype=np.float32)
for j,(name,rgb) in enumerate(PAINTS):
 set_paint(rgb);SC.render.filepath=str(CACHE/f'variant_{j}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(SC.render.filepath,check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape((360,640,4));y=(1-j//3)*360;x=(j%3)*640;canvas[y:y+360,x:x+640]=arr;bpy.data.images.remove(im)
im=bpy.data.images.new('Paint variants',width=1920,height=720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
set_paint(PAINTS[0][1])
print('COMPLETE',GLB.stat().st_size,triangles,metadata['bounding_box'],flush=True)
