import bpy, bmesh, math, os, json, sys, urllib.request, subprocess, random, struct
from mathutils import Vector, Quaternion
from math import sin,cos,pi
ROOT=os.path.dirname(os.path.abspath(__file__)); os.chdir(ROOT)
random.seed(19)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=24; sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices: d.use=d.type!='CPU'
sc.cycles.device='GPU'; sc.render.image_settings.file_format='PNG'
sc.world.color=(.3,.3,.3)
parts=[]; group='Body'; mats={}
def active(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def finish(o,name,mat,bev=0):
 o.name=name; active(o); bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if bev:
  m=o.modifiers.new('Manufactured edge radii','BEVEL'); m.width=bev; m.segments=1 if bev<=.003 or any(t in name.lower() for t in ['tread','bolt','nut','vent','rivet','fastener','cleat','slat']) else 2
  bpy.ops.object.modifier_apply(modifier=m.name)
 if mat:o.data.materials.append(mats[mat])
 o['assembly']=group; parts.append(o)
 if len(parts)%250==0:print('Built components',len(parts),flush=True)
 return o

def box(n,loc,size,mat='Paint',b=.008):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.dimensions=size; return finish(o,n,mat,b)
def mesh(n,v,f,mat='Paint',b=.005):
 me=bpy.data.meshes.new(n); me.from_pydata(v,[],f); me.update(); o=bpy.data.objects.new(n,me); sc.collection.objects.link(o); return finish(o,n,mat,b)
def prism(n,profile,a,b,axis='X',mat='Paint',bevel=.006):
 v=[]
 for d in [a,b]:
  for u,w in profile:v.append((d,u,w) if axis=='X' else (u,d,w))
 N=len(profile); f=[tuple(range(N-1,-1,-1)),tuple(range(N,2*N))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
 return mesh(n,v,f,mat,bevel)
def cyl(n,loc,r,depth,mat='Metal',axis='Z',verts=32):
 bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=r,depth=depth,location=loc);o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 finish(o,n,mat,.002)
 for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
 return o

def tube(n,pts,r=.012,mat='Metal',closed=False):
 cu=bpy.data.curves.new(n,'CURVE');cu.dimensions='3D';cu.resolution_u=1;cu.bevel_depth=r;cu.bevel_resolution=2
 sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
 for p,co in zip(sp.points,pts):p.co=(*co,1)
 sp.use_cyclic_u=closed;o=bpy.data.objects.new(n,cu);sc.collection.objects.link(o);active(o);bpy.ops.object.convert(target='MESH');return finish(bpy.context.object,n,mat)
def rod(n,a,b,r,mat='Metal'):
 o=cyl(n,(Vector(a)+Vector(b))/2,r,(Vector(b)-Vector(a)).length,mat);o.rotation_euler=(Vector(b)-Vector(a)).to_track_quat('Z','Y').to_euler();return o

def torus(n,loc,major,minor,mat='Rubber',axis='X',seg=64):
 bpy.ops.mesh.primitive_torus_add(major_segments=seg,minor_segments=12,location=loc,major_radius=major,minor_radius=minor);o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=pi/2
 finish(o,n,mat)
 for p in o.data.polygons:p.use_smooth=True
 return o

def node(nt,t):return nt.nodes.new(t)
def material(name,id,color,rough,metal=0):
 m=bpy.data.materials.new(name);m.use_nodes=True; nt=m.node_tree;nt.nodes.clear(); out=node(nt,'ShaderNodeOutputMaterial'); bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs['BSDF'],out.inputs['Surface']);bs.inputs['Roughness'].default_value=rough;bs.inputs['Metallic'].default_value=metal
 tex=node(nt,'ShaderNodeTexCoord'); noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=8;noise.inputs['Detail'].default_value=4;nt.links.new(tex.outputs['UV'],noise.inputs['Vector'])
 ramp=node(nt,'ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.2;ramp.color_ramp.elements[0].color=(*[v*.75 for v in color],1);ramp.color_ramp.elements[1].position=.8;ramp.color_ramp.elements[1].color=(*color,1);nt.links.new(noise.outputs['Fac'],ramp.inputs[0]);base=ramp.outputs[0]
 if id:
  imgs={}
  os.makedirs('textures',exist_ok=True)
  for mp in ['diff','nor_gl','arm']:
   path=f'textures/{id}_{mp}_1k.jpg'
   if not os.path.exists(path):subprocess.run(['curl','-f','-L','-A','Mozilla/5.0','--retry','2','-s',f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg','-o',path],check=True)
   im=bpy.data.images.load(path,check_existing=True); im.colorspace_settings.name='sRGB' if mp=='diff' else 'Non-Color';tx=node(nt,'ShaderNodeTexImage');tx.image=im;nt.links.new(tex.outputs['UV'],tx.inputs['Vector']);imgs[mp]=tx
  bw=node(nt,'ShaderNodeRGBToBW');nt.links.new(imgs['diff'].outputs['Color'],bw.inputs[0]);mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.28;nt.links.new(base,mix.inputs[1]);nt.links.new(bw.outputs[0],mix.inputs[2]);base=mix.outputs[0]
  nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.035 if name=='Paint' else .30;nt.links.new(imgs['nor_gl'].outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
  sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(imgs['arm'].outputs[0],sep.inputs[0]);mr=node(nt,'ShaderNodeMapRange');mr.inputs['To Min'].default_value=max(.1,rough-.17);mr.inputs['To Max'].default_value=min(1,rough+.15);nt.links.new(sep.outputs[1],mr.inputs['Value']);nt.links.new(mr.outputs[0],bs.inputs['Roughness'])
 if name=='Paint':
  mapping=node(nt,'ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(35,2,1);nt.links.new(tex.outputs['UV'],mapping.inputs[0]);streak=node(nt,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=3;streak.inputs['Detail'].default_value=3;nt.links.new(mapping.outputs[0],streak.inputs['Vector']);sr=node(nt,'ShaderNodeValToRGB');sr.color_ramp.elements[0].color=(.36,.36,.36,1);sr.color_ramp.elements[0].position=.30;sr.color_ramp.elements[1].color=(1,1,1,1);sr.color_ramp.elements[1].position=.58;nt.links.new(streak.outputs['Fac'],sr.inputs[0]);sm=node(nt,'ShaderNodeMixRGB');sm.blend_type='MULTIPLY';sm.inputs[0].default_value=.07;nt.links.new(base,sm.inputs[1]);nt.links.new(sr.outputs[0],sm.inputs[2]);base=sm.outputs[0]
 if name=='Paint':
  fine=node(nt,'ShaderNodeTexNoise');fine.inputs['Scale'].default_value=145;fine.inputs['Detail'].default_value=2;nt.links.new(tex.outputs['UV'],fine.inputs['Vector']);fr=node(nt,'ShaderNodeValToRGB');fr.color_ramp.elements[0].position=.38;fr.color_ramp.elements[0].color=(.35,.35,.35,1);fr.color_ramp.elements[1].position=.45;fr.color_ramp.elements[1].color=(1,1,1,1);nt.links.new(fine.outputs['Fac'],fr.inputs[0]);fm=node(nt,'ShaderNodeMixRGB');fm.blend_type='MULTIPLY';fm.inputs[0].default_value=.18;nt.links.new(base,fm.inputs[1]);nt.links.new(fr.outputs[0],fm.inputs[2]);base=fm.outputs[0]
 nt.links.new(base,bs.inputs['Base Color']);mats[name]=m;return m

# Each material is baked once as a reusable, metre-scale tile, not a vehicle-wide atlas.
for spec in [('Paint','blue_metal_plate',(.93,.93,.93),.44,.18),('Rubber','rubberized_track',(.016,.018,.020),.86,0),('Metal','blue_metal_plate',(.033,.038,.043),.52,.72),('Interior','fabric_leather_01',(.14,.125,.09),.85,0),('Cargo','denim_fabric',(.12,.14,.08),.9,0),('Glass',None,(.12,.18,.20),.16,.05),('Lamps',None,(.9,.88,.73),.25,.15)]:material(*spec)

def bake(m):
 nt=m.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');out=next(n for n in nt.nodes if n.type=='OUTPUT_MATERIAL');source=bs.inputs['Base Color'].links[0].from_socket
 bpy.ops.mesh.primitive_plane_add(size=1);o=bpy.context.object;o.data.materials.append(m);active(o)
 baked={}
 for kind in ['base','normal','orm']:
  res=512 if kind=='orm' else 1024; im=bpy.data.images.new(m.name+'_'+kind,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color';target=node(nt,'ShaderNodeTexImage');target.image=im;nt.nodes.active=target
  if kind=='base':
   em=node(nt,'ShaderNodeEmission');nt.links.new(source,em.inputs[0]);nt.links.new(em.outputs[0],out.inputs[0]);typ='EMIT'
  elif kind=='normal':nt.links.new(bs.outputs[0],out.inputs[0]);typ='NORMAL'
  else:
   em=node(nt,'ShaderNodeEmission');pack=node(nt,'ShaderNodeCombineColor');pack.inputs[0].default_value=1;pack.inputs[1].default_value=bs.inputs['Roughness'].default_value;pack.inputs[2].default_value=bs.inputs['Metallic'].default_value
   if bs.inputs['Roughness'].is_linked:nt.links.new(bs.inputs['Roughness'].links[0].from_socket,pack.inputs[1])
   nt.links.new(pack.outputs[0],em.inputs[0]);nt.links.new(em.outputs[0],out.inputs[0]);typ='EMIT'
  sc.cycles.samples=8;bpy.ops.object.bake(type=typ,margin=8);im.filepath_raw=os.path.join(ROOT,'textures',m.name+'_'+kind+'.png');im.file_format='PNG';im.save();baked[kind]=im
 bpy.data.objects.remove(o,do_unlink=True);nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs[0],out.inputs[0])
 for kind,im in baked.items():
  tx=node(nt,'ShaderNodeTexImage');tx.image=im
  if kind=='base':nt.links.new(tx.outputs[0],bs.inputs['Base Color'])
  elif kind=='normal':
   nm=node(nt,'ShaderNodeNormalMap');nt.links.new(tx.outputs[0],nm.inputs[0 if False else 'Color']);nt.links.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(tx.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs[1],bs.inputs['Roughness']);nt.links.new(sep.outputs[2],bs.inputs['Metallic'])
 if m.name=='Glass':
  bs.inputs['Alpha'].default_value=.30;bs.inputs['Transmission Weight'].default_value=.2;m.surface_render_method='BLENDED' if 'BLENDED' in [e.identifier for e in m.bl_rna.properties['surface_render_method'].enum_items] else 'DITHERED'
 if m.name=='Lamps':nt.links.new(bs.inputs['Base Color'].links[0].from_socket,bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.12
 if m.name=='Paint':bs.inputs['Coat Weight'].default_value=.15;bs.inputs['Coat Roughness'].default_value=.35
 return bs
for m in mats.values():bake(m)
# The shared lamp tile contains white, amber and red lens zones.
import numpy as np
lamp_image=bpy.data.images.get('Lamps_base');pixels=np.array(lamp_image.pixels[:],dtype=np.float32).reshape(1024,1024,4);pixels[:,512:768,:3]*=np.array([1,.23,.018]);pixels[:,768:,:3]*=np.array([.65,.015,.008]);lamp_image.pixels.foreach_set(pixels.ravel());lamp_image.save()
if '--preflight' in sys.argv:
 box('Probe',(0,0,.5),(1,1,1));active(parts[0]);bpy.ops.export_scene.gltf(filepath=ROOT+'/probe.glb',export_format='GLB',use_selection=True);print('PREFLIGHT OK');sys.exit(0)

# Axle centers at +/-1.65 m; front is Blender -Y (glTF +Z).
# Chassis, running gear and deep central transmission tunnel.
for x in [-.62,.62]:box('Boxed chassis rail',(x,0,.48),(.12,4.1,.15),'Metal')
for y in [-1.65,1.65]:
 box('Axle crossmember',(0,y,.49),(1.78,.12,.12),'Metal');cyl('Differential',(0,y,.49),.17,.3,'Metal','X')
 for s in [-1,1]:
  for dy in [-.18,.18]:rod('Wide track A arm',(s*.4,y+dy,.52),(s*.95,y,.5),.035)
  rod('Damper',(s*.82,y,.5),(s*.76,y,.94),.028)
  tube('Coil spring',[(s*.79+.075*cos(t*2*pi/16),y+.075*sin(t*2*pi/16),.53+t*.0045) for t in range(85)],.013,'Metal')
box('Engine block',(0,-1.56,.965),(1.39,1.08,.44),'Metal',.045)
box('Engine cover',(0,-1.52,1.20),(1.3,.9,.07),'Metal',.025)
for sx in [-1,1]:
 prism('Inner front wheelhouse',[(-2.12,1.03),(-2.12,1.24),(-1.03,1.29),(-1.03,.86),(-1.16,1.10),(-1.36,1.17),(-1.82,1.17)],sx*.77,sx*.79,mat='Metal',bevel=.009)
box('Cab floor',(0,.27,.7),(1.87,3.45,.13),'Metal');box('Transmission tunnel',(0,.2,.95),(.5,2.45,.45),'Interior',.04)
# Lower side panels have genuine cutaway arches, not tyres intersecting a box.
prof=[(-2.20,1.08),(-2.2,1.19),(-.89,1.22),(-.68,1.30),(2.20,1.30),(2.20,.86),(2.12,.86),(2.06,1.02),(1.96,1.13),(1.8,1.19),(1.49,1.19),(1.32,1.13),(1.2,.97),(1.08,.68),(-.86,.68),(-1.04,.92),(-1.14,1.09),(-1.35,1.17),(-1.82,1.17),(-2.00,1.12),(-2.08,1.04)]
for s in [-1,1]:
 prism('Sculpted fender and sill',prof,s*.96,s*1.02)
 box('Rocker protection',(s*.99,.13,.68),(.10,2.2,.095),'Metal',.012)
 # Fender lip with open arch
 for cy in [-1.65,1.65]:
  shape=[(-.55,.91),(-.46,1.105),(-.25,1.177),(.30,1.177),(.50,1.10),(.61,.91)] if cy<0 else [(-.57,.70),(-.45,.97),(-.33,1.13),(-.16,1.19),(.31,1.19),(.41,1.02),(.47,.86)]
  pts=[(s*1.035,cy+dy,z) for dy,z in shape]
  tube('Rolled wheel arch lip',pts,.018,'Paint')
 # four separate door lower skins and framed glass apertures
 for cy in [-.43,.47]:
  profile=[(cy-.42,.8),(cy+.37,.8),(cy+.42,.88),(cy+.42,1.72),(cy+.34,1.87),(cy-.36,1.87),(cy-.42,1.72)]
  # lower door with stamped recess nested in it
  prism('Door steel lower',[(cy-.42,.8),(cy+.36,.8),(cy+.41,.89),(cy+.41,1.29),(cy-.42,1.29)],s*.995,s*1.027)
  prism('Door pressed center',[(cy-.32,.93),(cy+.23,.93),(cy+.31,1.00),(cy+.31,1.16),(cy+.2,1.21),(cy-.32,1.21)],s*1.028,s*1.036,mat='Paint',bevel=.015)
  tube('Door stamping edge',[(s*1.038,yy,zz) for yy,zz in [(cy-.32,.93),(cy+.23,.93),(cy+.31,1.00),(cy+.31,1.16),(cy+.2,1.21),(cy-.32,1.21)]],.003,'Metal',True)
  box('Door belt rail',(s*1.008,cy,1.315),(.063,.825,.085),'Paint',.009)
  # Window frame bars, surrounding open real cab
  win=[(cy-.34,1.36),(cy+.32,1.36),(cy+.29,1.8),(cy-.30,1.8)]
  outerwin=[(cy-.415,1.29),(cy+.405,1.29),(cy+.405,1.865),(cy-.415,1.865)]
  for j in range(4):
   k=(j+1)%4;prism('Door aperture sheet',[outerwin[j],outerwin[k],win[k],win[j]],s*.995,s*1.004,mat='Paint',bevel=.001)
  tube('Window rubber seal',[(s*1.008,y,z) for y,z in win],.033,'Rubber',True)
  prism('Door glazing',win,s*1.003,s*1.008,mat='Glass',bevel=.012)
  for yy in [cy-.415,cy+.405]:box('Door upright',(s*.998,yy,1.58),(.072,.055,.56),'Paint')
  box('Door top rail',(s*.993,cy,1.845),(.08,.81,.058),'Paint')
  box('Recessed handle pocket',(s*1.045,cy+.29,1.23),(.015,.095,.12),'Rubber',.008)
  box('Handle lever',(s*1.061,cy+.29,1.25),(.025,.07,.022),'Metal',.003)
  for z in [.89,1.22]:
   box('Door hinge',(s*1.064,cy-.405,z),(.05,.135,.042),'Paint',.007)
   cyl('Hinge pin',(s*1.073,cy-.405,z),.014,.065,'Metal','Z',12)
  for z in [.85,1.29]:
   for yy in [cy-.35,cy+.33]:cyl('Door fastener',(s*1.042,yy,z),.008,.008,'Metal','X',8)
 # rear wagon quarter, window
 box('Rear quarter belt',(s*.994,1.52,1.245),(.083,1.19,.15))
 box('Rear quarter roof rail',(s*.986,1.52,1.84),(.09,1.2,.11))
 for yy in [.97,2.14]:box('Rear pillar',(s*.99,yy,1.54),(.095,.11,.62))
 win=[(1.08,1.37),(2.05,1.37),(2.05,1.77),(1.08,1.77)]
 tube('Quarter glass surround',[(s*1.006,y,z) for y,z in win],.033,'Rubber',True);prism('Quarter sliding glass',win,s*.997,s*1.0,mat='Glass')
 box('Sliding glass division',(s*1.01,1.57,1.57),(.017,.028,.40),'Metal')
 # side mirrors and supports
 rod('Mirror arm',(s*1.01,-.79,1.34),(s*1.06,-.9,1.47),.015)
 box('Mirror housing',(s*1.065,-.93,1.48),(.07,.14,.23),'Rubber',.026)
 box('Mirror face',(s*1.067,-.851,1.48),(.049,.006,.17),'Glass',.015)
 # body rivets beneath rear quarters
 for yy in [1.01+i*.14 for i in range(9)]:cyl('Quarter rivet',(s*1.041,yy,1.285),.005,.008,'Metal','X',8)
# Roof and split windshield
box('Hard top roof',(0,.66,1.912),(2.0,3.13,.076),'Paint',.035)
box('Gutter left',(.998,.66,1.879),(.045,3.18,.035),'Metal');box('Gutter right',(-.998,.66,1.879),(.045,3.18,.035),'Metal')
for x in [-.95,0,.95]:
 rod('Windshield structural pillar',(x,-.94,1.28),(x,-.84,1.875),.035,'Paint')
box('Windshield cowl',(0,-.94,1.27),(1.97,.14,.10),'Paint')
for s in [-1,1]:
 v=[(s*.05,-.925,1.34),(s*.89,-.925,1.34),(s*.89,-.852,1.81),(s*.05,-.852,1.81)]
 tube('Windshield gasket',v,.017,'Rubber',True);mesh('Split windshield',v,[(0,1,2,3)],'Glass',0)
 rod('Wiper arm',(s*.13,-.958,1.34),(s*.43,-.914,1.63),.009)
 rod('Wiper blade',(s*.25,-.92,1.54),(s*.62,-.885,1.74),.012,'Rubber')
box('Windshield header',(0,-.850,1.848),(1.96,.095,.080),'Paint',.012)
# Formed hood wedge
prism('Clamshell bonnet',[(-2.21,1.12),(-2.21,1.23),(-1.05,1.32),(-.95,1.28),(-.95,1.12)],-.95,.95,'X','Paint',.023)
for x in [-.54,.54]:tube('Bonnet strengthening crease',[(x,-2.10,1.246),(x,-1.04,1.326)],.011,'Paint')
box('Hood vent inset',(0,-1.39,1.301),(.66,.50,.012),'Rubber')
for i in range(14):box('Hood louvre',(0,-1.62+i*.035,1.316),(.65,.018,.015),'Metal',.003)
for s in [-1,1]:
 box('Bonnet latch',(s*.98,-1.01,1.16),(.022,.07,.16),'Metal',.009)
 tube('Tie down loop',[(s*.65,-1.99,1.253),(s*.65,-1.99,1.30),(s*.73,-1.99,1.30),(s*.73,-1.99,1.253)],.009)
# Front face, deep grille slots, round lamps
box('Front fascia',(0,-2.19,1.055),(2.02,.085,.30),'Paint',.019)
box('Grille recessed shadow',(0,-2.242,1.075),(1.02,.018,.246),'Rubber',.018)
for i in range(9):box('Vertical grille bar',(-.49+i*.123,-2.261,1.075),(.047,.029,.23),'Paint',.018)
for s in [-1,1]:
 cyl('Headlight bezel',(s*.65,-2.251,1.075),.127,.045,'Metal','Y',48)
 cyl('Headlight glass',(s*.65,-2.281,1.075),.103,.018,'Lamps','Y',48)
 for i in range(-3,4):rod('Headlamp lens rib',(s*.65+i*.022,-2.296,1.015),(s*.65+i*.022,-2.296,1.135),.002,'Glass')
 cyl('Indicator bezel',(s*.9,-2.255,1.13),.059,.036,'Rubber','Y',32);cyl('Turn signal',(s*.9,-2.279,1.13),.047,.018,'Lamps','Y',32)
box('Front steel bumper',(0,-2.245,.735),(2.13,.20,.19),'Metal',.02)
box('Rear steel bumper',(0,2.215,.71),(2.1,.26,.19),'Metal',.015)
# Brush guard with corner returns
for s in [-1,1]:
 tube('Brush guard',[(s*.48,-2.32,.79),(s*.48,-2.32,1.29),(s*1.02,-2.30,1.29),(s*1.04,-2.30,.97),(s*.50,-2.32,.97)],.021,'Metal')
 tube('Recovery shackle',[(s*.7,-2.351,.73),(s*.7,-2.351,.64),(s*.79,-2.351,.64),(s*.79,-2.351,.73)],.015,'Metal')
box('Winch housing',(0,-2.292,.79),(.48,.09,.20),'Metal',.02);cyl('Winch drum',(0,-2.32,.8),.065,.29,'Metal','X')
box('Roller fairlead',(0,-2.339,.80),(.31,.012,.072),'Metal')
tube('Winch recovery cable',[(0,-2.35,.80),(-.12,-2.35,.7),(-.18,-2.33,.53)],.006,'Metal')
for s in [-1,1]:
 cyl('Turn signal side',(s*1.04,-2.10,1.17),.027,.018,'Lamps','X',24)
 cyl('Tail lamp lens side',(s*1.04,2.10,1.10),.032,.012,'Lamps','X',24)
for x in [-.13,0,.13]:box('Turn signal clearance',(x,-.904,1.861),(.055,.021,.025),'Lamps',.005)
cyl('Fuel filler bezel',(1.043,1.99,1.25),.06,.013,'Metal','X',32)
cyl('Fuel filler cap',(1.054,1.99,1.25),.048,.018,'Paint','X',32)
# Rear corner grab rail and exhaust tailpipe.
tube('Rear grab rail',[(1.01,2.2,1.05),(1.075,2.2,1.12),(1.075,2.2,1.83),(1.01,2.2,1.89)],.017,'Metal')
tube('Exhaust tailpipe',[(-.58,1.45,.49),(-.75,1.90,.49),(-.75,2.23,.57)],.031,'Metal')
cyl('Exhaust dark bore',(-.75,2.237,.57),.024,.004,'Rubber','Y',24)
# Rear split cargo doors
for s in [-1,1]:
 box('Rear cargo door',(s*.475,2.173,1.305),(.93,.048,1.09),'Paint',.013)
 box('Rear window seal',(s*.475,2.207,1.565),(.69,.025,.47),'Rubber',.035)
 box('Rear cargo glass',(s*.475,2.223,1.565),(.61,.012,.39),'Glass',.025)
 box('Rear latch',(s*.12,2.221,1.20),(.07,.025,.08),'Metal')
 box('Tail lamp mounting',(s*.91,2.217,.985),(.11,.035,.18),'Rubber',.02)
 box('Tail lamp lens',(s*.91,2.241,.99),(.086,.02,.136),'Lamps',.018)
 for z in [1.0,1.73]:box('Rear door hinge',(s*.92,2.212,z),(.105,.05,.028),'Metal')
# Interior seats, dashboard, instruments and center controls
for y in [-.34,.62]:
 for x in [-.58,.58]:
  box('Seat pedestal',(x,y,.81),(.39,.43,.17),'Metal',.02)
  box('Seat cushion',(x,y,.98),(.46,.49,.14),'Interior',.055)
  o=box('Seat back',(x,y+.24,1.24),(.47,.14,.52),'Interior',.055);o.rotation_euler[0]=-.1
  box('Headrest',(x,y+.28,1.56),(.29,.12,.18),'Interior',.035)
  for xx in [-.16,.16]:tube('Seat piping',[(x+xx,y-.20,1.05),(x+xx,y+.16,1.05),(x+xx,y+.17,1.45)],.006,'Rubber')
box('Rear bench',(0,1.69,.99),(1.62,.43,.18),'Interior',.04);box('Rear bench back',(0,1.94,1.24),(1.62,.12,.45),'Interior',.04)
box('Dashboard',(0,-.75,1.22),(1.82,.27,.22),'Interior',.025)
box('Instrument binnacle',(.54,-.588,1.29),(.48,.08,.19),'Rubber',.02)
for x,r in [(.42,.059),(.56,.059),(.68,.033)]:cyl('Instrument dial',(x,-.537,1.31),r,.01,'Metal','Y',32);rod('Gauge needle',(x,-.529,1.31),(x+.018,-.529,1.34),.002,'Lamps')
for x in [-.8,-.19,.8]:box('Air vent',(x,-.597,1.25),(.13,.018,.075),'Rubber',.008)
rod('Gear selector',(0,-.18,1.12),(0,-.16,1.32),.013,'Metal');cyl('Gear knob',(0,-.16,1.33),.027,.045,'Rubber')
group='Body';rod('Steering column',(.55,-.69,1.17),(.55,-.40,1.28),.026,'Metal');group='SteeringWheel';o=torus('Steering rim',(.55,-.39,1.29),.16,.017,'Rubber','Z',48);o.rotation_euler[0]=pi/2-.35
for t in [0,2*pi/3,4*pi/3]:rod('Steering spoke',(.55,-.39,1.29),(.55+.15*cos(t),-.39-.05*sin(t),1.29+.14*sin(t)),.009,'Metal')
group='Body'
# Roof basket: body roof 1.95 m, expedition equipment extends above that.
for y in [-.7,.1,.9,1.8]:
 box('Rack foot',(0,y,1.958),(1.92,.09,.055),'Rubber')
 for s in [-1,1]:box('Rack mounting bracket',(s*.88,y,1.995),(.06,.12,.12),'Metal')
for z in [2.045,2.17]:tube('Basket perimeter',[(-.91,-.82,z),(.91,-.82,z),(.94,-.76,z),(.94,2.1,z),(.88,2.16,z),(-.88,2.16,z),(-.94,2.10,z),(-.94,-.76,z)],.018,'Metal',True)
for y in [-.7+i*.24 for i in range(12)]:rod('Rack floor slat',(-.9,y,2.043),(.9,y,2.043),.011,'Metal')
for s in [-1,1]:
 for y in [-.7,.1,.9,2.03]:rod('Basket upright',(s*.935,y,2.043),(s*.935,y,2.17),.014,'Metal')
# Spare tyre horizontal on roof; jerry cans and traction boards.
box('Rack tool chest',(-.30,-.35,2.12),(.58,.28,.14),'Cargo',.018)
box('Tool chest lid',(-.30,-.35,2.194),(.60,.30,.025),'Cargo',.009)
for xx in [-.48,-.12]:box('Tool chest latch',(xx,-.496,2.14),(.032,.018,.058),'Metal',.004)
for z in [2.065,2.09,2.115]:torus('Recovery rope coil',(.53,.70,z),.13,.012,'Cargo','Z',32)
for x in [.38,.68]:
 box('Jerry can',(x,1.71,2.255),(.24,.37,.40),'Cargo',.032)
 tube('Jerry can handle',[(x-.08,1.70,2.45),(x-.08,1.7,2.51),(x+.08,1.7,2.51),(x+.08,1.7,2.45)],.018,'Cargo')
 for dy in [-.19,.19]:
  tube('Can reinforcement cross',[(x-.085,1.71+dy,2.11),(x+.085,1.71+dy,2.36)],.01,'Cargo');tube('Can reinforcement cross',[(x+.085,1.71+dy,2.11),(x-.085,1.71+dy,2.36)],.01,'Cargo')
 cyl('Jerry can cap',(x,1.59,2.46),.029,.022,'Cargo','Z',20)
 box('Can retaining strap',(x,1.51,2.27),(.031,.01,.42),'Rubber')
for y in [1.13,1.28]:
 box('Recovery board',(-.27,y,2.105),(.93,.13,.05),'Cargo')
 for i in range(14):box('Traction cleat',(-.69+i*.064,y,2.14),(.027,.10,.018),'Metal',.004)
for x in [-.72,-.43,-.14,.14,.43,.72]:
 rod('Roof lamp foot',(x,-.70,2.045),(x,-.70,2.14),.012)
 cyl('Roof lamp housing',(x,-.76,2.17),.074,.055,'Metal','Y',32);cyl('Roof lamp lens',(x,-.795,2.17),.063,.014,'Lamps','Y',32)
# Detailed wheel assemblies, lathed sidewalls and individual tread blocks.
def wheel(s,y,name):
 global group
 group=name;cx=s*.905;cz=.49
 profile=[(-.19,.265),(-.198,.33),(-.181,.416),(-.145,.459),(-.105,.478),(.105,.478),(.145,.459),(.181,.416),(.198,.33),(.19,.265)]
 N=80;v=[(cx+u,y+r*cos(a*2*pi/N),cz+r*sin(a*2*pi/N)) for u,r in profile for a in range(N)];f=[]
 for j in range(len(profile)-1):
  for a in range(N):b=(a+1)%N;f.append((j*N+a,j*N+b,(j+1)*N+b,(j+1)*N+a))
 o=mesh('Radial tyre carcass',v,f,'Rubber',0)
 for p in o.data.polygons:p.use_smooth=True
 for i in range(44):
  a=i*2*pi/44
  for row in [-1,0,1]:
   aa=a+row*.034;o=box('All terrain tread',(cx+row*.108,y+.478*cos(aa),cz+.478*sin(aa)),(.108,.049,.052),'Rubber',.003);o.rotation_euler=(Quaternion((1,0,0),aa-pi/2)@Quaternion((0,0,1),row*.25)).to_euler()
 outer=cx+s*.132
 cyl('Wheel barrel',(cx,y,cz),.268,.22,'Paint','X',64)
 cyl('Wheel dish',(outer,y,cz),.239,.022,'Paint','X',64)
 torus('Rim rolled bead',(outer+s*.016,y,cz),.252,.012,'Paint','X',64)
 for i in range(10):
  a=i*2*pi/10;cyl('Rim vent',(outer+s*.015,y+.191*cos(a),cz+.191*sin(a)),.032,.012,'Rubber','X',16)
  cyl('Lug nut',(outer+s*.033,y+.114*cos(a),cz+.114*sin(a)),.012,.025,'Metal','X',6)
 for i in range(24):
  a=i*2*pi/24;cyl('Bead bolt',(outer+s*.022,y+.245*cos(a),cz+.245*sin(a)),.006,.008,'Metal','X',6)
 cyl('Hub',(outer+s*.028,y,cz),.083,.06,'Paint','X',40);cyl('Hub center',(outer+s*.061,y,cz),.043,.01,'Metal','X',32)
for s,side in [(1,'L'),(-1,'R')]:
 for y,ax in [(-1.65,'F'),(1.65,'R')]:wheel(s,y,'Wheel_'+ax+side)
# The roof spare uses the same manufactured wheel, laid horizontally and strapped down.
start=len(parts);wheel(1,0,'Body');q=Quaternion((0,1,0),-pi/2);centre=Vector((.905,0,.49));target_spare=Vector((-.2,.52,2.21))
for o in parts[start:]:
 o.location=target_spare+q@((o.location-centre)*.82);o.rotation_euler=(q@o.rotation_euler.to_quaternion()).to_euler();o.scale*=.82
 o['assembly']='Body'
group='Body'
for yy in [.27,.77]:tube('Spare securing strap',[(-.63,yy,2.047),(-.60,yy,2.29),(-.40,yy,2.38),(.05,yy,2.38),(.21,yy,2.29),(.24,yy,2.047)],.012,'Rubber')
# Tiled box-projected UVs at a coherent physical scale.
for o in parts:
 if o.type!='MESH':continue
 active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
 uv=o.data.uv_layers.active.data
 for p in o.data.polygons:
  axis=max(range(3),key=lambda i:abs(p.normal[i])); ij=[i for i in range(3) if i!=axis]
  for li in p.loop_indices:
   co=o.data.vertices[o.data.loops[li].vertex_index].co;uv[li].uv=(co[ij[0]]*1.3,co[ij[1]]*1.3)
   if o.data.materials and o.data.materials[0].name=='Lamps':uv[li].uv=((.87 if 'Tail lamp lens' in o.name else .62 if ('Turn signal' in o.name) else .24)+co[ij[0]]*.025,.5+co[ij[1]]*.025)
# Join only rigid assemblies and set required origins.
objects=[]
origins={'Body':(0,0,0),'Wheel_FL':(.905,-1.65,.49),'Wheel_FR':(-.905,-1.65,.49),'Wheel_RL':(.905,1.65,.49),'Wheel_RR':(-.905,1.65,.49),'SteeringWheel':(.55,-.39,1.29)}
assemblies={name:[o for o in parts if o.get('assembly')==name] for name in origins}
for name,org in origins.items():
 obs=assemblies[name];bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name;sc.cursor.location=org;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');objects.append(o)
 # Consistent outward normals for every closed component.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free()
 # Triangulate deterministically for production export
 m=o.modifiers.new('Triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=m.name)
# Fit the measured road contact, overall width and bumper length.
meshes=[o for o in objects if o.type=='MESH'];bpy.context.view_layer.update()
points=[o.matrix_world@v.co for o in meshes for v in o.data.vertices];minz=min(v.z for v in points);maxx=max(abs(v.x) for v in points);xscale=2.20/(maxx*2);frontscale=.695/abs(min(p.y for p in points)+1.65);rearscale=.695/(max(p.y for p in points)-1.65)
for o in meshes:
 for v in o.data.vertices:
  w=o.matrix_world@v.co;w.x*=xscale
  if o.name=='Body' and w.z>1.30:w.x*=1-.06*min(1,(w.z-1.30)/.65)
  w.z-=minz
  if o.name=='Body' and w.y < -1.65:w.y=-1.65+(w.y+1.65)*frontscale
  elif o.name=='Body' and w.y > 1.65:w.y=1.65+(w.y-1.65)*rearscale
  v.co=o.matrix_world.inverted()@w
# Preserve wheel-centred origins after the contact correction.
for o in meshes:
 if o.name.startswith('Wheel_') or o.name=='SteeringWheel':
  new=o.location.copy();new.x*=xscale;new.z-=minz;sc.cursor.location=new;active(o);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
# Stay inside the game budget while retaining all assemblies.
count=sum(len(o.data.polygons) for o in meshes)
if count>105000:
 for o in meshes:
  active(o);d=o.modifiers.new('Game budget','DECIMATE');d.ratio=104000/count;bpy.ops.object.modifier_apply(modifier=d.name)
  t=o.modifiers.new('Final triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=t.name)
# Remove numerical sliver faces left by bevels before serialization.
for o in meshes:
 bm=bmesh.new();bm.from_mesh(o.data);bad=[f for f in bm.faces if f.calc_area()<1e-10];bmesh.ops.delete(bm,geom=bad,context='FACES');bm.to_mesh(o.data);bm.free()
# The rear wheel instances share the corresponding front mesh, retaining separate pivots.
for a,b in [('Wheel_RL','Wheel_FL'),('Wheel_RR','Wheel_FR')]:bpy.data.objects[a].data=bpy.data.objects[b].data
# Socket positions are Blender Z-up, converted to glTF (x,z,-y).
sockets={'Seat_Driver':(.58,-.34,1.05),'Door_L':(1.04,-.78,1.25),'Door_R':(-1.04,-.78,1.25),'Exhaust':(-.75,2.20,.57),'Light_Head_L':(.65,-2.29,1.075),'Light_Head_R':(-.65,-2.29,1.075),'Light_Tail_L':(.91,2.25,.99),'Light_Tail_R':(-.91,2.25,.99),'Troop_1':(-.58,-.34,1.05),'Troop_2':(.58,.62,1.05),'Troop_3':(-.58,.62,1.05),'Troop_4':(.5,1.69,1.08),'Troop_5':(-.5,1.69,1.08)}
for name,loc in sockets.items():o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=(loc[0]*xscale,loc[1],loc[2]-minz);objects.append(o)
paints=[('dark green-black',[.02,.02,.02]),('desert tan',[.48,.39,.27]),('olive drab',[.15,.18,.09]),('field grey',[.24,.28,.25]),('winter white',[.8,.82,.78]),('forest green',[.055,.12,.064])]
bpy.ops.object.select_all(action='DESELECT')
for o in objects:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=ROOT+'/hummer-h1.glb',export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='JPEG',export_jpeg_quality=88,export_extras=False)
# Explicit paint-factor and transparent-window contract in serialized GLB.
p=ROOT+'/hummer-h1.glb';raw=open(p,'rb').read();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);tail=raw[20+jl:]
for m in doc['materials']:
 if 'metallicRoughnessTexture' in m.get('pbrMetallicRoughness',{}):m['occlusionTexture']={'index':m['pbrMetallicRoughness']['metallicRoughnessTexture']['index']}
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.02,.02,.02,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
jb=json.dumps(doc,separators=(',',':')).encode();jb+=b' '*((-len(jb))%4);open(p,'wb').write(struct.pack('<4sII',b'glTF',2,20+len(jb)+len(tail))+struct.pack('<I4s',len(jb),b'JSON')+jb+tail)
points=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
def gl(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
pts=[gl(v) for v in points];lo=[min(v[i] for v in pts) for i in range(3)];hi=[max(v[i] for v in pts) for i in range(3)]
tri=sum(len(o.data.polygons) for o in objects if o.type=='MESH')
json.dump({'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','nodes':[{'name':o.name,'position':gl(o.location),'type':o.type} for o in objects],'bounding_box':{'min':lo,'max':hi,'size':[hi[i]-lo[i] for i in range(3)]},'wheelbase':3.3,'body_roof_height':1.95,'paints':[{'name':n,'linear_rgb':c} for n,c in paints],'triangles':tri},open('nodes.json','w'),indent=2)
open('NOTES.md','w').write(f'Civilian H1-style four-door expedition wagon; metre scale; Garage v2 sockets and wheel pivots.\n{tri:,} triangles; GLB {os.path.getsize(p)/1048576:.2f} MiB.\n7 materials: Paint, Rubber, Metal, Interior, Cargo, Glass (BLEND), Lamps (emissive).\nCycles-baked shared tiling base/normal 1024px, ORM 512px; neutral Paint texture and black default factor.\nPoly Haven CC0: blue_metal_plate, rubberized_track, fabric_leather_01, denim_fabric; https://polyhaven.com/license .\nReference differences: overall height {hi[1]-lo[1]:.3f}m including rack cargo above the nominal 1.95m body roof; simplified interior and surface wear; no logos or lettering.\n')
# Presentation: tan comparison, clay, side and six paint variants.
paint=mats['Paint'];nt=paint.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');tx=bs.inputs['Base Color'].links[0].from_socket;mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(tx,mix.inputs[1]);nt.links.new(mix.outputs[0],bs.inputs['Base Color']);mix.inputs[2].default_value=(*paints[1][1],1)
# neutral studio daylight ground, not exported
floor=bpy.data.materials.new('Studio ground');floor.diffuse_color=(.24,.265,.28,1)
bpy.ops.mesh.primitive_plane_add(size=200);ground=bpy.context.object;ground.name='Render ground';ground.data.materials.append(floor);ground.location.z=-.008
world=sc.world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs[0].default_value=(.65,.74,.85,1);world.node_tree.nodes.get('Background').inputs[1].default_value=.25
for loc,power,size in [((3,-4,7),1100,5),((-4,-1,4),500,4),((1,5,6),1400,4)]:
 bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.camera_add(location=(6.6,-9.5,3.7));cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO';cam.data.ortho_scale=6.6;target=Vector((0,0,1.05));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()
sc.view_settings.view_transform='AgX';sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=32
bpy.ops.wm.save_as_mainfile(filepath=ROOT+'/hummer-source.blend')
def render(file):sc.render.filepath=ROOT+'/'+file;bpy.ops.render.render(write_still=True)
render('final.png')
cam.location=(7,0,1.3);cam.rotation_euler=(Vector((0,0,1.25))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=5.7;render('side.png')
cam.location=(6.6,-9.5,3.7);cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=6.6
clay=bpy.data.materials.new('QA clay');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.22,.22,.22,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.8;sc.view_layers[0].material_override=clay;render('clay.png');sc.view_layers[0].material_override=None
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
for i,(n,c) in enumerate(paints):mix.inputs[2].default_value=(*c,1);render(f'paint-{i}.png')
# Assemble grid with Blender image pixels (no external image dependency).
import numpy as np
canvas=np.ones((720,1920,4),dtype=np.float32)
for i in range(6):
 im=bpy.data.images.load(ROOT+f'/paint-{i}.png',check_existing=False);a=np.array(im.pixels[:]).reshape(360,640,4);row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=a
im=bpy.data.images.new('Paint comparison',width=1920,height=720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=ROOT+'/paints.png';im.file_format='PNG';im.save()
print('BUILD COMPLETE',tri,os.path.getsize(p))
