"""IS-3 style heavy tank. Blender 5.2; clean scene -> CC0 tiled Cycles bakes -> GLB and QA renders.
Author coordinates X left, -Y forward, Z up; glTF +X left,+Y up,+Z forward.
Run: blender -b --python-exit-code 1 --python build_is3-heavy-tank.py
"""
import bpy, bmesh, math, os, json, urllib.request, subprocess, struct, random, sys
import numpy as np
from mathutils import Vector, Matrix
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(33)
bpy.ops.wm.read_factory_settings(use_empty=True)
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=='OPTIX'
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'
MATS=[];paint_factor=None
PAINTS=[('Olive drab',[.13,.15,.08]),('NATO green',[.075,.115,.065]),('Desert tan',[.48,.36,.20]),('Field grey',[.18,.205,.18]),('Winter white',[.72,.74,.69]),('Dark green-black',[.035,.049,.031])]

def image_source(asset,kind):
    p=os.path.join(TEX,asset+'_'+kind+'_1k.jpg')
    if not os.path.exists(p):
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
        subprocess.run(['curl','-f','-L','--retry','3','-s',url,'-o',p],check=True)
    im=bpy.data.images.load(p,check_existing=True)
    if kind!='diff':im.colorspace_settings.name='Non-Color'
    return im

def bake_mat(name,asset,color,metal,rough,normal_strength):
    global paint_factor
    mat=bpy.data.materials.new(name);mat.use_nodes=True
    nt=mat.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')
    diff=n.new('ShaderNodeTexImage');diff.image=image_source(asset,'diff');l.new(uv.outputs['UV'],diff.inputs['Vector'])
    arm=n.new('ShaderNodeTexImage');arm.image=image_source(asset,'arm');l.new(uv.outputs['UV'],arm.inputs['Vector'])
    nor=n.new('ShaderNodeTexImage');nor.image=image_source(asset,'nor_gl');l.new(uv.outputs['UV'],nor.inputs['Vector'])
    bw=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],bw.inputs[0])
    ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.07;ramp.color_ramp.elements[1].position=.8
    if name=='Paint':
        ramp.color_ramp.elements[0].position=.008;ramp.color_ramp.elements[1].position=.16
        cols=((.25,.25,.25,1),(.96,.96,.96,1))
    else:cols=(tuple(c*.50 for c in color)+(1,),tuple(c*1.25 for c in color)+(1,))
    for e,c in zip(ramp.color_ramp.elements,cols):e.color=c
    if name=='Paint':
        mid=ramp.color_ramp.elements.new(.03);mid.color=(.80,.80,.80,1)
    l.new(bw.outputs[0],ramp.inputs[0])
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=230 if name=='Paint' else 75;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=.19;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':
        # Neutral baked rain streaks and ingrained grime remain recolourable.
        stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(1,1,1);l.new(uv.outputs['UV'],stretch.inputs[0])
        streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=5;streak.inputs['Detail'].default_value=3;l.new(stretch.outputs[0],streak.inputs['Vector'])
        sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.45;sr.color_ramp.elements[0].color=(1,1,1,1);sr.color_ramp.elements[1].position=.72;sr.color_ramp.elements[1].color=(.48,.48,.48,1);l.new(streak.outputs['Fac'],sr.inputs[0])
        weather=n.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=.4;l.new(mix.outputs[0],weather.inputs[1]);l.new(sr.outputs[0],weather.inputs[2]);l.new(weather.outputs[0],bs.inputs['Base Color']);mix=weather
    nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=normal_strength;l.new(nor.outputs[0],nm.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.55 if name=='Paint' else .12;bump.inputs['Distance'].default_value=.008 if name=='Paint' else .003;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
    sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
    rm=n.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=rough-.09;rm.inputs['To Max'].default_value=min(.98,rough+.08);l.new(sep.outputs[1],rm.inputs['Value']);l.new(rm.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
    baked={}
    cache={k:os.path.join(TEX,name+'_'+k+('.jpg' if k=='BaseColor' else '.png')) for k in ['BaseColor','Normal','ORM']}
    if '--rebake' not in sys.argv and all(os.path.exists(p) for p in cache.values()):
        baked={k:bpy.data.images.load(p,check_existing=True) for k,p in cache.items()}
    else:
        bpy.ops.mesh.primitive_plane_add(size=2);plane=bpy.context.object;plane.data.materials.append(mat)
        target=n.new('ShaderNodeTexImage');em=n.new('ShaderNodeEmission')
        baked={}
        for kind,res in [('BaseColor',1024 if name in {'Paint','Steel','Tracks'} else 256),('Normal',512 if name in {'Paint','Steel','Tracks'} else 256),('ORM',512 if name in {'Paint','Steel','Tracks'} else 128)]:
            im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
            if kind!='BaseColor':im.colorspace_settings.name='Non-Color'
            target.image=im;n.active=target
            sc.cycles.samples=8;sc.render.bake.margin=8;sc.render.bake.use_clear=True
            if kind=='Normal':
                l.new(bs.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
            else:
                l.new(em.outputs[0],out.inputs['Surface'])
                if kind=='BaseColor':l.new(mix.outputs[0],em.inputs['Color'])
                else:
                    comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1.;l.new(rm.outputs[0],comb.inputs[1]);comb.inputs[2].default_value=metal;l.new(comb.outputs[0],em.inputs['Color'])
                bpy.ops.object.bake(type='EMIT')
            im.filepath_raw=os.path.join(TEX,name+'_'+kind+('.jpg' if kind=='BaseColor' else '.png'));im.file_format='JPEG' if kind=='BaseColor' else 'PNG';im.save();baked[kind]=im
        bpy.data.objects.remove(plane,do_unlink=True)
    n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0]);bs.inputs['Roughness'].default_value=rough
    tx=n.new('ShaderNodeTexImage');tx.image=baked['BaseColor'];l.new(tx.outputs[0],bs.inputs['Base Color'])
    if name=='Paint':
        paint_factor=n.new('ShaderNodeMixRGB');paint_factor.blend_type='MULTIPLY';paint_factor.inputs[0].default_value=1;paint_factor.inputs[2].default_value=(*PAINTS[0][1],1);l.new(tx.outputs[0],paint_factor.inputs[1]);l.new(paint_factor.outputs[0],bs.inputs['Base Color'])
    nn=n.new('ShaderNodeTexImage');nn.image=baked['Normal'];nn.image.colorspace_settings.name='Non-Color';normal=n.new('ShaderNodeNormalMap');l.new(nn.outputs[0],normal.inputs['Color']);l.new(normal.outputs[0],bs.inputs['Normal'])
    orm=n.new('ShaderNodeTexImage');orm.image=baked['ORM'];orm.image.colorspace_settings.name='Non-Color';sp=n.new('ShaderNodeSeparateColor');l.new(orm.outputs[0],sp.inputs[0]);l.new(sp.outputs[1],bs.inputs['Roughness']);l.new(sp.outputs[2],bs.inputs['Metallic'])
    # Exporter-recognised occlusion socket.
    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(sp.outputs[0],gn.inputs['Occlusion'])
    if name=='Lamps':bs.inputs['Emission Color'].default_value=(.32,.27,.17,1);bs.inputs['Emission Strength'].default_value=.2
    MATS.append(mat);print('BAKED',name,flush=True)

# Shared tile bakes, retaining 1k texel density over large plates.
for args in [('Paint','green_metal_rust',(.8,.8,.8),.12,.72,.32),('Steel','rusty_metal_03',(.17,.16,.14),.78,.57,.5),('Tracks','rusty_metal_03',(.17,.13,.085),.68,.82,.65),('Rubber','metal_plate',(.028,.031,.029),0,.91,.12),('Glass','metal_plate',(.035,.085,.10),.4,.25,.035),('Lamps','metal_plate',(.62,.59,.45),.25,.3,.03),('Weapon','green_metal_rust',(.14,.16,.10),.5,.65,.12),('DryMud','brown_mud_dry',(.145,.12,.083),0,.94,.55)]:bake_mat(*args)
P,S,T,R,G,L,W,D=range(8)

PART_IDS={'':0}
class Mesh:
    def __init__(self,name):self.name=name;self.v=[];self.f=[];self.mi=[];self.sm=[];self.uv=[];self.tags=[];self.tag=''
    def face(self,ids,mat,smooth=False,uv=None):
        self.f.append(ids);self.mi.append(mat);self.sm.append(smooth)
        if self.tag not in PART_IDS:PART_IDS[self.tag]=len(PART_IDS)
        self.tags.append(PART_IDS[self.tag])
        if uv is None:
            a,b,c=[Vector(self.v[i]) for i in ids[:3]];normal=(b-a).cross(c-a);axis=max(range(3),key=lambda i:abs(normal[i]));axes=[i for i in range(3) if i!=axis]
            uv=[(self.v[i][axes[0]]*.8,self.v[i][axes[1]]*.8) for i in ids]
        self.uv.append(uv)
    def box(self,c,size,mat=P,rot=None):
        base=len(self.v);c=Vector(c)
        for x,y,z in [(-1,-1,-1),(1,-1,-1),(1,1,-1),(-1,1,-1),(-1,-1,1),(1,-1,1),(1,1,1),(-1,1,1)]:
            v=Vector((x*size[0]/2,y*size[1]/2,z*size[2]/2));self.v.append(tuple(c+(rot@v if rot else v)))
        for f in [(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)]:self.face([base+i for i in f],mat)
    def cyl(self,a,b,r,mat=P,n=32,r2=None,cap=True):
        a,b=Vector(a),Vector(b);axis=(b-a).normalized();u=axis.cross(Vector((0,0,1)))
        if u.length<.01:u=axis.cross(Vector((0,1,0)))
        u.normalize();v=axis.cross(u);base=len(self.v);r2=r if r2 is None else r2
        for c,rad in [(a,r),(b,r2)]:
            for i in range(n):self.v.append(tuple(c+rad*(cos(2*pi*i/n)*u+sin(2*pi*i/n)*v)))
        for i in range(n):j=(i+1)%n;self.face([base+i,base+j,base+n+j,base+n+i],mat,True,[(i/n,0),((i+1)/n,0),((i+1)/n,(b-a).length), (i/n,(b-a).length)])
        if cap:self.face([base+i for i in reversed(range(n))],mat);self.face([base+n+i for i in range(n)],mat)
    def ring(self,c,axis,r,t,mat=P,n=48,k=8):
        axis=Vector(axis).normalized();u=axis.cross(Vector((0,0,1)))
        if u.length<.01:u=axis.cross(Vector((0,1,0)))
        u.normalize();v=axis.cross(u);c=Vector(c);base=len(self.v)
        for i in range(n):
            radial=cos(2*pi*i/n)*u+sin(2*pi*i/n)*v
            for j in range(k):self.v.append(tuple(c+(r+t*cos(2*pi*j/k))*radial+t*sin(2*pi*j/k)*axis))
        for i in range(n):
            for j in range(k):self.face([base+i*k+j,base+((i+1)%n)*k+j,base+((i+1)%n)*k+(j+1)%k,base+i*k+(j+1)%k],mat,True)
    def tube(self,pts,r,mat=S,n=8):
        for a,b in zip(pts,pts[1:]):self.cyl(a,b,r,mat,n)
    def mesh(self,verts,faces,mat=P,smooth=False):
        base=len(self.v);self.v.extend(verts)
        for f in faces:self.face([base+i for i in f],mat,smooth)
    def obj(self,origin=(0,0,0),bevel=.005):
        me=bpy.data.meshes.new(self.name);me.from_pydata(self.v,[],self.f);me.update();o=bpy.data.objects.new(self.name,me);sc.collection.objects.link(o)
        for m in MATS:me.materials.append(m)
        layer=me.uv_layers.new(name='UVMap')
        for poly,mat,smooth,uv in zip(me.polygons,self.mi,self.sm,self.uv):
            poly.material_index=mat;poly.use_smooth=smooth
            for li,co in zip(poly.loop_indices,uv):layer.data[li].uv=co
        attr=me.attributes.new('_part','INT','FACE')
        for value,tag in zip(attr.data,self.tags):value.value=tag
        # Recalculate consistent exterior winding before bevel/export.
        bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
        bpy.context.view_layer.objects.active=o;o.select_set(True)
        if bevel:
            mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=bevel;mod.segments=1;mod.limit_method='ANGLE';mod.angle_limit=.65
            bpy.ops.object.modifier_apply(modifier=mod.name)
        o.data.transform(Matrix.Translation(-Vector(origin)));o.location=origin;o.select_set(False)
        return o

def bolt(m,p,axis=(0,0,1),r=.014,mat=S):m.cyl(Vector(p),Vector(p)+Vector(axis)*.012,r,mat,6)
def lathe(m,profiles,center=(0,0),mat=P,n=96):
    base=len(m.v)
    for rx,ry,z in profiles:
        for i in range(n):
            a=2*pi*i/n;shape=1+.012*sin(a*3)+.006*cos(a*7)
            m.v.append((center[0]+rx*cos(a)*shape,center[1]+ry*sin(a)*shape,z))
    for j in range(len(profiles)-1):
        for i in range(n):
            k=(i+1)%n
            m.face([base+j*n+i,base+j*n+k,base+(j+1)*n+k,base+(j+1)*n+i],mat,True,[(i/n*6.0,profiles[j][2]*.8),((i+1)/n*6.0,profiles[j][2]*.8),((i+1)/n*6.0,profiles[j+1][2]*.8),(i/n*6.0,profiles[j+1][2]*.8)])
    m.face([base+i for i in reversed(range(n))],mat);m.face([base+(len(profiles)-1)*n+i for i in range(n)],mat)

h=Mesh('Hull')
# Lower armoured tub and pointed pike: two sloping compound glacis plates meet at the nose.
v=[(-.96,-2.82,.37),(.96,-2.82,.37),(1.06,2.9,.43),(-1.06,2.9,.43),(-1.16,-2.64,1.25),(1.16,-2.64,1.25),(1.18,3.10,1.36),(-1.18,3.10,1.36)]
h.mesh(v,[(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)])
v=[(0,-3.35,.80),(-1.16,-2.88,.72),(1.16,-2.88,.72),(0,-2.20,1.57),(-1.12,-2.0,1.57),(1.12,-2.0,1.57),(-1.35,-2.84,1.07),(1.35,-2.84,1.07),(-1.30,2.8,1.44),(1.30,2.8,1.44),(-1.13,3.35,1.12),(1.13,3.35,1.12)]
h.mesh(v,[(0,6,4,3),(0,3,5,7),(0,1,6),(0,7,2),(1,10,8,4,6),(2,7,5,9,11),(4,8,9,5,3),(8,10,11,9),(10,1,0,2,11)])
# Pike welded seams, discreet weld bead at the ridge.
h.tube([(0,-3.33,.81),(0,-2.20,1.577)],.008,P)
# Track fenders with shaped turned-down noses; top leaves suspension open.
for side in [-1,1]:
    h.tag='Part_Fender_'+('L' if side==1 else 'R')
    x=side*1.29
    ys=[(-3.33,1.05),(-3.18,1.27),(-2.85,1.37),(-2.30,1.40),(2.65,1.40),(3.16,1.26),(3.35,1.06)]
    verts=[]
    for y,z in ys:verts.extend([(side*.995,y,z),(side*1.575,y,z),(side*.995,y,z-.045),(side*1.575,y,z-.045)])
    fs=[]
    for i in range(len(ys)-1):
        a=i*4;b=a+4;fs.extend([(a,a+1,b+1,b),(a+2,b+2,b+3,a+3),(a,b,b+2,a+2),(a+1,a+3,b+3,b+1)])
    fs.extend([(0,2,3,1),(24,25,27,26)]);h.mesh(verts,fs)
    for y in [-2.55,-1.4,0,1.45,2.65]:
        h.box((side*1.30,y,1.428),(.49,.018,.018),S)
        for xx in [1.07,1.51]:bolt(h,(side*xx,y,1.442),r=.012)
    # Stamped nose ribs.
    for xx in [1.12,1.3,1.48]:h.tube([(side*xx,-3.17,1.28),(side*xx,-2.95,1.366),(side*xx,-2.7,1.393)],.011,P)
    for y in [.2,1.12]:
        h.tag=f'Part_Stowage_{"L" if side==1 else "R"}{1 if y==.2 else 2}'
        h.box((side*1.19,y,1.496),(.25,.67,.045),P,Matrix.Rotation(side*.18,3,'Y'))
        for yy in [y-.29,y+.29]:bolt(h,(side*1.20,yy,1.53),r=.012)
    h.tag=''
    # Towing eyes and shackles at nose.
    h.box((side*.72,-2.78,1.18),(.12,.19,.09),P)
    h.ring((side*.72,-2.84,1.18),(1,0,0),.065,.018,S,20,6)
# Engine deck grilles, surround frames and slats.
for x in [-.65,.65]:
    h.tag='Part_EngineGrille_'+('L' if x>0 else 'R')
    h.box((x,2.17,1.461),(.91,1.03,.045),S)
    for xx in [x-.46,x+.46]:h.box((xx,2.17,1.496),(.035,1.10,.035),P)
    for yy in [1.62,2.72]:h.box((x,yy,1.496),(.93,.035,.035),P)
    for i in range(20):h.box((x,1.70+i*.049,1.49),(.86,.022,.028),P)
    for xx in [x-.42,x+.42]:
        for yy in [1.65,2.70]:bolt(h,(xx,yy,1.52))
h.tag=''
h.box((0,2.20,1.493),(.28,.91,.036),P)
for y in [1.76,2.63]:h.tube([(-.07,y,1.52),(-.07,y,1.56),(.07,y,1.56),(.07,y,1.52)],.012,S)
# Rear inspection covers, exhaust housings.
for x in [-.49,.49]:
    h.cyl((x,3.25,.99),(x,3.28,.99),.24,P,40)
    h.tube([(x-.08,3.29,1.02),(x-.08,3.33,1.10),(x+.08,3.33,1.10),(x+.08,3.29,1.02)],.018,S)
    h.tag='Part_ExhaustGuard_'+('L' if x>0 else 'R')
    h.box((x,3.12,1.28),(.36,.20,.12),S)
    h.tag=''
    h.cyl((x,3.2,1.23),(x,3.36,1.20),.062,S,24)
# External cylindrical rear fuel tanks, two each side; welded cap and bands.
for side in [-1,1]:
    for yy in [1.33,2.42]:
        h.tag=f'Part_FuelTank_{"L" if side==1 else "R"}{1 if yy==1.33 else 2}'
        x=side*1.25;z=1.70
        h.cyl((x,yy-.47,z),(x,yy+.47,z),.218,P,48)
        for end in [-.47,.47]:
            h.cyl((x,yy+end-.012,z),(x,yy+end+.012,z),.222,P,48)
            h.ring((x,yy+end,z),(0,1,0),.195,.009,S,40,6)
        for d in [-.30,.30]:
            h.cyl((x,yy+d-.026,z),(x,yy+d+.026,z),.229,P,48)
            saved_tag=h.tag;h.tag=''
            h.box((x,yy+d,1.469),(.36,.082,.11),P)
            bolt(h,(x+side*.20,yy+d,1.60),(side,0,0),.016)
            h.tag=saved_tag
        h.cyl((x,yy,1.908),(x,yy,1.941),.052,S,20)
        h.box((x,yy,1.948),(.065,.018,.018),P)
h.tag='Hatch_Driver'
# Driver hatch, periscopes, lamps with cages.
h.box((0,-1.98,1.602),(.60,.42,.055),P)
for x in [-.25,.25]:h.cyl((x-.05,-1.80,1.632),(x+.05,-1.8,1.632),.035,S,16)
h.tag=''
h.box((0,-2.14,1.65),(.28,.08,.065),S);h.box((0,-2.185,1.657),(.23,.008,.029),G)
for side in [-1,1]:
    h.tag='Part_Headlight_'+('L' if side==1 else 'R')
    x=side*.98
    h.cyl((x,-2.35,1.45),(x,-2.48,1.45),.104,P,32)
    h.cyl((x,-2.488,1.45),(x,-2.497,1.45),.081,L,32)
    h.ring((x,-2.50,1.45),(0,1,0),.09,.012,S,32,6)
    h.tag='Part_LampGuard_'+('L' if side==1 else 'R')
    h.tube([(x-.13,-2.43,1.36),(x-.13,-2.43,1.59),(x+.13,-2.43,1.59),(x+.13,-2.43,1.36)],.013,S)
    # Spare track links on glacis, aligned to sloping plate.
    rot=Matrix.Rotation(.53,3,'X')
    for k in range(3):
        h.tag=f'Part_SpareTrack_{"L" if side==1 else "R"}{k+1}'
        c=Vector((side*.50,-2.62+k*.16,1.32+k*.095));h.box(c,(.34,.145,.04),T,rot)
        for xx in [-.12,0,.12]:h.box(c+rot@Vector((xx,0,.031)),(.035,.13,.025),S,rot)
# Tow cable along both rear deck edges with eyelets.
for s in [-1,1]:
    h.tag='Part_TowCable_'+('L' if s==1 else 'R')
    pts=[(s*.97,-.15,1.60),(s*1.09,.3,1.58),(s*1.05,1.1,1.52),(s*.97,2.5,1.53),(s*.84,2.90,1.49)]
    h.tube(pts,.022,S,10)
    for p in [pts[0],pts[-1]]:h.ring(p,(0,0,1),.060,.014,S,20,6)
# Small irregular exposed-metal chips on the working edges, not painted decals.
for side in [-1,1]:
    h.tag='Part_Fender_'+('L' if side==1 else 'R')
    for i in range(90):
        y=random.uniform(-2.22,2.58);x=side*random.uniform(1.54,1.568);w=random.uniform(.008,.028);ln=random.uniform(.012,.075)
        h.mesh([(x,y,1.4015),(x-side*w,y+ln*.2,1.402),(x-side*w*.7,y+ln,1.402),(x,y+ln*.8,1.4015)],[(0,1,2,3)],S)
    h.tag=''
    # Tight weld bead where track guard meets armour.
    h.tube([(side*1.01,-2.16,1.414),(side*1.01,.65,1.414)],.006,S,6)
# Neutral-to-earth contact grime stays concentrated at fender seams and the rear floor.
for side in [-1,1]:
    h.tag='Part_Fender_'+('L' if side==1 else 'R')
    for i in range(60):
        y=random.uniform(-2.20,2.6);wid=random.uniform(.015,.065);dep=random.uniform(.02,.085);x=side*1.53
        h.mesh([(x,y,1.404),(x-side*dep,y+.012,1.404),(x-side*dep*.7,y+wid,1.404),(x,y+wid*.8,1.404)],[(0,1,2,3)],D)
h.tag=''
# Broad mantlet plate seam bolts and deck service hinges.
for side in [-1,1]:
    for yy in [.05,.92]:
        h.cyl((side*1.06,yy-.07,1.525),(side*1.06,yy+.07,1.525),.024,S,16)
# Armoured fixed collar closes the deck-to-turret bearing joint.
h.cyl((0,-.34,1.48),(0,-.34,1.605),1.14,P,96,r2=1.065)
HULL=h.obj(bevel=.006)
# Cast hemispherical turret, formed as a low asymmetric continuous shell.
t=Mesh('Turret');t.cyl((0,-.33,1.55),(0,-.33,1.66),1.03,S,96)
lathe(t,[(1.05,1.25,1.64),(1.22,1.41,1.69),(1.255,1.46,1.77),(1.23,1.445,1.86),(1.16,1.36,2.00),(.99,1.20,2.15),(.76,.94,2.26),(.48,.62,2.31),(.12,.17,2.325)],(0,-.34),P,112)
t.ring((0,-.34,1.682),(0,0,1),1.09,.012,S,80,6)
# Cast weld mould seam follows dome lower edge.
for s in [-1,1]:
    for yy in [-.95,-.2,.55]:
        t.tag=f'Part_Turret_GrabRail_{"L" if s==1 else "R"}{[-.95,-.2,.55].index(yy)+1}'
        # welded grab rails follow the side of the casting.
        z=2.015
        def surf_x(y):return s*1.142*math.sqrt(max(.05,1-((y+.34)/1.344)**2))
        ya,yb=yy-.19,yy+.19
        t.tube([(surf_x(ya),ya,z),(surf_x(ya)+s*.065,ya+.02,z+.025),(surf_x(yb)+s*.065,yb-.02,z+.025),(surf_x(yb),yb,z)],.013,S,10)
    t.tag=''
    for yy in [-1.1,.65]:
        t.ring((s*.77,yy,2.13),(0,1,0),.056,.018,P,20,8)
t.tag='Part_Turret_CupolaRing'
# Commander's cupola and second roof hatch.
t.cyl((-.43,-.10,2.255),(-.43,-.10,2.34),.325,P,64)
t.cyl((-.43,-.10,2.34),(-.43,-.10,2.37),.30,S,64)
for a in range(0,360,60):
    rad=math.radians(a);xx=-.43+.301*cos(rad);yy=-.10+.301*sin(rad)
    t.box((xx,yy,2.341),(.085,.025,.033),G,Matrix.Rotation(rad+pi/2,3,'Z'))
t.tag='Hatch_Loader'
t.cyl((.44,-.13,2.26),(.44,-.13,2.318),.245,P,56)
t.ring((.44,-.13,2.322),(0,0,1),.214,.010,S,48,6)
t.tube([(.39,-.13,2.324),(.39,-.13,2.365),(.50,-.13,2.365),(.50,-.13,2.324)],.011,S)
# Periscope housings, ventilator, gunner sight.
for x,y in [(-.73,-.50),(.40,-.67)]:
    t.tag='Part_Turret_Sight_'+('L' if x>0 else 'R')
    t.box((x,y,2.281),(.14,.17,.086),P);t.box((x,y-.088,2.291),(.10,.007,.042),G)
t.tag=''
t.cyl((.12,.39,2.295),(.12,.39,2.36),.095,P,32)
t.cyl((.12,.39,2.36),(.12,.39,2.38),.108,P,32)
t.tag='Part_Turret_Antenna'
# Aerial mounting only, reference's flexible whip omitted to preserve specified total height.
t.cyl((-.88,.37,2.08),(-.88,.37,2.17),.052,S,20)
t.cyl((-.88,.37,2.17),(-.88,.37,2.40),.012,S,12)
TURRET=t.obj((0,-.34,1.62),.004)
hatch=Mesh('Hatch_Commander');hatch.cyl((-.43,-.10,2.37),(-.43,-.10,2.403),.294,P,64)
hatch.tube([(-.49,-.10,2.409),(-.49,-.10,2.439),(-.37,-.10,2.439),(-.37,-.10,2.409)],.011,S)
hatch.cyl((-.63,.17,2.385),(-.23,.17,2.385),.029,S,24)
HATCH=hatch.obj((-.43,.17,2.385),.003)
# Gun and mantlet: no telescoping toy barrel; restrained taper and proper hollow brake.
gun=Mesh('Gun')
lathe(gun,[(.1,.1,0)],mat=P) if False else None
gun.cyl((0,-1.59,1.965),(0,-1.93,1.965),.29,P,64,r2=.248)
gun.cyl((0,-1.90,1.965),(0,-2.12,1.965),.18,S,48,r2=.143)
gun.tag='Barrel'
gun.cyl((0,-2.05,1.965),(0,-5.96,1.965),.123,P,64,r2=.083)
gun.cyl((0,-5.90,1.965),(0,-6.01,1.965),.093,S,48)
gun.tag=''
for a in range(0,360,60):
    an=a*pi/180;bolt(gun,(.257*cos(an),-1.935,1.965+.257*sin(an)),(0,-1,0),.017)
gun.tag='Barrel'
# Double-baffle brake: rounded casting with two real through side ports and a hollow bore.
sections=[-6.50,-6.445,-6.305,-6.235,-6.095,-6.025]
base=len(gun.v);nn=40
for inner in [False,True]:
    for yy in sections:
        for k in range(nn):
            a=2*pi*k/nn;cx,sz=cos(a),sin(a)
            if inner:x=.061*cx;z=.061*sz
            else:x=.133*math.copysign(abs(cx)**.68,cx);z=.124*math.copysign(abs(sz)**.68,sz)
            gun.v.append((x,yy,1.965+z))
def ix(inner,section,k):return base+inner*len(sections)*nn+section*nn+k%nn
def isport(j,k):return j in [1,3] and abs(cos(2*pi*(k+.5)/nn))>.72
for j in range(len(sections)-1):
    for k in range(nn):
        if not isport(j,k):
            gun.face([ix(0,j,k),ix(0,j,k+1),ix(0,j+1,k+1),ix(0,j+1,k)],S,True)
            gun.face([ix(1,j,k+1),ix(1,j,k),ix(1,j+1,k),ix(1,j+1,k+1)],S,True)
        else:
            # Throats have real thickness; only boundaries receive inner-to-outer jambs.
            for sj in [j,j+1]:gun.face([ix(0,sj,k),ix(1,sj,k),ix(1,sj,k+1),ix(0,sj,k+1)],S)
            for kk,neighbor in [(k,k-1),(k+1,k+1)]:
                if not isport(j,neighbor):gun.face([ix(0,j,kk),ix(0,j+1,kk),ix(1,j+1,kk),ix(1,j,kk)],S)
for j in [0,len(sections)-1]:
    for k in range(nn):gun.face([ix(0,j,k),ix(0,j,k+1),ix(1,j,k+1),ix(1,j,k)],S)
gun.cyl((0,-6.024,1.965),(0,-6.02,1.965),.060,R,32)
gun.mi=[W if mi==P else mi for mi in gun.mi]
GUN=gun.obj((0,-1.60,1.965),.005)

# Suspension and individually named road wheels, front to rear (idler then six road wheels then sprocket).
wheel_objects=[]
for side,label in [(1,'L'),(-1,'R')]:
    positions=[(-2.78,.76,.37)]+[(y,.395,.315) for y in [-2.11,-1.27,-.43,.43,1.29,2.13]]+[(2.81,.76,.415)]
    susp=Mesh('susp_'+label)
    for idx,(y,z,r) in enumerate(positions):
        w=Mesh(f'Wheel_{label}{idx+1}');x=side*1.278;out=side*1.552
        # All-steel IS running gear; narrow dark contact rim is metal, no modern fat rubber tyres.
        # Twin steel wheel rims leave a real centre channel for the track guide teeth.
        w.cyl((x-side*.19,y,z),(x-side*.048,y,z),r,T,56)
        w.cyl((x+side*.048,y,z),(x+side*.19,y,z),r,T,56)
        w.cyl((x-side*.20,y,z),(x+side*.20,y,z),r*.31,S,32)
        for face_x in [x-side*.16,x+side*.195]:
            w.cyl((face_x,y,z),(face_x+side*.018,y,z),r*.92,P,56)
            w.ring((face_x+side*.026,y,z),(1,0,0),r*.83,.014,S,48,6)
        w.cyl((out-side*.09,y,z),(out,y,z),r*.33,P,40,r2=r*.28)
        w.ring((out-side*.015,y,z),(1,0,0),r*.33,.012,S,32,6)
        if idx not in [0,7]:
            for j in range(22):
                a=j*2*pi/22;dy,dz=sin(a),cos(a)
                rib=[]
                for xx,halfwidth in [(out-side*.067,.014),(out-side*.023,.006)]:
                    for rr,ww in [(r*.34,-halfwidth),(r*.81,-halfwidth),(r*.81,halfwidth),(r*.34,halfwidth)]:
                        rib.append((xx,y+dy*rr+dz*ww,z+dz*rr-dy*ww))
                w.mesh(rib,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],P)
                bolt(w,(out-side*.014,y+dy*r*.48,z+dz*r*.48),(side,0,0),.010)
        else:
            for j in range(12):
                a=j*2*pi/12
                w.cyl((out-side*.01,y+sin(a)*r*.62,z+cos(a)*r*.62),(out+side*.002,y+sin(a)*r*.62,z+cos(a)*r*.62),.035,S,12)
            if idx==7:
                for j in range(16):
                    a=j*2*pi/16
                    for dx in [-.125,.125]:w.box((x+dx,y+sin(a)*(r+.018),z+cos(a)*(r+.018)),(.125,.085,.067),S,Matrix.Rotation(-a,3,'X'))
        for j in range(6):
            a=j*pi/3;bolt(w,(out+.003*side,y+sin(a)*r*.21,z+cos(a)*r*.21),(side,0,0),.014)
        # Thin irregular dried-earth deposits between ribs, bonded to the face of each wheel.
        for k in range(18):
            a=random.uniform(0,2*pi);rad=random.uniform(r*.70,r*.90);da=random.uniform(.015,.075);dr=random.uniform(.005,.026)
            vx=out-side*.059
            verts=[]
            for aa,rr in [(a-da,rad-dr),(a+da*.5,rad-dr*.6),(a+da,rad+dr*.5),(a,rad+dr)]:verts.append((vx,y+sin(aa)*rr,z+cos(aa)*rr))
            w.mesh(verts,[(0,1,2,3)],D)
        wheel_objects.append(w.obj((x,y,z),.003))
        susp.cyl((side*1.115,y-.32,z+.19),(side*1.20,y,z),.07,S,20)
        if idx not in [0,7]:
            susp.cyl((side*1.035,y-.32,z+.19),(side*1.195,y-.32,z+.19),.102,P,32)
            susp.cyl((side*1.195,y-.32,z+.19),(side*1.209,y-.32,z+.19),.052,S,24)
            for a in [0,2*pi/3,4*pi/3]:bolt(susp,(side*1.209,y-.32+cos(a)*.072,z+.19+sin(a)*.072),(side,0,0),.012)
    # Return rollers sit behind the upper run.
    for y in [-1.75,0,1.75]:
        susp.tag=f'Wheel_{label}{9+[-1.75,0,1.75].index(y)}'
        susp.cyl((side*1.06,y,1.035),(side*1.40,y,1.035),.155,S,32)
        susp.cyl((side*1.40,y,1.035),(side*1.43,y,1.035),.104,P,28)
    so=susp.obj(bevel=.004)
    bpy.ops.object.select_all(action='DESELECT');HULL.select_set(True);so.select_set(True);bpy.context.view_layer.objects.active=HULL;bpy.ops.object.join();HULL.select_set(False)
# Closed belts with exact tangent-aligned links. V is unwrapped arc length around each belt.
# Sample a rounded hull path with Catmull-Rom splines.
anchors=[(-2.18,.075),(2.18,.075),(2.88,.36),(3.17,.70),(3.10,1.03),(2.80,1.215),(1.5,1.22),(0,1.205),(-1.5,1.22),(-2.8,1.19),(-3.12,.99),(-3.17,.69),(-2.88,.36)]
path=[]
for i in range(len(anchors)):
    p0,p1,p2,p3=[Vector(anchors[j%len(anchors)]) for j in [i-1,i,i+1,i+2]]
    for k in range(20):
        t0=k/20;path.append(.5*((2*p1)+(-p0+p2)*t0+(2*p0-5*p1+4*p2-p3)*t0*t0+(-p0+3*p1-3*p2+p3)*t0**3))
# enforce level bottom contact and avoid Catmull overshoot under ground
for p in path:p.y=max(.052,p.y)
lengths=[0.]
for a,b in zip(path,path[1:]+path[:1]):lengths.append(lengths[-1]+(b-a).length)
total=lengths[-1]
def sample(d):
    d%=total;i=next(i for i in range(len(path)) if lengths[i+1]>=d);fac=(d-lengths[i])/(lengths[i+1]-lengths[i]);return path[i].lerp(path[(i+1)%len(path)],fac)
for side,label in [(1,'L'),(-1,'R')]:
    tr=Mesh('Track_'+label);nlinks=96;pitch=total/nlinks
    for i in range(nlinks):
        d=(i+.5)*pitch;p=sample(d);a=sample(d-.015);b=sample(d+.015);tan=(b-a).normalized();ang=math.atan2(tan.y,tan.x);rot=Matrix.Rotation(ang,3,'X');c=Vector((side*1.278,p.x,p.y))
        startface=len(tr.f)
        tr.box(c,(.586,pitch*.94,.055),T,rot)
        # crosswise traction bar and track end shoes.
        tr.box(c+rot@Vector((0,0,-.036)),(.61,.036,.025),S,rot)
        for xx in [-.245,.245]:
            tr.box(c+rot@Vector((xx,0,.031)),(.080,pitch*.88,.020),T,rot)
        # central guide horn, inside belt.
        tr.box(c+rot@Vector((0,0,.075)),(.048,.062,.105),T,rot)
        # exposed pin ends
        for xx in [-.302,.302]:
            cp=c+rot@Vector((xx,pitch*.39,0));tr.cyl(cp-Vector((.016,0,0)),cp+Vector((.016,0,0)),.022,S,10)
        if i%3!=0:
            xx=random.uniform(-.18,.18);wid=random.uniform(.06,.22)
            local=[(xx-wid/2,-pitch*.26,-.028),(xx+wid*.43,-pitch*.32,-.028),(xx+wid/2,pitch*.24,-.028),(xx-wid*.35,pitch*.30,-.028)]
            tr.mesh([tuple(c+rot@Vector(v)) for v in local],[(0,1,2,3)],D)
        for fi in range(startface,len(tr.f)):
            uv=[]
            for vi in tr.f[fi]:
                rel=rot.transposed()@(Vector(tr.v[vi])-c);uv.append((rel.x/.61+.5,(d+rel.y)/.62))
            tr.uv[fi]=uv
    globals()['TRACK_'+label]=tr.obj(bevel=.0025)
# Hierarchy retains zero orientation and hub/hinge local origins.
def parent_keep(o,parent):
    bpy.context.view_layer.update();mw=o.matrix_world.copy();o.parent=parent;o.matrix_world=mw
parent_keep(GUN,TURRET);parent_keep(HATCH,TURRET)
def socket(name,pos,parent=None):
    o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=pos;o.empty_display_size=.12
    if parent:parent_keep(o,parent)
    return o
socket('Muzzle',(0,-6.50,1.965),GUN)
socket('Seat_Commander',(-.43,-.10,2.43),TURRET);socket('Seat_Driver',(0,-1.95,1.52),HULL)
socket('Exhaust',(0,3.35,1.21),HULL)
socket('Light_Head_L',(.98,-2.505,1.45),HULL);socket('Light_Head_R',(-.98,-2.505,1.45),HULL)
# DShK heavy machine gun in its low travel stow beside the commander's cupola.
mg=Mesh('MG')
mg.tag='Part_Turret_DShKMount'
mg.cyl((-.72,.12,2.23),(-.72,.12,2.34),.042,S,20)
mg.tag=''
mg.box((-.72,-.02,2.375),(.12,.38,.115),W)
mg.box((-.81,.02,2.365),(.09,.16,.11),S)
mg.cyl((-.72,-.20,2.39),(-.72,-.96,2.39),.028,W,24,r2=.020)
mg.cyl((-.72,-.24,2.355),(-.72,-.79,2.355),.014,S,16)
mg.cyl((-.72,-.96,2.39),(-.72,-1.015,2.39),.031,S,24,cap=False)
for yy in [-.27-i*.042 for i in range(8)]:mg.ring((-.72,yy,2.39),(0,1,0),.028,.004,S,16,5)
mg.box((-.72,.26,2.37),(.14,.12,.055),W)
mg.tube([(-.79,.26,2.37),(-.82,.33,2.34),(-.82,.38,2.34)],.012,S,8)
mg.tube([(-.65,.26,2.37),(-.62,.33,2.34),(-.62,.38,2.34)],.012,S,8)
MG=mg.obj((-.72,.12,2.30),.002);parent_keep(MG,TURRET)
asset=[o for o in sc.objects if o.type in {'MESH','EMPTY'}]
bpy.context.view_layer.update()
# Ground is exactly zero; muzzle extent exactly 9.85m from rear. Preserve all pivot relationships.
# Normalize the tiny construction-edge overshoots to the exact shipping envelope.
pts0=[o.matrix_world@Vector(c) for o in asset if o.type=='MESH' for c in o.bound_box]
mn=[min(v[i] for v in pts0) for i in range(3)];mx=[max(v[i] for v in pts0) for i in range(3)]
scale=Vector((3.15/(mx[0]-mn[0]),9.85/(mx[1]-mn[1]),2.45/(mx[2]-mn[2])))
# Ground-plane hull-centre origin; axial correction does not change articulation axes.
world_matrices={o:o.matrix_world.copy() for o in asset}
for o in asset:
    old=world_matrices[o];pos=old.translation.copy();newpos=Vector((pos.x*scale.x,pos.y*scale.y,(pos.z-mn[2])*scale.z))
    if o.type=='MESH':o.data.transform(Matrix.Diagonal((*scale,1)))
    world_matrices[o]=Matrix.Translation(newpos)
for o in asset: o.matrix_world=world_matrices[o]
bpy.context.view_layer.update()
for o in asset:
    if o.type=='MESH':
        o.data.calc_loop_triangles()
rawtris=sum(len(o.data.loop_triangles) for o in asset if o.type=='MESH')
ratio=min(1.,118000/rawtris)
for o in asset:
    if o.type=='MESH' and ratio<1:
        bpy.context.view_layer.objects.active=o
        mod=o.modifiers.new('Game mesh simplification','DECIMATE');mod.ratio=ratio;mod.use_collapse_triangulate=True
        bpy.ops.object.modifier_apply(modifier=mod.name)
# Triangulate for deterministic exported triangle counts.
for o in asset:
    if o.type=='MESH':
        bpy.context.view_layer.objects.active=o;o.select_set(True)
        mod=o.modifiers.new('Export triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
        bm=bmesh.new();bm.from_mesh(o.data)
        degenerate=[f for f in bm.faces if f.calc_area()<1e-11]
        if degenerate:bmesh.ops.delete(bm,geom=degenerate,context='FACES_ONLY')
        bm.to_mesh(o.data);bm.free();o.data.update();o.select_set(False)
pts=[o.matrix_world@Vector(c) for o in asset if o.type=='MESH' for c in o.bound_box]
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
# Final measured envelope and an exact ground-plane Hull pivot after simplification.
correction=Vector((3.15/(hi[0]-lo[0]),9.85/(hi[1]-lo[1]),2.45/(hi[2]-lo[2])))
old_world={o:o.matrix_world.copy() for o in asset}
for o in asset:
    pos=old_world[o].translation
    if o.type=='MESH':o.data.transform(Matrix.Diagonal((*correction,1)))
    old_world[o]=Matrix.Translation((pos.x*correction.x,(pos.y)*correction.y,(pos.z-lo[2])*correction.z))
for o in asset:o.matrix_world=old_world[o]
bpy.context.view_layer.update()
child_world={o:o.matrix_world.copy() for o in HULL.children}
HULL.data.transform(Matrix.Translation(HULL.matrix_world.translation));HULL.location=(0,0,0)
for o,world_matrix in child_world.items():o.matrix_world=world_matrix
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(c) for o in asset if o.type=='MESH' for c in o.bound_box]
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
tri=sum(len(o.data.polygons) for o in asset if o.type=='MESH')
print('GEOMETRY',tri,'bounds',lo,hi,flush=True)
# tank-damage/v1 retrofit. Face-domain provenance survives the original modifiers;
# split only after the approved model's shaping and simplification have finished.
def author_pos(v):
    return Vector((v[0]*scale.x*correction.x,v[1]*scale.y*correction.y,((v[2]-mn[2])*scale.z-lo[2])*correction.z))
def recenter(o,p):
    bpy.context.view_layer.update();old=o.matrix_world.copy();p=Vector(p)
    o.data.transform(Matrix.Translation(old.translation-p));o.matrix_world=Matrix.Translation(p)
def mesh_filter(o,name,keep):
    me=o.data.copy();bm=bmesh.new();bm.from_mesh(me);bm.faces.ensure_lookup_table()
    bmesh.ops.delete(bm,geom=[f for f in bm.faces if not keep(f)],context='FACES')
    bm.to_mesh(me);bm.free();me.update()
    q=bpy.data.objects.new(name,me);sc.collection.objects.link(q);q.matrix_world=o.matrix_world.copy();return q
for o in list(asset):
    if o.type!='MESH' or '_part' not in o.data.attributes:continue
    ids=sorted(set(a.value for a in o.data.attributes['_part'].data)-{0})
    for ident in ids:
        name=next(k for k,v in PART_IDS.items() if v==ident)
        if not name:continue
        q=mesh_filter(o,name,lambda f:f[f.id_data.faces.layers.int.get('_part')]==ident) if False else None
        me=o.data.copy();bm=bmesh.new();bm.from_mesh(me);layer=bm.faces.layers.int.get('_part')
        bmesh.ops.delete(bm,geom=[f for f in bm.faces if f[layer]!=ident],context='FACES');bmesh.ops.delete(bm,geom=[v for v in bm.verts if not v.link_faces],context='VERTS');bm.to_mesh(me);bm.free();me.update()
        q=bpy.data.objects.new(name,me);sc.collection.objects.link(q);q.matrix_world=o.matrix_world.copy()
        bpy.context.view_layer.update();bounds=[q.matrix_world@Vector(c) for c in q.bound_box];cen=sum(bounds,Vector())/8
        pivots={'Barrel':(0,-2.12,1.965),'Hatch_Driver':(0,-1.80,1.632),'Hatch_Loader':(.44,.08,2.29)}
        if name in pivots:cen=author_pos(pivots[name])
        if name.startswith('Wheel_'):
            side=1 if '_L' in name else -1;idx=int(name.split('_')[1][1:])-9
            cen=author_pos((side*1.278,[-1.75,0,1.75][idx],1.035))
        recenter(q,cen)
        if name=='Barrel':parent_keep(q,GUN)
        elif name.startswith('Part_Turret_') or name=='Hatch_Loader':parent_keep(q,TURRET)
        elif name.startswith('Part_') or name=='Hatch_Driver':parent_keep(q,HULL)
    bm=bmesh.new();bm.from_mesh(o.data);layer=bm.faces.layers.int.get('_part')
    bmesh.ops.delete(bm,geom=[f for f in bm.faces if f[layer]!=0],context='FACES');bm.to_mesh(o.data);bm.free();o.data.update()
BARREL=bpy.data.objects['Barrel'];parent_keep(bpy.data.objects['Muzzle'],BARREL)
# Closed, individually detachable fender panels cut from the original continuous guard.
for label in ['L','R']:
    original=bpy.data.objects['Part_Fender_'+label]
    for name,low,high in [('Part_Fender_F'+label,-10,-2.3),('Part_Fender_'+label+'1',-2.3,.3),('Part_Fender_'+label+'2',.3,2.65),('Part_Fender_R'+label,2.65,10)]:
        q=original.copy();q.data=original.data.copy();sc.collection.objects.link(q);q.name=name
        bm=bmesh.new();bm.from_mesh(q.data)
        for val,normal in [(low,(0,-1,0)),(high,(0,1,0))]:
            plane=q.matrix_world.inverted()@author_pos((0,val,0))
            result=bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),dist=1e-6,plane_co=plane,plane_no=normal,clear_outer=True)
            cut=[e for e in result['geom_cut'] if isinstance(e,bmesh.types.BMEdge) and e.is_boundary]
            if cut:
                filled=bmesh.ops.holes_fill(bm,edges=cut,sides=0)
                for f in filled.get('faces',[]):f.material_index=P
        bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(q.data);bm.free();q.data.update();bpy.context.view_layer.update()
        cen=sum([q.matrix_world@Vector(c) for c in q.bound_box],Vector())/8;recenter(q,cen);parent_keep(q,HULL)
    bpy.data.objects.remove(original,do_unlink=True)

def merge_into(base,other):
    worlds={c:c.matrix_world.copy() for c in base.children}
    bpy.ops.object.select_all(action='DESELECT');base.select_set(True);other.select_set(True);bpy.context.view_layer.objects.active=base;bpy.ops.object.join();base.select_set(False)
    for c,w in worlds.items():c.matrix_world=w

def cut_cylinder(o,center,r,z0,z1,self_intersections=True):
    bounds=[o.matrix_world@Vector(c) for c in o.bound_box];lower=Vector(tuple(min(v[i] for v in bounds) for i in range(3)));upper=Vector(tuple(max(v[i] for v in bounds) for i in range(3)))
    bpy.ops.mesh.primitive_cylinder_add(vertices=64,radius=r,depth=z1-z0,location=(center[0],center[1],(z0+z1)/2));c=bpy.context.object;c.data.materials.append(MATS[P])
    bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Real crew opening','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.use_self=self_intersections;mod.use_hole_tolerant=True;mod.object=c
    bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(c,do_unlink=True)
    inv=o.matrix_world.inverted()
    for vert in o.data.vertices:
        w=o.matrix_world@vert.co
        for axis in range(3):w[axis]=max(lower[axis],min(upper[axis],w[axis]))
        vert.co=inv@w
    for i,mat in enumerate(o.data.materials):
        if mat is None:o.data.materials[i]=MATS[P]
# Openings are functional. Dark wells stop daylight and model the visible depth.
for name,center,r,top,bottom in [('Commander',(-.43,-.10),.256,2.39,1.85),('Loader',(.44,-.13),.19,2.30,1.80),('Driver',(0,-1.98),.19,1.58,1.06)]:
    c=author_pos((*center,0));z0=author_pos((0,0,bottom)).z;z1=author_pos((0,0,top+.08)).z
    base=HULL if name=='Driver' else TURRET
    cut_cylinder(base,c,r,z0,z1)
    if name=='Commander':cut_cylinder(bpy.data.objects['Part_Turret_CupolaRing'],c,r,z0,z1)
    well=Mesh('crew_well');well.cyl((c.x,c.y,z0),(c.x,c.y,z1-.08),r-.004,R,48,cap=False)
    well.cyl((c.x,c.y,z0-.015),(c.x,c.y,z0),r,R,48)
    well.ring((c.x,c.y,z1-.10),(0,0,1),r+.008,.012,S,48,6)
    merge_into(base,well.obj(bevel=.002))
# Small fixed bolt bosses behind removable fixtures make the bare armour intentional.
mount=Mesh('Fixed_mounting_bosses')
for side in [-1,1]:
    for y in [-2.3,-.7,.3,1.4,2.65]:
        c=author_pos((side*1.02,y,1.407));mount.box(c,(.055,.08,.025),P);bolt(mount,c+Vector((0,0,.018)),r=.012)
merge_into(HULL,mount.obj(bevel=.003))
# Original rear beam is now independently removable; secured below the rear inspection doors.
beam=Mesh('Part_UnditchingBeam');beam.cyl(author_pos((-.84,3.19,.58)),author_pos((.84,3.19,.58)),.075,D,20)
for xx in [-.6,.6]:beam.ring(author_pos((xx,3.19,.58)),(1,0,0),.078,.008,S,20,6)
beam=beam.obj(author_pos((0,3.19,.58)),.003);parent_keep(beam,HULL)
socket('Fx_Engine',author_pos((0,2.15,1.50)),HULL);socket('Fx_Ammo',author_pos((0,.2,.88)),HULL);socket('Fx_Hatch',author_pos((-.43,-.10,2.44)),TURRET)
# Track V makes one trip around the loop. Equivalent material UV transforms
# preserve the approved texture look everywhere those shared materials are used.
uvratio=total/.62
for q in [o for o in sc.objects if o.type=='MESH']:
    layer=q.data.uv_layers.active
    if not layer:continue
    for poly in q.data.polygons:
        if q.data.materials[poly.material_index].name in ['Steel','Tracks','DryMud']:
            for li in poly.loop_indices:layer.data[li].uv.y/=uvratio
for mat in [MATS[S],MATS[T],MATS[D]]:
    nt=mat.node_tree;coords=nt.nodes.new('ShaderNodeTexCoord');mapping=nt.nodes.new('ShaderNodeMapping');mapping.inputs['Scale'].default_value=(1,uvratio,1);nt.links.new(coords.outputs['UV'],mapping.inputs['Vector'])
    for node in nt.nodes:
        if node.type=='TEX_IMAGE':nt.links.new(mapping.outputs['Vector'],node.inputs['Vector'])
# Rebuild export set after splitting. Preserve every pre-existing articulated pivot.
asset=[o for o in sc.objects if o.type in {'MESH','EMPTY'}]
for o in asset:
    if o.type=='MESH':
        bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Retrofit triangulation','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
        if '_part' in o.data.attributes:o.data.attributes.remove(o.data.attributes['_part'])
bpy.context.view_layer.update()
tri=sum(len(o.data.polygons) for o in asset if o.type=='MESH')
print('RETROFIT_HEALTHY',tri,'parts',sum(o.name.startswith('Part_') for o in asset),flush=True)

# Ensure texture + tint uses standard glTF multiply; patch metadata only after export.
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
glb=os.path.join(ROOT,'is3-heavy-tank.glb')
bpy.ops.export_scene.gltf(filepath=glb,export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_tangents=False,export_materials='EXPORT',export_image_format='AUTO',export_extras=True)
def patch_glb(path):
    data=open(path,'rb').read();jlen,jtype=struct.unpack_from('<II',data,12);j=json.loads(data[20:20+jlen]);tail=data[20+jlen:]
    for m in j['materials']:
        if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.13,.15,.08,1]
    raw=json.dumps(j,separators=(',',':')).encode();raw+=b' '*((-len(raw))%4)
    open(path,'wb').write(struct.pack('<III',0x46546C67,2,20+len(raw)+len(tail))+struct.pack('<II',len(raw),0x4E4F534A)+raw+tail)
patch_glb(glb)
def game(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
bpy.context.view_layer.update()
manifest={'units':'metres','axes':'+X left, +Y up, +Z forward','triangles':tri,'bounds':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]},'paints':[{'name':name,'linear_rgb':color} for name,color in PAINTS], 'nodes':[{'name':o.name,'world_position':game(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type} for o in asset], 'wheel_order':'front idler, six road wheels, rear drive sprocket','track_uv':'U across belt; V is continuous path distance / 0.62m; repeat wrap','articulation':{'Turret':'Y','Gun':'X','Wheel_L1..8 / Wheel_R1..8':'X','Hatch_Commander':'X'},'reference_note':'Low stowed DShK fitted; antenna shortened to preserve 2.45m overall height.'}
open(os.path.join(ROOT,'nodes.json'),'w').write(json.dumps(manifest,indent=2))
open(os.path.join(ROOT,'NOTES.md'),'w').write(f'IS-3 style heavy tank: pike nose, low cast turret, 122mm gun, four rear fuel tanks; metres, +Z forward.\n{tri:,} triangles; articulated turret, gun, commander hatch and sixteen wheels.\n8 materials: Paint, Steel, Tracks, Rubber, Glass, Lamps, Weapon, DryMud; Paint neutral texture × linear Soviet green [0.13,0.15,0.08].\nShared Cycles-baked tiling base up to 1024², normals up to 512², ORM up to 512²; standard uncompressed geometry; GLB {os.path.getsize(glb)/1048576:.2f} MiB.\nPoly Haven CC0 ids: green_metal_rust, metal_plate, rusty_metal_03, brown_mud_dry; https://polyhaven.com/license ; https://polyhaven.com/a/green_metal_rust ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/rusty_metal_03 ; https://polyhaven.com/a/brown_mud_dry .\nDifferences: shortened antenna to hold height; low stowed DShK added (not shown on sheet); small fittings and suspension simplified; no insignia.\n')
print('FIRST_GLB_COMPLETE',os.path.getsize(glb),flush=True)
# FILE B: all damage geometry derives from approved healthy objects in their world frame.
healthy=set(asset)
bpy.ops.object.select_all(action='DESELECT')
bake_mat('Burnt','brown_mud_dry',(.085,.078,.065),.35,.94,.75)
bake_mat('Scars','rusty_metal_03',(.28,.29,.28),.82,.54,.55)
BURNT=bpy.data.materials['Burnt'];SCARS=bpy.data.materials['Scars']
# A narrow dark swatch in the Scars bake supports actual soot and black cavity geometry.
scars_image=bpy.data.images.get('Scars_BaseColor')
if scars_image is None:scars_image=next(n.image for n in SCARS.node_tree.nodes if n.type=='TEX_IMAGE' and n.image and 'BaseColor' in n.image.name)
arr=np.array(scars_image.pixels[:],dtype=np.float32).reshape(scars_image.size[1],scars_image.size[0],4)
# Soft soot edge in a dedicated RGBA trim; other UV regions retain opaque steel.
width=arr.shape[1];trim=max(12,width//8)
arr[:,:trim,:3]=.007
for x in range(trim):arr[:,x,3]=max(0,1-x/(trim-2))**.85
rgba=bpy.data.images.new('Scars_BaseColor_RGBA',width=width,height=arr.shape[0],alpha=True)
rgba.pixels.foreach_set(arr.ravel());rgba.filepath_raw=os.path.join(TEX,'Scars_BaseColor_RGBA.png');rgba.file_format='PNG';rgba.save()
for node in SCARS.node_tree.nodes:
    if node.type=='TEX_IMAGE' and node.image==scars_image:
        node.image=rgba;SCARS.node_tree.links.new(node.outputs['Alpha'],SCARS.node_tree.nodes['Principled BSDF'].inputs['Alpha'])
SCARS.surface_render_method='DITHERED'
# Broader burnt-metal variation remains greyscale with a restrained oxidised bias.
for node in BURNT.node_tree.nodes:
    if node.type=='TEX_IMAGE' and node.image and 'BaseColor' in node.image.name:
        im=node.image;px=np.array(im.pixels[:],dtype=np.float32).reshape(im.size[1],im.size[0],4)
        rng=np.random.default_rng(44);noise=rng.normal(size=(im.size[1],im.size[0]));spectrum=np.fft.fft2(noise)
        fy=np.fft.fftfreq(im.size[1])[:,None];fx=np.fft.fftfreq(im.size[0])[None,:];power=fx*fx+fy*fy
        soot=sum(weight*np.fft.ifft2(spectrum*np.exp(-power*radius*radius)).real for radius,weight in [(18,1),(45,2),(95,3)])
        soot=(soot-soot.min())/max(1e-8,soot.max()-soot.min());variation=.78+.44*soot
        px[:,:,:3]*=variation[:,:,None];px[:,:,:3]=np.clip(px[:,:,:3],.008,.22)
        variant=bpy.data.images.new('Burnt_BaseColor_Varied',width=im.size[0],height=im.size[1],alpha=False);variant.pixels.foreach_set(px.ravel());variant.filepath_raw=os.path.join(TEX,'Burnt_BaseColor_Varied.png');variant.file_format='PNG';variant.save();node.image=variant
TRACKSTEEL=bpy.data.materials['Tracks'].copy();TRACKSTEEL.name='TrackSteel'
for node in TRACKSTEEL.node_tree.nodes:
    if node.type=='TEX_IMAGE' and node.image:
        im=node.image.copy();im.name='Damage_'+im.name
        if max(im.size)>512:im.scale(512,512)
        im.filepath_raw=os.path.join(TEX,im.name+'.png');im.file_format='PNG';im.save();node.image=im
BI=MATS.index(BURNT);SI=MATS.index(SCARS)

def damage_materials(o,burnt=False):
    for i,m in enumerate(o.data.materials):
        if burnt:o.data.materials[i]=BURNT
        elif m is None:o.data.materials[i]=SCARS
        elif m.name=='Paint':pass
        elif m.name=='Rubber':pass
        elif m.name in ['Tracks','DryMud']:o.data.materials[i]=TRACKSTEEL
        else:o.data.materials[i]=SCARS

def duplicate(o,name):
    bpy.context.view_layer.update();w=o.matrix_world.copy();q=o.copy();q.data=o.data.copy();q.parent=None;sc.collection.objects.link(q);q.name=name;q.matrix_world=w;return q

def join_copies(obs,name,origin):
    bpy.ops.object.select_all(action='DESELECT')
    for o in obs:o.select_set(True)
    bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();q=obs[0];q.name=name;recenter(q,origin);q.select_set(False);return q

HD=socket('Hull_Damage',(0,0,0));TD=socket('Turret_Damage',TURRET.matrix_world.translation)
parts=[o for o in asset if o.name.startswith('Part_')];twins={}
for part in parts:
    selected=any(k in part.name for k in ['FuelTank','Fender_F','Fender_R','EngineGrille','Stowage','LampGuard','Turret_GrabRail','Turret_Sight','Turret_CupolaRing'])
    if not selected:continue
    q=duplicate(part,'Dmg_'+part.name[5:]);damage_materials(q);twins[part.name]=q.name
    # Fold sheet metal around its fastening line, rather than adding random vertex noise.
    if 'Fender' in part.name:
        for v in q.data.vertices:
            x,y,z=v.co;weight=max(0,min(1,abs(x)/.24));v.co.z+=weight*(.16+.12*sin(y*4));v.co.x+=math.copysign(.055*weight,x)
    elif 'FuelTank' in part.name:
        for v in q.data.vertices:
            x,y,z=v.co; dent=math.exp(-((y/.24)**2+(z/.2)**2));v.co.x-=math.copysign(.11*dent,x);v.co.z-=.045*sin(y*4)
        # Through tear in the exposed end, with an irregular raised torn lip.
        c=q.matrix_world.translation;side=1 if c.x>0 else -1
        bpy.ops.mesh.primitive_cylinder_add(vertices=12,radius=.09,depth=.8,location=c+Vector((0,-.18,.045)),rotation=(0,pi/2,0));cut=bpy.context.object;cut.data.materials.append(SCARS)
        bpy.context.view_layer.objects.active=q;mod=q.modifiers.new('Fuel vessel puncture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cut
        bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
        rim=Mesh('torn_tank_lip');pos=c+Vector((side*.16,-.18,.045))
        for k in range(9):
            a=k*2*pi/9;b=(k+1)*2*pi/9
            vs=[pos+Vector((0,.09*cos(a),.09*sin(a))),pos+Vector((0,.09*cos(b),.09*sin(b))),pos+Vector((side*.055,.13*cos((a+b)/2),.13*sin((a+b)/2)))]
            rim.mesh(vs,[(0,1,2)],SI)
        rr=rim.obj(bevel=0);damage_materials(rr);merge_into(q,rr)
    elif 'Grille' in part.name:
        for v in q.data.vertices:v.co.z+=.25*max(0,1-abs(v.co.x)/.5)*(v.co.y+.6)
    elif 'GrabRail' in part.name or 'LampGuard' in part.name:
        for v in q.data.vertices:v.co.z-=.10*(.5+sin(v.co.y*6))
    else:
        for v in q.data.vertices:v.co.z+=.10*sin(v.co.x*7)*max(.2,abs(v.co.y)*2)
    # Scorched fastening zones retain recolourable intact paint elsewhere.
    for poly in q.data.polygons:
        if poly.index%13==0:poly.material_index=next((i for i,m in enumerate(q.data.materials) if m==SCARS),0)
    parent_keep(q,TD if part.name.startswith('Part_Turret_') else HD)

# Armour-contact hit overlays are ray-projected against the actual curved/sloping armour.
def projected(target,p,d,offset=.003):
    inv=target.matrix_world.inverted();origin=Vector(p)-Vector(d)*4
    ok,loc,n,_=target.ray_cast(inv@origin,(inv.to_3x3()@Vector(d)).normalized(),distance=8)
    if not ok:
        # Near a rounded silhouette, shorten the soot fan toward the armour centre.
        center=sum((target.matrix_world@Vector(c) for c in target.bound_box),Vector())/8
        for step in range(1,16):
            adjusted=Vector(p).lerp(center,step*.035);origin=adjusted-Vector(d)*4
            ok,loc,n,_=target.ray_cast(inv@origin,(inv.to_3x3()@Vector(d)).normalized(),distance=8)
            if ok:break
        if not ok:raise RuntimeError('Scar projection missed '+target.name+' '+str(p))
    n=(target.matrix_world.to_3x3()@n).normalized();return target.matrix_world@loc+n*offset,n

def scar_group(target,name,centres,d,parent):
    m=Mesh(name);direction=Vector(d).normalized();u=direction.cross(Vector((0,0,1))).normalized();v=u.cross(direction).normalized()
    for hit,c in enumerate(centres):
        c=Vector(c);p,n=projected(target,c,d);radius=.095 if hit==0 else .058
        # Ragged soot fan hugs the plate. Explicit swatch UVs make it soot, not metallic grey.
        ring=[]
        for i in range(22):
            a=i*2*pi/22;r=radius*(2.10+.20*sin(i*5.3));q,_=projected(target,c+u*r*cos(a)+v*r*sin(a),d,.0025);ring.append(q)
        start=len(m.f);m.mesh([p]+ring,[(0,1+i,1+(i+1)%22) for i in range(22)],SI)
        for fi in range(start,len(m.f)):m.uv[fi]=[(.004,.5),(.116,.5),(.116,.5)]
        if hit==0:
            # Black penetration mouth with individually peeled steel petals.
            inner=[];outer=[]
            for i in range(14):
                a=i*2*pi/14;rr=radius*(.58+.08*sin(i*2.7))
                q,nn=projected(target,c+u*rr*cos(a)+v*rr*sin(a),d,.006);inner.append(q)
                q,nn=projected(target,c+u*rr*1.3*cos(a)+v*rr*1.3*sin(a),d,.003);outer.append(q+nn*(.004+.006*(i%3)/2))
            start=len(m.f);m.mesh([p+n*.001]+inner,[(0,1+i,1+(i+1)%14) for i in range(14)],SI)
            for fi in range(start,len(m.f)):m.uv[fi]=[(.003,.5)]*len(m.f[fi])
            for i in range(14):
                j=(i+1)%14;m.mesh([inner[i],inner[j],outer[j],outer[i]],[(0,1,2,3)],SI)
                m.uv[-1]=[(.3,.2),(.5,.3),(.7,.5),(.4,.7)]
        else:
            # Directional ricochet furrow with chipped bright edges.
            for j in range(3):
                cc=c+u*(j-1)*.035+v*(j-1)*.017;verts=[]
                for du,dv in [(-.045,-.013),(.065,-.01),(.055,.013),(-.035,.024)]:
                    pt,nn=projected(target,cc+u*du+v*dv,d,.0038);verts.append(pt)
                m.mesh(verts,[(0,1,2,3)],SI);m.uv[-1]=[(.4,.2),(.8,.2),(.8,.7),(.4,.7)]
    q=m.obj(bevel=0);damage_materials(q);parent_keep(q,parent);return q
scar_specs=[
(HULL,'Hull_Front',[(.48,-2.65,1.19),(-.52,-2.7,1.16),(.20,-2.7,1.37),(-.24,-2.8,.99)],(0,1,0),HD),
(HULL,'Hull_L',[(1.12,-1.2,.96),(1.12,-.3,1.02),(1.12,.65,.97),(1.12,1.7,1.02)],(-1,0,0),HD),
(HULL,'Hull_R',[(-1.12,-1.2,.96),(-1.12,-.3,1.02),(-1.12,.65,.97),(-1.12,1.7,1.02)],(1,0,0),HD),
(HULL,'Hull_Rear',[(.62,3.2,.89),(-.64,3.2,.89),(.27,3.2,.83),(-.25,3.2,1.06)],(0,-1,0),HD),
(TURRET,'Turret_Front',[(.52,-1.6,1.90),(-.56,-1.6,1.91),(.34,-1.6,2.13),(-.32,-1.6,2.10)],(0,1,0),TD),
(TURRET,'Turret_L',[(1.1,-.7,1.92),(1.1,-.15,2.03),(1.1,.43,1.91),(1.1,-.55,2.15)],(-1,0,0),TD),
(TURRET,'Turret_R',[(-1.1,-.7,1.92),(-1.1,-.15,2.03),(-1.1,.43,1.91),(-1.1,-.55,2.15)],(1,0,0),TD),
(TURRET,'Turret_Rear',[(.48,.9,1.93),(-.48,.9,1.93),(.22,.9,2.14),(-.25,.9,2.08)],(0,-1,0),TD)]
scars=[]
for target,name,centres,d,parent in scar_specs:
    scars.append(scar_group(target,'Scar_'+name,[author_pos(c) for c in centres],d,parent))
# Thrown belts derive from linked geometry: loose upper run unrolls onto the ground ahead.
def thrown(source,name):
    q=duplicate(source,name);damage_materials(q)
    # Connected primitive components are assigned to their original track link, then
    # moved rigidly: no threshold can stretch a track polygon across the tank.
    me=q.data;parents=list(range(len(me.vertices)))
    def root(i):
        while parents[i]!=i:parents[i]=parents[parents[i]];i=parents[i]
        return i
    for e in me.edges:
        a,b=e.vertices;ra,rb=root(a),root(b)
        if ra!=rb:parents[rb]=ra
    components={}
    for vert in me.vertices:components.setdefault(root(vert.index),[]).append(vert.index)
    side=1 if source==TRACK_L else -1
    centers=[];angles=[]
    for i in range(96):
        d=(i+.5)*total/96;p=sample(d);tan=(sample(d+.015)-sample(d-.015)).normalized()
        centers.append(author_pos((side*1.278,p.x,p.y)));angles.append(math.atan2(tan.y,tan.x))
    groups={}
    for ids in components.values():
        center=sum((q.matrix_world@me.vertices[i].co for i in ids),Vector())/len(ids)
        index=min(range(96),key=lambda k:(center.y-centers[k].y)**2+(center.z-centers[k].z)**2)
        groups.setdefault(index,[]).extend(ids)
    inv=q.matrix_world.inverted()
    for index,ids in groups.items():
        c=centers[index];new=c.copy();rotation=Matrix.Identity(3);flat=False
        if c.z>.89:
            # The fallen upper strand runs beside its own lower belt, joined at the rear.
            t=max(0,min(1,(c.y-1.65)/1.35));ease=t*t*(3-2*t)
            new.y=c.y-.72*(1-ease);new.x+=side*.68*(1-ease)
            new.z=.075+(c.z-.075)*ease
            slope=(c.z-.075)*6*t*(1-t)/1.35
            rotation=Matrix.Rotation(math.atan2(slope,1+.72*6*t*(1-t)/1.35)-angles[index],3,'X');flat=ease<.02
        elif c.y<-2.76 and c.z>.25:
            # Front wrap is snapped away, leaving an unmistakable open idler gap.
            new.y=-3.48-(c.z-.25)*.55;new.x+=side*.68;new.z=.075
            rotation=Matrix.Rotation(-angles[index],3,'X');flat=True
        elif c.y>2.4 and c.z>.30:new.z-=.06*(c.z-.30)
        if flat:new.z=-min((rotation@(q.matrix_world@me.vertices[vi].co-c)).z for vi in ids)
        for vi in ids:
            w=q.matrix_world@me.vertices[vi].co;me.vertices[vi].co=inv@(new+rotation@(w-c))
    q.data.update()
    parent_keep(q,HD);return q
BL=thrown(TRACK_L,'Track_L_Broken');BR=thrown(TRACK_R,'Track_R_Broken')
# Gun_Broken keeps the exact trunnion pivot; tube is bent down at mid length.
qa=duplicate(GUN,'broken_mantlet');qb=duplicate(BARREL,'broken_barrel');damage_materials(qa);damage_materials(qb)
hinge=author_pos((0,-3.4,1.965))
for vert in qb.data.vertices:
    w=qb.matrix_world@vert.co
    if w.y<hinge.y:w=hinge+Matrix.Rotation(.40,3,'X')@(w-hinge)
    vert.co=qb.matrix_world.inverted()@w
GB=join_copies([qa,qb],'Gun_Broken',GUN.matrix_world.translation);parent_keep(GB,TD)
# Wreck meshes: copies only, burnt materials, sagged suspension and ruptured deck.
def on_turret(o):
    p=o
    while p:
        if p==TURRET:return True
        p=p.parent
    return False
hullbits=[];turretbits=[]
for src in asset:
    if src.type!='MESH':continue
    isturret=on_turret(src)
    if any(k in src.name for k in ['FuelTank','TowCable','Headlight','LampGuard','Stowage','SpareTrack','DShKMount','Antenna']):continue
    if src.name in ['MG','Part_Fender_FL','Part_Fender_RR','Part_Fender_L1']:continue
    source=BL if src==TRACK_L else src
    q=duplicate(source,'wreck_'+src.name)
    if src==HULL:cut_cylinder(q,author_pos((0,-.34,0)),1.035,1.02,2.0,False)
    if src.name.startswith('Wheel_'):
        rubberverts={vi for poly in q.data.polygons if q.data.materials[poly.material_index].name=='Rubber' for vi in poly.vertices}
        for vi in rubberverts:
            vert=q.data.vertices[vi];radius=math.hypot(vert.co.y,vert.co.z)
            if radius>.10:
                shrink=max(.1,(radius-.045)/radius);vert.co.y*=shrink;vert.co.z*=shrink
        q.location.z-=.05
    damage_materials(q,True)
    if src==TRACK_R:
        for vert in q.data.vertices:
            if vert.co.z>.9:vert.co.z-=.15*max(0,1-abs(vert.co.y)/3.3)
    if 'Fender' in src.name or 'EngineGrille' in src.name:
        for vert in q.data.vertices:
            vert.co.z+=.32*sin(vert.co.y*2.2)*max(.15,abs(vert.co.x)*2)
    if src.name.startswith('Hatch_'):q.matrix_world=q.matrix_world@Matrix.Rotation(-1.6,4,'X')
    if src in [GUN,BARREL]:
        pivot=GUN.matrix_world.translation;q.matrix_world=Matrix.Translation(pivot)@Matrix.Rotation(.22,4,'X')@Matrix.Translation(-pivot)@q.matrix_world
    (turretbits if isturret else hullbits).append(q)
WH=join_copies(hullbits,'Wreck_Hull',(0,0,0));WT=join_copies(turretbits,'Wreck_Turret',TURRET.matrix_world.translation)
well=Mesh('Burnt_ring_well');c=author_pos((0,-.34,0));well.cyl((c.x,c.y,.84),(c.x,c.y,1.53),1.03,BI,64,cap=False);well.cyl((c.x,c.y,.82),(c.x,c.y,.84),1.03,BI,64);well.ring((c.x,c.y,1.54),(0,0,1),1.07,.035,BI,64,8)
q=well.obj(bevel=.004);damage_materials(q,True);merge_into(WH,q)
# Closed underside, bearing ring, exposed basket stub for the thrown turret.
basket=Mesh('Turret_basket_stub');basket.cyl((c.x,c.y,1.59),(c.x,c.y,1.62),.99,BI,64);basket.ring((c.x,c.y,1.53),(0,0,1),.98,.042,BI,64,8);basket.ring((c.x,c.y,1.13),(0,0,1),.63,.03,BI,48,6)
for a in range(8):
    ang=a*pi/4;basket.cyl((c.x+.76*cos(ang),c.y+.76*sin(ang),1.55),(c.x+.63*cos(ang),c.y+.63*sin(ang),1.13),.024,BI,10)
q=basket.obj(bevel=.004);damage_materials(q,True);merge_into(WT,q)
# Wreck alone is approximately forty percent of healthy, with B under 110k triangles.
for o,target in [(WH,33000),(WT,12500),(BL,12500),(BR,12500)]:
    o.data.validate(clean_customdata=False);o.data.update();o.data.calc_loop_triangles();count=len(o.data.loop_triangles)
    if count>target:
        bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Damage game budget','DECIMATE');mod.ratio=target/count;bpy.ops.object.modifier_apply(modifier=mod.name)
damage=[o for o in sc.objects if o not in healthy and o.type in {'MESH','EMPTY'}]
for o in damage:
    if o.type=='MESH':
        bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Damage triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
        o.data.validate(clean_customdata=False);o.data.update()
        # Strip unused slots: only the five contract materials can enter File B.
        used=sorted(set(p.material_index for p in o.data.polygons));materials=[o.data.materials[i] for i in used];lookup={old:new for new,old in enumerate(used)};indices=[lookup[p.material_index] for p in o.data.polygons];o.data.materials.clear()
        for mat in materials:o.data.materials.append(mat)
        for p,i in zip(o.data.polygons,indices):p.material_index=i
bpy.context.view_layer.update()
for o in damage:
    if o.parent is None:assert o.name in ['Hull_Damage','Turret_Damage','Wreck_Hull','Wreck_Turret'],o.name
bpy.ops.object.select_all(action='DESELECT')
for o in damage:o.select_set(True)
damage_glb=os.path.join(ROOT,'is3-heavy-tank-damage.glb')
bpy.ops.export_scene.gltf(filepath=damage_glb,export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_tangents=False,export_materials='EXPORT',export_image_format='AUTO',export_extras=True)
patch_glb(damage_glb)
damage_tri=sum(len(o.data.polygons) for o in damage if o.type=='MESH')
print('DAMAGE_BUDGET',damage_tri,'limit 110000',flush=True)
manifest['track_uv']='U across belt; V once around the complete loop'
manifest['articulation'].update({'Barrel':'translation along local +Z for recoil','MG':'Y','Hatch_Driver':'X','Hatch_Loader':'X','Wheel_L9..11 / Wheel_R9..11':'X'})
healthy_points=[o.matrix_world@vert.co for o in asset if o.type=='MESH' for vert in o.data.vertices]
manifest['bounds']={'min':[min(game(v)[i] for v in healthy_points) for i in range(3)],'max':[max(game(v)[i] for v in healthy_points) for i in range(3)]}
manifest['wheel_order']='Existing L1/R1 front idler, L2..7/R2..7 road wheels, L8/R8 rear sprockets; L9..11/R9..11 return rollers front to back (legacy wheel names preserved)'
manifest['contract']='tank-damage/v1';manifest['damage']={'parts':[{'part':o.name,'twin':twins.get(o.name)} for o in parts],'scars':[o.name for o in scars],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']}
damage_points=[game(o.matrix_world@vert.co) for o in damage if o.type=='MESH' for vert in o.data.vertices]
manifest['damage_bounds']={'min':[min(v[i] for v in damage_points) for i in range(3)],'max':[max(v[i] for v in damage_points) for i in range(3)]}
manifest['damage_nodes']=[{'name':o.name,'world_position':game(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type} for o in damage]
manifest['files']={'healthy':{'file':os.path.basename(glb),'triangles':tri,'bytes':os.path.getsize(glb)},'damage':{'file':os.path.basename(damage_glb),'triangles':damage_tri,'bytes':os.path.getsize(damage_glb)}}
open(os.path.join(ROOT,'nodes.json'),'w').write(json.dumps(manifest,indent=2))
open(os.path.join(ROOT,'NOTES.md'),'w').write(f'IS-3 retrofit: original silhouette, materials and legacy pivots retained; {len(parts)} detachable parts, articulated hatches, recoil barrel and tank-damage/v1 pair.\nHealthy {tri:,} triangles; damage {damage_tri:,} triangles; wreck {len(WH.data.polygons)+len(WT.data.polygons):,}; healthy {os.path.getsize(glb)/1048576:.2f} MiB, damage {os.path.getsize(damage_glb)/1048576:.2f} MiB.\nHealthy: Paint, Steel, Tracks, Rubber, Glass, Lamps, Weapon, DryMud; damage: Paint, Burnt, Scars, TrackSteel, Rubber; unchanged Paint factor [0.13,0.15,0.08].\nOriginal Cycles baked tiles reused unchanged; healthy base up to 1024, normal/ORM up to 512; damage up to 512 except shared Paint; new Burnt/Scars Cycles bakes included.\nPoly Haven CC0: green_metal_rust, metal_plate, rusty_metal_03, brown_mud_dry; https://polyhaven.com/license ; original recipe retained; --rebake regenerates textures.\nReference differences retained: shortened aerial, low stowed DShK, simplified fittings; damage is authored fictional battle wear; no insignia or logos.\n')
print('DAMAGE_GLB_COMPLETE',damage_tri,os.path.getsize(damage_glb),flush=True)
for o in damage:o.hide_render=True;o.hide_set(True)
bpy.ops.object.select_all(action='DESELECT')

# Soft daylight presentation, camera on lit side.
world=bpy.data.worlds.new('Daylight');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.83,1);world.node_tree.nodes['Background'].inputs[1].default_value=.55;sc.world=world
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.018));ground=bpy.context.object;ground.name='Render_ground'
mat=bpy.data.materials.new('Ground only');mat.diffuse_color=(.26,.28,.30,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.26,.28,.30,1);mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.85;ground.data.materials.append(mat)
def area(name,pos,power,size):
    data=bpy.data.lights.new(name,'AREA');o=bpy.data.objects.new(name,data);sc.collection.objects.link(o);o.location=pos;o.rotation_euler=(Vector((0,-.5,1))-o.location).to_track_quat('-Z','Y').to_euler();data.energy=power;data.shape='DISK';data.size=size
area('Soft sky key',(2,-5,9),1900,7);area('Rear sky',(-5,4,6),1400,6)
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO';cam.data.lens=50
center=Vector((0,-1.25,1.15));cam.location=center+Vector((10,-14,6.1));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=10.4
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=32
sc.render.filepath=os.path.join(ROOT,'final.png');bpy.ops.render.render(write_still=True)
# Save native source checkpoint only remotely; not a requested delivery artifact.
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT,'is3-source.blend'))
cam.location=(12,0,2.2);target=Vector((0,-1.3,1.23));cam.location=(12,-1.3,1.23);cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=11.25
sc.render.filepath=os.path.join(ROOT,'side.png');bpy.ops.render.render(write_still=True)
cam.location=center+Vector((10,-14,6.1));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=10.4
clay=bpy.data.materials.new('Clay QA only');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.35,.35,.35,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.72
sc.view_layers[0].material_override=clay;sc.render.filepath=os.path.join(ROOT,'clay.png');bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
# Six recolours in one 3x2 contact sheet. Tiles rendered directly using the export material.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
frames=[]
for name,color in PAINTS:
    paint_factor.inputs[2].default_value=(*color,1);sc.render.filepath=os.path.join(TEX,'variant_'+name.replace(' ','_')+'.png');bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(sc.render.filepath,check_existing=False);frames.append(np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4));bpy.data.images.remove(im)
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,frame in enumerate(frames):
    row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=frame
im=bpy.data.images.new('Six paints',1920,720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
paint_factor.inputs[2].default_value=(*PAINTS[0][1],1)
print('BUILD_COMPLETE',flush=True)

# Consumer proof: discard authored scene and import BOTH delivered GLBs into an empty scene.
bpy.ops.wm.read_factory_settings(use_empty=True);sc=bpy.context.scene;sc.render.engine='CYCLES';sc.cycles.device='GPU';sc.cycles.samples=16;sc.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'
sc.view_settings.view_transform='AgX';sc.render.image_settings.file_format='PNG'
bpy.ops.import_scene.gltf(filepath=glb);A=list(sc.objects)
bpy.ops.import_scene.gltf(filepath=damage_glb);B=[o for o in sc.objects if o not in A]
lookup={o.name:o for o in A+B}
# Verify hierarchy and identity socket rotation on the imported delivery, not just the author graph.
assert lookup['Barrel'].parent==lookup['Gun'];assert lookup['Muzzle'].parent==lookup['Barrel']
assert lookup['Muzzle'].rotation_euler.to_matrix().is_identity
for nm in ['Turret','Gun','MG','Hatch_Commander']+[f'Wheel_{s}{i}' for s in ['L','R'] for i in range(1,9)]:
    expected=next(n['world_position'] for n in manifest['nodes'] if n['name']==nm)
    actual=game(lookup[nm].matrix_world.translation)
    assert max(abs(a-b) for a,b in zip(expected,actual))<.00003,(nm,expected,actual)
world=bpy.data.worlds.new('Proof daylight');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.83,1);world.node_tree.nodes['Background'].inputs[1].default_value=.55;sc.world=world
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.021));ground=bpy.context.object;ground.name='Proof_ground'
gm=bpy.data.materials.new('Proof_ground_material');gm.use_nodes=True;gm.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.26,.28,.30,1);gm.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.85;ground.data.materials.append(gm)
area('Proof key',(2,-5,9),1900,7);area('Proof rear',(-5,4,6),1400,6)
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO'
center=Vector((.65,-1.15,1.0));cam.location=center+Vector((10,-14,7.0));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=12.5
sc.render.resolution_x=800;sc.render.resolution_y=600;sc.render.resolution_percentage=100
# State titles are presentation-only geometry created after export.
textdata=bpy.data.curves.new('State title','FONT');textdata.size=.38;textdata.body='';title=bpy.data.objects.new('Proof_title',textdata);sc.collection.objects.link(title);title.parent=cam;title.location=(-5.6,4.02,-20)
labelmat=bpy.data.materials.new('Proof_label');labelmat.use_nodes=True;bs=labelmat.node_tree.nodes['Principled BSDF'];bs.inputs['Base Color'].default_value=(.015,.018,.022,1);bs.inputs['Roughness'].default_value=1;title.data.materials.append(labelmat)
def visible(o,value):o.hide_render=not value;o.hide_set(not value)
def subtree(o):return [o]+[q for child in o.children for q in subtree(child)]
def render_frame(filename):
    sc.render.filepath=os.path.join(ROOT,filename);bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(sc.render.filepath,check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(sc.render.resolution_y,sc.render.resolution_x,4);bpy.data.images.remove(im);return a
frames=[]
wt=lookup['Wreck_Turret'];wt_world=wt.matrix_world.copy()
state_names=['1  HEALTHY','2  HIT','3  STRIPPED','4  IMMOBILISED','5  BURNT WRECK','6  TURRET BLOWN OFF']
swap=['Part_FuelTank_L1','Part_FuelTank_R1','Part_Fender_FL','Part_Fender_FR','Part_EngineGrille_L','Part_EngineGrille_R']
for state in range(6):
    wt.matrix_world=wt_world
    for o in A:visible(o,state<4)
    for o in B:visible(o,False)
    if state==1:
        for o in B:
            if o.name.startswith('Scar_'):visible(o,True)
        for name in swap:visible(lookup[name],False);visible(lookup[twins[name]],True)
    if state==2:
        for o in A:
            if o.name.startswith('Part_'):visible(o,False)
    if state==3:
        visible(lookup['Track_L'],False)
        for o in subtree(lookup['Gun']):visible(o,False)
        visible(lookup['Track_L_Broken'],True);visible(lookup['Gun_Broken'],True)
    if state>=4:
        visible(lookup['Wreck_Hull'],True);visible(wt,True)
    if state==5:
        wt.matrix_world=Matrix.Translation((4.8,.4,1.0))@Matrix.Rotation(-.30,4,'Z')@Matrix.Rotation(pi/2,4,'Y')
        bpy.context.view_layer.update();zmin=min((wt.matrix_world@vert.co).z for vert in wt.data.vertices);wt.location.z-=zmin
    title.data.body=state_names[state]
    frames.append(render_frame('state_'+str(state+1)+'.png'))
canvas=np.zeros((1200,2400,4),dtype=np.float32)
for i,f in enumerate(frames):canvas[(1-i//3)*600:(2-i//3)*600,(i%3)*800:(i%3+1)*800]=f
im=bpy.data.images.new('Six delivered damage states',2400,1200);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=os.path.join(ROOT,'states.png');im.file_format='PNG';im.save()
# Moving-node sweep: every legacy pivot plus new hatches, barrel and return rollers.
wt.matrix_world=wt_world
for o in B:visible(o,False)
for o in A:visible(o,True)
saved={o:o.matrix_basis.copy() for o in A};tested=[]
for o in A:
    axis=None
    if o.name in ['Turret','MG']:axis='Z'
    elif o.name=='Barrel':axis='Y'
    elif o.name=='Gun' or o.name.startswith(('Wheel_','Hatch_')):axis='X'
    if axis:
        old=o.location.copy();o.matrix_basis=o.matrix_basis@Matrix.Rotation((-1 if o.name.startswith('Hatch_') else 1)*pi/6,4,axis);assert (o.location-old).length<1e-6;tested.append(o.name)
title.data.body='PIVOT SWEEP: 30 DEGREES';render_frame('pivots.png')
for o,mat in saved.items():o.matrix_basis=mat
manifest['verification']={'proof':'states.png imported both exported GLBs into an empty Blender scene','states':state_names,'swapped_parts':swap,'pivot_sweep':tested,'pivot_render':'pivots.png','muzzle_rotation':'identity; glTF +Z forward'}
open(os.path.join(ROOT,'nodes.json'),'w').write(json.dumps(manifest,indent=2))
print('ROUNDTRIP_STATES_COMPLETE',flush=True)

assert damage_tri<=110000,damage_tri
assert 100000<=tri<=160000,tri
assert os.path.getsize(glb)<=12000000,os.path.getsize(glb)
