"""M35-style cargo truck. Blender 5.2; clean deterministic build, CC0 tile bakes, GLB and review renders."""
import bpy, math, random, json, os, pathlib, subprocess, struct
import numpy as np
from mathutils import Vector
from math import sin,cos,pi
ROOT=pathlib.Path(__file__).resolve().parent
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
random.seed(35)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.edit.use_global_undo=False
sc=bpy.context.scene;sc.render.engine='CYCLES';sc.cycles.samples=20
prefs=bpy.context.preferences.addons['cycles'].preferences;prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
sc.cycles.device='GPU';sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG';sc.render.film_transparent=False
sc.world=bpy.data.worlds.new('Daylight');sc.world.use_nodes=True;sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.82,1);sc.world.node_tree.nodes['Background'].inputs[1].default_value=.45
sc.view_settings.view_transform='AgX'
M={};parts={};group='Body'
def activate(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def basic(name,col,rough=.7,metal=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['Metallic'].default_value=metal;M[name]=m;return m

def source_image(asset,typ):
 p=TEX/f'{asset}_{typ}_1k.jpg'
 if not p.exists():subprocess.run(['curl','-f','-L','--retry','2','-A','Mozilla/5.0','-o',str(p),f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{typ}_1k.jpg'],check=True)
 im=bpy.data.images.load(str(p),check_existing=True)
 if typ!='diff':im.colorspace_settings.name='Non-Color'
 return im

def baked_material(name,asset,tint,rough,metal=0):
 m=basic(name,tint,rough,metal);nt=m.node_tree;n=nt.nodes;l=nt.links;p=n.get('Principled BSDF');out=n.get('Material Output')
 uv=n.new('ShaderNodeTexCoord');ims={}
 for typ in ['diff','nor_gl','arm']:
  q=n.new('ShaderNodeTexImage');q.image=source_image(asset,typ);l.new(uv.outputs['UV'],q.inputs['Vector']);ims[typ]=q
 bw=n.new('ShaderNodeRGBToBW');l.new(ims['diff'].outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');l.new(bw.outputs[0],ramp.inputs[0]);ramp.color_ramp.elements[0].position={'Rubber':.002,'Paint':.008,'Metal':.008,'Wood':.008}.get(name,.02);ramp.color_ramp.elements[1].position={'Paint':.14,'Metal':.14,'Rubber':.075,'Wood':.22,'Canvas':.40,'Interior':.20}[name]
 if name=='Paint':low=(.40,.40,.40);high=(.98,.98,.98)
 else:low=tuple(c*.48 for c in tint);high=tuple(min(c*1.45,1) for c in tint)
 ramp.color_ramp.elements[0].color=(*low,1);ramp.color_ramp.elements[1].color=(*high,1)
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=65;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.14;l.new(ramp.outputs[0],mix.inputs[1]);l.new(noise.outputs['Fac'],mix.inputs[2]);l.new(mix.outputs[0],p.inputs['Base Color'])
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.3 if name in ['Paint','Metal'] else .65;l.new(ims['nor_gl'].outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-20));tile=bpy.context.object;tile.data.materials.append(m)
 baked={}
 for typ,res in [('base',1024 if name in ['Paint','Canvas','Wood'] else 512),('normal',512),('orm',512)]:
  im=bpy.data.images.new(name+'_'+typ,res,res,alpha=False);im.colorspace_settings.name='sRGB' if typ=='base' else 'Non-Color'
  dest=n.new('ShaderNodeTexImage');dest.image=im;n.active=dest
  if typ=='normal':l.new(p.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
  else:
   em=n.new('ShaderNodeEmission')
   if typ=='base':l.new(mix.outputs[0],em.inputs[0])
   else:
    sep=n.new('ShaderNodeSeparateColor');l.new(ims['arm'].outputs[0],sep.inputs[0]);cmb=n.new('ShaderNodeCombineColor');cmb.inputs[0].default_value=1;cmb.inputs[2].default_value=metal
    rm=n.new('ShaderNodeMath');rm.operation='MULTIPLY_ADD';rm.inputs[1].default_value=.18;rm.inputs[2].default_value=rough-.09;l.new(sep.outputs[1],rm.inputs[0]);l.new(rm.outputs[0],cmb.inputs[1]);l.new(cmb.outputs[0],em.inputs[0])
   l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=str(TEX/(name+'_'+typ+('.jpg' if typ=='base' else '.png')));im.file_format='JPEG' if typ=='base' else 'PNG';im.save();baked[typ]=im
 bpy.data.objects.remove(tile,do_unlink=True)
 n.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0]);p.inputs['Roughness'].default_value=rough
 for typ,im in baked.items():
  q=n.new('ShaderNodeTexImage');q.image=im
  if typ=='base':
   if name=='Paint':
    mul=n.new('ShaderNodeMixRGB');mul.name='PaintTint';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(.10,.12,.06,1);l.new(q.outputs[0],mul.inputs[1]);l.new(mul.outputs[0],p.inputs['Base Color'])
   else:l.new(q.outputs[0],p.inputs['Base Color'])
  elif typ=='normal':
   nm=n.new('ShaderNodeNormalMap');l.new(q.outputs[0],nm.inputs[0 if False else 'Color']);l.new(nm.outputs[0],p.inputs['Normal'])
  else:
   sep=n.new('ShaderNodeSeparateColor');l.new(q.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],p.inputs['Roughness']);l.new(sep.outputs[2],p.inputs['Metallic'])
 print('BAKED',name,flush=True)
 return m

# Photographic sources are CC0: https://polyhaven.com/license
baked_material('Paint','green_metal_rust',(.8,.8,.8),.78,.12)
baked_material('Rubber','rubberized_track',(.035,.031,.027),.91)
baked_material('Metal','green_metal_rust',(.11,.105,.087),.66,.65)
baked_material('Canvas','denim_fabric',(.066,.073,.037),.95)
baked_material('Wood','wood_planks_grey',(.17,.12,.075),.86)
baked_material('Interior','fabric_leather_01',(.065,.072,.04),.92)
glass=basic('Glass',(.16,.22,.23),.16);p=glass.node_tree.nodes.get('Principled BSDF');p.inputs['Alpha'].default_value=.24;glass.surface_render_method='BLENDED'
lamps=basic('Lamps',(.85,.66,.37),.25);p=lamps.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Color'].default_value=(.7,.38,.10,1);p.inputs['Emission Strength'].default_value=.25

# Bake a tiny lens palette so all head, marker and tail lights share one emissive material.
nt=lamps.node_tree;n=nt.nodes;l=nt.links;out=n.get('Material Output');p=n.get('Principled BSDF')
uv=n.new('ShaderNodeTexCoord');sep=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],sep.inputs[0]);ramp=n.new('ShaderNodeValToRGB');l.new(sep.outputs[0],ramp.inputs[0]);ramp.color_ramp.interpolation='CONSTANT'
ramp.color_ramp.elements[0].color=(.34,.36,.34,1);ramp.color_ramp.elements[1].position=.66;ramp.color_ramp.elements[1].color=(.34,.008,.003,1);ramp.color_ramp.elements.new(.33).color=(.64,.18,.014,1)
em=n.new('ShaderNodeEmission');l.new(ramp.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);im=bpy.data.images.new('Lamps_base',32,32,alpha=False);dest=n.new('ShaderNodeTexImage');dest.image=im;n.active=dest
bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-20));tile=bpy.context.object;tile.data.materials.append(lamps);bpy.ops.object.bake(type='EMIT');im.filepath_raw=str(TEX/'Lamps_base.png');im.file_format='PNG';im.save();bpy.data.objects.remove(tile,do_unlink=True)
l.new(p.outputs[0],out.inputs[0]);l.new(dest.outputs[0],p.inputs['Base Color']);l.new(dest.outputs[0],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=.03

def finish(o,name,mat,bevel=0,smooth=False):
 o.name=name;o.data.materials.append(M[mat]);activate(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if bevel:
  mod=o.modifiers.new('Edge radii','BEVEL');mod.width=bevel;mod.segments=1 if bevel<.025 else 3
  bpy.ops.object.modifier_apply(modifier=mod.name)
 if smooth:
  for f in o.data.polygons:f.use_smooth=True
 if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
 # Local metric triplanar projection, tiled instead of a vehicle-wide atlas.
 uv=o.data.uv_layers.active.data
 for poly in o.data.polygons:
  axis=max(range(3),key=lambda a:abs(poly.normal[a]));axes=[a for a in range(3) if a!=axis]
  for li in poly.loop_indices:
   co=o.data.vertices[o.data.loops[li].vertex_index].co
   uv[li].uv=(co[axes[0]]*1.3,co[axes[1]]*1.3)
   if mat=='Wood':uv[li].uv=((co.z if axis==0 else co.x)+.10,co.y*.65+o.location.z*.17)
   if mat=='Lamps':uv[li].uv=(.83 if 'Taillight' in name else .50 if 'Marker' in name else .16,.5)
 parts.setdefault(group,[]).append(o);return o

def box(name,loc,dim,mat='Paint',bev=.012):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.dimensions=dim;return finish(o,name,mat,bev)
def mesh(name,verts,faces,mat='Paint',bev=0,smooth=False):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(name,me);sc.collection.objects.link(o);return finish(o,name,mat,bev,smooth)
def rod(name,a,b,r,mat='Metal',verts=12):
 a,b=Vector(a),Vector(b);v=b-a;bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=r,depth=v.length,location=(a+b)/2);o=bpy.context.object;o.rotation_euler=v.to_track_quat('Z','Y').to_euler();return finish(o,name,mat,0 if r<.04 else min(.003,r*.12),True)
def tube(name,pts,r,mat='Metal',res=8):
 pts=[p for i,p in enumerate(pts) if i==0 or (Vector(p)-Vector(pts[i-1])).length>1e-6]
 v=[];f=[]
 for i,p in enumerate(pts):
  tangent=Vector(pts[min(i+1,len(pts)-1)])-Vector(pts[max(0,i-1)]);tangent.normalize();a=tangent.cross(Vector((0,0,1)))
  if a.length<.1:a=tangent.cross(Vector((0,1,0)))
  a.normalize();b=tangent.cross(a)
  for j in range(res):v.append(Vector(p)+r*(a*cos(j*2*pi/res)+b*sin(j*2*pi/res)))
 for i in range(len(pts)-1):
  for j in range(res):k=i*res+j;f.append((k,i*res+(j+1)%res,(i+1)*res+(j+1)%res,k+res))
 return mesh(name,v,f,mat,0,True)
def torus(name,loc,major,minor,mat='Metal',axis='X',seg=48,minorseg=8):
 bpy.ops.mesh.primitive_torus_add(major_segments=seg,minor_segments=minorseg,location=loc,major_radius=major,minor_radius=minor);o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=pi/2
 elif axis=='Y':o.rotation_euler[0]=pi/2
 return finish(o,name,mat,0,True)
def bolt(loc,axis='X',r=.012,mat='Metal'):
 vec={'X':(1,0,0),'Y':(0,1,0),'Z':(0,0,1)}[axis];return rod('Hex fastener',Vector(loc)-Vector(vec)*.006,Vector(loc)+Vector(vec)*.006,r,mat,6)

# Ladder frame, driveline and correctly spaced three driven axles.
for x in [-.56,.56]:box('Chassis C rail',(x,.1,.73),(.13,6.1,.23),'Metal')
for y in [-2.55,-1.4,-.2,.85,2.1,3.0]:box('Frame crossmember',(0,y,.75),(1.22,.12,.16),'Metal')
for y in [-2.2,1.08,2.2]:
 rod('Axle housing',(-1.02,y,.50),(1.02,y,.50),.075)
 bpy.ops.mesh.primitive_uv_sphere_add(segments=20,ring_count=12,location=(0,y,.5));o=bpy.context.object;o.scale=(.24,.18,.19);finish(o,'Differential','Metal',0,True)
 for x in [-.62,.62]:
  for j in range(5):
   pts=[(x,y+t,.58+j*.017+.06*(t/.58)**2) for t in np.linspace(-.58+j*.06,.58-j*.06,9)]
   vv=[];ff=[]
   for px,py,pz in pts:vv.extend([(px-.035,py,pz-.006),(px+.035,py,pz-.006),(px+.035,py,pz+.006),(px-.035,py,pz+.006)])
   for k in range(len(pts)-1):
    for q in range(4):ff.append((k*4+q,k*4+(q+1)%4,(k+1)*4+(q+1)%4,(k+1)*4+q))
   ff.extend([(3,2,1,0),tuple((len(pts)-1)*4+q for q in range(4))]);mesh('Laminated leaf spring',vv,ff,'Metal')
  rod('Damper',(x,y-.25,.56),(x,y-.40,.98),.035)
rod('Prop shaft',(0,-1.3,.64),(0,2.2,.55),.048)
box('Gearbox',(0,-1.0,.7),(.5,.62,.35),'Metal',.08)

# Wheel shells: custom section with rounded shoulder, dual tyres on both rear axles.
for tag,y in [('F',-2.2),('R',1.08),('R2',2.2)]:
 for side,s in [('L',1),('R',-1)]:
  group='Wheel_'+(tag+side if tag!='R2' else 'R'+side+'2');center=(s*(1.005 if tag=='F' else .960),y,.515)
  centers=[s*1.005] if tag=='F' else [s*.825,s*1.095]
  for xc in centers:
   width=.30 if tag=='F' else .22
   profile=[(-width/2,.31),(-width/2-.006,.39),(-width*.43,.465),(-width*.29,.505),(width*.29,.505),(width*.43,.465),(width/2+.006,.39),(width/2,.31)]
   vs=[];fs=[];N=48
   for dx,r in profile:
    for i in range(N):a=2*pi*i/N;vs.append((xc+dx,y+r*sin(a),.515+r*cos(a)))
   for j in range(len(profile)):
    for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,((j+1)%len(profile))*N+(i+1)%N,((j+1)%len(profile))*N+i))
   mesh('Rounded tyre casing',vs,fs,'Rubber',0,True)
   for i in range(32):
    a=2*pi*i/32
    for row in [-1,1]:
     o=box('Chevron tread',(xc+row*width*.22,y+.508*sin(a),.515+.508*cos(a)),(width*.49,.044,.022),'Rubber',.006);o.rotation_euler[0]=-a;o.rotation_euler[2]=row*.40
   outer=xc+s*width*.49
   for rr in [.40]:torus('Tyre sidewall moulding',(outer-s*.002,y,.515),rr,.0035,'Rubber',seg=40,minorseg=4)
   # Pressed steel dish curves back from the rim bead toward the bolt circle.
   dish=[];df=[];rings=[(.0,.075),(.11,.075),(.19,.066),(.24,width*.36),(.29,width*.49),(.31,width*.49)]
   for radius,depth in rings[1:]:
    for k in range(48):a=k*2*pi/48;dish.append((xc+s*depth,y+radius*sin(a),.515+radius*cos(a)))
   for j in range(4):
    for k in range(48):df.append((j*48+k,j*48+(k+1)%48,(j+1)*48+(k+1)%48,(j+1)*48+k))
   mesh('Dished steel wheel',dish,df,'Paint',0,True)
   torus('Rim bead',(outer,y,.515),.298,.022,'Paint',seg=40,minorseg=6)
   torus('Rim inset',(xc+s*width*.38,y,.515),.246,.014,'Metal',seg=40,minorseg=6)
   rod('Hub',(xc+s*.063,y,.515),(outer+s*.07,y,.515),.113,'Paint',32)
   for j in range(6):
    a=j*2*pi/6;bolt((xc+s*.075,y+.163*sin(a),.515+.163*cos(a)),r=.016)
    rod('Rim vent',(xc+s*width*.335,y+.225*sin(a+.3),.515+.225*cos(a+.3)),(xc+s*(width*.335+.004),y+.225*sin(a+.3),.515+.225*cos(a+.3)),.038,'Rubber',12)
  # preserve wheel-group pivot separately from dual tyre placements
  parts[group+'_pivot']=center

group='Body'
# Rounded bonnet loft: soft crown across long, tapered engine hood.
vs=[];fs=[]
sections=[(-3.04,.72,1.52),(-2.98,.77,1.60),(-2.70,.79,1.64),(-1.72,.83,1.69),(-1.63,.81,1.67)]
profile=[(-1,-.31),(-1,-.05),(-.95,.025),(-.75,.06),(0,.082),(.75,.06),(.95,.025),(1,-.05),(1,-.31)]
for y,w,h in sections:
 for px,pz in profile:vs.append((px*w,y,h+pz))
N=len(profile)
for j in range(len(sections)-1):
 for i in range(N-1):fs.append((j*N+i,j*N+i+1,(j+1)*N+i+1,(j+1)*N+i))
fs += [tuple(reversed(range(N))),tuple((len(sections)-1)*N+i for i in range(N))]
mesh('Crowned bonnet',vs,fs,'Paint',.018,True)
for s in [-1,1]:
 box('Engine side panel',(s*.791,-2.34,1.34),(.032,1.40,.33),'Paint')
 for yy in [-2.76,-1.88]:
  rod('Hood latch',(s*.819,yy,1.38),(s*.819,yy,1.48),.012)
  box('Latch foot',(s*.821,yy,1.36),(.02,.048,.024),'Metal',.004)
 tube('Hood side seam',[(s*.80,-2.99,1.51),(s*.83,-1.68,1.57)],.006)
 # Extruded angular fenders with rolled edge, not a solid cube over wheels.
 prof=[(-2.98,.94),(-2.66,1.28),(-1.88,1.28),(-1.52,.93)]
 v=[(s*x,y,z) for x in [.76,1.16] for y,z in prof]+[(s*x,y,z-.024) for x in [.76,1.16] for y,z in prof]
 f=[]
 for i in range(3):f.extend([(i,i+1,5+i,4+i),(8+i,12+i,13+i,9+i),(i,8+i,9+i,i+1),(4+i,5+i,13+i,12+i)])
 f.extend([(0,4,12,8),(3,11,15,7)])
 mesh('Pressed front wing',v,f,'Paint',.012)
 tube('Fender rolled lip',[(s*1.166,y,z) for y,z in prof],.010,'Paint')
 box('Cab entry step',(s*.94,-.93,.53),(.44,.97,.085),'Paint')
 for j in range(6):box('Step grip',(s*.95,-1.32+j*.14,.578),(.35,.026,.012),'Metal',.003)
 box('Battery storage',(s*.87,-.94,.73),(.38,.84,.32),'Paint',.026)
 for yy in [-1.27,-.60]:bolt((s*1.066,yy,.80))
# Radiator face, mesh dark core, grille protection.
box('Radiator core',(0,-3.00,1.20),(1.43,.09,.69),'Rubber',.02)
for x in [-.755,.755]:box('Grille frame',(x,-3.073,1.24),(.065,.09,.78),'Paint')
for z in [.86,1.61]:box('Grille frame',(0,-3.073,z),(1.55,.09,.06),'Paint')
for i in range(17):rod('Vertical grille bar',(-.68+i*.085,-3.115,.90),(-.68+i*.085,-3.115,1.57),.011,'Paint',8)
for z in [1.00,1.15,1.31,1.47]:rod('Radiator fins',(-.71,-3.06,z),(.71,-3.06,z),.009,'Metal',6)
box('Front bumper',(0,-3.275,.76),(2.16,.16,.23),'Paint',.025)
for x in [-.68,.68]:
 torus('Tow shackle',(x,-3.37,.82),.058,.014,'Metal',axis='Y',seg=20)
 box('Tow lug',(x,-3.29,.85),(.065,.13,.07),'Paint')
rod('Winch drum',(-.36,-3.16,.88),(.36,-3.16,.88),.10,'Metal',32)
for i in range(24):torus('Winch cable',(-.28+i*.023,-3.16,.88),.10,.008,'Metal',seg=20,minorseg=6)
for s in [-1,1]:
 x=s*.60
 rod('Headlamp housing',(x,-3.03,1.37),(x,-3.135,1.37),.13,'Paint',32)
 rod('Headlamp glass',(x,-3.14,1.37),(x,-3.154,1.37),.105,'Lamps',32)
 torus('Lamp rim',(x,-3.157,1.37),.113,.012,'Metal','Y',32)
 for j in [-3,-2,-1,0,1,2,3]:
  xx=j*.024;half=(.099**2-xx**2)**.5
  rod('Lens fluting',(x+xx,-3.156,1.37-half),(x+xx,-3.156,1.37+half),.0014,'Metal',6)
 for j in [-1,0,1]:rod('Headlight guard',(x+j*.061,-3.179,1.28),(x+j*.061,-3.179,1.46),.006,'Paint',6)
 rod('Marker mount',(s*1.02,-2.76,1.18),(s*1.02,-2.76,1.35),.028,'Paint')
 rod('Marker lamp',(s*1.02,-2.79,1.35),(s*1.02,-2.83,1.35),.052,'Lamps',20)
# Open cab shell and divided windshield.
box('Cab floor',(0,-1.00,.99),(1.85,1.35,.12),'Paint')
box('Cab rear wall',(0,-.31,1.40),(1.85,.09,.84),'Paint',.025)
box('Cowl',(0,-1.62,1.46),(1.82,.18,.46),'Paint',.04)
for s in [-1,1]:
 box('Door lower',(s*.93,-.95,1.35),(.065,1.24,.67),'Paint',.025)
 tube('Door perimeter',[(s*.970,-1.51,1.62),(s*.970,-1.51,1.05),(s*.970,-.39,1.05),(s*.970,-.39,1.62)],.006,'Metal')
 rod('Door pull',(s*.977,-.66,1.56),(s*.977,-.49,1.56),.016,'Metal')
 for z in [1.16,1.50]:rod('Door hinge',(s*.974,-1.48,z-.043),(s*.974,-1.48,z+.043),.019,'Paint')
 rod('Rear cab roof support',(s*.9,-.32,1.64),(s*.9,-.32,2.42),.025,'Paint')
 rod('Windscreen pillar',(s*.86,-1.61,1.71),(s*.86,-1.48,2.36),.029,'Paint')
 for xoff in [0]:
  rod('Mirror lower arm',(s*.90,-1.49,1.64),(s*1.155,-1.38,1.94),.012)
  rod('Mirror brace',(s*.90,-1.48,2.12),(s*1.155,-1.38,1.94),.010)
  box('Mirror casing',(s*1.17,-1.38,2.01),(.055,.15,.30),'Paint',.045)
  box('Mirror surface',(s*1.17,-1.293,2.01),(.039,.008,.255),'Glass',.016)
for z,y in [(1.73,-1.60),(2.36,-1.48)]:rod('Windshield frame',(-.87,y,z),(.87,y,z),.027,'Paint')
rod('Windshield divider',(0,-1.61,1.73),(0,-1.48,2.36),.027,'Paint')
for s in [-1,1]:
 v=[(s*.035,-1.597,1.77),(s*.82,-1.597,1.77),(s*.82,-1.487,2.32),(s*.035,-1.487,2.32)]
 mesh('Windscreen glass',v,[(0,1,2,3)],'Glass')
 tube('Window rubber seal',v+[v[0]],.012,'Rubber')
 rod('Wiper arm',(s*.39,-1.522,2.28),(s*.64,-1.593,1.89),.008)
 rod('Wiper blade',(s*.42,-1.586,1.90),(s*.77,-1.586,1.90),.013,'Rubber')
# Seats, dash, gauges and driver controls.
box('Dashboard',(0,-1.48,1.56),(1.62,.13,.32),'Interior',.025)
for x,z,r in [(.33,1.62,.065),(.49,1.62,.041),(.17,1.62,.041),(-.03,1.62,.036),(.31,1.48,.03)]:
 rod('Gauge bezel',(x,-1.398,z),(x,-1.375,z),r,'Metal',24)
 rod('Gauge dial',(x,-1.371,z),(x,-1.367,z),r*.83,'Rubber',24)
 rod('Gauge needle',(x,-1.362,z),(x+r*.5,-1.362,z+r*.25),.003,'Lamps',6)
for x in [-.52,-.32,-.14]:rod('Dashboard switch',(x,-1.40,1.51),(x,-1.34,1.51),.013,'Metal')
for x in [-.47,.47]:
 box('Seat cushion',(x,-.79,1.20),(.64,.57,.16),'Interior',.075)
 o=box('Seat back',(x,-.47,1.49),(.64,.13,.55),'Interior',.075);o.rotation_euler[0]=-.10
 for xx in [x-.25,x+.25]:rod('Seat frame',(xx,-.91,1.02),(xx,-.60,1.24),.021)
rod('Steering column',(.47,-1.40,1.29),(.47,-1.03,1.73),.027)
group='SteeringWheel';hub=(.47,-1.03,1.73)
torus('Steering rim',hub,.20,.016,'Rubber',axis='Z',seg=48)
for a in [0,2*pi/3,4*pi/3]:rod('Steering spoke',hub,(hub[0]+.18*cos(a),hub[1]+.18*sin(a),hub[2]),.012)
rod('Steering hub',(.47,-1.03,1.70),(.47,-1.03,1.75),.043)
parts['SteeringWheel_pivot']=hub
from mathutils import Matrix
rot=Matrix.Rotation(math.radians(32),4,'X')
for obj in parts['SteeringWheel']:
 obj.matrix_world=Matrix.Translation(Vector(hub))@rot@Matrix.Translation(-Vector(hub))@obj.matrix_world
group='Body'
for x,y in [(.12,-.94),(-.05,-.89),(-.17,-.86)]:
 rod('Gear lever',(x,y,1.03),(x,y-.09,1.43),.012);bpy.ops.mesh.primitive_uv_sphere_add(segments=12,ring_count=8,radius=.029,location=(x,y-.09,1.43));finish(bpy.context.object,'Gear knob','Rubber',0,True)
# Fuel tank and exhaust with perforated heat guard represented in geometry.
box('Fuel tank',(.87,.25,.70),(.55,.84,.40),'Paint',.09)
for y in [-.03,.53]:box('Tank strap',(1.153,y,.7),(.025,.042,.36),'Metal',.01)
rod('Filler neck',(.99,.12,.88),(.99,.12,.96),.048,'Metal',24)
rod('Exhaust stack',(-.91,-1.45,.85),(-.91,-1.45,2.45),.041,'Metal',20)
tube('Exhaust bend',[(-.91,-1.45,2.39),(-.91,-1.45,2.49),(-.91,-1.39,2.54)],.042,'Metal')
for z in np.linspace(1.37,1.86,13):torus('Heat shield ring',(-.91,-1.45,z),.061,.006,'Metal','Z',16,6)
for a in range(8):
 t=a*pi/4;rod('Shield strip',(-.91+.061*cos(t),-1.45+.061*sin(t),1.37),(-.91+.061*cos(t),-1.45+.061*sin(t),1.86),.006)
# Bed steel sill, wooden deck, drop sides, troop seating.
box('Cargo bed floor',(0,1.56,1.18),(2.30,3.56,.13),'Paint')
for i in range(13):box('Bed deck plank',(-1.05+i*.175,1.56,1.27),(.168,3.47,.065),'Wood',.004)
for s in [-1,1]:
 box('Bed lower side',(s*1.14,1.57,1.43),(.06,3.55,.36),'Paint')
 for z in [1.25,1.63]:box('Bed flange',(s*1.16,1.57,z),(.09,3.60,.065),'Paint')
 for y in [-.17,.69,1.55,2.41,3.30]:
  box('Side rib',(s*1.182,y,1.44),(.045,.065,.37),'Paint',.006)
  box('Stake',(s*1.105,y,1.96),(.065,.065,.72),'Paint',.008)
  for z in [1.30,1.59,1.85,2.11]:bolt((s*(1.209 if z<1.7 else 1.141),y,z),r=.010)
  torus('Rope lashing eye',(s*1.20,y,1.66),.024,.006,'Metal',seg=16,minorseg=6)
 for z in [1.79,1.95,2.11]:box('Wooden side board',(s*1.104,1.55,z),(.045,3.44,.125),'Wood',.006)
 for j in range(3):box('Troop seat plank',(s*(.71+j*.10),1.55,1.59),(.095,3.30,.045),'Wood',.004)
 for y in [.05,1.1,2.1,3.06]:
  rod('Bench leg',(s*.87,y,1.29),(s*.87,y,1.58),.022,'Paint')
  rod('Bench brace',(s*.61,y,1.58),(s*1.08,y,1.29),.014,'Paint')
 box('Rear mudflap',(s*.93,2.85,.69),(.49,.045,.60),'Rubber',.007)
box('Tailgate',(0,3.30,1.45),(2.26,.075,.37),'Paint')
for x in [-.75,0,.75]:
 box('Tailgate rib',(x,3.355,1.45),(.055,.035,.35),'Paint',.006)
 rod('Tailgate hinge',(x-.09,3.365,1.28),(x+.09,3.365,1.28),.025,'Metal')
box('Rear crossmember',(0,3.22,1.02),(2.20,.18,.16),'Paint')
for s in [-1,1]:
 rod('Taillight housing',(s*.89,3.335,1.44),(s*.89,3.39,1.44),.068,'Metal',24)
 rod('Taillight lens',(s*.89,3.391,1.44),(s*.89,3.40,1.44),.052,'Lamps',24)
 tube('Tailgate chain',[(s*1.03,3.35,1.61),(s*.99,3.39,1.45),(s*.91,3.37,1.32)],.009,'Metal')
# Hoops and soft fabric roof, with drape between supports and sewn edge rolls.
def roof(name,y0,y1,width,edge,top,nx,ny):
 vs=[];fs=[]
 for j in range(ny+1):
  y=y0+(y1-y0)*j/ny;t=(y-y0)/(y1-y0)
  for i in range(nx+1):
   u=-1+2*i/nx;x=width*u
   arch=(max(0,1-abs(u)**6))**.5
   sag=.028*sin(t*pi*4)**2*(.35+.65*abs(u))
   wrinkle=.009*sin(t*72+u*11)*abs(u)**5+.004*sin(t*37+u*24)
   z=edge+(top-edge)*arch-sag+wrinkle
   vs.append((x,y,z))
 for j in range(ny):
  for i in range(nx):k=j*(nx+1)+i;fs.append((k,k+1,k+nx+2,k+nx+1))
 o=mesh(name,vs,fs,'Canvas',0,True);activate(o);md=o.modifiers.new('Canvas thickness','SOLIDIFY');md.thickness=.008;bpy.ops.object.modifier_apply(modifier=md.name)
 for s in [-1,1]:
  pts=[(s*width,y0+(y1-y0)*j/ny,edge-.028*sin(j/ny*pi*4)**2+.009*sin(j/ny*72+s*11)) for j in range(ny+1)]
  tube('Sewn rolled canvas hem',pts,.012,'Canvas')
 return o
roof('Troop canvas roof',-.24,3.34,1.18,2.52,2.827,20,36)
for y in [-.17,.69,1.55,2.41,3.28]:
 pts=[(-1.105,y,1.63),(-1.105,y,2.49)]+[(1.105*u,y,2.49+.31*max(0,1-abs(u)**6)**.5) for u in np.linspace(-1,1,25)]+[(1.105,y,1.63)]
 tube('Canvas support hoop',pts,.023,'Paint')
 for s in [-1,1]:
  tube('Canvas tie cord',[(s*1.18,y,2.52),(s*1.15,y+.035,2.44),(s*1.108,y,2.36)],.006,'Canvas')
# Cab fabric cap and short rear curtain; sides remain open.
roof('Cab canvas roof',-1.57,-.24,.95,2.38,2.48,16,20)
v=[];f=[]
for j in range(17):
 for i in range(33):
  x=-.94+1.88*i/32;z=1.67+.71*j/16;y=-.255+.024*sin(i*.9)*sin(j*pi/16);v.append((x,y,z))
for j in range(16):
 for i in range(32):k=j*33+i;f.append((k,k+1,k+34,k+33))
mesh('Cab rear canvas curtain',v,f,'Canvas',0,True)
# Closed rear tarp, patterned to the rounded hoop with gravity folds and lacing.
v=[];f=[];nx=32;ny=18
for j in range(ny+1):
 t=j/ny
 for i in range(nx+1):
  u=-1+2*i/nx;top=2.51+.30*max(0,1-abs(u)**6)**.5
  x=1.13*u;z=1.68+(top-1.68)*t
  y=3.31+.034*sin(u*19+t*3)*sin(pi*t)+.018*sin(u*39-t*8)*(1-t)
  v.append((x,y,z))
for j in range(ny):
 for i in range(nx):k=j*(nx+1)+i;f.append((k,k+1,k+nx+2,k+nx+1))
o=mesh('Rear canvas curtain',v,f,'Canvas',0,True)
activate(o);md=o.modifiers.new('Curtain cloth thickness','SOLIDIFY');md.thickness=.006;bpy.ops.object.modifier_apply(modifier=md.name)
tube('Rear tarp bottom hem',[(1.13*u,3.31+.018*sin(u*39),1.68) for u in np.linspace(-1,1,49)],.015,'Canvas')
for x in [-1.02,-.68,-.34,0,.34,.68,1.02]:
 torus('Rear curtain eyelet',(x,3.337,1.70),.012,.004,'Metal','Y',12,6)
 tube('Rear tarp tie',[(x,3.35,1.70),(x+.035,3.36,1.64),(x,3.36,1.58)],.005,'Canvas')

# Irregular edge chips on working panels: real submillimetre-thick flecks of exposed metal.
for sign in [-1,1]:
 for k in range(90):
  yy=random.uniform(-.12,3.28);zz=random.choice([1.25,1.63])+random.uniform(-.018,.018)
  length=random.uniform(.008,.040);height=random.uniform(.0015,.006)
  mesh('Bed edge abrasion',[(sign*1.2052,yy-length,zz),(sign*1.2052,yy,zz+height),(sign*1.2052,yy+length*.65,zz),(sign*1.2052,yy+length*.2,zz-height*.5)],[(0,1,2,3)],'Metal')
 for k in range(32):
  yy=random.uniform(-1.43,-.43);zz=1.075+random.uniform(-.008,.016);ll=random.uniform(.004,.020)
  mesh('Door sill wear',[(sign*.9627,yy-ll,zz),(sign*.9627,yy+ll,zz+.003),(sign*.9627,yy,zz+.009)],[(0,1,2)],'Metal')

# Consolidate only rigid/moving assemblies, apply rotations, and set exact pivots.
assets=[]
for key,objs in list(parts.items()):
 if key.endswith('_pivot'):continue
 bpy.ops.object.select_all(action='DESELECT')
 for o in objs:o.select_set(True)
 bpy.context.view_layer.objects.active=objs[0];bpy.ops.object.join();o=bpy.context.object;o.name=key
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 sc.cursor.location=parts.get(key+'_pivot',(0,0,0));bpy.ops.object.origin_set(type='ORIGIN_CURSOR');assets.append(o)
 # Recalculate consistent outside winding and remove exact duplicate vertices.
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=1e-6);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bmesh.ops.triangulate(bm,faces=bm.faces[:]);bad=[f for f in bm.faces if f.calc_area()<1e-12];bmesh.ops.delete(bm,geom=bad,context='FACES');bm.to_mesh(o.data);bm.free()

sockets={'Seat_Driver':(.47,-.79,1.32),'Door_L':(.95,-1.48,1.40),'Door_R':(-.95,-1.48,1.40),'Exhaust':(-.91,-1.39,2.54),'Light_Head_L':(.60,-3.16,1.37),'Light_Head_R':(-.60,-3.16,1.37),'Light_Tail_L':(.89,3.40,1.44),'Light_Tail_R':(-.89,3.40,1.44)}
for s in [1,-1]:
 for j,y in enumerate([.18,.96,1.74,2.52]):sockets['Troop_'+str(j+1+(0 if s==1 else 4))]=(s*.80,y,1.64)
for name,loc in sockets.items():o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=loc;assets.append(o)
# Fit the reference envelope exactly, retaining the axle-midpoint origin.
bpy.context.view_layer.update()
worldverts=[o.matrix_world@v.co for o in assets if o.type=='MESH' for v in o.data.vertices]
mins=Vector(tuple(min(v[i] for v in worldverts) for i in range(3)))
maxs=Vector(tuple(max(v[i] for v in worldverts) for i in range(3)))
factors=Vector((2.41/(maxs.x-mins.x),6.71/(maxs.y-mins.y),2.84/(maxs.z-mins.z)))
for o in assets:
 if o.type=='MESH':
  for v in o.data.vertices:
   for k in range(3):v.co[k]*=factors[k]
 for k in range(3):o.location[k]*=factors[k]
 o.location.z-=mins.z*factors.z
body=next(o for o in assets if o.name=='Body')
for v in body.data.vertices:v.co+=body.location
body.location=(0,0,0)
paints=[('Olive drab' ,[.10,.12,.06]),('NATO green',[.065,.105,.063]),('Desert tan',[.48,.36,.20]),('Field grey',[.20,.23,.20]),('Winter white',[.74,.76,.70]),('Dark green-black',[.027,.038,.029])]
def gamepos(p):return [round(p[0],5),round(p[2],5),round(-p[1],5)]
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(c) for o in assets if o.type=='MESH' for c in o.bound_box]
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
tri=sum(len(p.vertices)-2 for o in assets if o.type=='MESH' for p in o.data.polygons)
metadata={'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','left':'+X','nodes':[{'name':o.name,'position':gamepos(o.location),'type':o.type} for o in assets],'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]},'triangles':tri,'paints':[{'name':n,'linear_rgb':c} for n,c in paints]}
(ROOT/'nodes.json').write_text(json.dumps(metadata,indent=2))
bpy.ops.object.select_all(action='DESELECT')
for o in assets:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'cargo-truck-m35.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_materials='EXPORT')
# Enforce serialized paint factor independently of exporter node-recognition versions.
path=ROOT/'cargo-truck-m35.glb';raw=path.read_bytes();jl,jt=struct.unpack_from('<II',raw,12);doc=json.loads(raw[20:20+jl]);tail=raw[20+jl:]
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.10,.12,.06,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
 if 'metallicRoughnessTexture' in m.get('pbrMetallicRoughness',{}):m['occlusionTexture']=dict(m['pbrMetallicRoughness']['metallicRoughnessTexture'])
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4);path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(js)+len(tail))+struct.pack('<II',len(js),0x4e4f534a)+js+tail)
(ROOT/'NOTES.md').write_text(f'M35-style 2.5-ton 6x6 cargo truck; open cab, canvas roofs, dual rear tyres, wooden troop benches and eight troop sockets.\n{tri:,} triangles; six wheel assemblies and separate steering wheel.\n8 materials: Paint, Rubber, Metal, Canvas, Wood, Interior, Glass (BLEND), Lamps (emissive).\nCycles-baked base colour 32–1024 px; tiled surface sets 512–1024 px; tangent normals and packed ORM 512 px.\nPoly Haven CC0: green_metal_rust, rubberized_track, denim_fabric, wood_planks_grey, fabric_leather_01; https://polyhaven.com/license\nReference differences: simplified engine bay, underbody and wheel dishes; regularized cloth folds and restrained wear; no markings or insignia.\n')
print('EXPORTED',tri,'triangles',path.stat().st_size,'bytes',flush=True)
# Presentation scene is added only after exporting the game nodes.
group='Presentation';floor=basic('Studio floor',(.28,.30,.31),.88);box('Ground',(0,0,-.055),(200,200,.08),'Studio floor',0)
def area(name,loc,power,size,col):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;d.color=col;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1.2))-o.location).to_track_quat('-Z','Y').to_euler()
area('Large daylight',(4,-5,8),1600,6,(1,.94,.85));area('Sky fill',(-4,-1,5),900,5,(.77,.86,1));area('Rear rim',(2,5,6),1400,4,(1,1,1))
camdata=bpy.data.cameras.new('Review camera');cam=bpy.data.objects.new('Review camera',camdata);sc.collection.objects.link(cam);sc.camera=cam
camdata.type='ORTHO';camdata.ortho_scale=8.6
front=(8,-10,4.9);target=Vector((0,0,1.30))
def view(loc,scale):cam.location=loc;cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camdata.ortho_scale=scale

def render(name,w=1920,h=1080,samples=28):
 sc.render.resolution_x=w;sc.render.resolution_y=h;sc.render.resolution_percentage=100;sc.cycles.samples=samples;sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
view(front,9.1);render('final.png')
view((10,0,2.5),7.65);render('side.png',1920,1080,20)
view(front,9.1)
clay=basic('Review clay',(.40,.40,.40),.8);sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,20);sc.view_layers[0].material_override=None
# Six identical views, combined using Blender image buffers (no external imaging dependency).
frames=[]
for i,(name,col) in enumerate(paints):
 M['Paint'].node_tree.nodes['PaintTint'].inputs[2].default_value=(*col,1);render(f'paint-{i}.png',640,360,12)
 im=bpy.data.images.load(str(ROOT/f'paint-{i}.png'));frames.append(np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4));bpy.data.images.remove(im)
canvas=np.ones((720,1920,4),dtype=np.float32)
for i,f in enumerate(frames):r=1-i//3;c=i%3;canvas[r*360:(r+1)*360,c*640:(c+1)*640]=f
im=bpy.data.images.new('Paint variants',1920,720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
for i in range(6):(ROOT/f'paint-{i}.png').unlink()
M['Paint'].node_tree.nodes['PaintTint'].inputs[2].default_value=(.10,.12,.06,1)
bpy.ops.wm.save_as_mainfile(filepath='/tmp/m35-review.blend')
print('COMPLETE',flush=True)
