"""T-90 style game asset. Run in Blender 5.2 background; no input blend required.
CC0 scan sources are downloaded directly from Poly Haven. All shipping shaders
are Cycles-baked reusable tiles; the host Python Pillow installation encodes JPEG textures.
Budget: 80k-160k triangles, <=8 materials, GLB <=12 MB; 3 revision cap.
Cameras: front three-quarter, orthographic left side, clay, six paint variants.
Axes during authoring: X left, -Y forward, Z up; glTF exporter converts to Y up.
"""
import bpy, math, random, json, os, sys, struct, urllib.request, subprocess
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
random.seed(90)
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
PAINTS=[('Olive drab',[.12,.14,.08]),('NATO green',[.075,.12,.065]),('Desert tan',[.48,.36,.20]),('Field grey',[.19,.22,.19]),('Winter white',[.78,.80,.76]),('Dark green-black',[.027,.043,.026])]
PH_IDS=['painted_metal_shutter','rusty_metal_02','bark_brown_02','rubber_tiles']
bpy.ops.wm.read_factory_settings(use_empty=True)
scene=bpy.context.scene;scene.unit_settings.system='METRIC';scene.unit_settings.scale_length=1
scene.render.engine='CYCLES';scene.cycles.samples=16;scene.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
try:prefs.compute_device_type='OPTIX';prefs.get_devices()
except:prefs.compute_device_type='CUDA';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
assert any(d.use for d in prefs.devices),'GPU required'
scene.cycles.device='GPU';scene.render.bake.margin=8
scene.render.image_settings.file_format='PNG'
scene.view_settings.view_transform='AgX';scene.view_settings.look='AgX - Medium High Contrast'
print('GPU',[(d.name,d.type,d.use) for d in prefs.devices],flush=True)

# Acquire only CC0 photographic source maps, with a browser user agent for CDN.
def source(asset,kind):
    p=TEX/f'{asset}_{kind}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
        req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
        try:
            with urllib.request.urlopen(req,timeout=90) as r:p.write_bytes(r.read())
        except Exception:
            subprocess.run(['curl','--fail','-L','-A','Mozilla/5.0',url,'-o',str(p)],check=True)
    im=bpy.data.images.load(str(p),check_existing=True)
    if kind!='diff':im.colorspace_settings.name='Non-Color'
    return im
sources={a:{k:source(a,k) for k in ['diff','nor_gl','arm']} for a in PH_IDS}

# Bake seven material tiles, using aligned scan albedo/normal/ARM and procedural
# fine wear/grime. This retains physical texel density rather than one tank atlas.
def bake_material(name,asset,color,rough,metal,res=1024,neutral=False):
    m=bpy.data.materials.new(name+'_source');m.use_nodes=True
    nt=m.node_tree;n=nt.nodes;l=nt.links;n.clear()
    out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
    uv=n.new('ShaderNodeTexCoord')
    imgs={}
    for k in ['diff','nor_gl','arm']:
        q=n.new('ShaderNodeTexImage');q.image=sources[asset][k];l.new(uv.outputs['UV'],q.inputs['Vector']);imgs[k]=q
    bw=n.new('ShaderNodeRGBToBW');l.new(imgs['diff'].outputs['Color'],bw.inputs[0])
    ramp=n.new('ShaderNodeValToRGB');l.new(bw.outputs[0],ramp.inputs[0])
    if neutral:
        ramp.color_ramp.elements[0].position=.08;ramp.color_ramp.elements[0].color=(.62,.62,.62,1)
        ramp.color_ramp.elements[1].position=.72;ramp.color_ramp.elements[1].color=(.94,.94,.94,1)
    else:
        ramp.color_ramp.elements[0].color=tuple(c*.38 for c in color)+(1,)
        ramp.color_ramp.elements[1].color=tuple(min(1,c*1.8) for c in color)+(1,)
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=85;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
    mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.18;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 neutral:
        dirt=n.new('ShaderNodeTexNoise');dirt.inputs['Scale'].default_value=5;dirt.inputs['Detail'].default_value=5;dirt.inputs['Roughness'].default_value=.75;l.new(uv.outputs['UV'],dirt.inputs['Vector'])
        stains=n.new('ShaderNodeValToRGB');l.new(dirt.outputs['Fac'],stains.inputs[0])
        stains.color_ramp.elements[0].position=.28;stains.color_ramp.elements[0].color=(.12,.12,.12,1)
        stains.color_ramp.elements[1].position=.63;stains.color_ramp.elements[1].color=(1,1,1,1)
        stainmix=n.new('ShaderNodeMixRGB');stainmix.blend_type='MULTIPLY';stainmix.inputs[0].default_value=.72;l.new(mix.outputs[0],stainmix.inputs[1]);l.new(stains.outputs[0],stainmix.inputs[2]);l.new(stainmix.outputs[0],bs.inputs['Base Color'])
    if name=='Lamps':
        uvsep=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],uvsep.inputs[0]);white=n.new('ShaderNodeMath');white.operation='GREATER_THAN';white.inputs[1].default_value=.5;l.new(uvsep.outputs['X'],white.inputs[0])
        palette=n.new('ShaderNodeMixRGB');l.new(white.outputs[0],palette.inputs[0]);l.new(mix.outputs[0],palette.inputs[1]);palette.inputs[2].default_value=(.28,.34,.30,1);l.new(palette.outputs[0],bs.inputs['Base Color'])
    norm=n.new('ShaderNodeNormalMap');norm.inputs['Strength'].default_value=.28 if neutral else .5;l.new(imgs['nor_gl'].outputs['Color'],norm.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.14;bump.inputs['Distance'].default_value=.0015;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(norm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
    split=n.new('ShaderNodeSeparateColor');l.new(imgs['arm'].outputs[0],split.inputs[0])
    rr=n.new('ShaderNodeMapRange');rr.inputs['To Min'].default_value=max(.1,rough-.14);rr.inputs['To Max'].default_value=min(1,rough+.10);l.new(split.outputs['Green'],rr.inputs['Value']);l.new(rr.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
    bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-20));plane=bpy.context.object;plane.data.materials.append(m)
    target=n.new('ShaderNodeTexImage');n.active=target
    baked={}
    for kind in ['base','normal','orm']:
        size=min(res,512) if kind=='orm' else res
        im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
        if kind!='base':im.colorspace_settings.name='Non-Color'
        target.image=im;n.active=target
        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');combine=n.new('ShaderNodeCombineColor')
            ao=n.new('ShaderNodeMapRange');ao.inputs['To Min'].default_value=.78;ao.inputs['To Max'].default_value=1;l.new(split.outputs['Red'],ao.inputs['Value']);l.new(ao.outputs[0],combine.inputs['Red']);l.new(rr.outputs[0],combine.inputs['Green']);combine.inputs['Blue'].default_value=metal
            l.new(combine.outputs[0],em.inputs['Color']);l.new(em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
        im.filepath_raw=str(TEX/f'{name}_{kind}.png');im.file_format='PNG';im.save()
        if kind in ['base','normal']:
            jpg=TEX/f'{name}_{kind}.jpg'
            subprocess.run(['python3','-c',"from PIL import Image; import sys; Image.open(sys.argv[1]).convert('RGB').save(sys.argv[2],quality=int(sys.argv[3]),subsampling=0)",im.filepath_raw,str(jpg),'94' if kind=='normal' else '90'],check=True)
            im=bpy.data.images.load(str(jpg),check_existing=False)
            if kind=='normal':im.colorspace_settings.name='Non-Color'
        baked[kind]=im
    bpy.data.objects.remove(plane,do_unlink=True);bpy.data.materials.remove(m)
    mat=bpy.data.materials.new(name);mat.use_nodes=True;nt=mat.node_tree;n=nt.nodes;l=nt.links;bs=n.get('Principled BSDF')
    tex=n.new('ShaderNodeTexImage');tex.image=baked['base'];tex.label='Cycles baked CC0 scan + neutral grime' if neutral else 'Cycles baked CC0 scan + wear'
    if neutral:
        mul=n.new('ShaderNodeMixRGB');mul.name='PaintFactor';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=tuple(PAINTS[0][1])+(1,);l.new(tex.outputs[0],mul.inputs[1]);l.new(mul.outputs[0],bs.inputs['Base Color'])
    else:l.new(tex.outputs[0],bs.inputs['Base Color'])
    nm=n.new('ShaderNodeTexImage');nm.image=baked['normal'];normal=n.new('ShaderNodeNormalMap');l.new(nm.outputs[0],normal.inputs['Color']);l.new(normal.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['Green'],bs.inputs['Roughness']);l.new(sep.outputs['Blue'],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['Red'],gn.inputs['Occlusion'])
    bs.inputs['Coat Weight'].default_value=0
    print('BAKED',name,flush=True)
    return mat
mats={}
for args in [('Paint','rusty_metal_02',(1,1,1),.82,.04,1024,True),('Rubber','rubber_tiles',(.022,.025,.024),.91,0,1024,False),('Steel','rusty_metal_02',(.16,.175,.135),.59,.58,1024,False),('TrackSteel','rusty_metal_02',(.075,.068,.054),.75,.68,1024,False),('Wood','bark_brown_02',(.23,.12,.051),.94,0,1024,False),('Glass','painted_metal_shutter',(.014,.055,.075),.2,.28,256,False),('Lamps','painted_metal_shutter',(.20,.008,.011),.14,.40,256,False)]:
    mats[args[0]]=bake_material(*args)

# Geometry builders. All primitives are transformed before bevels and each
# mechanical assembly is consolidated into its explicitly named mesh node.
parts={};current='Hull'
def reg(o,mat):
    o.data.materials.append(mats[mat]);parts.setdefault(current,[]).append(o);return o
def active(o):
    bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o
def bevel(o,w=.01,seg=2):
    if w:
        mod=o.modifiers.new('Machined edge radii','BEVEL');mod.width=w;mod.segments=seg
        active(o);bpy.ops.object.modifier_apply(modifier=mod.name)
    return o
def box(label,loc,dim,mat='Paint',bev=.012,rot=None):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=label;
    for v in o.data.vertices:
        v.co.x*=dim[0];v.co.y*=dim[1];v.co.z*=dim[2]
    o.data.update()
    if rot:o.rotation_euler=rot
    reg(o,mat);bevel(o,bev,1 if bev<=.012 else 2);return o
def mesh(label,verts,faces,mat='Paint',bev=0,smooth=False):
    me=bpy.data.meshes.new(label);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(label,me);bpy.context.collection.objects.link(o);reg(o,mat)
    if smooth:
        for p in me.polygons:p.use_smooth=True
    bevel(o,bev);return o
def cyl(label,a,b,r,mat='Paint',vertices=24,r2=None):
    a=Vector(a);b=Vector(b);d=b-a
    bpy.ops.mesh.primitive_cone_add(vertices=vertices,radius1=r,radius2=r if r2 is None else r2,depth=d.length,location=(a+b)*.5)
    o=bpy.context.object;o.name=label;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();reg(o,mat)
    for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
    bevel(o,min(.006,r*.09),1 if r<.07 else 2);return o
def tube(label,points,r,mat='Steel',sides=8):
    verts=[]
    for i,p in enumerate(points):
        t=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(0,i-1)])
        t.normalize();n=t.cross(Vector((0,0,1)))
        if n.length<.01:n=t.cross(Vector((0,1,0)))
        n.normalize();b=t.cross(n)
        for j in range(sides):verts.append(Vector(p)+r*(n*math.cos(j*2*math.pi/sides)+b*math.sin(j*2*math.pi/sides)))
    faces=[]
    for i in range(len(points)-1):
        for j in range(sides):faces.append((i*sides+j,i*sides+(j+1)%sides,(i+1)*sides+(j+1)%sides,(i+1)*sides+j))
    faces.extend([tuple(reversed(range(sides))),tuple(range((len(points)-1)*sides,len(points)*sides))]);return mesh(label,verts,faces,mat,0,True)
def lathe_x(label,x,y,z,profile,mat='Paint',segments=48):
    vs=[(x+ax,y+r*math.cos(t*2*math.pi/segments),z+r*math.sin(t*2*math.pi/segments)) for ax,r in profile for t in range(segments)]
    fs=[]
    for k in range(len(profile)-1):
        for t in range(segments):fs.append((k*segments+t,k*segments+(t+1)%segments,(k+1)*segments+(t+1)%segments,(k+1)*segments+t))
    return mesh(label,vs,fs,mat,0,True)
def bolt(loc,axis='Z',r=.018,mat='Steel'):
    p=Vector(loc);d=Vector({'X':(1,0,0),'Y':(0,1,0),'Z':(0,0,1)}[axis])*.016
    return cyl('Hex fastener',p-d*.5,p+d*.5,r,mat,6)
def handle(p,width=.15,height=.035,axis='X',mat='Steel'):
    x,y,z=p
    if axis=='X':pts=[(x-width/2,y,z),(x-width/2,y,z+height),(x+width/2,y,z+height),(x+width/2,y,z)]
    else:pts=[(x,y-width/2,z),(x,y-width/2,z+height),(x,y+width/2,z+height),(x,y+width/2,z)]
    return tube('Grab handle',pts,.011,mat,8)
def panel_bolts(cx,cy,z,w,h):
    for dx in [-w/2+.045,w/2-.045]:
        for dy in [-h/2+.045,h/2-.045]:bolt((cx+dx,cy+dy,z+.007),'Z',.014)

# Welded hull: faceted bow, chamfered shoulders, narrow lower tub.
sections=[(-3.43,1.28,.71,1.02),(-3.10,1.53,.48,1.24),(-2.15,1.53,.43,1.37),(2.85,1.53,.44,1.37),(3.30,1.38,.60,1.22)]
vs=[]
for y,w,low,top in sections:vs.extend([(-w+.12,y,low),(-w,y,low+.15),(-w,y,top-.12),(-w+.14,y,top),(w-.14,y,top),(w,y,top-.12),(w,y,low+.15),(w-.12,y,low)])
fs=[]
for i in range(len(sections)-1):
    for j in range(8):fs.append((i*8+j,i*8+(j+1)%8,(i+1)*8+(j+1)%8,(i+1)*8+j))
fs.extend([tuple(reversed(range(8))),tuple(range(32,40))]);mesh('Armoured welded hull',vs,fs,'Paint',.025)
# Distinct inclined glacis with ERA tiles and seams.
for row,y in enumerate([-2.91,-2.48]):
    for x in [-.96,-.48,0,.48,.96]:
        z=1.265+(y+2.91)*.14
        box('Glacis applique armour',(x,y,z),(.46,.40,.065),'Paint',.012,(.14,0,0))
        panel_bolts(x,y,z+.04,.44,.37)
box('Driver hatch',(-.27,-1.84,1.405),(.64,.64,.055),'Paint',.055)
handle((-.27,-1.72,1.44),.21)
box('Driver periscope housing',(-.27,-2.15,1.45),(.30,.11,.10),'Paint',.012)
box('Driver periscope glass',(-.27,-2.21,1.46),(.23,.008,.058),'Glass',.004)
# Bow tow lugs, welded towing beam and guards.
cyl('Bow tow bar',(-1.28,-3.41,.91),(1.28,-3.41,.91),.044,'Steel',24)
for x in [-1.03,1.03]:
    box('Tow lug foundation',(x,-3.33,.79),(.20,.13,.20),'Paint',.025)
    pts=[(x-.07,-3.44,.8),(x-.08,-3.49,.64),(x+.08,-3.49,.64),(x+.07,-3.44,.8)]
    tube('Tow shackle',pts,.027,'Steel',12)
# Fenders, skirt panels, their hinges and front ERA sections.
for s in [-1,1]:
    box('Fender deck',(s*1.61,0,1.36),(.54,6.26,.10),'Paint',.025)
    # front fender angled nose
    box('Front mudguard',(s*1.62,-3.19,1.18),(.54,.50,.08),'Rubber',.018,(.34,0,0))
    for j in range(7):
        y=-2.65+j*.84
        box('Flexible rubber skirt',(s*1.815,y,1.08),(.068,.805,.50),'Rubber',.009)
        cyl('Skirt hinge',(s*1.855,y-.32,1.335),(s*1.855,y+.32,1.335),.022,'Steel',12)
        for yy in [y-.29,y+.29]:bolt((s*1.862,yy,1.28),'X',.019)
        if j<3:
            box('Side reactive armour',(s*1.855,y,1.12),(.07,.755,.44),'Paint',.013)
            for yy in [y-.31,y+.31]:
                for z in [.94,1.30]:bolt((s*1.898,yy,z),'X',.018)
    for y in [-2.15,-1.05,.05,1.15,2.25]:
        box('Fender storage chest',(s*1.61,y,1.47),(.47,.99,.17),'Paint',.025)
        box('Storage lid lip',(s*1.61,y,1.555),(.48,1.0,.025),'Paint',.007)
        handle((s*1.61,y,1.571),.17)
        for yy in [y-.38,y+.38]:box('Chest latch',(s*1.87,yy,1.49),(.024,.055,.09),'Steel',.004)
    # lamp at front, red Lamps material reserved for dazzlers, headlight uses Glass
    cyl('Headlamp casing',(s*1.30,-2.85,1.39),(s*1.30,-3.00,1.39),.095,'Paint')
    # Reflector bowl and proud bezel surrounding a shallow domed lens.
    vv=[];ff=[]
    for yy,rr in [(-3.004,.094),(-3.036,.094),(-3.037,.073),(-3.010,.073)]:
        for j in range(32):
            a=j*math.tau/32;vv.append((s*1.30+rr*math.cos(a),yy,1.39+rr*math.sin(a)))
    for k in range(4):
        for j in range(32):ff.append((k*32+j,k*32+(j+1)%32,((k+1)%4)*32+(j+1)%32,((k+1)%4)*32+j))
    mesh('Headlamp deep reflector bezel',vv,ff,'Steel',.002,True)
    vv=[(s*1.30,-3.032,1.39)];ff=[]
    for k in range(1,5):
        rr=.072*k/4;yy=-3.032+.016*(k/4)**2
        for j in range(32):a=j*math.tau/32;vv.append((s*1.30+rr*math.cos(a),yy,1.39+rr*math.sin(a)))
    for j in range(32):ff.append((0,1+j,1+(j+1)%32))
    for k in range(3):
        for j in range(32):ff.append((1+k*32+j,1+(k+1)*32+j,1+(k+1)*32+(j+1)%32,1+k*32+(j+1)%32))
    mesh('Domed recessed headlamp lens',vv,ff,'Lamps',0,True)['lamp_white']=True
    tube('Lamp guard',[(s*1.30-.13,-3.01,1.31),(s*1.30-.13,-3.03,1.52),(s*1.30+.13,-3.03,1.52),(s*1.30+.13,-3.01,1.31)],.013,'Steel')
# Rear engine deck: true recesses with separate grille bars, hinges, filler caps.
box('Engine deck',(0,2.13,1.405),(2.51,2.02,.07),'Paint',.018)
for x in [-.66,.66]:
    box('Engine grille recess',(x,2.14,1.446),(.98,1.38,.025),'Rubber',.008)
    for k in range(16):box('Engine cooling slat',(x,1.51+k*.083,1.472),(.95,.033,.028),'Steel',.006)
    for yy in [1.43,2.86]:box('Grille frame',(x,yy,1.469),(1.05,.045,.04),'Paint',.008)
    for xx in [x-.51,x+.51]:box('Grille side frame',(xx,2.14,1.468),(.035,1.47,.04),'Paint',.006)
for x in [-1.1,1.1]:
    cyl('Fuel filler',(x,2.92,1.46),(x,2.92,1.505),.104,'Paint');handle((x,2.92,1.51),.11,.018)
box('Rear exhaust dark opening',(-1.535,2.65,1.17),(.07,.62,.24),'Rubber',.012)
box('Exhaust top shield',(-1.59,2.65,1.305),(.19,.69,.055),'Steel',.009)
# Recovery timber: cylinder with irregular bark silhouette and real retaining straps.
log=cyl('Recovery log',(-1.68,3.265,1.07),(1.68,3.265,1.07),.147,'Wood',48)
for v in log.data.vertices:
    v.co.x*=1+random.uniform(-.018,.018);v.co.y*=1+random.uniform(-.018,.018)
for x in [-1.12,1.12]:
    tube('Log retaining strap',[(x,3.10,.91),(x,3.37,.94),(x,3.42,1.07),(x,3.37,1.19),(x,3.10,1.21)],.024,'Steel',8)
    box('Log bracket',(x,3.14,.93),(.11,.19,.09),'Paint',.012)
    for dx in [-.03,.03]:bolt((x+dx,3.245,.93),'Y',.014)
# Long tow cables follow the hull shoulders, with eyes.
for s in [-1,1]:
    pts=[(s*1.24,-2.97,1.28),(s*1.39,-2.45,1.43),(s*1.42,-1.5,1.44),(s*1.42,0,1.44),(s*1.42,1.55,1.44),(s*1.30,2.94,1.44)]
    tube('Braided tow cable',pts,.021,'Steel',10)
    for p in [pts[0],pts[-1]]:
        x,y,z=p;tube('Cable eye',[(x-.05,y,z),(x-.085,y+.12,z),(x,y+.20,z),(x+.085,y+.12,z),(x+.05,y,z)],.018,'Steel',10)

print('HULL_BUILT',sum(map(len,parts.values())),flush=True)
# Wheel assemblies: separate hubs, rounded rubber tyres, pressed wheel dishes,
# radial stiffeners, recessed pockets and exposed hub fasteners.
wheel_pivots={}
for side,s in [('L',1),('R',-1)]:
    ys=[-2.87,-2.13,-1.28,-.43,.42,1.27,2.12,2.86]
    for k,y in enumerate(ys):
        current=f'Wheel_{side}{k+1}';z=.71 if k in [0,7] else .49;r=.34 if k in [0,7] else .415;x=s*1.525
        wheel_pivots[current]=(x,y,z)
        # mirrored radial profile remains winding-consistent after normal recalculation
        profile=[(-.18,0),(-.18,r*.75),(-.165,r*.94),(-.12,r),(.12,r),(.165,r*.94),(.18,r*.75),(.18,0)]
        lathe_x('Rubber running tyre',x,y,z,profile,'Rubber',48)
        outward=x+s*.18
        prof=[(s*0,r*.78),(s*.025,r*.79),(s*.045,r*.71),(s*.05,r*.58),(s*.00,r*.32),(s*.035,r*.20)]
        lathe_x('Pressed steel wheel dish',outward,y,z,prof,'Paint',48)
        cyl('Hub boss',(outward,y,z),(outward+s*.095,y,z),r*.24,'Paint',32)
        cyl('Hub cap',(outward+s*.095,y,z),(outward+s*.111,y,z),r*.16,'Steel',24)
        count=6 if k not in [0,7] else 8
        for j in range(count):
            t=j*2*math.pi/count;dy=math.cos(t);dz=math.sin(t)
            cyl('Recessed wheel pocket',(outward+s*.036,y+dy*r*.51,z+dz*r*.51),(outward+s*.041,y+dy*r*.51,z+dz*r*.51),r*.115,'Rubber',12)
            bolt((outward+s*.073,y+dy*r*.28,z+dz*r*.28),'X',.017)
            # radial rib between recesses
            tt=t+math.pi/count
            o=box('Wheel dish rib',(outward+s*.043,y+math.cos(tt)*r*.52,z+math.sin(tt)*r*.52),(.018,r*.31,.045),'Paint',.005)
            o.rotation_euler.x=tt
        if k==7:
            for j in range(16):
                t=j*2*math.pi/16
                o=box('Drive sprocket tooth',(x,y+math.cos(t)*.345,z+math.sin(t)*.345),(.33,.073,.08),'Steel',.008);o.rotation_euler.x=t
print('WHEELS_BUILT',sum(map(len,parts.values())),flush=True)
# Closed track path sampled by arc length. Each link has tread bars, hinge pins
# and inward guide teeth. V follows increasing belt distance on every face.
for side,s in [('L',1),('R',-1)]:
    current=f'Track_{side}';x=s*1.45
    path=[]
    # path front top -> rear top -> rear wrap -> bottom -> front wrap
    for i in range(41):
        t=i/40;path.append((-2.87+5.73*t,1.065-.045*math.sin(math.pi*t)))
    for i in range(1,21):
        a=math.pi/2-i*math.pi/20;path.append((2.86+.355*math.cos(a),.71+.355*math.sin(a)))
    path.extend([(2.52,.20),(2.25,.073),(-2.24,.073),(-2.55,.21),(-2.87,.355)])
    for i in range(1,21):
        a=-math.pi/2-i*math.pi/20;path.append((-2.87+.355*math.cos(a),.71+.355*math.sin(a)))
    path.append(path[0]);lens=[0]
    for a,b in zip(path,path[1:]):lens.append(lens[-1]+math.dist(a,b))
    total=lens[-1];N=100
    def sample(t):
        t%=total
        for j in range(len(lens)-1):
            if lens[j+1]>=t:
                f=(t-lens[j])/(lens[j+1]-lens[j]);a=Vector(path[j]);b=Vector(path[j+1]);return a.lerp(b,f),(b-a).normalized()
    for i in range(N):
        p,d=sample((i+.5)*total/N);y,z=p;ang=math.atan2(d.y,d.x)
        o=box('Cast track shoe',(x,y,z),(.62,total/N*.91,.068),'TrackSteel',.007,(ang,0,0))
        # Project all link component UVs later with per-link local longitudinal V.
        o['belt_v']=(i+.5)*total/N;o['belt_ang']=ang;o['belt_center']=(x,y,z)
        for delta in [-.044,.044]:
            yy=y+math.cos(ang)*delta;zz=z+math.sin(ang)*delta
            o=box('Raised traction grouser',(x,yy-math.sin(ang)*.044,zz+math.cos(ang)*.044),(.60,.026,.026),'TrackSteel',.004,(ang,0,0))
            o['belt_v']=(i+.5)*total/N;o['belt_ang']=ang;o['belt_center']=(x,y,z)
        # steel pin heads on both exposed edges
        for edge in [-1,1]:
            o=cyl('Track hinge pin',(x+edge*.298,y,z),(x+edge*.327,y,z),.028,'Steel',10)
            o['belt_v']=(i+.5)*total/N;o['belt_ang']=ang;o['belt_center']=(x,y,z)
        if i%2==0:
            normal=Vector((0,math.sin(ang),-math.cos(ang)));along=Vector((0,math.cos(ang),math.sin(ang)));base=Vector((x,y,z))+normal*.038
            vv=[base+Vector((xx,0,0))+along*yy for xx,yy in [(-.035,-.045),(.035,-.045),(.035,.045),(-.035,.045)]]
            vv.extend([base+normal*.090+Vector((xx,0,0)) for xx in [-.016,.016]])
            o=mesh('Tapered inner track guide',vv,[(3,2,1,0),(0,1,5,4),(1,2,5),(2,3,4,5),(3,0,4)],'TrackSteel',.003);o['belt_v']=(i+.5)*total/N;o['belt_ang']=ang;o['belt_center']=(x,y,z)

print('TRACKS_BUILT',sum(map(len,parts.values())),flush=True)
# Low cast turret, a rounded asymmetric dome generated as concentric loft rings.
current='Turret';ring_y=-.25
rings=[(1.38,1.06,1.10),(1.44,1.29,1.27),(1.60,1.36,1.30),(1.79,1.16,1.15),(1.94,.85,.89),(1.995,.59,.65)]
vs=[];ns=64
for z,rx,ry in rings:
    for j in range(ns):
        a=j*2*math.pi/ns;x=rx*math.cos(a);y=ring_y+ry*math.sin(a)
        if y<-.75:y=-.75+(y+.75)*.84
        vs.append((x,y,z+random.uniform(-.003,.003)))
fs=[]
for i in range(len(rings)-1):
    for j in range(ns):fs.append((i*ns+j,i*ns+(j+1)%ns,(i+1)*ns+(j+1)%ns,(i+1)*ns+j))
fs.extend([tuple(reversed(range(ns))),tuple(range((len(rings)-1)*ns,len(rings)*ns))]);mesh('Cast turret shell',vs,fs,'Paint',.016,True)
cyl('Turret race',(0,ring_y,1.355),(0,ring_y,1.43),1.02,'Steel',64)
# ERA array: angled wedge blocks along the cheeks and flanks, with layered plates.
for s in [-1,1]:
    for k in range(4):
        a=math.radians(-66+k*26);x=s*(1.18*math.cos(a));y=ring_y+1.36*math.sin(a)
        # orientation: local Y outward radial; sloped face catches the broad light
        yaw=s*(a+math.pi/2)
        # use tangent/radial vertices for true wedge instead of simple cube armour
        tangent=Vector((math.cos(yaw),math.sin(yaw),0));radial=Vector((-math.sin(yaw),math.cos(yaw),0))
        if radial.dot(Vector((x,y-ring_y,0)))<0:radial=-radial
        center=Vector((x,y,1.65));vv=[]
        for zz,dd in [(-.17,-.08),(-.17,.39),(.20,.16),(.20,-.08)]:
            for u in [-.225,.225]:vv.append(center+tangent*u+radial*dd+Vector((0,0,zz)))
        oo=mesh('Kontakt ERA wedge',vv,[(0,1,3,2),(2,3,5,4),(4,5,7,6),(6,7,1,0),(0,2,4,6),(1,7,5,3)],'Paint',.012)
        # thin dividing strap runs down the front sloped face
        for u in [-.205,.205]:
            tube('ERA edge binding',[center+tangent*u+radial*.385+Vector((0,0,-.15)),center+tangent*u+radial*.16+Vector((0,0,.20))],.008,'Steel',6)
    # Upper cheek ERA plates follow the dome's slope and break up the casting.
    for k in range(3):
        a=math.radians(-62+k*28);xx=s*.89*math.cos(a);yy=ring_y+1.0*math.sin(a)
        box('ERA upper sloped tile',(xx,yy,1.889),(.38,.32,.095),'Paint',.012,(.19,-s*.26,s*(a+math.pi/2)))
        bolt((xx,yy,1.955),'Z',.014)
    # Turret flank stowage and long tool cases
    box('Turret stowage box',(s*1.19,.64,1.70),(.34,1.05,.34),'Paint',.045)
    box('Turret stowage lid',(s*1.19,.64,1.884),(.36,1.06,.038),'Paint',.009)
    handle((s*1.19,.64,1.914),.22)
    for y in [.29,.95]:box('Turret stowage latch',(s*1.371,y,1.72),(.026,.065,.13),'Steel',.004)
    # red IR dazzlers in ribbed square housings, facing forward
    box('Shtora armoured housing',(s*.67,-1.64,1.70),(.36,.34,.34),'Paint',.036)
    cyl('Dazzler bezel',(s*.67,-1.812,1.70),(s*.67,-1.836,1.70),.129,'Steel',40)
    cyl('IR ruby lens',(s*.67,-1.837,1.70),(s*.67,-1.841,1.70),.108,'Lamps',40)['lamp_red']=True
    for k in range(6):box('Dazzler cooling fin',(s*.67,-1.76+k*.04,1.877),(.28,.014,.016),'Steel',.003)
    for dx in [-.155,.155]:box('Dazzler protective side frame',(s*.67+dx,-1.85,1.70),(.026,.07,.33),'Paint',.005)
    for zz in [1.548,1.852]:box('Dazzler protective cross frame',(s*.67,-1.85,zz),(.31,.07,.026),'Paint',.005)
    # Smoke dischargers: two rows of three outward-canted real tubes
    for k in range(6):
        xx=s*(1.10+(k%3)*.075);yy=-.46+(k//3)*.18;zz=1.76-(k%3)*.095
        a=Vector((xx,yy,zz));b=a+Vector((s*.21,-.15,.20))
        cyl('Smoke launcher tube',a,b,.052,'Steel',20)
        cyl('Smoke launcher cap',b,b+(b-a).normalized()*.012,.046,'Rubber',20)
# Rear bustle cylinder and attachment brackets.
cyl('Turret rear stowage tube',(-.82,1.02,1.73),(.82,1.02,1.73),.14,'Paint',32)
for x in [-.65,.65]:box('Rear tube strap',(x,1.02,1.73),(.05,.30,.29),'Steel',.012)
# Cast-to-machined roof weld beads and rear bracket fasteners.
for x in [-.57,.54]:
    pts=[(x+.295*math.cos(j*math.tau/48),.15+.295*math.sin(j*math.tau/48),2.008+random.uniform(-.002,.002)) for j in range(49)]
    tube('Hatch coaming weld',pts,.008,'Steel',6)
# Roof plates, optics and sensors.
for x,y,w,h in [(-.57,-.04,.56,.63),(.54,.15,.56,.64),(.10,-.77,.45,.22)]:
    box('Roof welded plate',(x,y,1.995),(w,h,.04),'Paint',.018);panel_bolts(x,y,2.022,w,h)
for x in [-.25,.03,.31]:box('Roof ERA tile',(x,-.93,1.94),(.24,.20,.10),'Paint',.010,(.16,0,0))
box('Gunner optical hood',(-.49,-.68,2.036),(.23,.25,.23),'Paint',.012)
box('Gunner optical glass',(-.49,-.812,2.052),(.16,.012,.135),'Glass',.004)
for x,y in [(-.95,.44),(.97,.55)]:
    cyl('Antenna foot',(x,y,1.90),(x,y,2.03),.045,'Paint',20)
    cyl('Antenna spring',(x,y,2.03),(x,y,2.11),.016,'Steel',16)
    cyl('Short flexible antenna',(x,y,2.11),(x,y,2.23),.006,'Steel',8,r2=.002)
# Commander closed hatch as its own pivoting assembly.
cyl('Commander hatch coaming',(.54,.15,1.99),(.54,.15,2.042),.31,'Paint',48)
current='Hatch_Commander'
cyl('Commander hatch lid',(.54,.15,2.04),(.54,.15,2.084),.285,'Paint',48)
handle((.54,.10,2.09),.18,.027)
cyl('Hatch hinge',(.31,.405,2.07),(.77,.405,2.07),.032,'Steel',20)
current='Turret'
cyl('Loader hatch',(-.57,.15,2.00),(-.57,.15,2.052),.275,'Paint',48)
handle((-.57,.15,2.058),.17,.025)
# Commander periscope ring segments.
for i in range(5):
    a=math.radians(195+i*28);x=.54+.32*math.cos(a);y=.15+.32*math.sin(a)
    box('Commander periscope block',(x,y,2.066),(.11,.07,.075),'Paint',.008,(0,0,a))
    box('Commander periscope lens',(x,y,2.108),(.080,.045,.009),'Glass',.002,(0,0,a))
# Gun: mantlet, protective fabric bellows, concentric barrel sections and hollow bore.
current='Gun';gunz=1.67
cyl('Cast gun mantlet',(0,-1.02,gunz),(0,-1.64,gunz),.245,'Paint',48,r2=.19)
for j in range(6):
    y=-1.34-j*.044;cyl('Mantlet boot rib',(0,y,gunz),(0,y-.025,gunz),.212-j*.006,'Rubber',40)
# gun forward overall tip at -6.10, back hull to +3.43 =9.53
for y1,y2,r in [(-1.61,-2.15,.122),(-2.15,-3.37,.114),(-3.37,-4.18,.143),(-4.18,-4.88,.102),(-4.88,-5.76,.091),(-5.76,-5.92,.083)]:
    cyl('125 mm barrel sleeve',(0,y1,gunz),(0,y2,gunz),r,'Steel',48,r2=r*.97)
for y,r in [(-1.77,.135),(-2.16,.125),(-3.36,.13),(-3.42,.151),(-4.15,.151),(-4.23,.114),(-4.87,.109),(-5.74,.099)]:
    cyl('Thermal shroud clamp',(0,y-.017,gunz),(0,y+.017,gunz),r,'Steel',40)
# Hollow muzzle sleeve ring (no solid disk at front).
vs=[];fs=[]
for y,r in [(-6.10,.083),(-6.105,.064),(-5.92,.064),(-5.92,.081)]:
    for j in range(48):a=j*math.tau/48;vs.append((r*math.cos(a),y,gunz+r*math.sin(a)))
for k in range(4):
    for j in range(48):fs.append((k*48+j,k*48+(j+1)%48,((k+1)%4)*48+(j+1)%48,((k+1)%4)*48+j))
mesh('Muzzle bore steel ring',vs,fs,'Steel',0,True)
cyl('Dark bore interior',(0,-5.915,gunz),(0,-5.92,gunz),.063,'Rubber',32)
# Roof NSVT style machine gun, separate articulated mesh; compact stowed cradle.
current='MG'
box('MG pedestal',(.54,.29,2.12),(.17,.19,.15),'Paint',.010)
box('Machine gun receiver',(.54,-.01,2.18),(.115,.45,.095),'Steel',.009)
cyl('Machine gun barrel',(.54,-.22,2.19),(.54,-1.05,2.19),.018,'Steel',20)
cyl('MG flash hider',(.54,-1.05,2.19),(.54,-1.14,2.19),.027,'Steel',20)
box('Ammunition box',(.73,.03,2.16),(.23,.28,.13),'Paint',.012)
box('Ammo lid',(.73,.03,2.231-.012),(.24,.29,.02),'Paint',.004)
tube('MG cradle',[(.43,.20,2.07),(.43,.20,2.20),(.64,.20,2.20),(.64,.20,2.07)],.014,'Steel')

print('ALL_PARTS_BUILT',sum(map(len,parts.values())),flush=True)
# Recalculate normals, assign shared tiled UVs, preserve longitudinal belt mapping,
# then join each assembly and set its true articulation origin.
import bmesh
origins={'Hull':(0,0,0),'Turret':(0,ring_y,1.39),'Gun':(0,-1.07,gunz),'Hatch_Commander':(.54,.405,2.07),'MG':(.54,.29,2.085),'Track_L':(0,0,0),'Track_R':(0,0,0),**wheel_pivots}
objects={}
for name,items in parts.items():
    print('ASSEMBLY',name,len(items),flush=True)
    for o in items:
        o.data.transform(o.matrix_basis.to_3x3().to_4x4());o.rotation_euler=(0,0,0);o.scale=(1,1,1)
        bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=0.000001);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
        uv=o.data.uv_layers.active or o.data.uv_layers.new(name='UVMap')
        for p in o.data.polygons:
            n=p.normal;axis=max(range(3),key=lambda i:abs(n[i]));coords=[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
                if 'belt_v' in o:
                    w=o.matrix_world@v;c=o['belt_center'];a=o['belt_ang'];uv.data[li].uv=((w.x-c[0])/.65+.5,(o['belt_v']+(w.y-c[1])*math.cos(a)+(w.z-c[2])*math.sin(a))/.65)
                else:uv.data[li].uv=(v[coords[0]]*.85+o.location[coords[0]]*.137,v[coords[1]]*.85+o.location[coords[1]]*.137)
                if 'lamp_white' in o:uv.data[li].uv.x=.62+.30*(uv.data[li].uv.x%1)
                elif 'lamp_red' in o:uv.data[li].uv.x=.06+.30*(uv.data[li].uv.x%1)
    bpy.ops.object.select_all(action='DESELECT')
    for o in items:o.select_set(True)
    bpy.context.view_layer.objects.active=items[0];bpy.ops.object.join();o=items[0];o.name=name
    scene.cursor.location=origins[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR');objects[name]=o
# Join leaves unused slots; prune while keeping seven global materials.
for name,o in objects.items():
    active(o)
    bpy.ops.object.material_slot_remove_unused()
    # Apply final triangulation, making file/manifest triangle counts identical.
    tri=o.modifiers.new('Shipping triangulation','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
for child,parent in [('Gun','Turret'),('Hatch_Commander','Turret'),('MG','Turret')]:
    o=objects[child];w=o.matrix_world.copy();o.parent=objects[parent];o.matrix_world=w
sockets={'Muzzle':((0,-6.105,gunz),'Gun'),'Seat_Commander':((.54,.15,2.12),'Turret'),'Seat_Driver':((-.27,-1.84,1.49),'Hull'),'Exhaust':((-1.60,2.65,1.19),'Hull'),'Light_Head_L':((1.30,-3.02,1.39),'Hull'),'Light_Head_R':((-1.30,-3.02,1.39),'Hull')}
for name,(pos,par) in sockets.items():
    o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=pos;w=o.matrix_world.copy();bpy.context.view_layer.update();w=o.matrix_world.copy();o.parent=objects[par];o.matrix_world=w;objects[name]=o
scene.cursor.location=(0,0,0);bpy.context.view_layer.update()
# Exact contract: width 3.78, overall length 9.53; ground contact zero. Hull
# footprint centre stays at 0, and one global correction transforms pivots too.
meshobjects=[o for o in objects.values() if o.type=='MESH']
pts=[o.matrix_world@v.co for o in meshobjects for v in o.data.vertices]
lo=Vector([min(v[i] for v in pts) for i in range(3)]);hi=Vector([max(v[i] for v in pts) for i in range(3)])
# Ground is defined by bottom faces of track shoes; maintain all wheel contacts.
scale=Vector((3.78/(hi.x-lo.x),9.53/(hi.y-lo.y),2.23/(hi.z-lo.z)))
worlds={o.name:o.matrix_world.copy() for o in objects.values()}
S=Matrix.Diagonal((scale.x,scale.y,scale.z,1))
for o in objects.values():
    if o.type=='MESH':o.data.transform(S)
    old=worlds[o.name].translation
    new=Vector((old.x*scale.x,old.y*scale.y,(old.z-lo.z)*scale.z))
    o.matrix_world=Matrix.Translation(new)
    bpy.context.view_layer.update()
# Static hull/track pivots must be exactly on the world ground origin.
saved_socket_worlds={o.name:o.matrix_world.copy() for o in objects.values() if o.type=='EMPTY'}
for name in ['Hull','Track_L','Track_R']:
    active(objects[name]);scene.cursor.location=(0,0,0);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
for name,w in saved_socket_worlds.items():objects[name].matrix_world=w
bpy.context.view_layer.update()
# Weapon components retain their own material under paint variants.
for o in [objects['MG']]:
    for slot in o.material_slots:
        if slot.material==mats['Paint']:slot.material=mats['Steel']
# Save standalone clean source state for diagnostics; delivery is generated here.
tris=sum(len(o.data.polygons) for o in meshobjects)
if tris>160000:
    ratio=149000/tris
    for o in meshobjects:
        active(o);dec=o.modifiers.new('Budget preserving reduction','DECIMATE');dec.ratio=ratio;dec.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=dec.name)
        tri=o.modifiers.new('Final triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tri.name)
    tris=sum(len(o.data.polygons) for o in meshobjects)
assert 80000 <= tris <= 160000, f'Triangle budget exceeded: {tris}'
# The fixed hull including its fittings is precisely 6.86 m, centred at zero.
# Keep the gun at 9.53 m overall after this small fabrication-envelope correction.
hull=objects['Hull'];yy=[(hull.matrix_world@v.co).y for v in hull.data.vertices]
hull_center=(min(yy)+max(yy))*.5;hull_ratio=6.86/(max(yy)-min(yy))
for v in hull.data.vertices:v.co.y=(v.co.y-hull_center)*hull_ratio
for name in ['Seat_Driver','Exhaust','Light_Head_L','Light_Head_R']:
    objects[name].location.y=(objects[name].location.y-hull_center)*hull_ratio
bpy.context.view_layer.update()
gun=objects['Gun'];pivot_y=gun.matrix_world.translation.y;tip_y=min((gun.matrix_world@v.co).y for v in gun.data.vertices)
gun_ratio=(-6.10-pivot_y)/(tip_y-pivot_y)
for v in gun.data.vertices:v.co.y*=gun_ratio
muzzle=objects['Muzzle'];mw=muzzle.matrix_world.copy();mw.translation.y=-6.10;muzzle.matrix_world=mw
bpy.context.view_layer.update()
print('GEOMETRY',tris,'triangles',len(meshobjects),'mesh nodes',flush=True)

# Studio lighting and cameras for clay and texture verification.
world=bpy.data.worlds.new('Soft daylight');scene.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.72,.79,.88,1);world.node_tree.nodes['Background'].inputs[1].default_value=.45
floor=bpy.data.materials.new('Studio floor');floor.diffuse_color=(.19,.21,.23,1);floor.use_nodes=True;floor.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.19,.21,.23,1);floor.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.87
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.008));ground=bpy.context.object;ground.name='Studio floor';ground.data.materials.append(floor)
def area(name,loc,power,size):
    bpy.ops.object.light_add(type='AREA',location=loc);o=bpy.context.object;o.name=name;o.data.energy=power;o.data.shape='DISK';o.data.size=size;o.rotation_euler=(Vector((0,-.5,1))-o.location).to_track_quat('-Z','Y').to_euler()
area('Daylight key',(5,-7,10),1900,7);area('Sky fill',(-6,-2,6),1200,8);area('Soft rim',(2,6,8),1900,6)
bpy.ops.object.camera_add();cam=bpy.context.object;scene.camera=cam
cam.data.type='ORTHO';cam.data.ortho_scale=11.8
cam.location=(9,-12,7.0);target=Vector((0,-1.02,1.02));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler()
scene.render.resolution_x=1920;scene.render.resolution_y=1080;scene.render.resolution_percentage=100
scene.cycles.samples=32
# Clay rendered first from the exact geometry, before trusting texture review.
clay=bpy.data.materials.new('QA clay');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.38,.38,.38,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8
scene.view_layers[0].material_override=clay;scene.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True);scene.view_layers[0].material_override=None
# Export selected asset nodes only, baked textures, no cameras or light objects.
bpy.ops.object.select_all(action='DESELECT')
for o in objects.values():o.select_set(True)
bpy.context.view_layer.objects.active=objects['Hull']
paint_nt=mats['Paint'].node_tree;paint_bs=paint_nt.nodes.get('Principled BSDF');paint_mix=paint_nt.nodes['PaintFactor'];paint_tex=paint_mix.inputs[1].links[0].from_socket
paint_nt.links.new(paint_tex,paint_bs.inputs['Base Color'])
bpy.ops.export_scene.gltf(filepath=str(ROOT/'t90-tank.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
paint_nt.links.new(paint_mix.outputs[0],paint_bs.inputs['Base Color'])
# Explicitly set the paint factor in the serialized glTF; Blender exporter
# versions differ in how they recognize a texture x RGB multiply graph.
def patch_paint(path):
    raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);oldbin=raw[28+jl:]
    for m in doc['materials']:
        if m['name']=='Paint':m.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=PAINTS[0][1]+[1]
    # Standard KHR_mesh_quantization packs unit normals into signed bytes,
    # retaining all geometry and UVs while saving over two MB. No decoder needed.
    replacements={}
    normals={p['attributes']['NORMAL'] for mesh in doc['meshes'] for p in mesh['primitives']}
    for ai in normals:
        ac=doc['accessors'][ai];vi=ac['bufferView'];view=doc['bufferViews'][vi]
        assert ac['componentType']==5126 and ac.get('byteOffset',0)==0
        arr=np.frombuffer(oldbin,dtype='<f4',count=ac['count']*3,offset=view.get('byteOffset',0)).reshape(-1,3)
        packed=np.zeros((len(arr),4),dtype=np.int8);packed[:,:3]=np.clip(np.rint(arr*127),-127,127).astype(np.int8)
        replacements[vi]=packed.tobytes();ac['componentType']=5120;ac['normalized']=True;ac.pop('min',None);ac.pop('max',None);view['byteStride']=4
    binary=bytearray()
    for i,v in enumerate(doc['bufferViews']):
        binary.extend(b'\x00'*((-len(binary))%4));offset=v.get('byteOffset',0)
        data=replacements.get(i,oldbin[offset:offset+v['byteLength']]);v['byteOffset']=len(binary);v['byteLength']=len(data);binary.extend(data)
    binary.extend(b'\x00'*((-len(binary))%4));doc['buffers'][0]['byteLength']=len(binary)
    for key in ['extensionsUsed','extensionsRequired']:
        if 'KHR_mesh_quantization' not in doc.setdefault(key,[]):doc[key].append('KHR_mesh_quantization')
    out=json.dumps(doc,separators=(',',':')).encode();out+=b' '*((-len(out))%4)
    path.write_bytes(struct.pack('<4sII',b'glTF',2,28+len(out)+len(binary))+struct.pack('<II',len(out),0x4e4f534a)+out+struct.pack('<II',len(binary),0x004e4942)+binary)
patch_paint(ROOT/'t90-tank.glb')
# Node positions and bounds are expressed in the shipping glTF axes, never Blender axes.
def gl(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
node_data=[]
for name,o in objects.items():
    node_data.append({'name':name,'type':'mesh' if o.type=='MESH' else 'empty','parent':o.parent.name if o.parent else None,'world_position':gl(o.matrix_world.translation),'rotation_axis':'Y' if name=='Turret' else 'X' if name.startswith('Wheel_') or name=='Gun' else None})
pts=[o.matrix_world@v.co for o in meshobjects for v in o.data.vertices];gpts=[gl(p) for p in pts]
minimum=[min(v[i] for v in gpts) for i in range(3)];maximum=[max(v[i] for v in gpts) for i in range(3)]
manifest={'asset':'T-90 style main battle tank','units':'metres','forward':'+Z','up':'+Y','left':'+X','nodes':node_data,'bounding_box':{'min':minimum,'max':maximum,'size':[round(b-a,6) for a,b in zip(minimum,maximum)]},'triangles':tris,'hull_length':6.86,'materials':list(mats),'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'track_uv':'UV V follows belt arc length; U runs across shoes. Repeat wrap.','wheel_order':'1 front idler, 2-7 road wheels, 8 rear drive sprocket','bakes':{'engine':'Cycles GPU','samples':16,'base_normal':1024,'orm':512,'small_optics':256,'sources':PH_IDS},'revision_limit':3}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
notes=["T-90 style MBT: cast turret, Kontakt ERA, 125 mm gun, Shtora dazzlers, skirts, recovery log; Y-up metres.",f"Triangles: {tris:,}; 23 articulated/static mesh nodes plus 6 gameplay sockets.","Materials: Paint, Rubber, Steel, TrackSteel, Wood, Glass, Lamps; Paint is neutral-detail texture times linear colour.","Textures: tiled Cycles bakes; colour/normal 1024 max, ORM 512 max; optics 256; all embedded in GLB.","Poly Haven CC0 IDs: "+', '.join(PH_IDS)+"; https://polyhaven.com/license and https://polyhaven.com/a/<id>.","Differences: compact roof MG/antennae; simplified cast welds and under-hull suspension; no insignia or markings."]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
assert os.path.getsize(ROOT/'t90-tank.glb')<=12000000, 'GLB exceeds 12 MB'
print('EXPORTED',os.path.getsize(ROOT/'t90-tank.glb'),flush=True)
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'asset_scene.blend'))
scene.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
hero_matrix=cam.matrix_world.copy();hero_scale=cam.data.ortho_scale
cam.location=(12,-1.0,1.15);target=Vector((0,-1.0,1.15));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=10.6
scene.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
cam.matrix_world=hero_matrix;cam.data.ortho_scale=hero_scale
# Six consistent camera views; combine actual render pixels without extra libraries.
scene.render.resolution_x=640;scene.render.resolution_y=360;scene.cycles.samples=12
sheet=np.zeros((720,1920,4),dtype=np.float32);sheet[:,:,3]=1
for i,(name,col) in enumerate(PAINTS):
    mats['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=tuple(col)+(1,)
    scene.render.filepath=str(TEX/f'paint_{i}.png');bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(scene.render.filepath,check_existing=False);buf=np.asarray(im.pixels[:],dtype=np.float32).reshape(360,640,4)
    y=(1-i//3)*360;x=(i%3)*640;sheet[y:y+360,x:x+640,:]=buf;bpy.data.images.remove(im)
image=bpy.data.images.new('Paint variants',1920,720,alpha=True);image.pixels.foreach_set(sheet.ravel());image.filepath_raw=str(ROOT/'paints.png');image.file_format='PNG';image.save()
mats['Paint'].node_tree.nodes['PaintFactor'].inputs[2].default_value=tuple(PAINTS[0][1])+(1,)
scene.render.resolution_x=1920;scene.render.resolution_y=1080
print('COMPLETE',tris,manifest['bounding_box'],flush=True)
