"""Clean scene build. Blender 5.2; metres; +X left, -Y front, Z up before glTF conversion.
Sources: Poly Haven CC0. Tiled Cycles material bakes preserve close-range texel density.
Run: blender -b --python-exit-code 1 --python build_tactical-truck.py
"""
import bpy, bmesh, math, os, json, urllib.request, subprocess, struct, random
from pathlib import Path
from mathutils import Vector
from math import sin, cos, pi
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(18)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials): bpy.data.materials.remove(m)
sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=16
prefs=bpy.context.preferences.addons['cycles'].preferences
try: prefs.compute_device_type='OPTIX'
except: prefs.compute_device_type='CUDA'
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.world.color=(.3,.3,.3)
sc.view_settings.view_transform='AgX'
PAINTS=[('Sand',[.42,.37,.25]),('Olive drab',[.16,.19,.095]),('NATO green',[.075,.13,.08]),('Field grey',[.23,.26,.23]),('Winter white',[.78,.8,.77]),('Dark green-black',[.035,.047,.034])]
M={}; BODY=[]; GROUP=None

def active(o):
 
 for selected in bpy.context.selected_objects: selected.select_set(False)
 o.select_set(True); bpy.context.view_layer.objects.active=o

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

def material_bake(name,id,tint,rough,metal=0,normal_strength=.15):
 print('BAKE',name,flush=True)
 m=bpy.data.materials.new(name); m.use_nodes=True; n=m.node_tree.nodes; l=m.node_tree.links; n.clear()
 out=n.new('ShaderNodeOutputMaterial'); bs=n.new('ShaderNodeBsdfPrincipled'); l.new(bs.outputs[0],out.inputs[0])
 uv=n.new('ShaderNodeTexCoord')
 diff=n.new('ShaderNodeTexImage'); diff.image=source(id,'diff'); l.new(uv.outputs['UV'],diff.inputs['Vector'])
 bw=n.new('ShaderNodeRGBToBW'); l.new(diff.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB'); ramp.color_ramp.elements[0].position=.05; ramp.color_ramp.elements[1].position=.8
 low=.48 if name=='Paint' else .52
 ramp.color_ramp.elements[0].color=tuple(c*low for c in tint)+(1,)
 ramp.color_ramp.elements[1].color=tuple(tint)+(1,)
 l.new(bw.outputs[0],ramp.inputs[0])
 noise=n.new('ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=37; noise.inputs['Detail'].default_value=3.5; l.new(uv.outputs['UV'],noise.inputs['Vector'])
 mix=n.new('ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=.12; l.new(ramp.outputs[0],mix.inputs[1]); l.new(noise.outputs['Fac'],mix.inputs[2]); l.new(mix.outputs[0],bs.inputs['Base Color'])
 # Baked neutral chips, fine grime and elongated rain marks in reusable surface tile.
 if name in ['Paint','Canvas','Rubber']:
  fine=n.new('ShaderNodeTexNoise');fine.inputs['Scale'].default_value=155;fine.inputs['Detail'].default_value=2;l.new(uv.outputs['UV'],fine.inputs['Vector'])
  cr=n.new('ShaderNodeValToRGB');cr.color_ramp.elements[0].position=.32;cr.color_ramp.elements[0].color=(.32,.32,.32,1);cr.color_ramp.elements[1].position=.48;cr.color_ramp.elements[1].color=(1,1,1,1);l.new(fine.outputs['Fac'],cr.inputs[0])
  cm=n.new('ShaderNodeMixRGB');cm.blend_type='MULTIPLY';cm.inputs[0].default_value=.65;l.new(mix.outputs[0],cm.inputs[1]);l.new(cr.outputs[0],cm.inputs[2]);mix=cm
  stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(35,7,1);l.new(uv.outputs['UV'],stretch.inputs[0])
  rain=n.new('ShaderNodeTexNoise');rain.inputs['Scale'].default_value=1;rain.inputs['Detail'].default_value=3;l.new(stretch.outputs[0],rain.inputs['Vector'])
  rr=n.new('ShaderNodeValToRGB');rr.color_ramp.elements[0].position=.22;rr.color_ramp.elements[0].color=(.48,.48,.48,1);rr.color_ramp.elements[1].position=.56;rr.color_ramp.elements[1].color=(1,1,1,1);l.new(rain.outputs['Fac'],rr.inputs[0])
  rm=n.new('ShaderNodeMixRGB');rm.blend_type='MULTIPLY';rm.inputs[0].default_value=.13;l.new(mix.outputs[0],rm.inputs[1]);l.new(rr.outputs[0],rm.inputs[2]);mix=rm;l.new(mix.outputs[0],bs.inputs['Base Color'])
 ni=n.new('ShaderNodeTexImage'); ni.image=source(id,'nor_gl'); l.new(uv.outputs['UV'],ni.inputs['Vector'])
 nm=n.new('ShaderNodeNormalMap'); nm.inputs['Strength'].default_value=normal_strength; l.new(ni.outputs['Color'],nm.inputs['Color']); l.new(nm.outputs[0],bs.inputs['Normal'])
 ar=n.new('ShaderNodeTexImage'); ar.image=source(id,'arm'); l.new(uv.outputs['UV'],ar.inputs['Vector'])
 sep=n.new('ShaderNodeSeparateColor'); l.new(ar.outputs[0],sep.inputs[0])
 rmap=n.new('ShaderNodeMapRange'); rmap.inputs['To Min'].default_value=max(.1,rough-.09); rmap.inputs['To Max'].default_value=min(1,rough+.08); l.new(sep.outputs[1],rmap.inputs['Value'])
 l.new(rmap.outputs[0],bs.inputs['Roughness']); bs.inputs['Metallic'].default_value=metal
 ao=n.new('ShaderNodeMapRange'); ao.inputs['To Min'].default_value=.78; ao.inputs['To Max'].default_value=1; l.new(sep.outputs[0],ao.inputs[0])
 comb=n.new('ShaderNodeCombineColor'); l.new(ao.outputs[0],comb.inputs[0]); l.new(rmap.outputs[0],comb.inputs[1]); comb.inputs[2].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=1); tile=bpy.context.object; tile.data.materials.append(m)
 emit=n.new('ShaderNodeEmission'); target=n.new('ShaderNodeTexImage')
 images={}
 for kind,res in [('base',1024),('normal',1024),('orm',512)]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
  if kind!='base': im.colorspace_settings.name='Non-Color'
  target.image=im; n.active=target; active(tile)
  if kind=='normal':
   l.new(bs.outputs[0],out.inputs[0]); bpy.ops.object.bake(type='NORMAL',margin=8)
  else:
   l.new(mix.outputs[0] if kind=='base' else comb.outputs[0],emit.inputs[0]); l.new(emit.outputs[0],out.inputs[0]); bpy.ops.object.bake(type='EMIT',margin=8)
  im.filepath_raw=str(TEX/(name+'_'+kind+('.jpg' if kind=='base' else '.png'))); im.file_format='JPEG' if kind=='base' else 'PNG'; im.save(); images[kind]=im
 bpy.data.objects.remove(tile,do_unlink=True)
 n.clear(); out=n.new('ShaderNodeOutputMaterial'); bs=n.new('ShaderNodeBsdfPrincipled'); l.new(bs.outputs[0],out.inputs[0])
 base=n.new('ShaderNodeTexImage'); base.image=images['base']
 if name=='Paint':
  mul=n.new('ShaderNodeMixRGB'); mul.name='PaintFactor'; mul.blend_type='MULTIPLY'; mul.inputs[0].default_value=1; mul.inputs[2].default_value=(*PAINTS[0][1],1); l.new(base.outputs[0],mul.inputs[1]); l.new(mul.outputs[0],bs.inputs['Base Color'])
 else: l.new(base.outputs[0],bs.inputs['Base Color'])
 ni=n.new('ShaderNodeTexImage'); ni.image=images['normal']; nm=n.new('ShaderNodeNormalMap'); l.new(ni.outputs[0],nm.inputs['Color']); l.new(nm.outputs[0],bs.inputs['Normal'])
 ar=n.new('ShaderNodeTexImage'); ar.image=images['orm']; sep=n.new('ShaderNodeSeparateColor'); l.new(ar.outputs[0],sep.inputs[0]); l.new(sep.outputs[1],bs.inputs['Roughness']); l.new(sep.outputs[2],bs.inputs['Metallic'])
 group=bpy.data.node_groups.get('glTF Material Output')
 if not group:
  group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=n.new('ShaderNodeGroup'); gn.node_tree=group; l.new(sep.outputs[0],gn.inputs['Occlusion'])
 M[name]=m; return m

def simple(name,col,rough=.5,metal=0):
 m=bpy.data.materials.new(name); m.use_nodes=True; bs=m.node_tree.nodes.get('Principled BSDF'); bs.inputs['Base Color'].default_value=(*col,1); bs.inputs['Roughness'].default_value=rough; bs.inputs['Metallic'].default_value=metal; M[name]=m; return m

# Baked reusable 1 m tiles, not a low-density whole-vehicle atlas.
material_bake('Paint','metal_plate',(.96,.96,.96),.78,.08,.055)
material_bake('Metal','rusty_metal_03',(.12,.125,.12),.64,.72,.25)
material_bake('Rubber','rubberized_track',(.055,.06,.058),.86,0,.16)
material_bake('Canvas','denim_fabric',(.46,.39,.26),.94,0,.28)
material_bake('Interior','fabric_leather_01',(.065,.075,.06),.83,0,.18)
# Fixed sand wheel finish shares the baked neutral tile; never responds to body repaint.
rim=M['Paint'].copy(); rim.name='Rim'; rim.node_tree.nodes['PaintFactor'].inputs[2].default_value=(.34,.29,.19,1); M['Rim']=rim
glass=simple('Glass',(.20,.28,.30),.14,.05); bs=glass.node_tree.nodes.get('Principled BSDF'); bs.inputs['Alpha'].default_value=.28; bs.inputs['Transmission Weight'].default_value=.12; glass.surface_render_method='BLENDED'; glass.use_transparency_overlap=False
lamps=simple('Lamps',(.75,.72,.59),.22,.12); bs=lamps.node_tree.nodes.get('Principled BSDF'); bs.inputs['Emission Color'].default_value=(.9,.8,.55,1); bs.inputs['Emission Strength'].default_value=.22
# Tiny shared lamp atlas allows white, amber and red lenses in one material.
im=bpy.data.images.new('Lamp_lenses',width=96,height=32,alpha=False); pix=np.ones((32,96,4),dtype=np.float32)
for a,b,col in [(0,32,(.72,.75,.69)),(32,64,(.8,.19,.018)),(64,96,(.42,.012,.008))]:
 pix[:,a:b,:3]=col
 for xx in range(a,b): pix[:,xx,:3]*=.8+.2*(xx%4)/3
im.pixels.foreach_set(pix.ravel()); im.filepath_raw=str(TEX/'Lamps_base.png'); im.file_format='PNG'; im.save(); nt=lamps.node_tree; it=nt.nodes.new('ShaderNodeTexImage'); it.image=im; nt.links.new(it.outputs[0],bs.inputs['Base Color']); nt.links.new(it.outputs[0],bs.inputs['Emission Color'])


# Flatten the optical materials too, using small shared maps rather than large atlases.
for name in ['Glass','Lamps']:
 m=M[name]; nt=m.node_tree; nn=nt.nodes; ll=nt.links
 bs=nn.get('Principled BSDF'); output=nn.get('Material Output')
 bpy.ops.mesh.primitive_plane_add(size=1); tile=bpy.context.object; tile.data.materials.append(m)
 target=nn.new('ShaderNodeTexImage'); em=nn.new('ShaderNodeEmission')
 base_source=bs.inputs['Base Color'].links[0].from_socket if bs.inputs['Base Color'].is_linked else None
 made={}
 for kind in ['base','normal','orm']:
  image=bpy.data.images.new(name+'_'+kind+'_baked',width=96 if name=='Lamps' else 32,height=32,alpha=False)
  if kind!='base':image.colorspace_settings.name='Non-Color'
  target.image=image;nn.active=target;active(tile)
  if kind=='normal':
   ll.new(bs.outputs[0],output.inputs[0]);bpy.ops.object.bake(type='NORMAL',margin=2)
  else:
   for link in list(em.inputs[0].links):ll.remove(link)
   if kind=='base':
    if base_source:ll.new(base_source,em.inputs[0])
    else:em.inputs[0].default_value=bs.inputs['Base Color'].default_value
   else:em.inputs[0].default_value=(1,bs.inputs['Roughness'].default_value,bs.inputs['Metallic'].default_value,1)
   ll.new(em.outputs[0],output.inputs[0]);bpy.ops.object.bake(type='EMIT',margin=2)
  image.filepath_raw=str(TEX/(name+'_'+kind+'_baked.png'));image.file_format='PNG';image.save();made[kind]=image
 ll.new(bs.outputs[0],output.inputs[0]);bpy.data.objects.remove(tile,do_unlink=True)
 base=nn.new('ShaderNodeTexImage');base.image=made['base'];ll.new(base.outputs[0],bs.inputs['Base Color'])
 if name=='Lamps':ll.new(base.outputs[0],bs.inputs['Emission Color'])
 ni=nn.new('ShaderNodeTexImage');ni.image=made['normal'];nm=nn.new('ShaderNodeNormalMap');ll.new(ni.outputs[0],nm.inputs['Color']);ll.new(nm.outputs[0],bs.inputs['Normal'])
 ar=nn.new('ShaderNodeTexImage');ar.image=made['orm'];se=nn.new('ShaderNodeSeparateColor');ll.new(ar.outputs[0],se.inputs[0]);ll.new(se.outputs[1],bs.inputs['Roughness']);ll.new(se.outputs[2],bs.inputs['Metallic'])
 nn.remove(target);nn.remove(em)

def finish(o,name,mat,bevel=0,smooth=False):
 o.name=name; o.data.materials.append(M[mat]); active(o)
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if bevel:
  mod=o.modifiers.new('Manufactured edge radii','BEVEL'); mod.width=bevel; mod.segments=1 if bevel<=.021 else 2
  bpy.ops.object.modifier_apply(modifier=mod.name)
 if smooth:
  for p in o.data.polygons: p.use_smooth=True
 elif bevel:
  mod=o.modifiers.new('Corner normals','WEIGHTED_NORMAL'); mod.keep_sharp=True; mod.weight=40; bpy.ops.object.modifier_apply(modifier=mod.name)
 # World-metric planar UV projection on each face; shared 1m tiled bakes.
 uv=o.data.uv_layers.active or o.data.uv_layers.new(name='UVMap')
 for p in o.data.polygons:
  ax=max(range(3),key=lambda k:abs(p.normal[k])); dims=[k for k in range(3) if k!=ax]
  for li in p.loop_indices:
   v=o.data.vertices[o.data.loops[li].vertex_index].co
   uv.data[li].uv=(v[dims[0]],v[dims[1]])
 (GROUP if GROUP is not None else BODY).append(o)
 return o

def box(name,loc,size,mat='Paint',bev=.015,rot=None):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.scale=size
 if rot:o.rotation_euler=rot
 return finish(o,name,mat,bev)

def mesh(name,verts,faces,mat='Paint',bev=.01,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)
 bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=bm.faces); bm.to_mesh(me); bm.free()
 return finish(o,name,mat,bev,smooth)

def prism(name,poly,x1,x2,mat='Paint',bev=.015):
 # Side outline (y,z), extruded laterally.
 N=len(poly); vs=[(x,y,z) for x in (x1,x2) for y,z in poly]; fs=[tuple(range(N-1,-1,-1)),tuple(range(N,N*2))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
 return mesh(name,vs,fs,mat,bev)

def cyl(name,loc,r,depth,mat='Metal',axis=(0,0,1),n=24,bev=.006):
 bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=depth,location=loc); o=bpy.context.object; o.rotation_euler=Vector(axis).to_track_quat('Z','Y').to_euler(); return finish(o,name,mat,bev,True)

def rod(name,a,b,r,mat='Metal',n=12):
 a,b=Vector(a),Vector(b); return cyl(name,(a+b)/2,r,(b-a).length,mat,b-a,n,min(r*.3,.006))

def tube(name,pts,r,mat='Paint',res=8):
 vs=[]; fs=[]
 for i,p in enumerate(pts):
  v=Vector(p); tangent=Vector(pts[min(i+1,len(pts)-1)])-Vector(pts[max(0,i-1)])
  q=tangent.to_track_quat('Z','Y')
  for j in range(res): vs.append(v+q@Vector((r*cos(2*pi*j/res),r*sin(2*pi*j/res),0)))
 for i in range(len(pts)-1):
  for j in range(res): fs.append((i*res+j,i*res+(j+1)%res,(i+1)*res+(j+1)%res,(i+1)*res+j))
 fs.extend([tuple(range(res-1,-1,-1)),tuple((len(pts)-1)*res+j for j in range(res))]); return mesh(name,vs,fs,mat,0,True)

def ring(name,center,major,minor,mat='Paint',axis='X',seg=48,sub=6):
 vs=[]; fs=[]
 for i in range(seg):
  a=2*pi*i/seg
  for j in range(sub):
   b=2*pi*j/sub; r=major+minor*cos(b); p=(minor*sin(b),r*cos(a),r*sin(a)) if axis=='X' else (r*cos(a),r*sin(a),minor*sin(b))
   vs.append(tuple(center[k]+p[k] for k in range(3)))
 for i in range(seg):
  for j in range(sub): fs.append((i*sub+j,((i+1)%seg)*sub+j,((i+1)%seg)*sub+(j+1)%sub,i*sub+(j+1)%sub))
 return mesh(name,vs,fs,mat,0,True)

def bolt(loc,axis=(1,0,0),r=.018,mat='Paint'):
 return cyl('Hex fastener',loc,r,.018,mat,axis,6,.002)

def lamp(loc,size,kind=0):
 o=box('Prismatic lamp lens',loc,size,'Lamps',.009)
 for d in o.data.uv_layers.active.data: d.uv=(kind/3+.03+(d.uv.x%1)*.24,.1+(d.uv.y%1)*.8)
 return o

# Chassis: twin frame rails, crossmembers, spring packs and three live axles.
for x in [-.68,.68]: box('Chassis rail',(x,-.1,.88),(.16,7.9,.26),'Metal',.018)
for y in [-3.55,-2.7,-1.45,-.15,1.3,2.7,3.6]: box('Crossmember',(0,y,.91),(1.52,.12,.18),'Metal',.015)
for y in [-2.7,1.3,2.7]:
 cyl('Live axle',(0,y,.63),.105,2.15,'Metal',(1,0,0),24)
 bpy.ops.mesh.primitive_uv_sphere_add(segments=24,ring_count=12,radius=1,location=(0,y,.64)); o=bpy.context.object; o.scale=(.26,.25,.21); finish(o,'Differential housing','Metal',0,True)
 for x in [-.69,.69]:
  for j in range(4):
   pts=[(x,y+u,.72-j*.026+.10*(u/.63)**2) for u in [-.65,-.43,-.2,0,.2,.43,.65]]
   # flattened leaf as narrow side profile
   poly=[(p[1],p[2]) for p in pts]+[(p[1],p[2]-.019) for p in reversed(pts)]
   prism('Laminated leaf spring',poly,x-.055,x+.055,'Metal',.004)
  for yy in [y-.17,y+.17]: tube('Axle U bolt',[(x-.09,yy,.85),(x-.09,yy,.52),(x+.09,yy,.52),(x+.09,yy,.85)],.013,'Metal')
  rod('Suspension damper',(x,y-.32,.63),(x,y-.6,1.12),.044,'Metal')
# Rear tandem equalizer ties both sprung axles to the frame.
for x in [-.77,.77]:
 prism('Tandem walking beam',[(1.23,.66),(1.40,.61),(2.58,.61),(2.77,.66),(2.77,.78),(2.05,.96),(1.23,.78)],x-.065,x+.065,'Metal',.016)
 box('Bogie central frame hanger',(x,2,.94),(.19,.25,.42),'Metal',.021)
 cyl('Equalizer pivot',(x,2,.80),.13,.23,'Metal',(1,0,0),24,.01)
 for y in [1.3,2.7]:rod('Axle torque stay',(x,2,1.02),(x,y,.66),.029,'Metal')
rod('Rear prop shaft',(0,-2.7,.65),(0,2.7,.65),.06)
for y in [-2.1,.4,2.0]: cyl('Drive universal joint',(0,y,.65),.09,.13,'Metal',(0,1,0))
# Tank, storage, air cylinders, exhaust and fuel filler.
box('Fuel tank',(1.0,-.42,.94),(.43,1.06,.5),'Metal',.065)
for y in [-.81,-.07]: box('Tank retaining strap',(1.225,y,.94),(.026,.045,.51),'Paint',.006)
cyl('Fuel cap',(1.23,-.65,1.1),.068,.024,'Metal',(1,0,0),24)
for yy in [-.84,-.51,-.18]: cyl('Air reservoir',(1.0,yy,.55),.13,.39,'Paint',(1,0,0),24,.025)
for yy in [-.84,-.51,-.18]:
 for xx in [.86,1.14]:
  pts=[(xx,yy+.139*cos(a),.55+.139*sin(a)) for a in [2*pi*k/24 for k in range(25)]]
  tube('Air tank retention band',pts,.013,'Metal',6)
  rod('Air tank cradle',(xx,yy,.68),(xx,yy,.80),.020,'Metal')
box('Air tank mounting rail',(1,-.51,.72),(.35,1.03,.045),'Metal',.008)
box('Equipment locker',(-1.0,-.42,1.05),(.42,1.0,.55),'Paint',.025)
for y in [-.7,-.14]: box('Locker clasp',(-1.222,y,1.10),(.024,.045,.09),'Metal',.005)
tube('Exhaust pipe',[(-.8,-1.9,.68),(-1,-1.3,.73),(-1.08,-.7,.77),(-1.08,.15,.68),(-1.18,.35,.63)],.065,'Metal',16)
# Wheels: radial casing profile, separate bead/rim/hub, chevron shoulder lugs.
WHEELS={}
for suffix,y,s in [('FL',-2.7,1)]:
 GROUP=[]; center=(s*1.015,y,.65)
 profile=[(-.218,.345),(-.238,.42),(-.228,.52),(-.194,.60),(-.143,.635),(.143,.635),(.194,.60),(.228,.52),(.238,.42),(.218,.345)]
 vs=[];fs=[];N=64
 for xx,r in profile:
  for k in range(N):
   a=2*pi*k/N; vs.append((center[0]+xx,y+r*cos(a),.65+r*sin(a)))
 for j in range(len(profile)-1):
  for k in range(N):fs.append((j*N+k,j*N+(k+1)%N,(j+1)*N+(k+1)%N,(j+1)*N+k))
 for k in range(N):fs.append((k,(len(profile)-1)*N+k,(len(profile)-1)*N+(k+1)%N,(k+1)%N))
 mesh('Radial tyre casing',vs,fs,'Rubber',0,True)
 for k in range(44):
  a=2*pi*k/44
  for side in [-1,1]:
   # Splayed tread blocks, chamfered, follow tread radius.
   xx=center[0]+side*.098; aa=a+side*.02
   o=box('Off road tread lug',(xx,y+.636*cos(aa),.65+.636*sin(aa)),(.19,.078,.027),'Rubber',.009)
   o.rotation_euler=(aa-pi/2,0,side*.28)
  for side in [-1,1]:
   aa=a+.035; o=box('Shoulder traction lug',(center[0]+side*.191,y+.592*cos(aa),.65+.592*sin(aa)),(.055,.08,.026),'Rubber',.006); o.rotation_euler=(aa-pi/2,side*.4,0)
 # Rim discs: tyre+rim use separate fixed rim material (never faction Paint).
 for side in [-1,1]:
  xx=center[0]+side*.23
  ring('Raised sidewall bead',(xx,y,.65),.415,.009,'Rubber',seg=48,sub=5)
  ring('Rim lip',(xx,y,.65),.342,.017,'Rim',seg=48,sub=6)
  cyl('Stamped wheel dish',(xx-side*.019,y,.65),.325,.038,'Rim',(1,0,0),48,.013)
  ring('Wheel concentric pressing',(xx+side*.02,y,.65),.265,.012,'Rim')
  cyl('Axle hub',(xx+side*.037,y,.65),.147,.112,'Rim',(1,0,0),32,.017)
  for k in range(10):
   a=2*pi*k/10
   bolt((xx+side*.044,y+.218*cos(a),.65+.218*sin(a)),(1,0,0),.023,'Rim')
   cyl('Rim vent',(xx+side*.023,y+.286*cos(a+.12),.65+.286*sin(a+.12)),.027,.008,'Rubber',(1,0,0),12,.003)
 WHEELS['Wheel_'+suffix]=(GROUP,center); GROUP=None; print('Built wheel',suffix,flush=True)
# Cab floor and angular lower structure, leaving real wheel arch openings.
box('Cab floor',(0,-2.94,1.48),(2.28,2.53,.14),'Paint',.025)
box('Armoured back wall',(0,-1.70,2.17),(2.30,.12,1.45),'Paint',.035)
# Side armour lower outline with wheel arch cutout, matching MAN HX silhouette.
side_poly=[(-4.19,1.21),(-4.19,1.97),(-1.65,1.97),(-1.65,.88),(-1.95,.88),(-2.06,1.28),(-2.34,1.51),(-3.05,1.51),(-3.42,1.13),(-3.65,.92),(-3.98,1.02)]
for s in [-1,1]:
 prism('Cab lower armour',side_poly,s*1.12,s*1.205,'Paint',.022)
 # Window zone is built around actual open glass aperture, not a solid cab box.
 prism('Door lower skin',[(-4.08,1.32),(-4.08,2.08),(-3.09,2.08),(-3.06,1.85),(-3.53,1.32)],s*1.207,s*1.228,'Paint',.012)
 prism('Cab rear side panel',[(-3.04,1.88),(-3.04,2.81),(-1.7,2.81),(-1.7,1.54),(-2.24,1.54)],s*1.12,s*1.207,'Paint',.022)
 # Closed armour infill around the shaped side glazing aperture.
 prism('Upper window armour',[(-4.19,2.69),(-3.26,2.69),(-3.10,2.59),(-3.01,2.59),(-3.01,2.83),(-4.19,2.83)],s*1.12,s*1.207,'Paint',.012)
 prism('Lower window armour',[(-4.19,1.95),(-3.01,1.95),(-3.01,2.33),(-3.11,2.33),(-3.79,2.12),(-4.19,2.12)],s*1.12,s*1.207,'Paint',.012)
 box('Rear glazing pillar',(s*1.165,-3.065,2.48),(.085,.105,.42),'Paint',.013)
 # Side window four-sided clipped profile.
 win=[(-4.07,2.11),(-4.07,2.70),(-3.26,2.70),(-3.11,2.60),(-3.11,2.32),(-3.79,2.11)]
 prism('Side armoured glazing',win,s*1.177,s*1.19,'Glass',.008)
 tube('Side glazing gasket',[(s*1.205,y,z) for y,z in win+[win[0]]],.029,'Rubber')
 tube('Window armour frame',[(s*1.208,y,z) for y,z in win+[win[0]]],.015,'Paint')
 box('Front A pillar',(s*1.13,-4.135,2.4),(.12,.12,.88),'Paint',.018)
 # Door seams and rear maintenance panel outline.
 tube('Door seam',[(s*1.233,y,z) for y,z in [(-4.12,2.78),(-3.02,2.78),(-3.02,1.85),(-3.53,1.26),(-4.12,1.26),(-4.12,2.78)]],.006,'Metal')
 tube('Maintenance hatch gasket',[(s*1.218,y,z) for y,z in [(-2.48,1.64),(-1.84,1.64),(-1.84,2.67),(-2.48,2.67),(-2.48,1.64)]],.009,'Rubber')
 box('Service panel',(s*1.222,-2.16,2.15),(.024,.59,.96),'Paint',.035)
 box('Door handle backing',(s*1.244,-3.44,1.91),(.025,.25,.075),'Metal',.015)
 rod('Door grip',(s*1.276,-3.54,1.93),(s*1.276,-3.35,1.93),.021,'Metal')
 for z in [1.55,2.05]:box('Door hinge',(s*1.237,-4.07,z),(.044,.11,.06),'Paint',.007)
 for y,z in [(-4.08,2.78),(-3.03,2.75),(-3.02,2.10),(-3.5,1.36),(-1.83,2.7),(-1.83,1.65),(-2.48,2.7)]:bolt((s*1.24,y,z))
 # Fender lip follows wheel opening, not a rectangular wheel cover.
 pts=[(s*1.25,y,z) for y,z in [(-3.59,.92),(-3.38,1.26),(-3.05,1.55),(-2.30,1.55),(-2.02,1.29),(-1.9,.89)]]
 for a,b in zip(pts,pts[1:]):
  a,b=Vector(a),Vector(b); o=box('Armoured wheel arch lip',(a+b)/2,(.12,(b-a).length,.085),'Metal',.012); o.rotation_euler.x=math.atan2((b-a).z,(b-a).y)
 box('Front mud flap',(s*1.05,-1.87,.94),(.44,.045,.5),'Rubber',.01,rot=(.12,0,0))
 tube('Cab access step frame',[(s*1.12,-4.0,1.14),(s*1.12,-4.0,.73),(s*1.12,-3.56,.73),(s*1.12,-3.56,1.02)],.021,'Metal')
 for y in [-3.91,-3.8,-3.69]:box('Anti-slip step bar',(s*1.12,y,.735),(.26,.065,.026),'Metal',.005)
 # Mirrors contained close to the 2.55m transport envelope.
 tube('Mirror support',[(s*1.14,-4.05,2.61),(s*1.23,-4.20,2.61),(s*1.24,-4.20,2.35)],.018,'Metal')
 box('Mirror housing',(s*1.24,-4.18,2.38),(.065,.14,.27),'Rubber',.028)
 box('Mirror reflector',(s*1.24,-4.101,2.38),(.047,.008,.225),'Glass',.012)
 box('Wide angle lower mirror',(s*1.247,-4.18,2.16),(.083,.15,.13),'Rubber',.024)
 box('Lower mirror reflector',(s*1.247,-4.101,2.16),(.065,.01,.105),'Glass',.012)
 tube('Lower mirror stay',[(s*1.12,-4.08,2.08),(s*1.245,-4.20,2.08),(s*1.245,-4.20,2.30)],.014,'Metal')
# Cab front fascia and split windscreens.
box('Front armoured face',(0,-4.18,1.79),(2.31,.13,.52),'Paint',.035)
box('Windshield sill',(0,-4.177,2.10),(2.28,.10,.10),'Paint',.015)
box('Windscreen header',(0,-4.105,2.79),(2.25,.16,.16),'Paint',.028)
for s in [-1,1]:
 box('Windshield gasket',(s*.567,-4.195,2.438),(1.069,.036,.622),'Rubber',.063)
 # Remove middle of gasket with boolean to retain true transparent opening.
 gasket=BODY[-1]; cutter=box('temporary cutter',(s*.567,-4.195,2.438),(.977,.3,.538),'Rubber',.047); BODY.remove(cutter)
 active(gasket); mod=gasket.modifiers.new('Open glazing aperture','BOOLEAN'); mod.operation='DIFFERENCE'; mod.object=cutter; bpy.ops.object.modifier_apply(modifier=mod.name); bpy.data.objects.remove(cutter,do_unlink=True)
 box('Front windscreen',(s*.567,-4.193,2.438),(.977,.012,.538),'Glass',.045)
 rod('Wiper arm',(s*.91,-4.235,2.19),(s*.50,-4.24,2.46),.012,'Metal')
 rod('Wiper rubber blade',(s*.32,-4.25,2.52),(s*.71,-4.25,2.28),.014,'Rubber')
 cyl('Wiper spindle',(s*.91,-4.237,2.19),.025,.035,'Metal',(0,1,0),16)
box('Centre screen pillar',(0,-4.208,2.43),(.065,.08,.68),'Paint',.01)
# Roof is layered armoured plate with raised sloped crown and hatch.
box('Armoured cab roof',(0,-2.92,2.87),(2.36,2.57,.13),'Paint',.035)
prism('Roof raised armour',[(-4.04,2.92),(-3.85,3.13),(-2.38,3.13),(-2.26,2.92)],-.98,.98,'Paint',.025)
cyl('Roof hatch coaming',(.52,-2.56,3.02),.39,.16,'Metal',(0,0,1),48,.018)
cyl('Roof hatch',(.52,-2.56,3.12),.405,.08,'Paint',(0,0,1),48,.017)
ring('Hatch rim',(.52,-2.56,3.159),.356,.012,'Paint','Z')
tube('Hatch lifting handle',[(.39,-2.58,3.17),(.39,-2.58,3.22),(.65,-2.58,3.22),(.65,-2.58,3.17)],.016,'Metal')
for y in [-2.78,-2.36]: box('Hatch hinge',(.86,y,3.14),(.12,.095,.065),'Paint',.01)
box('Roof ventilation housing',(-.59,-2.13,3.02),(.61,.59,.18),'Paint',.02)
for i in range(11):box('Roof ventilation louvre',(-.59,-2.35+i*.041,3.122),(.50,.018,.012),'Metal',.004)
for x in [-.85,.85]:
 for y in [-3.8,-3.1,-2.0]:bolt((x,y,2.953),(0,0,1),.016)
box('Lower frontal armour',(0,-4.15,1.36),(2.28,.15,.40),'Paint',.024)
for z in [1.24,1.34,1.44]:box('Lower cooling slot',(0,-4.233,z),(1.15,.015,.029),'Metal',.006)
# Front bumper, recesses, tow jaws, headlight cages and radiator slots.
box('Front bumper',(0,-4.32,1.01),(2.43,.26,.30),'Paint',.04)
box('Winch aperture',(0,-4.456,1.03),(.46,.018,.13),'Metal',.018)
for x in [-.31,.31]:cyl('Winch fairlead roller',(x,-4.477,1.03),.034,.15,'Metal',(0,0,1),16)
for s in [-1,1]:
 box('Headlight recess',(s*.91,-4.459,1.04),(.38,.025,.22),'Rubber',.03)
 for xx in [s*.82,s*1.00]:
  cyl('Headlamp bezel',(xx,-4.48,1.045),.077,.025,'Metal',(0,1,0),32,.006)
  o=lamp((xx,-4.496,1.045),(.123,.009,.123),0)
 for z in [.956,1.12]:rod('Headlight guard',(s*.71,-4.507,z),(s*1.10,-4.507,z),.01,'Metal')
 for x in [s*.73,s*.89,s*1.07]:rod('Headlight guard upright',(x,-4.507,.95),(x,-4.507,1.13),.009,'Metal')
 lamp((s*1.12,-4.252,1.56),(.14,.04,.13),1)
 tube('Front recovery loop',[(s*.55,-4.4,.98),(s*.55,-4.54,.91),(s*.68,-4.54,.91),(s*.68,-4.4,.98)],.026,'Metal')
for i in range(8):box('Radiator louvre',(0,-4.255,1.64+i*.032),(1.13,.018,.013),'Metal',.004)
for x in [-.59,.59]:
 box('Recovery clevis base',(x,-4.46,1.02),(.14,.10,.14),'Paint',.012)
 cyl('Recovery clevis pin',(x,-4.52,1.01),.025,.20,'Metal',(1,0,0),16)
# No inscriptions or insignia.
# Cab interior visible through real window apertures.
box('Dashboard',(0,-3.84,2.08),(2.09,.40,.20),'Interior',.055)
box('Instrument binnacle',(.58,-3.73,2.20),(.63,.23,.16),'Interior',.025)
for x in [.40,.59,.77]:
 cyl('Instrument dial',(x,-3.60,2.20),.055,.018,'Metal',(0,1,0),24,.002)
 rod('Gauge needle',(x,-3.588,2.20),(x+.02,-3.588,2.225),.004,'Lamps',6)
for x in [-.67,.67]:
 box('Cab seat pedestal',(x,-2.89,1.66),(.36,.45,.26),'Metal',.015)
 box('Cab seat cushion',(x,-2.9,1.87),(.57,.57,.16),'Interior',.075)
 box('Cab seat back',(x,-2.62,2.17),(.56,.16,.55),'Interior',.065,rot=(.10,0,0))
 box('Cab headrest',(x,-2.60,2.51),(.32,.13,.17),'Interior',.035)
 for xx in [x-.2,x+.2]:rod('Seat slide rail',(xx,-3.19,1.53),(xx,-2.56,1.53),.017,'Metal')
box('Centre transmission tunnel',(0,-3.15,1.65),(.45,.8,.25),'Interior',.025)
rod('Gear lever',(.13,-3.15,1.8),(.14,-3.10,2.07),.022,'Metal')
cyl('Gear knob',(.14,-3.1,2.07),.045,.067,'Interior',(0,0,1),16,.02)
GROUP=[]; swcenter=(.67,-3.49,2.16)
# Steering ring lies oblique; transform its assembly about hub as one unit.
ring('Steering rim',swcenter,.215,.021,'Rubber','Z',48,10)
cyl('Steering hub',swcenter,.06,.065,'Interior',(0,0,1),24,.01)
for a in [0,2*pi/3,4*pi/3]:rod('Steering spoke',swcenter,(swcenter[0]+.20*cos(a),swcenter[1]+.20*sin(a),swcenter[2]),.014,'Metal')
STEER=GROUP; GROUP=None
rod('Steering column',(.67,-3.65,1.67),swcenter,.033,'Metal')
# Cargo bed structure.
box('Cargo deck',(0,1.23,1.43),(2.47,5.51,.16),'Paint',.025)
for y in [-1.3,-.35,.7,1.8,2.9,3.8]:box('Bed cross bearer',(0,y,1.30),(2.36,.12,.15),'Metal',.012)
for s in [-1,1]:
 for ya,yb in [(-1.46,1.20),(1.27,3.98)]:
  yc=(ya+yb)/2
  box('Drop side panel',(s*1.225,yc,1.72),(.065,yb-ya,.52),'Paint',.016)
  for z in [1.51,1.70,1.92]:box('Pressed side reinforcement',(s*1.268,yc,z),(.043,yb-ya-.1,.05),'Paint',.014)
  for y in [ya+.25,yc,yb-.25]:
   box('Drop side hinge',(s*1.26,y,1.47),(.055,.21,.055),'Metal',.007)
   cyl('Hinge pin',(s*1.285,y,1.47),.024,.23,'Paint',(0,1,0),16,.004)
  for y in [ya+.05,yb-.05]:
   box('Side latch',(s*1.275,y,1.83),(.027,.063,.14),'Metal',.005)
   bolt((s*1.29,y,1.89),r=.013)
 for y in [-1.49,1.23,4.0]:
  box('Stake pocket upright',(s*1.238,y,1.77),(.10,.105,.70),'Paint',.015)
  for z in [1.5,1.99]:bolt((s*1.296,y,z),r=.021)
 for y in [-1.25,-.5,.3,1.05,1.8,2.55,3.3,3.85]:
  tube('Tie down loop',[(s*1.28,y-.055,1.60),(s*1.307,y-.055,1.55),(s*1.307,y+.055,1.55),(s*1.28,y+.055,1.60)],.009,'Metal')
 for yy in [1.3,2.7]:
  box('Rear wheel splash shield',(s*1.00,yy,1.355),(.47,1.28,.045),'Metal',.012)
 for y in [.57,3.43]:
  box('Rear mud flap',(s*1.01,y,.88),(.48,.045,.57),'Rubber',.012)
  for x in [s*.83,s*1.17]:bolt((x,y-.03,1.12),(0,1,0),.018,'Metal')
  rod('Mudguard stay',(s*1.19,y,1.00),(s*1.16,y-.22 if y<1 else y+.22,1.4),.022,'Paint')
# Rear drop gate and tail fixtures.
box('Rear tailgate',(0,4.015,1.73),(2.42,.08,.53),'Paint',.02)
for z in [1.51,1.72,1.95]:box('Tailgate pressed rib',(0,4.065,z),(2.25,.035,.047),'Paint',.011)
for x in [-.82,.82]:
 cyl('Tailgate hinge',(x,4.07,1.45),.031,.25,'Metal',(1,0,0),20)
 box('Tail light housing',(x,3.98,1.13),(.4,.10,.16),'Rubber',.018)
 lamp((x-.09,4.042,1.13),(.17,.018,.12),2); lamp((x+.095,4.042,1.13),(.16,.018,.12),1)
box('Rear cross bumper',(0,4.02,.90),(2.18,.13,.13),'Metal',.015)
cyl('Tow coupling',(0,4.10,.95),.095,.18,'Metal',(0,1,0),20,.01)
for x in [-.5,.5]:tube('Rear boarding rung',[(x,4.04,1.44),(x,4.12,1.19),(x,4.18,.94)],.025,'Metal')
rod('Rear step',(-.5,4.18,.94),(.5,4.18,.94),.025,'Metal')
# Troop benches facing inwards; cushions, backs and hoop grab rails.
TROOPS=[]
for s in [-1,1]:
 for j,y in enumerate([-.93,.20,1.33,2.46,3.53]):
  box('Troop seat cushion',(s*.84,y,1.85),(.57,.89,.125),'Interior',.05)
  box('Troop back cushion',(s*1.075,y,2.08),(.115,.89,.33),'Interior',.045,rot=(0,-s*.10,0))
  for yy in [y-.32,y+.32]:
   rod('Bench leg',(s*.72,yy,1.50),(s*.72,yy,1.79),.025,'Metal')
   rod('Bench support',(s*.58,yy,1.78),(s*1.14,yy,1.78),.023,'Metal')
  TROOPS.append((s*.80,y,1.93))
 for y in [-1.37,.76,2.98,3.91]:
  tube('Troop grab hoop',[(s*.58,y,1.77),(s*.58,y,2.19),(s*.60,y,2.26),(s*.87,y,2.26),(s*.93,y,2.19),(s*.93,y,1.81)],.018,'Paint',10)
# Canvas canopy: bowed roof, fabric sag and stitched seams, open side curtains.
# Support bows run underneath the fabric at correct contact height.
for y in [-1.42,-.36,.72,1.80,2.88,3.95]:
 pts=[(-1.18,y,1.98),(-1.18,y,2.89),(-1.10,y,3.10),(-.89,y,3.19),(0,y,3.25),(.89,y,3.19),(1.10,y,3.10),(1.18,y,2.89),(1.18,y,1.98)]
 tube('Canopy steel bow',pts,.026,'Paint',12)
# Swept canvas surface with broad roof and downturned side edges.
NX=36; NY=90; vs=[];fs=[]
for j in range(NY+1):
 y=-1.51+5.56*j/NY
 for i in range(NX+1):
  t=-1+2*i/NX; x=1.275*t
  z=3.29-.065*t*t-.25*abs(t)**12
  sag=.028*sin(pi*(y+1.42)/1.08)**2*(.30+.7*(1-t*t))
  wrinkle=.011*sin(y*16+t*8)+.006*sin(y*29-t*13)
  z-=sag; z+=wrinkle*(.15+.85*abs(t)**3)
  vs.append((x,y,z))
for j in range(NY):
 for i in range(NX):a=j*(NX+1)+i;fs.append((a,a+1,a+NX+2,a+NX+1))
o=mesh('Tailored canvas canopy',vs,fs,'Canvas',0,True); active(o); mod=o.modifiers.new('Canvas thickness','SOLIDIFY');mod.thickness=.009; bpy.ops.object.modifier_apply(modifier=mod.name)
# Rolled side curtains, subtly sagged between retaining straps.
for s in [-1,1]:
 pts=[]
 for j in range(112):
  y=-1.47+5.48*j/111; pts.append((s*1.247,y,2.89-.018*sin(pi*(y+1.42)/1.08)**2))
 tube('Rolled side canvas curtain',pts,.064,'Canvas',12)
 for y in [-1.39,-.36,.72,1.80,2.88,3.91]:
  # straps wrapped around roll with buckle, plus seam across roof
  pts=[(s*(1.247+.067*cos(a)),y,2.89+.068*sin(a)) for a in [2*pi*k/16 for k in range(17)]]
  tube('Curtain retaining strap',pts,.018,'Canvas',6)
  box('Curtain strap buckle',(s*1.318,y,2.89),(.019,.049,.065),'Metal',.004)
  tube('Hanging cinch strap',[(s*1.27,y,2.88),(s*1.275,y+.012,2.75)],.009,'Canvas',6)
 # Narrow vertical end flaps, leaving big open troop compartment.
 for y in [-1.48,3.97]:
  verts=[];faces=[]
  for j in range(18):
   z=1.98+j/17*1.05
   for k in range(5):verts.append((s*(1.259+.014*sin(j*.8+k)),y+(k/4-.5)*.19,z))
  for j in range(17):
   for k in range(4):a=j*5+k;faces.append((a,a+1,a+6,a+5))
  o=mesh('Canvas corner drop',verts,faces,'Canvas',0,True);active(o);mod=o.modifiers.new('Double canvas','SOLIDIFY');mod.thickness=.012;bpy.ops.object.modifier_apply(modifier=mod.name)
# Front canvas bulkhead drape behind cab.
vs=[];fs=[]
for j in range(22):
 for i in range(25):
  x=-1.22+2.44*i/24;t=x/1.275;top=3.28-.065*t*t-.25*abs(t)**12;z=1.98+(top-1.98)*j/21;vs.append((x,-1.485+.025*sin(i*1.1)*sin(pi*j/21),z))
for j in range(21):
 for i in range(24):a=j*25+i;fs.append((a,a+1,a+26,a+25))
o=mesh('Front canvas bulkhead',vs,fs,'Canvas',0,True);active(o);mod=o.modifiers.new('Canvas thickness','SOLIDIFY');mod.thickness=.008;bpy.ops.object.modifier_apply(modifier=mod.name)
# Sewn roof seams track the cross bows.
for y in [-1.43,-.36,.72,1.80,2.88,3.96]:
 pts=[]
 for i in range(37):
  t=-1+2*i/36;x=1.275*t;z=3.296-.065*t*t-.25*abs(t)**12-.028*sin(pi*(y+1.42)/1.08)**2*(.30+.7*(1-t*t))+.011*sin(y*16+t*8)*(.15+.85*abs(t)**3)
  pts.append((x,y,z))
 tube('Canvas reinforced seam',pts,.006,'Canvas',6)
print('Geometry complete; joining rigid assemblies',flush=True)
# Assembly joins; all transforms applied, wheel origins at their actual centres.
def join(parts,name,origin):
 bpy.ops.object.select_all(action='DESELECT')
 for o in parts:o.select_set(True)
 bpy.context.view_layer.objects.active=parts[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 sc.cursor.location=origin;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 return o
body=join(BODY,'Body',(0,0,0)); assets=[body]
for name,(parts,center) in WHEELS.items():assets.append(join(parts,name,center))
template=assets[-1]
for suffix,y,sign in [('FR',-2.7,-1),('RL',1.3,1),('RR',1.3,-1),('RL2',2.7,1),('RR2',2.7,-1)]:
 wheel=template.copy(); wheel.data=template.data.copy(); wheel.name='Wheel_'+suffix; sc.collection.objects.link(wheel); wheel.location=(sign*1.015,y,.65)
 if sign<0:
  for v in wheel.data.vertices:v.co.x=-v.co.x
  bm=bmesh.new();bm.from_mesh(wheel.data);bmesh.ops.reverse_faces(bm,faces=list(bm.faces));bm.to_mesh(wheel.data);bm.free()
 assets.append(wheel)
sw=join(STEER,'SteeringWheel',swcenter);sw.rotation_euler.x=math.radians(33);active(sw);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);assets.append(sw)
# Keep the delivered scene, including all six wheel instances, below the hard triangle cap.
def triangles(ob):
 ob.data.calc_loop_triangles(); return len(ob.data.loop_triangles)
fixed=sum(triangles(o) for o in assets if not o.name.startswith('Wheel_'))
rolling=sum(triangles(o) for o in assets if o.name.startswith('Wheel_'))
if fixed+rolling>106000:
 ratio=max(.25,(104000-fixed)/rolling)
 for ob in assets:
  if ob.name.startswith('Wheel_'):
   active(ob); mod=ob.modifiers.new('Budgeted small wheel details','DECIMATE');mod.ratio=ratio;bpy.ops.object.modifier_apply(modifier=mod.name)
# Remove bevel-clamp zero-area slivers, retaining UV and material assignments.
for ob in assets:
 bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bad=[f for f in bm.faces if f.calc_area()<5e-13]
 bmesh.ops.delete(bm,geom=bad,context='FACES');bmesh.ops.dissolve_degenerate(bm,dist=1e-7,edges=list(bm.edges));bm.to_mesh(ob.data);bm.free();ob.data.update()
print('Prepared triangles',sum(triangles(o) for o in assets),flush=True)

# Exact transport dimensions: adjust uniformly per axis about axle midpoint.
# Wheel grounding from casing/tread bounds, preserve rolling symmetry about X.
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(c) for o in assets for c in o.bound_box]
lo=Vector([min(p[k] for p in pts) for k in range(3)]); hi=Vector([max(p[k] for p in pts) for k in range(3)])
scale=Vector((2.55/(hi.x-lo.x),8.5/(hi.y-lo.y),3.3/(hi.z-lo.z)))
# Keep radial wheel dimensions circular: longitudinal envelope is mostly bumpers.
for o in assets:
 for v in o.data.vertices:v.co=Vector((v.co.x*scale.x,v.co.y*scale.y,v.co.z*scale.z))
 o.location=Vector((o.location.x*scale.x,o.location.y*scale.y,(o.location.z-lo.z)*scale.z))
# Avoid tiny non-circular wheel distortion: normalize tyre local Y/Z to vertical scale,
# then use body extremities for exact length. The original axle symmetry is retained.
for o in assets:
 if o.name.startswith('Wheel_'):
  for v in o.data.vertices:v.co.y*=scale.z/scale.y
empties={
'Seat_Driver':(.67,-2.9,1.99),'Door_L':(1.22,-4.07,2.05),'Door_R':(-1.22,-4.07,2.05),
'Exhaust':(-1.18,.35,.63),'Light_Head_L':(.91,-4.49,1.045),'Light_Head_R':(-.91,-4.49,1.045),
'Light_Tail_L':(.82,4.04,1.13),'Light_Tail_R':(-.82,4.04,1.13)}
empties.update({'Troop_'+str(i+1):p for i,p in enumerate(TROOPS)})
for name,p in empties.items():
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=(p[0]*scale.x,p[1]*scale.y,(p[2]-lo.z)*scale.z);assets.append(o)
# Identical left/right wheel meshes are shared across axles, preserving distinct pivot nodes.
for names in [('Wheel_FL','Wheel_RL','Wheel_RL2'),('Wheel_FR','Wheel_RR','Wheel_RR2')]:
 master=bpy.data.objects[names[0]]
 for name in names[1:]:bpy.data.objects[name].data=master.data
# Export before adding studio helpers.
bpy.ops.object.select_all(action='DESELECT')
for o in assets:o.select_set(True)
bpy.context.view_layer.objects.active=body
bpy.ops.export_scene.gltf(filepath=str(ROOT/'tactical-truck.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_tangents=True,export_image_format='AUTO',export_jpeg_quality=86,export_materials='EXPORT',export_cameras=False,export_lights=False)
# Guarantee the interoperable Paint factor and BLEND contract in serialized glTF.
p=ROOT/'tactical-truck.glb';raw=p.read_bytes();n=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+n]);tail=raw[20+n:]
# Blender can emit zero tangents at collapsed UV corners on tiny bevels.
# Supply an orthonormal fallback for those corners; all stored tangent vectors stay unit length.
tail=bytearray(tail)
for gm in doc.get('meshes',[]):
 for primitive in gm['primitives']:
  attributes=primitive['attributes']
  if 'TANGENT' not in attributes:continue
  ta=doc['accessors'][attributes['TANGENT']];na=doc['accessors'][attributes['NORMAL']]
  tv=doc['bufferViews'][ta['bufferView']];nv=doc['bufferViews'][na['bufferView']]
  for k in range(ta['count']):
   offset=8+tv.get('byteOffset',0)+ta.get('byteOffset',0)+k*tv.get('byteStride',16)
   tx,ty,tz,handedness=struct.unpack_from('<4f',tail,offset)
   tangent=Vector((tx,ty,tz))
   if tangent.length<1e-8:
    no=8+nv.get('byteOffset',0)+na.get('byteOffset',0)+k*nv.get('byteStride',12)
    normal=Vector(struct.unpack_from('<3f',tail,no));axis=Vector((1,0,0)) if abs(normal.x)<.9 else Vector((0,1,0));tangent=normal.cross(axis).normalized()
   else:tangent.normalize()
   struct.pack_into('<4f',tail,offset,*tangent,handedness)
for mat in doc['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=[.42,.37,.25,1];mat.pop('extensions',None)
 if mat['name']=='Rim':mat['pbrMetallicRoughness']['baseColorFactor']=[.34,.29,.19,1];mat.pop('extensions',None)
 if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4);p.write_bytes(struct.pack('<III',0x46546C67,2,20+len(js)+len(tail))+struct.pack('<II',len(js),0x4E4F534A)+js+tail)
tri=sum(len(o.data.polygons) for o in []);tri=0
for o in assets:
 if o.type=='MESH':o.data.calc_loop_triangles();tri+=len(o.data.loop_triangles)
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]
conv=lambda p:[round(p[0],6),round(p[2],6),round(-p[1],6)]
bounds={'min':[min(conv(p)[k] for p in pts) for k in range(3)],'max':[max(conv(p)[k] for p in pts) for k in range(3)]}
manifest={'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','left':'+X','bounding_box':bounds,'triangles':tri,'materials':list(M),'nodes':[{'name':o.name,'type':'mesh' if o.type=='MESH' else 'empty','world_position':conv(o.location)} for o in assets],'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS]}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text(f'Modern 6x6 armoured troop/cargo truck; functional wheel pivots, cab interior, ten troop seats, roof hatch and open canvas canopy.\n{tri:,} triangles; 8 mesh nodes and 18 attachment empties; metres, Garage v2 axes and ground origin.\n8 materials: Paint, Rim (fixed sand), Metal, Rubber, Canvas, Interior, Glass (BLEND), Lamps (emissive); neutral Paint tile with linear sand factor.\nTiled Cycles GPU bakes: 1024 base/normal, 512 ORM; lamp atlas 96x32; no texture exceeds 1024.\nPoly Haven CC0: metal_plate, rusty_metal_03, rubberized_track, denim_fabric, fabric_leather_01; https://polyhaven.com/license and https://polyhaven.com/a/<id>.\nReference differences: generic unbranded fittings, simplified instrument controls and canvas wrinkles; no logos, lettering or insignia.\n')
print('EXPORTED',tri,p.stat().st_size,'bounds',bounds,flush=True)
# Studio floor and soft daylight, excluded from GLB.
floor=simple('Studio floor',(.22,.25,.27),.86)
bpy.ops.mesh.primitive_plane_add(size=2000,location=(0,0,-.016));ground=bpy.context.object;ground.name='Studio';ground.data.materials.append(floor)
world=sc.world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs[0].default_value=(.62,.72,.86,1);world.node_tree.nodes.get('Background').inputs[1].default_value=.40
for name,loc,power,size,col in [('Softbox',(5,-7,10),2000,7,(1,.91,.78)),('Sky fill',(-5,-2,6),1200,6,(.72,.83,1)),('Rim',(1,6,8),2300,5,(1,.94,.82))]:
 data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;data.color=col;o=bpy.data.objects.new(name,data);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()
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO';cam.data.lens=52
ctr=Vector((0,-.1,1.55));radius=max((p-ctr).length for p in pts)
def camera(direction,ortho):
 cam.location=ctr+Vector(direction).normalized()*radius*3.3;cam.rotation_euler=(ctr-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=ortho
camera((1,-1.25,.48),11.65)
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=32
sc.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
camera((1,0,.02),10.15);sc.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
camera((1,-1.25,.48),11.65)
clay=simple('Clay proof',(.37,.39,.42),.79);sc.view_layers[0].material_override=clay;sc.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
# Paint contact sheet: same camera and exposure in every panel.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
sheet=np.ones((720,1920,4),dtype=np.float32)
for i,(name,col) in enumerate(PAINTS):
 M['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=(*col,1)
 sc.render.filepath=str(ROOT/f'paint_{i}.png');bpy.ops.render.render(write_still=True)
 img=bpy.data.images.load(str(ROOT/f'paint_{i}.png'),check_existing=False);arr=np.array(img.pixels[:]).reshape(360,640,4);row=1-i//3;column=i%3;sheet[row*360:(row+1)*360,column*640:(column+1)*640]=arr;bpy.data.images.remove(img);(ROOT/f'paint_{i}.png').unlink()
im=bpy.data.images.new('Paint variants',width=1920,height=720,alpha=True);im.pixels.foreach_set(sheet.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
M['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=(*PAINTS[0][1],1)
print('COMPLETE',tri,'triangles',p.stat().st_size,'bytes',flush=True)
