"""Leopard 2A6 style tank / tank-damage/v1. Blender 5.2; metres.
Run with Blender -b --python-exit-code 1 --python this_file.py.
All geometry is authored afresh. Photographic sources: Poly Haven CC0.
Scope: 80-140k healthy triangles, <=110k damage; 12 MB GLB; 1024/512px maps.
Views: daylight front three-quarter, orthographic side, clay, six states and paints.
Revision cap: 3 visual revisions, at most 2 independent critic passes per revision.
"""
import bpy, bmesh, math, os, json, random, struct, urllib.request, sys, hashlib
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin, cos, pi
import numpy as np
ROOT=Path(__file__).resolve().parent
SOURCE_SHA256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest();print('BUILD_SOURCE_SHA256',SOURCE_SHA256,flush=True)
CACHE=ROOT/'textures';CACHE.mkdir(exist_ok=True)
random.seed(26)
bpy.ops.wm.read_factory_settings(use_empty=True)
SC=bpy.context.scene;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!='CPU'
SC.cycles.device='GPU';SC.unit_settings.system='METRIC'
SC.render.image_settings.file_format='PNG';SC.view_settings.view_transform='AgX'
# Build coordinates are (left, forward, up); Blender stores (left, -forward, up).
def V(p):return Vector((p[0],-p[1],p[2]))
def vp(p):return list(V(p))
MATS={};TEX={};DEFAULT=(.09,.12,.07)
PAINTS=[('NATO green',DEFAULT),('desert tan',(.46,.35,.20)),('field grey',(.20,.23,.19)),('winter white',(.76,.78,.73)),('dark green-black',(.025,.035,.026)),('olive drab',(.16,.17,.075))]
def node(nt,t):return nt.nodes.new(t)
def link(nt,a,b):nt.links.new(a,b)
def download(id,map):
    f=CACHE/f'{id}_{map}_1k.jpg'
    if not f.exists():
        u=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
        urllib.request.urlretrieve(u,f)
    return bpy.data.images.load(str(f),check_existing=True)
def source_mat(name,id,color,metal,rough,neutral=False):
    m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
    out=node(nt,'ShaderNodeOutputMaterial');p=node(nt,'ShaderNodeBsdfPrincipled');link(nt,p.outputs['BSDF'],out.inputs['Surface'])
    uv=node(nt,'ShaderNodeTexCoord');tex=node(nt,'ShaderNodeTexImage');tex.image=download(id,'diff');link(nt,uv.outputs['UV'],tex.inputs['Vector'])
    gray=node(nt,'ShaderNodeRGBToBW');link(nt,tex.outputs['Color'],gray.inputs[0])
    rem=node(nt,'ShaderNodeMapRange');rem.inputs['From Min'].default_value=0;rem.inputs['From Max'].default_value=1;rem.inputs['To Min'].default_value=.54 if neutral else .45;rem.inputs['To Max'].default_value=.96 if neutral else 1.25;link(nt,gray.outputs[0],rem.inputs[0])
    noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=7;noise.inputs['Detail'].default_value=4;link(nt,uv.outputs['UV'],noise.inputs['Vector'])
    ramp=node(nt,'ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.2;ramp.color_ramp.elements[0].color=(.38,.38,.38,1);ramp.color_ramp.elements[1].position=.65;ramp.color_ramp.elements[1].color=(1,1,1,1);link(nt,noise.outputs['Fac'],ramp.inputs[0])
    mul=node(nt,'ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=.35;link(nt,rem.outputs[0],mul.inputs[1]);link(nt,ramp.outputs[0],mul.inputs[2])
    # Neutral paint trim: photographic detail, fine rain streaks and worn panel margins.
    paint_detail=mul.outputs[0]
    if neutral:
        sepUV=node(nt,'ShaderNodeSeparateXYZ');link(nt,uv.outputs['UV'],sepUV.inputs[0])
        distances=[]
        for ax in ['X','Y']:
            inv=node(nt,'ShaderNodeMath');inv.operation='SUBTRACT';inv.inputs[0].default_value=1;link(nt,sepUV.outputs[ax],inv.inputs[1])
            mn=node(nt,'ShaderNodeMath');mn.operation='MINIMUM';link(nt,sepUV.outputs[ax],mn.inputs[0]);link(nt,inv.outputs[0],mn.inputs[1]);distances.append(mn.outputs[0])
        edge=node(nt,'ShaderNodeMath');edge.operation='MINIMUM';link(nt,distances[0],edge.inputs[0]);link(nt,distances[1],edge.inputs[1])
        er=node(nt,'ShaderNodeValToRGB');cr=er.color_ramp;cr.elements.remove(cr.elements[1]);cr.elements[0].position=0;cr.elements[0].color=(.98,.98,.98,1)
        for pos,c in [(.006,.92),(.016,.38),(.037,.72),(.10,1.0)]:e=cr.elements.new(pos);e.color=(c,c,c,1)
        link(nt,edge.outputs[0],er.inputs[0])
        mapping=node(nt,'ShaderNodeVectorMath');mapping.operation='MULTIPLY';mapping.inputs[1].default_value=(36,2.5,1);link(nt,uv.outputs['UV'],mapping.inputs[0])
        streak=node(nt,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=5;streak.inputs['Detail'].default_value=3;link(nt,mapping.outputs[0],streak.inputs[0])
        sr=node(nt,'ShaderNodeMapRange');sr.inputs['To Min'].default_value=.68;sr.inputs['To Max'].default_value=1.07;link(nt,streak.outputs['Fac'],sr.inputs[0])
        for detail,factor in [(er.outputs[0],.75),(sr.outputs[0],.72)]:
            mm=node(nt,'ShaderNodeMixRGB');mm.blend_type='MULTIPLY';mm.inputs[0].default_value=factor;link(nt,paint_detail,mm.inputs[1]);link(nt,detail,mm.inputs[2]);paint_detail=mm.outputs[0]
    tint=node(nt,'ShaderNodeMixRGB');tint.blend_type='MULTIPLY' ;tint.inputs[0].default_value=1;link(nt,paint_detail,tint.inputs[1]);tint.inputs[2].default_value=(*color,1);link(nt,tint.outputs[0],p.inputs['Base Color'])
    arm=node(nt,'ShaderNodeTexImage');arm.image=download(id,'arm');arm.image.colorspace_settings.name='Non-Color';link(nt,uv.outputs['UV'],arm.inputs['Vector'])
    sep=node(nt,'ShaderNodeSeparateColor');link(nt,arm.outputs[0],sep.inputs[0]);rm=node(nt,'ShaderNodeMapRange');rm.inputs['To Min'].default_value=rough-.12;rm.inputs['To Max'].default_value=min(1,rough+.12);link(nt,sep.outputs['Green'],rm.inputs[0]);link(nt,rm.outputs[0],p.inputs['Roughness']);p.inputs['Metallic'].default_value=metal
    nor=node(nt,'ShaderNodeTexImage');nor.image=download(id,'nor_gl');nor.image.colorspace_settings.name='Non-Color';link(nt,uv.outputs['UV'],nor.inputs[0]);nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.32 if neutral else .4;link(nt,nor.outputs[0],nm.inputs['Color']);link(nt,nm.outputs[0],p.inputs['Normal'])
    return m,p,tint.outputs[0],rm.outputs[0],nm.outputs[0]
def bake_material(name,id,color,metal,rough,size=512,neutral=False):
    print('BAKE',name,'revision-3-final',flush=True)
    m,p,base,roughsock,normsock=source_mat(name,id,color,metal,rough,neutral)
    bpy.ops.mesh.primitive_plane_add(size=2);o=bpy.context.object;o.name='BakeFixture';o.data.materials.append(m)
    nt=m.node_tree;out=next(n for n in nt.nodes if n.type=='OUTPUT_MATERIAL');bsdf=p.outputs[0]
    result={}
    for kind,sz in [('base',size),('normal',512),('orm',512)]:
        im=bpy.data.images.new(name+'_'+kind,width=sz,height=sz,alpha=False)
        if kind!='base':im.colorspace_settings.name='Non-Color'
        target=node(nt,'ShaderNodeTexImage');target.image=im;nt.nodes.active=target
        if kind=='normal':
            link(nt,bsdf,out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL',margin=8)
        else:
            em=node(nt,'ShaderNodeEmission')
            if kind=='base':link(nt,base,em.inputs[0])
            else:
                comb=node(nt,'ShaderNodeCombineColor');comb.inputs['Red'].default_value=1;comb.inputs['Blue'].default_value=metal;link(nt,roughsock,comb.inputs['Green']);link(nt,comb.outputs[0],em.inputs[0])
            link(nt,em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT',margin=8)
        im.filepath_raw=str(CACHE/(name+'_'+kind+('.jpg' if kind=='base' else '.png')));im.file_format='JPEG' if kind=='base' else 'PNG';im.save();result[kind]=im
    bpy.data.objects.remove(o,do_unlink=True)
    nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');p=node(nt,'ShaderNodeBsdfPrincipled');link(nt,p.outputs[0],out.inputs[0]);p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
    for kind,im in result.items():
        t=node(nt,'ShaderNodeTexImage');t.image=im
        if kind=='base':link(nt,t.outputs[0],p.inputs['Base Color'])
        elif kind=='normal':
            n=node(nt,'ShaderNodeNormalMap');link(nt,t.outputs[0],n.inputs['Color']);link(nt,n.outputs[0],p.inputs['Normal'])
        else:
            sp=node(nt,'ShaderNodeSeparateColor');link(nt,t.outputs[0],sp.inputs[0]);link(nt,sp.outputs['Green'],p.inputs['Roughness']);link(nt,sp.outputs['Blue'],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')
            gn=node(nt,'ShaderNodeGroup');gn.node_tree=group;link(nt,sp.outputs['Red'],gn.inputs['Occlusion'])
    if name=='Lamps':p.inputs['Emission Color'].default_value=(.5,.39,.22,1);p.inputs['Emission Strength'].default_value=.35
    MATS[name]=m;TEX[name]=result
for args in [
    ('Paint','metal_plate', (1,1,1),.08,.8,1024,True),
    ('Rubber','rubberized_track',(.06,.066,.058),0,.88),
    ('TrackSteel','rusty_metal_02',(.20,.18,.145),.7,.66),
    ('Steel','metal_plate',(.23,.25,.24),.8,.42),
    ('Glass','metal_plate',(.022,.055,.062),.6,.22),
    ('Lamps','metal_plate',(.8,.74,.53),.15,.26),
    ('Burnt','rusty_metal_02',(.025,.023,.021),.28,.95),
    ('Scars','metal_plate',(.30,.30,.29),.8,.57)]:bake_material(*args)
# Geometry assembler: faces share vertices within each primitive, then bevel physical edges.
class Geo:
    def __init__(self):self.v=[];self.f=[];self.mi=[];self.uv=[];self.sm=[]
    def mesh(self,vs,fs,mat='Paint',uv=None):
        n=len(self.v);self.v += [vp(v) for v in vs];self.f += [tuple(n+i for i in f) for f in fs];self.mi += [list(MATS).index(mat)]*len(fs);self.sm += [False]*len(fs)
        for f in fs:
            if uv is not None:self.uv.append([uv[i] for i in f])
            else:
                a,b,c=[Vector(vs[i]) for i in f[:3]];norm=(b-a).cross(c-a);axis=max(range(3),key=lambda i:abs(norm[i]));inds=[i for i in range(3) if i!=axis]
                coords=[(vs[i][inds[0]]*.65,vs[i][inds[1]]*.65) for i in f]
                if mat=='Paint':
                    lows=[min(c[k] for c in coords) for k in range(2)];spans=[max(c[k] for c in coords)-lows[k] for k in range(2)]
                    if min(spans)>.12:coords=[(.001+.998*(c[0]-lows[0])/spans[0],.001+.998*(c[1]-lows[1])/spans[1]) for c in coords]
                self.uv.append(coords)
    def box(self,c,s,mat='Paint',rot=None):
        vs=[Vector((a*s[0]/2,b*s[1]/2,d*s[2]/2)) for a,b,d 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)]]
        if rot:vs=[rot@v for v in vs]
        vs=[tuple(v+Vector(c)) for v in vs]
        self.mesh(vs,[(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3),(0,2,3,1),(4,5,7,6)],mat)
    def rod(self,a,b,r,mat='Paint',n=12,r2=None,caps=True):
        a,b=Vector(a),Vector(b);d=(b-a).normalized();u=d.cross(Vector((0,0,1)))
        if u.length<.1:u=d.cross(Vector((0,1,0)))
        u.normalize();v=d.cross(u);r2=r if r2 is None else r2
        vs=[tuple(p+(u*cos(2*pi*i/n)+v*sin(2*pi*i/n))*rr) for p,rr 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 caps:fs += [tuple(reversed(range(n))),tuple(range(n,2*n))]
        self.mesh(vs,fs,mat)
        self.sm[-len(fs):]=[True]*n+[False]*(len(fs)-n)
    def lathe(self,c,profile,mat='Paint',n=48,axis='X'):
        # profile ordered along outer and inner rim, pairs axial offset/radius
        vs=[]
        for d,r in profile:
            for i in range(n):
                a=2*pi*i/n
                q=(d,r*cos(a),r*sin(a)) if axis=='X' else ((r*cos(a),d,r*sin(a)) if axis=='Y' else (r*cos(a),r*sin(a),d))
                vs.append(tuple(Vector(c)+Vector(q)))
        fs=[]
        for j in range(len(profile)-1):
            for i in range(n):fs.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
        self.mesh(vs,fs,mat)
        self.sm[-len(fs):]=[abs(profile[j+1][0]-profile[j][0])>1e-6 for j in range(len(profile)-1) for i in range(n)]
    def poly(self,outline,z0,z1,mat='Paint'):
        vs=[(x,y,z0) for x,y in outline]+[(x,y,z1) for x,y in outline];n=len(outline)
        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)]
        self.mesh(vs,fs,mat)
    def bolt(self,p,axis=(0,0,1),r=.021,mat='Steel'):
        a=Vector(p);d=Vector(axis);self.rod(a-d*.006,a+d*.013,r,mat,6)
    def tube(self,points,r=.016,mat='Steel',n=8):
        for a,b in zip(points,points[1:]):self.rod(a,b,r,mat,n)
    def obj(self,name,pivot=(0,0,0),parent=None,bevel=.004,segments=1):
        me=bpy.data.meshes.new(name);me.from_pydata(self.v,[],self.f);me.update()
        for m in MATS.values():me.materials.append(m)
        for p,m,sm in zip(me.polygons,self.mi,self.sm):p.material_index=m;p.use_smooth=sm
        uv=me.uv_layers.new(name='UVMap')
        for p,coords in zip(me.polygons,self.uv):
            for li,co in zip(p.loop_indices,coords):uv.data[li].uv=co
        ob=bpy.data.objects.new(name,me);SC.collection.objects.link(ob)
        bpy.context.view_layer.objects.active=ob;ob.select_set(True)
        # V flips handedness: recalculate normals of closed primitives consistently.
        bm=bmesh.new();bm.from_mesh(me);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=1e-7);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
        if bevel:
            mod=ob.modifiers.new('Manufactured edge radii','BEVEL');mod.width=bevel;mod.segments=segments;mod.limit_method='ANGLE';mod.angle_limit=.55
            bpy.ops.object.modifier_apply(modifier=mod.name)
        # Smooth cylindrical faces, preserve planar armor normals.
        if name.startswith('Wheel') or name in ['Barrel']:
            mod=ob.modifiers.new('Weighted normals','WEIGHTED_NORMAL');mod.keep_sharp=True
            try:bpy.ops.object.modifier_apply(modifier=mod.name)
            except:pass
        shift=V(pivot);me.transform(Matrix.Translation(-shift));ob.location=shift
        if parent:
            ob.parent=parent;ob.matrix_parent_inverse=Matrix.Identity(4);ob.location=shift-parent.matrix_world.translation
        ob.select_set(False);bpy.context.view_layer.update();return ob

def empty(name,pos,parent=None):
    o=bpy.data.objects.new(name,None);SC.collection.objects.link(o);o.location=V(pos)
    if parent:o.parent=parent;o.location=V(pos)-parent.matrix_world.translation
    bpy.context.view_layer.update();return o
PARTS={};HEALTH=[]
def part(name,g,pivot=None,parent=None,bev=.004):
    if pivot is None:
        vv=np.array(g.v);c=(vv.min(0)+vv.max(0))/2;pivot=(c[0],-c[1],c[2])
    o=g.obj(name,pivot,parent,bev);PARTS[name]=o;return o
# Low welded hull, chamfered glacis and full inner side walls.
g=Geo()
sections=[(-3.86,1.57,.62,1.78),(-3.40,1.68,.49,1.82),(2.55,1.66,.48,1.66),(3.86,1.47,.78,1.40)]
vs=[]
for f,w,lo,hi in sections:vs += [(-w,f,lo),(w,f,lo),(w,f,hi),(-w,f,hi)]

for k in range(4):vs[k*4]=(-1.20,vs[k*4][1],vs[k*4][2]);vs[k*4+1]=(1.20,vs[k*4+1][1],vs[k*4+1][2])
vs[12]=(-1.20,3.49,.78);vs[13]=(1.20,3.49,.78)
g.mesh(vs,[(0,3,2,1),(12,13,14,15)]+[(j*4+i,j*4+(i+1)%4,(j+1)*4+(i+1)%4,(j+1)*4+i) for j in range(3) for i in range(4)])
# Continuous fender shelves, ring race and rear bulkhead detailing.
for s in [-1,1]:
    g.box((s*1.57,0,1.61),(.28,7.44,.16))
    for f in np.linspace(-3.4,3.35,11):
        g.box((s*1.686,f,1.48),(.09,.13,.24));g.bolt((s*1.74,f,1.48),(s,0,0))
    # Upper hull access panels, flush hinges, latch handles and intake louvers.
    for ff,zz in [(-2.66,1.765),(-1.52,1.745),(-.35,1.715),(.86,1.685)]:
        g.box((s*1.676,ff,zz),(.026,1.08,.195),'Paint')
        for fy in [ff-.44,ff+.44]:g.bolt((s*1.697,fy,zz+.065),(s,0,0),.012,'Paint')
        g.tube([(s*1.704,ff-.13,zz+.028),(s*1.733,ff-.13,zz+.043),(s*1.733,ff+.10,zz+.043),(s*1.704,ff+.10,zz+.028)],.011,'Paint')
    for ff in np.linspace(-1.12,-.86,8):g.box((s*1.700,ff,1.739),(.012,.016,.12),'Rubber')
    g.tube([(s*1.65,-3.5,1.84),(s*1.65,2.7,1.67),(s*1.55,3.53,1.50)],.017,'Paint')
    for f in [2.52,1.65,.78,-.09,-.96,-1.83,-2.70]:
        g.rod((s*1.22,f+.16,.86),(s*1.54,f,.49),.067,'Steel',12)
    # Recovery shackle mounts and front lamps base
    g.box((s*1.03,3.72,1.10),(.22,.15,.16));g.lathe((s*1.03,3.80,1.10),[(0,.079),(.065,.079),(.065,.045),(0,.045),(0,.079)],'Steel',20,'Y')
    g.box((s*1.32,3.51,1.45),(.27,.16,.14),'Steel')
# Driver hatch well physically recessed into separate deck patch; main hull roof is below well.
g.lathe((.64,2.42,1.51),[(0,.31),(.15,.31),(.15,.37),(.02,.38),(0,.31)],'Paint',40,'Z')
g.lathe((.64,2.42,1.48),[(0,.0),(0,.29),(.15,.29)],'Rubber',32,'Z')
# Turret bearing and dark closed ring interior
g.lathe((0,.18,1.82),[(0,1.08),(.13,1.08),(.13,1.18),(0,1.18),(0,1.08)],'Steel',64,'Z')
# Rear access panels, tow eyes, small bolts
for x in [-1.23,0,1.23]:
    g.box((x,-3.871,1.05),(.53,.025,.39),'Paint')
    for dx in [-.20,.20]:g.bolt((x+dx,-3.89,1.05),(0,-1,0))
for x in [-.55,.55]:g.lathe((x,-3.91,.83),[(0,.10),(.035,.10),(.035,.065),(0,.065),(0,.10)],'Steel',16,'Y')
Hull=g.obj('Hull',bevel=.012,segments=2)
# Track/wheel arrangement. All wheel nodes numbered front to back; seven road wheels.
WHEELS=[]
for side,s in [('L',1),('R',-1)]:
    wheeldefs=[('idler',3.19,.94,.40)]+[('road',f,.50,.395) for f in [2.51,1.66,.81,-.04,-.89,-1.74,-2.59]]+[('sprocket',-3.24,1.00,.42)]+[('return',f,1.24,.155) for f in [2.25,.78,-.73,-2.23]]
    wheeldefs.sort(key=lambda z:-z[1])
    for idx,(typ,f,z,r) in enumerate(wheeldefs,1):
        g=Geo();x=s*1.52
        # twin dished wheel shells with rubber shoulders, recessed concentric hubs
        for off in [-.14,.14]:
            xx=x+off
            if typ in ['road','idler','return']:
                g.lathe((xx,f,z),[(-.078,r*.86),(-.070,r),(.060,r),(.079,r*.88)],'Rubber',40)
                g.lathe((xx,f,z),[(-.068,0),(-.068,r*.84),(-.045,r*.89),(.066,r*.89),(.083,r*.78),(.038,r*.67),(.014,r*.36),(.09,r*.26),(.09,0)],'Paint',40)
            else:
                g.lathe((xx,f,z),[(-.055,0),(-.055,r*.83),(.055,r*.83),(.055,0)],'TrackSteel',40)
                for t in range(16):
                    a=2*pi*t/16;g.box((xx,f+r*.90*cos(a),z+r*.90*sin(a)),(.13,.105,.105),'TrackSteel',Matrix.Rotation(a,3,'X'))
        # Exposed wheel lip and recessed dish fasteners, mirrored on both sides.
        if typ in ['road','idler']:
            g.lathe((x+s*.218,f,z),[(s*d,rr) for d,rr in [(0,r*.88),(.021,r*.88),(.021,r*.81),(.008,r*.78),(0,r*.88)]],'Paint',40)
            g.lathe((x+s*.207,f,z),[(0,r*.74),(s*.004,r*.74),(s*.004,r*.716),(0,r*.716),(0,r*.74)],'Rubber',40)
        outer=x+s*.218
        g.rod((outer-s*.035,f,z),(outer+s*.024,f,z),r*.24,'Paint',24)
        for t in range(10 if typ!='return' else 6):
            a=2*pi*t/(10 if typ!='return' else 6);g.bolt((outer,f+r*.41*cos(a),z+r*.41*sin(a)),(s,0,0),.014,'Steel')
        if typ=='sprocket':
            for t in range(10):
                a=2*pi*t/10;g.rod((outer-s*.014,f+.245*cos(a),z+.245*sin(a)),(outer+s*.008,f+.245*cos(a),z+.245*sin(a)),.047,'Rubber',12)
        o=g.obj(f'Wheel_{side}{idx}',(x,f,z),bevel=.002);o['role']=typ;WHEELS.append(o)
# Capsule path follows raised idler/sprocket, flat lower run; link V = loop distance.
PATH=[(3.18+ .48*cos(t),.59+.48*sin(t)) for t in np.linspace(-pi/2,pi/2,19)]
PATH += [(f,1.07) for f in np.linspace(3.18,-3.20,35)[1:]]
PATH += [(-3.20+.48*cos(t),.59+.48*sin(t)) for t in np.linspace(pi/2,3*pi/2,19)[1:]]
PATH += [(f,.11) for f in np.linspace(-3.20,3.18,35)[1:]]
# Raise forward/back corners to match wheel wrap and upper run. Capsule radius .56 for clearance.
PATH=[(3.15+.58*cos(t),.66+.58*sin(t)) for t in np.linspace(-pi/2,pi/2,23)] + [(f,1.24) for f in np.linspace(3.15,-3.18,35)[1:]] + [(-3.18+.58*cos(t),.66+.58*sin(t)) for t in np.linspace(pi/2,3*pi/2,23)[1:]] + [(f,.08) for f in np.linspace(-3.18,3.15,35)[1:]]
# Convex tangent envelope of actual front idler, road wheels and rear drive.
cloud=[]
for f,z,r in [(3.19,.94,.45),(2.51,.50,.455),(-2.59,.50,.455),(-3.24,1.00,.48)]:
    cloud += [(f+r*cos(a),z+r*sin(a)) for a in np.linspace(0,2*pi,65)[:-1]]
def cross2(o,a,b):return (a[0]-o[0])*(b[1]-o[1])-(a[1]-o[1])*(b[0]-o[0])
cloud=sorted(set(cloud));lower=[];upper=[]
for p in cloud:
    while len(lower)>=2 and cross2(lower[-2],lower[-1],p)<=0:lower.pop()
    lower.append(p)
for p in reversed(cloud):
    while len(upper)>=2 and cross2(upper[-2],upper[-1],p)<=0:upper.pop()
    upper.append(p)
PATH=lower[:-1]+upper[:-1]
D=[0]
for a,b in zip(PATH,PATH[1:]+PATH[:1]):D.append(D[-1]+math.dist(a,b))
LOOP=D[-1];LINKS=84
def point_on(t):
    d=(t%1)*LOOP
    for i in range(len(PATH)):
        if D[i+1]>=d:
            u=(d-D[i])/(D[i+1]-D[i]);a=Vector(PATH[i]);b=Vector(PATH[(i+1)%len(PATH)]);p=a.lerp(b,u);v=(b-a).normalized();return p,v

def track_geo(s,broken=False):
    g=Geo()
    for i in range(LINKS):
        t=i/LINKS;p,d=point_on(t);f,z=p
        if broken:
            # Fall upper run down over suspension, split front arc and spill in front.
            if z>1.12:z=.91-.30*sin(pi*max(0,min(1,(f+3.18)/6.36)))
            if f>3.15 and z>.38:
                f=3.30+(z-.38)*1.55;z=.078;d=Vector((1,0))
        a=math.atan2(d[1],d[0]);rot=Matrix.Rotation(a,3,'X')
        start=len(g.uv)
        g.box((s*1.53,f,z),(.63,LOOP/LINKS*.97,.078),'TrackSteel',rot)
        # two replaceable rubber pads, exposed metal hinge crossbars, guide horn inside
        outward=Vector((0,-sin(a),cos(a)))
        # lower run outer pad is below; path tangent gives outer right normal
        outward=-outward
        for xx in [-.151,.151]:
            c=Vector((s*1.53+xx,f,z))+outward*.047;g.box(c,(.25,LOOP/LINKS*.61,.034),'Rubber',rot)
        c=Vector((s*1.53,f,z))-outward*.065;g.box(c,(.063,.071,.098),'TrackSteel',rot)
        # pin heads are visible down both belt margins
        g.rod((s*1.53-.332,f+d[0]*LOOP/LINKS*.48,z+d[1]*LOOP/LINKS*.48),(s*1.53+.332,f+d[0]*LOOP/LINKS*.48,z+d[1]*LOOP/LINKS*.48),.029,'TrackSteel',8)
        for j in range(start,len(g.uv)):
            g.uv[j]=[(u%1,(t+(v%1-.5)/LINKS)%1) for u,v in g.uv[j]]
    return g
TRACKS=[]
for side,s in [('L',1),('R',-1)]:TRACKS.append(track_geo(s).obj('Track_'+side,bevel=.003))
# Individually detachable skirt armor. Seven plates on each side, four thick forward panels.
for side,s in [('L',1),('R',-1)]:
    for i in range(7):
        f=3.14-i*1.00;g=Geo();x=s*(1.815 if i<4 else 1.854)
        zbottom=1.06 if i<4 else 1.13
        # Composite front plates have clipped lower corners; lighter rear skirt clears sprocket.
        thick=.115 if i<4 else .050
        if i<4:
            edge=[(f-.487,1.67),(f+.487,1.67),(f+.487,zbottom+.10),(f+.38,zbottom),(f-.34,zbottom),(f-.487,zbottom+.10)]
        elif i==6:
            edge=[(f-.487,1.67),(f+.487,1.67),(f+.487,zbottom),(f-.12,zbottom),(f-.487,zbottom+.20)]
        else:edge=[(f-.487,1.67),(f+.487,1.67),(f+.487,zbottom),(f-.487,zbottom)]
        nv=len(edge);vv=[(x+dx,ff,zz) for dx in [-thick/2,thick/2] for ff,zz in edge]
        g.mesh(vv,[tuple(reversed(range(nv))),tuple(range(nv,2*nv))]+[(j,(j+1)%nv,(j+1)%nv+nv,j+nv) for j in range(nv)])
        for ff in [f-.39,f+.39]:
            g.box((x+s*.063,ff,1.60),(.035,.083,.087));g.bolt((x+s*.087,ff,1.60),(s,0,0),.018)
            g.bolt((x+s*.071,ff,zbottom+.075),(s,0,0),.015)
        if i<4:g.tube([(x+s*.08,f-.30,zbottom+.03),(x+s*.08,f-.18,zbottom-.09),(x+s*.08,f+.18,zbottom-.09),(x+s*.08,f+.30,zbottom+.03)],.013)
        part(f'Part_Skirt_{side}{i+1}',g)
    for end,f in [('F',3.50),('R',-3.51)]:
        g=Geo()
        # Thin folded fender skin, sloped over the leading/trailing belt with reinforced edges.
        xa=s*1.54-.325;xb=s*1.54+.325;fa=f-.33;fb=f+.33
        za,zb=(1.57,1.43) if end=='F' else (1.43,1.57)
        vv=[(xa,fa,za),(xb,fa,za),(xb,fb,zb),(xa,fb,zb),(xa,fa,za-.028),(xb,fa,za-.028),(xb,fb,zb-.028),(xa,fb,zb-.028)]
        g.mesh(vv,[(0,1,2,3),(7,6,5,4),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)])
        for xx in [xa,xb]:g.mesh([(xx,fa,za),(xx,fb,zb),(xx,fb,zb-.09),(xx,fa,za-.09)],[(0,1,2,3)],'Paint')
        g.rod((xa+.025,f,1.45),(xb-.025,f,1.45),.018,'Steel',10)
        g.box((s*1.55,f+(.24 if end=='F' else -.28),1.29),(.64,.075,.29),'Rubber',Matrix.Rotation(-.45 if end=='F' else .1,3,'X'))
        for xx in [-.22,.22]:g.bolt((s*1.54+xx,f,1.56))
        part(f'Part_Fender_{end}{side}',g)
    # lamp and protective steel cage
    g=Geo();g.rod((s*1.30,3.83,1.49),(s*1.30,3.94,1.49),.094,'Steel',24);g.lathe((s*1.30,3.941,1.49),[(0,.078),(.009,.073),(.017,.05),(.020,0)],'Lamps',32,'Y');g.lathe((s*1.30,3.94,1.49),[(0,.098),(.014,.098),(.014,.077),(0,.077),(0,.098)],'Steel',32,'Y')
    part(f'Part_Headlamp_{side}',g)
    g=Geo();g.tube([(s*1.46,3.96,1.40),(s*1.46,3.96,1.65),(s*1.15,3.96,1.65),(s*1.15,3.96,1.40)],.017);part(f'Part_LampGuard_{side}',g)
    # rear brake light assembly, accessible exhaust grilles and armored shroud
    g=Geo();g.box((s*1.24,-3.90,1.47),(.37,.09,.16),'Rubber');g.box((s*1.24,-3.952,1.47),(.25,.013,.08),'Lamps');part(f'Part_TailLight_{side}',g)
    g=Geo();g.box((s*.94,-3.913,1.66),(1.05,.11,.24),'Rubber')
    for zz in np.linspace(1.57,1.77,8):g.box((s*.94,-3.98,zz),(1.04,.055,.014),'Paint')
    for xx in [s*.94-.53,s*.94+.53]:g.box((xx,-3.965,1.67),(.045,.065,.29))
    part(f'Part_ExhaustShroud_{side}',g)
# Engine deck with two visible fan grilles, lattice made of steel bars.
for s in [-1,1]:
    g=Geo();cx=s*.76;f=-2.74
    g.box((cx,f,1.844),(1.32,1.48,.032),'Rubber')
    g.lathe((cx,f,1.865),[(0,.52),(.018,.52),(.018,.56),(0,.56),(0,.52)],'Steel',48,'Z')
    for t in range(10):
        a=2*pi*t/10;g.rod((cx+.12*cos(a),f+.12*sin(a),1.869),(cx+.49*cos(a+.38),f+.49*sin(a+.38),1.869),.048,'Rubber',8)
    for xx in np.linspace(cx-.63,cx+.63,25):g.box((xx,f,1.897),(.013,1.42,.018),'Paint')
    for ff in np.linspace(f-.70,f+.70,19):g.box((cx,ff,1.911),(1.27,.012,.014),'Steel')
    for ff in [f-.74,f+.74]:g.box((cx,ff,1.9),(1.34,.04,.035))
    for xx in [cx-.67,cx+.67]:g.box((xx,f,1.9),(.04,1.50,.035))
    part('Part_EngineGrille_'+('L' if s>0 else 'R'),g)
# Tow cables along deck with forged eyes, long brackets; front spare track bundle.
for s in [-1,1]:
    g=Geo();pts=[(s*1.44,-3.40,1.91),(s*1.58,-3.16,1.94),(s*1.58,-1.6,1.85),(s*1.53,.95,1.78),(s*1.42,1.8,1.70)]
    for k in range(3):g.tube([(x+.012*cos(k*2*pi/3),y,z+.012*sin(k*2*pi/3)) for x,y,z in pts],.009,'Steel',8)
    for x,y,z in [pts[0],pts[-1]]:g.lathe((x,y,z),[(0,.07),(.024,.07),(.024,.045),(0,.045),(0,.07)],'Steel',16,'Z')
    part('Part_TowCable_'+('L' if s>0 else 'R'),g)
    g=Geo()
    for i in range(3):
        x=s*(.30+i*.22);g.box((x,3.53,1.47),(.18,.26,.074),'TrackSteel',Matrix.Rotation(.21,3,'X'));g.box((x,3.53,1.518),(.13,.14,.03),'Rubber')
        for yy in [3.425,3.635]:g.rod((x-.105,yy,1.495),(x+.105,yy,1.495),.025,'TrackSteel',10)
        for xx in [x-.091,x+.091]:g.bolt((xx,3.53,1.514),r=.011,mat='Steel')
    part('Part_SpareTrack_'+('L' if s>0 else 'R'),g)
# Distinct pioneer tools with shaped blades, grips and individual clasp brackets.
g=Geo()
# Spade: rolled blade, socket, composite shaft and D grip.
g.rod((-.47,2.88,1.622),(-.47,3.30,1.548),.019,'Rubber',12)
g.rod((-.47,3.23,1.56),(-.47,3.35,1.535),.026,'Steel',12)
g.mesh([(-.60,3.30,1.535),(-.34,3.30,1.535),(-.35,3.47,1.505),(-.42,3.54,1.49),(-.52,3.54,1.49),(-.59,3.47,1.505)],[(0,1,2,3,4,5)],'Steel')
g.tube([(-.47,2.89,1.62),(-.54,2.82,1.638),(-.54,2.73,1.65),(-.40,2.73,1.65),(-.40,2.82,1.638),(-.47,2.89,1.62)],.013,'Rubber')
# Axe and short breaker bar.
g.rod((-.10,2.89,1.624),(-.10,3.42,1.533),.021,'Rubber',12)
g.mesh([(-.20,3.35,1.53),(-.18,3.46,1.51),(.04,3.48,1.505),(.08,3.30,1.54),(-.02,3.34,1.536),(-.20,3.35,1.57),(-.18,3.46,1.55),(.04,3.48,1.545),(.08,3.30,1.58),(-.02,3.34,1.576)],[(0,1,2,3,4),(9,8,7,6,5),(0,5,6,1),(1,6,7,2),(2,7,8,3),(3,8,9,4),(4,9,5,0)],'Steel')
g.tube([(.20,2.82,1.64),(.20,3.36,1.55),(.24,3.43,1.55),(.28,3.44,1.56)],.018,'Steel',12)
for x in [-.47,-.10,.20]:
    for f,z in [(2.99,1.626),(3.20,1.588)]:
        g.box((x,f,z-.03),(.10,.065,.035),'Paint')
        g.tube([(x-.04,f,z-.02),(x-.04,f,z+.017),(x+.04,f,z+.017),(x+.04,f,z-.02)],.009,'Steel',8)
        g.bolt((x+.057,f,z-.011),r=.009,mat='Steel')
part('Part_ToolSet_Front',g)
# Complete turret base; outline deliberately broad and low, with a rear bustle.
TURRET_P=(0,.18,1.91)
g=Geo()
outline=[(-.53,1.82),(.53,1.82),(1.35,1.06),(1.52,.42),(1.47,-1.75),(1.12,-2.03),(-1.12,-2.03),(-1.47,-1.75),(-1.52,.42),(-1.35,1.06)]
# loft sloped sides rather than rectangular turret
bottom=[(x*.90,f,1.94) for x,f in outline];top=[(x*.91,f-.08,2.64) for x,f in outline]
g.mesh(bottom+top,[tuple(reversed(range(10))),tuple(range(10,20))]+[(i,(i+1)%10,(i+1)%10+10,i+10) for i in range(10)])
# roof welds and mounting fixtures on underlying armor
for s in [-1,1]:
    g.tube([(s*1.17,1.16,2.65),(s*1.34,.35,2.65),(s*1.30,-1.53,2.65)],.009,'Steel')
    for f in [-1.43,-.85,-.25,.36,1.03]:g.bolt((s*1.38,f,2.27),(s,0,0),.020)
    for f in [-1.38,-.61]:g.box((s*1.42,f,2.31),(.085,.11,.14))
    # lifting eyes and mounting strip
    g.tube([(s*1.03,.41,2.65),(s*1.03,.41,2.72),(s*1.03,.19,2.72),(s*1.03,.19,2.65)],.021,'Steel')
# Roof maintenance covers, fastener rows and weld lines.
for x,f,w,l in [(0,-1.33,.70,.73),(-.98,.51,.34,.62),(.99,.45,.25,.52)]:
    g.box((x,f,2.645),(w,l,.014),'Paint')
    for xx in [x-w/2+.04,x+w/2-.04]:
        for ff in [f-l/2+.045,f+l/2-.045]:g.bolt((xx,ff,2.660),r=.012,mat='Paint')
    g.tube([(x-w/2,f-l/2,2.654),(x+w/2,f-l/2,2.654),(x+w/2,f+l/2,2.654)],.005,'Steel')
for s in [-1,1]:
    for ff in np.linspace(-1.67,.80,12):g.bolt((s*1.24,ff,2.650),r=.011,mat='Paint')
Turret=g.obj('Turret',TURRET_P,bevel=.014,segments=2)
# Arrowhead modules: pointed front at mid-height, faceted sloped upper/lower faces.
for side,s in [('L',1),('R',-1)]:
    g=Geo()
    # nested front edge, shoulder, rear cut; front face drops toward the gun
    pts=[(.28,2.69,2.29),(1.20,2.45,2.23),(1.59,1.19,2.24),(1.53,.60,2.25),(.35,.75,2.24),
         (.32,1.76,2.64),(1.07,1.60,2.63),(1.47,1.06,2.64),(1.38,.59,2.64),(.35,.75,2.64),
         (.31,2.21,1.99),(1.09,2.04,1.99),(1.47,1.08,1.99),(1.39,.60,2.01),(.35,.75,2.01)]
    pts=[(s*x,f,z) for x,f,z in pts]
    fs=[]
    for i in range(5):j=(i+1)%5;fs += [(i,j,5+j,5+i),(i,10+i,10+j,j)]
    fs += [(5,6,7,8,9),(14,13,12,11,10)]
    g.mesh(pts,fs)
    # edge trim, fasteners, hinges are module-owned
    g.tube([(s*.37,1.76,2.646),(s*1.06,1.60,2.646),(s*1.48,1.06,2.652)],.009,'Steel')
    for xx,ff in [(.41,1.735),(.65,1.69),(.95,1.63),(1.24,1.38),(1.39,1.18)]:g.bolt((s*xx,ff,2.65),r=.014)
    for f in [.88,1.04]:g.box((s*1.53,f,2.32),(.025,.14,.035),'Steel')
    part('Part_Turret_Arrowhead_'+side,g,parent=Turret,bev=.008)
# Turret rear stowage bins and wire bustle basket.
for side,s in [('L',1),('R',-1)]:
    g=Geo();g.box((s*1.40,-1.60,2.36),(.39,.85,.44))
    g.box((s*1.405,-1.60,2.596),(.43,.90,.045));g.box((s*1.619,-1.60,2.35),(.025,.75,.32),'Rubber')
    for ff in np.linspace(-1.94,-1.25,10):g.rod((s*1.638,ff,2.21),(s*1.638,ff,2.49),.006,'Steel',6)
    for zz in np.linspace(2.21,2.49,6):g.rod((s*1.638,-1.96,zz),(s*1.638,-1.23,zz),.006,'Steel',6)
    g.box((s*1.42,-1.60,2.625),(.14,.16,.014),'Steel')
    part('Part_Turret_Bin_'+side,g,parent=Turret)
g=Geo()
for zz in [1.94,2.44]:g.tube([(-1.22,-2.03,zz),(-1.19,-2.40,zz),(1.19,-2.40,zz),(1.22,-2.03,zz)],.022,'Paint')
for x in np.linspace(-1.18,1.18,15):g.tube([(x,-2.04,1.94),(x,-2.39,1.94),(x,-2.39,2.44)],.008,'Steel')
for zz in [2.12,2.23,2.34]:g.rod((-1.18,-2.40,zz),(1.18,-2.40,zz),.007,'Steel',6)
part('Part_Turret_BustleRack',g,parent=Turret)
# Smoke dispensers: two banks of four at each side, canted upward and outward.
for side,s in [('L',1),('R',-1)]:
    g=Geo()
    for row in range(2):
        for k in range(4):
            f=-.21-k*.21-row*.06;z=2.19+row*.27;x=s*(1.45+row*.01)
            a=(x,f,z);b=(x+s*(.18+k*.012),f+.12-k*.015,z+.17+k*.024)
            g.rod(a,b,.052,'Paint',16);g.rod(b,tuple(Vector(b)+Vector((s*.015,.007,.012))),.045,'Steel',16);g.rod(tuple(Vector(b)+Vector((s*.016,.008,.013))),tuple(Vector(b)+Vector((s*.019,.009,.015))),.031,'Rubber',16)
    for z in [2.16,2.43]:g.box((s*1.47,-.55,z),(.10,.97,.054),'Steel')
    part('Part_Turret_Smoke_'+side,g,parent=Turret)
# Gun group at trunnions; barrel child has separate recoil pivot.
g=Geo();g.rod((-.31,1.67,2.29),(.31,1.67,2.29),.265,'Paint',40)
g.box((0,1.76,2.29),(.56,.59,.38));g.rod((0,1.89,2.29),(0,2.26,2.29),.214,'Rubber',40)
for ff in [1.94,2.02,2.10,2.18]:g.lathe((0,ff,2.29),[(0,.21),(.018,.22),(.035,.21)],'Rubber',40,'Y')
Gun=g.obj('Gun',(0,1.68,2.29),Turret,bevel=.009)
g=Geo()
# L/55 smoothbore thermal sleeve, fume extractor and open muzzle bore.
profile=[(0,.15),(.13,.155),(.18,.137),(1.15,.137),(1.20,.177),(1.29,.185),(1.72,.185),(1.83,.148),(1.92,.132),(3.90,.112),(4.07,.112),(4.11,.126),(4.17,.126),(4.20,.108),(4.84,.103),(4.85,.103),(4.85,.062),(4.67,.062)]
# Starts at 2.26, ends at 7.11
# extractor slightly aft of middle as reference; uninterrupted taper to muzzle
g.lathe((0,2.26,2.29),profile,'Paint',48,'Y')
for ff,r in [(2.48,.140),(3.42,.178),(3.92,.183),(4.15,.134),(6.21,.115),(7.04,.109)]:g.lathe((0,ff,2.29),[(0,r),(.024,r),(.024,r+.009),(.045,r+.009),(.045,r)],'Steel',48,'Y')
g.lathe((0,7.104,2.29),[(0,.107),(.006,.107),(.006,.062),(-.25,.062),(-.25,0)],'Steel',48,'Y')
Barrel=g.obj('Barrel',(0,2.26,2.29),Gun,bevel=.001)
Muzzle=empty('Muzzle',(0,7.11,2.29),Barrel)
# Optical head, armored sights with inset dark lenses and sun shields.
for name,c,size in [('MainSight',(.63,1.13,2.75),(.48,.40,.21)),('PanoramicSight',(-.62,-.12,2.86),(.30,.27,.26))]:
    g=Geo();x,f,z=c
    if name=='PanoramicSight':g.rod((x,f,2.65),(x,f,2.77),.18,'Paint',32)
    g.box(c,size);g.box((x,f+size[1]/2+.009,z),(.75*size[0],.023,.60*size[2]),'Glass')
    for dx in [-size[0]*.40,size[0]*.40]:
        for dz in [-size[2]*.39,size[2]*.39]:g.bolt((x+dx,f+size[1]/2+.021,z+dz),(0,1,0),.008,'Paint')
    g.box((x,f+.025,z+size[2]/2+.018),(size[0]+.05,size[1]+.10,.035));
    for dx in [-size[0]/2,size[0]/2]:g.box((x+dx,f+size[1]/2,z),(.027,.08,size[2]))
    g.box((x,f+size[1]/2+.024,z),(.023,.014,.60*size[2]),'Paint')
    part('Part_Turret_'+name,g,parent=Turret,bev=.009)
# Hatches and enclosed black crew wells; pivots placed at hinge lines.
for name,x,f,r,parent,z in [('Commander',-.67,-.42,.33,Turret,2.66),('Loader',.65,-.46,.34,Turret,2.66),('Driver',.64,2.42,.30,None,1.69)]:
    g=Geo();g.lathe((x,f,z-.07),[(0,0),(0,r*.92),(.075,r*.92),(.075,r+.035),(.02,r+.035)],'Rubber',40,'Z')
    # ring and hinge hardware are fixed (part owned for cupolas)
    g.lathe((x,f,z),[(0,r),(.055,r),(.055,r+.055),(0,r+.055),(0,r)],'Paint',40,'Z')
    for dx in [-.18,.18]:g.rod((x+dx-.045,f-r,z+.052),(x+dx+.045,f-r,z+.052),.038,'Steel',12)
    if parent:part('Part_Turret_Cupola_'+name,g,parent=parent)
    else:
        fixed=g.obj('Driver_Well',bevel=.003)
        # join fixed driver well into Hull later
    g=Geo();g.lathe((x,f,z+.05),[(0,0),(0,r),(.028,r),(.050,r*.95),(.05,0)],'Paint',48,'Z')
    g.tube([(x-.11,f+.09,z+.11),(x-.11,f+.09,z+.16),(x+.11,f+.09,z+.16),(x+.11,f+.09,z+.11)],.015,'Steel')
    for t in range(8):
        a=t*pi/4;g.bolt((x+r*.83*cos(a),f+r*.83*sin(a),z+.105),r=.012)
    o=g.obj('Hatch_'+name,(x,f-r,z+.05),parent,bevel=.003)
    if name=='Commander':empty('Seat_Commander',(x,f,z+.12),Turret);empty('Fx_Hatch',(x,f,z+.15),Turret)
    if name=='Driver':empty('Seat_Driver',(x,f,z+.10))
# Roof MG3 on a pintle, detailed perforated heat shield, stock, receiver, belt box.
g=Geo();g.rod((.71,-.76,2.74),(.71,-.76,2.92),.035,'Steel',16)
g.box((.71,-.53,2.93),(.115,.40,.13),'Steel');g.box((.70,-.88,2.91),(.09,.30,.13),'Rubber',Matrix.Rotation(.10,3,'X'))
g.rod((.71,-.34,2.94),(.71,.54,2.94),.019,'Steel',20)
for ff in np.linspace(-.32,.31,10):
    g.lathe((.71,float(ff),2.94),[(0,.043),(.023,.043),(.023,.034),(0,.034),(0,.043)],'Steel',16,'Y')
for a in [pi/4,3*pi/4,5*pi/4,7*pi/4]:
    g.rod((.71+.038*cos(a),-.32,2.94+.038*sin(a)),(.71+.038*cos(a),.333,2.94+.038*sin(a)),.007,'Steel',8)
g.rod((.71,.40,2.94),(.71,.60,2.94),.018,'Steel',16)
g.box((.89,-.48,2.87),(.22,.25,.21),'Rubber')
g.tube([(.68,-.58,2.89),(.68,-.61,2.80),(.76,-.61,2.80),(.76,-.58,2.89)],.012,'Steel')
g.box((.71,.31,3.005),(.016,.037,.05),'Steel')
# Receiver seams, feed cover, charging handle, pistol grip and folded bipod.
g.box((.71,-.52,3.003),(.127,.29,.018),'Steel')
g.rod((.775,-.60,2.954),(.839,-.60,2.954),.013,'Steel',10)
g.box((.71,-.67,2.813),(.056,.095,.15),'Rubber',Matrix.Rotation(-.24,3,'X'))
for xx in [.665,.755]:g.rod((xx,.24,2.916),(xx,-.15,2.870),.012,'Steel',10)
for ff in [-.39,-.62]:g.bolt((.777,ff,2.96),(1,0,0),.008,'Steel')
MG=g.obj('MG',(.71,-.76,2.80),Turret,bevel=.002)
# Whip antennas and mounting bases (reference whips extend beyond nominal roof height).
for side,s in [('L',1),('R',-1)]:
    g=Geo();g.rod((s*1.16,-1.45,2.65),(s*1.16,-1.45,2.78),.041,'Rubber',12);g.rod((s*1.16,-1.45,2.78),(s*1.19,-1.56,3.86),.008,'Steel',8,r2=.0035)
    part('Part_Turret_Antenna_'+side,g,parent=Turret,bev=0)
for name,pos in [('Exhaust',(0,-3.90,1.63)),('Light_Head_L',(1.30,3.95,1.49)),('Light_Head_R',(-1.30,3.95,1.49)),('Fx_Engine',(0,-2.70,1.91)),('Fx_Ammo',(-.72,1.60,1.01))]:empty(name,pos)
# Join only fixed under-hatch wall to Hull, preserving Hull origin.
def join_objects(obs,name,pivot=None):
    bpy.context.view_layer.update()
    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=obs[0];o.name=name
    if pivot is not None:
        new=Matrix.Translation(V(pivot));old=o.matrix_world.copy();o.data.transform(new.inverted()@old);o.matrix_world=new
    bpy.ops.object.select_all(action='DESELECT');return o
join_objects([Hull,bpy.data.objects['Driver_Well']],'Hull')
# Real hatch apertures and permanent enclosed crew spaces.
for hatch,x,f,z,r,body in [('Commander',-.67,-.42,2.66,.30,Turret),('Loader',.65,-.46,2.66,.31,Turret),('Driver',.64,2.42,1.69,.27,Hull)]:
    bpy.ops.mesh.primitive_cylinder_add(vertices=40,radius=r,depth=.50,location=V((x,f,z-.10)));cut=bpy.context.object
    bpy.context.view_layer.objects.active=body;mod=body.modifiers.new('Crew hatch aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut
    bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
    well=Geo();well.lathe((x,f,z-.34),[(0,0),(0,r),(.34,r)],'Rubber',40,'Z')
    wo=well.obj('Permanent crew well',bevel=0);join_objects([body,wo],body.name)
HEALTH=[o for o in SC.objects if o.type in ['MESH','EMPTY']]
# Reduce redundant bevel/cylinder facets without changing pivot coordinates.
for o in HEALTH:
    if o.type=='MESH' and sum(len(p.vertices)-2 for p in o.data.polygons)>600:
        bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Game mesh budget','DECIMATE');mod.ratio=.60 if o.name.startswith('Wheel_') else (.32 if o.name=='Hull' else (.40 if o.name=='Turret' else .47));bpy.ops.object.modifier_apply(modifier=mod.name)

# Utilities: validated triangle counts, export and GLB JSON paint-factor correction.
def tris(obs):
    n=0
    for o in obs:
        if o.type=='MESH':o.data.calc_loop_triangles();n+=len(o.data.loop_triangles)
    return n

def export_file(name,obs):
    # Triangulate then remove zero-area remnants left by bevel/boolean/decimate.
    for ob in obs:
        if ob.type!='MESH':continue
        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()<1e-10]
        if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
        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/name),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)
    data=(ROOT/name).read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);tail=data[20+jl:]
    for m in doc.get('materials',[]):
        if m['name']=='Paint':m.setdefault('pbrMetallicRoughness',{})['baseColorFactor']=[*DEFAULT,1]
    js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
    out=struct.pack('<4sII',b'glTF',2,20+len(js)+len(tail))+struct.pack('<I4s',len(js),b'JSON')+js+tail;(ROOT/name).write_bytes(out)
    print('EXPORTED',name,tris(obs),len(out),flush=True)
    return doc
healthy_doc=export_file('leopard2-tank.glb',HEALTH)
print('HEALTHY_COMPLETE',flush=True)
# Damage assembly, all built from healthy geometry with identical pivots.
HullD=empty('Hull_Damage',(0,0,0));TurretD=empty('Turret_Damage',TURRET_P)
DAMAGE=[HullD,TurretD];TWINS={}
def copy_object(src,name,parent=None):
    o=src.copy();o.data=src.data.copy();SC.collection.objects.link(o);o.name=name;world=src.matrix_world.copy();o.parent=parent;o.matrix_world=world;return o
# Bent armored skirts/fenders and scorched turret attachments; actual breach cuts follow.
selected=['Part_Skirt_L1','Part_Skirt_L2','Part_Skirt_L3','Part_Skirt_L5','Part_Skirt_R1','Part_Skirt_R3','Part_Fender_FL','Part_Fender_RR','Part_EngineGrille_L','Part_ExhaustShroud_R','Part_Turret_Arrowhead_L','Part_Turret_Bin_R','Part_Turret_BustleRack','Part_Turret_Smoke_L']
for idx,name in enumerate(selected):
    src=PARTS[name];dn='Dmg_'+name[5:];o=copy_object(src,dn,TurretD if name.startswith('Part_Turret') else HullD)
    random.seed(400+idx)
    # local vertices move coherently with a broad bent edge, small torn facets.
    for v in o.data.vertices:
        p=v.co;u=max(0,min(1,(p.y+.32)/.65));w=u*u
        p.x += (.11+.035*(idx%3))*w*(1 if o.location.x>=0 else -1)
        p.z -= .12*w
        p.y += .025*sin(p.z*21+p.x*13)*w
    if 'Skirt' in name or 'Arrowhead' in name:
        # Boolean pierce a real hole through plate, cutter aligned to lateral armor.
        center=o.matrix_world.translation.copy();center.z-=.07
        bpy.ops.mesh.primitive_cylinder_add(vertices=12,radius=.085,depth=.75,location=center,rotation=(0,pi/2,0));cut=bpy.context.object
        bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Penetrated armor','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut
        try:bpy.ops.object.modifier_apply(modifier=mod.name)
        except:pass
        bpy.data.objects.remove(cut,do_unlink=True)
    # scorch part edges by material index; preserves recolorable undamaged paint
    burnidx=list(MATS).index('Burnt')
    for p in o.data.polygons:
        if p.center.z<-.06 or random.random()<.10:p.material_index=burnidx
    DAMAGE.append(o);TWINS[name]=dn
# Surface-conforming impact groups: irregular black soot footprints with steel petalled rims.
def impact(g,center,u,v,r=.10,seed=1):
    rng=random.Random(seed);c=Vector(center);u=Vector(u);v=Vector(v);n=v.cross(u).normalized()
    if seed%4!=3:
        # Glancing hits are long irregular scraped furrows, not circular punched holes.
        for sx,sy,depth,material in [(1.95,.37,.0025,'Burnt'),(1.62,.105,.004,'Scars')]:
            vv=[tuple(c+n*depth)]+[tuple(c+u*cos(2*pi*i/12)*r*sx*(1+rng.uniform(-.14,.14))+v*sin(2*pi*i/12)*r*sy*(1+rng.uniform(-.20,.20))+n*depth) for i in range(12)]
            g.mesh(vv,[(0,i+1,(i+1)%12+1) for i in range(12)],material)
        a=c+u*r*.3;g.mesh([tuple(a+n*.003),tuple(a-u*r*2.1+v*r*.56+n*.0025),tuple(a-u*r*2.6-v*r*.23+n*.0025)],[(0,1,2)],'Burnt')
        return
    # two irregular low-profile rings: soot skirt and shiny folded torn lip
    rings=[]
    for rr,depth in [(r*2.0,.0025),(r*1.22,.003),(r,.004),(r*.69,.016 if seed%4==3 else .004),(r*.45,.004)]:
        rings.append([tuple(c+u*cos(2*pi*i/14)*rr*(1+rng.uniform(-.15,.15))+v*sin(2*pi*i/14)*rr*(1+rng.uniform(-.15,.15))+n*depth) for i in range(14)])
    for k in range(4):
        vs=rings[k]+rings[k+1];fs=[(i,(i+1)%14,(i+1)%14+14,i+14) for i in range(14)];g.mesh(vs,fs,'Burnt' if k<2 else 'Scars')
    g.mesh([tuple(c+n*.003)]+rings[-1],[(0,i+1,(i+1)%14+1) for i in range(14)],'Burnt' if seed%4==3 else 'Scars')
    # tapered ricochet furrow across surface
    a=c+u*r*1.1-v*.07;b=a+u*r*2.6+v*.13
    g.mesh([tuple(a-v*.017+n*.004),tuple(b+n*.003),tuple(a+v*.017+n*.004)],[(0,1,2)],'Scars')
for group,parent in [('Hull',HullD),('Turret',TurretD)]:
    for face in ['Front','L','R','Rear']:
        g=Geo()
        # Ray-project each mark onto its actual armor mesh, preserving millimetre relief.
        from mathutils.bvhtree import BVHTree
        targets=[Hull] if group=='Hull' else [Turret]
        if group=='Hull' and face in ['L','R']:
            targets += [o for name,o in PARTS.items() if name.startswith('Part_Skirt_'+face)]
        if group=='Turret' and face=='Front':
            targets += [o for name,o in PARTS.items() if 'Arrowhead' in name]
        verts=[];faces=[]
        for target in targets:
            offset=len(verts);verts.extend([target.matrix_world@v.co for v in target.data.vertices])
            faces.extend([tuple(offset+i for i in p.vertices) for p in target.data.polygons])
        armor=BVHTree.FromPolygons(verts,faces)
        exterior=V({'Front':(0,1,0),'Rear':(0,-1,0),'L':(1,0,0),'R':(-1,0,0)}[face])
        for j in range(4):
            if group=='Hull':
                if face=='Front':
                    zz=[1.14,1.25,1.02,1.18][j];c=(-.94+j*.57,3.49+(zz-.78)*.37/.62+.002,zz);u=(1,0,0);v=(0,.512,.859)
                elif face=='Rear':c=(-.93+j*.60,-3.877,1.05);u=(-1,0,0);v=(0,0,1)
                else:
                    s=1 if face=='L' else -1;c=(s*1.875,2.10-j*1.24,1.30);u=(0,-s,0);v=(0,0,1)
            else:
                if face=='Front':
                    xx=[-.58,-1.02,.53,.95][j];zz=2.46;ff=2.69-(abs(xx)-.28)*.261-(zz-2.29)*2.657;c=(xx,ff+.003,zz);u=(1,-.261*(1 if xx>0 else -1),0);v=(0,-.936,.352)
                elif face=='Rear':c=(-.80+j*.52,-2.038,2.30);u=(-1,0,0);v=(0,0,1)
                else:
                    s=1 if face=='L' else -1;c=(s*(1.52+((.11-j*.4)-.42)/(-2.17)*(-.05))*.906+s*.003,.11-j*.40,2.33);u=(0,-s,0);v=(0,0,1)
            # normal points to exterior (u x v)
            first=len(g.v)
            impact(g,c,u,v,[.073,.092,.057,.067][j],30+j)
            mark_normal=V(Vector(v).cross(Vector(u))).normalized()
            for vi in range(first,len(g.v)):
                point=Vector(g.v[vi]);relief=max(.0025,(point-V(c)).dot(mark_normal))
                hit,normal,_,_=armor.ray_cast(point+exterior*10,-exterior,20)
                if hit is not None:
                    if normal.dot(exterior)<0:normal=-normal
                    g.v[vi]=tuple(hit+normal*relief)
        o=g.obj('Scar_'+group+'_'+face, (0,0,0) if group=='Hull' else TURRET_P,parent,bevel=0);DAMAGE.append(o)
for side,s in [('L',1),('R',-1)]:
    o=track_geo(s,True).obj('Track_'+side+'_Broken',parent=HullD,bevel=.003)
    bpy.context.view_layer.objects.active=o;mod=o.modifiers.new('Broken track budget','DECIMATE');mod.ratio=.52;bpy.ops.object.modifier_apply(modifier=mod.name);DAMAGE.append(o)
# Burst barrel assembled from the healthy gun and a shortened/drooped barrel copy.
gunbroken=copy_object(Gun,'Gun_Broken',TurretD)
bb=copy_object(Barrel,'BurstTube')
for v in bb.data.vertices:
    # original local Y points aft: forward is negative
    f=-v.co.y
    if f>2.5:
        v.co.z -= (f-2.5)*.36
        if f>4.45:v.co.x += .055*sin(v.co.z*42+v.co.x*32)
# Whole Gun_Broken one mesh, pivot at healthy gun trunnions.
gunbroken=join_objects([gunbroken,bb],'Gun_Broken')
# Add peeled petals at mid-length rupture.
g=Geo()
for j in range(7):
    a=2*pi*j/7;x=.13*cos(a);z=2.29+.13*sin(a);xx=.24*cos(a);zz=2.29+.24*sin(a)
    g.mesh([(x,4.80,z),(x+.035,4.83,z+.02),(xx+.02,5.11,zz),(xx,5.19,zz-.02)],[(0,1,2,3)],'Scars')
pet=g.obj('BurstPetals',bevel=.003);gunbroken=join_objects([gunbroken,pet],'Gun_Broken');DAMAGE.append(gunbroken)
# Burnt wreck from copies: wheel tires removed, suspension sag, missing skirts, burst deck.
WRECK_H=[];WRECK_T=[]
turret_set={Turret, Gun, Barrel,MG}|set(Turret.children_recursive)
for src in HEALTH:
    if src.type!='MESH':continue
    if src.name.startswith('Part_Skirt_') and src.name[-1] in ['1','3','6']:continue
    if src.name in ['Part_EngineGrille_L','Track_L','Part_Turret_Bin_L','Part_Turret_Smoke_R','Part_Turret_Arrowhead_L','Part_Turret_Antenna_L','Part_Turret_Antenna_R']:continue
    is_tur=src in turret_set
    o=copy_object(src,'W_'+src.name)
    o.parent=None;o.matrix_world=src.matrix_world.copy()
    if src.name.startswith('Wheel'):
        bm=bmesh.new();bm.from_mesh(o.data);bad=[f for f in bm.faces if o.data.materials[f.material_index].name=='Rubber'];bmesh.ops.delete(bm,geom=bad,context='FACES');bm.to_mesh(o.data);bm.free();o.location.z-=.05
    if src.name=='Track_R':
        for vert in o.data.vertices:
            if vert.co.z>1.1:vert.co.z-=.12*max(0,1-(vert.co.y/3.8)**2)
    if src.name.startswith('Hatch_'):
        o.rotation_euler.x=math.radians(-78)
    if src.name.startswith('Part_Skirt'):
        for vert in o.data.vertices:vert.co.x += .08*sin(vert.co.y*3);vert.co.z-=.08*(vert.co.y+.4)
    if src.name in ['Gun','Barrel']:
        # rotate around actual gun trunnion; consistent full depression
        pivot=V((0,1.68,2.29));r=Matrix.Rotation(math.radians(12),4,'X');o.matrix_world=Matrix.Translation(pivot)@r@Matrix.Translation(-pivot)@o.matrix_world
    if src.name=='Turret':
        for vert in o.data.vertices:
            if vert.co.z>.55 and vert.co.y>.4:vert.co.z+=.10*sin(vert.co.x*7)*max(0,vert.co.y-.4)
    for p in o.data.polygons:p.material_index=list(MATS).index('Burnt')
    (WRECK_T if is_tur else WRECK_H).append(o)
# Open engine bay with torn plates and charred turbine detail under missing grate.
g=Geo();g.box((.76,-2.74,1.78),(1.20,1.30,.055),'Burnt')
for i in range(6):
    f=-3.28+i*.21;g.rod((.23,f,1.79),(1.20,f,1.87),.065,'Burnt',12)
for i in range(5):
    f=-3.35+i*.29;g.mesh([(.08,f,1.82),(1.35,f,1.82),(1.18,f+.18,2.10+random.random()*.18),(.15,f+.18,2.04)],[(0,1,2,3)],'Burnt')
# visible deep ring well and stepped rim (roof opening cut below)
g.lathe((0,.18,1.22),[(0,0),(0,1.04),(.63,1.04),(.67,1.16),(.59,1.18)],'Burnt',64,'Z')
whdetail=g.obj('WreckDetails',bevel=.006);WRECK_H.append(whdetail)
WRECK_H.append(track_geo(1,True).obj('WreckBrokenTrack',bevel=.002))
for p in WRECK_H[-1].data.polygons:p.material_index=list(MATS).index('Burnt')
# Bore an actual open well through the original wreck hull roof, preserving side/bottom.
wh=next(o for o in WRECK_H if o.name=='W_Hull')
bpy.ops.mesh.primitive_cylinder_add(vertices=64,radius=1.04,depth=1.05,location=V((0,.18,1.94)));cut=bpy.context.object
bpy.context.view_layer.objects.active=wh;mod=wh.modifiers.new('Open turret ring','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut
try:bpy.ops.object.modifier_apply(modifier=mod.name)
except:pass
bpy.data.objects.remove(cut,do_unlink=True)
# Open engine bay through the hull roof; the separately modelled engine and floor remain.
bpy.ops.mesh.primitive_cube_add(size=1,location=V((.76,-2.74,1.94)));cut=bpy.context.object;cut.dimensions=(1.20,1.30,.46);bpy.context.view_layer.update()
bpy.context.view_layer.objects.active=wh;mod=wh.modifiers.new('Ruptured engine bay','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut
bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
# Burnt turret closed underside and basket stub, visible when tossed.
g=Geo();g.lathe((0,.18,1.85),[(0,0),(0,1.09),(-.11,1.09),(-.11,.94),(-.56,.72),(-.60,0)],'Burnt',48,'Z')
for a in np.linspace(0,2*pi,9)[:-1]:g.rod((.83*cos(a),.18+.83*sin(a),1.77),(.65*cos(a),.18+.65*sin(a),1.20),.035,'Burnt',10)
WRECK_T.append(g.obj('WreckBasket',bevel=.006))
WreckHull=join_objects(WRECK_H,'Wreck_Hull',(0,0,0));WreckTurret=join_objects(WRECK_T,'Wreck_Turret',TURRET_P)
for ob in [WreckHull,WreckTurret]:
    for p in ob.data.polygons:p.material_index=list(MATS).index('Burnt')
    # game-weight wreck decimation, smooth small parts retained as silhouette
    bpy.context.view_layer.objects.active=ob;mod=ob.modifiers.new('Wreck game budget','DECIMATE');mod.ratio=.37;bpy.ops.object.modifier_apply(modifier=mod.name)
DAMAGE += [WreckHull,WreckTurret]
# Damage material allow-list. Convert healthy Steel, Glass, Lamps to scorch steel.
for o in DAMAGE:
    if o.type!='MESH':continue
    for p in o.data.polygons:
        if o.data.materials[p.material_index].name in ['Steel','Glass','Lamps']:p.material_index=list(MATS).index('Scars')
# Exact outer dimensions and ground contact, including detachable fittings.
pts=[o.matrix_world@Vector(p) for o in HEALTH if o.type=='MESH' for p in o.bound_box]
xmax=max(abs(p.x) for p in pts);rear=max(p.y for p in pts);groundz=min(p.z for p in pts)
roof=max((o.matrix_world@Vector(p)).z for o in HEALTH if o.type=='MESH' and 'Antenna' not in o.name for p in o.bound_box)
def normalized(p):return Vector((p.x*1.875/xmax,p.y*(3.86/rear if p.y>0 else 1),(p.z-groundz)*3.03/(roof-groundz)))
allobs=HEALTH+DAMAGE;old={o:o.matrix_world.copy() for o in allobs};new={}
for o in allobs:
    new[o]=old[o].copy()
    if o.name not in ['Hull','Hull_Damage','Wreck_Hull','Track_L','Track_R','Track_L_Broken','Track_R_Broken']:
        new[o].translation=normalized(old[o].translation)
    else:new[o].translation=Vector((0,0,0))
    if o.type=='MESH':
        inv=new[o].inverted()
        for v in o.data.vertices:v.co=inv@normalized(old[o]@v.co)
def hierarchy_depth(o):
    n=0
    while o.parent:n+=1;o=o.parent
    return n
for o in sorted(allobs,key=hierarchy_depth):o.matrix_world=new[o]
bpy.context.view_layer.update()
healthy_doc=export_file('leopard2-tank.glb',HEALTH)
damage_doc=export_file('leopard2-tank-damage.glb',DAMAGE)
# Manifest world coordinates are exported convention (X left,Y up,Z forward).
def manifest(obs):
    out=[]
    for o in obs:
        p=o.matrix_world.translation
        out.append({'name':o.name,'parent':o.parent.name if o.parent else None,'position':[round(p.x,6),round(p.z,6),round(-p.y,6)],'type':o.type,'triangles':tris([o])})
    return out
pts=[o.matrix_world@Vector(p) for o in HEALTH if o.type=='MESH' for p 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)]
meta={'schema':'tank-damage/v1','units':'metres','forward':'+Z','up':'+Y','left':'+X','nodes':{'leopard2-tank.glb':manifest(HEALTH),'leopard2-tank-damage.glb':manifest(DAMAGE)},'bounds':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]},'triangles':{'healthy':tris(HEALTH),'damage':tris(DAMAGE)},'paints':[{'name':n,'linearRGB':c} for n,c in PAINTS],'damage':{'parts':[{'part':n,'twin':TWINS.get(n)} for n in PARTS],'scars':[o.name for o in DAMAGE if o.name.startswith('Scar_')],'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']}}
def exported_bounds(obs):
    p=[o.matrix_world@Vector(c) for o in obs if o.type=='MESH' for c in o.bound_box]
    return {'min':[min(v.x for v in p),min(v.z for v in p),min(-v.y for v in p)],'max':[max(v.x for v in p),max(v.z for v in p),max(-v.y for v in p)]}
meta['source_sha256']=SOURCE_SHA256
meta['boundsByFile']={'leopard2-tank.glb':exported_bounds(HEALTH),'leopard2-tank-damage.glb':exported_bounds(DAMAGE)}
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join([
'Leopard 2A6 style tank, articulated healthy asset and tank-damage/v1 swaps; metres, +Z forward, +Y up; no insignia.',
f'Triangles: healthy {tris(HEALTH):,}; damage {tris(DAMAGE):,}; wreck {tris([WreckHull,WreckTurret]):,}.',
'Materials: Paint, Rubber, TrackSteel, Steel, Glass, Lamps, Burnt, Scars; Paint factor defaults to linear [0.09, 0.12, 0.07].',
'Textures: Cycles baked shared tiling base 1024px Paint, 512px others; all normal and packed ORM maps 512px; neutral Paint allows six variants.',
'Poly Haven CC0 ids: metal_plate, rubberized_track, rusty_metal_02; https://polyhaven.com/license ; https://polyhaven.com/a/<id>.',
'Reference differences: antenna tips exceed nominal 3.03m roof height; small fittings and damage are authored approximations, not factory blueprints.'
])+'\n')
print('GEOMETRY_AND_EXPORT_DONE',meta['triangles'],flush=True)
# Proof scenes are rebuilt from BOTH delivered GLBs, never source-only geometry.
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
bpy.data.node_groups.remove(bpy.data.node_groups['glTF Material Output'],do_unlink=True)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'leopard2-tank.glb'));A=list(SC.objects)
bpy.ops.import_scene.gltf(filepath=str(ROOT/'leopard2-tank-damage.glb'));B=[o for o in SC.objects if o not in A]
# Import suffixes on materials are harmless; proof uses actual exported factor and textures.
def hide(obs,flag):
    for o in obs:o.hide_render=flag
hide(B,True)
# Ground and natural broad soft daylight.
groundmat=bpy.data.materials.new('Studio ground');groundmat.diffuse_color=(.17,.19,.205,1);groundmat.use_nodes=True;groundmat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.89
bpy.ops.mesh.primitive_plane_add(size=200);ground=bpy.context.object;ground.name='RenderGround';ground.data.materials.append(groundmat);ground.location.z=-.012
world=bpy.data.worlds.new('Soft daylight');world.use_nodes=True;world.node_tree.nodes.get('Background').inputs[0].default_value=(.62,.71,.85,1);world.node_tree.nodes.get('Background').inputs[1].default_value=.5;SC.world=world
ld=bpy.data.lights.new('Key','AREA');ld.energy=2100;ld.shape='DISK';ld.size=7;lamp=bpy.data.objects.new('Key',ld);SC.collection.objects.link(lamp);lamp.location=(5,-6,11)
ld=bpy.data.lights.new('Rim','AREA');ld.energy=1700;ld.size=6;lamp=bpy.data.objects.new('Rim',ld);SC.collection.objects.link(lamp);lamp.location=(-5,2,7);lamp.rotation_euler=(Vector((0,0,1.3))-lamp.location).to_track_quat('-Z','Y').to_euler()
ld=bpy.data.lights.new('Sun','SUN');ld.energy=1.5;ld.angle=.28;lamp=bpy.data.objects.new('Sun',ld);SC.collection.objects.link(lamp);lamp.rotation_euler=(.35,-.5,-.4)
camdata=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camdata);SC.collection.objects.link(cam);SC.camera=cam;camdata.type='ORTHO'
def camera(hero=True,wide=False):
    camdata.type='PERSP' if hero else 'ORTHO';camdata.lens=47 if wide else 55
    target=Vector((0,-1.2,1.30));cam.location=target+Vector((11,-15,9.0)) if hero else Vector((13,-1.2,1.30));cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();camdata.ortho_scale=13.7 if not wide else 16.3
    if not hero:camdata.ortho_scale=13.0
camera()
def render(name,w=1920,h=1080,samples=32):
    SC.render.resolution_x=w;SC.render.resolution_y=h;SC.render.resolution_percentage=100;SC.cycles.samples=samples;SC.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);print('RENDERED',name,flush=True)
render('final.png')
camera(False);render('side.png');camera()
# grey clay overrides the imported materials, confirms contour and real detailing.
clay=bpy.data.materials.new('Clay override');clay.use_nodes=True;cp=clay.node_tree.nodes.get('Principled BSDF');cp.inputs['Base Color'].default_value=(.35,.35,.35,1);cp.inputs['Roughness'].default_value=.72
SC.view_layers[0].material_override=clay;render('clay.png');SC.view_layers[0].material_override=None
# Rig test: all pivoted roles 30 degrees together, source matrices restored afterward.
poses={}
for o in A:
    if o.name=='Turret' or o.name.startswith('Wheel_') or o.name.startswith('Hatch_') or o.name in ['Gun','Barrel','MG']:
        poses[o]=o.matrix_basis.copy();o.rotation_mode='XYZ'
        if o.name in ['Turret','MG']:o.rotation_euler.z+=pi/6
        elif o.name=='Barrel':o.location.y+=.22
        elif o.name.startswith('Hatch_') or o.name=='Gun':o.rotation_euler.x-=pi/6
        else:o.rotation_euler.x+=pi/6
render('rig-test.png',1280,720,16)
for o,m in poses.items():o.matrix_basis=m
# Contact sheets composited from actual saved render pixels; no external tools needed.
def sheet(files,name):
    ims=[bpy.data.images.load(str(ROOT/f),check_existing=False) for f in files];w,h=ims[0].size;canvas=np.zeros((h*2,w*3,4),dtype=np.float32)
    for i,im in enumerate(ims):
        a=np.array(im.pixels[:],dtype=np.float32).reshape(h,w,4);row=1-i//3;col=i%3;canvas[row*h:(row+1)*h,col*w:(col+1)*w]=a
    out=bpy.data.images.new(name,w*3,h*2,alpha=True);out.pixels.foreach_set(canvas.ravel());out.filepath_raw=str(ROOT/name);out.file_format='PNG';out.save()
    for im in ims:bpy.data.images.remove(im)
# Imported glTF paints store the multiply factor in a Mix node. Change by finding it.
pm=next(m for m in bpy.data.materials if m.name.startswith('Paint') and m.users>0 and m.node_tree and any(n.type=='BSDF_PRINCIPLED' for n in m.node_tree.nodes) and m not in list(MATS.values()))
def setpaint(rgb):
    nt=pm.node_tree;p=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED');inp=p.inputs['Base Color']
    # glTF importer uses Mix node multiplication; replacing with explicit texture multiply is deterministic.
    tex=next(n for n in nt.nodes if n.type=='TEX_IMAGE' and 'base' in (n.image.name.lower() if n.image else '')) if any(n.type=='TEX_IMAGE' and n.image and 'base' in n.image.name.lower() for n in nt.nodes) else next(n for n in nt.nodes if n.type=='TEX_IMAGE' and n.image.colorspace_settings.name!='Non-Color')
    mix=nt.nodes.get('PaintPreviewFactor')
    if not mix:mix=node(nt,'ShaderNodeMixRGB');mix.name='PaintPreviewFactor';mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;link(nt,tex.outputs[0],mix.inputs[1]);link(nt,mix.outputs[0],inp)
    mix.inputs[2].default_value=(*rgb,1)
files=[]
for i,(name,col) in enumerate(PAINTS):setpaint(col);fn=f'paint-{i}.png';render(fn,640,360,12);files.append(fn)
sheet(files,'paints.png');setpaint(DEFAULT)
# Six requested switch-only gameplay states. All imported export nodes are toggled explicitly.
def reset():hide(A,False);hide(B,True)
def show_name(name,flag=True):
    o=next((o for o in A+B if o.name==name),None)
    if o:o.hide_render=not flag
    return o
statefiles=[]
for i in range(6):
    reset()
    if i==1:
        for o in B:
            if o.name.startswith('Scar_'):o.hide_render=False
        for n in list(TWINS)[:6]:show_name(n,False);show_name(TWINS[n])
    elif i==2:
        for o in A:
            if o.name.startswith('Part_'):o.hide_render=True
    elif i==3:
        show_name('Track_L',False);show_name('Track_L_Broken');o=show_name('Gun',False)
        if o:
            for c in o.children_recursive:c.hide_render=True
        show_name('Gun_Broken')
    elif i in [4,5]:
        hide(A,True);show_name('Wreck_Hull');wt=show_name('Wreck_Turret')
        if i==5:
            wt.rotation_mode='XYZ';wt.location=Vector((3.75,-.45,.89));wt.rotation_euler=(.08,2.25,.42);bpy.context.view_layer.update()
            lowest=min((wt.matrix_world@v.co).z for v in wt.data.vertices);wt.location.z-=lowest;camera(wide=True)
    fn=f'state-{i}.png';render(fn,640,360,16);statefiles.append(fn)
sheet(statefiles,'states.png')
reset();camera()
# Save final source-independent proof scene for reproducible view QA, optional artifact.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'proof.blend'))
print('BUILD_COMPLETE',flush=True)
