"""Willys MB style jeep. Run in Blender 5.2; all coordinates authored in metres.
Blender X=vehicle left, -Y=front, Z=up; glTF conversion yields +Z forward.
Photographic CC0 sources are downloaded from Poly Haven and baked as reusable tiles.
"""
import bpy, bmesh, math, os, json, urllib.request, struct, random
import numpy as np
from mathutils import Vector
from math import sin,cos,pi
ROOT=os.path.dirname(os.path.abspath(__file__))
os.chdir(ROOT);random.seed(18)
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for dat in list(bpy.data.materials):bpy.data.materials.remove(dat)
sc=bpy.context.scene;sc.render.engine='CYCLES';sc.cycles.samples=24
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.view_settings.view_transform='AgX'
os.makedirs('textures',exist_ok=True)
M={};groups={};GROUP='Body'
def loc(p):return (p[0],-p[1],p[2])
def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def add(o,name,mat):
 o.name=name;o.data.materials.append(M[mat]);groups.setdefault(GROUP,[]).append(o);return o

def bevel(o,w=.005,n=2):
 active(o);m=o.modifiers.new('machined edge radii','BEVEL');m.width=w;m.segments=n
 bpy.ops.object.modifier_apply(modifier=m.name)
 return o

def cube(name,p,d,mat='Paint',r=.005):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc(p));o=bpy.context.object;o.dimensions=d
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if r:
  bevel(o,r,3)
  for face in o.data.polygons:face.use_smooth=True
  wn=o.modifiers.new('weighted panel normals','WEIGHTED_NORMAL');wn.keep_sharp=True;bpy.ops.object.modifier_apply(modifier=wn.name)
 return add(o,name,mat)

def mesh(name,verts,faces,mat='Paint',r=0):
 me=bpy.data.meshes.new(name);me.from_pydata([loc(v) for v in verts],[],faces);me.update()
 o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o)
 if r:bevel(o,r,2)
 return add(o,name,mat)

def cyl(name,p,r,depth,mat='Paint',axis=(0,0,1),v=32):
 bpy.ops.mesh.primitive_cylinder_add(vertices=v,radius=r,depth=depth,location=loc(p));o=bpy.context.object
 o.rotation_euler=Vector(loc(axis)).to_track_quat('Z','Y').to_euler();active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 bevel(o,min(.003,r*.12),2)
 for f in o.data.polygons:f.use_smooth=len(f.vertices)==4
 return add(o,name,mat)

def rod(name,a,b,r=.01,mat='Paint',v=12):
 A=Vector(a);B=Vector(b);return cyl(name,(A+B)/2,r,(B-A).length,mat,B-A,v)

def tube(name,pts,r=.012,mat='Paint',closed=False):
 cu=bpy.data.curves.new(name,'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 q,p in zip(sp.points,pts):q.co=(*loc(p),1)
 sp.use_cyclic_u=closed;o=bpy.data.objects.new(name,cu);bpy.context.collection.objects.link(o);active(o);bpy.ops.object.convert(target='MESH');return add(bpy.context.object,name,mat)

def torus(name,p,major,minor,mat='Paint',axis=(1,0,0),segs=64,minorseg=10):
 bpy.ops.mesh.primitive_torus_add(major_segments=segs,minor_segments=minorseg,location=loc(p),major_radius=major,minor_radius=minor)
 o=bpy.context.object;o.rotation_euler=Vector(loc(axis)).to_track_quat('Z','Y').to_euler();active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 for f in o.data.polygons:f.use_smooth=True
 return add(o,name,mat)

def padded(name,p,d,mat='Canvas'):
 ob=cube(name,p,d,mat,min(d)*.44);active(ob)
 sub=ob.modifiers.new('soft upholstered surface','SUBSURF');sub.levels=2;bpy.ops.object.modifier_apply(modifier=sub.name)
 thin=min(range(3),key=lambda k:d[k]);other=[k for k in range(3) if k!=thin]
 for vert in ob.data.vertices:
  u=vert.co[other[0]]/(d[other[0]]*.5);v=vert.co[other[1]]/(d[other[1]]*.5)
  side=1 if vert.co[thin]>0 else -1
  fold=.0025*sin(u*24+sin(v*11))*abs(v)**5+.0018*sin(v*28)*abs(u)**5
  vert.co[thin]+=side*(fold-.007*math.exp(-4*(u*u+v*v)))
 for face in ob.data.polygons:face.use_smooth=True
 return ob

def basic(name,color,rough=.6,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=(*color,1);p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal;M[name]=m;return m

# Bake photographic/procedural combinations on a 1 metre tile. Geometry uses metric planar UVs.
specs=[('Paint','blue_metal_plate',(0.93,.93,.93),.66,.10),('Rubber','rubberized_track',(.024,.026,.025),.88,0),('Metal','rusty_metal_03',(.13,.12,.095),.65,.72),('Canvas','denim_fabric',(.23,.205,.135),.94,0),('Wood','fine_grained_wood',(.20,.10,.043),.75,0)]
def bake_material(name,aid,col,rough,metal):
 source=basic('_source_'+name,col,rough,metal);nt=source.node_tree;n=nt.nodes;l=nt.links;p=n.get('Principled BSDF');out=n.get('Material Output')
 tex={}
 for key in ['diff','nor_gl','arm']:
  path=os.path.join(ROOT,'textures',aid+'_'+key+'_1k.jpg')
  if not os.path.exists(path):
   remote_key='col' if aid=='fine_grained_wood' and key=='diff' else key
   urllib.request.urlretrieve(f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{aid}/{aid}_{remote_key}_1k.jpg',path)
  t=n.new('ShaderNodeTexImage');t.image=bpy.data.images.load(path,check_existing=True)
  if key!='diff':t.image.colorspace_settings.name='Non-Color'
  tex[key]=t
 bw=n.new('ShaderNodeRGBToBW');l.new(tex['diff'].outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.005 if name=='Paint' else .12;ramp.color_ramp.elements[1].position=.14 if name=='Paint' else .8
 for e,mul in zip(ramp.color_ramp.elements,[.36 if name=='Paint' else .53,1.1]):e.color=(*[min(.98,x*mul) for x in col],1)
 l.new(bw.outputs[0],ramp.inputs[0])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=5 if name in ['Canvas','Paint'] else 18;noise.inputs['Detail'].default_value=4
 mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.32 if name=='Canvas' else .22;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'])
 norm=n.new('ShaderNodeNormalMap');norm.inputs['Strength'].default_value=.10 if name=='Paint' else .55;l.new(tex['nor_gl'].outputs[0],norm.inputs['Color']);l.new(norm.outputs[0],p.inputs['Normal'])
 separate=n.new('ShaderNodeSeparateColor');l.new(tex['arm'].outputs[0],separate.inputs[0])
 rr=n.new('ShaderNodeMath');rr.operation='MULTIPLY_ADD';rr.inputs[1].default_value=.22;rr.inputs[2].default_value=rough-.15;l.new(separate.outputs[1],rr.inputs[0]);l.new(rr.outputs[0],p.inputs['Roughness'])
 bpy.ops.mesh.primitive_plane_add(size=1);ob=bpy.context.object;ob.data.materials.append(source);sc.cycles.samples=8
 results={}
 for kind,size in [('base',1024),('normal',1024),('orm',512)]:
  im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
  if kind!='base':im.colorspace_settings.name='Non-Color'
  target=n.new('ShaderNodeTexImage');target.image=im;n.active=target
  if kind=='normal':bpy.ops.object.bake(type='NORMAL')
  else:
   emit=n.new('ShaderNodeEmission')
   if kind=='base':l.new(mix.outputs[0],emit.inputs[0])
   else:
    combine=n.new('ShaderNodeCombineColor');combine.inputs[2].default_value=metal;ao=n.new('ShaderNodeMath');ao.operation='MULTIPLY_ADD';ao.inputs[1].default_value=.35;ao.inputs[2].default_value=.65;l.new(separate.outputs[0],ao.inputs[0]);l.new(ao.outputs[0],combine.inputs[0]);l.new(rr.outputs[0],combine.inputs[1]);l.new(combine.outputs[0],emit.inputs[0])
   l.new(emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT');l.new(p.outputs[0],out.inputs['Surface']);n.remove(emit)
  im.filepath_raw=os.path.join(ROOT,'textures',name+'_'+kind+'.png');im.file_format='PNG';im.save()
  if kind!='orm':
   im.filepath_raw=os.path.join(ROOT,'textures',name+'_'+kind+'.jpg');im.file_format='JPEG';im.save();im=bpy.data.images.load(im.filepath_raw,check_existing=False)
   if kind=='normal':im.colorspace_settings.name='Non-Color'
  results[kind]=im
 bpy.data.objects.remove(ob,do_unlink=True)
 mat=basic(name,col,rough,metal);nt=mat.node_tree;p=nt.nodes.get('Principled BSDF')
 for kind,im in results.items():
  t=nt.nodes.new('ShaderNodeTexImage');t.image=im;t.extension='REPEAT'
  if kind=='base':
   if name=='Paint':
    mult=nt.nodes.new('ShaderNodeMixRGB');mult.name='PaintTint';mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;mult.inputs[2].default_value=(.12,.13,.07,1);nt.links.new(t.outputs[0],mult.inputs[1]);nt.links.new(mult.outputs[0],p.inputs['Base Color'])
   else:nt.links.new(t.outputs[0],p.inputs['Base Color'])
  elif kind=='normal':
   nn=nt.nodes.new('ShaderNodeNormalMap');nt.links.new(t.outputs[0],nn.inputs['Color']);nt.links.new(nn.outputs[0],p.inputs['Normal'])
  else:
   sep=nt.nodes.new('ShaderNodeSeparateColor');nt.links.new(t.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs[1],p.inputs['Roughness']);nt.links.new(sep.outputs[2],p.inputs['Metallic'])
   gn=bpy.data.node_groups.get('glTF Material Output') or bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree')
   if not gn.interface.items_tree:gn.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   g=nt.nodes.new('ShaderNodeGroup');g.node_tree=gn;nt.links.new(sep.outputs[0],g.inputs['Occlusion'])
 print('BAKED',name,flush=True)
for spec in specs:bake_material(*spec)
glass=basic('Glass',(.56,.65,.65),.12);p=glass.node_tree.nodes.get('Principled BSDF');p.inputs['Alpha'].default_value=.10;p.inputs['Transmission Weight'].default_value=0;glass.surface_render_method='BLENDED'
lamps=basic('Lamps',(.75,.77,.70),.23,.15);p=lamps.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Color'].default_value=(.5,.48,.38,1);p.inputs['Emission Strength'].default_value=.04
lampcol=lamps.node_tree.nodes.new('ShaderNodeVertexColor');lampcol.layer_name='Color';lamps.node_tree.links.new(lampcol.outputs['Color'],p.inputs['Base Color']);lamps.node_tree.links.new(lampcol.outputs['Color'],p.inputs['Emission Color'])
# ladder chassis, running gear, springs
for x in [-.43,.43]:
 cube('boxed chassis rail',(x,0,.39),(.075,2.91,.12),'Metal',.012)
 for f in [-1.015,1.015]:
  for layer in range(5):
   pts=[(x,f+(i-6)*.071,.27+layer*.007+.035*((i-6)/6)**2) for i in range(13)]
   tube('laminated leaf spring',pts,.009,'Metal')
  for df in [-.33,.33]:rod('spring shackle',(x,f+df,.30),(x,f+df,.40),.023,'Metal')
for f in [-1.32,-.38,.65,1.3]:cube('frame crossmember',(0,f,.39),(.9,.065,.095),'Metal')
for f in [-1.015,1.015]:
 cyl('live axle',(0,f,.34),.045,1.38,'Metal',(1,0,0))
 bpy.ops.mesh.primitive_uv_sphere_add(segments=24,ring_count=12,location=loc((.10,f,.34)));o=bpy.context.object;o.scale=(.145,.11,.12);active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);add(o,'differential housing','Metal')
 for x in [-.43,.43]:rod('damper',(x,f-.08,.33),(x,f+.15,.57),.025,'Metal')
rod('propeller shaft',(.10,-1.015,.34),(.10,1.015,.36),.028,'Metal')
cube('transmission case',(0,.10,.43),(.24,.45,.19),'Metal',.05)
# wheel with cross-section spun about X, accurately modelled dished rim and military block tread
wheel_centres={}
def wheel(name,x,f,z,spare=False):
 global GROUP
 old=GROUP;GROUP=name if not spare else 'Body';start=len(groups.get(GROUP,[]))
 profile=[(-.104,.22),(-.118,.252),(-.113,.304),(-.092,.337),(-.060,.351),(.060,.351),(.092,.337),(.113,.304),(.118,.252),(.104,.22)]
 N=72;vs=[]
 for xx,rr in profile:
  for j in range(N):a=2*pi*j/N;vs.append((x+xx,f+rr*sin(a),z+rr*cos(a)))
 fs=[]
 for k in range(len(profile)-1):
  for j in range(N):fs.append((k*N+j,k*N+(j+1)%N,(k+1)*N+(j+1)%N,(k+1)*N+j))
 o=mesh('moulded tyre carcass',vs,fs,'Rubber')
 for p in o.data.polygons:p.use_smooth=True
 for side in [-1,1]:
  torus('sidewall bead',(x+side*.11,f,z),.238,.008,'Rubber',segs=72)
  torus('sidewall mould line',(x+side*.105,f,z),.298,.0025,'Rubber',segs=72,minorseg=6)
 for j in range(40):
  for side in [-1,1]:
   a=2*pi*j/40+side*.008;vv=[]
   for xx in [side*.005,side*.059,side*.111]:
    rr=.351-max(0,abs(xx)-.060)*.48
    for angle,dr in [(-.054,-.003),(.054,-.003),(.054,.012),(-.054,.012)]:
     aa=a+angle;vv.append((x+xx,f+(rr+dr)*sin(aa),z+(rr+dr)*cos(aa)))
   ff=[(3,2,1,0),(8,9,10,11)]+[(k*4+i,k*4+(i+1)%4,(k+1)*4+(i+1)%4,(k+1)*4+i) for k in range(2) for i in range(4)]
   block=mesh('crowned rounded military tread',vv,ff,'Rubber',.003)
   for face in block.data.polygons:face.use_smooth=True
 # rim profile faces on both sides, stamped recess, rolled lip
 for side in [-1,1]:
  cyl('pressed steel rim',(x+side*.065,f,z),.231,.034,'Paint',(1,0,0),64)
  cyl('rim dish',(x+side*.088,f,z),.182,.025,'Paint',(1,0,0),64)
  torus('rolled rim lip',(x+side*.089,f,z),.216,.012,'Paint',segs=72)
  torus('dish shoulder',(x+side*.104,f,z),.151,.012,'Paint',segs=64)
  cyl('hub casting',(x+side*.116,f,z),.082,.055,'Paint',(1,0,0),40)
  cyl('hub dust cap',(x+side*.15,f,z),.049,.022,'Paint',(1,0,0),32)
  for j in range(5):
   a=2*pi*j/5;cyl('lug nut',(x+side*.142,f+.105*sin(a),z+.105*cos(a)),.012,.019,'Metal',(1,0,0),6)
  cyl('valve stem',(x+side*.115,f+.174,z+.055),.006,.025,'Rubber',(1,0,0),10)
 if spare:
  # Original local X axis becomes the rearward longitudinal axis.
  for ob in groups[GROUP][start:]:
   for v in ob.data.vertices:
    w=ob.matrix_world@v.co;dx=w.x-x;dy=w.y+f
    w.x=x+dy;w.y=-f-dx*.65;v.co=ob.matrix_world.inverted()@w
 else:wheel_centres[name]=loc((x,f,z))
 GROUP=old
for name,x,f in [('Wheel_FL',.624,1.015),('Wheel_FR',-.624,1.015),('Wheel_RL',.624,-1.015),('Wheel_RR',-.624,-1.015)]:wheel(name,x,f,.363)
# floor and inner rear wheel housings
cube('pressed floor pan',(0,-.37,.52),(1.27,2.01,.065),'Paint',.018)
cube('transmission tunnel',(0,.12,.62),(.29,.76,.20),'Paint',.065)
for x in [-.49,.49]:cube('rear wheel box',(x,-1.055,.76),(.32,.86,.43),'Paint',.022)
cube('rear tail panel',(0,-1.46,.85),(1.32,.045,.66),'Paint',.015)
# shaped tub side, real open wheel arch (polygon extruded in X)
# contour from front sill, up shoulder, around top, then rear wheel arc and lower sill
for s in [-1,1]:
 contour=[(.35,.53),(.35,.65),(.02,.63),(-.28,.64),(-.42,.72),(-.55,.96),(-.72,1.12),(-1.46,1.12),(-1.46,.56),(-1.40,.56)]
 # lower arch runs from rear-most toward front-most, rising above tyre
 for j in range(25):
  a=pi-pi*j/24;contour.append((-1.015+.405*cos(a),.365+.405*sin(a)))
 contour += [(-.61,.53)]
 vs=[(s*(.64+t),f,z) for t in [-.013,.013] for f,z in contour];L=len(contour)
 faces=[tuple(reversed(range(L))),tuple(range(L,2*L))]+[(i,(i+1)%L,(i+1)%L+L,i+L) for i in range(L)]
 mesh('sculpted rear quarter with open arch',vs,faces,'Paint',.006)
 tube('tub rolled upper edge',[(s*.655,f,z+.005) for f,z in contour[:8]],.014)
 tube('door opening rolled edge',[(s*.66,.38,.66),(s*.66,.30,.64),(s*.66,.02,.63),(s*.66,-.28,.64),(s*.66,-.42,.72),(s*.66,-.55,.96),(s*.66,-.72,1.12)],.014)
 cube('entry step',(s*.61,.01,.49),(.27,.68,.045),'Paint',.012)
 # rear grab handles
 for f in [-1.31,-.52]:tube('body grab handle',[(s*.665,f-.09,.81),(s*.715,f-.09,.81),(s*.715,f+.09,.81),(s*.665,f+.09,.81)],.011)
 # top bow stowed rails
 for h in [1.15,1.19]:tube('folded canvas bow',[(s*.60,-.72,1.06),(s*.63,-.76,h),(s*.63,-1.45,h),(0,-1.47,h)],.012)
 for f in [-.76,-1.29]:cube('bow retaining bracket',(s*.663,f,1.04),(.027,.07,.11),'Metal')
# Engine compartment apron and fenders
for s in [-1,1]:
 cube('engine side apron',(s*.455,.98,.79),(.025,1.04,.45),'Paint',.015)
 # flat forward fender and bent rear skirt
 cube('flat front fender',(s*.58,1.075,.853),(.41,1.05,.036),'Paint',.018)
 o=cube('rear fender sloping apron',(s*.58,.463,.677),(.40,.40,.036),'Paint',.012);o.rotation_euler[0]=-.98
 tube('fender rolled outer lip',[(s*.773,1.57,.85),(s*.773,.56,.85),(s*.773,.34,.515)],.012)
# broad rounded bonnet loft (cross sections in X,Z)
profile=[(-.475,1.02),(-.475,1.075),(-.467,1.102),(-.445,1.12),(-.415,1.128),(.415,1.128),(.445,1.12),(.467,1.102),(.475,1.075),(.475,1.02)]
vs=[(x,f,z) for f in [.40,1.53] for x,z in profile];L=len(profile)
bonnet=mesh('rounded pressed bonnet',vs,[tuple(reversed(range(L))),tuple(range(L,2*L))]+[(i,(i+1)%L,(i+1)%L+L,i+L) for i in range(L)],'Paint',.006)
for face in bonnet.data.polygons:face.use_smooth=True
active(bonnet);wn=bonnet.modifiers.new('bonnet weighted normals','WEIGHTED_NORMAL');wn.keep_sharp=True;bpy.ops.object.modifier_apply(modifier=wn.name)
cube('cowl below windscreen',(0,.32,1.00),(.99,.22,.25),'Paint',.038)
rod('bonnet centre seam',(0,.42,1.134),(0,1.51,1.134),.002,'Metal',8)
for s in [-1,1]:
 for f in [.52,1.34]:
  cube('bonnet hinge or catch',(s*.47,f,1.015),(.021,.053,.06),'Metal',.004)
  rod('bonnet spring catch',(s*.489,f,.981),(s*.489,f,1.055),.006,'Metal',10)
 for f in [.64,1.37]:cube('bonnet rubber windscreen rest',(s*.27,f,1.141),(.027,.085,.017),'Rubber',.004)
# grille carrier with circular lamp openings cut through thick steel
panel=cube('front grille pressing',(0,1.54,.872),(1.00,.055,.49),'Paint',.055)
def cut(ob,cutter):
 active(ob);m=ob.modifiers.new('pressed opening','BOOLEAN');m.operation='DIFFERENCE';m.object=cutter;bpy.ops.object.modifier_apply(modifier=m.name)
 for arr in groups.values():
  if cutter in arr:arr.remove(cutter)
 bpy.data.objects.remove(cutter,do_unlink=True)
for x in [-.397,.397]:
 c=cyl('lamp aperture cutter',(x,1.54,.986),.095,.18,'Metal',(0,1,0),48);cut(panel,c)
for j in range(9):
 x=(j-4)*.067;c=cube('slot cutter',(x,1.54,.856),(.039,.16,.355),'Metal',.019);cut(panel,c)
bevel(panel,.002,2)
active(panel);wn=panel.modifiers.new('pressed grille weighted normals','WEIGHTED_NORMAL');wn.keep_sharp=True;bpy.ops.object.modifier_apply(modifier=wn.name)
cube('dark radiator core',(0,1.486,.85),(.70,.022,.41),'Rubber',.008)
for j in range(32):rod('radiator fine cooling fin',(-.32+j*.02,1.507,.68),(-.32+j*.02,1.507,1.04),.0015,'Metal',6)
for s in [-1,1]:
 x=s*.397
 cyl('headlight bucket',(x,1.50,.986),.091,.10,'Paint',(0,1,0),48)
 torus('headlight rolled bezel',(x,1.574,.986),.082,.012,'Paint',(0,1,0),64)
 # Recessed concave reflector, stepped lens gasket and a domed glass cover.
 vs=[];fs=[];N=48
 for rr,ff in [(0,1.537),(.025,1.541),(.05,1.55),(.071,1.572)]:
  for k in range(N):a=2*pi*k/N;vs.append((x+rr*sin(a),ff,.986+rr*cos(a)))
 for ring in range(3):
  for k in range(N):fs.append((ring*N+k,ring*N+(k+1)%N,(ring+1)*N+(k+1)%N,(ring+1)*N+k))
 reflector=mesh('headlamp silver reflector',vs,fs,'Lamps')
 for face in reflector.data.polygons:face.use_smooth=True
 torus('headlamp lens gasket',(x,1.577,.986),.073,.003,'Rubber',(0,1,0),48,8)
 cyl('headlamp central bulb',(x,1.55,.986),.012,.021,'Lamps',(0,1,0),20)
 lensverts=[(x,1.594,.986)]+[(x+.071*sin(k*2*pi/64),1.58,.986+.071*cos(k*2*pi/64)) for k in range(64)]
 lens=mesh('convex headlamp glass',lensverts,[(0,k+1,(k+1)%64+1) for k in range(64)],'Glass')
 for face in lens.data.polygons:face.use_smooth=True
 for j in range(-5,6):
  xx=j*.011;hh=math.sqrt(max(0,.069**2-xx**2));rod('headlight lens flute',(x+xx,1.584,.986-hh),(x+xx,1.584,.986+hh),.0012,'Glass',6)
 cyl('blackout marker housing',(s*.406,1.58,.825),.032,.025,'Paint',(0,1,0),24)
 cube('blackout slit',(s*.406,1.597,.825),(.034,.004,.008),'Rubber',.002)
rod('blackout lamp pedestal',(.615,1.30,.87),(.615,1.30,1.01),.019)
cyl('blackout driving lamp shell',(.615,1.30,1.024),.066,.10,'Paint',(0,1,0),40)
cyl('blackout lamp recessed face',(.615,1.353,1.024),.052,.007,'Rubber',(0,1,0),32)
cube('blackout lamp hood',(.615,1.37,1.047),(.115,.071,.03),'Paint',.012)
cube('blackout driving slit',(.615,1.361,1.02),(.052,.006,.008),'Lamps',.002)
for xx in [.557,.673]:tube('blackout lamp protective hoop',[(xx,1.23,.875),(xx,1.23,1.08),(xx,1.40,1.08),(xx,1.40,.875)],.007)
# bumpers, tow eyes and fasteners
cube('front bumper channel',(0,1.615,.48),(1.54,.083,.115),'Paint',.008)
for x in [-.46,.46]:
 cube('bumper chassis mounting',(x,1.50,.45),(.08,.32,.07),'Metal')
 for z in [.455,.505]:cyl('bumper bolt',(x,1.661,z),.012,.008,'Metal',(0,1,0),6)
 tube('front tow loop',[(x-.045,1.61,.405),(x-.043,1.65,.385),(x+.043,1.65,.385),(x+.045,1.61,.405)],.015,'Metal')
cube('rear cross bumper',(0,-1.50,.45),(1.36,.10,.115),'Paint',.009)
for x in [-.52,.52]:cube('rear bumperette',(x,-1.58,.48),(.25,.10,.14),'Paint',.009)
torus('rear pintle ring',(0,-1.60,.47),.045,.012,'Metal',(0,1,0),32)
# Windscreen: inclined backwards with split panes, lower sheet and rubber seals.
def W(x,h,off=0):return (x,.32-.18*(h-1.08)+off,h)
for x in [-.52,.52]:rod('windscreen outer upright',W(x,1.08),W(x,1.752),.019)
for h in [1.08,1.27,1.752]:rod('windscreen cross rail',W(-.52,h),W(.52,h),.018)
rod('windscreen centre mullion',W(0,1.27),W(0,1.752),.012)
apron=mesh('windscreen lower apron',[W(-.50,1.09),W(.50,1.09),W(.50,1.265),W(-.50,1.265)],[(0,1,2,3)],'Paint');active(apron);th=apron.modifiers.new('sheet thickness','SOLIDIFY');th.thickness=.014;bpy.ops.object.modifier_apply(modifier=th.name);bevel(apron,.003,2)
for s in [-1,1]:
 a,b=sorted([s*.014,s*.497]);pts=[W(a,1.285),W(b,1.285),W(b,1.734),W(a,1.734)]
 mesh('laminated windscreen pane',pts,[(0,1,2,3)],'Glass');tube('windscreen rubber gasket',pts,.006,'Rubber',True)
 cyl('wiper pivot motor',W(s*.31,1.721,-.018),.023,.043,'Metal',(0,1,0),20)
 rod('vacuum wiper arm',W(s*.31,1.72,.015),W(s*.13,1.57,.02),.004,'Metal',10)
 rod('wiper blade',W(s*.075,1.54,.025),W(s*.23,1.67,.025),.006,'Rubber',10)
 cyl('windscreen hinge',(s*.53,.32,1.09),.034,.048,'Metal',(1,0,0),24)
 rod('windscreen brace',(s*.53,.32,1.12),(s*.53,.15,1.30),.008)
 cyl('hinge wing nut',(s*.558,.32,1.09),.015,.015,'Metal',(1,0,0),6)
# Dashboard and legible mechanical instruments with geometry, no text
cube('dashboard',(0,.25,.925),(.94,.037,.28),'Paint',.015)
for x,z,r in [(0,1.00,.062),(-.16,1.00,.035),(.16,1.00,.035),(-.10,.89,.03),(.10,.89,.03)]:
 cyl('instrument chrome rim',(x,.222,z),r,.012,'Metal',(0,1,0),40)
 cyl('instrument black dial',(x,.211,z),r*.86,.008,'Rubber',(0,1,0),40)
 for j in range(11):
  a=-.7+j*.49;rod('gauge tick',(x+sin(a)*r*.64,.205,z+cos(a)*r*.64),(x+sin(a)*r*.77,.205,z+cos(a)*r*.77),.0012,'Lamps',6)
 rod('gauge needle',(x,.201,z),(x-r*.35,.201,z+r*.40),.0015,'Lamps',6)
for x in [-.27,.26,.32]:cyl('dashboard pull switch',(x,.19,.88),.012,.035,'Rubber',(0,1,0),16)
cube('glovebox lid',(-.335,.222,.995),(.22,.014,.16),'Paint',.008)
cyl('glovebox latch',(-.335,.207,1.055),.012,.01,'Metal',(0,1,0),16)
# seats with rounded pads, sewn piping, subtle sewn depressions and frame
for x in [-.32,.32]:
 for f in [-.16,-.55]:rod('seat tubular leg',(x-.17,f,.55),(x-.17,f,.66),.014,'Metal');rod('seat tubular leg',(x+.17,f,.55),(x+.17,f,.66),.014,'Metal')
 cushion=padded('canvas driver passenger cushion',(x,-.34,.686),(.43,.47,.105))
 back=padded('canvas front seat back',(x,-.585,.955),(.43,.102,.44))
 for zz in [.686,.713]:
  pts=[(x+.19*cos(a),-.34+.211*sin(a),zz) for a in [j*2*pi/48 for j in range(48)]];tube('seat cushion sewn piping',pts,.003,'Canvas',True)
 for sx in [-.16,.16]:rod('seat back frame',(x+sx,-.59,.63),(x+sx,-.63,1.08),.012,'Metal')
 for xx in [-.12,.12]:tube('canvas back sewn seam',[(x+xx,-.525,.78),(x+xx,-.55,1.04)],.002,'Canvas')
padded('rear bench canvas cushion',(0,-1.20,.79),(.88,.39,.10))
padded('rear bench canvas backrest',(0,-1.40,1.035),(.89,.105,.34))
for x in [-.39,.39]:rod('bench back frame',(x,-1.43,.66),(x,-1.43,1.12),.012,'Metal')
# foot pedals, levers, steering wheel
for x in [.22,.34,.44]:
 rod('pedal stalk',(x,.30,.56),(x,.16,.69),.009,'Metal');cube('pedal rubber pad',(x,.15,.69),(.07,.045,.024),'Rubber',.006)
for x,f,h in [(0,-.06,.89),(-.10,-.12,.77),(-.16,-.15,.75)]:
 rod('gear selector lever',(x,.14,.62),(x,f,h),.007,'Metal')
 bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,radius=.021,location=loc((x,f,h)));add(bpy.context.object,'gear knob','Rubber')
rod('steering column',(.32,.25,.83),(.32,-.055,1.11),.023,'Metal')
GROUP='SteeringWheel';hub=(.32,-.055,1.11);axis=Vector((0,-.305,.28)).normalized()
# planar wheel basis in vehicle coords
u=Vector((1,0,0));v=axis.cross(u);pts=[Vector(hub)+.19*(u*cos(i*2*pi/64)+v*sin(i*2*pi/64)) for i in range(64)]
tube('steering wheel rim',pts,.013,'Rubber',True)
for a in [0,2*pi/3,4*pi/3]:rod('steering spoke',hub,Vector(hub)+.18*(u*cos(a)+v*sin(a)),.009,'Metal')
cyl('steering hub',hub,.035,.03,'Metal',axis,32);GROUP='Body'
# left mirror and side tools
rod('mirror stalk',(.53,.30,1.02),(.719,.32,1.27),.007,'Metal');cyl('mirror back',(.719,.32,1.29),.062,.013,'Paint',(0,1,0),40);cyl('mirror face',(.719,.309,1.29),.055,.004,'Metal',(0,1,0),40)
# Axe and shovel secured to left side of tub
rod('axe hickory handle',(.697,-.52,.565),(.697,.19,.61),.016,'Wood',16)
cube('axe forged head',(.697,-.55,.58),(.065,.13,.10),'Metal',.015)
rod('shovel wooden shaft',(.703,-.49,.73),(.703,.17,.75),.012,'Wood',16)
sh=cube('shovel pressed blade',(.70,.25,.745),(.043,.23,.135),'Metal',.034)
tube('shovel D grip',[(.705,-.50,.73),(.705,-.57,.78),(.705,-.63,.76),(.705,-.63,.70),(.705,-.57,.69),(.705,-.50,.73)],.008,'Metal')
for f in [-.40,.07]:cube('tool retention strap',(.727,f,.65),(.025,.032,.23),'Canvas',.003);cube('strap buckle',(.745,f,.67),(.014,.044,.045),'Metal',.003)
# spare tyre mount and rear jerrycan
cube('spare wheel carrier',(-.21,-1.52,.96),(.38,.12,.12),'Metal',.012)
wheel('Spare',-.21,-1.56,1.015,True)
cube('jerrycan cradle',(.43,-1.54,.80),(.32,.20,.055),'Metal',.008)
cube('jerrycan pressed tank',(.43,-1.55,1.06),(.29,.18,.46),'Paint',.025)
# stamped X reinforcement rear face
for a,b in [((.32,-1.648,.92),(.54,-1.648,1.19)),((.32,-1.648,1.19),(.54,-1.648,.92))]:rod('jerrycan embossed rib',a,b,.009)
for xx in [.36,.43,.50]:tube('jerrycan carry handle',[(xx,-1.60,1.28),(xx,-1.59,1.34),(xx,-1.48,1.34),(xx,-1.47,1.28)],.012)
cyl('jerrycan filler cap',(.53,-1.55,1.295),.034,.025,'Metal',v=24)
cube('jerrycan retaining strap',(.43,-1.651,1.07),(.033,.012,.43),'Canvas',.003)
# tail lights, fuel cap, exhaust and bolts
for x in [-.54,.54]:
 cyl('tail lamp housing',(x,-1.489,.67),.04,.028,'Metal',(0,1,0),24)
 cyl('tail lamp lens',(x,-1.506,.67),.029,.008,'Lamps',(0,1,0),24)
 cyl('rear blackout lamp',(x,-1.496,.78),.025,.024,'Paint',(0,1,0),24)
cyl('fuel filler cap',(.667,-.60,.95),.049,.020,'Paint',(1,0,0),32)
rod('fuel cap handle',(.68,-.63,.95),(.68,-.57,.95),.007,'Metal')
tube('exhaust pipe',[(-.30,.62,.39),(-.35,.30,.31),(-.37,-.65,.30),(-.54,-1.38,.30),(-.62,-1.49,.31)],.024,'Metal')
cyl('exhaust muffler',(-.36,-.55,.32),.075,.48,'Metal',(0,1,0),24)
for s in [-1,1]:
 for f,z in [(-1.4,1.07),(-.55,1.05),(-1.41,.59),(.4,.85),(.54,.89),(1.4,.88)]:cyl('body round head bolt',(s*.666,f,z),.008,.008,'Metal',(1,0,0),12)
# Seats receive smooth rounded textile edges; joined solids have coherent outward normals.
for ob in groups['Body']:
 if False:
  for poly in ob.data.polygons:poly.use_smooth=True
  active(ob);wn=ob.modifiers.new('weighted padded normals','WEIGHTED_NORMAL');wn.keep_sharp=True;bpy.ops.object.modifier_apply(modifier=wn.name)
# Join rigid nodes. Metric box projection UVs preserve photographic detail across big panels.
exports=[]
for name,obs in groups.items():
 for ob in obs:
  active(ob);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
  bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(ob.data);bm.free()
  colors=ob.data.color_attributes.new(name='Color',type='BYTE_COLOR',domain='CORNER')
  rgba=(.42,.014,.009,1) if 'tail lamp lens' in ob.name else ((.32,.35,.34,1) if 'reflector' in ob.name else (1,1,1,1))
  for datum in colors.data:datum.color=rgba
  uv=ob.data.uv_layers.new(name='UVMap') if not ob.data.uv_layers else ob.data.uv_layers.active
  for poly in ob.data.polygons:
   normal=poly.normal;axis=max(range(3),key=lambda i:abs(normal[i]));inds=[i for i in range(3) if i!=axis]
   scale=2.5 if ob.data.materials[0].name=='Canvas' else 1.5
   for li in poly.loop_indices:
    co=ob.matrix_world@ob.data.vertices[ob.data.loops[li].vertex_index].co;uv.data[li].uv=(co[inds[0]]*scale,co[inds[1]]*scale)
 bpy.ops.object.select_all(action='DESELECT')
 for ob in obs:ob.select_set(True)
 bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();ob=bpy.context.object;ob.name=name
 sc.cursor.location=wheel_centres.get(name,loc(hub) if name=='SteeringWheel' else (0,0,0));bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 # triangulate deterministically and remove only numerical degeneracies
 active(ob);mod=ob.modifiers.new('runtime triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
 exports.append(ob)
# Spend geometry where it shapes the vehicle, keeping the complete runtime mesh below 100k.
raw_tri=sum(len(o.data.polygons) for o in exports)
if raw_tri>98000:
 for ob in exports:
  active(ob);dec=ob.modifiers.new('game mesh budget','DECIMATE');dec.ratio=96000/raw_tri;dec.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=dec.name)
for ob in exports:
 bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.dissolve_degenerate(bm,dist=.0000001,edges=list(bm.edges));bad=[f for f in bm.faces if f.calc_area()<1e-12]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(ob.data);bm.free()
master=next(o for o in exports if o.name=='Wheel_FL')
for ob in exports:
 if ob.name.startswith('Wheel_') and ob!=master:ob.data=master.data
for ob in exports:
 if ob.name.startswith('Wheel_'):ob.location.z=-min(v.co.z for v in ob.data.vertices)
# attachment sockets in native coordinates
sockets={'Seat_Driver':(.32,-.34,.74),'Door_L':(.68,-.02,.72),'Door_R':(-.68,-.02,.72),'Exhaust':(-.62,-1.49,.31),'Light_Head_L':(.397,1.60,.986),'Light_Head_R':(-.397,1.60,.986),'Light_Tail_L':(.54,-1.52,.67),'Light_Tail_R':(-.54,-1.52,.67),'Troop_1':(-.32,-.34,.74),'Troop_2':(.23,-1.17,.85),'Troop_3':(-.23,-1.17,.85)}
for name,p in sockets.items():
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=loc(p);exports.append(o)
paintchoices=[('Olive drab',[.12,.13,.07]),('NATO green',[.075,.11,.068]),('Desert tan',[.43,.33,.19]),('Field grey',[.19,.215,.18]),('Winter white',[.75,.77,.70]),('Dark green-black',[.028,.041,.029])]
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(c) for o in exports if o.type=='MESH' for c in o.bound_box]
def game(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
gpts=[game(p) for p in pts];bbox={'min':[min(p[i] for p in gpts) for i in range(3)],'max':[max(p[i] for p in gpts) for i in range(3)]}
tri=sum(len(o.data.polygons) for o in exports if o.type=='MESH')
manifest={'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','left':'+X','nodes':[{'name':o.name,'position':game(o.matrix_world.translation),'type':o.type} for o in exports],'bounding_box':bbox,'triangles':tri,'paints':[{'name':n,'linear_rgb':c} for n,c in paintchoices]}
json.dump(manifest,open('nodes.json','w'),indent=2)
# Export with untinted neutral paint texture; explicitly set standard glTF baseColorFactor.
pnt=M['Paint'].node_tree;bs=pnt.nodes.get('Principled BSDF');mult=pnt.nodes.get('PaintTint');pnt.links.new(mult.inputs[1].links[0].from_socket,bs.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in exports:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=os.path.join(ROOT,'ww2-jeep.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO')
pnt.links.new(mult.outputs[0],bs.inputs['Base Color'])
path='ww2-jeep.glb';raw=open(path,'rb').read();ln,typ=struct.unpack_from('<II',raw,12);j=json.loads(raw[20:20+ln]);tail=raw[20+ln:]
for m in j['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.12,.13,.07,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
js=json.dumps(j,separators=(',',':')).encode();js+=b' '*((-len(js))%4);open(path,'wb').write(struct.pack('<4sII',b'glTF',2,20+len(js)+len(tail))+struct.pack('<II',len(js),0x4e4f534a)+js+tail)
print('ASSET_EXPORTED',tri,os.path.getsize(path),bbox,flush=True)
# Soft daylight studio, ground excluded from GLB.
ground=basic('Studio ground',(.32,.345,.365),.86)
bpy.ops.mesh.primitive_plane_add(size=200);floor=bpy.context.object;floor.name='Studio ground';floor.data.materials.append(ground);floor.location.z=-.004
world=bpy.data.worlds.new('soft daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.74,.85,1);world.node_tree.nodes['Background'].inputs[1].default_value=.35
for name,p,power,size,col in [('large softbox', (3,-4,6),1350,5,(1,.94,.84)),('sky fill',(-4,-1,3),750,4,(.78,.86,1)),('rear rim',(1,4,5),1050,3,(1,.94,.84))]:
 bpy.ops.object.light_add(type='AREA',location=p);o=bpy.context.object;o.name=name;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.data.color=col;o.rotation_euler=(Vector((0,0,.7))-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO'
def camera(p,target,scale):cam.location=p;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale
camera((4.5,-6.3,3.1),(0,0,.83),4.6)
cam.data.type='PERSP';cam.data.lens=60
sc.view_settings.look='AgX - Medium High Contrast';sc.view_settings.exposure=-.25
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=32
sc.render.filepath=os.path.join(ROOT,'final.png');bpy.ops.render.render(write_still=True)
# Clay, same camera, all mesh slots temporarily overridden
clay=basic('QA clay',(.39,.39,.39),.73);original={}
for o in exports:
 if o.type=='MESH':
  if o.data.name not in original:
   original[o.data.name]=list(o.data.materials)
   for i in range(len(o.data.materials)):o.data.materials[i]=clay
sc.render.filepath=os.path.join(ROOT,'clay.png');sc.cycles.samples=16;bpy.ops.render.render(write_still=True)
for o in exports:
 if o.type=='MESH':
  for i,m in enumerate(original[o.data.name]):o.data.materials[i]=m
cam.data.type='ORTHO'
camera((5,0,1.0),(0,0,.88),4.05);sc.render.filepath=os.path.join(ROOT,'side.png');bpy.ops.render.render(write_still=True)
# Six actual material recolours, rendered sequentially on the same camera.
cam.data.type='PERSP'
camera((4.5,-6.3,3.1),(0,0,.83),4.6);sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
pixels=[]
for idx,(name,color) in enumerate(paintchoices):
 mult.inputs[2].default_value=(*color,1);sc.render.filepath=os.path.join(ROOT,'textures',f'paint_{idx}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(sc.render.filepath,check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);pixels.append(a);bpy.data.images.remove(im)
grid=np.concatenate([np.concatenate(pixels[3:],axis=1),np.concatenate(pixels[:3],axis=1)],axis=0)
im=bpy.data.images.new('Paint variants',1920,720,alpha=True);im.pixels.foreach_set(grid.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save();mult.inputs[2].default_value=(.12,.13,.07,1)
notes=["WWII US Willys MB style open quarter-ton jeep, reference reconstruction; metres, +Z front, ground-centred axle midpoint.",f"Triangles: {tri:,}; GLB: {os.path.getsize(path)/1048576:.2f} MiB; separate wheel and steering pivots plus Garage v2 sockets.","Materials: Paint, Rubber, Metal, Canvas, Wood, Glass (BLEND), Lamps (emissive); neutral Paint texture with olive baseColorFactor.","Shared Cycles-baked tiling textures: base colour and tangent normal 1024px; ORM 512px; OptiX GPU bake/render.","Poly Haven CC0 ids: blue_metal_plate, rubberized_track, rusty_metal_03, denim_fabric, fine_grained_wood; https://polyhaven.com/license ; source https://api.polyhaven.com/assets?t=textures .", "Reference differences: no insignia/text by request; simplified engine underside and canvas creasing; spare and jerrycan fittings reconstructed from visible views."]
open('NOTES.md','w').write('\n'.join(notes)+'\n')
print('BUILD_COMPLETE',flush=True)
