"""Forge H3-inspired SUV; clean-scene deterministic build. Blender 5.2, Cycles GPU.
CC0 photographic inputs are downloaded from Poly Haven; all delivered material maps
are Cycles bakes on repeatable material tiles. Front is Blender -Y / glTF +Z.
"""
import bpy, math, os, json, subprocess, struct, random, sys
import numpy as np
from mathutils import Vector
from math import sin,cos,pi
ROOT=os.path.dirname(os.path.abspath(__file__))
TEX=os.path.join(ROOT,'textures');os.makedirs(TEX,exist_ok=True)
random.seed(31)
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for d in list(bpy.data.materials):bpy.data.materials.remove(d)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';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.render.film_transparent=False
sc.view_settings.view_transform='AgX'
# Photographic source maps, 1k CC0, repeatable downloads.
SOURCES=['metal_plate','rubberized_track','fabric_leather_02']
def source(id,map):
 p=os.path.join(TEX,f'{id}_{map}_1k.jpg')
 if not os.path.exists(p):subprocess.run(['curl','-fL','--retry','3','-A','Mozilla/5.0',f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg','-o',p],check=True)
 return p
for id in SOURCES:
 for mp in ['diff','nor_gl','arm']:source(id,mp)
M={};paint_mult=None

def node(nt,kind,**attrs):
 n=nt.nodes.new(kind)
 for k,v in attrs.items():setattr(n,k,v)
 return n

def bake_material(name,id,base,rough,metal=0,size=512,alpha=1):
 global paint_mult
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();L=nt.links
 out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');L.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=node(nt,'ShaderNodeTexCoord');im=node(nt,'ShaderNodeTexImage');im.image=bpy.data.images.load(source(id,'diff'),check_existing=True);L.new(uv.outputs['UV'],im.inputs['Vector'])
 bw=node(nt,'ShaderNodeRGBToBW');L.new(im.outputs['Color'],bw.inputs[0])
 ramp=node(nt,'ShaderNodeValToRGB');L.new(bw.outputs[0],ramp.inputs[0]);ramp.color_ramp.elements[0].position=.05;ramp.color_ramp.elements[1].position=.9
 if name=='Paint':c0=(.82,.82,.82,1);c1=(.99,.99,.99,1)
 else:c0=tuple(v*.65 for v in base)+(1,);c1=tuple(min(1,v*1.25) for v in base)+(1,)
 ramp.color_ramp.elements[0].color=c0;ramp.color_ramp.elements[1].color=c1
 noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=38;noise.inputs['Detail'].default_value=4;L.new(uv.outputs['UV'],noise.inputs['Vector'])
 mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.15;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'])
 if name=='Paint':
  mapping=node(nt,'ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(12,1.2,1);L.new(uv.outputs['UV'],mapping.inputs[0])
  streak=node(nt,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=3.0;streak.inputs['Detail'].default_value=3;streak.inputs['Roughness'].default_value=.7;L.new(mapping.outputs[0],streak.inputs['Vector'])
  stain=node(nt,'ShaderNodeValToRGB');stain.color_ramp.elements[0].position=.28;stain.color_ramp.elements[0].color=(.52,.52,.52,1);stain.color_ramp.elements[1].position=.63;stain.color_ramp.elements[1].color=(1,1,1,1);L.new(streak.outputs['Fac'],stain.inputs[0])
  dirt=node(nt,'ShaderNodeMixRGB');dirt.blend_type='MULTIPLY';dirt.inputs[0].default_value=.08;L.new(mix.outputs[0],dirt.inputs[1]);L.new(stain.outputs[0],dirt.inputs[2]);mix=dirt
  L.new(mix.outputs[0],bs.inputs['Base Color'])
  uvsplit=node(nt,'ShaderNodeSeparateXYZ');L.new(uv.outputs['UV'],uvsplit.inputs[0]);lower=node(nt,'ShaderNodeMapRange');lower.inputs['From Min'].default_value=.16;lower.inputs['From Max'].default_value=.40;lower.inputs['To Min'].default_value=.58;lower.inputs['To Max'].default_value=1.0;L.new(uvsplit.outputs['Y'],lower.inputs['Value'])
  roadgrime=node(nt,'ShaderNodeMixRGB');roadgrime.blend_type='MULTIPLY';roadgrime.inputs[0].default_value=.65;L.new(mix.outputs[0],roadgrime.inputs[1]);L.new(lower.outputs['Result'],roadgrime.inputs[2]);mix=roadgrime
  L.new(mix.outputs[0],bs.inputs['Base Color'])
 normal=node(nt,'ShaderNodeTexImage');normal.image=bpy.data.images.load(source(id,'nor_gl'),check_existing=True);normal.image.colorspace_settings.name='Non-Color';L.new(uv.outputs['UV'],normal.inputs['Vector'])
 nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.02 if name=='Reflector' else (.08 if name in ['Paint','Metal','DarkMetal'] else .45);L.new(normal.outputs['Color'],nm.inputs['Color'])
 bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=.02 if name=='Reflector' else .12;bump.inputs['Distance'].default_value=.0015;L.new(noise.outputs['Fac'],bump.inputs['Height']);L.new(nm.outputs[0],bump.inputs['Normal']);L.new(bump.outputs[0],bs.inputs['Normal'])
 arm=node(nt,'ShaderNodeTexImage');arm.image=bpy.data.images.load(source(id,'arm'),check_existing=True);arm.image.colorspace_settings.name='Non-Color';L.new(uv.outputs['UV'],arm.inputs['Vector'])
 sep=node(nt,'ShaderNodeSeparateColor');L.new(arm.outputs['Color'],sep.inputs[0]);r=node(nt,'ShaderNodeMath');r.operation='MULTIPLY_ADD';r.inputs[1].default_value=.14;r.inputs[2].default_value=max(0,rough-.07);L.new(sep.outputs[1],r.inputs[0]);L.new(r.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 # The same unit tile drives every part, with metre-scaled planar UVs.
 bpy.ops.mesh.primitive_plane_add(size=2);plane=bpy.context.object;plane.name='BakeTile';plane.data.materials.append(m)
 emit=node(nt,'ShaderNodeEmission');target=node(nt,'ShaderNodeTexImage')
 baked={}
 for typ,res in [('base',size),('normal',size),('orm',min(size,512))]:
  img=bpy.data.images.new(f'{name}_{typ}',res,res,alpha=False);img.colorspace_settings.name='sRGB' if typ=='base' else 'Non-Color';target.image=img;nt.nodes.active=target
  if typ=='normal':L.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL',margin=4)
  else:
   L.new(emit.outputs[0],out.inputs[0])
   if typ=='base':L.new(mix.outputs[0],emit.inputs[0])
   else:
    comb=node(nt,'ShaderNodeCombineColor');L.new(sep.outputs[0],comb.inputs[0]);comb.inputs[2].default_value=metal;L.new(r.outputs[0],comb.inputs[1]);L.new(comb.outputs[0],emit.inputs[0])
   bpy.ops.object.bake(type='EMIT',margin=4)
  img.filepath_raw=os.path.join(TEX,f'{name}_{typ}.jpg' if typ!='orm' else f'{name}_{typ}.png');img.file_format='JPEG' if typ!='orm' else 'PNG';img.save();baked[typ]=img
 bpy.data.objects.remove(plane,do_unlink=True)
 nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');L.new(bs.outputs[0],out.inputs[0])
 col=node(nt,'ShaderNodeTexImage');col.image=baked['base'];L.new(col.outputs[0],bs.inputs['Base Color'])
 norm=node(nt,'ShaderNodeTexImage');norm.image=baked['normal'];nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.08 if name=='Reflector' else (.18 if name in ['Metal','DarkMetal'] else 1.0);L.new(norm.outputs[0],nm.inputs['Color']);L.new(nm.outputs[0],bs.inputs['Normal'])
 orm=node(nt,'ShaderNodeTexImage');orm.image=baked['orm'];sep=node(nt,'ShaderNodeSeparateColor');L.new(orm.outputs[0],sep.inputs[0]);L.new(sep.outputs[1],bs.inputs['Roughness']);L.new(sep.outputs[2],bs.inputs['Metallic'])
 # glTF AO socket gives the packed R channel an explicit portable binding.
 grp=bpy.data.node_groups.get('glTF Material Output')
 if grp is None:
  grp=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');grp.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=node(nt,'ShaderNodeGroup');gn.node_tree=grp;L.new(sep.outputs[0],gn.inputs['Occlusion'])
 if name=='Paint':
  paint_mult=node(nt,'ShaderNodeMixRGB');paint_mult.blend_type='MULTIPLY';paint_mult.inputs[0].default_value=1;paint_mult.inputs[2].default_value=(.62,.48,.08,1);L.new(col.outputs[0],paint_mult.inputs[1]);L.new(paint_mult.outputs[0],bs.inputs['Base Color']);bs.inputs['Coat Weight'].default_value=.12;bs.inputs['Coat Roughness'].default_value=.32
 if name=='Glass':
  bs.inputs['Alpha'].default_value=.42;nm.inputs['Strength'].default_value=.02;bs.inputs['Roughness'].default_value=.12;bs.inputs['Metallic'].default_value=.22;m.surface_render_method='BLENDED';m.use_transparent_shadow=True
 M[name]=m;print('BAKED',name,flush=True)
 return m

bake_material('Paint','metal_plate',(.9,.9,.9),.45,0,1024)
bake_material('Rubber','rubberized_track',(.020,.022,.024),.84,0,1024)
bake_material('Metal','metal_plate',(.42,.44,.46),.36,.85,1024)
bake_material('DarkMetal','metal_plate',(.034,.039,.037),.68,.30,512)
bake_material('Interior','fabric_leather_02',(.045,.052,.047),.81,0,1024)
bake_material('Glass','metal_plate',(.09,.12,.13),.15,0,64)
bake_material('Reflector','metal_plate',(.76,.79,.82),.08,.95,256)
bake_material('Lamps','metal_plate',(.8,.8,.8),.25,.1,256)
# Lamps share one baked atlas: optical white, amber, red, instrument pale green.
lm=M['Lamps'];nt=lm.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');img=next(n.image for n in nt.nodes if n.type=='TEX_IMAGE' and '_base' in n.image.name)
a=np.array(img.pixels[:]).reshape(256,256,4)
for j,c in enumerate([(1,.88,.66),(1,.24,.018),(.62,.014,.008),(.2,.48,.24)]):a[:,j*64:(j+1)*64,:3]*=c
img.pixels[:]=a.ravel();img.save();tn=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image==img);nt.links.new(tn.outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.12

PARTS={};group='Body'
def finish(o,name,mat,bev=0,smooth=False):
 o.name=name;o.data.materials.append(M[mat]);PARTS.setdefault(group,[]).append(o)
 if bev:
  mod=o.modifiers.new('Machined edge radii','BEVEL');mod.width=bev;mod.segments=1 if 'tread blocks' in name else (4 if name=='Broad fender flare' else 2)
  bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name)
 if smooth or bev:
  for p in o.data.polygons:p.use_smooth=True
  if bev:
   mod=o.modifiers.new('Area weighted corner normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=40;bpy.context.view_layer.objects.active=o;bpy.ops.object.modifier_apply(modifier=mod.name)
 else:
  for p in o.data.polygons:p.use_smooth=False
 if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
 # Metre-scaled box projection retains tile density on large panels.
 uv=o.data.uv_layers.active.data
 for p in o.data.polygons:
  n=p.normal;axis=max(range(3),key=lambda i:abs(n[i]));axes=[i for i in range(3) if i!=axis]
  for li in p.loop_indices:
   v=o.data.vertices[o.data.loops[li].vertex_index].co;uv[li].uv=(v[axes[0]]*1.7,v[axes[1]]*1.7)
 return o

def mesh(name,verts,faces,mat,bev=0,smooth=False):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(name,me);sc.collection.objects.link(o);return finish(o,name,mat,bev,smooth)
def box(name,loc,dims,mat,bev=.01,rot=None):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.dimensions=dims;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if rot:o.rotation_euler=rot
 return finish(o,name,mat,bev)
def cyl(name,loc,r,depth,mat,axis='Z',verts=48,bev=.004):
 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
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);return finish(o,name,mat,bev,True)
def tube(name,pts,r,mat):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.bevel_depth=r;cu.bevel_resolution=1;sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
 for p,co in zip(sp.points,pts):p.co=(*co,1)
 ob=bpy.data.objects.new(name,cu);sc.collection.objects.link(ob);bpy.context.view_layer.objects.active=ob;ob.select_set(True);bpy.ops.object.convert(target='MESH');ob=bpy.context.object;ob.select_set(False);return finish(ob,name,mat,0,True)
def beam(name,a,b,width,mat,depth=None,bev=.008):
 v=Vector(b)-Vector(a);o=box(name,(Vector(a)+Vector(b))/2,(width,depth or width,v.length),mat,bev);o.rotation_euler=v.to_track_quat('Z','Y').to_euler();return o

def extrude_yz(name,poly,x1,x2,mat,bev=.015):
 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,2*n))]
 for i in range(n):j=(i+1)%n;fs.append((i,j,n+j,n+i))
 return mesh(name,vs,fs,mat,bev)
def rounded_poly(poly,r=.055,steps=5):
 out=[]
 for i,p in enumerate(poly):
  p=Vector(p);a=Vector(poly[i-1]);b=Vector(poly[(i+1)%len(poly)]);d=min(r,(a-p).length*.3,(b-p).length*.3);a=p+(a-p).normalized()*d;b=p+(b-p).normalized()*d
  for j in range(steps+1):t=j/steps;q=(1-t)**2*a+2*(1-t)*t*p+t*t*b;out.append(tuple(q))
 return out

def side_ring(name,outer,inner,x1,x2,mat):
 n=len(outer);vs=[(x,y,z) for x,pr in [(x1,outer),(x2,outer),(x2,inner),(x1,inner)] for y,z in pr];fs=[]
 for k in range(4):
  kk=(k+1)%4
  for j in range(n):q=(j+1)%n;fs.append((k*n+j,k*n+q,kk*n+q,kk*n+j))
 return mesh(name,vs,fs,mat,.003)
def window_side(name,poly,s):
 poly=rounded_poly(poly,.042,5);cen=Vector((sum(p[0] for p in poly)/len(poly),sum(p[1] for p in poly)/len(poly)))
 def scpoly(scale):return [tuple(cen+(Vector(p)-cen)*scale) for p in poly]
 side_ring(name+' frame',poly,scpoly(.91),s*.825,s*.853,'Paint')
 side_ring(name+' gasket',scpoly(.918),scpoly(.873),s*.850,s*.858,'Rubber')
 return extrude_yz(name+' glass',scpoly(.88),s*.851,s*.856,'Glass',.002)

def lamp_uv(o,patch):
 for d in o.data.uv_layers.active.data:d.uv=(patch*.25+.12,.5)
 return o

# Main welded underbody with distinct ladder rails, crossmembers and axles.
box('Floor pan',(0,.35,.53),(1.24,3.52,.13),'DarkMetal',.045)
for x in [-.55,.55]:box('Chassis longitudinal',(x,0,.435),(.10,3.65,.085),'DarkMetal',.023)
for y in [-1.42,-.4,.75,1.42]:box('Chassis crossmember',(0,y,.435),(1.25,.095,.085),'DarkMetal',.015)
for y in [-1.42,1.42]:
 cyl('Axle housing',(0,y,.4),.075,1.68,'DarkMetal','X');cyl('Differential',(0,y,.4),.15,.25,'DarkMetal','X')
 for s in [-1,1]:
  beam('Suspension damper',(s*.57,y-.12,.37),(s*.72,y+.10,.73),.055,'Metal')
  for z in np.linspace(.44,.70,6):
   pts=[(s*.61+.078*cos(t),y+.078*sin(t),z) for t in np.linspace(0,2*pi,25)];tube('Coil spring',pts,.009,'DarkMetal')
# Continuous side skins with open wheel arches.
for s in [-1,1]:
 poly=[(-2.16,1.08),(-1.97,1.20),(-.76,1.38),(2.06,1.38),(2.14,1.13),(2.14,.54)]
 for yc in [1.42,-1.42]:
  poly.extend([(yc+.50,.50),(yc+.50,.78),(yc+.40,.99),(yc-.40,.99),(yc-.50,.78),(yc-.50,.50)])
 poly.extend([(-2.16,.54)])
 extrude_yz('Pressed body side',poly,s*.787,s*.851,'Paint',.026)
 # squared and crowned H3 wheel arches, continuous deep lip returns
 for yc in [-1.42,1.42]:
  inn=[(yc-.50,.51),(yc-.50,.78),(yc-.40,.99),(yc+.40,.99),(yc+.50,.78),(yc+.50,.51)]
  outer=[(yc-.61,.51),(yc-.59,.82),(yc-.47,1.14),(yc+.47,1.14),(yc+.59,.82),(yc+.61,.51)]
  vs=[]
  for x,pr in [(s*.842,outer),(s*.948,outer),(s*.948,inn),(s*.862,inn)]:vs.extend([(x,y,z) for y,z in pr])
  fs=[]
  for k in range(3):
   for j in range(5):fs.append((k*6+j,k*6+j+1,(k+1)*6+j+1,(k+1)*6+j))
  fs.extend([(0,6,12,18),(5,23,17,11)])
  mesh('Broad fender flare',vs,fs,'Paint',.040)
  # Inner wheel housing closes the view through the cabin underbody.
  liner=[(yc-.52,.30),(yc-.52,.79),(yc-.41,1.025),(yc+.41,1.025),(yc+.52,.79),(yc+.52,.30)]
  extrude_yz('Wheel well inner splash shield',liner,s*.575,s*.597,'Rubber',.016)
  lining=[]
  for xx in [s*.589,s*.855]:lining.extend([(xx,y,z) for y,z in inn[1:5]])
  mesh('Wheel well roof liner',lining,[(0,1,5,4),(1,2,6,5),(2,3,7,6)],'Rubber',.008)
  # black inner liner avoids light leaks through the open arches
  pts=[(s*.856,y,z-.015) for y,z in inn];tube('Wheel arch seal',pts,.018,'Rubber')
 # doors are pressed surface polygons, their inset seams have real depth
 for lab,p in [('front',[(-.84,.58),(.30,.58),(.30,1.38),(-.69,1.38)]),('rear',[(.32,.58),(1.00,.58),(1.14,1.05),(1.14,1.38),(.32,1.38)])]:
  extrude_yz(lab+' door shut gap',p,s*.852,s*.858,'Rubber',.011)
  cen=Vector((sum(v[0] for v in p)/len(p),sum(v[1] for v in p)/len(p)));p2=[tuple(cen+(Vector(v)-cen)*.979) for v in p]
  extrude_yz(lab+' door skin',p2,s*.858,s*.863,'Paint',.013)
 for yy in [.13,.96]:
  box('Handle recess',(s*.869,yy,1.145),(.018,.28,.075),'DarkMetal',.027)
  box('Pull handle',(s*.898,yy,1.17),(.035,.21,.031),'Rubber',.014)
  cyl('Door key barrel',(s*.906,yy+.08,1.125),.013,.012,'Metal','X',20,.002)
 box('Rocker sill',(s*.817,.13,.505),(.15,1.55,.095),'DarkMetal',.024)
 box('Running step',(s*.87,.15,.46),(.15,1.53,.04),'Rubber',.016)
 for yy in [-.43,.56]:box('Step support',(s*.78,yy,.465),(.25,.045,.05),'DarkMetal',.009)
 # real cabin side pillars; windows remain transparent with interior behind
 window_side('Front door', [(-.66,1.38),(.30,1.38),(.30,1.736),(-.35,1.736)],s)
 window_side('Rear door',[(.36,1.38),(1.15,1.38),(1.15,1.741),(.36,1.741)],s)
 window_side('Cargo',[(1.22,1.38),(2.04,1.38),(2.00,1.735),(1.22,1.741)],s)
 # Narrow black divider in the rear door window
 box('Rear quarter light divider',(s*.859,.99,1.545),(.012,.020,.307),'Rubber',.006)
 # wing mirrors, two point stalk, rounded shell and inset glass
 beam('Mirror stalk',(s*.81,-.57,1.22),(s*.907,-.64,1.37),.054,'Rubber',bev=.012)
 box('Mirror shell',(s*.924,-.66,1.40),(.052,.22,.16),'Rubber',.035)
 box('Mirror reflective inset',(s*.928,-.545,1.40),(.041,.008,.117),'Metal',.016)
 # Side markers at front arch and tail quarter
 lamp_uv(box('Amber side repeater',(s*.944,-1.94,1.02),(.011,.052,.097),'Lamps',.01),1)
 for yy in [-.63,.35]:box('Door hinge',(s*.875,yy,1.23),(.027,.042,.075),'Paint',.009)
# Engine bay, flat crown hood with subtle shoulder facets.
box('Engine dark volume',(0,-1.47,.89),(1.20,1.22,.44),'DarkMetal',.06)
vs=[(-.80,-2.18,1.13),(.80,-2.18,1.13),(-.75,-.71,1.245),(.75,-.71,1.245),(-.76,-2.14,1.21),(.76,-2.14,1.21),(-.71,-.72,1.29),(.71,-.72,1.29)]
mesh('Sculpted flat hood',vs,[(0,1,5,4),(2,6,7,3),(0,4,6,2),(1,3,7,5),(4,5,7,6),(0,2,3,1)],'Paint',.025)
box('Hood vent surround',(0,-1.05,1.279),(.69,.40,.015),'DarkMetal',.035)
for y in np.linspace(-1.20,-.91,12):box('Hood vent louvre',(0,y,1.292),(.59,.012,.008),'Rubber',.003)
# Hood rear cowl, front cabin pillars, roof, tailgate.
box('Cowl',(0,-.69,1.23),(1.63,.13,.095),'Paint',.025)
for s in [-1,1]:
 beam('A pillar',(s*.805,-.73,1.23),(s*.795,-.35,1.76),.076,'Paint',depth=.083,bev=.022)
 for yy in [.33,1.185]:box('Cabin pillar',(s*.813,yy,1.50),(.07,.07,.54),'Paint',.018)
 beam('Rear pillar',(s*.805,2.09,1.20),(s*.794,2.034,1.77),.105,'Paint',bev=.022)
for s in [-1,1]:box('Roof cant rail',(s*.815,.84,1.754),(.059,2.42,.052),'Paint',.020)
box('Rear windscreen structural header',(0,2.080,1.752),(1.63,.145,.083),'Paint',.012)
box('Windscreen structural header',(0,-.355,1.757),(1.63,.095,.061),'Paint',.012)
box('Roof crown',(0,.855,1.806),(1.69,2.49,.066),'Paint',.06)
for xx in [-.4,-.2,0,.2,.4]:box('Roof pressing rib',(xx,.79,1.840),(.023,1.88,.010),'Paint',.007)
# windscreen quadrilateral and rubber surround, actual slant
v=[(-.771,-.694,1.268),(.771,-.694,1.268),(.751,-.355,1.747),(-.751,-.355,1.747)]
# Thin perimeter seal leaves a real transparent opening.
c=sum((Vector(p) for p in v),Vector())/4
v2=[tuple(c+(Vector(p)-c)*.941+Vector((0,-.005,0))) for p in v]
mesh('Windscreen',v2,[(0,1,2,3)],'Glass')
mesh('Windscreen gasket',v+v2,[(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Rubber')
for s in [-1,1]:
 beam('Wiper arm',(s*.51,-.713,1.27),(s*.19,-.594,1.40),.014,'DarkMetal',bev=.003)
 beam('Wiper blade',(s*.40,-.602,1.394),(s*.02,-.602,1.394),.015,'Rubber',depth=.022,bev=.005)
box('Tailgate',(0,2.106,.91),(1.65,.092,.65),'Paint',.035)
for xx in [-.738,.738]:box('Rear glass side seal',(xx,2.107,1.49),(.043,.049,.48),'Rubber',.017)
for zz in [1.26,1.72]:box('Rear glass horizontal seal',(0,2.107,zz),(1.49,.049,.043),'Rubber',.017)
box('Rear windscreen',(0,2.135,1.49),(1.41,.012,.409),'Glass',.039)
box('Tailgate handle',(.55,2.172,1.13),(.24,.04,.044),'Rubber',.012)
beam('Rear wiper',(-.05,2.15,1.34),(.47,2.15,1.39),.015,'Rubber',bev=.004)
for s in [-1,1]:
 box('Tail lamp housing',(s*.793,2.16,1.09),(.108,.093,.44),'Rubber',.022)
 lamp_uv(box('Vertical red tail lens',(s*.793,2.213,1.15),(.085,.026,.27),'Lamps',.012),2)
 lamp_uv(box('Reverse lens',(s*.793,2.214,.975),(.08,.026,.065),'Lamps',.009),0)
 for z in np.linspace(1.035,1.264,8):box('Tail lamp lens ribs',(s*.793,2.229,z),(.079,.004,.003),'Metal',.001)
# Roof rails and attachment shoes, height exactly 1.89.
for s in [-1,1]:
 for yy in [-.19,1.82]:box('Roof rail foot',(s*.62,yy,1.839),(.11,.17,.049),'DarkMetal',.015)
 tube('Roof side rail',[(s*.62,-.27,1.84),(s*.62,-.20,1.876),(s*.62,1.81,1.876),(s*.62,1.9,1.84)],.014,'DarkMetal')
for yy in [-.17,1.78]:box('Roof transverse rail',(0,yy,1.859),(1.27,.041,.034),'Metal',.012)
# Closing returns meet the hood corners behind the grille without open gaps.
for s in [-1,1]:box('Front fender closing return',(s*.79,-2.15,1.13),(.105,.055,.135),'Paint',.012)
# grille frame with seven true upright recesses and stout round headlamps.
box('Radiator backing',(0,-2.163,.991),(1.55,.045,.35),'Rubber',.015)
grille=box('Monolithic slotted grille',(0,-2.209,1.005),(1.65,.071,.36),'Metal',.020)
for x in np.linspace(-.33,.33,7):
 cutter=box('Temporary slot tool',(x,-2.215,1.005),(.073,.22,.263),'Rubber',.035)
 bpy.context.view_layer.objects.active=grille;mod=grille.modifiers.new('Deep rounded grille slot','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
 PARTS['Body'].remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
def optical_lamp(x,y,z,r):
 # Parabolic concave reflector and shallow convex front lens, actual optical depth.
 n=64;steps=8;vs=[];fs=[]
 for k in range(steps+1):
  radius=r*(.065+.935*k/steps);depth=r*.37*(1-(radius/r)**2)
  for i in range(n):
   a=2*pi*i/n;scallop=r*.025*cos(24*a)*sin(pi*radius/r);vs.append((x+radius*cos(a),y+depth+scallop,z+radius*sin(a)))
 for k in range(steps):
  for i in range(n):j=k*n+i;ni=k*n+(i+1)%n;fs.append((j,ni,ni+n,j+n))
 mesh('Parabolic lamp reflector',vs,fs,'Reflector',0,True)
 tube('Polished lamp retaining rim',[(x+r*cos(a),y,z+r*sin(a)) for a in np.linspace(0,2*pi,65)],r*.033,'Metal')
 lamp_uv(cyl('Small halogen bulb',(x,y+r*.16,z),r*.115,r*.20,'Lamps','Y',24,.002),0)
 vs=[(x,y-r*.13,z)];fs=[]
 for k in range(1,7):
  radius=r*.985*k/6
  for i in range(n):
   a=2*pi*i/n;vs.append((x+radius*cos(a),y-r*.13*(1-(radius/r)**2),z+radius*sin(a)))
 for i in range(n):fs.append((0,1+(i+1)%n,1+i))
 for k in range(5):
  for i in range(n):j=1+k*n+i;ni=1+k*n+(i+1)%n;fs.append((j,j+n,ni+n,ni))
 mesh('Convex optical glass',vs,fs,'Glass',0,True)
for s in [-1,1]:
 box('Headlight bezel',(s*.594,-2.205,1.004),(.302,.07,.30),'Metal',.042)
 tube('Annular headlamp mounting gasket',[(s*.583+.119*cos(a),-2.297,1.006+.119*sin(a)) for a in np.linspace(0,2*pi,65)],.008,'Rubber')
 optical_lamp(s*.583,-2.300,1.006,.108)
 lamp_uv(box('Amber front indicator',(s*.755,-2.256,1.00),(.065,.018,.18),'Lamps',.009),1)
# Shaped crash bumpers, towing eyes, skid plate, grille perforations.
outline=[(-.89,.73),(-.81,.805),(.81,.805),(.89,.73),(.87,.605),(.70,.55),(-.70,.55),(-.87,.605)]
vs=[]
for back in [0,.16]:
 for x,z in outline:vs.append((x,-2.312+.074*(abs(x)/.89)**5+back,z))
fs=[tuple(range(7,-1,-1)),tuple(range(8,16))]
for i in range(8):j=(i+1)%8;fs.append((i,j,j+8,i+8))
bumper=mesh('Sculpted front bumper',vs,fs,'DarkMetal',.028)
for s in [-1,1]:
 cutter=cyl('Temporary fog pocket tool',(s*.52,-2.23,.683),.091,.37,'Rubber','Y',64,.003)
 bpy.context.view_layer.objects.active=bumper;mod=bumper.modifiers.new('Recessed fog pocket','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
 PARTS['Body'].remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
bpy.context.view_layer.objects.active=bumper;mod=bumper.modifiers.new('Fog pocket edge radius','BEVEL');mod.width=.004;mod.segments=2;bpy.ops.object.modifier_apply(modifier=mod.name)
box('Front lower skid',(0,-2.214,.459),(.96,.135,.15),'DarkMetal',.032)
box('Bumper air intake',(0,-2.318,.711),(.76,.012,.094),'Rubber',.014)
for x in np.linspace(-.35,.35,25):
 for z in [.687,.711,.735]:cyl('Intake perforation',(x,-2.327,z),.006,.003,'Metal','Y',8,0)
for s in [-1,1]:
 tube('Annular recessed fog mounting',[(s*.52+.070*cos(a),-2.277,.683+.070*sin(a)) for a in np.linspace(0,2*pi,49)],.010,'Rubber')
 cyl('Fog pocket back wall',(s*.52,-2.155,.683),.087,.006,'Rubber','Y',48,.002)
 optical_lamp(s*.52,-2.278,.683,.058)
 box('Tow mount',(s*.51,-2.258,.48),(.13,.14,.13),'DarkMetal',.016)
 pts=[(s*.51+.055*cos(a),-2.327,.465+.069*sin(a)) for a in np.linspace(0,2*pi,25)]
 tube('Front recovery shackle',pts,.020,'Metal')
 for xx in [-.03,.03]:cyl('Tow bolt',(s*.51+xx,-2.337,.52),.012,.008,'Metal','Y',6,.001)
box('Rear bumper',(0,2.188,.57),(1.81,.155,.24),'DarkMetal',.035)
for s in [-1,1]:box('Rear bumper tread',(s*.68,2.195,.70),(.33,.16,.022),'Rubber',.008)
box('Hitch receiver',(0,2.241,.39),(.12,.18,.105),'DarkMetal',.011)
cyl('Exhaust pipe',(-.60,2.08,.36),.040,.26,'Metal','Y',32,.004);cyl('Exhaust dark mouth',(-.60,2.216,.36),.031,.003,'Rubber','Y',32,.001)
# Fuel filler with visible flange and inset cap.
cyl('Fuel filler recess',(.864,1.955,1.11),.09,.015,'Rubber','X',48,.003)
cyl('Fuel filler cap',(.88,1.955,1.11),.067,.018,'DarkMetal','X',48,.004)
beam('Fuel cap grip',(.895,1.925,1.08),(.895,1.985,1.14),.016,'Rubber',bev=.004)
# Interior: deep dashboard, gauge binnacle, centre console, bolstered seats.
box('Dashboard',(0,-.50,1.14),(1.47,.35,.21),'Interior',.055)
box('Instrument binnacle',(.41,-.40,1.264),(.45,.25,.13),'Rubber',.045)
for x in [.31,.49]:
 cyl('Gauge bezel',(x,-.260,1.245),.057,.014,'Metal','Y',32,.003)
 cyl('Gauge dial',(x,-.251,1.245),.049,.006,'Rubber','Y',32,.002)
 beam('Gauge needle',(x,-.245,1.245),(x+.024,-.245,1.269),.003,'Metal',bev=.001)
for x in [-.59,-.18,.12,.61]:box('Dashboard vent',(x,-.301,1.15),(.15,.018,.075),'Rubber',.01)
box('Centre console',(0,-.05,.79),(.31,.65,.29),'Interior',.035)
box('Radio panel',(0,-.299,1.075),(.21,.025,.12),'DarkMetal',.013)
for x in [-.077,.077]:cyl('Climate knob',(x,-.275,1.03),.02,.02,'Rubber','Y',24,.003)
beam('Gear lever',(0,-.07,.93),(0,-.12,1.08),.024,'Metal',bev=.005);box('Gear knob',(0,-.12,1.08),(.065,.068,.055),'Rubber',.023)
for row,yy in enumerate([.10,1.13]):
 for s in [-1,1]:
  x=s*.43
  box('Seat cushion',(x,yy,.76),(.56,.58,.15),'Interior',.065)
  box('Seat back',(x,yy+.25,1.02),(.55,.16,.58),'Interior',.062,rot=(.12,0,0))
  box('Head restraint',(x,yy+.27,1.40),(.32,.12,.20),'Interior',.045)
  for xx in [x-.12,x+.12]:cyl('Headrest post',(xx,yy+.27,1.29),.010,.16,'Metal','Z',16,.001)
  for side in [-1,1]:box('Seat bolster',(x+side*.233,yy,.84),(.09,.48,.12),'Interior',.035)
  for xx in [x-.18,x+.18]:tube('Seat stitch',[(xx,yy-.21,.841),(xx,yy+.18,.845),(xx,yy+.174,1.23)],.0025,'DarkMetal')
for s in [-1,1]:
 for yy in [.06,1.00]:
  box('Interior door card',(s*.774,yy,.98),(.075,.75,.40),'Interior',.025)
  box('Door armrest',(s*.727,yy,1.025),(.09,.40,.07),'Rubber',.018)
# Steering hub sits on the vehicle LEFT (+X), separate moving node.
group='SteeringWheel';hub=Vector((.43,-.235,1.17))
# Slightly inclined real wheel ring, spoke fan and centre horn pad.
pts=[tuple(hub+Vector((.167*cos(a),.034*sin(a),.167*sin(a)))) for a in np.linspace(0,2*pi,65)]
tube('Steering leather rim',pts,.019,'Rubber')
for a in [0,pi,3*pi/2]:beam('Steering spoke',hub,hub+Vector((.148*cos(a),.028*sin(a),.148*sin(a))),.030,'DarkMetal',depth=.04,bev=.008)
box('Steering hub',hub,(.13,.062,.12),'Rubber',.032)
group='Body';beam('Steering column',(.43,-.46,1.10),hub,.060,'DarkMetal')
# Wheel construction: tyre lathe with sidewall crown, 60 rows of staggered tread,
# cast rim barrel, open multi-spoke wheel, six lug nuts and moulded sidewall ribs.
def wheel(center,sgn=1,spare=False):
 global group
 c=Vector(center)
 def cv(p):
  x,y,z=p
  return tuple(c+Vector((y,x,z))) if spare else tuple(c+Vector((x,y,z)))
 def wheelmesh(name,vs,fs,mat,bev=0,smooth=False):return mesh(name,[cv(v) for v in vs],fs,mat,bev,smooth)
 profile=[(-.142,.265),(-.147,.307),(-.139,.348),(-.112,.381),(-.072,.396),(0,.400),(.072,.396),(.112,.381),(.139,.348),(.147,.307),(.142,.265),(.106,.25),(-.106,.25)]
 vs=[];n=80
 for x,r in profile:
  for j in range(n):a=2*pi*j/n;vs.append((x,r*sin(a),r*cos(a)))
 fs=[]
 for i in range(len(profile)):
  for j in range(n):k=(j+1)%n;ii=(i+1)%len(profile);fs.append((i*n+j,i*n+k,ii*n+k,ii*n+j))
 wheelmesh('Radial all-terrain carcass',vs,fs,'Rubber',0,True)
 # All blocks share one mesh; bevel gives physically rounded tread shoulders.
 tv=[];tf=[]
 for j in range(48):
  for k in range(4):
   a=2*pi*(j+(k%2)*.37)/48;x=(k-1.5)*.059;r=.399;tw=.053;tl=.031;rad=.009
   base=len(tv)
   for dx,dt,dr in [(-tw/2,-tl/2,-rad/2),(tw/2,-tl/2,-rad/2),(tw/2,tl/2,-rad/2),(-tw/2,tl/2,-rad/2),(-tw/2,-tl/2,rad/2),(tw/2,-tl/2,rad/2),(tw/2,tl/2,rad/2),(-tw/2,tl/2,rad/2)]:
    aa=a+(dt+dx*.32)/r;tv.append((x+dx,(r+dr)*sin(aa),(r+dr)*cos(aa)))
   tf.extend([tuple(base+q for q in f) for f in [(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)]])
 wheelmesh('Rounded staggered tread blocks',tv,tf,'Rubber',.0018,False)
 # Ring lathe helper uses cylindrical concentric bands.
 def ring(name,x,r1,r2,depth,mat):
  vs=[]
  for xx,r in [(x-depth/2,r1),(x-depth/2,r2),(x+depth/2,r2),(x+depth/2,r1)]:
   for j in range(64):a=j*2*pi/64;vs.append((xx,r*sin(a),r*cos(a)))
  fs=[]
  for k in range(4):
   for j in range(64):l=(j+1)%64;kk=(k+1)%4;fs.append((k*64+j,k*64+l,kk*64+l,kk*64+j))
  wheelmesh(name,vs,fs,mat,0,True)
 out=.148*sgn
 ring('Alloy rim outer lip',out,.219,.244,.025,'Metal');ring('Alloy inner barrel',0,.205,.229,.25,'DarkMetal');ring('Brake disc',out-sgn*.07,.10,.213,.014,'Metal')
 for j in range(12):
  a=j*2*pi/12
  # tapered cast spokes show negative space, never a painted disc
  pts=[]
  for xx in [out-.015,out+.015]:
   for rr,da in [(.084,-.07),(.220,-.073),(.220,.073),(.084,.07)]:pts.append((xx,rr*sin(a+da),rr*cos(a+da)))
  wheelmesh('Cast alloy spoke',pts,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],'Metal',.007)
 # Hub cylinders created world aligned to axle
 axis='Y' if spare else 'X'
 cyl('Alloy hub',cv((out,0,0)),.093,.040,'Metal',axis,48,.006)
 cyl('Hub centre cap',cv((out+sgn*.024,0,0)),.066,.012,'DarkMetal',axis,48,.004)
 for j in range(6):
  a=j*pi/3;cyl('Hex lug nut',cv((out+sgn*.027,.076*sin(a),.076*cos(a))),.011,.018,'Metal',axis,6,.002)
 for r in [.292,.334,.359]:ring('Moulded sidewall rib',sgn*.143,r-.0015,r+.0015,.0018,'Rubber')
 # valve stem
 cyl('Tyre valve',cv((out+sgn*.019,.199,.10)),.006,.025,'Rubber',axis,12,.001)
for lab,x,y in [('FL',.766,-1.42),('FR',-.766,-1.42),('RL',.766,1.42),('RR',-.766,1.42)]:
 group='Wheel_'+lab;wheel((x,y,.4045),1 if x>0 else -1)
group='Body'
box('Spare carrier',(0,2.203,1.12),(.48,.17,.43),'DarkMetal',.025)
wheel((0,2.329,1.15),1,True)
# Weld fixed groups, bake transforms, preserve exact wheel / steering origins.
objects=[]
origins={'Body':(0,0,0),'SteeringWheel':tuple(hub),'Wheel_FL':(.766,-1.42,.4045),'Wheel_FR':(-.766,-1.42,.4045),'Wheel_RL':(.766,1.42,.4045),'Wheel_RR':(-.766,1.42,.4045)}
for name,parts in PARTS.items():
 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
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 sc.cursor.location=origins[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 # Triangulate final geometry and discard accidental zero-area faces.
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.000005);bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=.000001);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bmesh.ops.triangulate(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free();o.data.update();objects.append(o)
# Remove redundant tessellation while preserving silhouette and all required nodes.
initial_triangles=sum(len(o.data.polygons) for o in objects)
if initial_triangles>104000:
 ratio=103500/initial_triangles
 for o in objects:
  bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Game topology budget','DECIMATE');mod.ratio=ratio;mod.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=mod.name)
for o in objects:
 bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Final weighted corner normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=40;bpy.ops.object.modifier_apply(modifier=mod.name)
 if o.name.startswith('Wheel_'):o.location.z=-min(v.co.z for v in o.data.vertices)
bpy.context.view_layer.update()
# Meet the exact length without changing the wheelbase or round tyres.
body=bpy.data.objects['Body'];local_y=[v.co.y for v in body.data.vertices]
front_min=min(local_y);rear_max=max(local_y)
for v in body.data.vertices:
 if v.co.y < -1.92:v.co.y=-1.92+(v.co.y+1.92)*(-2.28+1.92)/(front_min+1.92)
 if v.co.y > 2.00:v.co.y=2.00+(v.co.y-2.00)*(2.46-2.00)/(rear_max-2.00)
# Real cabin tumblehome: the upper pillars, glazing and roof taper together.
for v in body.data.vertices:
 if v.co.z>1.32 and -.85<v.co.y<2.18:
  t=min(1.0,(v.co.z-1.32)/.48);v.co.x*=1.0-.085*t
top=max(v.co.z for v in body.data.vertices)
for v in body.data.vertices:
 if v.co.z>1.865:v.co.z=1.865+(v.co.z-1.865)*(.025)/(top-1.865)
body.data.update()
print('MESH_COUNTS',[(o.name,len(o.data.polygons)) for o in objects if o.type=='MESH'],flush=True)
# Explicit sockets, positions convert through the same coordinate contract.
sockets={'Seat_Driver':(.43,.10,.85),'Door_L':(.82,-.64,1.10),'Door_R':(-.82,-.64,1.10),'Exhaust':(-.60,2.22,.36),'Light_Head_L':(.583,-2.29,1.006),'Light_Head_R':(-.583,-2.29,1.006),'Light_Tail_L':(.793,2.235,1.15),'Light_Tail_R':(-.793,2.235,1.15),'Troop_1':(-.43,.1,.85),'Troop_2':(.43,1.13,.85),'Troop_3':(-.43,1.13,.85)}
for name,loc in sockets.items():
 x,y,z=loc
 if y < -1.92:y=-1.92+(y+1.92)*(-2.28+1.92)/(front_min+1.92)
 if y > 2:y=2+(y-2)*(.46)/(rear_max-2)
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=(x,y,z);objects.append(o)
bpy.context.view_layer.update()
paints=[{'name':'Desert yellow','linear_rgb':[.62,.48,.08]},{'name':'Olive drab','linear_rgb':[.16,.185,.105]},{'name':'NATO green','linear_rgb':[.095,.15,.09]},{'name':'Desert tan','linear_rgb':[.48,.36,.205]},{'name':'Field grey','linear_rgb':[.23,.27,.245]},{'name':'Winter white','linear_rgb':[.78,.80,.75]}]
def cvg(p):return [round(p[0],6),round(p[2],6),round(-p[1],6)]
pts=[o.matrix_world@Vector(v) for o in objects if o.type=='MESH' for v in o.bound_box];gpts=[cvg(p) for p in pts];lo=[min(p[i] for p in gpts) for i in range(3)];hi=[max(p[i] for p in gpts) for i in range(3)]
tris=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','left':'+X','nodes':[{'name':o.name,'type':o.type,'world_position':cvg(o.matrix_world.translation)} for o in objects],'bounding_box':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'wheelbase':2.84,'triangles':tris,'paints':paints,'paint_note':'The explicit default yellow factor takes priority; remaining five paints come from the requested palette.'},open(os.path.join(ROOT,'nodes.json'),'w'),indent=2)
# Shared paint trim UVs: vertical faces map height into a neutral lower road-grime
# band; long panels repeat U, retaining useful texel density without a unique atlas.
for o in objects:
 if o.type!='MESH':continue
 uv=o.data.uv_layers.active.data
 for p in o.data.polygons:
  if o.data.materials[p.material_index].name!='Paint':continue
  axis=max(range(3),key=lambda i:abs(p.normal[i]))
  for li in p.loop_indices:
   v=o.matrix_world@o.data.vertices[o.data.loops[li].vertex_index].co
   if axis==0:uv[li].uv=(v.y*1.7,v.z/2)
   elif axis==1:uv[li].uv=(v.x*1.7,v.z/2)
   else:uv[li].uv=(v.x*1.7,.60+(v.y*.16)% .35)
# Wheels on a side share one mesh resource while retaining their own pivots.
bpy.data.objects['Wheel_RL'].data=bpy.data.objects['Wheel_FL'].data
bpy.data.objects['Wheel_RR'].data=bpy.data.objects['Wheel_FR'].data
# Ensure direct baked image graph for portable glTF; factor is patched explicitly.
paint=M['Paint'];nt=paint.node_tree;pbs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');base=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and '_base' in n.image.name);nt.links.new(base.outputs[0],pbs.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in objects:o.select_set(True)
path=os.path.join(ROOT,'hummer-h3.glb')
bpy.ops.export_scene.gltf(filepath=path,export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_materials='EXPORT',export_extras=True)
# glTF stores colour in linear RGB. Patch only a scalar factor, never bitmap pixels.
buf=open(path,'rb').read();jl,jt=struct.unpack_from('<II',buf,12);doc=json.loads(buf[20:20+jl]);binary=buf[20+jl:]
for mat in doc['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=[.62,.48,.08,1]
 if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
raw=json.dumps(doc,separators=(',',':')).encode();raw+=b' '*((-len(raw))%4);new=struct.pack('<4sII',b'glTF',2,12+8+len(raw)+len(binary))+struct.pack('<II',len(raw),0x4E4F534A)+raw+binary;open(path,'wb').write(new)
nt.links.new(paint_mult.outputs[0],pbs.inputs['Base Color'])
# Count the actual instantiated exported triangles, including shared wheel meshes.
tris=sum(doc['accessors'][p['indices']]['count']//3 for node in doc['nodes'] if 'mesh' in node for p in doc['meshes'][node['mesh']]['primitives'])
meta_path=os.path.join(ROOT,'nodes.json');meta=json.load(open(meta_path));meta['triangles']=tris;json.dump(meta,open(meta_path,'w'),indent=2)
print('EXPORT_READY',path,'TRIS',tris,'BYTES',len(new),flush=True)
open(os.path.join(ROOT,'NOTES.md'),'w').write('Hummer H3-inspired four-seat SUV, metres; +Z front, +Y up, axle-midpoint ground origin.\n'+f'{tris:,} triangles; six named mesh nodes and eleven attachment sockets.\n'+'8 materials: Paint, Rubber, Metal, DarkMetal, Interior, Glass (BLEND), Lamps (emissive), Reflector.\n'+'Cycles GPU baked shared tiling maps: colour/normal 64–1024 px; packed ORM 64–512 px; GLB '+f'{len(new)/1048576:.2f} MiB.\n'+'Poly Haven CC0 ids: metal_plate, rubberized_track, fabric_leather_02; https://polyhaven.com/license (CC0).\n'+'Reference differences: default yellow paint per contract; simplified stamped body contours, underbody and dashboard; no branding or lettering.\n')
# Render studio: soft daylight, ground contact, neutral photographic backdrop.
world=bpy.data.worlds.new('Daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.70,.78,.86,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.001));ground=bpy.context.object;ground.name='Studio ground';gm=bpy.data.materials.new('Studio');gm.diffuse_color=(.22,.245,.26,1);ground.data.materials.append(gm)
def light(name,loc,energy,size,color):
 data=bpy.data.lights.new(name,'AREA');data.energy=energy;data.shape='DISK';data.size=size;data.color=color;o=bpy.data.objects.new(name,data);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,.9))-o.location).to_track_quat('-Z','Y').to_euler()
light('Softbox key',(4,-4,7),850,5,(1,.94,.85));light('Sky fill',(-4,-1,4),450,5,(.72,.84,1));light('Long rim',(1,5,5),900,4,(1,1,1))
camdata=bpy.data.cameras.new('Presentation');cam=bpy.data.objects.new('Presentation',camdata);sc.collection.objects.link(cam);sc.camera=cam
camdata.type='PERSP';camdata.lens=57;camdata.ortho_scale=6.7
hero=(6.8,-8.0,3.3);target=(0,0,.92)
def camera(loc,target,scale):cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camdata.ortho_scale=scale
camera(hero,target,6.7)
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
sc.cycles.samples=48;sc.render.filepath=os.path.join(ROOT,'final.png');bpy.ops.render.render(write_still=True)
# Same hero view with a single plain grey material on every exported mesh.
clay=bpy.data.materials.new('QA clay');clay.diffuse_color=(.42,.42,.42,1);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=.67
saved={}
for o in objects:
 if o.type=='MESH':
  if o.data not in saved:
   saved[o.data]=list(o.data.materials)
   for i,m in enumerate(o.data.materials):o.data.materials[i]=clay
sc.cycles.samples=24;sc.render.filepath=os.path.join(ROOT,'clay.png');bpy.ops.render.render(write_still=True)
for me,mats in saved.items():
 for i,m in enumerate(mats):me.materials[i]=m
camdata.type='ORTHO'
camera((7,0,1.02),(0,0,.99),5.65);sc.render.filepath=os.path.join(ROOT,'side.png');bpy.ops.render.render(write_still=True)
camera((-5.7,7.1,3.0),(0,.3,1),6.7);camdata.type='PERSP';sc.render.resolution_x=1280;sc.render.resolution_y=720;sc.render.filepath=os.path.join(ROOT,'rear.png');bpy.ops.render.render(write_still=True)
# Render six actual recolours, then compose using Blender's own image buffers.
camdata.type='PERSP'
camera(hero,target,6.7);sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
arr=np.ones((720,1920,4),dtype=np.float32)
for i,p in enumerate(paints):
 paint_mult.inputs[2].default_value=(*p['linear_rgb'],1);sc.render.filepath=os.path.join(ROOT,f'paint-{i}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(sc.render.filepath,check_existing=False);pixels=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);x=(i%3)*640;y=(1-i//3)*360;arr[y:y+360,x:x+640]=pixels;bpy.data.images.remove(im);os.remove(sc.render.filepath)
img=bpy.data.images.new('Six paints',1920,720,alpha=True);img.pixels[:]=arr.ravel();img.filepath_raw=os.path.join(ROOT,'paints.png');img.file_format='PNG';img.save()
paint_mult.inputs[2].default_value=(.62,.48,.08,1);camera(hero,target,6.7)
print('COMPLETE',flush=True)
