"""T-34-85 library asset. Blender 5.2, clean scene, metres, baked CC0 materials.
Run: blender -b --python-exit-code 1 --python build_t34-85-tank.py
Optional -- --draft: 960px proof only; default writes the complete delivery.
"""
import bpy, bmesh, math, os, json, random, struct, urllib.request, sys
from mathutils import Vector, Matrix
from math import sin, cos, pi
from pathlib import Path
random.seed(34)
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
DRAFT='--draft' in sys.argv
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
S=bpy.context.scene
S.unit_settings.system='METRIC'; S.unit_settings.scale_length=1
S.render.engine='CYCLES'; S.cycles.samples=24; S.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices: d.use=d.type!='CPU'
S.cycles.device='GPU'
S.render.image_settings.file_format='PNG'
S.view_settings.view_transform='AgX'
S.render.film_transparent=False

# Download original CC0 photography; no other image sources.
PH=['metal_plate','rusty_metal_03','bark_brown_02']
def download(id,map):
    p=TEX/f'{id}_{map}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
        urllib.request.urlretrieve(url,str(p))
    return p
for id in PH:
    for mp in ['diff','nor_gl','arm']: download(id,mp)

def activate(o):
    bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def node(nt,typ): return nt.nodes.new(typ)
def img_node(nt,p,non=False):
    n=node(nt,'ShaderNodeTexImage'); n.image=bpy.data.images.load(str(p),check_existing=True)
    if non: n.image.colorspace_settings.name='Non-Color'
    return n

def bake_material(name,source,base,metal,rough,res=512):
    # Bake on a tile: photographic detail + fine procedural casting/grime.
    m=bpy.data.materials.new(name); m.use_nodes=True; nt=m.node_tree; nt.nodes.clear()
    out=node(nt,'ShaderNodeOutputMaterial'); bs=node(nt,'ShaderNodeBsdfPrincipled'); nt.links.new(bs.outputs['BSDF'],out.inputs['Surface'])
    uv=node(nt,'ShaderNodeTexCoord')
    diff=img_node(nt,download(source,'diff')); nt.links.new(uv.outputs['UV'],diff.inputs['Vector'])
    bw=node(nt,'ShaderNodeRGBToBW'); nt.links.new(diff.outputs['Color'],bw.inputs[0])
    ramp=node(nt,'ShaderNodeValToRGB')
    ramp.color_ramp.elements[0].position=.10; ramp.color_ramp.elements[1].position=.75
    low=.47 if name=='Paint' else .32
    ramp.color_ramp.elements[0].color=(*[v*low for v in base],1)
    ramp.color_ramp.elements[1].color=(*base,1)
    nt.links.new(bw.outputs[0],ramp.inputs[0])
    color=ramp.outputs[0]
    if name=='Wood':
        woodmul=node(nt,'ShaderNodeMixRGB');woodmul.blend_type='MULTIPLY';woodmul.inputs[0].default_value=1;woodmul.inputs[2].default_value=(.34,.27,.20,1);nt.links.new(diff.outputs[0],woodmul.inputs[1]);color=woodmul.outputs[0]
    if name in ['Paint','Burnt','Scars']:
        gr=node(nt,'ShaderNodeTexNoise');gr.inputs['Scale'].default_value=5.5;gr.inputs['Detail'].default_value=5;gr.inputs['Roughness'].default_value=.75
        nt.links.new(uv.outputs['UV'],gr.inputs['Vector'])
        rr=node(nt,'ShaderNodeValToRGB');rr.color_ramp.elements[0].position=.27;rr.color_ramp.elements[0].color=(.20,.20,.20,1);rr.color_ramp.elements[1].position=.63;rr.color_ramp.elements[1].color=(1,1,1,1)
        nt.links.new(gr.outputs['Fac'],rr.inputs[0])
        mul=node(nt,'ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=.48;nt.links.new(color,mul.inputs[1]);nt.links.new(rr.outputs[0],mul.inputs[2]);color=mul.outputs[0]
        mapping=node(nt,'ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(20,1.1,1);nt.links.new(uv.outputs['UV'],mapping.inputs[0])
        rain=node(nt,'ShaderNodeTexNoise');rain.inputs['Scale'].default_value=3;rain.inputs['Detail'].default_value=2;nt.links.new(mapping.outputs[0],rain.inputs[0])
        rr2=node(nt,'ShaderNodeValToRGB');rr2.color_ramp.elements[0].position=.36;rr2.color_ramp.elements[0].color=(.35,.35,.35,1);rr2.color_ramp.elements[1].position=.57
        nt.links.new(rain.outputs['Fac'],rr2.inputs[0]);mul2=node(nt,'ShaderNodeMixRGB');mul2.blend_type='MULTIPLY';mul2.inputs[0].default_value=.12;nt.links.new(color,mul2.inputs[1]);nt.links.new(rr2.outputs[0],mul2.inputs[2]);color=mul2.outputs[0]
    nt.links.new(color,bs.inputs['Base Color'])
    bs.inputs['Metallic'].default_value=metal; bs.inputs['Roughness'].default_value=rough
    nor=img_node(nt,download(source,'nor_gl'),True); nt.links.new(uv.outputs['UV'],nor.inputs['Vector'])
    nm=node(nt,'ShaderNodeNormalMap'); nm.inputs['Strength'].default_value=.26 if name=='Paint' else .5
    nt.links.new(nor.outputs['Color'],nm.inputs['Color'])
    noise=node(nt,'ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=180; noise.inputs['Detail'].default_value=3
    nt.links.new(uv.outputs['UV'],noise.inputs['Vector'])
    bump=node(nt,'ShaderNodeBump'); bump.inputs['Strength'].default_value=.22; bump.inputs['Distance'].default_value=.009 if name=='Paint' else .003
    nt.links.new(noise.outputs['Fac'],bump.inputs['Height']); nt.links.new(nm.outputs[0],bump.inputs['Normal']); nt.links.new(bump.outputs[0],bs.inputs['Normal'])
    arm=img_node(nt,download(source,'arm'),True); sep=node(nt,'ShaderNodeSeparateColor'); nt.links.new(arm.outputs[0],sep.inputs[0])
    roughmath=node(nt,'ShaderNodeMath'); roughmath.operation='MULTIPLY_ADD'; roughmath.inputs[1].default_value=.22; roughmath.inputs[2].default_value=rough-.10
    nt.links.new(sep.outputs[1],roughmath.inputs[0]); nt.links.new(roughmath.outputs[0],bs.inputs['Roughness'])
    bpy.ops.mesh.primitive_plane_add(size=2); plane=bpy.context.object; plane.data.materials.append(m)
    S.cycles.samples=8; S.render.bake.margin=4; S.render.bake.use_clear=True
    maps={}
    for kind,size in [('base',res),('normal',res),('orm',min(res,512))]:
        im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
        im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color'
        target=node(nt,'ShaderNodeTexImage'); target.image=im; nt.nodes.active=target
        activate(plane)
        if kind=='normal':
            nt.links.new(bs.outputs[0],out.inputs[0]); bpy.ops.object.bake(type='NORMAL')
        else:
            emit=node(nt,'ShaderNodeEmission')
            if kind=='base': nt.links.new(color,emit.inputs[0])
            else:
                comb=node(nt,'ShaderNodeCombineColor'); comb.inputs[0].default_value=1.; comb.inputs[2].default_value=metal
                nt.links.new(roughmath.outputs[0],comb.inputs[1]); nt.links.new(comb.outputs[0],emit.inputs[0])
            nt.links.new(emit.outputs[0],out.inputs[0]); bpy.ops.object.bake(type='EMIT')
        im.filepath_raw=str(TEX/(name+'_'+kind+'.png')); im.file_format='PNG'; im.save(); maps[kind]=im
        nt.nodes.remove(target)
    bpy.data.objects.remove(plane,do_unlink=True)
    nt.nodes.clear(); out=node(nt,'ShaderNodeOutputMaterial'); bs=node(nt,'ShaderNodeBsdfPrincipled'); nt.links.new(bs.outputs[0],out.inputs[0])
    for kind,im in maps.items():
        n=node(nt,'ShaderNodeTexImage'); n.image=im; n.label=kind
        if kind=='base':
            if name=='Paint':
                mix=node(nt,'ShaderNodeMixRGB'); mix.blend_type='MULTIPLY'; mix.inputs[0].default_value=1; mix.inputs[2].default_value=(.10,.13,.07,1); mix.name='PaintFactor'; nt.links.new(n.outputs[0],mix.inputs[1]); nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
            else: nt.links.new(n.outputs[0],bs.inputs['Base Color'])
        elif kind=='normal':
            nm=node(nt,'ShaderNodeNormalMap'); nt.links.new(n.outputs[0],nm.inputs['Color']); nt.links.new(nm.outputs[0],bs.inputs['Normal'])
        else:
            sep=node(nt,'ShaderNodeSeparateColor'); nt.links.new(n.outputs[0],sep.inputs[0]); nt.links.new(sep.outputs[1],bs.inputs['Roughness']); nt.links.new(sep.outputs[2],bs.inputs['Metallic'])
            group=bpy.data.node_groups.get('glTF Material Output')
            if not group:
                group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree'); group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
            gn=node(nt,'ShaderNodeGroup'); gn.node_tree=group; nt.links.new(sep.outputs[0],gn.inputs['Occlusion'])
    m.diffuse_color=(*((.10,.13,.07) if name=='Paint' else base),1)
    return m

M={}
for args in [('Paint','rusty_metal_03',(1,1,1),.08,.78,1024),('TrackSteel','rusty_metal_03',(.20,.16,.12),.72,.72,512),('Rubber','metal_plate',(.026,.028,.025),.0,.89,512),('Wood','bark_brown_02',(.3,.22,.14),0,.85,1024),('Interior','metal_plate',(.018,.021,.019),.1,.89,256),('Lamps','metal_plate',(.63,.68,.64),.42,.21,256),('Burnt','rusty_metal_03',(.075,.067,.057),.36,.94,512),('Scars','rusty_metal_03',(.34,.31,.27),.82,.68,512)]:
    M[args[0]]=bake_material(*args)
# Weapon finish shares the baked tile but is deliberately independent of armour recolouring.
M['GunSteel']=M['Paint'].copy();M['GunSteel'].name='GunSteel'
print('BAKES_COMPLETE',flush=True)

# Geometry builders. Vertices are authored in world space then moved into pivots.
allhealthy=[]
def finish(o,mat='Paint',smooth=False,bevel=0):
    if mat: o.data.materials.append(M[mat])
    if bevel:
        activate(o); mod=o.modifiers.new('Rounded manufactured edges','BEVEL'); mod.width=bevel; mod.segments=1; mod.limit_method='ANGLE'; mod.angle_limit=.55; bpy.ops.object.modifier_apply(modifier=mod.name)
    for p in o.data.polygons: p.use_smooth=smooth
    return o

def mesh(name,vs,fs,mat='Paint',smooth=False,bevel=0):
    me=bpy.data.meshes.new(name); me.from_pydata(vs,[],fs); me.update(); o=bpy.data.objects.new(name,me); S.collection.objects.link(o)
    bm=bmesh.new(); bm.from_mesh(me); bmesh.ops.recalc_face_normals(bm,faces=bm.faces); bm.to_mesh(me); bm.free()
    return finish(o,mat,smooth,bevel)

def box(loc,dim,mat='Paint',bev=.006,rot=None):
    x,y,z=[v/2 for v in dim]
    vs=[(a*x,b*y,c*z) for a,b,c 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)]]
    R=Matrix.Identity(3) if rot is None else rot
    vs=[Vector(loc)+R@Vector(v) for v in vs]
    return mesh('plate',vs,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],mat,False,bev)

def cyl(loc,r,depth,mat='Paint',axis=(0,0,1),n=40,bev=.004,r2=None):
    d=Vector(axis).normalized(); q=Vector((0,0,1)).rotation_difference(d); vs=[]
    for z,rad in [(-depth/2,r),(depth/2,r if r2 is None else r2)]:
        vs.extend([Vector(loc)+q@Vector((rad*cos(i*2*pi/n),rad*sin(i*2*pi/n),z)) for i in range(n)])
    fs=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
    o=mesh('cylinder',vs,fs,mat,True,bev)
    for p in o.data.polygons:
        if len(p.vertices)>4:p.use_smooth=False
    return o

def torus(loc,major,minor,mat='Paint',axis=(0,0,1),n=48,m=8):
    q=Vector((0,0,1)).rotation_difference(Vector(axis).normalized()); vs=[]; fs=[]
    for i in range(n):
        a=2*pi*i/n
        for j in range(m):
            b=2*pi*j/m; vs.append(Vector(loc)+q@Vector(((major+minor*cos(b))*cos(a),(major+minor*cos(b))*sin(a),minor*sin(b))))
    for i in range(n):
        for j in range(m):fs.append((i*m+j,((i+1)%n)*m+j,((i+1)%n)*m+(j+1)%m,i*m+(j+1)%m))
    return mesh('ring',vs,fs,mat,True)

def barrel_tube():
    vs=[];fs=[];n=48
    for y,r in [(-5.10,.052),(-2.20,.088),(-5.10,.0425),(-2.20,.044)]:
        vs.extend([(r*cos(i*2*pi/n),y,2.085+r*sin(i*2*pi/n)) for i in range(n)])
    for i in range(n):
        j=(i+1)%n;fs.extend([(i,j,n+j,n+i),(2*n+j,2*n+i,3*n+i,3*n+j),(j,i,2*n+i,2*n+j),(n+i,n+j,3*n+j,3*n+i)])
    o=mesh('hollow gun tube',vs,fs,'GunSteel',True)
    o.data.materials.append(M['Interior'])
    for i,p in enumerate(o.data.polygons):
        if i%4==1:p.material_index=1
    return o

def tube(points,r,mat='Paint',n=8):
    # Tangent-aligned tube, closed caps.
    vs=[]; fs=[]
    for i,p in enumerate(points):
        d=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(i-1,0)]); q=Vector((0,0,1)).rotation_difference(d.normalized())
        vs.extend([Vector(p)+q@Vector((r*cos(j*2*pi/n),r*sin(j*2*pi/n),0)) for j in range(n)])
    for i in range(len(points)-1):
        for j in range(n):fs.append((i*n+j,i*n+(j+1)%n,(i+1)*n+(j+1)%n,(i+1)*n+j))
    fs.extend([tuple(reversed(range(n))),tuple((len(points)-1)*n+j for j in range(n))])
    return mesh('tube',vs,fs,mat,True)

def ring(loc,outer,inner,depth,mat='Paint',axis=(0,0,1),n=48):
    q=Vector((0,0,1)).rotation_difference(Vector(axis).normalized()); vs=[]
    for z,r in [(-depth/2,outer),(depth/2,outer),(-depth/2,inner),(depth/2,inner)]:
        vs.extend([Vector(loc)+q@Vector((r*cos(i*2*pi/n),r*sin(i*2*pi/n),z)) for i in range(n)])
    fs=[]
    for i in range(n):
        j=(i+1)%n
        fs.extend([(i,j,n+j,n+i),(2*n+j,2*n+i,3*n+i,3*n+j),(n+i,n+j,3*n+j,3*n+i),(j,i,2*n+i,2*n+j)])
    return mesh('annulus',vs,fs,mat,True,0 if mat=='Interior' else .002)

def uv_project(o):
    me=o.data; uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers.active
    for p in me.polygons:
        n=p.normal; ax=max(range(3),key=lambda i:abs(n[i])); axes=[i for i in range(3) if i!=ax]
        for li in p.loop_indices:
            v=me.vertices[me.loops[li].vertex_index].co; uv.data[li].uv=(v[axes[0]]*.83,v[axes[1]]*.83)

def join(name,obs,pivot=(0,0,0),parent=None,healthy=True):
    obs=[o for o in obs if o is not None]
    bpy.ops.object.select_all(action='DESELECT')
    for o in obs:
        if not o.data.uv_layers:uv_project(o)
        o.select_set(True)
    bpy.context.view_layer.objects.active=obs[0]; bpy.ops.object.join(); o=obs[0]; o.name=name
    # all builders have identity transforms, except copies which are handled below
    world=o.matrix_world.copy(); p=Vector(pivot)
    o.data.transform(Matrix.Translation(-p)@world); o.matrix_world=Matrix.Translation(p)
    if not o.data.uv_layers:uv_project(o)
    if parent:
        world=o.matrix_world.copy(); o.parent=parent; o.matrix_world=world
    if healthy:allhealthy.append(o)
    return o

def empty(name,loc,parent=None,healthy=True):
    o=bpy.data.objects.new(name,None); S.collection.objects.link(o); o.location=loc; bpy.context.view_layer.update()
    if parent:
        w=o.matrix_world.copy(); o.parent=parent; o.matrix_world=w
    if healthy:allhealthy.append(o)
    return o

def bolt(loc,axis=(0,0,1),r=.013):return cyl(loc,r,.012,'TrackSteel',axis,6,.001)
def handle(points,r=.012):return tube(points,r,'Paint',8)
def loft_hull():
    # Three welded stations: undertray, wide lower shoulder, narrower upper deck.
    rings=[ [(-1.04,-2.60,.38),(1.04,-2.60,.38),(1.04,2.56,.38),(-1.04,2.56,.38)],
      [(-1.36,-3.05,1.03),(1.36,-3.05,1.03),(1.36,3.05,1.03),(-1.36,3.05,1.03)],
      [(-1.05,-2.06,1.58),(1.05,-2.06,1.58),(1.07,2.43,1.58),(-1.07,2.43,1.58)] ]
    vs=sum(rings,[]);fs=[(3,2,1,0),(8,9,10,11)]
    for k in range(2):
        for j in range(4):fs.append((k*4+j,k*4+(j+1)%4,(k+1)*4+(j+1)%4,(k+1)*4+j))
    return mesh('welded hull',vs,fs,bevel=.014)

h=[loft_hull()]
# Welds and suspension arms: every accessory has real mounting structure beneath it.
for side in [-1,1]:
    x=side*1.075
    h.append(tube([(x,-2.05,1.586),(x,2.41,1.586)],.008,'TrackSteel'))
    for y in [-1.98,-.99,0,.99,1.98]:
        h.append(cyl((side*1.08,y,.50),.115,.20,'Paint',(1,0,0),24))
        h.append(tube([(side*1.09,y+.20,.79),(side*1.12,y,.50)],.045))
    for y in [-1.6,-.6,.6,1.7,2.4]:
        h.append(box((side*1.13,y,1.48),(.065,.16,.10)))
        h.append(bolt((side*1.17,y,1.5),(side,0,0)))
    # rider handrails on sloping shoulders
    for y in [-1.35,-.55,.3]:
        h.append(handle([(side*1.095,y-.23,1.47),(side*1.18,y-.23,1.51),(side*1.18,y+.23,1.51),(side*1.095,y+.23,1.47)]))
# Front ball mount, towing clevises, spare mount pads
h.append(cyl((-.54,-2.54,1.32),.18,.065,axis=(0,-.486,.874),n=40))
bpy.ops.mesh.primitive_uv_sphere_add(segments=32,ring_count=16,radius=.148,location=(-.54,-2.59,1.36))
ball=bpy.context.object
for v in ball.data.vertices:v.co+=ball.location
ball.location=(0,0,0);h.append(finish(ball,'Paint',True))
h.append(cyl((-.54,-2.73,1.36),.036,.075,'TrackSteel',(0,1,0),24))
h.append(cyl((-.54,-2.785,1.36),.014,.085,'TrackSteel',(0,1,0),16))
for side in [-1,1]:
    for y,z in [(-2.99,.96),(2.88,1.02)]:
        h.append(box((side*.83,y,z),(.13,.09,.18)))
        h.append(torus((side*.83,y+(-.05 if y<0 else .05),z),.068,.019,'TrackSteel',(1,0,0),24,8))
# Rear access cover, hinges and bolts
h.append(cyl((0,2.81,1.27),.245,.027,axis=(0,.71,.70),n=40))
for x in [-.96,-.72,-.48,-.24,0,.24,.48,.72,.96]:
    h.append(bolt((x,2.52,1.53),(0,.71,.70)))
    h.append(bolt((x,2.93,1.15),(0,.71,.70)))
# Engine panel borders, fixed rib network.
for y in [1.05,1.5,2.15,2.38]:h.append(tube([(-.93,y,1.592),(.93,y,1.592)],.007,'TrackSteel'))
for x in [-.88,0,.88]:h.append(tube([(x,1.03,1.591),(x,2.36,1.591)],.007,'TrackSteel'))
# Turret ring surrounds dark internal volume. Boolean a proper ring opening.
base=h[0]
cut=cyl((0,-.43,1.55),.81,.50,None,n=64,bev=0)
activate(base);mod=base.modifiers.new('turret ring opening','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
h.append(ring((0,-.43,1.58),.91,.81,.10,n=64))
h.append(cyl((0,-.43,1.10),.80,.04,'Interior',n=64))
h.append(ring((0,-.43,1.35),.815,.78,.44,'Interior',n=64))
Rdriver=Matrix.Rotation(math.radians(29),3,'X')
cut=box((.49,-2.44,1.386),(.47,.43,.24),None,0,Rdriver);activate(base);mo=base.modifiers.new('driver opening','BOOLEAN');mo.operation='DIFFERENCE';mo.object=cut;bpy.ops.object.modifier_apply(modifier=mo.name);bpy.data.objects.remove(cut,do_unlink=True)
Hull=join('Hull',h)

# Five perforated Christie road wheels, plus front idler and rear drive wheel.
wheelobs=[]
for side,label in [(1,'L'),(-1,'R')]:
    for k,(y,z,r,kind) in enumerate([(-2.63,.70,.29,'idler')]+[(v,.49,.44,'road') for v in [-1.98,-.99,0,.99,1.98]]+[(2.61,.69,.32,'sprocket')],1):
        x=side*1.21; face=x+side*.13; bits=[]
        bits.append(ring((x,y,z),r,r-.075,.22,'Rubber' if kind=='road' else 'TrackSteel',(1,0,0),48))
        bits.append(ring((face,y,z),r-.06,r-.11,.042,'Paint',(1,0,0),48))
        bits.append(ring((face-side*.024,y,z),r-.1,.118,.018,'Interior',(1,0,0),32))
        # Pressed steel dished face with circular ventilation holes.
        disc=ring((face,y,z),r-.080,.123,.034,'Paint',(1,0,0),48)
        cutters=[]
        hole_r=.040 if kind=='road' else .027
        hole_ring=r-.144
        for j in range(12):
            a=j*2*pi/12
            cutters.append(cyl((face,y+hole_ring*cos(a),z+hole_ring*sin(a)),hole_r,.16,None,(1,0,0),12,0))
        cutter=join('wheel apertures',cutters,healthy=False)
        activate(disc);mod=disc.modifiers.new('pressed ventilation holes','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cutter,do_unlink=True)
        activate(disc);mod=disc.modifiers.new('hole rim rounding','BEVEL');mod.width=.003;mod.segments=1;mod.limit_method='ANGLE';bpy.ops.object.modifier_apply(modifier=mod.name)
        for p in disc.data.polygons:p.use_smooth=abs(p.normal.x)<.9
        bits.append(disc)
        for j in range(12):
            a=(j+.5)*2*pi/12
            bits.append(tube([(face+side*.026,y+.137*cos(a),z+.137*sin(a)),(face+side*.018,y+(r-.106)*cos(a),z+(r-.106)*sin(a))],.009,'Paint',6))
            if kind=='sprocket':bits.append(box((x,y+(r+.008)*cos(a),z+(r+.008)*sin(a)),(.18,.08,.065),'TrackSteel',.008,Matrix.Rotation(a,3,'X')))
        bits.append(cyl((face,y,z),.126,.083,n=32,axis=(1,0,0)))
        bits.append(cyl((face+side*.05,y,z),.063,.014,'TrackSteel',(1,0,0),24))
        for j in range(6):
            a=2*pi*j/6; bits.append(bolt((face+side*.046,y+.091*cos(a),z+.091*sin(a)),(side,0,0),.010))
        o=join(f'Wheel_{label}{k}',bits,(x,y,z));o['role']=kind;activate(o);md=o.modifiers.new('wheel optimized casting','DECIMATE');md.ratio=.42;bpy.ops.object.modifier_apply(modifier=md.name);wheelobs.append(o)

# Track links: a closed sagging capsule, individual shoes, waffle cleats and pins.
# Track V is cumulative loop fraction and wraps exactly once.
def trackpath():
    p=[]
    for i in range(37):p.append((-2.05+4.10*i/36,.025))
    for i in range(1,9):p.append((2.05+.822*i/8,.025+.403*i/8))
    for i in range(1,20):
        a=-pi/4+(3*pi/4)*i/19;p.append((2.61+.37*cos(a),.69+.37*sin(a)))
    for i in range(1,45):
        f=i/44;p.append((2.61-5.24*f,1.06-.03*f-.035*sin(pi*f)**2))
    for i in range(1,20):
        a=pi/2+(3*pi/4)*i/19;p.append((-2.63+.33*cos(a),.70+.33*sin(a)))
    for i in range(1,9):p.append((-2.863+.813*i/9,.467-.442*i/9))
    return p
P=trackpath()
def make_track(side,label,broken=False):
    # Resample to constant-pitch links.
    path=[Vector((y,z)) for y,z in P]; lens=[0]
    if broken:
        path=[Vector(p) for p in [(-2.20,.025),(1.98,.025),(2.66,.28),(2.96,.63),(2.88,.89),(2.60,1.02),(1.5,.90),(.5,.83),(-.6,.64),(-1.5,.42),(-2.4,.19),(-3.1,.055),(-4.2,.05)]]
    for a,b in zip(path,path[1:]+([] if broken else path[:1])):lens.append(lens[-1]+(b-a).length)
    L=lens[-1]; count=94; bits=[]
    for i in range(count):
        t=(i+.5)*L/count;j=next(j for j in range(len(path)) if lens[j+1]>t); f=(t-lens[j])/(lens[j+1]-lens[j]); p=path[j].lerp(path[(j+1)%len(path)],f)
        d=(path[(j+1)%len(path)]-path[j]).normalized(); y,z=p; ang=math.atan2(d.y,d.x)
        R=Matrix.Rotation(ang,3,'X'); center=Vector((side*1.205,y,z))
        shoe=[box(center,(.55,L/count*.94,.042),'TrackSteel',.004,R)]
        # Four transverse ribs and two longitudinal runners form waffle relief.
        for xx in [-.20,0,.20]:shoe.append(box(center+R@Vector((xx,0,-.031)),(.026,.105,.024),'TrackSteel',.002,R))
        for yy in [-.047,.047]:shoe.append(box(center+R@Vector((0,yy,-.027)),(.51,.018,.022),'TrackSteel',0,R))
        shoe.append(cyl(center+R@Vector((0,.063,0)),.018,.57,'TrackSteel',(1,0,0),8,0))
        if i%2==0:shoe.append(box(center+R@Vector((0,0,.051)),(.055,.055,.065),'TrackSteel',.009,R))
        for ob in shoe:
            uv_project(ob)
            for poly in ob.data.polygons:
                for li in poly.loop_indices:
                    v=ob.data.vertices[ob.data.loops[li].vertex_index].co-center
                    along=(R.transposed()@v).y
                    ob.data.uv_layers.active.data[li].uv=((v.x/.55)+.5,(i+.5+along/(L/count))/count)
        bits.extend(shoe)
    o=join(('Track_'+label+'_Broken') if broken else ('Track_'+label),bits,healthy=not broken)
    zmin=min(v.co.z for v in o.data.vertices)
    for v in o.data.vertices:v.co.z-=zmin
    return o
tracks=[make_track(1,'L'),make_track(-1,'R')]

# Cast turret, a dense smoothly varying elliptical loft with asymmetric nose.
TC=(0,-.43,1.62)
vs=[];fs=[];N=80
sections=[(1.62,.84,.94,0),(1.73,1.09,1.28,0),(1.83,1.21,1.39,0),(2.00,1.18,1.39,.03),(2.25,1.09,1.32,.07),(2.37,1.055,1.25,.08),(2.43,1.01,1.20,.08),(2.46,.955,1.16,.08)]
for z,rx,ry,shift in sections:
    for i in range(N):
        a=2*pi*i/N; bump=.003*sin(7*a+z*31)+.002*cos(19*a)
        vs.append(((rx+bump)*math.copysign(abs(cos(a))**.89,cos(a)),-.43+shift+(ry+bump)*math.copysign(abs(sin(a))**.92,sin(a)),z+.003*sin(a*13)))
for k in range(len(sections)-1):
    for i in range(N):fs.append((k*N+i,k*N+(i+1)%N,(k+1)*N+(i+1)%N,(k+1)*N+i))
fs.extend([tuple(reversed(range(N))),tuple((len(sections)-1)*N+i for i in range(N))])
tshell=mesh('cast shell',vs,fs,smooth=True,bevel=.007)
# Commander and loader genuine wells cut in top shell.
for x,y,r in [(.48,.03,.295),(-.49,-.28,.25)]:
    c=cyl((x,y,2.48),r,.44,None,n=48,bev=0);activate(tshell);mo=tshell.modifiers.new('hatch aperture','BOOLEAN');mo.operation='DIFFERENCE';mo.object=c;bpy.ops.object.modifier_apply(modifier=mo.name);bpy.data.objects.remove(c,do_unlink=True)
t=[tshell,ring((0,-.43,1.675),.94,.80,.09,n=64)]
# Subtle cast seam and cast cheek welds.
def cast_point(z,a,offset=.002):
    k=next((k for k in range(len(sections)-1) if sections[k][0]<=z<=sections[k+1][0]),len(sections)-2)
    lo,hi=sections[k],sections[k+1];f=(z-lo[0])/(hi[0]-lo[0])
    rx,ry,shift=[lo[j]+f*(hi[j]-lo[j]) for j in range(1,4)]
    bump=.003*sin(7*a+z*31)+.002*cos(19*a)
    return ((rx+bump+offset)*math.copysign(abs(cos(a))**.89,cos(a)),-.43+shift+(ry+bump+offset)*math.copysign(abs(sin(a))**.92,sin(a)),z+.003*sin(a*13))
pts=[cast_point(1.835,i*2*pi/80) for i in range(81)]
t.append(tube(pts,.007,'TrackSteel',6))
# Raised mould joints follow the actual curved casting, never a floating ellipse.
for a in [-2.15,-.99,.75,2.39]:
    pts=[cast_point(z,a+.012*sin(j*2.7),.002) for j,z in enumerate([1.845,1.96,2.08,2.20,2.31,2.385])]
    t.append(tube(pts,.0045,'TrackSteel',6))
for x,y,r in [(.48,.03,.295),(-.49,-.28,.25)]:
    t.append(ring((x,y,2.35),r+.02,r-.013,.26,'Interior',n=48));t.append(cyl((x,y,2.17),r,.02,'Interior',n=48))
t.append(ring((.48,.03,2.53),.307,.295,.17,n=48))
for side in [-1,1]:
    for x,y in [(1.105,-.45),(1.015,.20)]:
        t.append(cyl((side*x,y,2.22),.037,.024,axis=(side,0,0),n=16))
Turret=join('Turret',t,TC)
# Mantlet, barrel recoil and bore.
GUN=(0,-1.72,2.085)
g=[cyl((0,-1.71,2.085),.29,.53,axis=(1,0,0),n=48),box((0,-1.63,2.21),(.60,.21,.36),bev=.025)]
for x in [-.235,.235]:
    for z in [1.94,2.19]:g.append(bolt((x,-1.975,z),(0,-1,0),.019))
g.append(ring((-.20,-1.993,2.08),.027,.012,.018,'TrackSteel',(0,1,0),20));g.append(cyl((-.20,-1.975,2.08),.012,.004,'Interior',(0,1,0),16,0))
Gun=join('Gun',g,GUN,Turret)
BAR=(0,-1.97,2.085)
b=[cyl((0,-2.15,2.085),.115,.36,'GunSteel',axis=(0,1,0),n=48,r2=.15),barrel_tube(),ring((0,-5.085,2.085),.054,.0425,.030,'TrackSteel',(0,1,0),48),cyl((0,-4.90,2.085),.042,.012,'Interior',(0,1,0),32)]
Barrel=join('Barrel',b,BAR,Gun)
empty('Muzzle',(0,-5.10,2.085),Barrel)

# Closed hinged hatches with recessed crew space.
comm=[ring((.48,.03,2.535),.337,.295,.16,n=48)]
# observation slits in cupola: inset dark rectangles surrounded by rim.
for i in range(6):
    a=i*2*pi/6;comm.append(box((.48+.331*cos(a),.03+.331*sin(a),2.553),(.12,.012,.033),'Interior',.004,Matrix.Rotation(a-pi/2,3,'Z')))
cupola=join('Part_Turret_Cupola',comm,(.48,.03,2.535),Turret)
hc=[cyl((.48,.03,2.642),.335,.046,n=48),tube([(.41,-.03,2.67),(.41,-.03,2.70),(.55,-.03,2.70),(.55,-.03,2.67)],.010),cyl((.48,.34,2.634),.027,.24,axis=(1,0,0),n=20)]
HatchC=join('Hatch_Commander',hc,(.48,.34,2.63),Turret)
hl=[cyl((-.49,-.28,2.486),.28,.04,n=48),handle([(-.56,-.31,2.506),(-.56,-.31,2.54),(-.42,-.31,2.54),(-.42,-.31,2.506)]),cyl((-.49,-.01,2.48),.023,.20,axis=(1,0,0),n=20)]
HatchL=join('Hatch_Loader',hl,(-.49,-.01,2.48),Turret)
# Driver hatch sits flush on the upper glacis slope.
R=Matrix.Rotation(math.radians(29),3,'X'); driverloc=Vector((.49,-2.44,1.386))
dh=[box(driverloc,(.56,.52,.065),bev=.025,rot=R)]
for x in [.34,.64]:
    dh.append(box((x,-2.32,1.507),(.22,.14,.075),bev=.012,rot=R));dh.append(box((x,-2.376,1.51),(.15,.028,.025),'Interior',.003,rot=R))
dh.append(cyl((.49,-2.21,1.515),.023,.49,axis=(1,0,0),n=20))
HatchD=join('Hatch_Driver',dh,(.49,-2.21,1.515))
# Dark well immediately behind hatch, housed within complete front armour.
well=join('Driver_Well',[box((.49,-2.40,1.30),(.48,.40,.11),'Interior',.01,rot=R)])
# cut matching aperture under the driver hatch into the front face

allhealthy.remove(Hull);allhealthy.remove(well);Hull=join('Hull',[Hull,well])

parts=[]
def part(name,bits,pivot=None,parent=None):
    if pivot is None:
        pts=[v.co for o in bits for v in o.data.vertices];pivot=sum(pts,Vector())/len(pts)
    o=join('Part_'+name,bits,pivot,parent);parts.append(o);return o
parts.append(cupola)
for side,label in [(1,'L'),(-1,'R')]:
    # four detachable fender sections total, broad enough to alter silhouette.
    for front in [True,False]:
        y=-1.53 if front else 1.53;nm=('F' if front else 'R')+label
        bits=[box((side*1.265,y,1.093),(.47,3.00,.036),bev=.009)]
        end=-2.955 if front else 2.955
        bits.append(box((side*1.265,end,1.04),(.47,.20,.04),bev=.008,rot=Matrix.Rotation(.48 if front else -.48,3,'X')))
        bits.append(tube([(side*1.488,y-1.47,1.11),(side*1.488,y+1.47,1.11)],.012))
        for yy in [y-.9,y,y+.9]:bits.append(box((side*1.27,yy,1.12),(.40,.035,.024),bev=.003))
        part('Fender_'+nm,bits)
    # fuel drum tank-mounted longitudinally with rolled seams and retaining bands
    x=side*1.095;y=1.92;z=1.68
    bits=[cyl((x,y,z),.205,1.05,axis=(0,1,0),n=48)]
    for yy in [y-.52,y+.52]:
        bits.append(ring((x,yy,z),.211,.178,.022,axis=(0,1,0),n=48));bits.append(cyl((x,yy,z),.18,.014,axis=(0,1,0),n=40))
    for yy in [y-.32,y+.32]:bits.append(ring((x,yy,z),.216,.207,.048,'TrackSteel',(0,1,0),48))
    bits.append(cyl((x,1.76,1.894),.031,.026,n=16));part('FuelDrum_'+label,bits)
    # mounts stay attached underneath
    mount=join('mounttemp',[box((x,yy,1.50),(.29,.09,.18)) for yy in [1.60,2.24]],healthy=False)
    # join into fixed hull preserving it as Hull
    allhealthy.remove(Hull);Hull=join('Hull',[Hull,mount])
    # stowage box forward shelf
    x=side*1.21;y=-.93
    bits=[box((x,y,1.235),(.33,1.05,.23),bev=.014),box((x,y,1.358),(.35,1.07,.025),bev=.006)]
    for yy in [y-.33,y+.33]:bits.append(box((side*1.384,yy,1.25),(.025,.048,.11),'TrackSteel',.004))
    part('Stowage_'+label,bits)
    # exhaust pipes curved down from rear armour, protective collars
    x=side*.55
    bits=[cyl((x,2.80,1.29),.105,.08,axis=(0,1,0),n=32),tube([(x,2.78,1.33),(x,2.93,1.30),(x,2.97,1.22),(x,2.97,1.12)],.066,'TrackSteel',16),ring((x,2.97,1.10),.07,.050,.03,'TrackSteel',n=24)]
    part('Exhaust_'+label,bits)
# Engine grille: border, recessed fins; intact dark radiator underlayer.
for side,label in [(1,'L'),(-1,'R')]:
    x=side*.69;y=1.67
    bits=[box((x,y,1.625),(.43,1.15,.045),'TrackSteel',.006)]
    for i in range(14):bits.append(box((x,y-.51+i*.078,1.66),(.39,.030,.04),bev=.004))
    for xx in [x-.215,x+.215]:bits.append(box((xx,y,1.66),(.022,1.17,.056),bev=.004))
    part('EngineGrille_'+label,bits)
# Headlight and guard on front left fender shoulder. Right marker socket still supplied.
lamp=[cyl((1.04,-2.00,1.57),.09,.11,axis=(0,1,0),n=40),cyl((1.04,-2.065,1.57),.071,.015,'Lamps',(0,1,0),40),box((1.04,-1.99,1.455),(.07,.10,.17),bev=.012)]
part('Headlamp',lamp)
part('LampGuard',[tube([(x,-2.10,1.49),(x,-2.10,1.68),(x,-1.89,1.68),(x,-1.89,1.49)],.011) for x in [.95,1.13]]+[tube([(.95,-2.10,1.68),(1.13,-2.10,1.68)],.011)])
# Unditching beam with wood grain, irregular bark, endgrain cap.
log=[cyl((1.39,.60,1.31),.092,1.91,'Wood',(0,1,0),24,.005,r2=.105)]
for yy in [-.10,1.29]:log.append(ring((1.39,yy,1.31),.11,.098,.035,'TrackSteel',(0,1,0),32))
beam=part('UnditchingBeam',log)
for poly in beam.data.polygons:
    if beam.data.materials[poly.material_index].name=='Wood':
        for li in poly.loop_indices:
            v=beam.data.vertices[beam.data.loops[li].vertex_index].co;beam.data.uv_layers.active.data[li].uv=(math.atan2(v.z,v.x)/(2*pi),v.y*.4)
# Steel braided tow cable, loops and twisted strands.
path=[(1.30,-2.64,1.14),(1.40,-2.1,1.17),(1.42,-.6,1.16),(1.42,1.1,1.16),(1.34,2.70,1.15),(.93,2.87,1.17)]
cab=[tube(path,.020,'TrackSteel',10)]
for end in [path[0],path[-1]]:cab.append(torus(end,.072,.016,'TrackSteel',(1,0,0),32,8))
for strand in range(4):
    pts=[]
    for j in range(90):
        f=j/89*(len(path)-1);ii=min(int(f),len(path)-2);p=Vector(path[ii]).lerp(Vector(path[ii+1]),f-ii); a=j*.9+strand*pi/2;p+=Vector((.016*cos(a),0,.016*sin(a)));pts.append(p)
    cab.append(tube(pts,.004,'Scars',5))
part('TowCable',cab)
# Spare track links bolted across glacis.
bits=[]
for x in [-.30,0,.30]:
    bits.append(box((x,-2.88,1.20),(.28,.30,.045),'TrackSteel',.006,R))
    for xx in [-.09,.09]:bits.append(box((x+xx,-2.89,1.23),(.026,.27,.024),'TrackSteel',.003,R))
    bits.append(bolt((x,-2.86,1.26),(0,-.5,.87),.018))
part('SpareTrack_Front',bits)
# Tool set on right rear fender: spade and pry bar.
bits=[tube([(-1.28,.05,1.27),(-1.28,1.04,1.28)],.018,'Wood',10),box((-1.28,1.13,1.28),(.16,.25,.025),'TrackSteel',.008),tube([(-1.36,.10,1.25),(-1.36,1.36,1.25)],.015,'TrackSteel')]
part('ToolSet',bits)
# Turret rails and lifting eyes are detachable, retain welded bosses below.
for side,label in [(1,'L'),(-1,'R')]:
    bits=[tube([(side*1.11,-.45,2.22),(side*1.21,-.45,2.22),(side*1.21,.20,2.22),(side*1.015,.20,2.22)],.015)]
    for xx,yy in [(1.10,-.45),(1.015,.20)]:bits.append(box((side*xx,yy,2.21),(.04,.055,.06),bev=.007))
    part('Turret_Rail_'+label,bits,parent=Turret)
part('Turret_RearRail',[handle([(-.34,.94,2.24),(-.34,1.07,2.24),(.34,1.07,2.24),(.34,.94,2.24)])],parent=Turret)
part('Turret_Sight',[box((-.34,-1.02,2.454),(.16,.24,.065),bev=.012),box((-.34,-1.126,2.47),(.095,.02,.028),'Interior',.002)],parent=Turret)
part('Turret_Vent',[cyl((-.41,.47,2.485),.19,.08,n=40),cyl((-.41,.47,2.53),.205,.025,n=40)],parent=Turret)
# antenna folded short to preserve reference height envelope.
part('Turret_Antenna',[cyl((-.75,.32,2.445),.045,.06,n=20),tube([(-.75,.32,2.46),(-.75,.32,2.72)],.003,'TrackSteel',6)],parent=Turret)
for name,loc,parent in [('Seat_Commander',(.48,.03,2.57),Turret),('Seat_Driver',(.49,-2.35,1.47),None),('Exhaust',(0,2.98,1.14),None),('Light_Head_L',(1.04,-2.10,1.57),None),('Light_Head_R',(-1.04,-2.10,1.57),None),('Fx_Engine',(0,1.74,1.64),None),('Fx_Ammo',(0,-.3,.78),None),('Fx_Hatch',(.48,.03,2.69),Turret)]:empty(name,loc,parent)

# Final UVs after booleans, then clean export with stable pivots.
for o in allhealthy:
    if o.type=='MESH' and not o.data.uv_layers:uv_project(o)
S.cycles.samples=24
bpy.context.view_layer.update()
def tri_count(obs):return sum(sum(len(p.vertices)-2 for p in o.data.polygons) for o in obs if o.type=='MESH')
def export(path,obs):
    for ob in obs:
        if ob.type!='MESH':continue
        ob.data.name=ob.name
        if ob.data.uv_layers:
            ob.data.uv_layers.active_index=0;ob.data.uv_layers[0].active_render=True
        bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.triangulate(bm,faces=list(bm.faces));bad=[f for f in bm.faces if f.calc_area()<5e-13]
        if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
        bm.to_mesh(ob.data);bm.free();ob.data.update()
    bpy.ops.object.select_all(action='DESELECT')
    for o in obs:o.hide_set(False);o.select_set(True)
    bpy.ops.export_scene.gltf(filepath=str(ROOT/path),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True,export_image_format='JPEG',export_jpeg_quality=82)
    # Explicit factor: portable glTF material contract, independent of exporter node heuristics.
    p=ROOT/path;data=p.read_bytes();ln,typ=struct.unpack_from('<II',data,12);j=json.loads(data[20:20+ln]);tail=data[20+ln:]
    for m in j.get('materials',[]):
        if m['name'] in ['Paint','GunSteel']:m['pbrMetallicRoughness']['baseColorFactor']=[.10,.13,.07,1]
    # Blender 5.2 can emit an implicit UV index of -1 after rigid mesh joins.
    # Every mesh above has its active UV at channel zero. Canonicalise those
    # bindings and collapse the redundant per-UV material variants.
    def clean_uv(value):
        if isinstance(value,dict):
            if 'texCoord' in value and value['texCoord']<=0:value.pop('texCoord')
            for v in value.values():clean_uv(v)
        elif isinstance(value,list):
            for v in value:clean_uv(v)
    clean_uv(j['materials']);unique=[];remap={};keys={}
    for i,m in enumerate(j['materials']):
        key=json.dumps(m,sort_keys=True)
        if key not in keys:keys[key]=len(unique);unique.append(m)
        remap[i]=keys[key]
    j['materials']=unique
    for me in j['meshes']:
        for pr in me['primitives']:
            if 'material' in pr:pr['material']=remap[pr['material']]
    js=json.dumps(j,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
    p.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(js)+len(tail))+struct.pack('<II',len(js),0x4E4F534A)+js+tail)
    assert len(j['materials'])<=10
    assert tri_count(obs)<=(110000 if 'damage' in path else 140000)
    if 'damage' not in path:assert p.stat().st_size<=12000000,'Healthy GLB exceeds decimal 12 MB'
export('t34-85-tank.glb',allhealthy)
print('HEALTHY_COMPLETE',tri_count(allhealthy),flush=True)

# Damage derived from the healthy meshes, aligned for direct swaps.
D=[]
HD=empty('Hull_Damage',(0,0,0),healthy=False);TD=empty('Turret_Damage',TC,healthy=False);D.extend([HD,TD])
def clone(o,name,parent=None,mat=None):
    n=o.copy();n.data=o.data.copy();S.collection.objects.link(n);n.name=name;w=o.matrix_world.copy();n.parent=None;n.matrix_world=w
    if mat:
        n.data.materials.clear();n.data.materials.append(M[mat])
        for p in n.data.polygons:p.material_index=0
    if parent:n.parent=parent;n.matrix_world=w
    return n

def dent(o,amount=.13):
    # coherent creased warp, keeps mounts and origins exactly aligned
    for v in o.data.vertices:
        p=v.co;f=max(0,min(1,(p.y+.35)/.8));p.z+=amount*sin(p.y*5+p.x*3)*f;p.x+=amount*.32*sin(p.y*6)*f
    return o
pairs=[]
for o in parts:
    parent=TD if o.parent==Turret else HD
    name=o.name.replace('Part_','Dmg_',1)
    n=clone(o,name,parent);dent(n,.22 if 'Fender' in name else .11)
    n.data.materials.append(M['Burnt']);burnslot=len(n.data.materials)-1
    for poly in n.data.polygons:
        if poly.center.x+.3*sin(poly.center.y*9)>.10 and poly.center.z>-.08:poly.material_index=burnslot
    # Torn mouth cut in large drums/boxes: visible inward cavity and curled rim.
    if 'FuelDrum' in name or 'Stowage' in name:
        cen=o.matrix_world.translation.copy();cen.x+=.14 if cen.x>0 else -.14
        cut=cyl(cen,.13,.30,None,(1,0,0),16,0)
        activate(n);mo=n.modifiers.new('shell torn by impact','BOOLEAN');mo.operation='DIFFERENCE';mo.object=cut
        try:bpy.ops.object.modifier_apply(modifier=mo.name)
        except: n.modifiers.remove(mo)
        bpy.data.objects.remove(cut,do_unlink=True)
    D.append(n);pairs.append({'part':o.name,'twin':n.name})
# Scars adhere to four armour faces; local oriented petalled rim + metal furrows.
def scars(name,centers,normal,parent):
    bits=[];n=Vector(normal).normalized();q=Vector((0,0,1)).rotation_difference(n)
    for k,cen in enumerate(centers):
        c=Vector(cen)
        target=Turret if parent==TD else Hull;inv=target.matrix_world.inverted();ok,hit,hn,fi=target.ray_cast(inv@(c+n*2),inv.to_3x3()@(-n))
        if ok:c=target.matrix_world@hit;n=(target.matrix_world.to_3x3()@hn).normalized();q=Vector((0,0,1)).rotation_difference(n)
        # soot disk and torn crater cup. 2-4 mm above the original surface.
        fan=[c+n*.002]
        for i in range(24):
            a=2*pi*i/24;rr=.10*(1+.38*cos(a)+.12*sin(i*5))
            fan.append(c+q@Vector((rr*cos(a)*(1.7 if k==1 else 1),rr*sin(a)*(.52 if k==1 else 1),.002)))
        bits.append(mesh('soot fan',fan,[(0,i+1,(i+1)%24+1) for i in range(24)],'Burnt'))
        bits.append(cyl(c+n*.003,.029 if k==0 else .017,.002,'Interior' if k==0 else 'Scars',n,20,0))
        vs=[];fs=[];N=20
        for rr,zz in [(.032,.003),(.054,.004),(.073,.002)]:
            for i in range(N):
                a=i*2*pi/N;v=Vector((rr*(1+.15*sin(i*7))*cos(a)*(1.7 if k==1 else 1),rr*(1+.15*sin(i*7))*sin(a)*(.30 if k==1 else 1),zz+(.014 if k==0 and rr==.054 and i%3==0 else 0)));vs.append(c+q@v)
        for j in range(2):
            for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,(j+1)*N+(i+1)%N,(j+1)*N+i))
        bits.append(mesh('petalled impact',vs,fs,'Scars',False))
        pts=[c+q@Vector((.055+t*.022,.014*sin(t),.004)) for t in range(6)]
        bits.append(tube(pts,.004,'Scars',6))
    o=join(name,bits,parent=parent,healthy=False);D.append(o);return o
sc=[]
sc.append(scars('Scar_Hull_Front',[(-.67,-2.73,1.208),(-.12,-2.54,1.314),(.90,-2.82,1.158)],(0,-.486,.874),HD))
for side,label in [(1,'L'),(-1,'R')]:
    sc.append(scars('Scar_Hull_'+label,[(side*1.21,y,1.30) for y in [-1.7,-.4,.6]],(side*.87,0,.49),HD))
sc.append(scars('Scar_Hull_Rear',[(x,2.82,1.235) for x in [-.72,0,.76]],(0,.67,.74),HD))
sc.append(scars('Scar_Turret_Front',[(x,-1.77,2.10) for x in [-.56,0,.54]],(0,-1,.1),TD))
for side,label in [(1,'L'),(-1,'R')]:sc.append(scars('Scar_Turret_'+label,[(side*1.165,y,2.07) for y in [-.76,-.44,-.08]],(side,0,.15),TD))
sc.append(scars('Scar_Turret_Rear',[(x,.93,2.12) for x in [-.36,0,.36]],(0,1,.15),TD))
for side,label in [(1,'L'),(-1,'R')]:
    o=make_track(side,label,True);activate(o);md=o.modifiers.new('damaged track density','DECIMATE');md.ratio=.52;bpy.ops.object.modifier_apply(modifier=md.name);w=o.matrix_world.copy();o.parent=HD;o.matrix_world=w;D.append(o)
# burst barrel copied, shortened and bent; open petals at its fracture.
bg=clone(Gun,'broken mantlet',mat='Paint');bb=clone(Barrel,'broken barrel',mat='TrackSteel')
for v in bb.data.vertices:
    world=bb.matrix_world@v.co
    if world.y< -3.35:
        world.y=-3.35+(world.y+3.35)*.22;world.z-=(-world.y-3.35)*.8
        v.co=bb.matrix_world.inverted()@world
bits=[bg,bb]
for i in range(7):
    a=2*pi*i/7;bits.append(tube([(.07*cos(a),-3.72,1.80),(.12*cos(a),-3.84,1.80+.10*sin(a)),(.19*cos(a),-3.91,1.80+.20*sin(a))],.022,'Scars',7))
GB=join('Gun_Broken',bits,GUN,TD,False);D.append(GB)
# Wreck assembly, entirely Burnt; lower density and deformed suspension.
wbits=[]
for o in allhealthy:
    if o.type!='MESH' or o==Turret or o.parent==Turret or o==Barrel or o==Gun:continue
    if o.name.startswith('Part_') and any(k in o.name for k in ['Fender_FR','Fender_RL','FuelDrum_L','EngineGrille_L','Stowage_R','TowCable']):continue
    n=clone(o,'wreck copy',mat='Burnt')
    if o.name.startswith('Wheel_'):
        # contract burnt-off rubber: contract wheel tyre outer radii
        for v in n.data.vertices:
            r=math.hypot(v.co.y,v.co.z)
            if r>.36:v.co.y*=.91;v.co.z*=.91
        n.location.z-=.05
    if o.name=='Track_R':
        for v in n.data.vertices:
            if v.co.z>.70:v.co.z-=.10*max(0,1-(v.co.y/2.65)**2)
    if o.name=='Hatch_Driver':n.rotation_euler.x=math.radians(-100)
    if 'Grille' in o.name:dent(n,.34)
    if o.name=='Track_L':
        bpy.data.objects.remove(n,do_unlink=True);n=clone(next(d for d in D if d.name=='Track_L_Broken'),'wreck thrown track',mat='Burnt')
    wbits.append(n)
WH=join('Wreck_Hull',wbits,healthy=False)
activate(WH);mod=WH.modifiers.new('wreck density','DECIMATE');mod.ratio=.42;bpy.ops.object.modifier_apply(modifier=mod.name);D.append(WH)
wt=[]
for o in [Turret,Gun,Barrel]+[a for a in allhealthy if a.type=='MESH' and a.parent==Turret and a!=Gun]:
    n=clone(o,'wreck turret copy',mat='Burnt')
    if o==Gun or o==Barrel:
        n.matrix_world=Matrix.Translation(Vector(GUN))@Matrix.Rotation(math.radians(9),4,'X')@Matrix.Translation(-Vector(GUN))@n.matrix_world
    if o.name.startswith('Hatch_'):
        w=n.matrix_world.copy();n.matrix_world=w@Matrix.Rotation(math.radians(-105),4,'X')
    wt.append(n)
wt.extend([cyl((0,-.43,1.58),.80,.14,'Burnt',n=48),ring((0,-.43,1.44),.62,.56,.22,'Burnt',n=48),cyl((0,-.43,1.32),.61,.025,'Burnt',n=48)])
WT=join('Wreck_Turret',wt,TC,healthy=False);activate(WT);mod=WT.modifiers.new('wreck turret density','DECIMATE');mod.ratio=.58;bpy.ops.object.modifier_apply(modifier=mod.name);D.append(WT)
# File B only allowed palette; reduce non-Paint material maps to <=512 already.
for o in D:
    if o.type=='MESH':
        for i,m in enumerate(o.data.materials):
            if m and m.name not in ['Paint','Burnt','Scars','TrackSteel','Rubber']:o.data.materials[i]=M['Burnt']
export('t34-85-tank-damage.glb',D)
print('DAMAGE_COMPLETE',tri_count(D),flush=True)

paints=[('Olive drab',[.10,.13,.07]),('NATO green',[.063,.092,.059]),('Desert tan',[.42,.31,.17]),('Field grey',[.17,.19,.17]),('Winter white',[.70,.72,.66]),('Dark green-black',[.028,.040,.028])]
def bounds(obs):
    p=[o.matrix_world@Vector(v) for o in obs if o.type=='MESH' for v in o.bound_box]
    return {'min':[min(v[i] for v in p) for i in range(3)],'max':[max(v[i] for v in p) for i in range(3)]}
def gamevec(v):return [round(v[0],5),round(v[2],5),round(-v[1],5)]
def records(obs):return [{'name':o.name,'parent':o.parent.name if o.parent else None,'world_position':gamevec(o.matrix_world.translation),'type':o.type} for o in obs]
bb=bounds(allhealthy);lo=gamevec((bb['min'][0],bb['max'][1],bb['min'][2]));hi=gamevec((bb['max'][0],bb['min'][1],bb['max'][2]))
meta={'contract':'tank-damage/v1','units':'metres','forward':'+Z','up':'+Y','left':'+X','bounding_box':{'min':lo,'max':hi},'healthy':records(allhealthy),'damage_nodes':records(D),'triangles':{'healthy':tri_count(allhealthy),'damage':tri_count(D)},'paints':[{'name':n,'linear_rgb':c} for n,c in paints], 'damage':{'parts':pairs,'scars':[o.name for o in sc],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'rig':{'Turret':{'axis':'Y'},'Gun':{'axis':'X'},'Barrel':{'translation_axis':'Z'},'wheel_order':'front idler, five road wheels, rear sprocket; spin X','MG':'not fitted on reference T-34-85'}}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))

# Proof comes from importing BOTH final exported files into a genuinely empty scene.
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for db in list(bpy.data.materials):bpy.data.materials.remove(db)
for db in list(bpy.data.node_groups):bpy.data.node_groups.remove(db)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'t34-85-tank.glb'));healthy=set(bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'t34-85-tank-damage.glb'));damage=set(bpy.context.scene.objects)-healthy
for o in healthy|damage:o.rotation_mode='XYZ'
# Name collisions for shared materials are harmless; recolour both by material prefix.
def paint(color):
    for m in bpy.data.materials:
        if m.name.split('.')[0]=='Paint':
            bs=next(n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED')
            link=bs.inputs['Base Color'].links
            if link and link[0].from_node.type=='MIX':link[0].from_node.inputs[7].default_value=(*color,1)
            elif link and link[0].from_node.type=='MIX_RGB':link[0].from_node.inputs[2].default_value=(*color,1)
            else:bs.inputs['Base Color'].default_value=(*color,1)
# staging
floor=bpy.data.materials.new('Studio floor');floor.diffuse_color=(.22,.235,.24,1);floor.use_nodes=True;floor.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.22,.235,.24,1);floor.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.86
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.008));ground=bpy.context.object;ground.name='STUDIO';ground.data.materials.append(floor)
world=bpy.data.worlds.new('Soft overcast');S.world=world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs[0].default_value=(.65,.72,.81,1);world.node_tree.nodes.get('Background').inputs[1].default_value=.38
for name,loc,energy,size in [('Key', (1,-5,9),2100,7),('Fill',(-5,-2,5),1200,6),('Rim',(1,5,7),1900,5)]:
    data=bpy.data.lights.new(name,'AREA');data.energy=energy;data.shape='DISK';data.size=size;o=bpy.data.objects.new(name,data);S.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.camera_add();cam=bpy.context.object;S.camera=cam

def camera(side=False,wide=False):
    target=Vector((0,-.75,1.2));cam.location=(10,-13,5.9) if not side else (12,0,2.0)
    if wide:cam.location=(13,-16,8);target=Vector((1,-.3,1.05))
    cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.type='ORTHO';cam.data.ortho_scale=10.1 if not wide else 11.4
    if side:cam.data.ortho_scale=9.4

def state(n):
    for o in healthy:o.hide_render=n>=4
    for o in damage:o.hide_render=True
    if n==1:
        for o in damage:
            if o.name.startswith('Scar_'):o.hide_render=False
        for name in ['FuelDrum_L','Stowage_L','Fender_FL','EngineGrille_L','Turret_Rail_L','Turret_Sight']:
            a=bpy.data.objects.get('Part_'+name);b=bpy.data.objects.get('Dmg_'+name)
            if a and b:a.hide_render=True;b.hide_render=False
    elif n==2:
        for o in healthy:
            if o.name.startswith('Part_'):o.hide_render=True
    elif n==3:
        for name in ['Track_L','Gun','Barrel','Muzzle']:bpy.data.objects[name].hide_render=True
        bpy.data.objects['Track_L_Broken'].hide_render=False;bpy.data.objects['Gun_Broken'].hide_render=False
    elif n>=4:
        bpy.data.objects['Wreck_Hull'].hide_render=False;bpy.data.objects['Wreck_Turret'].hide_render=False
    wt=bpy.data.objects['Wreck_Turret'];wt.location=TC;wt.rotation_euler=(0,0,0)
    if n==5:
        wt.location=(3.25,.25,.88);wt.rotation_euler=(0,math.radians(90),math.radians(-22));bpy.context.view_layer.update();zmin=min((wt.matrix_world@v.co).z for v in wt.data.vertices);wt.location.z-=zmin

def render(name,w=1920,h=1080,samples=32):
    S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
state(0);camera();render('final.png',960 if DRAFT else 1920,540 if DRAFT else 1080)
camera(True);render('side.png',1600,900,24)
camera();clay=bpy.data.materials.new('QA clay');clay.diffuse_color=(.42,.42,.42,1);clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.42,.42,.42,1);S.view_layers[0].material_override=clay;render('clay.png',1600,900,24);S.view_layers[0].material_override=None
# Pivot exercise from exported nodes: 30 degree motion, saved diagnostic render.
changed=[]
for o in healthy:
    if o.name in ['Turret','Gun','Hatch_Commander','Hatch_Driver','Hatch_Loader'] or o.name.startswith('Wheel_'):
        changed.append((o,o.rotation_euler.copy()));o.rotation_euler[2 if o.name=='Turret' else 0]+=(-pi/6 if o.name=='Gun' or o.name.startswith('Hatch_') else pi/6)
recoil=bpy.data.objects['Barrel'];recoil.location.y+=.18
render('pivots.png',1280,720,16)
recoil.location.y-=.18
for o,r in changed:o.rotation_euler=r
# Contact sheets composed directly with Blender image buffers, no image dependency.
def grid(files,out):
    import numpy as np
    ims=[bpy.data.images.load(str(ROOT/f),check_existing=False) for f in files];w,h=ims[0].size
    dest=np.ones((h*2,w*3,4),dtype=np.float32)
    for k,im in enumerate(ims):
        a=np.array(im.pixels[:],dtype=np.float32).reshape(h,w,4);row=1-k//3;col=k%3;dest[row*h:(row+1)*h,col*w:(col+1)*w]=a
    im=bpy.data.images.new(out,width=w*3,height=h*2);im.pixels.foreach_set(dest.ravel());im.filepath_raw=str(ROOT/out);im.file_format='PNG';im.save()
for i in range(6):state(i);camera(wide=True);render(f'state_{i}.png',640,420,16)
grid([f'state_{i}.png' for i in range(6)],'states.png')
state(0);camera()
for i,(name,col) in enumerate(paints):paint(col);render(f'paint_{i}.png',640,420,16)
grid([f'paint_{i}.png' for i in range(6)],'paints.png')
paint(paints[0][1])
(ROOT/'NOTES.md').write_text('T-34-85 style medium tank, metre-scale pivot rig and tank-damage/v1 state meshes; built and baked in Blender 5.2.\n'+f'Triangles: healthy {meta["triangles"]["healthy"]:,}; damage {meta["triangles"]["damage"]:,}.\n'+'Materials: Paint, GunSteel, TrackSteel, Rubber, Wood, Interior, Lamps, Burnt, Scars; damage uses Paint, Burnt, Scars and TrackSteel.\n'+'Textures: shared Cycles-baked tiling base/normal up to 1024px; ORM up to 512px; damage non-Paint up to 512px.\n'+'Poly Haven CC0: metal_plate, rusty_metal_03, bark_brown_02; https://polyhaven.com/license.\n'+'Differences: simplified cast-cheek contours and crew interior; shorter antenna; less localized wear than reference; no roof MG fitted on reference.\n')
print('DELIVERY_COMPLETE',json.dumps(meta['triangles']),flush=True)
