"""Mark IV male — metres, Blender Z-up / -Y forward, glTF Y-up / +Z forward.
Clean build: blender -b --python-exit-code 1 --python build_mark-iv-tank.py
All photographic inputs: Poly Haven CC0. Generated tile bakes retained in textures/.
"""
import bpy, bmesh, math, random, os, json, struct, urllib.request, subprocess, sys
from pathlib import Path
from mathutils import Vector, Matrix
from mathutils.bvhtree import BVHTree
from math import sin, cos, pi
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(41)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene
sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
sc.render.engine='CYCLES'; sc.cycles.samples=24; 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.cycles.device='GPU'; sc.render.image_settings.file_format='PNG'
sc.world=bpy.data.worlds.new('Soft daylight'); sc.world.use_nodes=True
sc.world.node_tree.nodes['Background'].inputs[0].default_value=(.56,.65,.76,1)
sc.world.node_tree.nodes['Background'].inputs[1].default_value=.45
sc.view_settings.view_transform='AgX'
PH=['metal_plate','rusty_metal_03','wood_planks','green_metal_rust','brown_mud_dry']
MATS={}; HEALTH=[]; DAMAGE=[]
PAINTS=[('Khaki brown',[.20,.17,.10]),('Olive drab',[.135,.155,.075]),('Field grey',[.19,.22,.20]),('Desert tan',[.48,.36,.21]),('Winter white',[.72,.74,.69]),('Dark green-black',[.055,.075,.052])]

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'
        req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0'})
        try:
            with urllib.request.urlopen(req,timeout=60) as r: p.write_bytes(r.read())
        except Exception:
            subprocess.run(['curl','-fL','--retry','3','-A','Mozilla/5.0',url,'-o',str(p)],check=True)
    return bpy.data.images.load(str(p),check_existing=True)

def bake_material(name,id,color,metal,rough,res=512):
    # A physically scaled tile: photograph + directional rain/grime + micro-pitting.
    m=bpy.data.materials.new(name);m.use_nodes=True;n=m.node_tree.nodes;l=m.node_tree.links;n.clear()
    out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
    uv=n.new('ShaderNodeTexCoord');diff=n.new('ShaderNodeTexImage');diff.image=download(id,'diff');l.new(uv.outputs['UV'],diff.inputs[0])
    norm=n.new('ShaderNodeTexImage');norm.image=download(id,'nor_gl');norm.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],norm.inputs[0])
    arm=n.new('ShaderNodeTexImage');arm.image=download(id,'arm');arm.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],arm.inputs[0])
    bw=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],bw.inputs[0])
    ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.05;ramp.color_ramp.elements[1].position=.8
    if name=='Paint':
        ramp.color_ramp.elements[0].position=.048;ramp.color_ramp.elements[1].position=.084;ramp.color_ramp.elements[0].color=(.20,.20,.20,1);ramp.color_ramp.elements[1].color=(.97,.97,.97,1)
        e=ramp.color_ramp.elements.new(.063);e.color=(.86,.86,.86,1)
    else:
        ramp.color_ramp.elements[0].color=tuple(c*.32 for c in color)+(1,);ramp.color_ramp.elements[1].color=tuple(color)+(1,)
    # Preserve photographic pitting with a physically useful albedo range rather than flattening it.
    ranges={'TrackSteel':(.012,.24,(.015,.014,.012),(.17,.15,.13)),
            'Mud':(.005,.30,(.018,.011,.006),(.14,.095,.052)),
            'Rust':(.01,.24,(.018,.009,.005),(.20,.075,.028)),
            'Wood':(.01,.32,(.055,.025,.008),(.32,.19,.08)),
            'Burnt':(.01,.24,(.008,.008,.008),(.075,.068,.059)),
            'Scars':(.01,.24,(.06,.055,.05),(.32,.30,.26))}
    if name in ranges:
        lo,hi,dark,light=ranges[name];ramp.color_ramp.elements[0].position=lo;ramp.color_ramp.elements[1].position=hi
        ramp.color_ramp.elements[0].color=(*dark,1);ramp.color_ramp.elements[1].color=(*light,1)
    l.new(bw.outputs[0],ramp.inputs[0])
    mapping=n.new('ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(65,3,1);l.new(uv.outputs['UV'],mapping.inputs[0])
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=2;noise.inputs['Detail'].default_value=3;l.new(mapping.outputs[0],noise.inputs[0])
    streak=n.new('ShaderNodeValToRGB');streak.color_ramp.elements[0].position=.27;streak.color_ramp.elements[0].color=(.28,.28,.28,1);streak.color_ramp.elements[1].position=.56;streak.color_ramp.elements[1].color=(1,1,1,1);l.new(noise.outputs['Fac'],streak.inputs[0])
    mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.12;l.new(ramp.outputs[0],mix.inputs[1]);l.new(streak.outputs[0],mix.inputs[2]);l.new(mix.outputs[0],p.inputs['Base Color'])
    nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.72 if name=='Paint' else .85;l.new(norm.outputs[0],nm.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.035;bump.inputs['Distance'].default_value=.0005;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],p.inputs['Normal'])
    sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
    roughmix=n.new('ShaderNodeMapRange');roughmix.inputs['To Min'].default_value=rough-.1;roughmix.inputs['To Max'].default_value=min(1,rough+.1);l.new(sep.outputs[1],roughmix.inputs[0]);l.new(roughmix.outputs[0],p.inputs['Roughness']);p.inputs['Metallic'].default_value=metal
    if name=='Paint':
        split=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],split.inputs[0])
        dirt=n.new('ShaderNodeValToRGB');dirt.color_ramp.elements[0].position=.05;dirt.color_ramp.elements[0].color=(.28,.28,.28,1);dirt.color_ramp.elements[1].position=.42;dirt.color_ramp.elements[1].color=(1,1,1,1)
        l.new(split.outputs['Y'],dirt.inputs[0])
        grime=n.new('ShaderNodeMixRGB');grime.blend_type='MULTIPLY';grime.inputs[0].default_value=.75;l.new(mix.outputs[0],grime.inputs[1]);l.new(dirt.outputs[0],grime.inputs[2]);mix=grime
        l.new(mix.outputs[0],p.inputs['Base Color'])
    bpy.ops.mesh.primitive_plane_add(size=1);o=bpy.context.object;o.name='BakeTile';o.data.materials.append(m)
    emit=n.new('ShaderNodeEmission');target=n.new('ShaderNodeTexImage');n.active=target
    baked={}
    for kind,size in [('base',res),('normal',res),('orm',min(res,512))]:
        img=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
        if kind!='base':img.colorspace_settings.name='Non-Color'
        target.image=img;n.active=target
        if kind=='base':l.new(mix.outputs[0],emit.inputs[0]);l.new(emit.outputs[0],out.inputs[0]);typ='EMIT'
        elif kind=='normal':l.new(p.outputs[0],out.inputs[0]);typ='NORMAL'
        else:
            comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1;l.new(roughmix.outputs[0],comb.inputs[1]);comb.inputs[2].default_value=metal;l.new(comb.outputs[0],emit.inputs[0]);l.new(emit.outputs[0],out.inputs[0]);typ='EMIT'
        sc.cycles.samples=4;bpy.ops.object.bake(type=typ,margin=8)
        img.filepath_raw=str(TEX/(name+'_'+kind+('.jpg' if kind=='base' else '.png')));img.file_format='JPEG' if kind=='base' else 'PNG';img.save();baked[kind]=img
    bpy.data.objects.remove(o,do_unlink=True)
    n.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
    for kind,img in baked.items():
        t=n.new('ShaderNodeTexImage');t.image=img;t.label=kind
        if kind=='base':
            if name=='Paint':
                mul=n.new('ShaderNodeMixRGB');mul.name='PaintTint';mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(*PAINTS[0][1],1);l.new(t.outputs[0],mul.inputs[1]);l.new(mul.outputs[0],p.inputs['Base Color'])
            else:l.new(t.outputs[0],p.inputs['Base Color'])
        elif kind=='normal':
            nm=n.new('ShaderNodeNormalMap');l.new(t.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
        else:
            sep=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],p.inputs['Roughness']);l.new(sep.outputs[2],p.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')
            g=n.new('ShaderNodeGroup');g.node_tree=group;l.new(sep.outputs[0],g.inputs['Occlusion'])
    MATS[name]=m
    print('BAKED',name,flush=True)

# Pure geometry builders retain actual pivots; all mesh data is baked in local space.
CACHE={}
class B:
    def __init__(self): self.v=[];self.f=[];self.mi=[];self.uv=[];self.smooth=[]
    def add(self,verts,faces,mat='Paint',smooth=False,uvs=None):
        k=len(self.v);self.v.extend([tuple(v) for v in verts]);self.f.extend([tuple(k+i for i in f) for f in faces]);self.mi.extend([mat]*len(faces));self.smooth.extend([smooth]*len(faces));self.uv.extend(uvs if uvs else [None]*len(faces))
    def box(self,c,d,mat='Paint',bev=.009,rot=None,seg=2):
        key=(tuple(round(x,5) for x in d),bev,seg)
        if key not in CACHE:
            bm=bmesh.new();bmesh.ops.create_cube(bm,size=1)
            for v in bm.verts:v.co=Vector((v.co.x*d[0],v.co.y*d[1],v.co.z*d[2]))
            if bev:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=bev,segments=seg,affect='EDGES')
            bm.verts.ensure_lookup_table();bm.verts.index_update();CACHE[key]=([v.co.copy() for v in bm.verts],[tuple(v.index for v in f.verts) for f in bm.faces]);bm.free()
        vs,fs=CACHE[key];r=rot or Matrix.Identity(3);self.add([r@v+Vector(c) for v in vs],fs,mat)
    def cyl(self,a,b,r,mat='TrackSteel',n=16,r2=None,cap=True):
        a,b=Vector(a),Vector(b);axis=(b-a).normalized();u=axis.cross(Vector((0,0,1)))
        if u.length<.1:u=axis.cross(Vector((0,1,0)))
        u.normalize();w=axis.cross(u);r2=r if r2 is None else r2
        vs=[c+rad*(u*cos(2*pi*i/n)+w*sin(2*pi*i/n)) for c,rad in [(a,r),(b,r2)] for i in range(n)]
        fs=[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
        if cap:fs += [tuple(reversed(range(n))),tuple(range(n,2*n))]
        self.add(vs,fs,mat,True)
    def rivet(self,p,axis=(1,0,0),r=.015,mat='Paint'):
        p=Vector(p);a=Vector(axis).normalized();u=a.cross(Vector((0,0,1)))
        if u.length<.1:u=a.cross(Vector((0,1,0)))
        u.normalize();w=a.cross(u);n=8
        vs=[p-a*r+r*(u*cos(i*2*pi/n)+w*sin(i*2*pi/n)) for i in range(n)]
        vs +=[p+a*r*.35+r*.7*(u*cos(i*2*pi/n)+w*sin(i*2*pi/n)) for i in range(n)]+[p+a*r*.65]
        fs=[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]+[(i+n,(i+1)%n+n,2*n) for i in range(n)]
        self.add(vs,fs,mat,True)
    def beam(self,a,b,width,height,mat='Paint'):
        d=Vector(b)-Vector(a);r=d.to_track_quat('Y','Z').to_matrix();self.box((Vector(a)+Vector(b))/2,(width,d.length,height),mat,.006,r)
    def obj(self,name,pivot=(0,0,0),parent=None,dest=HEALTH):
        mesh=bpy.data.meshes.new(name);mesh.from_pydata([Vector(v)-Vector(pivot) for v in self.v],[],self.f);mesh.update()
        ob=bpy.data.objects.new(name,mesh);sc.collection.objects.link(ob);ob.location=pivot
        names=list(dict.fromkeys(self.mi))
        for nm in names:mesh.materials.append(MATS[nm])
        uv=mesh.uv_layers.new(name='UVMap')
        for i,poly in enumerate(mesh.polygons):
            poly.material_index=names.index(self.mi[i]);poly.use_smooth=self.smooth[i]
            ax=max(range(3),key=lambda k:abs(poly.normal[k]));coords=[j for j in range(3) if j!=ax]
            for j,li in enumerate(poly.loop_indices):
                p=self.v[mesh.loops[li].vertex_index]
                if self.uv[i]:uv.data[li].uv=self.uv[i][j]
                elif self.mi[i]=='Paint' and ax!=2:uv.data[li].uv=(p[coords[0]]*.62,(p[2]+.015)/2.46)
                elif self.mi[i]=='Paint':uv.data[li].uv=(p[0]*.62,.55+.40*((p[1]*.42)%1))
                else:uv.data[li].uv=(p[coords[0]]*.9,p[coords[1]]*.9)
        if parent:par(ob,parent)
        dest.append(ob);return ob

def par(ob,p):
    bpy.context.view_layer.update();mw=ob.matrix_world.copy();ob.parent=p;ob.matrix_world=mw

def empty(name,pos=(0,0,0),parent=None,dest=HEALTH):
    o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=pos
    if parent:par(o,parent)
    dest.append(o);return o

def join_objects(obs,name,pivot=(0,0,0),dest=None):
    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();o=bpy.context.object;o.name=name
    # Place origin by changing mesh coordinates only, preserving geometry and pivot frame.
    mw=o.matrix_world.copy();verts=[mw@v.co-Vector(pivot) for v in o.data.vertices];o.matrix_world=Matrix.Translation(Vector(pivot))
    for v,co in zip(o.data.vertices,verts):v.co=co
    if dest is not None:dest.append(o)
    return o

def prism_x(b,profile,x0,x1,mat='Paint'):
    n=len(profile);b.add([(x,y,z) for x in [x0,x1] for y,z in profile],[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],mat)

def bevel_obj(o,w=.007):
    bpy.context.view_layer.objects.active=o;o.select_set(True);mod=o.modifiers.new('Machined edges','BEVEL');mod.width=w;mod.segments=2;mod.angle_limit=math.radians(65);bpy.ops.object.modifier_apply(modifier=mod.name);o.select_set(False)

PROFILE=[(-3.91,1.30),(-3.96,1.59),(-3.76,1.88),(-3.47,1.99),(-1.9,2.16),(.83,2.16),(2.02,1.89),(3.77,.96),(3.95,.73),(3.91,.47),(3.68,.28),(1.32,.10),(-1.49,.10),(-2.31,.35),(-3.69,1.12)]
# Rounded tangent-continuous polygon corners, preserving the WWI asymmetric rhomboid.
def round_profile(points,r=.18,steps=5):
    result=[]
    for i,p in enumerate(points):
        p=Vector(p);a=Vector(points[i-1]);c=Vector(points[(i+1)%len(points)]);d=min(r,(p-a).length*.3,(c-p).length*.3);a=p+(a-p).normalized()*d;c=p+(c-p).normalized()*d
        for j in range(steps):t=j/steps;v=(1-t)**2*a+2*(1-t)*t*p+t*t*c;result.append(tuple(v))
    return result
PATH=round_profile(PROFILE)
LENS=[(Vector(PATH[(i+1)%len(PATH)])-Vector(p)).length for i,p in enumerate(PATH)];TOTAL=sum(LENS)
def sample(t):
    d=(t%1)*TOTAL
    for i,l in enumerate(LENS):
        if d<=l:
            a=Vector(PATH[i]);delta=Vector(PATH[(i+1)%len(PATH)])-a;return a+delta*d/l,delta.normalized()
        d-=l
    return Vector(PATH[0]),Vector((1,0))

def belt(side,broken=False,dest=HEALTH):
    b=B();N=114;xc=side*1.22
    thrown=[(-4.9,.055),(-3.2,.055),(-1.5,.08),(1.4,.10),(3.68,.28),(3.91,.54),(3.74,.93),(2.25,1.60),(1.2,1.32),(.20,.66),(-.85,.15),(-2.0,.055),(-3.15,.055)]
    thrown_lengths=[(Vector(c)-Vector(a)).length for a,c in zip(thrown,thrown[1:])]
    def fallen(t):
        dist=t*sum(thrown_lengths)
        for j,ln in enumerate(thrown_lengths):
            if dist<=ln:
                aa=Vector(thrown[j]);delta=Vector(thrown[j+1])-aa
                return aa+delta*dist/ln,delta.normalized(),j
            dist-=ln
        return Vector(thrown[-1]),Vector((-1,0)),len(thrown)-2
    for i in range(N):
        t=i/N;p,tan=sample(t)
        if broken:
            p,tan,segment=fallen(i/(N-1))
            # Upper return run has fallen outboard and down to the ground, continuously.
            xc=side*(1.22+.40*max(0,min(1,(t-.55)/.25)))
        y,z=p;dy,dz=tan;rot=Matrix(((1,0,0),(0,dy,-dz),(0,dz,dy)))
        start=len(b.f)
        b.box((xc,y,z),(.50,(sum(thrown_lengths)/(N-1) if broken else TOTAL/N)*.99,.083),'TrackSteel',.012,rot,seg=1)
        outward=Vector((0,-dz,dy));center=Vector((xc,y,z))+outward*.057
        b.box(center,(.505,.064,.033),'Scars' if i%7==2 else 'TrackSteel',.007,rot,seg=1)
        # The hollow pin ends and alternate guide horns catch highlights.
        b.cyl((xc-.261,y,z),(xc+.261,y,z),.023,'TrackSteel',8)
        # Shallow irregular cakes gather beside the raised grouser, with real edge relief.
        rng=random.Random(9000+i+int(side)*400)
        if i%5!=0:
            for k in range(2):
                dx=(-1 if k==0 else 1)*rng.uniform(.11,.20)
                along=rng.choice([-1,1])*rng.uniform(.045,.055)
                c=Vector((xc+dx,y,z))+Vector((0,dy,dz))*along+outward*.041
                rx=rng.uniform(.025,.057);ry=rng.uniform(.014,.025);h=rng.uniform(.004,.009)
                vs=[tuple(c+outward*h)]
                for q in range(8):
                    a=q*pi/4;rr=rng.uniform(.72,1.16)
                    vs.append(tuple(c+Vector((rx*cos(a)*rr,0,0))+Vector((0,dy,dz))*ry*sin(a)*rr))
                b.add(vs,[(0,q+1,(q+1)%8+1) for q in range(8)],'Mud',True)
        for j in range(start,len(b.f)):
            b.uv[j]=[((b.v[vi][0]-xc)/.52+.5,t+((b.v[vi][1]-y)*dy+(b.v[vi][2]-z)*dz)/TOTAL) for vi in b.f[j]]
    if broken:
        low=min(v[2] for v in b.v);b.v=[(x,y,z-low) for x,y,z in b.v]
    return b.obj(('Track_' +('L' if side==1 else 'R'))+('_Broken' if broken else ''),dest=dest)

def build_healthy():
    hull=B()
    center=[(-3.63,1.39),(-2.66,1.93),(-1.84,2.075),(1.42,2.045),(3.43,.96),(3.55,.52),(-2.05,.35)]
    prism_x(hull,center,-.98,.98)
    def roof_z(y):
        for (a,z),(b,w) in zip(center[:4],center[1:5]):
            if a<=y<=b:return z+(w-z)*(y-a)/(b-a)
        return .96
    # Inner complete rhomboid armour carcasses beneath removable outer cheek plates.
    inner=[(y*.993,1.09+(z-1.09)*.96) for y,z in PATH]
    for s in [1,-1]:
        prism_x(hull,inner,s*.98,s*1.432)
        # Continuous perimeter angle iron with rivets.
        for i in range(82):
            p,t=sample(i/82);y,z=p;out=Vector((0,-t.y,t.x));hull.rivet((s*1.449,y,z), (s,0,0),.012)
        # Exposed longitudinal riveted strips and plate seams following hull geometry.
        for y in [-3.1,-2.45,-1.9,-1.18,-.46,.3,1.05,1.79,2.5,3.13]:
            zs=[p[1] for p in inner if abs(p[0]-y)<.4]
            if not zs:continue
            lo=min(zs)+.07;hi=max(zs)-.04
            if hi-lo<.15:continue
            hull.box((s*1.45,y,(lo+hi)/2),(.022,.043,hi-lo),'Paint',.004)
            for j in range(max(2,int((hi-lo)/.15))):hull.rivet((s*1.467,y,lo+j*.15),(s,0,0),.013)
        # Bolted bearing covers on cheek plates.
        for y,z,r in [(2.96,.73,.145),(2.25,1.1,.115),(1.44,1.35,.12),(-3.45,1.53,.11)]:
            hull.cyl((s*1.43,y,z),(s*1.487,y,z),r,'Paint',24)
            for k in range(8):hull.rivet((s*1.495,y+cos(k*pi/4)*r*.8,z+sin(k*pi/4)*r*.8),(s,0,0),.009)
    # Front driver's cab, open topped well under the hinged cover.
    hull.box((0,-2.33,2.045),(1.13,.80,.14),'Paint',.015)
    cab_profile=[(-2.75,2.01),(-2.75,2.35),(-2.11,2.35),(-1.92,2.06)]
    for sgn in [-1,1]:prism_x(hull,cab_profile,sgn*.55,sgn*.63)
    hull.box((0,-2.018,2.198),(1.18,.346,.04),'Paint',.010,Matrix.Rotation(-.991,3,'X'))
    hull.box((0,-2.747,2.17),(1.19,.075,.33),'Paint',.012);hull.box((0,-1.94,2.08),(1.19,.045,.10),'Paint',.009)
    hull.box((0,-2.31,1.93),(1.02,.64,.20),'Interior',.01)
    for x in [-.3,.3]:
        hull.box((x,-2.789,2.18),(.35,.025,.105),'Interior',.009)
        hull.box((x,-2.81,2.225),(.40,.035,.045),'Paint',.005)
        hull.box((x,-2.81,2.123),(.40,.035,.026),'Paint',.004)
        for a in [-.18,.18]:hull.rivet((x+a,-2.831,2.2),(0,-1,0),.012)
    for x in np.arange(-.5,.51,.13):hull.rivet((x,-2.79,2.31),(0,-1,0),.013)
    for x in [-.3,.3]:
        hull.box((x,-2.806,2.176),(.36,.018,.12),'Paint',.004)
        hull.box((x,-2.818,2.174),(.22,.009,.018),'Interior',.002)
        hull.cyl((x-.145,-2.825,2.240),(x+.145,-2.825,2.240),.018,'TrackSteel',12)
        for xx in [-.17,.17]:hull.rivet((x+xx,-2.824,2.157),(0,-1,0),.011)
    for x in np.arange(-.53,.54,.105):hull.rivet((x,-2.791,2.035),(0,-1,0),.012)
    for x in [-.555,.555]:
        for z in [2.08,2.18,2.28]:hull.rivet((x,-2.791,z),(0,-1,0),.012)
    # Riveted front glacis panel lines and tow eyes.
    for x in [-.82,.82]:
        hull.beam((x,-3.59,1.4),(x,-2.7,1.94),.045,.025)
        for j in range(9):hull.rivet((x,-3.56+j*.095,1.43+j*.058),(0,-.5,.86),.012)
    for x in [-.56,.56]:
        hull.box((x,-3.43,1.43),(.15,.09,.18),'TrackSteel',.01)
        hull.cyl((x-.095,-3.49,1.43),(x+.095,-3.49,1.43),.037,'TrackSteel',12)
    # Glacis perimeter fasteners and an inset service flap follow the sloping plate.
    front_n=Vector((0,-.486,.874));front_r=Matrix.Rotation(math.atan2(.54,.97),3,'X')
    for y in [-3.49,-2.79]:
        z=1.39+(y+3.63)*.54/.97
        for x in np.arange(-.74,.75,.115):hull.rivet(Vector((x,y,z))+front_n*.017,front_n,.012)
    hull.box((0,-3.16,1.666),(.38,.29,.028),'Paint',.008,front_r)
    hull.cyl((-.16,-3.019,1.765),(.16,-3.019,1.765),.018,'TrackSteel',12)
    for x in [-.145,.145]:hull.rivet((x,-3.244,1.638),front_n,.011)
    # Top seam rows, warm steel edge strips and fixed rail mounting feet remain when stripped.
    for x in [-.94,.94]:
        for y in np.arange(-1.74,1.30,.13):hull.rivet((x,y,roof_z(y)+.002),(0,0,1),.011)
    for x in [-.83,.83]:
        for y in [-2.40,.95]:
            hull.box((x,y,roof_z(y)+.015),(.16,.18,.030),'Paint',.006)
            for dy in [-.055,.055]:hull.rivet((x,y+dy,roof_z(y)+.032),(0,0,1),.012)
    # Solid riveted sponson side, sloped lower cheek, framed forward gun aperture.
    for s in [1,-1]:
        xy=[(s*1.39,-1.85),(s*2.025,-.95),(s*2.025,-.50),(s*2.025,.28),(s*1.72,.62),(s*1.39,.62)]
        low=[(x*.95,y*.98,.66 if abs(x)>1.9 else .76) for x,y in xy]
        upper=[(x,y,1.055) for x,y in xy]
        hull.add(low+upper,[tuple(reversed(range(6))),tuple(range(6,12))]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)])
        # Thick roof cap with chamfered edge rather than a single thin floating face.
        top=[(x,y,z) for z in [1.94,2.0] for x,y in xy]
        hull.add(top,[tuple(reversed(range(6))),tuple(range(6,12))]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)])
        for i in [1,2,3,4,5]:
            j=(i+1)%6
            wall=[Vector((xy[i][0],xy[i][1],1.04)),Vector((xy[j][0],xy[j][1],1.04)),Vector((xy[j][0],xy[j][1],1.96)),Vector((xy[i][0],xy[i][1],1.96))]
            edge=wall[1]-wall[0];outward=Vector((edge.y,-edge.x,0)).normalized()*s
            hull.add(wall+[v-outward*.025 for v in wall],[(0,1,2,3),(7,6,5,4)]+[(k,(k+1)%4,(k+1)%4+4,k+4) for k in range(4)])
        # Wide oblique embrasure: the distinctive Mark IV sponson face.
        fa=Vector((s*1.39,-1.85,0));fb=Vector((s*2.025,-.95,0));fn=Vector((s*.817,-.576,0))
        for q in [fa,fb]:hull.beam(q+Vector((0,0,1.10)),q+Vector((0,0,1.91)),.085,.085)
        for z in [1.10,1.92]:
            hull.beam(fa+Vector((0,0,z)),fb+Vector((0,0,z)),.095,.115)
            for j in range(10):hull.rivet(fa.lerp(fb,(j+.5)/10)+Vector((0,0,z))+fn*.052,fn,.014)
        for q in [fa,fb]:
            for z in np.arange(1.19,1.85,.12):hull.rivet(q+Vector((0,0,z))+fn*.053,fn,.014)
        # Recessed inner back and dark floor provide depth around the curved gun shield.
        hull.box((s*1.63,-1.10,1.49),(.55,.055,.78),'Interior',.008)
        # Seam straps, and rows of fasteners on every outer edge.
        hull.box((s*2.038,.18,1.49),(.019,.053,.86),'Paint',.004)
        for y in np.arange(-.86,.30,.125):
            for z in [1.09,1.91]:hull.rivet((s*2.048,y,z),(s,0,0),.014)
        for z in np.arange(1.16,1.90,.13):
            for y in [-.84,.18]:hull.rivet((s*2.048,y,z),(s,0,0),.014)
        for y in np.arange(-.84,.38,.14):hull.rivet((s*1.992,y,.76),(s,0,0),.014)
    for x in [.42,.59]:
        for y in [-.55,.45]:hull.box((x,y,roof_z(y)+.040),(.065,.085,.080),'TrackSteel',.006)
    for y in [-1.35,.45,1.24]:hull.box((-.48,y,roof_z(y)+.049),(.21,.085,.098),'TrackSteel',.006)
    ho=hull.obj('Hull');bevel_obj(ho,.005)
    # Curved steel shields + individual recoil barrels; identity local rotations.
    for s,tag in [(1,'L'),(-1,'R')]:
        pivot=Vector((s*1.545,-1.27,1.50));d=Vector((s*.55,-.835165,0));side=Vector((d.y,-d.x,0))
        g=B();vs=[];N=24
        for z in [-.335,.335]:
            for i in range(N+1):
                a=-1.20+2.40*i/N;v=pivot+d*(.29*cos(a))+side*(.29*sin(a))+Vector((0,0,z));vs.append(v)
        inner=[pivot+Vector(((v.x-pivot.x)*.95,(v.y-pivot.y)*.95,v.z-pivot.z)) for v in vs]
        off=len(vs);faces=[(i,i+1,i+N+2,i+N+1) for i in range(N)]
        faces += [tuple(off+j for j in reversed(f)) for f in faces[:]]
        faces += [(i,i+off,i+1+off,i+1) for i in range(N)]
        faces += [(i+N+1,i+N+2,i+N+2+off,i+N+1+off) for i in range(N)]
        faces += [(0,N+1,N+1+off,off),(N,N+off,2*N+1+off,2*N+1)]
        g.add(vs+inner,faces,'Paint',True)
        g.cyl(pivot-Vector((0,0,.335)),pivot+Vector((0,0,.335)),.245,'Interior',24)
        g.cyl(pivot+d*.22,pivot+d*.32,.125,'TrackSteel',24)
        gun=g.obj('Gun_'+tag,pivot);gun['yaw_axis']='Y';gun['elevation_axis']='X'
        barrel=B();a=pivot+d*.28;b=pivot+d*.88
        barrel.cyl(a,b,.060,'TrackSteel',24,r2=.046,cap=False)
        barrel.cyl(a,pivot+d*.43,.083,'TrackSteel',24)
        # Annular muzzle lip and deep black bore, never a solid end cap.
        barrel.cyl(b-d*.13,b,.033,'Interior',20,cap=False)
        v=[]
        for rad in [.046,.033]:
            v += [b+side*cos(k*2*pi/24)*rad+Vector((0,0,sin(k*2*pi/24)*rad)) for k in range(24)]
        barrel.add(v,[(k,(k+1)%24,(k+1)%24+24,k+24) for k in range(24)],'TrackSteel')
        barrel.cyl(b-d*.135,b-d*.137,.034,'Interior',20)
        bar=barrel.obj('Barrel_'+tag,a,gun)
        empty('Muzzle_'+tag,b,bar)
    # Small ball-mounted Lewis guns.
    for idx,(p,d) in enumerate([((0,-2.83,2.045),(0,-1,0)),((1.994,-.04,1.52),(.04,-1,0)),((-1.994,-.04,1.52),(-.04,-1,0)),((0,3.41,.89),(0,1,0))],1):
        p=Vector(p);d=Vector(d).normalized();b=B();b.cyl(p-d*.06,p+d*.045,.106,'Paint',24);b.cyl(p,p+d*.29,.026,'TrackSteel',14);b.cyl(p+d*.26,p+d*.31,.032,'TrackSteel',14,cap=False);b.obj('MG_'+str(idx),p)
    # Wheel axles are truly separate and X aligned, including small road rollers.
    for s,tag in [(1,'L'),(-1,'R')]:
        coords=[(-3.59,1.49,.27)]+[(float(y),.32,.19) for y in np.linspace(-1.88,2.8,18)]+[(3.62,.69,.28)]+[(float(y),1.94,.16) for y in [-2.8,-1.8,-.8,.2,1.2]]
        coords.sort(key=lambda p:p[0])
        for i,(y,z,r) in enumerate(coords,1):
            b=B();x=s*1.21;b.cyl((x-.20,y,z),(x+.20,y,z),r,'TrackSteel',20)
            for off in [-.212,.212]:
                b.cyl((x+off,y,z),(x+off+s*.018,y,z),r*.74,'Paint',16)
                b.cyl((x+off,y,z),(x+off+s*.032,y,z),r*.28,'TrackSteel',12)
            ob=b.obj(f'Wheel_{tag}{i}',(x,y,z));ob['spin_axis']='X'
        belt(s)
    # Detachable parts, with a full armour foundation under every one.
    parts=[]
    for s,tag in [(1,'L'),(-1,'R')]:
        # Full-height contiguous removable cheeks are clipped to the actual track profile.
        def limits(y):
            hits=[]
            for k,(ya,za) in enumerate(inner_profile):
                yb,zb=inner_profile[(k+1)%len(inner_profile)]
                if min(ya,yb)<=y<=max(ya,yb) and abs(yb-ya)>1e-6:hits.append(za+(zb-za)*(y-ya)/(yb-ya))
            return min(hits)+.045,max(hits)-.045
        inner_profile=[(y*.993,1.09+(z-1.09)*.96) for y,z in PATH]
        edges=[-3.62,-2.98,-2.23,-1.46,-.65,.19,1.03,1.82,2.54,3.55]
        for i,(ya,yb) in enumerate(zip(edges,edges[1:]),1):
            ya+=.010;yb-=.010
            la,ha=limits(ya);lb,hb=limits(yb);profile=[(ya,la),(yb,lb),(yb,hb),(ya,ha)]
            b=B();prism_x(b,profile,s*1.444,s*1.474)
            for aa,bb in zip(profile,profile[1:]+profile[:1]):
                aa,bb=Vector(aa),Vector(bb);delta=bb-aa;count=max(2,int(delta.length/.135))
                for j in range(count):
                    pt=aa+delta*(j+.3)/count;pt=(pt+Vector(((ya+yb)/2,(la+ha+lb+hb)/4))*.02)/1.02
                    b.rivet((s*1.487,pt.x,pt.y),(s,0,0),.014)
            ob=b.obj(f'Part_Skirt_{tag}{i}',(s*1.46,(ya+yb)/2,(la+ha+lb+hb)/4));bevel_obj(ob,.005);parts.append(ob)
        # Rail is an actual narrow channel, bent along roof supports.
        b=B();pts=[(s*.83,-3.18,1.98),(s*.83,-2.43,2.40),(s*.83,.93,2.40),(s*.83,2.20,2.00),(s*.83,3.45,1.23)]
        for a,c in zip(pts,pts[1:]):b.beam(a,c,.047,.062)
        for y,top in [(-2.40,2.40),(.95,2.394),(2.71,1.686)]:b.beam((s*.83,y,roof_z(y)+.020),(s*.83,y,top),.045,.045)
        parts.append(b.obj('Part_UnditchingRail_'+tag,(s*.83,0,2.15)))
        # Side roof attachment seam; realistic engine access fittings.
        for front,y,z in [('F',-3.1,1.99),('R',2.40,1.67)]:
            b=B();b.box((s*1.22,y,z),(.53,.31,.037),'Paint',.008)
            for dx in [-.19,.19]:b.rivet((s*1.22+dx,y,z+.02),(0,0,1),.012)
            parts.append(b.obj(f'Part_Fender_{front}{tag}',(s*1.22,y,z)))
        b=B();b.box((s*.88,1.05,2.10),(.27,.8,.075),'Paint',.01)
        for y in np.arange(.72,1.39,.10):b.box((s*.88,y,2.144),(.20,.033,.01),'Interior',.002)
        parts.append(b.obj('Part_EngineGrille_'+tag,(s*.88,1.05,2.1)))
    b=B();b.box((0,1.72,2.272),(2.34,.19,.18),'Wood',.023)
    for x in [-.88,.88]:
        b.box((x,1.72,2.272),(.10,.207,.193),'TrackSteel',.009)
        for zz in [2.207,2.337]:b.rivet((x,1.61,zz),(0,-1,0),.012,'TrackSteel')
    parts.append(b.obj('Part_UnditchingBeam',(0,1.72,2.272)))
    b=B();b.cyl((-.48,-1.55,2.20),(-.48,.42,2.20),.064,'Rust',20);b.cyl((-.48,.36,2.20),(-.48,1.38,2.20),.12,'Rust',24)
    for y in [.45,1.24]:b.box((-.48,y,2.16),(.30,.06,.10),'TrackSteel',.008)
    b.cyl((-.48,1.33,2.20),(-.48,1.67,2.09),.071,'Rust',20,cap=False)
    parts.append(b.obj('Part_ExhaustMuffler',(-.48,.2,2.2)))
    # Tow cable routed across rear plate, complete eyes and standoff cleats.
    b=B()
    cablepts=[(-.65,3.48,.88),(-.5,3.53,.66),(0,3.59,.55),(.5,3.53,.66),(.65,3.48,.88)]
    for a,c in zip(cablepts,cablepts[1:]):b.cyl(a,c,.019,'TrackSteel',10)
    for x in [-.65,.65]:b.cyl((x,3.46,.80),(x,3.50,.94),.04,'TrackSteel',12)
    parts.append(b.obj('Part_TowCable',(0,3.52,.70)))
    # Tools are carried on the engine deck.
    b=B();b.cyl((.42,-.7,2.14),(.42,.73,2.14),.024,'Wood',12);b.box((.42,.81,2.15),(.24,.31,.035),'TrackSteel',.012)
    b.cyl((.59,-.7,2.14),(.59,.45,2.14),.023,'Wood',12);b.box((.59,.47,2.15),(.34,.065,.055),'TrackSteel',.015)
    parts.append(b.obj('Part_ToolSet',(.49,0,2.14)))
    for s,tag in [(1,'L'),(-1,'R')]:
        b=B();b.box((s*.84,2.36,1.50),(.24,.44,.33),'Paint',.014);b.box((s*.84,2.36,1.68),(.255,.46,.035),'Paint',.007)
        b.box((s*.84,2.12,1.52),(.05,.03,.11),'TrackSteel',.004);parts.append(b.obj('Part_Stowage_'+tag,(s*.84,2.36,1.52)))
        b=B();b.box((s*1.23,.15,2.25),(.60,.13,.075),'Wood',.012);parts.append(b.obj('Part_TrackSpud_'+tag,(s*1.23,.15,2.25)))
    # Hinged, closed hatches above deep crew wells. Commander/loader on roof, driver on cab.
    for name,c,d in [('Driver',(0,-2.43,2.37),(1.25,.64,.050)),('Commander',(0,-.90,2.145),(.63,.62,.045)),('Loader',(.3,.40,2.145),(.57,.61,.045))]:
        b=B();b.box(c,d,'Paint',.012)
        for x in [-d[0]*.34,d[0]*.34]:
            b.cyl((c[0]+x-.07,c[1]+d[1]/2,c[2]),(c[0]+x+.07,c[1]+d[1]/2,c[2]),.035,'TrackSteel',12)
        b.beam((c[0]-.1,c[1]-.05,c[2]+.07),(c[0]+.1,c[1]-.05,c[2]+.07),.023,.023)
        for x in [-.1,.1]:b.beam((c[0]+x,c[1]-.05,c[2]+.01),(c[0]+x,c[1]-.05,c[2]+.07),.025,.02)
        for xx in [-d[0]*.42,d[0]*.42]:
            for yy in [-d[1]*.38,d[1]*.38]:b.rivet((c[0]+xx,c[1]+yy,c[2]+d[2]/2+.001),(0,0,1),.010)
        b.obj('Hatch_'+name,(c[0],c[1]+d[1]/2,c[2]))
        b=B()
        w=d[0]*.86;h=d[1]*.86
        b.box((c[0],c[1],c[2]-.37),(w,h,.04),'Interior',.008)
        for x in [-w/2,w/2]:b.box((c[0]+x,c[1],c[2]-.20),(.025,h,.36),'Interior',.004)
        for y in [-h/2,h/2]:b.box((c[0],c[1]+y,c[2]-.20),(w,.025,.36),'Interior',.004)
        for x in [-w/2,w/2]:b.box((c[0]+x,c[1],c[2]-.025),(.035,h,.04),'Paint',.005)
        for y in [-h/2,h/2]:b.box((c[0],c[1]+y,c[2]-.025),(w,.035,.04),'Paint',.005)
        o=b.obj('CrewWell_'+name)
        # Actual roof aperture; moving hatch reveals a deep dark enclosed crew well.
        bpy.ops.mesh.primitive_cube_add(size=1,location=(c[0],c[1],c[2]-.14))
        cutter=bpy.context.object;cutter.dimensions=(w-.04,h-.04,.60)
        bpy.context.view_layer.objects.active=ho
        mod=ho.modifiers.new('Hatch aperture '+name,'BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter
        bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cutter,do_unlink=True)
        # This separate fixed assembly is joined into Hull below.
    # Lamps, historically modest removable lamps, no markings.
    for s,tag in [(1,'L'),(-1,'R')]:
        b=B();b.cyl((s*.76,-2.94,1.92),(s*.76,-3.05,1.92),.070,'TrackSteel',20);b.cyl((s*.76,-3.052,1.92),(s*.76,-3.059,1.92),.058,'Lamps',20)
        parts.append(b.obj('Part_LampGuard_'+tag,(s*.76,-3.0,1.92)))
    for name,p in [('Seat_Commander',(0,-.9,2.2)),('Seat_Driver',(0,-2.34,2.12)),('Exhaust',(-.48,1.67,2.09)),('Light_Head_L',(.76,-3.06,1.92)),('Light_Head_R',(-.76,-3.06,1.92)),('Fx_Engine',(0,1.0,2.13)),('Fx_Ammo',(0,-.6,1.0)),('Fx_Hatch',(0,-.9,2.19))]:empty(name,p)
    # Fixed crew interiors form part of Hull, not spurious rig nodes.
    fixed=[ho]+[o for o in HEALTH if o.name.startswith('CrewWell_')]
    for o in fixed:HEALTH.remove(o)
    join_objects(fixed,'Hull',dest=HEALTH)
    return parts

def copyob(o,name,dest=DAMAGE):
    c=o.copy();c.data=o.data.copy() if o.data else None;sc.collection.objects.link(c);c.name=name;c.parent=None;c.matrix_world=o.matrix_world.copy();dest.append(c);return c

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 scar(name,center,normal,parent):
    b=B();p=Vector(center);n=Vector(normal).normalized();u=n.cross(Vector((0,0,1)))
    if u.length<.1:u=n.cross(Vector((0,1,0)))
    u.normalize();v=n.cross(u)
    for j in range(4):
        c=p+u*((j-1.5)*.21)+v*(sin(j*2.6)*.15)
        # Irregular soot fan in the armour plane with a raised jagged steel lip.
        rad=.054 if j==1 else .022;N=14;vs=[]
        for ring in [0,1,2]:
            for k in range(N):
                a=k*2*pi/N;rr=rad*[2.0,1.08,.64][ring]*(1+.14*sin(k*4.1+j));off=[.003,.011,.005][ring]
                vs.append(c+u*cos(a)*rr+v*sin(a)*rr+n*off)
        b.add(vs,[(k,(k+1)%N,(k+1)%N+N,k+N) for k in range(N)],'Burnt')
        b.add(vs,[(k+N,(k+1)%N+N,(k+1)%N+2*N,k+2*N) for k in range(N)],'Scars')
        b.add([c+n*.003]+[c+u*cos(k*2*pi/N)*rad*.67+v*sin(k*2*pi/N)*rad*.67+n*.003 for k in range(N)],[(0,k+1,(k+1)%N+1) for k in range(N)],'Burnt')
        # Short scraped ricochet furrow.
        b.beam(c+u*.05+n*.006,c+u*.18+v*.03+n*.006,.018,.006,'Scars')
    # Project every scar vertex onto the exported armour, then lift by 3mm (rim petals up to 11mm).
    projected=[]
    for vv in b.v:
        vv=Vector(vv);height=max(.003,(vv-p).dot(n));hit,_,_,_=ARMOUR_BVH.ray_cast(vv+n*.60,-n,1.5)
        projected.append(hit+n*height if hit is not None else vv)
    b.v=projected
    return b.obj(name,center,parent,dest=DAMAGE)

def build_damage(parts):
    global ARMOUR_BVH
    vertices=[];faces=[]
    for o in HEALTH:
        if o.type!='MESH':continue
        base=len(vertices);vertices += [o.matrix_world@v.co for v in o.data.vertices]
        faces += [tuple(base+i for i in f.vertices) for f in o.data.polygons]
    ARMOUR_BVH=BVHTree.FromPolygons(vertices,faces,all_triangles=True)
    hd=empty('Hull_Damage',dest=DAMAGE);td=empty('Turret_Damage',dest=DAMAGE)
    twins={}
    selected=[o for o in parts if o.name in ['Part_Skirt_L1','Part_Skirt_L2','Part_Skirt_L6','Part_Skirt_L8','Part_Skirt_R1','Part_Skirt_R3','Part_Skirt_R7','Part_Skirt_R9','Part_UnditchingBeam','Part_UnditchingRail_L','Part_ExhaustMuffler','Part_Fender_FL']]
    for o in selected:
        c=copyob(o,'Dmg_'+o.name[5:]);twins[o.name]=c.name
        for v in c.data.vertices:
            q=v.co.copy();s=1 if o.location.x>=0 else -1
            v.co.x+=s*.17*sin(q.y*3.9+q.z*2.7)*min(1,q.length*1.5)
            v.co.z+=.10*sin(q.y*5.5)*min(1,q.length*1.7)
        # Torn corner, open local split (copies retain original part origin).
        bm=bmesh.new();bm.from_mesh(c.data)
        faces=[f for f in bm.faces if f.calc_center_median().y>.17 and f.calc_center_median().z>.16 and random.random()<.45]
        bmesh.ops.delete(bm,geom=faces,context='FACES');bm.to_mesh(c.data);bm.free();par(c,hd)
        c.data.materials.append(MATS['Burnt'])
        for f in c.data.polygons:
            if random.random()<.22:f.material_index=len(c.data.materials)-1
    for name,c,n in [('Front',(0,-3.35,1.60),(0,-.52,.86)),('L',(1.481,1.60,1.24),(1,0,0)),('R',(-1.481,1.60,1.24),(-1,0,0)),('Rear',(0,3.40,.89),(0,.87,.5))]:scar('Scar_Hull_'+name,c,n,hd)
    for name,c,n in [('L',(2.052,-.17,1.55),(1,0,0)),('R',(-2.052,-.17,1.55),(-1,0,0)),('Front',(0,-2.791,2.18),(0,-1,0)),('Rear',(.3,.45,2.177),(0,0,1))]:scar('Scar_Turret_'+name,c,n,td)
    for s in [1,-1]:par(belt(s,True,DAMAGE),hd)
    # Sponson-side turretless damage twins: the shield, cheek, and cab hatch.
    for src,name in [('Gun_L','Dmg_Turret_Shield_L'),('Gun_R','Dmg_Turret_Shield_R'),('Hatch_Driver','Dmg_Turret_CabHatch')]:
        c=copyob(bpy.data.objects[src],name)
        for v in c.data.vertices:v.co.z-=.11*max(0,v.co.y+.25);v.co.x+=.045*sin(v.co.z*15)
        par(c,td)
    gun=bpy.data.objects['Gun_L'];c=copyob(gun,'Gun_Broken');par(c,td)
    b=B();p=gun.matrix_world.translation;d=Vector((.55,-.835165,0));a=p+d*.28;end=p+d*.67+Vector((0,0,-.27))
    b.cyl(a,end,.064,'Scars',20,r2=.062,cap=False)
    side=Vector((d.y,-d.x,0))
    for i in range(7):
        ang=i*2*pi/7;rad=side*cos(ang)+Vector((0,0,sin(ang)));v0=end+rad*.06;v1=end+rad*.12+d*.10+Vector((0,0,-.07));b.beam(v0,v1,.026,.012,'Scars')
    brokenbar=b.obj('BurstBarrel',a,parent=c,dest=DAMAGE)
    # Whole burnt wreck from the exact healthy meshes, including sagging running gear.
    copies=[]
    omit={'Part_Skirt_L2','Part_Skirt_L6','Part_Skirt_R7','Part_Fender_FL','Part_EngineGrille_L','Track_L'}
    for o in HEALTH:
        if o.type!='MESH' or o.name in omit:continue
        w=copyob(o,'wreck_tmp',dest=[])
        w.data.materials.clear();w.data.materials.append(MATS['Burnt'])
        for f in w.data.polygons:f.material_index=0
        if o.name.startswith('Wheel_'):w.location.z-=.05
        if o.name.startswith('Hatch_'):
            w.rotation_euler.x=math.radians(-74)
        if o.name.startswith('Barrel_'):
            for v in w.data.vertices:v.co.z-=.45*max(0,-v.co.y)
        if o.name=='Hull':
            bm=bmesh.new();bm.from_mesh(w.data)
            # Tear away the right sponson's outer wall and expose its blackened inner shell.
            fs=[f for f in bm.faces if f.calc_center_median().x>1.88 and -.55<f.calc_center_median().y<.35 and 1.1<f.calc_center_median().z<1.86]
            bmesh.ops.delete(bm,geom=fs,context='FACES');bm.to_mesh(w.data);bm.free()
        if o.name=='Part_UnditchingRail_L':
            for v in w.data.vertices:v.co.z+=.22*sin(v.co.y*1.3)
        copies.append(w)
    t=copyob(bpy.data.objects['Track_L_Broken'],'wreck_track',dest=[]);t.data.materials.clear();t.data.materials.append(MATS['Burnt'])
    for f in t.data.polygons:f.material_index=0
    copies.append(t)
    b=B()
    for x in [-.5,0,.5]:b.box((x,1.05,2.24),(.47,.92,.04),'Burnt',.005,Matrix.Rotation(.4+x,3,'X'))
    # Black crew space under ruptured deck, not an absent interior.
    b.box((0,.85,1.83),(1.6,1.3,.23),'Burnt',.009)
    copies.append(b.obj('wreck_rupture',dest=[]))
    wreck=join_objects(copies,'Wreck_Hull',dest=DAMAGE)
    bpy.context.view_layer.objects.active=wreck;wreck.select_set(True)
    dec=wreck.modifiers.new('Wreck simplified torn steel','DECIMATE');dec.ratio=.40;bpy.ops.object.modifier_apply(modifier=dec.name);wreck.select_set(False)
    for o in DAMAGE:
        if o.type=='MESH':
            for i,m in enumerate(o.data.materials):
                if m.name not in {'Paint','Burnt','Scars','TrackSteel','Rubber'}:o.data.materials[i]=MATS['Burnt']
    return twins

def clean_meshes(obs):
    removed=0
    for o in obs:
        if o.type!='MESH':continue
        bm=bmesh.new();bm.from_mesh(o.data)
        bmesh.ops.triangulate(bm,faces=list(bm.faces),quad_method='BEAUTY',ngon_method='BEAUTY')
        bad=[f for f in bm.faces if f.calc_area()<1e-10]
        removed+=len(bad)
        if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
        loose=[v for v in bm.verts if not v.link_faces]
        if loose:bmesh.ops.delete(bm,geom=loose,context='VERTS')
        bm.to_mesh(o.data);bm.free();o.data.update()
    print('Removed numerical degenerate faces',removed,flush=True)

def export_glb(path,obs):
    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(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO',export_extras=True)
    # Explicit portable paint factor, independent of exporter graph heuristics.
    raw=path.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);tail=raw[20+jl:]
    for m in doc.get('materials',[]):
        if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[*PAINTS[0][1],1]
    js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
    path.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(js)+len(tail))+struct.pack('<I4s',len(js),b'JSON')+js+tail)
    print('EXPORTED',path.name,path.stat().st_size,'triangles',tri_count(obs),flush=True)

def setup_render():
    sc.cycles.samples=32;sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
    floor=bpy.data.materials.new('Studio ground');floor.diffuse_color=(.28,.30,.31,1);floor.use_nodes=True;floor.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.28,.30,.31,1);floor.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.86
    bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.005));bpy.context.object.name='Studio';bpy.context.object.data.materials.append(floor)
    for name,pos,power,size in [('Key',(2,-5,8),1600,7),('Fill',(-6,-1,5),1050,6),('Rim',(2,6,7),2000,5)]:
        d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=pos;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
    d=bpy.data.cameras.new('Camera');o=bpy.data.objects.new('Camera',d);sc.collection.objects.link(o);sc.camera=o
    o.location=(10.5,-12,7.0);target=Vector((0,0,1.06));o.rotation_euler=(target-o.location).to_track_quat('-Z','Y').to_euler();d.type='ORTHO';d.ortho_scale=10.9
    return o

def render(name,w=1920,h=1080,samples=32):
    sc.render.resolution_x=w;sc.render.resolution_y=h;sc.cycles.samples=samples;sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)

def set_visible(objects,visible):
    for o in objects:o.hide_render=not visible

def metadata(healthy,damage,twins):
    bpy.context.view_layer.update()
    def node(o):
        p=o.matrix_world.translation
        return {'name':o.name,'parent':o.parent.name if o.parent else None,'world_position':[round(p.x,6),round(p.z,6),round(-p.y,6)],'type':o.type}
    pts=[o.matrix_world@Vector(c) for o in healthy 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)]
    return {'asset':'British Mark IV male','contract':'tank-damage/v1','axes':'+Z forward, +Y up, +X left','units':'metres','bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]],'size':[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]]},'files':{'mark-iv-tank.glb':[node(o) for o in healthy],'mark-iv-tank-damage.glb':[node(o) for o in damage]},'paints':[{'name':n,'linear_rgb':v} for n,v in PAINTS],'damage':{'parts':[{'part':o.name,'twin':twins.get(o.name)} for o in healthy if o.name.startswith('Part_')],'scars':[o.name for o in damage if o.name.startswith('Scar_')],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun_L':'Gun_Broken'},'wreck':['Wreck_Hull'],'sponson_twins':{'Gun_L':'Dmg_Turret_Shield_L','Gun_R':'Dmg_Turret_Shield_R','Hatch_Driver':'Dmg_Turret_CabHatch'},'turretless':True,'state_6':'Whole wreck displaced/rotated; no separate turret exists.'},'triangles':{'healthy':tri_count(healthy),'damage':tri_count(damage)},'moving_nodes':{'guns':'Y yaw, X elevation in glTF frame; local recoil direction from Muzzle socket','wheels':'local X','hatches':'local X hinge'}}

def contact_sheet(paths,out):
    # Pixel-only assembly of actual Cycles frames; never generated or repainted images.
    imgs=[bpy.data.images.load(str(p),check_existing=False) for p in paths];w,h=imgs[0].size
    arr=np.ones((h*2,w*3,4),dtype=np.float32)
    for k,img in enumerate(imgs):
        a=np.array(img.pixels[:],dtype=np.float32).reshape(h,w,4);row=1-k//3;col=k%3;arr[row*h:(row+1)*h,col*w:(col+1)*w]=a
    o=bpy.data.images.new(out,width=w*3,height=h*2,alpha=True);o.pixels.foreach_set(arr.ravel());o.filepath_raw=str(ROOT/out);o.file_format='PNG';o.save()
    for img in imgs:bpy.data.images.remove(img)

def main():
    for spec in [('Paint','green_metal_rust',(.9,.9,.9),.16,.83,1024),('TrackSteel','rusty_metal_03',(.28,.25,.21),.72,.70,512),('Wood','wood_planks',(.32,.22,.105),0,.86,512),('Interior','metal_plate',(.027,.031,.030),.05,.93,256),('Lamps','metal_plate',(.65,.57,.36),.1,.28,256),('Burnt','rusty_metal_03',(.085,.073,.062),.35,.94,512),('Scars','rusty_metal_03',(.40,.34,.27),.78,.66,512),('Rust','rusty_metal_03',(.20,.085,.043),.45,.88,512),('Mud','brown_mud_dry',(.14,.095,.052),0,.96,512)]:bake_material(*spec)
    parts=build_healthy();bpy.context.view_layer.update()
    # Clamp declared external width: ball MG muzzles are folded forward against sponsons.
    for name in ['MG_2','MG_3']:
        o=bpy.data.objects[name]
        for v in o.data.vertices:
            if abs((o.matrix_world@v.co).x)>2.055:v.co.x*=.04
    for o in HEALTH:
        if o.type=='MESH':o.data.update()
    bpy.context.view_layer.update()
    # Track sole sets zero precisely; final global dimensions normalized in mesh data and pivots.
    points=[o.matrix_world@Vector(c) for o in HEALTH if o.type=='MESH' for c in o.bound_box]
    lo=Vector([min(p[i] for p in points) for i in range(3)]);hi=Vector([max(p[i] for p in points) for i in range(3)])
    scale=Vector((4.11/(hi.x-lo.x),8.05/(hi.y-lo.y),2.43/(hi.z-lo.z)))
    # Flatten hierarchies temporarily to scale world geometry without applying moving transforms.
    parents={o:o.parent for o in HEALTH};worlds={o:o.matrix_world.copy() for o in HEALTH}
    for o in HEALTH:o.parent=None;o.matrix_world=worlds[o]
    for o in HEALTH:
        if o.type=='MESH':
            for v in o.data.vertices:v.co=Vector((v.co.x*scale.x,v.co.y*scale.y,v.co.z*scale.z))
        p=o.location;o.location=(p.x*scale.x,(p.y-(hi.y+lo.y)/2)*scale.y,(p.z-lo.z)*scale.z)
    for o,p in parents.items():
        if p:par(o,p)
    # Fixed root nodes use the common ground origin; moving nodes keep their real pivots.
    for o in HEALTH:
        if o.name in {'Hull','Track_L','Track_R'}:
            offset=o.location.copy()
            for v in o.data.vertices:v.co+=offset
            o.location=(0,0,0)
    # Exact healthy export first, for the requested early usable milestone.
    clean_meshes(HEALTH)
    export_glb(ROOT/'mark-iv-tank.glb',HEALTH)
    cam=setup_render();set_visible(HEALTH,True)
    render('preview.png',1280,720,16)
    # Damage copies use the final healthy frame; generated accents get the same normalization.
    twins=build_damage(parts);set_visible(DAMAGE,False)
    clean_meshes(DAMAGE)
    export_glb(ROOT/'mark-iv-tank-damage.glb',DAMAGE)
    data=metadata(HEALTH,DAMAGE,twins);(ROOT/'nodes.json').write_text(json.dumps(data,indent=2))
    bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'mark-iv-tank.blend'))
    render('final.png');old=cam.matrix_world.copy();cam.location=(11,0,1.30);cam.rotation_euler=(Vector((0,0,1.20))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=9.4;render('side.png',1600,800,24);cam.matrix_world=old;cam.data.ortho_scale=10.9
    cam.location=(-9,11,5);cam.rotation_euler=(Vector((0,0,1.05))-cam.location).to_track_quat('-Z','Y').to_euler();render('rear.png',1280,720,16)
    cam.location=(6,-5,3.2);cam.rotation_euler=(Vector((1.5,-.55,1.3))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=4.2;render('close.png',1280,720,24)
    cam.matrix_world=old;cam.data.ortho_scale=10.9
    clay=bpy.data.materials.new('Clay');clay.diffuse_color=(.45,.45,.45,1);clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.45,.45,.45,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.78
    sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);sc.view_layers[0].material_override=None
    # Exercise 30-degree local rotations with real pivot locations retained.
    rotations={o:o.rotation_euler.copy() for o in HEALTH if o.name.startswith(('Gun_','Wheel_','Hatch_','MG_'))}
    for o in rotations:
        o.rotation_euler.x+=pi/6
        if o.name.startswith('Gun_'):o.rotation_euler.z+=pi/6
    render('pivots.png',1280,720,12)
    for o,r in rotations.items():o.rotation_euler=r
    # Consumer proof: discard ALL authored tank objects, import BOTH files into this empty asset stage.
    for o in HEALTH+DAMAGE:
        if o.name in bpy.data.objects:bpy.data.objects.remove(o,do_unlink=True)
    group=bpy.data.node_groups.get('glTF Material Output')
    if group:bpy.data.node_groups.remove(group,do_unlink=True)
    before=set(bpy.data.objects);bpy.ops.import_scene.gltf(filepath=str(ROOT/'mark-iv-tank.glb'));healthy=list(set(bpy.data.objects)-before)
    before=set(bpy.data.objects);bpy.ops.import_scene.gltf(filepath=str(ROOT/'mark-iv-tank-damage.glb'));damage=list(set(bpy.data.objects)-before)
    # Importer may append material .001: recolour every imported Paint instance.
    paintmats={m for o in healthy if o.type=='MESH' for m in o.data.materials if m.name.startswith('Paint')}
    def tint(rgb):
        for m in paintmats:
            nt=m.node_tree
            # glTF importer uses a MULTIPLY Mix for texture * factor.
            found=False
            for n in nt.nodes:
                if n.type=='MIX' and n.data_type=='RGBA':
                    for inp in n.inputs:
                        if inp.name=='B' and inp.type=='RGBA' and not inp.is_linked:inp.default_value=(*rgb,1);found=True
                if n.type=='MIX_RGB' and n.blend_type=='MULTIPLY':n.inputs[2].default_value=(*rgb,1);found=True
            if not found:
                p=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');sock=p.inputs['Base Color']
                if sock.is_linked:
                    source=sock.links[0].from_socket;mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*rgb,1);nt.links.new(source,mix.inputs[1]);nt.links.new(mix.outputs[0],sock)
                else:sock.default_value=(*rgb,1)
    set_visible(damage,False);set_visible(healthy,True)
    paths=[]
    for i,(name,rgb) in enumerate(PAINTS):
        tint(rgb);p=ROOT/f'paint_{i}.png';render(p.name,640,360,12);paths.append(p)
    contact_sheet(paths,'paints.png');tint(PAINTS[0][1])
    hmap={o.name:o for o in healthy};dmap={o.name:o for o in damage}
    paths=[]
    for i in range(6):
        set_visible(healthy,i<4);set_visible(damage,False)
        if i==1:
            for o in damage:
                if o.name.startswith('Scar_'):o.hide_render=False
            for part,twin in list(twins.items())[:6]:hmap[part].hide_render=True;dmap[twin].hide_render=False
        if i==2:
            for o in healthy:
                if o.name.startswith('Part_'):o.hide_render=True
        if i==3:
            hmap['Track_L'].hide_render=True;dmap['Track_L_Broken'].hide_render=False
            hmap['Gun_L'].hide_render=True;hmap['Barrel_L'].hide_render=True;dmap['Gun_Broken'].hide_render=False;dmap['BurstBarrel'].hide_render=False
        if i>=4:
            w=dmap['Wreck_Hull'];w.hide_render=False
            if i==5:
                # Turretless contract: show the entire tank's wreck; a torn sponson is already built in.
                w.rotation_euler.z=math.radians(-12);w.location.x=.15
        p=ROOT/f'state_{i}.png';render(p.name,640,360,16);paths.append(p)
    contact_sheet(paths,'states.png')
    notes=["British Mark IV male: rhomboid tracks, riveted sponsons, hinged hatches, recoil barrels and turretless damage rig; metres, +Z forward.",f"Triangles: healthy {data['triangles']['healthy']:,}; damage {data['triangles']['damage']:,}; geometry and named origins retained.","Materials: Paint, TrackSteel, Wood, Interior, Lamps, Rust, Mud, Burnt, Scars; Paint factor [0.20,0.17,0.10], six variants.","Cycles OptiX baked tile textures: Paint base/normal 1024; ORM 512; all other maps 512 or 256; photographic sources 1k.","Poly Haven CC0 IDs: metal_plate, rusty_metal_03, wood_planks, green_metal_rust, brown_mud_dry; https://polyhaven.com/license and https://polyhaven.com/a/<id>.","Differences: stylised wear placement, simplified hidden suspension/interior; turretless exception uses three damage roots and no Wreck_Turret; state 6 is a displaced whole wreck."]
    (ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
    print('COMPLETE',json.dumps(data['triangles']),flush=True)
if __name__=='__main__':main()
