"""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.
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=20; 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=(1.3,1,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=.29;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,.10)
R=bake_material('Rubber','rubberized_track',(.048,.052,.052),.88,0,.22)
M=bake_material('Metal','metal_plate',(.085,.094,.080),.66,.64,.12)
C=bake_material('Canvas','denim_fabric',(.040,.043,.027),.97,0,.42)
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,.36),(.72,.8,.11),M,.035)
for y in [-1.65,1.65]:
 cyl((0,y,.39),.13,.27,M,(0,1,0),28)
 for s in [-1,1]:
  beam((s*.13,y,.39),(s*.91,y,.44),.055,M)
  for dy in [-.14,.14]:
   beam((s*.50,y+dy,.50),(s*.91,y,.40),.048,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,.39),(0,1.6,.39),.052,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.
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)
  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.07,2.19)
  top=1.08 if front else 1.14
  # arch radius with wide shoulders
  arc=[(cy+.56*cos(a),.455+.565*sin(a)) for a in [.48+i*(pi-.96)/16 for i in range(17)]]
  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+18,k+17) for k in range(16)],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)
# Cab front bulkhead and windscreen, slope back towards roof.
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)
# Cargo tray; open behind the four-door canvas cab as depicted in sheet.jpg.
box((0,1.65,.765),(1.82,1.06,.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.64,1.127),(.056,1.12,.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)
# Front radiator: framed panel with nine real dark slots and narrow vanes.
box((0,-2.088,.938),(1.94,.055,.325),P,.025)
box((0,-2.122,.954),(.91,.025,.265),R,.025)
for x in [-.44+i*.11 for i in range(9)]:box((x,-2.144,.954),(.037,.030,.259),P,.016)
for z in [.816,1.089]:box((0,-2.143,z),(.95,.032,.028),P,.012)
# 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)
# 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,.365),.074,.57,M,(0,1,0),24)
tube([(-.61,.97,.37),(-.72,1.18,.34),(-.86,1.23,.33),(-1.014,1.25,.35)],.023,M,12)
cyl((-1.026,1.253,.351),.019,.006,R,(1,0,0),20)
# 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)
BODY=B.finish()
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*.846,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=72
  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(40):
   for row in [-1,0,1]:
    a=2*pi*(j+(row%2)*.45)/40
    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),64,6)
  for j in range(48):
   a=2*pi*j/48
   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(64):
    a=j*2*pi/64;rv.append(tuple(center+Vector((s*dx,radius*sin(a),radius*cos(a)))))
  for k in range(len(profile)-1):
   for j in range(64):rf.append((k*64+j,k*64+(j+1)%64,(k+1)*64+(j+1)%64,(k+1)*64+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),64,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,.351),'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():
 ob=bpy.data.objects.new(name,None);SC.collection.objects.link(ob);ob.location=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)
# 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, canvas cab roof, open rear tray, 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: no lettering/insignia; simplified underbody and fabric folds; canvas covers cab, rear cargo tray open as on sheet.\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.7);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 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.7);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)
