"""Reproducible Sherman-style game asset. Blender 5.2, Cycles OptiX.
Run: blender -b --python-exit-code 1 --python build_sherman-tank.py
All authored coordinates are metres, Z-up, forward -Y; glTF converts to Y-up/+Z.
Only CC0 Poly Haven photographic sources are downloaded. No insignia.
"""
import bpy, bmesh, math, os, json, urllib.request, random, struct, time
import numpy as np
from mathutils import Vector
from mathutils import noise
from pathlib import Path
print('BUILD REVISION 6: CONTACT DETAIL, HATCH WELL, ROUNDTRIP',flush=True)
ROOT=Path(__file__).resolve().parent
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
random.seed(41)
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene; S.unit_settings.system='METRIC'; S.render.engine='CYCLES'
S.cycles.samples=20; S.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
S.cycles.device='GPU';S.render.threads_mode='FIXED';S.render.threads=8
S.render.image_settings.file_format='PNG'; S.view_settings.view_transform='AgX'; S.view_settings.exposure=-.35
PAINTS=[('Olive drab',[.12,.13,.07]),('NATO green',[.075,.115,.065]),('Desert tan',[.46,.35,.20]),('Field grey',[.19,.22,.19]),('Winter white',[.72,.75,.69]),('Dark green-black',[.027,.038,.025])]
IDS=['rusty_metal_03','denim_fabric','wood_table_worn','rubberized_track']
GROUPS={};MATS={};paint_mix=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 apply(o):
 active(o)
 for mod in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=mod.name)

def reg(o,g,mat):
 o.data.materials.append(MATS[mat]);GROUPS.setdefault(g,[]).append(o);return o

def bevel(o,w=.015,n=2):
 mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=w;mod.segments=4 if w>.04 else n;mod.harden_normals=True
 mod=o.modifiers.new('Corner normals','WEIGHTED_NORMAL');mod.keep_sharp=True
 return o

def box(g,loc,dim,mat='Paint',b=.015,rot=None):
 dx,dy,dz=[v/2 for v in dim]
 vs=[(-dx,-dy,-dz),(dx,-dy,-dz),(dx,dy,-dz),(-dx,dy,-dz),(-dx,-dy,dz),(dx,-dy,dz),(dx,dy,dz),(-dx,dy,dz)]
 o=mesh(g,vs,[(3,2,1,0),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],mat,False);o.location=loc
 if rot:o.rotation_euler=rot
 if b:
  for face in o.data.polygons:face.use_smooth=True
  bevel(o,b,2)
 return o

def cyl(g,loc,r,depth,mat='Paint',axis='Z',verts=32,r2=None):
 upper=r if r2 is None else r2
 vs=[(rad*math.cos(i*2*math.pi/verts),rad*math.sin(i*2*math.pi/verts),z) for z,rad in [(-depth/2,r),(depth/2,upper)] for i in range(verts)]
 fs=[tuple(reversed(range(verts))),tuple(range(verts,2*verts))]+[(i,(i+1)%verts,(i+1)%verts+verts,i+verts) for i in range(verts)]
 o=mesh(g,vs,fs,mat,False);o.location=loc
 if axis=='X':o.rotation_euler[1]=math.pi/2
 if axis=='Y':o.rotation_euler[0]=math.pi/2
 bevel(o,min(.008,r*.12,depth*.15),2)
 for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
 return o

def ball(g,loc,scale,mat='Paint',seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,radius=1,location=loc)
 o=bpy.context.object;o.scale=scale;active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 for p in o.data.polygons:p.use_smooth=True
 return reg(o,g,mat)

def mesh(g,vs,fs,mat='Paint',smooth=True):
 me=bpy.data.meshes.new(g);me.from_pydata(vs,[],fs);me.update();o=bpy.data.objects.new(g,me);S.collection.objects.link(o)
 for p in me.polygons:p.use_smooth=smooth
 return reg(o,g,mat)

def tube(g,pts,r,mat='Paint',res=8):
 c=bpy.data.curves.new('formed tube','CURVE');c.dimensions='3D';c.resolution_u=1;c.bevel_depth=r;c.bevel_resolution=1;c.resolution_u=2
 sp=c.splines.new('POLY');sp.points.add(len(pts)-1)
 for p,co in zip(sp.points,pts):p.co=(*co,1)
 o=bpy.data.objects.new('formed tube',c);S.collection.objects.link(o);active(o);bpy.ops.object.convert(target='MESH');return reg(bpy.context.object,g,mat)

def rod(g,a,b,r,mat='Paint',verts=12):
 a,b=Vector(a),Vector(b);o=cyl(g,(a+b)/2,r,(b-a).length,mat,verts=verts);o.rotation_euler=(b-a).to_track_quat('Z','Y').to_euler();return o

def torus(g,loc,major,minor,mat='Paint',axis='Z',majorseg=40,minorseg=8):
 bpy.ops.mesh.primitive_torus_add(major_segments=majorseg,minor_segments=minorseg,location=loc,major_radius=major,minor_radius=minor)
 o=bpy.context.object
 if axis=='X':o.rotation_euler[1]=math.pi/2
 if axis=='Y':o.rotation_euler[0]=math.pi/2
 for p in o.data.polygons:p.use_smooth=True
 return reg(o,g,mat)

def bolt(g,loc,r=.018,axis='Z',mat='Paint'):
 return cyl(g,loc,r,.014,mat,axis,6)

def handle(g,loc,width=.16,height=.055,axis='Z',mat='Paint'):
 x,y,z=loc
 if axis=='Z':pts=[(x-width/2,y,z),(x-width/2,y,z+height),(x+width/2,y,z+height),(x+width/2,y,z)]
 elif axis=='X':pts=[(x,y-width/2,z),(x+height,y-width/2,z),(x+height,y+width/2,z),(x,y+width/2,z)]
 else:pts=[(x-width/2,y,z),(x-width/2,y-height,z),(x+width/2,y-height,z),(x+width/2,y,z)]
 tube(g,pts,.012,mat)

def download(id,kind):
 p=CACHE/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'
  print('DOWNLOAD',url,flush=True);urllib.request.urlretrieve(url,p)
 return bpy.data.images.load(str(p),check_existing=True)

def make_material(name,id,color,rough=.8,metal=0.,size=512):
 global paint_mix
 m=bpy.data.materials.new(name);m.use_nodes=True;MATS[name]=m
 n=m.node_tree.nodes;l=m.node_tree.links;n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=n.new('ShaderNodeTexCoord')
 diff=n.new('ShaderNodeTexImage');diff.image=download(id,'diff');l.new(uv.outputs['UV'],diff.inputs['Vector'])
 gray=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],gray.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.05;ramp.color_ramp.elements[1].position=.75
 low=.49 if name=='Paint' else .52
 ramp.color_ramp.elements[0].color=tuple(v*low for v in color)+(1,);ramp.color_ramp.elements[1].color=tuple(color)+(1,)
 l.new(gray.outputs[0],ramp.inputs[0]);l.new(ramp.outputs['Color'],bs.inputs['Base Color'])
 if name=='Paint':
  stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(15,.65,1);l.new(uv.outputs['UV'],stretch.inputs[0])
  rain=n.new('ShaderNodeTexNoise');rain.inputs['Scale'].default_value=3;rain.inputs['Detail'].default_value=2;l.new(stretch.outputs[0],rain.inputs['Vector'])
  dirt=n.new('ShaderNodeValToRGB');dirt.color_ramp.elements[0].position=.34;dirt.color_ramp.elements[0].color=(.88,.88,.88,1);dirt.color_ramp.elements[1].position=.49;l.new(rain.outputs['Fac'],dirt.inputs[0])
  weather=n.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=1;l.new(ramp.outputs[0],weather.inputs[1]);l.new(dirt.outputs[0],weather.inputs[2]);l.new(weather.outputs[0],bs.inputs['Base Color'])
 nor=n.new('ShaderNodeTexImage');nor.image=download(id,'nor_gl');nor.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],nor.inputs['Vector'])
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.32 if name=='Paint' else .65;l.new(nor.outputs['Color'],nm.inputs['Color'])
 tex=n.new('ShaderNodeTexNoise');tex.inputs['Scale'].default_value=145;tex.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],tex.inputs['Vector'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.19;bump.inputs['Distance'].default_value=.0007;l.new(tex.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=download(id,'arm');arm.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],arm.inputs['Vector'])
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
 rm=n.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=max(.3,rough-.12);rm.inputs['To Max'].default_value=min(1,rough+.12);l.new(sep.outputs[1],rm.inputs[0]);l.new(rm.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 # Bake a reusable physical surface tile; UVs on asset repeat at metre scale.
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-10));p=bpy.context.object;p.data.materials.append(m);active(p)
 target=n.new('ShaderNodeTexImage');n.active=target
 baked={}
 for kind,res in [('base',size),('normal',size),('orm',min(size,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
  S.render.bake.margin=4;S.cycles.samples=4
  if kind=='base':bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
  elif kind=='normal':bpy.ops.object.bake(type='NORMAL')
  else:
   em=n.new('ShaderNodeEmission');pack=n.new('ShaderNodeCombineColor');pack.mode='RGB';pack.inputs[0].default_value=1.;l.new(rm.outputs[0],pack.inputs[1]);pack.inputs[2].default_value=metal;l.new(pack.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');l.new(bs.outputs[0],out.inputs[0]);n.remove(em);n.remove(pack)
  im.filepath_raw=str(CACHE/(name+'_'+kind+'.jpg'));im.file_format='JPEG';im.save();baked[kind]=im
 bpy.data.objects.remove(p,do_unlink=True)
 n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0]);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 base=n.new('ShaderNodeTexImage');base.image=baked['base'];base.name='Baked Base';l.new(base.outputs[0],bs.inputs['Base Color'])
 norm=n.new('ShaderNodeTexImage');norm.image=baked['normal'];nm=n.new('ShaderNodeNormalMap');l.new(norm.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 orm=n.new('ShaderNodeTexImage');orm.image=baked['orm'];sep=n.new('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'])
 if name=='Paint':
  paint_mix=n.new('ShaderNodeMixRGB');paint_mix.blend_type='MULTIPLY';paint_mix.inputs[0].default_value=1;paint_mix.inputs[2].default_value=(*PAINTS[0][1],1);l.new(base.outputs[0],paint_mix.inputs[1]);l.new(paint_mix.outputs[0],bs.inputs['Base Color'])
 if name=='Lamps':bs.inputs['Emission Color'].default_value=(.15,.11,.05,1);bs.inputs['Emission Strength'].default_value=.2
 print('BAKED',name,flush=True)

# Eight portable material sets, every one actually baked with Cycles.
make_material('Paint','rusty_metal_03',(1,1,1),.83,.08,1024)
make_material('TrackSteel','rusty_metal_03',(.19,.16,.12),.76,.72,1024)
make_material('Rubber','rubberized_track',(.047,.044,.038),.9,0,512)
make_material('Canvas','denim_fabric',(.25,.18,.115),.95,0,1024)
make_material('Wood','wood_table_worn',(.25,.12,.045),.76,0,512)
make_material('WeaponSteel','rusty_metal_03',(.065,.07,.072),.52,.78,512)
make_material('Lamps','wood_table_worn',(.68,.59,.39),.24,.18,128)
make_material('Glass','rubberized_track',(.035,.065,.07),.22,.3,128)
print('MATERIALS COMPLETE',flush=True)
# Rounded cast hull: longitudinal sections with curved shoulders, not a bevelled box.
sections=[(-2.81,1.63,1.16,1.37),(-2.65,2.12,1.16,1.46),(-2.23,2.43,1.16,1.72),(-1.72,2.49,1.16,1.92),(-1.2,2.49,1.16,1.97),(0,2.48,1.16,1.94),(1.35,2.47,1.16,1.84),(2.48,2.39,1.16,1.74),(2.81,2.25,1.16,1.61)]
profile=[(0,-.5),(.73,-.5),(.94,-.46),(1,-.3),(1,-.03),(.96,.22),(.84,.40),(.60,.49),(0,.5),(-.60,.49),(-.84,.4),(-.96,.22),(-1,-.03),(-1,-.3),(-.94,-.46),(-.73,-.5)]
vs=[];fs=[]
for y,w,b,t in sections:
 for x,z in profile:vs.append((x*w/2,y,(t+b)/2+z*(t-b)))
N=len(profile)
for j in range(len(sections)-1):
 for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,(j+1)*N+(i+1)%N,(j+1)*N+i))
fs.extend([tuple(reversed(range(N))),tuple((len(sections)-1)*N+i for i in range(N))])
o=mesh('Hull',vs,fs);su=o.modifiers.new('Cast hull fairing','SUBSURF');su.levels=2
# Transmission-to-upper-casting flange follows the actual cast surface.
bpy.context.view_layer.update();cast=o.evaluated_get(bpy.context.evaluated_depsgraph_get());joint=[]
for x in np.linspace(-.90,.90,31):
 hit,point,normal,face=cast.ray_cast(Vector((x,-2.44,3)),Vector((0,0,-1)))
 if hit:joint.append(tuple(point+normal*.005))
tube('Hull',joint,.011,'Paint')
for x in np.linspace(-.84,.84,17):
 hit,point,normal,face=cast.ray_cast(Vector((x,-2.44,3)),Vector((0,0,-1)))
 if hit:
  fastener=bolt('Hull',point+normal*.012,.014);fastener.rotation_euler=normal.to_track_quat('Z','Y').to_euler()

# Lower hull, final drive rounded nose, rear belly.
box('Hull',(0,0,.88),(1.67,4.65,.70),b=.14)
ball('Hull',(0,-2.40,.88),(.96,.52,.46),seg=48,rings=24)
box('Hull',(0,2.47,.84),(1.72,.36,.64),b=.065)
# Two cast transmission joints running around the nose.
for x in [-.70,.70]:
 pts=[(x,-2.42-.495*math.cos(a),.89+.425*math.sin(a)) for a in np.linspace(-1.1,1.1,24)]
 tube('Hull',pts,.012,'TrackSteel')
 for a in np.linspace(-.8,.9,7):bolt('Hull',(x,-2.43-.50*math.cos(a),.89+.43*math.sin(a)),.016,'Y')
# Sheet-steel track fenders, with a rolled rim and bolts.
for side in [-1,1]:
 box('Hull',(side*1.075,.02,1.275),(.47,5.53,.055),b=.022)
 tube('Hull',[(side*1.305,-2.73,1.27),(side*1.305,2.78,1.27)],.012)
 for y in np.arange(-2.5,2.65,.31):bolt('Hull',(side*1.18,y,1.31),.013)
 for y in [-2.57,2.57]:box('Hull',(side*1.08,y,1.17),(.46,.35,.045),rot=(.25 if y<0 else -.3,0,0),b=.013)
# Cast turret ring and custom asymmetrical cast turret shell, with rear bustle.
cyl('Hull',(0,-.20,1.93),.88,.085,verts=64)
torus('Turret',(0,-.20,1.99),.855,.035,majorseg=64)
rings=[(1.965,.76,.86,0),(2.04,.88,.97,.02),(2.26,.93,1.0,.05),(2.43,.86,.92,.07),(2.50,.69,.75,.10)]
vs=[];fs=[];N=64
for z,rx,ry,cy in rings:
 for i in range(N):
  a=2*math.pi*i/N;x=rx*math.cos(a);y=-.20+cy+ry*math.sin(a)
  # Flatten the front cheek, retain the rounded rear bustle.
  if y<-.99:y=-.99+(y+.99)*.48
  vs.append((x,y,z))
for j in range(len(rings)-1):
 for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,(j+1)*N+(i+1)%N,(j+1)*N+i))
fs += [tuple(reversed(range(N))),tuple((len(rings)-1)*N+i for i in range(N))]
o=mesh('Turret',vs,fs);turret_shell=o;mod=o.modifiers.new('Cast turret curvature','SUBSURF');mod.levels=2
box('Turret',(0,.70,2.27),(1.33,.65,.33),b=.14)
# Cast side pistol ports, hinges and lifting lugs.
for side in [-1,1]:
 box('Turret',(side*.875,.45,2.25),(.05,.23,.235),b=.027)
 handle('Turret',(side*.910,.45,2.35),.105,.023,axis='X')
 cyl('Turret',(side*.905,.45,2.365),.019,.14,'TrackSteel','Y',12)
 torus('Turret',(side*.60,-.77,2.43),.044,.014,'Paint','Y',24)
# Mantlet cast shield, trunnion collars and actual open bore.
box('Gun',(0,-1.065,2.205),(.89,.31,.48),b=.105)
for x in [-.39,.39]:cyl('Gun',(x,-1.08,2.205),.195,.08,'Paint','X',40)
cyl('Gun',(0,-1.30,2.205),.145,.27,'Paint','Y',48)
cyl('Gun',(0,-1.47,2.205),.101,.20,'Paint','Y',48)
# barrel rings along Y; tip includes an inner bore and a deep dark cap
ys=[(-1.50,.092),(-1.60,.078),(-2.80,.056),(-2.92,.056),(-2.92,.0375),(-2.76,.0375)]
vs=[];fs=[];N=48
for y,r in ys:
 for i in range(N):
  a=2*math.pi*i/N;vs.append((r*math.cos(a),y,2.205+r*math.sin(a)))
for j in range(len(ys)-1):
 for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,(j+1)*N+(i+1)%N,(j+1)*N+i))
mesh('Gun',vs,fs,'WeaponSteel');cyl('Gun',(0,-2.755,2.205),.0375,.008,'Rubber','Y',32)
for x in [-.32,.32]:
 for z in [2.10,2.31]:bolt('Gun',(x,-1.229,z),.014,'Y')
cyl('Gun',(.29,-1.24,2.25),.032,.03,'WeaponSteel','Y',20)
# Driver hatches raised into the cast deck, vision blocks, handles and hinges.
for x in [-.55,.55]:
 ball('Hull',(x,-1.74,1.74),(.33,.43,.19),seg=40,rings=20)
 cyl('Hull',(x,-1.58,1.925),.245,.04,verts=40)
 torus('Hull',(x,-1.58,1.945),.226,.009,'TrackSteel')
 handle('Hull',(x,-1.63,1.97),.13,.035)
 box('Hull',(x,-1.74,1.98),(.17,.105,.085),b=.018)
 box('Hull',(x,-1.798,1.986),(.125,.013,.042),'Glass',.004)
 for xx in [-.15,.15]:cyl('Hull',(x+xx,-1.38,1.949),.025,.10,'Paint','X',12)
# Bow machine gun cast ball and short jacket.
ball('Hull',(.48,-2.46,1.61),(.18,.115,.15),seg=40,rings=20)
cyl('Hull',(.48,-2.59,1.61),.055,.17,'WeaponSteel','Y',20)
cyl('Hull',(.48,-2.69,1.61),.025,.11,'WeaponSteel','Y',16)
# Front lamps with guard cages, towing eyes.
for side in [-1,1]:
 x=side*.95
 before_lamps=len(GROUPS['Hull'])
 rod('Hull',(x,-2.21,1.47),(x,-2.22,1.64),.03)
 cyl('Hull',(x,-2.24,1.64),.077,.092,'Paint','Y',32)
 cyl('Hull',(x,-2.292,1.64),.059,.009,'Lamps','Y',32)
 pts=[(x+.105*math.cos(a),-2.30,1.64+.13*math.sin(a)) for a in np.linspace(0,math.pi,18)]
 tube('Hull',[(x+.105,-2.29,1.53)]+pts+[(x-.105,-2.29,1.53)],.012)
 tube('Hull',[(x,-2.19,1.54),(x,-2.18,1.78),(x,-2.30,1.78),(x,-2.31,1.60)],.011)
 for ob in GROUPS['Hull'][before_lamps:]:ob.location.y-=.14;ob.location.z+=.07
 torus('Hull',(side*.67,-2.88,.70),.07,.021,'TrackSteel','Y',24)
 box('Hull',(side*.67,-2.82,.70),(.08,.10,.14),b=.02)
# Cupola, hinged commander's hatch, sight and periscopes.
cupola=cyl('Turret',(.27,.14,2.52),.34,.09,verts=48)
# A real dark-lined hatch well, visible when the separate hatch opens.
bpy.ops.mesh.primitive_cylinder_add(vertices=64,radius=.270,depth=.70,location=(.27,.14,2.50))
hatch_cut=bpy.context.object;hatch_cut.name='Temporary hatch cutter'
hatch_cut.data.materials.append(MATS['Paint']);hatch_cut.data.materials.append(MATS['WeaponSteel'])
for face in hatch_cut.data.polygons:face.material_index=1
for target in [turret_shell,cupola]:
 target.data.materials.append(MATS['WeaponSteel'])
 mod=target.modifiers.new('Commander hatch well','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=hatch_cut
 apply(target)
bpy.data.objects.remove(hatch_cut,do_unlink=True)

torus('Turret',(.27,.14,2.56),.316,.019)
for a in np.linspace(0,2*math.pi,6,endpoint=False):
 x=.27+.30*math.cos(a);y=.14+.30*math.sin(a)
 box('Turret',(x,y,2.565),(.08,.07,.035),'Glass',.009,rot=(0,0,a))
cyl('Hatch_Commander',(.27,.14,2.594),.305,.045,verts=48)
handle('Hatch_Commander',(.27,.14,2.622),.14,.045)
for x in [.13,.41]:cyl('Hatch_Commander',(x,.415,2.599),.025,.09,'Paint','X',12)
box('Turret',(-.33,-.44,2.51),(.20,.13,.075),b=.025)
box('Turret',(-.33,-.51,2.52),(.13,.017,.033),'Glass',.004)
for x,y,z in [(-.70,-.6,2.38),(.70,-.6,2.38),(0,.88,2.40)]:
 torus('Turret',(x,y,z),.047,.013,'Paint','Y',24)
# Pistol port with hinge, welded roof fittings and aerial.
box('Turret',(-.895,.17,2.22),(.06,.31,.28),b=.03)
handle('Turret',(-.94,.17,2.29),.13,.04,'X')
cyl('Turret',(-.62,.62,2.47),.045,.12,verts=20)
rod('Turret',(-.62,.62,2.49),(-.62,.62,2.74),.005,'WeaponSteel',8)
# Browning-style roof MG: low travelling mount, separate pivoted assembly.
cyl('Turret',(-.43,.23,2.52),.036,.17,'WeaponSteel',verts=16)
box('MG',(-.43,.03,2.655),(.095,.32,.09),'WeaponSteel',.012)
cyl('MG',(-.43,-.32,2.655),.028,.41,'WeaponSteel','Y',20)
cyl('MG',(-.43,-.62,2.655),.014,.19,'WeaponSteel','Y',16)
box('MG',(-.525,.065,2.64),(.10,.16,.15),'WeaponSteel',.014)
rod('MG',(-.475,.21,2.655),(-.475,.26,2.60),.011,'Wood',10)
rod('MG',(-.385,.21,2.655),(-.385,.26,2.60),.011,'Wood',10)
for y in [-.16,-.22,-.28,-.34,-.40]:
 for x in [-.459,-.401]:ball('MG',(x,y,2.657),(.003,.012,.011),'Rubber',12,8)
box('MG',(-.43,-.02,2.714),(.012,.03,.052),'WeaponSteel',.004)
# Rear engine deck: recessed cooling grilles and frames.
box('Hull',(0,1.72,1.793),(1.39,1.16,.055),'TrackSteel',.015)
for x in [-.72,.72]:box('Hull',(x,1.72,1.827),(.045,1.22,.037),b=.008)
for y in [1.12,2.32]:box('Hull',(0,y,1.827),(1.47,.045,.037),b=.008)
for y in np.arange(1.17,2.28,.055):box('Hull',(0,y,1.823),(1.38,.021,.032),b=.005)
for x in np.arange(-.65,.7,.115):box('Hull',(x,1.72,1.843),(.012,1.12,.016),'TrackSteel',.003)
for x in [-.96,.96]:
 cyl('Hull',(x,2.26,1.756),.11,.045,verts=32);handle('Hull',(x,2.26,1.78),.08,.025)
box('Hull',(0,2.79,1.30),(1.05,.20,.32),b=.025)
for x in [-.48,.48]:box('Hull',(x,2.91,1.3),(.035,.025,.36),'TrackSteel',.005)
for side in [-1,1]:
 box('Hull',(side*.89,2.79,1.34),(.30,.20,.27),b=.025)
 for z in [1.28,1.35,1.42]:box('Hull',(side*.89,2.903,z),(.22,.012,.025),'Rubber',.004)
 torus('Hull',(side*.65,2.89,.79),.063,.018,'TrackSteel','Y',24)
 cyl('Hull',(side*.52,2.68,.79),.09,.23,'WeaponSteel','Z',24)
# Tow rope following cast shoulders with retainers.
for side in [-1,1]:
 pts=[(side*.77,-2.28,1.61),(side*.90,-1.88,1.84),(side*1.02,-1.13,1.93),(side*1.035,.45,1.88),(side*1.02,1.7,1.78),(side*.93,2.47,1.67)]
 tube('Hull',pts,.017,'TrackSteel')
 for y,z in [(-1.1,1.935),(.5,1.88),(1.68,1.79)]:box('Hull',(side*1.033,y,z),(.07,.055,.04),b=.009)
 for p in [pts[0],pts[-1]]:torus('Hull',p,.044,.012,'TrackSteel','Z',20)
# Onboard pioneer tools with wood handles, metal heads and clamps.
for side in [-1,1]:
 x=side*1.245
 # shovel horizontal along side, spade blade at rear
 rod('Hull',(x,-.20,1.52),(x,1.27,1.52),.022,'Wood',12)
 profile=[(1.14,1.57),(1.23,1.65),(1.39,1.65),(1.52,1.56),(1.53,1.50),(1.39,1.39),(1.23,1.39),(1.14,1.47)]
 vs=[(x+dx,y,z) for dx in [-.015,.015] for y,z in profile];n=len(profile)
 shovel=mesh('Hull',vs,[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],'WeaponSteel');bevel(shovel,.011,3)
 torus('Hull',(x,-.31,1.52),.066,.012,'WeaponSteel','X',24)
 # axe above shovel
 rod('Hull',(x,-.22,1.72),(x,.75,1.72),.023,'Wood',12)
 profile=[(.74,1.75),(.83,1.78),(.87,1.89),(.94,1.89),(.98,1.59),(.90,1.57),(.82,1.68),(.74,1.69)]
 vs=[(x+dx,y,z) for dx in [-.025,.025] for y,z in profile];n=len(profile)
 axe=mesh('Hull',vs,[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],'WeaponSteel');bevel(axe,.009,3)
 # crowbar and straps
 rod('Hull',(x,-.02,1.61),(x,.67,1.61),.012,'WeaponSteel',10)
 for y in [.03,.61]:
  box('Hull',(x+side*.025,y,1.52),(.025,.035,.07),'TrackSteel',.005)
  box('Hull',(x+side*.025,y,1.72),(.025,.035,.07),'TrackSteel',.005)
 # applique side plate with slightly radiused corners
 box('Hull',(side*1.238,-.91,1.57),(.035,.62,.36),b=.027)
# Sandbags: irregular stitched sacks, pinched perimeter, tension wrinkles in geometry.
def bag(g,loc,scale,angle=0):
 o=ball(g,loc,scale,'Canvas',40,20);o.rotation_euler[2]=angle
 for v in o.data.vertices:
  q=v.co;nx=q.x/scale[0];ny=q.y/scale[1];nz=q.z/scale[2]
  q.x=scale[0]*math.copysign(abs(nx)**.70,nx);q.y=scale[1]*math.copysign(abs(ny)**.70,ny);q.z=scale[2]*math.copysign(abs(nz)**.85,nz)
  edge=max(abs(nx),abs(ny));q.z += .009*math.sin(nx*21+ny*9)*math.sin(ny*17)*max(0,edge-.2)
  q.x += .008*math.sin(ny*17+nz*7);q.y+=.008*math.sin(nx*15+nz*8)
 # Filled canvas gathers at its stitched ends and under the retaining tie.
  if scale[2]>.18:
   q.x+=math.copysign(1,nx)*(.011*math.sin(nz*28+ny*5)*abs(ny)**2-.013*math.exp(-(ny/.12)**2)*abs(nx))
  else:q.z+=.013*math.sin(ny*32+nx*6)*abs(nx)**2
 # cloth seam along the sack's equator
 pts=[]
 for a in np.linspace(0,2*math.pi,45):
  xx=scale[0]*math.copysign(abs(math.cos(a))**.70,math.cos(a));yy=scale[1]*math.copysign(abs(math.sin(a))**.70,math.sin(a))
  pts.append((loc[0]+xx*math.cos(angle)-yy*math.sin(angle),loc[1]+xx*math.sin(angle)+yy*math.cos(angle),loc[2]+.002))
 tube(g,pts,.005,'Canvas')
 return o
for side in [-1,1]:
 for j,(yy,zz) in enumerate([(1.99,1.82),(2.40,1.76),(2.19,1.965)]):bag('Hull',(side*(.64+random.uniform(-.035,.035)),yy,zz),(.34,.245,.115),side*(.13-j*.2))
 # turret side sandbags, vertical stack tied to the side
 for y in [-.35,.08]:bag('Turret',(side*.91,y,2.28),(.15,.245,.22),.10)
 for y in [-.35,.08]:
  tube('Turret',[(side*.91,y,2.485),(side*1.025,y,2.447),(side*1.065,y,2.32),(side*1.052,y,2.17),(side*.94,y,2.07),(side*.86,y,2.12)],.006,'Canvas')
# Track belts: continuously indexed UV V coordinates follow belt travel.
# Path runs bottom rearward, around idler, top forward and around sprocket.
path=[]
path.extend([(-1.98,.049),(1.98,.049),(2.58,.35)])
for a in np.linspace(-.70,math.pi/2,20):path.append((2.31+.50*math.cos(a),.70+.50*math.sin(a)))
path.append((-2.31,1.20))
for a in np.linspace(math.pi/2,3.85,22):path.append((-2.31+.50*math.cos(a),.70+.50*math.sin(a)))
path.append((-1.98,.049))
lengths=[0]
for a,b in zip(path,path[1:]):lengths.append(lengths[-1]+math.dist(a,b))
length=lengths[-1];count=90;pitch=length/count

def sample(t):
 t=t%length
 for j in range(len(lengths)-1):
  if lengths[j]<=t<=lengths[j+1]:
   d=lengths[j+1]-lengths[j];f=(t-lengths[j])/d;a,b=path[j],path[j+1]
   return (a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f),math.atan2(b[1]-a[1],b[0]-a[0])
for side,label in [(1,'L'),(-1,'R')]:
 g='Track_'+label;x=side*1.08
 for i in range(count):
  (y,z),a=sample(i*pitch);before=len(GROUPS.get(g,[]))
  box(g,(x,y,z),(.46,pitch*.94,.072),'TrackSteel',.007,rot=(a,0,0))
  # Flat rubber shoe, steel double chevron catches grazing light.
  def world(xx,yy,zz):return (x+xx,y+yy*math.cos(a)-zz*math.sin(a),z+yy*math.sin(a)+zz*math.cos(a))
  box(g,world(0,0,-.041),(.355,pitch*.68,.022),'Rubber',.006,rot=(a,0,0))
  for ss in [-1,1]:
   rod(g,world(ss*.178,-pitch*.20,-.056),world(0,pitch*.17,-.056),.014,'TrackSteel',6)
  for xx in [-.214,.214]:cyl(g,world(xx,pitch*.46,0),.027,.048,'TrackSteel','X',10)
  # inward guide horn
  o=box(g,world(0,0,.062),(.055,.045,.055),'TrackSteel',.010,rot=(a,0,0))
  for ob in GROUPS[g][before:]:
   ob['track_v']=i*pitch/.40;ob['track_center']=(x,y,z);ob['track_tangent']=(0,math.cos(a),math.sin(a))
 # Individually articulated road wheels; X axis, front to back including drive and idler.
 wheel_positions=[(-2.36,.75,.39,'drive')]+[(y,.35,.27,'road') for y in [-1.75,-1.05,-.35,.35,1.05,1.75]]+[(2.36,.75,.405,'idler')]
 for idx,(y,z,r,kind) in enumerate(wheel_positions,1):
  w=f'Wheel_{label}{idx}';outer=side*1.265
  cyl(w,(x,y,z),r,.25,'Rubber' if kind=='road' else 'TrackSteel','X',48)
  cyl(w,(side*1.215,y,z),r*(.79 if kind=='road' else .82),.11,'TrackSteel','X',48)
  torus(w,(outer,y,z),r*.78,.018,'TrackSteel','X',40)
  cyl(w,(side*1.28,y,z),r*.28,.045,'Paint','X',32)
  cyl(w,(side*1.307,y,z),r*.12,.012,'TrackSteel','X',20)
  for a in np.linspace(0,2*math.pi,6 if kind=='road' else 8,endpoint=False):
   # radial stamped stiffeners, not solid disks
   p=(side*1.275,y+math.cos(a)*r*.32,z+math.sin(a)*r*.32)
   q=(side*1.275,y+math.cos(a)*r*.70,z+math.sin(a)*r*.70)
   rod(w,p,q,.018,'TrackSteel',8)
   bolt(w,(side*1.309,y+math.cos(a)*r*.19,z+math.sin(a)*r*.19),.011,'X','TrackSteel')
  if kind=='drive':
   for a in np.linspace(0,2*math.pi,18,endpoint=False):
    for xx in [side*.945,side*1.22]:
     box(w,(xx,y+math.cos(a)*(r+.004),z+math.sin(a)*(r+.004)),(.05,.093,.065),'TrackSteel',.01,rot=(a,0,0))
  GROUPS[w][0]['hub']=(x,y,z)
 # VVSS three bogies per side: triangular articulated arms and volute spring housing.
 for yc in [-1.40,0,1.40]:
  for dy in [-.35,.35]:
   rod('Hull',(side*1.30,yc+dy,.35),(side*1.30,yc,.46),.048,'Paint',12)
   rod('Hull',(side*1.21,yc+dy,.39),(side*1.21,yc+dy*.4,.76),.038,'Paint',12)
  # tapered cast housing with two proud vertical spring ribs
  vs=[(side*xx,yc+dy,z) for z,xx,dy in [( .43,1.15,-.16),(.43,1.15,.16),(.43,1.30,.16),(.43,1.30,-.16),(1.03,1.10,-.24),(1.03,1.10,.24),(1.03,1.29,.24),(1.03,1.29,-.24)]]
  o=mesh('Hull',vs,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],smooth=False);bevel(o,.028,3)
  for dy in [-.115,0,.115]:rod('Hull',(side*1.31,yc+dy,.47),(side*1.31,yc+dy,1.00),.023,'TrackSteel',10)
  for dy in [-.12,.12]:bolt('Hull',(side*1.325,yc+dy,.49),.021,'X')
  rod('Hull',(side*1.10,yc,1.00),(side*1.10,yc+.44,1.061),.045)
  cyl('Hull',(side*1.08,yc+.44,1.061),.103,.21,'Rubber','X',28)
  cyl('Hull',(side*1.21,yc+.44,1.061),.073,.03,'Paint','X',24)
  tube('Hull',[(side*1.09,yc-.33,1.18),(side*1.09,yc-.30,1.14),(side*1.09,yc+.30,1.14)],.016)
print('GEOMETRY COMPLETE',len(bpy.data.objects),flush=True)
# Resolve geometry, metric tiling UVs; each node combines rigid attached parts.
# Project by dominant polygon normal. On tracks local V is an unwrapped arclength.
bpy.context.view_layer.update()
deps=bpy.context.evaluated_depsgraph_get()
resolved={o:bpy.data.meshes.new_from_object(o.evaluated_get(deps),preserve_all_data_layers=True,depsgraph=deps) for objects in GROUPS.values() for o in objects}
for o,me in resolved.items():
 o.modifiers.clear();o.data=me
for g,objects in GROUPS.items():
 for o in objects:
  o.data.transform(o.matrix_basis.to_3x3().to_4x4());o.rotation_euler=(0,0,0);o.scale=(1,1,1)
  if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
  uv=o.data.uv_layers.active.data
  for p in o.data.polygons:
   axis=max(range(3),key=lambda k:abs(p.normal[k]))
   for li in p.loop_indices:
    co=o.data.vertices[o.data.loops[li].vertex_index].co
    if g.startswith('Track_'):
     # Pin/chevron each shares the link's along-belt V coordinate.
     along=(co+o.location-Vector(o['track_center'])).dot(Vector(o['track_tangent']))
     uv[li].uv=(co.x/.46+.5,float(o.get('track_v',0))+along/.40)
    elif axis==0:uv[li].uv=(co.y*2,co.z*2)
    elif axis==1:uv[li].uv=(co.x*2,co.z*2)
    else:uv[li].uv=(co.x*2,co.y*2)
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.select_set(True)
 bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();o=bpy.context.object;o.name=g
 # Recalculate outward normals (also fixes mirrored bogie housings).
 active(o);bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.mesh.normals_make_consistent(inside=False);bpy.ops.object.mode_set(mode='OBJECT')
 GROUPS[g]=o
# Place articulation origins in world space; all scales and rotations applied.
def origin(o,pos):
 S.cursor.location=pos;active(o);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
def parent(o,p):
 world=o.matrix_world.copy();o.parent=p;o.matrix_world=world
for name,o in GROUPS.items():
 if name.startswith('Wheel_'):pos=o.get('hub')
 elif name=='Turret':pos=(0,-.20,1.965)
 elif name=='Gun':pos=(0,-1.065,2.205)
 elif name=='Hatch_Commander':pos=(.27,.415,2.599)
 elif name=='MG':pos=(-.43,.23,2.52)
 else:pos=(0,0,0)
 origin(o,pos)
for name in ['Gun','MG','Hatch_Commander']:parent(GROUPS[name],GROUPS['Turret'])

def empty(name,loc,par=None):
 o=bpy.data.objects.new(name,None);S.collection.objects.link(o);o.location=loc
 if par:parent(o,GROUPS[par])
 return o
empty('Muzzle',(0,-2.92,2.205),'Gun')
empty('Seat_Commander',(.27,.14,2.64),'Turret')
empty('Seat_Driver',(-.55,-1.58,1.97),'Hull')
empty('Exhaust',(0,2.72,.83),'Hull')
empty('Light_Head_L',(.95,-2.435,1.71),'Hull');empty('Light_Head_R',(-.95,-2.435,1.71),'Hull')
asset=list(GROUPS.values())+[o for o in S.objects if o.type=='EMPTY']
# Count true triangle budget; reduce only if the detailed build exceeds the cap.
for o in GROUPS.values():o.data.calc_loop_triangles()
tri=sum(len(o.data.loop_triangles) for o in GROUPS.values())
if tri>157000:
 ratio=153000/tri
 for o in GROUPS.values():
  mod=o.modifiers.new('Game triangle budget','DECIMATE');mod.ratio=ratio;apply(o)
tri=sum(len(o.data.polygons) for o in GROUPS.values())
for o in GROUPS.values():o.data.calc_loop_triangles()
tri=sum(len(o.data.loop_triangles) for o in GROUPS.values())
# Remove collapsed bevel slivers before exporting actual triangles.
for o in GROUPS.values():
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
 bad=[f for f in bm.faces if f.calc_area()<1e-10]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
 bm.to_mesh(o.data);bm.free();o.data.update();o.data.calc_loop_triangles()
tri=sum(len(o.data.loop_triangles) for o in GROUPS.values())
# Enforce the exact delivery envelope and ground contact without residual scales.
bpy.context.view_layer.update()
raw_points=[o.matrix_world@v.co for o in GROUPS.values() for v in o.data.vertices]
raw_lo=Vector(tuple(min(p[i] for p in raw_points) for i in range(3)))
raw_hi=Vector(tuple(max(p[i] for p in raw_points) for i in range(3)))
scale=Vector((2.62/(raw_hi.x-raw_lo.x),5.84/(raw_hi.y-raw_lo.y),2.74/(raw_hi.z-raw_lo.z)))
centre=Vector(((raw_hi.x+raw_lo.x)/2,(raw_hi.y+raw_lo.y)/2,raw_lo.z))
def fit(p):
 # Cast M4A1 deck is lower than its tall turret; preserve the overall envelope.
 q=p.copy();z=q.z
 if 1.2<z<=1.94:q.z=1.2+(z-1.2)*(.57/.74)
 elif 1.94<z<2.5:q.z=1.77+(z-1.94)*(.73/.56)
 return Vector(tuple((q[i]-centre[i])*scale[i] for i in range(3)))
old_world={o:o.matrix_world.copy() for o in asset};old_parents={o:o.parent for o in asset}
for o in asset:
 o.parent=None
 pos=fit(old_world[o].translation)
 if o.type=='MESH':
  for v in o.data.vertices:v.co=fit(old_world[o]@v.co)-pos
 o.location=pos;o.rotation_euler=(0,0,0);o.scale=(1,1,1)
bpy.context.view_layer.update()
for o in asset:
 if old_parents[o]:parent(o,old_parents[o])
# Fixed rigid assemblies use the hull-footprint ground origin.
for name in ['Hull','Track_L','Track_R']:origin(GROUPS[name],(0,0,0))
print('TRIANGLES',tri,flush=True)
# Cycles contact occlusion baked to shared neutral vertex detail, portable as COLOR_0.
# This complements the small repeating texture sets without a large unique atlas.
S.cycles.samples=12
for name,o in GROUPS.items():
 active(o)
 col=o.data.color_attributes.new(name='ContactDetail',type='FLOAT_COLOR',domain='CORNER')
 o.data.color_attributes.active_color=col
 values=np.ones((len(col.data),4),dtype=np.float32)
 col.data.foreach_set('color',values.ravel())
 if name in ['Hull','Turret','Gun']:
  bpy.ops.object.bake(type='AO',target='VERTEX_COLORS')
  col.data.foreach_get('color',values.ravel())
  values[:,:3]=.55+.45*np.clip(values[:,:3],0,1)
  values[:,3]=1;col.data.foreach_set('color',values.ravel())
print('CONTACT BAKE COMPLETE',flush=True)
# Export standard GLB without procedural colour nodes. Then set exact standard factor.
pm=MATS['Paint'];nt=pm.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');base=nt.nodes['Baked Base'];nt.links.new(base.outputs[0],bs.inputs['Base Color'])
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
glb=ROOT/'sherman-tank.glb'
bpy.ops.export_scene.gltf(filepath=str(glb),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='JPEG',export_jpeg_quality=88,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_vertex_color='ACTIVE',export_all_vertex_colors=False)
data=glb.read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);tail=data[20+jl:]
for m in doc['materials']:
 if m['name']=='Paint':m.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=[*PAINTS[0][1],1]
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);glb.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(tail))+struct.pack('<II',len(j),0x4e4f534a)+j+tail)
nt.links.new(paint_mix.outputs[0],bs.inputs['Base Color'])
# Metadata in exported Y-up world coordinates.
bpy.context.view_layer.update()
def yup(p):return [round(float(p[0]),6),round(float(p[2]),6),round(float(-p[1]),6)]
points=[o.matrix_world@Vector(p) for o in GROUPS.values() for p in o.bound_box]
lo=[min(yup(p)[i] for p in points) for i in range(3)];hi=[max(yup(p)[i] for p in points) for i in range(3)]
meta={'asset':'Sherman cast-hull 75 mm','units':'metres','forward':'+Z','up':'+Y','left':'+X','triangles':tri,'bounds':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'nodes':[{'name':o.name,'world_position':yup(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type} for o in asset],'track_uv':'V increases continuously along track loop; 0.40 m per texture repeat','wheel_order':'1 front drive sprocket; 2 through 7 road wheels; 8 rear idler','poly_haven':IDS}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
(ROOT/'NOTES.md').write_text(f'Cast-hull WWII US M4 Sherman-style 75 mm tank, detailed VVSS, sandbags, pioneer tools, articulated nodes; metres, +Z forward.\n{tri:,} triangles; {glb.stat().st_size/1048576:.2f} MiB GLB.\n8 materials: Paint, TrackSteel, Rubber, Canvas, Wood, WeaponSteel, Lamps, Glass; neutral Paint bake with exact recolour factor.\nCycles-baked shared tiling base/normal up to 1024 px; packed ORM up to 512 px.\nPoly Haven CC0: {", ".join(IDS)}; https://polyhaven.com/license ; source maps from dl.polyhaven.org.\nReference differences: adds a roof Browning-style MG; aerial shortened to the 2.74 m envelope; no markings; cast detail and stowage placement reconstructed.\n')
print('EXPORT COMPLETE',glb.stat().st_size,flush=True)
# Use precisely the same neutral vertex multiplier for offline presentation.
for m in MATS.values():
 nt=m.node_tree;bs=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED')
 previous=bs.inputs['Base Color'].links[0].from_socket
 vc=nt.nodes.new('ShaderNodeVertexColor');vc.layer_name='ContactDetail'
 mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1
 nt.links.new(previous,mix.inputs[1]);nt.links.new(vc.outputs['Color'],mix.inputs[2]);nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
# Presentation only: excluded from GLB.
mat=bpy.data.materials.new('Studio ground');mat.diffuse_color=(.19,.205,.22,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.85
bpy.ops.mesh.primitive_plane_add(size=200);ground=bpy.context.object;ground.name='Studio ground';ground.data.materials.append(mat);ground.location.z=-.002
S.world=bpy.data.worlds.new('Soft daylight');S.world.use_nodes=True;S.world.node_tree.nodes['Background'].inputs[0].default_value=(.60,.68,.79,1);S.world.node_tree.nodes['Background'].inputs[1].default_value=.45
for name,loc,power,size,col in [('Key',(-4,-6,8),1700,6,(1,.91,.79)),('Fill',(5,-2,5),600,5,(.79,.87,1)),('Rim',(1,5,7),1800,4,(1,.95,.86))]:
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;d.color=col;o=bpy.data.objects.new(name,d);S.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
d=bpy.data.cameras.new('Presentation');cam=bpy.data.objects.new('Presentation',d);S.collection.objects.link(cam);S.camera=cam

def view(loc,target,ortho=8):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();d.type='ORTHO';d.ortho_scale=ortho

def render(name,w=1920,h=1080,samples=24):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);print('RENDER',name,flush=True)
view((7.7,-10,5.1),(0,-.10,1.21),8.0)
# Clay before accepting textured result.
clay=bpy.data.materials.new('Inspection clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.33,.34,.35,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
S.view_settings.exposure=-1.0;S.view_layers[0].material_override=clay;render('clay.png',1920,1080,16);S.view_layers[0].material_override=None;S.view_settings.exposure=-.35
render('final.png',1920,1080,32)
view((9,0,1.34),(0,0,1.34),6.6);render('side.png',1920,1080,20)
view((7.7,-10,5.1),(0,-.10,1.21),8.0)
# Assemble the six actual recolour renders into a 3x2 PNG without extra libraries.
canvas=np.ones((720,1920,4),dtype=np.float32)
for i,(name,col) in enumerate(PAINTS):
 paint_mix.inputs[2].default_value=(*col,1);render(f'paint-{i}.png',640,360,10)
 im=bpy.data.images.load(str(ROOT/f'paint-{i}.png'),check_existing=False);pixels=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4)
 row=1-i//3;column=i%3;canvas[row*360:(row+1)*360,column*640:(column+1)*640]=pixels;bpy.data.images.remove(im);(ROOT/f'paint-{i}.png').unlink()
im=bpy.data.images.new('Six paint variants',width=1920,height=720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
paint_mix.inputs[2].default_value=(*PAINTS[0][1],1)
print('PRESENTATION COMPLETE',flush=True)

# Validate the exported, embedded game asset in a fresh scene.
import bpy, math, json
from mathutils import Vector
from pathlib import Path
R=ROOT
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(R/'sherman-tank.glb'))
S=bpy.context.scene;S.render.engine='CYCLES';S.cycles.device='GPU';S.cycles.samples=16;S.cycles.use_denoising=True
p=bpy.context.preferences.addons['cycles'].preferences;p.compute_device_type='OPTIX';p.get_devices()
for d in p.devices:d.use=d.type=='OPTIX'
S.render.resolution_x=1280;S.render.resolution_y=720;S.render.resolution_percentage=100;S.view_settings.view_transform='AgX';S.view_settings.exposure=-.35
meshes=[o for o in S.objects if o.type=='MESH']
assert len(meshes)==23,(len(meshes),[o.name for o in meshes])
assert bpy.data.objects['Gun'].parent==bpy.data.objects['Turret']
assert bpy.data.objects['Muzzle'].parent==bpy.data.objects['Gun']
assert all(len(o.modifiers)==0 for o in meshes)
mat=bpy.data.materials.new('Studio');mat.diffuse_color=(.19,.205,.22,1);mat.use_nodes=True;bs=mat.node_tree.nodes.get('Principled BSDF');bs.inputs['Roughness'].default_value=.85
bpy.ops.mesh.primitive_plane_add(size=200);ground=bpy.context.object;ground.location.z=-.002;ground.data.materials.append(mat)
S.world=bpy.data.worlds.new('Soft daylight');S.world.use_nodes=True;S.world.node_tree.nodes['Background'].inputs[0].default_value=(.6,.68,.79,1);S.world.node_tree.nodes['Background'].inputs[1].default_value=.45
for name,loc,power,size,col in [('Key',(-4,-6,8),1700,6,(1,.91,.79)),('Fill',(5,-2,5),600,5,(.79,.87,1)),('Rim',(1,5,7),1800,4,(1,.95,.86))]:
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;d.color=col;o=bpy.data.objects.new(name,d);S.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
d=bpy.data.cameras.new('Check');cam=bpy.data.objects.new('Check',d);S.collection.objects.link(cam);S.camera=cam;cam.location=(7.7,-10,5.1);cam.rotation_euler=(Vector((0,-.1,1.21))-cam.location).to_track_quat('-Z','Y').to_euler();d.type='ORTHO';d.ortho_scale=8
S.render.filepath='/tmp/sherman-roundtrip.png';bpy.ops.render.render(write_still=True)
for name in ['Turret','Gun','Hatch_Commander']:bpy.data.objects[name].rotation_mode='XYZ'
bpy.data.objects['Turret'].rotation_euler.z=.45
bpy.data.objects['Gun'].rotation_euler.x=-.16
bpy.data.objects['Hatch_Commander'].rotation_euler.x=-.9
S.render.filepath='/tmp/sherman-articulation.png';bpy.ops.render.render(write_still=True)
print('ROUNDTRIP AND ARTICULATION CHECK COMPLETE')

print('BUILD FINISHED',flush=True)
