"""Panzer IV Ausf. H inspired game asset. Blender 5.2, clean scene, metre units.
Run: blender -b --python-exit-code 1 --python build_panzer-iv-tank.py
Optional -- geometry (reuse baked tiles), -- proof (import exports and render).
CC0 source tiles fetched directly from Poly Haven. No insignia or image overlays.
"""
import bpy, bmesh, math, random, json, os, sys, struct, urllib.request, subprocess
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin, cos, pi
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
random.seed(1943)
PAINTS=[('Dark yellow',[.47,.42,.28]),('Panzer grey',[.115,.135,.15]),('Olive drab',[.22,.26,.12]),('Desert tan',[.63,.50,.32]),('Winter white',[.83,.84,.79]),('Dark green-black',[.065,.095,.072])]
MATS={}; GROUPS={}; OBJECTS={}; PARTS=[]; WHEEL_INFO=[]
DEFAULT=PAINTS[0][1]

def reset():
    bpy.ops.wm.read_factory_settings(use_empty=True)
    s=bpy.context.scene;s.unit_settings.system='METRIC';s.render.engine='CYCLES';s.cycles.samples=24;s.cycles.use_denoising=True
    p=bpy.context.preferences.addons['cycles'].preferences
    for typ in ('OPTIX','CUDA'):
        try:
            p.compute_device_type=typ;p.get_devices()
            if any(d.type==typ for d in p.devices):
                for d in p.devices:d.use=d.type==typ
                s.cycles.device='GPU';break
        except Exception:pass
    else:raise RuntimeError('GPU required')
    s.view_settings.view_transform='AgX';s.view_settings.exposure=-1.1
    s.render.image_settings.file_format='PNG';s.render.film_transparent=False
    s.world=bpy.data.worlds.new('Bake World');s.world.color=(.22,.22,.22)

def fetch():
    for ident in ('metal_plate','rusty_metal_03','rubber_tiles'):
        for mp in ('diff','nor_gl','arm'):
            p=TEX/f'{ident}_{mp}_1k.jpg'
            if not p.exists():
                url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{mp}_1k.jpg'
                subprocess.run(['curl','-f','-L','--retry','2','-A','ForgeAssetBuilder/1.0',url,'-o',str(p)],check=True)

def node(nt,typ,**kw):
    n=nt.nodes.new(typ)
    for k,v in kw.items():setattr(n,k,v)
    return n

def bake_tiles():
    fetch()
    # Bake shared physical-scale tiles, not a low-density unique vehicle atlas.
    for name,src,base,metal,rough in [
        ('Paint','metal_plate',(.83,.83,.83),0,.81),('TrackSteel','rusty_metal_03',(.19,.15,.11),.68,.78),
        ('Rubber','rubber_tiles',(.025,.028,.026),0,.88),('Steel','metal_plate',(.23,.25,.25),.8,.56),
        ('Interior','metal_plate',(.012,.014,.013),0,.95),('Lamps','metal_plate',(.45,.43,.32),.25,.25),
        ('Burnt','rusty_metal_03',(.15,.135,.12),.35,.88),('Scars','metal_plate',(.32,.34,.34),.85,.45)]:
        print('BAKE',name,flush=True)
        bpy.ops.mesh.primitive_plane_add(size=2);o=bpy.context.object
        mat=bpy.data.materials.new('source_'+name);mat.use_nodes=True;o.data.materials.append(mat)
        nt=mat.node_tree;nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');bs=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(bs.outputs['BSDF'],out.inputs['Surface'])
        uv=node(nt,'ShaderNodeTexCoord')
        tx=node(nt,'ShaderNodeTexImage');tx.image=bpy.data.images.load(str(TEX/f'{src}_diff_1k.jpg'),check_existing=True);nt.links.new(uv.outputs['UV'],tx.inputs['Vector'])
        bw=node(nt,'ShaderNodeRGBToBW');nt.links.new(tx.outputs['Color'],bw.inputs[0])
        ramp=node(nt,'ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.08;ramp.color_ramp.elements[1].position=.72
        ramp.color_ramp.elements[0].color=(*[c*.65 for c in base],1);ramp.color_ramp.elements[1].color=(*[min(1,c*1.15) for c in base],1);nt.links.new(bw.outputs[0],ramp.inputs[0])
        mapping=node(nt,'ShaderNodeVectorMath',operation='MULTIPLY');mapping.inputs[1].default_value=(7,1.4,1);nt.links.new(uv.outputs['UV'],mapping.inputs[0])
        noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=3;noise.inputs['Detail'].default_value=5;noise.inputs['Roughness'].default_value=.8;nt.links.new(mapping.outputs[0],noise.inputs['Vector'])
        streak=node(nt,'ShaderNodeValToRGB');streak.color_ramp.elements[0].position=.30;streak.color_ramp.elements[0].color=(.24,.24,.24,1);streak.color_ramp.elements[1].position=.56;streak.color_ramp.elements[1].color=(1,1,1,1);nt.links.new(noise.outputs['Fac'],streak.inputs[0])
        mix=node(nt,'ShaderNodeMixRGB',blend_type='MULTIPLY');mix.inputs[0].default_value=.24 if name=='Paint' else .45;nt.links.new(ramp.outputs[0],mix.inputs[1]);nt.links.new(streak.outputs[0],mix.inputs[2]);nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
        if name in ('Paint','Burnt'):
            dirt=node(nt,'ShaderNodeTexNoise');dirt.inputs['Scale'].default_value=7;dirt.inputs['Detail'].default_value=5;dirt.inputs['Roughness'].default_value=.78;nt.links.new(uv.outputs['UV'],dirt.inputs['Vector'])
            dr=node(nt,'ShaderNodeValToRGB');dr.color_ramp.elements[0].position=.32;dr.color_ramp.elements[0].color=(.10,.10,.10,1);dr.color_ramp.elements[1].position=.65;dr.color_ramp.elements[1].color=(1,1,1,1);nt.links.new(dirt.outputs['Fac'],dr.inputs[0])
            dirty=node(nt,'ShaderNodeMixRGB',blend_type='MULTIPLY');dirty.inputs[0].default_value=.48 if name=='Paint' else .68;nt.links.new(mix.outputs[0],dirty.inputs[1]);nt.links.new(dr.outputs[0],dirty.inputs[2]);nt.links.new(dirty.outputs[0],bs.inputs['Base Color'])
        normaltex=node(nt,'ShaderNodeTexImage');normaltex.image=bpy.data.images.load(str(TEX/f'{src}_nor_gl_1k.jpg'),check_existing=True);normaltex.image.colorspace_settings.name='Non-Color';nt.links.new(uv.outputs['UV'],normaltex.inputs['Vector'])
        nm=node(nt,'ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.065 if name=='Paint' else .45;nt.links.new(normaltex.outputs[0],nm.inputs['Color'])
        bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=.08;bump.inputs['Distance'].default_value=.001;nt.links.new(noise.outputs['Fac'],bump.inputs['Height']);nt.links.new(nm.outputs[0],bump.inputs['Normal']);nt.links.new(bump.outputs[0],bs.inputs['Normal'])
        bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
        sz=1024 if name=='Paint' else 512
        for typ in ('base','normal','orm'):
            size=512 if typ=='orm' else sz
            im=bpy.data.images.new(name+'_'+typ,width=size,height=size,alpha=False)
            im.colorspace_settings.name='sRGB' if typ=='base' else 'Non-Color'
            target=node(nt,'ShaderNodeTexImage');target.image=im;nt.nodes.active=target
            if typ=='base':bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'},margin=8)
            elif typ=='normal':bpy.ops.object.bake(type='NORMAL',margin=8)
            else:
                emit=node(nt,'ShaderNodeEmission');sep=node(nt,'ShaderNodeSeparateColor');arm=node(nt,'ShaderNodeTexImage');arm.image=bpy.data.images.load(str(TEX/f'{src}_arm_1k.jpg'),check_existing=True);arm.image.colorspace_settings.name='Non-Color';nt.links.new(uv.outputs['UV'],arm.inputs['Vector']);nt.links.new(arm.outputs[0],sep.inputs[0])
                combine=node(nt,'ShaderNodeCombineColor');combine.inputs[0].default_value=1;combine.inputs[1].default_value=rough;combine.inputs[2].default_value=metal
                rm=node(nt,'ShaderNodeMath',operation='MULTIPLY_ADD');rm.inputs[1].default_value=.18;rm.inputs[2].default_value=rough-.1;nt.links.new(sep.outputs[1],rm.inputs[0]);nt.links.new(rm.outputs[0],combine.inputs[1]);nt.links.new(sep.outputs[0],combine.inputs[0]);nt.links.new(combine.outputs[0],emit.inputs[0]);nt.links.new(emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT',margin=8)
            im.filepath_raw=str(TEX/(name+'_'+typ+'.png'));im.file_format='PNG';im.save();nt.nodes.remove(target)
        bpy.data.objects.remove(o,do_unlink=True)
    print('BAKES_DONE',flush=True)

def materials():
    for name in ('Paint','TrackSteel','Rubber','Steel','Interior','Lamps','Burnt','Scars'):
        m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;bs=nt.nodes.get('Principled BSDF')
        t=node(nt,'ShaderNodeTexImage');t.image=bpy.data.images.load(str(TEX/(name+'_base.png')),check_existing=True)
        if name=='Paint':
            mul=node(nt,'ShaderNodeMixRGB',blend_type='MULTIPLY');mul.name='PaintFactor';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(*DEFAULT,1);nt.links.new(t.outputs[0],mul.inputs[1]);nt.links.new(mul.outputs[0],bs.inputs['Base Color'])
        else:nt.links.new(t.outputs[0],bs.inputs['Base Color'])
        n=node(nt,'ShaderNodeTexImage');n.image=bpy.data.images.load(str(TEX/(name+'_normal.png')),check_existing=True);n.image.colorspace_settings.name='Non-Color'
        normal=node(nt,'ShaderNodeNormalMap');nt.links.new(n.outputs[0],normal.inputs['Color']);nt.links.new(normal.outputs[0],bs.inputs['Normal'])
        orm=node(nt,'ShaderNodeTexImage');orm.image=bpy.data.images.load(str(TEX/(name+'_orm.png')),check_existing=True);orm.image.colorspace_settings.name='Non-Color';sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(orm.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs[1],bs.inputs['Roughness']);nt.links.new(sep.outputs[2],bs.inputs['Metallic'])
        bs.inputs['Coat Weight'].default_value=0
        settings=bpy.data.node_groups.get('glTF Material Output')
        if not settings:
            settings=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');settings.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
        occ=node(nt,'ShaderNodeGroup');occ.node_tree=settings;nt.links.new(sep.outputs[0],occ.inputs['Occlusion'])
        MATS[name]=m

class Geo:
    def __init__(self,name,origin=(0,0,0),parent=None):self.name=name;self.origin=Vector(origin);self.parent=parent;self.v=[];self.f=[];self.mi=[];self.smooth=[];self.uv=[]
    def add(self,vs,fs,mat='Paint',smooth=False,uv=None):
        start=len(self.v);self.v.extend([tuple(v) for v in vs]);self.f.extend([tuple(start+i for i in f) for f in fs]);self.mi.extend([list(MATS).index(mat)]*len(fs));self.smooth.extend([smooth]*len(fs));self.uv.extend(uv if uv else [None]*len(fs))
    def box(self,c,d,mat='Paint',rot=None):
        c=Vector(c);dx,dy,dz=[v/2 for v in d]
        vs=[Vector((x*dx,y*dy,z*dz)) for x,y,z in [(-1,-1,-1),(-1,-1,1),(-1,1,-1),(-1,1,1),(1,-1,-1),(1,-1,1),(1,1,-1),(1,1,1)]]
        if rot is not None:vs=[rot@v for v in vs]
        self.add([v+c for v in vs],[tuple(reversed(f)) for f in [(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 cyl(self,c,r,depth,mat='Paint',axis=(0,0,1),n=24,r2=None):
        axis=Vector(axis).normalized();q=Vector((0,0,1)).rotation_difference(axis);c=Vector(c);r2=r if r2 is None else r2
        vs=[c+q@Vector((rr*cos(i*2*pi/n),rr*sin(i*2*pi/n),z)) for z,rr in [(-depth/2,r),(depth/2,r2)] for i in range(n)]
        fs=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
        self.add(vs,fs,mat,True);self.smooth[-(n+2)]=False;self.smooth[-(n+1)]=False
    def sphere(self,c,r,mat='Paint',segments=32,rings=16):
        c=Vector(c);vs=[c+Vector((r*sin(pi*j/rings)*cos(2*pi*i/segments),r*sin(pi*j/rings)*sin(2*pi*i/segments),r*cos(pi*j/rings))) for j in range(rings+1) for i in range(segments)]
        fs=[(j*segments+i,j*segments+(i+1)%segments,(j+1)*segments+(i+1)%segments,(j+1)*segments+i) for j in range(rings) for i in range(segments)]
        self.add(vs,fs,mat,True)
    def ring(self,c,r,ri,depth,mat='Paint',axis=(0,0,1),n=48):
        q=Vector((0,0,1)).rotation_difference(Vector(axis).normalized());c=Vector(c)
        vs=[c+q@Vector((rr*cos(i*2*pi/n),rr*sin(i*2*pi/n),z)) for z,rr in [(-depth/2,r),(depth/2,r),(-depth/2,ri),(depth/2,ri)] for i in range(n)]
        fs=[]
        for i in range(n):
            j=(i+1)%n;fs.extend([(i,j,n+j,n+i),(2*n+j,2*n+i,3*n+i,3*n+j),(n+i,n+j,3*n+j,3*n+i),(j,i,2*n+i,2*n+j)])
        self.add(vs,fs,mat,True)
    def beam(self,a,b,width,mat='Paint',thick=None):
        a,b=Vector(a),Vector(b);v=b-a;q=Vector((0,0,1)).rotation_difference(v.normalized()).to_matrix();self.box((a+b)/2,(width,thick or width,v.length),mat,q)
    def pipe(self,pts,r,mat='Steel',n=8):
        pts=[Vector(p) for p in pts];vs=[]
        for i,p in enumerate(pts):
            tangent=pts[min(i+1,len(pts)-1)]-pts[max(i-1,0)];q=Vector((0,0,1)).rotation_difference(tangent.normalized())
            vs += [p+q@Vector((r*cos(j*2*pi/n),r*sin(j*2*pi/n),0)) for j in range(n)]
        fs=[]
        for i in range(len(pts)-1):
            for j in range(n):fs.append((i*n+j,i*n+(j+1)%n,(i+1)*n+(j+1)%n,(i+1)*n+j))
        fs.extend([tuple(reversed(range(n))),tuple((len(pts)-1)*n+j for j in range(n))]);self.add(vs,fs,mat,True)
    def build(self,bevel=.006,segments=1):
        mesh=bpy.data.meshes.new(self.name);mesh.from_pydata([v-self.origin for v in map(Vector,self.v)],[],self.f);mesh.update()
        ob=bpy.data.objects.new(self.name,mesh);bpy.context.collection.objects.link(ob);ob.location=self.origin
        for m in MATS.values():mesh.materials.append(m)
        for p,mi,sm in zip(mesh.polygons,self.mi,self.smooth):p.material_index=mi;p.use_smooth=sm
        uv=mesh.uv_layers.new(name='UVMap')
        for p,custom in zip(mesh.polygons,self.uv):
            no=p.normal;dominant=max(range(3),key=lambda a:abs(no[a]));axes=[a for a in range(3) if a!=dominant]
            if dominant==1:axes=[0,2]
            if dominant==0:axes=[1,2]
            for j,li in enumerate(p.loop_indices):
                v=Vector(self.v[mesh.loops[li].vertex_index]);uv.data[li].uv=custom[j] if custom else (v[axes[0]]*.65+.137,v[axes[1]]*.65+.091)
        bm=bmesh.new();bm.from_mesh(mesh);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(mesh);bm.free();mesh.update()
        if bevel:
            bpy.context.view_layer.objects.active=ob;ob.select_set(True);mod=ob.modifiers.new('Manufactured edge radii','BEVEL');mod.width=bevel;mod.segments=segments;mod.limit_method='ANGLE'
            bpy.ops.object.modifier_apply(modifier=mod.name);ob.select_set(False)
        if self.parent:
            bpy.context.view_layer.update()
            ob.parent=OBJECTS[self.parent];ob.matrix_parent_inverse=ob.parent.matrix_world.inverted()
        OBJECTS[self.name]=ob
        if self.name.startswith('Part_'):PARTS.append(self.name)
        return ob

def geo(name,origin=(0,0,0),parent=None):
    g=Geo(name,origin,parent);GROUPS[name]=g;return g

def bolt(g,c,axis=(0,0,1),r=.017):g.cyl(c,r,.012,'Steel',axis,n=6)
def handle(g,c,w=.16,axis='x'):
    x,y,z=c
    if axis=='x':g.pipe([(x-w/2,y,z),(x-w/2,y,z+.035),(x+w/2,y,z+.035),(x+w/2,y,z)],.009,'Steel',8)
    else:g.pipe([(x,y-w/2,z),(x,y-w/2,z+.035),(x,y+w/2,z+.035),(x,y+w/2,z)],.009,'Steel',8)
def prism(g,profile,width,mat='Paint'):
    vs=[(x,y,z) for x in (-width/2,width/2) for y,z in profile];n=len(profile)
    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)];g.add(vs,fs,mat)
def turret_shell(g):
    # Chamfered six-sided welded turret, front cheeks taper into mantlet.
    bottom=[(-.66,-1.25),(.66,-1.25),(1.01,-.60),(.90,.80),(.57,1.00),(-.57,1.00),(-.90,.80),(-1.01,-.60)]
    top=[(-.54,-1.09),(.54,-1.09),(.86,-.51),(.73,.69),(.50,.86),(-.50,.86),(-.73,.69),(-.86,-.51)]
    vs=[(x,y-.20,z) for z,ring in [(1.79,bottom),(2.30,top)] for x,y in ring]
    g.add(vs,[tuple(reversed(range(8))),tuple(range(8,16))]+[(i,(i+1)%8,(i+1)%8+8,i+8) for i in range(8)])

def make_healthy():
    h=geo('Hull')
    prism(h,[(-2.96,.69),(-2.65,.40),(2.64,.40),(2.63,.80),(2.62,1.27),(-2.15,1.27)],2.40)
    prism(h,[(-2.96,.87),(-2.71,1.19),(-1.96,1.39),(-1.94,1.68),(1.47,1.68),(2.68,1.45),(2.66,1.13),(2.63,.91)],2.70)
    # Raised fighting deck, low rear engine, layered front glacis and maintenance cover.
    h.box((0,-.2,1.65),(2.40,3.55,.10))
    h.box((0,1.98,1.49),(2.40,1.45,.12))
    h.box((0,-2.73,1.16),(2.20,.12,.19))
    for x in (-1.24,1.24):
        h.pipe([(x,-2.6,1.19),(x,-1.96,1.40),(x,-1.92,1.68),(x,1.40,1.68),(x,2.64,1.46)],.010,'Steel')
    # Front visors with deep framed slit; hull MG to tank's right.
    h.box((.63,-1.967,1.51),(.51,.055,.19),'Steel');h.box((.63,-2.0,1.53),(.43,.028,.07),'Interior');h.box((.63,-2.03,1.585),(.51,.065,.045))
    h.ring((-.68,-1.986,1.50),.16,.11,.045,'Paint',(0,1,0),32);h.sphere((-.68,-1.981,1.50),.136)
    h.ring((-.68,-2.065,1.50),.062,.023,.16,'Steel',(0,1,0),24);h.cyl((-.68,-2.25,1.50),.022,.24,'Steel',(0,1,0),16)
    for x in (-1.03,1.03):
        for z in (.79,1.01):bolt(h,(x,-2.93,z),(0,-1,0),.023)
        h.ring((x,-2.89,.77),.078,.041,.065,'Steel',(1,0,0),24)
        h.ring((x,2.88,.80),.078,.041,.065,'Steel',(1,0,0),24)
    # Suspension backplates, leaf spring packs and four bogies.
    for s in (-1,1):
        for k,y in enumerate([-1.80,-.63,.54,1.71]):
            h.box((s*1.25,y,.63),(.16,.36,.26))
            h.cyl((s*1.34,y,.61),.093,.12,'Steel',(1,0,0),20)
            for leaf in range(5):
                h.pipe([(s*1.26,y-.41,.53+leaf*.018),(s*1.26,y-.19,.47+leaf*.018),(s*1.26,y+.19,.47+leaf*.018),(s*1.26,y+.41,.53+leaf*.018)],.015,'Steel',6)
            for dy in (-.24,.24):h.beam((s*1.27,y,.61),(s*1.30,y+dy,.36),.09)
        # skirt hanger structure remains when stripped.
        for y in [-2.37,-1.45,-.53,.39,1.31,2.23]:
            h.beam((s*1.31,y,1.36),(s*1.62,y,1.62),.032)
            h.box((s*1.59,y,1.60),(.08,.07,.12));bolt(h,(s*1.625,y,1.62),(s,0,0))
        for z in (1.56,1.64):h.beam((s*1.60,-2.58,z),(s*1.60,2.62,z),.029)
        # fixed full-width fender ledge with rolled outer edge.
        h.box((s*1.40,0,1.20),(.42,5.24,.032))
        h.beam((s*1.60,-2.6,1.22),(s*1.60,2.6,1.22),.018,'Steel')
        for y in [-2.20,-1.10,0,1.1,2.2]:h.box((s*1.44,y,1.23),(.33,.035,.021),'Steel')
    h.ring((0,-.22,1.735),.90,.72,.105,'Steel',n=64)
    h.cyl((0,-.22,1.50),.72,.08,'Interior',n=48)
    # hatch wells under the closed deck lids.
    for x,y in ((.65,-1.47),(-.65,-1.47)):
        h.box((x,y,1.675),(.48,.49,.035),'Interior');h.box((x,y,1.48),(.39,.42,.23),'Interior')
        for dx in (-.26,.26):h.box((x+dx,y,1.71),(.025,.56,.055))
    h.build(.009)
    # Track loop: straight top/bottom runs and circular drive/idler arcs.
    # Raised end wheels and angled lower runs reproduce the Panzer IV track profile.
    dense=[(-1.88,.057),(1.88,.057)]
    for i in range(51):
        a=-pi/3+(5*pi/6)*i/50;dense.append((2.43+.435*cos(a),.73+.435*sin(a)))
    for i in range(1,61):
        t=i/60;dense.append((2.43-4.86*t,1.165-.08*sin(pi*t)))
    for i in range(1,51):
        a=pi/2+(5*pi/6)*i/50;dense.append((-2.43+.435*cos(a),.73+.435*sin(a)))
    dense.append(dense[0]);lengths=[0]
    for a,b in zip(dense,dense[1:]):lengths.append(lengths[-1]+math.hypot(b[0]-a[0],b[1]-a[1]))
    path=[];seg=0
    for i in range(102):
        dist=lengths[-1]*i/102
        while seg<len(lengths)-2 and lengths[seg+1]<dist:seg+=1
        a,b=dense[seg],dense[seg+1];t=(dist-lengths[seg])/(lengths[seg+1]-lengths[seg]);path.append((a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,math.atan2(b[1]-a[1],b[0]-a[0])))
    for s,side in ((1,'L'),(-1,'R')):
        t=geo('Track_'+side)
        for k,(y,z,a) in enumerate(path):
            rot=Matrix.Rotation(a,3,'X')
            startf=len(t.f)
            t.box((s*1.34,y,z),(.40,.112,.065),'TrackSteel',rot)
            # central solid grouser with relieved ends and guide tooth.
            p=Vector((s*1.34,y,z))+rot@Vector((0,0,-.043));t.box(p,(.435,.032,.028),'TrackSteel',rot)
            p=Vector((s*1.34,y,z))+rot@Vector((0,0,.061));t.box(p,(.047,.060,.071),'TrackSteel',rot)
            for dx in (-.20,.20):
                p=Vector((s*1.34+dx,y,z))+rot@Vector((0,.038,0));t.cyl(p,.018,.038,'TrackSteel',(1,0,0),8)
            for fi in range(startf,len(t.f)):
                t.uv[fi]=[(.5+(t.v[vi][0]-s*1.34)/.48,(k+(j%2)*.92)/len(path)) for j,vi in enumerate(t.f[fi])]
        t.build(.003)
        for idx,y in enumerate([-1.88+i*(3.76/7) for i in range(8)]):
            z=.355;w=geo(f'Wheel_{side}{idx+1}',(s*1.34,y,z))
            for dx in (-.105,.105):
                x=s*1.34+dx;w.cyl((x,y,z),.265,.095,'Rubber',(1,0,0),40);w.cyl((x+s*.054,y,z),.222,.018,'Paint',(1,0,0),40);w.ring((x+s*.067,y,z),.206,.181,.014,'Steel',(1,0,0),32)
            x=s*1.51;w.ring((x-s*.022,y,z),.162,.145,.018,'Paint',(1,0,0),32);w.cyl((x,y,z),.092,.076,'Paint',(1,0,0),28);w.cyl((x+s*.042,y,z),.047,.024,'Paint',(1,0,0),20)
            for k in range(8):
                a=k*2*pi/8;bolt(w,(x+s*.047,y+.128*cos(a),z+.128*sin(a)),(s,0,0),.013)
            w.build(.003);WHEEL_INFO.append((w.name,'road',side,idx+1))
        for idx,y,kind in [(9,-2.43,'sprocket'),(10,2.43,'idler')]:
            z=.73;w=geo(f'Wheel_{side}{idx}',(s*1.34,y,z))
            for dx in (-.12,.12):
                x=s*1.34+dx;w.ring((x,y,z),.391,.307,.055,'Steel',(1,0,0),48);w.cyl((x,y,z),.128,.078,'Paint',(1,0,0),32)
                for k in range(8):
                    a=k*2*pi/8;w.beam((x,y+.105*cos(a),z+.105*sin(a)),(x,y+.326*cos(a+.08),z+.326*sin(a+.08)),.050)
            if kind=='sprocket':
                for k in range(20):
                    a=k*2*pi/20;w.box((s*1.46,y+.406*cos(a),z+.406*sin(a)),(.060,.067,.072),'Steel',Matrix.Rotation(a,3,'X'))
            w.cyl((s*1.52,y,z),.081,.05,'Paint',(1,0,0),24)
            for k in range(6):
                a=k*2*pi/6;bolt(w,(s*1.55,y+.102*cos(a),z+.102*sin(a)),(s,0,0),.013)
            w.build(.003);WHEEL_INFO.append((w.name,kind,side,idx))
        for k,y in enumerate([-1.77,-.59,.59,1.77]):
            z=.945;w=geo(f'Wheel_{side}{11+k}',(s*1.34,y,z))
            w.cyl((s*1.34,y,z),.123,.27,'Rubber',(1,0,0),28);w.cyl((s*1.49,y,z),.089,.035,'Paint',(1,0,0),24);w.cyl((s*1.515,y,z),.041,.025,'Steel',(1,0,0),16);w.build(.003);WHEEL_INFO.append((w.name,'return',side,11+k))
    # Five individual overlapping-but-gapped plate segments per side, 5 mm steel.
    for s,side in ((1,'L'),(-1,'R')):
        for k in range(5):
            y=-2.07+k*1.035;x=s*(1.633 if k%2 else 1.643);g=geo(f'Part_Skirt_{side}{k+1}',(x,y,1.16))
            top=1.605;bottom=.69
            profile=[(y-.495,top-.08 if k==0 else top),(y+.495,top),(y+.495,bottom+.11 if k==4 else bottom),(y-.495,bottom+.18 if k==0 else bottom)]
            if k==0:profile=[(y-.30,top-.035),(y+.495,top),(y+.495,bottom),(y-.25,bottom+.18),(y-.495,1.13),(y-.495,1.35)]
            vs=[(x+dx,yy,zz) for dx in (-.0025,.0025) for yy,zz in profile];nn=len(profile)
            g.add(vs,[tuple(reversed(range(nn))),tuple(range(nn,2*nn))]+[(i,(i+1)%nn,(i+1)%nn+nn,i+nn) for i in range(nn)])
            for dy in (-.40,.40):
                g.box((x-s*.014,y+dy,1.57),(.036,.058,.12));bolt(g,(x+s*.015,y+dy,1.58),(s,0,0),.014)
            bolt(g,(x+s*.015,y,1.06),(s,0,0),.013)
            g.build(.002)
        for fb,y in [('F',-2.69),('R',2.69)]:
            name=f'Part_Fender_{fb}{side}';g=geo(name,(s*1.40,y,1.12));g.box((s*1.4,y,1.12),(.43,.53,.032),rot=Matrix.Rotation(.35 if fb=='F' else -.30,3,'X'))
            g.box((s*1.4,y+(-.20 if fb=='F' else .20),1.02),(.43,.04,.17));g.cyl((s*1.40,y,1.19),.018,.46,'Steel',(1,0,0),12)
            for dx in (-.15,.15):bolt(g,(s*1.4+dx,y,1.16))
            g.build(.005)
    # Spare links, visible nose rack and rear replacement set.
    for loc,y,z,count in [('Nose',-2.946,.96,9),('Glacis',-2.41,1.295,6),('Rear',2.72,1.19,4)]:
        g=geo('Part_SpareTracks_'+loc,(0,y,z))
        for k in range(count):
            x=(k-(count-1)/2)*.19;start=len(g.v)
            for dx in (-.065,.065):g.box((x+dx,y,z),(.035,.065,.16),'TrackSteel')
            for dz in (-.063,.063):g.box((x,y,z+dz),(.17,.065,.035),'TrackSteel')
            g.box((x,y-.045,z),(.036,.050,.13),'TrackSteel')
            for zz in (-.075,.075):g.cyl((x,y,z+zz),.023,.19,'TrackSteel',(1,0,0),12)
            if loc in ('Glacis','Nose','Rear'):
                rot=Matrix.Rotation(math.radians(-74 if loc=='Glacis' else -38),3,'X') if loc!='Rear' else Matrix.Rotation(pi,3,'Z');c=Vector((x,y,z))
                for vi in range(start,len(g.v)):g.v[vi]=tuple(c+rot@(Vector(g.v[vi])-c))
        g.beam((-.10*count,y-.06,z-.08),(.10*count,y-.06,z-.08),.025,'Steel');g.build(.004)
    # Rear exhaust, mounts and towing cable.
    g=geo('Part_Exhaust_Muffler',(0,2.78,.94));g.cyl((0,2.78,.94),.17,1.58,'Steel',(1,0,0),40)
    for x in (-.61,.61):g.ring((x,2.78,.94),.182,.17,.065,'Paint',(1,0,0),32)
    g.pipe([(.68,2.78,.94),(.91,2.78,.94),(1.03,2.78,1.12),(1.03,2.69,1.23)],.065,'Steel',16);g.build(.005)
    g=geo('Part_Exhaust_Aux',(-.98,2.73,1.13));g.cyl((-.98,2.73,1.13),.066,.35,'Steel',n=24);g.pipe([(-.98,2.73,1.29),(-.98,2.79,1.38)],.035,'Steel',12);g.build(.003)
    g=geo('Part_TowCable',(0,2.74,1.41))
    pts=[(-1,2.72,1.40),(-.8,2.80,1.34),(-.35,2.82,1.32),(.25,2.81,1.43),(.8,2.76,1.43),(1,2.74,1.39)]
    g.pipe(pts,.020,'Steel',12)
    for x in (-1,1):g.ring((x,2.74,1.40),.08,.052,.025,'Steel',(0,1,0),24)
    g.build(.002)
    for s,side in ((1,'L'),(-1,'R')):
        g=geo('Part_Stowage_'+side,(s*1.42,.94,1.37));g.box((s*1.42,.94,1.37),(.30,.94,.26));g.box((s*1.42,.94,1.515),(.325,.96,.027))
        for dy in (-.31,.31):g.box((s*1.58,.94+dy,1.4),(.022,.047,.10),'Steel');bolt(g,(s*1.60,.94+dy,1.41),(s,0,0),.012)
        g.build(.006)
        g=geo('Part_Tools_'+side,(s*1.39,-.73,1.30));g.pipe([(s*1.39,-1.35,1.28),(s*1.39,-.40,1.28)],.023,'Steel',10);g.box((s*1.39,-.25,1.28),(.15,.29,.027),'Steel')
        g.pipe([(s*1.48,-1.4,1.29),(s*1.48,-.57,1.29)],.018,'Steel',8)
        for y in (-1.12,-.60):g.box((s*1.41,y,1.31),(.22,.033,.035))
        g.build(.003)
        g=geo('Part_EngineGrille_'+side,(s*.77,1.98,1.578));g.box((s*.77,1.98,1.56),(.65,1.03,.025),'Interior')
        for j in range(13):g.box((s*.77,1.51+j*.078,1.584),(.66,.026,.032))
        for xx in (-.34,.34):g.box((s*.77+xx,1.98,1.59),(.025,1.09,.031))
        g.build(.002)
        # Head lamps are independently detachable, guards separately detachable.
        g=geo('Part_Headlamp_'+side,(s*1.15,-2.25,1.42));g.cyl((s*1.15,-2.25,1.42),.075,.11,'Paint',(0,1,0),24);g.cyl((s*1.15,-2.311,1.42),.058,.012,'Lamps',(0,1,0),24);g.box((s*1.15,-2.324,1.45),(.13,.013,.055))
        g.beam((s*1.15,-2.25,1.25),(s*1.15,-2.25,1.38),.034);g.build(.003)
        g=geo('Part_LampGuard_'+side,(s*1.15,-2.25,1.45));g.pipe([(s*1.15-.12,-2.35,1.25),(s*1.15-.12,-2.35,1.54),(s*1.15+.12,-2.35,1.54),(s*1.15+.12,-2.35,1.25)],.012,'Steel');g.build(.002)
    # Working turret, separate mantlet, recoil barrel.
    t=geo('Turret',(0,-.22,1.75));turret_shell(t);t.ring((0,-.22,1.81),.83,.70,.10,'Paint',n=64)
    for s in (-1,1):
        t.box((s*.945,-.13,2.045),(.025,.62,.28));t.cyl((s*.97,.10,2.04),.074,.035,'Paint',(1,0,0),24)
        for y in (-.36,.10):bolt(t,(s*.971,y,2.15),(s,0,0))
        # skirt radial stands and fixed upper rim.
        for y in (-.89,-.26,.40,.85):t.beam((s*.81,y,2.27),(s*(1.16 if y<.65 else .87),y,2.27),.027)
    t.cyl((.31,.27,2.28),.34,.05,'Interior',n=48)
    t.box((-.40,-.27,2.30),(.43,.49,.025),'Interior')
    t.build(.008)
    g=geo('Gun',(0,-1.42,2.04),'Turret');g.box((0,-1.45,2.04),(.87,.29,.45));g.cyl((0,-1.62,2.04),.223,.24,'Paint',(0,1,0),48,r2=.19)
    for s in (-1,1):g.cyl((s*.46,-1.44,2.04),.153,.10,'Paint',(1,0,0),32)
    for x in (-.30,.30):
        for z in (1.91,2.18):bolt(g,(x,-1.612,z),(0,-1,0))
    g.build(.025,3)
    g=geo('Barrel',(0,-1.74,2.04),'Gun');g.cyl((0,-1.95,2.04),.111,.46,'Paint',(0,1,0),40,r2=.119)
    g.cyl((0,-2.99,2.04),.056,1.72,'Paint',(0,1,0),40,r2=.083)
    g.ring((0,-2.16,2.04),.118,.077,.057,'Steel',(0,1,0),32)
    # Real open double-baffle brake: three rings linked by top/bottom webs.
    for y,rr in [(-3.78,.085),(-3.91,.102),(-4.035,.104)]:g.ring((0,y,2.04),rr,.045,.05,'Steel',(0,1,0),32)
    for zz in (-.076,.076):g.box((0,-3.91,2.04+zz),(.096,.28,.030),'Steel')
    g.build(.0025)
    # Cupola with five vision blocks and ring; commander lid pivots rearward.
    g=geo('Part_Turret_CupolaRing',(.31,.27,2.44),'Turret');g.ring((.31,.27,2.42),.36,.263,.22,'Paint',n=48)
    for k in range(8):
        a=k*2*pi/8;x=.31+.343*cos(a);y=.27+.343*sin(a)
        g.box((x,y,2.47),(.15,.065,.105),'Paint',Matrix.Rotation(a-pi/2,3,'Z'));g.box((x+cos(a)*.008,y+sin(a)*.008,2.465),(.10,.071,.026),'Interior',Matrix.Rotation(a-pi/2,3,'Z'))
    g.ring((.31,.27,2.315),.382,.263,.035,'Paint',n=48);g.ring((.31,.27,2.34),.370,.263,.028,'Paint',n=48)
    g.ring((.31,.27,2.56),.367,.27,.036,'Paint',n=48);g.build(.007,3)
    g=geo('Hatch_Commander',(.31,.58,2.595),'Turret');g.cyl((.31,.27,2.605),.326,.055,'Paint',n=48);g.ring((.31,.27,2.635),.302,.287,.018,'Steel',n=48);handle(g,(.31,.18,2.632),.15)
    g.cyl((.31,.58,2.60),.025,.30,'Steel',(1,0,0),16);g.build(.004)
    g=geo('Hatch_Loader',(-.39,-.02,2.31),'Turret');g.box((-.39,-.27,2.325),(.45,.49,.041));handle(g,(-.39,-.32,2.35));g.cyl((-.39,-.02,2.32),.020,.37,'Steel',(1,0,0),16);g.build(.006)
    g=geo('Hatch_Driver',(.65,-1.22,1.73));g.box((.65,-1.47,1.735),(.48,.48,.04));handle(g,(.65,-1.49,1.76));g.cyl((.65,-1.22,1.75),.022,.39,'Steel',(1,0,0),16);g.build(.006)
    g=geo('Hatch_Radio',(-.65,-1.22,1.73));g.box((-.65,-1.47,1.735),(.48,.48,.04));handle(g,(-.65,-1.49,1.76));g.cyl((-.65,-1.22,1.75),.022,.39,'Steel',(1,0,0),16);g.build(.006)
    # Curving polygonal turret Schurzen, open at the forward mantlet.
    pts=[(-.70,-1.39),(-1.18,-.91),(-1.24,-.18),(-1.18,.54),(-.73,1.02),(.0,1.12),(.73,1.02),(1.18,.54),(1.24,-.18),(1.18,-.91),(.70,-1.39)]
    for i,(a,b) in enumerate(zip(pts,pts[1:])):
        x,y=(a[0]+b[0])/2,(a[1]+b[1])/2;g=geo(f'Part_Turret_Skirt{i+1}',(x,y,2.07),'Turret')
        v=Vector((b[0]-a[0],b[1]-a[1],0));normal=Vector((v.y,-v.x,0)).normalized();vs=[]
        for d in (-.006,.006):
            for z in (1.83,2.30):
                for p in (a,b):vs.append(Vector((p[0],p[1],z))+normal*d)
        g.add(vs,[(0,1,3,2),(4,6,7,5),(0,4,5,1),(2,3,7,6),(0,2,6,4),(1,5,7,3)])
        for p in (a,b):
            for z in (1.90,2.24):bolt(g,Vector((p[0]*.98,p[1]*.98,z))+normal*.013,normal,.012)
        g.beam((a[0],a[1],2.3),(b[0],b[1],2.3),.014,'Steel')
        # Door hinge leaves and vertical pins on the two side escape panels.
        if i in (1,2,7,8):
            tangent=v.normalized()
            for z in (1.94,2.20):
                c=Vector((a[0],a[1],z))+tangent*.06+normal*.018
                g.beam(c-tangent*.042,c+tangent*.042,.025,'Paint')
                g.cyl(c+normal*.008,.014,.071,'Steel',n=12)
            c=Vector((x,y,2.04))+normal*.025
            g.pipe([c+Vector((0,0,-.035)),c+normal*.027+Vector((0,0,-.035)),c+normal*.027+Vector((0,0,.035)),c+Vector((0,0,.035))],.007,'Steel',8)
        g.build(.002)
    g=geo('Part_Turret_Bin',(0,.94,2.07),'Turret');g.box((0,.94,2.07),(1.17,.32,.36));g.box((0,.94,2.26),(1.19,.35,.025))
    for x in (-.40,.40):g.box((x,1.115,2.10),(.046,.026,.15),'Steel')
    g.build(.006)
    g=geo('Part_Turret_Sight',(-.39,-.69,2.33),'Turret');g.box((-.39,-.69,2.34),(.18,.19,.07));g.box((-.39,-.795,2.34),(.105,.013,.025),'Interior');g.build(.004)
    g=geo('Part_Antenna',(-1.14,1.13,1.73));g.cyl((-1.14,1.13,1.72),.045,.10,'Rubber',n=16);g.cyl((-1.14,1.13,2.18),.006,.90,'Steel',n=8,r2=.010);g.build(.001)
    # No optional roof AA MG: field configuration shown on supplied sheet has none.
    for name,p,parent in [('Muzzle',(0,-4.06,2.04),'Barrel'),('Seat_Commander',(.31,.27,2.54),'Turret'),('Seat_Driver',(.65,-1.47,1.63),'Hull'),('Exhaust',(1.03,2.79,1.24),'Hull'),('Light_Head_L',(1.15,-2.33,1.42),'Hull'),('Light_Head_R',(-1.15,-2.33,1.42),'Hull'),('Fx_Engine',(0,1.96,1.60),'Hull'),('Fx_Ammo',(.45,0,.85),'Hull'),('Fx_Hatch',(.31,.27,2.66),'Turret')]:empty(name,p,parent)
    print('HEALTHY_GEOMETRY',len(OBJECTS),sum(tris(o) for o in OBJECTS.values()),flush=True)

def empty(name,p=(0,0,0),parent=None):
    ob=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(ob);ob.location=p
    if parent:ob.parent=OBJECTS[parent];ob.matrix_parent_inverse=ob.parent.matrix_world.inverted()
    OBJECTS[name]=ob;return ob

def tris(o):return sum(len(p.vertices)-2 for p in o.data.polygons) if o.type=='MESH' else 0

def patch_glb(path):
    data=path.read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);binary=data[20+jl:]
    for m in doc.get('materials',[]):
        if m['name'].split('.')[0]=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=DEFAULT+[1.0];m['name']='Paint'
    out=json.dumps(doc,separators=(',',':')).encode();out+=b' '*((-len(out))%4)
    path.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(out)+len(binary))+struct.pack('<I4s',len(out),b'JSON')+out+binary)

def export(path,objects):
    bpy.ops.object.select_all(action='DESELECT')
    for o in objects:o.hide_set(False);o.select_set(True)
    # Standard glTF material with explicit factor patched after serialization.
    nt=MATS['Paint'].node_tree;bs=nt.nodes.get('Principled BSDF');mul=nt.nodes.get('PaintFactor');tex=mul.inputs[1].links[0].from_node;nt.links.new(tex.outputs[0],bs.inputs['Base Color'])
    bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_animations=False,export_cameras=False,export_lights=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='JPEG',export_jpeg_quality=87)
    nt.links.new(mul.outputs[0],bs.inputs['Base Color']);patch_glb(path)
    assert path.stat().st_size<=12000000, 'GLB exceeds 12 MB'
    print('EXPORTED',path.name,path.stat().st_size,sum(tris(o) for o in objects),flush=True)

def setup_render():
    s=bpy.context.scene
    world=bpy.data.worlds.new('Soft overcast');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.73,.82,1);world.node_tree.nodes['Background'].inputs[1].default_value=.35;s.world=world
    for name,loc,power,size in [('Key',(-3,-5,9),1800,7),('Fill',(5,-1,5),1100,6),('Rim',(0,5,7),1900,5)]:
        l=bpy.data.lights.new(name,'AREA');l.energy=power;l.shape='DISK';l.size=size;o=bpy.data.objects.new(name,l);bpy.context.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
    m=bpy.data.materials.new('Studio ground');m.diffuse_color=(.20,.23,.24,1);m.use_nodes=True;m.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.20,.23,.24,1);m.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.87
    bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));floor=bpy.context.object;floor.name='_Studio';floor.data.materials.append(m)
    cam=bpy.data.cameras.new('Camera');ob=bpy.data.objects.new('Camera',cam);bpy.context.collection.objects.link(ob);s.camera=ob;cam.type='ORTHO';cam.ortho_scale=9.3
    return ob

def view(cam,side=False):
    cam.location=(10,-12,7.1) if not side else (12,0,3.5)
    target=Vector((0,-.48,1.08)) if not side else Vector((0,-.48,1.30))
    if side:cam.location=Vector((12,-.48,1.30))
    cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=9.15 if not side else 8.35

def render_file(path,cam,w=1920,h=1080,samples=32):
    s=bpy.context.scene;s.camera=cam;s.render.resolution_x=w;s.render.resolution_y=h;s.render.resolution_percentage=100;s.cycles.samples=samples;s.render.filepath=str(path);bpy.ops.render.render(write_still=True)

def healthy_stage():
    reset()
    if '--geometry' not in sys.argv:bake_tiles()
    materials();make_healthy();bpy.context.view_layer.update();cut_hatches();add_wear();optimize_healthy();bpy.context.view_layer.update()
    healthy=list(OBJECTS.values());export(ROOT/'panzer-iv-tank.glb',healthy)
    bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'healthy.blend'))
    cam=setup_render();view(cam);render_file(ROOT/'final.png',cam,1920,1080,32)
    view(cam,True);render_file(ROOT/'side.png',cam,1600,900,24)
    cam.location=(9,11,6);cam.rotation_euler=(Vector((0,0,1.1))-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=8.7;render_file(ROOT/'rear.png',cam,1600,900,24)
    view(cam);clay=bpy.data.materials.new('Clay');clay.diffuse_color=(.36,.36,.36,1);clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.36,.36,.36,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.8;bpy.context.view_layer.material_override=clay;render_file(ROOT/'clay.png',cam,1920,1080,24);bpy.context.view_layer.material_override=None
    return healthy



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

def boolean_cut(ob,center,size=None,radius=None,depth=.5,rotation=None):
    if radius:
        bpy.ops.mesh.primitive_cylinder_add(vertices=32,radius=radius,depth=depth,location=center)
    else:bpy.ops.mesh.primitive_cube_add(size=1,location=center);bpy.context.object.scale=size;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    cutter=bpy.context.object
    if rotation:cutter.rotation_euler=rotation
    select_only(ob);mod=ob.modifiers.new('Actual aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.use_self=True;mod.object=cutter
    bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cutter,do_unlink=True)

def cut_hatches():
    for x in (-.65,.65):boolean_cut(OBJECTS['Hull'],(x,-1.47,1.77),(.42,.42,.35))
    boolean_cut(OBJECTS['Turret'],(.31,.27,2.37),radius=.265,depth=.37)
    boolean_cut(OBJECTS['Turret'],(-.39,-.27,2.38),(.38,.41,.28))
    g=geo('_Wells')
    for x in (-.65,.65):
        g.box((x,-1.47,1.525),(.43,.43,.023),'Interior')
        for dx in (-.215,.215):g.box((x+dx,-1.47,1.61),(.018,.43,.18),'Interior')
        for dy in (-.215,.215):g.box((x,-1.47+dy,1.61),(.43,.018,.18),'Interior')
    o=g.build(0);join_into(OBJECTS['Hull'],[o])
    g=geo('_TurretWells');g.ring((.31,.27,2.425),.278,.263,.30,'Paint',n=40);g.ring((.31,.27,2.25),.277,.260,.30,'Interior',n=40);g.cyl((.31,.27,2.09),.268,.025,'Interior',n=40);g.box((-.39,-.27,2.11),(.40,.43,.02),'Interior')
    for x in (-.595,-.185):g.box((x,-.27,2.20),(.02,.43,.20),'Interior')
    o=g.build(0);join_into(OBJECTS['Turret'],[o])
    for k in ('_Wells','_TurretWells'):OBJECTS.pop(k,None)

def join_into(base,others):
    select_only(base)
    for o in others:o.hide_set(False);o.select_set(True)
    bpy.ops.object.join()

def clone(o,name,parent=None):
    n=o.copy();n.data=o.data.copy() if o.type=='MESH' else None;bpy.context.collection.objects.link(n);n.name=name;n.parent=None;n.matrix_world=o.matrix_world.copy()
    if parent:
        n.parent=parent;n.matrix_parent_inverse=parent.matrix_world.inverted()
    return n

def damage_materials(o,burnt=False):
    if o.type!='MESH':return
    for i,m in enumerate(o.data.materials):
        if not m:continue
        key=m.name.split('.')[0]
        if burnt:o.data.materials[i]=MATS['Burnt']
        elif key not in ('Paint','Burnt','Scars','TrackSteel','Rubber'):o.data.materials[i]=MATS['Scars'] if key=='Steel' else MATS['Burnt']

def make_damage(healthy):
    damage=[];twins={};scars=[]
    hd=empty('Hull_Damage');td=empty('Turret_Damage',tuple(OBJECTS['Turret'].matrix_world.translation));bpy.context.view_layer.update();damage.extend([hd,td])
    names=['Part_Skirt_L1','Part_Skirt_L2','Part_Skirt_L4','Part_Skirt_R1','Part_Skirt_R3','Part_Skirt_R5','Part_Fender_FL','Part_Fender_RR','Part_Stowage_L','Part_EngineGrille_R','Part_Exhaust_Muffler','Part_Turret_Skirt2','Part_Turret_Skirt5','Part_Turret_Skirt8','Part_Turret_Bin']
    for index,name in enumerate(names):
        src=OBJECTS[name];dn='Dmg_'+name[5:];parent=td if name.startswith('Part_Turret_') else hd;ob=clone(src,dn,parent);damage_materials(ob)
        # Torn plate: remove a ragged lower wedge and curl the exposed edge.
        if 'Skirt' in name:
            mesh=ob.data;inv=ob.matrix_world.inverted();origin=src.matrix_world.translation
            # Bend lower half away; keep mounted upper fasteners in place.
            for v in mesh.vertices:
                p=ob.matrix_world@v.co;low=max(0,(origin.z-p.z)/.50)
                p.x+=(1 if origin.x>0 else -1)*(.08+index%3*.035)*low**1.3
                p.z-=.08*low;p.y+=.06*sin(p.z*14+index)*low
                v.co=inv@p
            # A true missing chunk, with boolean edge bevel afterwards.
            center=origin+Vector((0,.36,-.28));boolean_cut(ob,center,(.55,.30,.27),rotation=(math.radians(19+index%3*12),0,0));boolean_cut(ob,center+Vector((0,-.12,-.065)),(.55,.12,.20),rotation=(-.38,0,0))
            extra=Geo('_curl',origin)
            sign=1 if origin.x>0 else -1
            a=origin+Vector((sign*.08,.19,-.18));b=a+Vector((sign*.10,.08,-.12));c=b+Vector((sign*.08,.09,.035))
            extra.beam(a,b,.055,'Scars',.012);extra.beam(b,c,.045,'Scars',.009)
            q=extra.build(.001);join_into(ob,[q]);OBJECTS.pop('_curl',None)
        else:
            inv=ob.matrix_world.inverted();origin=ob.matrix_world.translation
            for v in ob.data.vertices:
                p=ob.matrix_world@v.co;d=p-origin;p+=Vector((.08*sin(d.y*5),.07*sin(d.x*4),.09*sin(d.x*3+d.y*5)));v.co=inv@p
        damage_materials(ob);damage.append(ob);twins[name]=dn
    # A scar patch is authored in a face basis and hugs its parent armour.
    def scar_group(name,center,u,v,normal):
        center,u,v,normal=map(Vector,(center,u,v,normal));normal.normalize();g=Geo(name)
        if name=='Scar_Hull_Front':v*=.45
        elif name=='Scar_Hull_Rear':v*=.55
        for j,(du,dv,rad) in enumerate([(-.32,.05,.065),(.02,-.09,.047),(.30,.11,.052),(.16,.20,.027)]):
            c=center+u*du+v*dv+normal*.003
            # Irregular soot halo using coplanar concentric wedges.
            nn=20;vs=[c+normal*.0001]+[c+u*cos(k*2*pi/nn)*rad*(1.6+.35*sin(k*7+j))+v*sin(k*2*pi/nn)*rad*(1.45+.2*cos(k*4))+normal*.0001 for k in range(nn)]
            g.add(vs,[(0,k+1,(k+1)%nn+1) for k in range(nn)],'Burnt')
            q=Vector((0,0,1)).rotation_difference(normal)
            g.ring(c+normal*.002,rad,rad*.60,.006,'Scars',normal,n=20)
            g.cyl(c+normal*.001,rad*.59,.003,'Burnt',normal,n=20)
            if j==0:
                for k in range(8):
                    a=k*2*pi/8;b=a+.45
                    p1=c+u*cos(a)*rad*.60+v*sin(a)*rad*.60
                    p2=c+u*cos(b)*rad*.65+v*sin(b)*rad*.65
                    p3=c+u*cos((a+b)/2)*rad*1.05+v*sin((a+b)/2)*rad*1.05+normal*.014
                    g.add([p1,p2,p3],[(0,1,2)],'Scars')
            else:
                g.pipe([c-u*rad*.5,c+u*rad*1.4+v*.02,c+u*rad*2.4+v*.04],.007,'Scars',6)
        base=OBJECTS['Turret' if 'Turret' in name else 'Hull'];inv=base.matrix_world.inverted()
        for vi,point in enumerate(g.v):
            point=Vector(point);proud=max(.002,min(.004,(point-center).dot(normal)))
            hit,loc,nrm,fi=base.ray_cast(inv@(point+normal*.8),inv.to_3x3()@(-normal),distance=1.8)
            if hit:g.v[vi]=tuple(base.matrix_world@loc+(base.matrix_world.to_3x3()@nrm).normalized()*proud)
        ob=g.build(0);p=td if 'Turret' in name else hd;ob.parent=p;ob.matrix_parent_inverse=p.matrix_world.inverted();damage_materials(ob);damage.append(ob);scars.append(name)
    scar_group('Scar_Hull_Front',(0,-1.974,1.51),(1,0,0),(0,0,1),(0,-1,0))
    scar_group('Scar_Hull_L',(1.356,-.70,1.43),(0,1,0),(0,0,1),(1,0,0))
    scar_group('Scar_Hull_R',(-1.356,-.70,1.43),(0,-1,0),(0,0,1),(-1,0,0))
    scar_group('Scar_Hull_Rear',(0,2.861,1.01),(-1,0,0),(0,0,1),(0,1,0))
    scar_group('Scar_Turret_Front',(0,-1.452,2.06),(1,0,0),(0,0,1),(0,-1,0))
    scar_group('Scar_Turret_L',(.917,-.15,2.06),(0,1,0),(-.26,0,.97),(.97,0,.26))
    scar_group('Scar_Turret_R',(-.917,-.15,2.06),(0,-1,0),(.26,0,.97),(-.97,0,.26))
    scar_group('Scar_Turret_Rear',(0,.744,2.04),(-1,0,0),(0,-.25,.97),(0,.97,.25))
    # Both thrown tracks use healthy tread-link geometry with matching UVs.
    for side,s in [('L',1),('R',-1)]:
        source=GROUPS['Track_'+side];g=Geo('Track_'+side+'_Broken')
        # Rebuild the same links on an open, physically continuous thrown path.
        perv=len(source.v)//102;perf=len(source.f)//102
        route=[(-3.78,.057),(-2.6,.057),(-1.88,.057),(1.88,.057)]
        for j in range(35):
            a=-pi/3+(5*pi/6)*j/34;route.append((2.43+.435*cos(a),.73+.435*sin(a)))
        route.extend([(1.7,.91),(.8,.60),(-.2,.37),(-1.25,.16),(-2.35,.057)])
        lens=[0]
        for a,b in zip(route,route[1:]):lens.append(lens[-1]+math.hypot(b[0]-a[0],b[1]-a[1]))
        seg=0
        # The raw first link lies horizontally and is reused without changing its identity UV.
        template=[Vector(p)-Vector((s*1.34,-1.88,.057)) for p in source.v[:perv]]
        for k in range(102):
            d=lens[-1]*k/101
            while seg<len(route)-2 and lens[seg+1]<d:seg+=1
            a,b=route[seg],route[seg+1];tt=(d-lens[seg])/(lens[seg+1]-lens[seg]);y=a[0]+(b[0]-a[0])*tt;z=a[1]+(b[1]-a[1])*tt
            angle=math.atan2(b[1]-a[1],b[0]-a[0]);rot=Matrix.Rotation(angle,3,'X')
            thrown=max(0,min(1,(d-lens[-6])/(lens[-1]-lens[-6]))) if d>lens[-6] else 0
            z=max(z,-min((rot@v).z for v in template));center=Vector((s*(1.34+.36*thrown),y,z));vs=[center+rot@v for v in template]
            fs=[tuple(vi for vi in f) for f in source.f[:perf]];uv=source.uv[k*perf:(k+1)*perf]
            g.add(vs,fs,'TrackSteel',uv=uv)
        ob=g.build(.002);ob.parent=hd;damage.append(ob)
    # Burst/drooped gun copied from healthy mantlet and barrel, trunnion preserved.
    broken=clone(OBJECTS['Gun'],'Gun_Broken',td);bar=clone(OBJECTS['Barrel'],'_BrokenBarrel');damage_materials(broken);damage_materials(bar)
    pivot=OBJECTS['Gun'].matrix_world.translation.copy();inv=bar.matrix_world.inverted()
    for vert in bar.data.vertices:
        p=bar.matrix_world@vert.co
        if p.y < -2.5:
            t=-p.y-2.5;p.z-=.21*t;p.x+=.025*t*t
        vert.co=inv@p
    join_into(broken,[bar]);damage.append(broken)
    g=Geo('_Burst',pivot)
    for k in range(7):
        a=k*2*pi/7;x=.055*cos(a);z=2.02+.055*sin(a)
        g.pipe([(x,-2.73,z),(.10*cos(a),-2.95,z+.07*sin(a)),(.145*cos(a),-3.1,z+.15*sin(a))],.014,'Scars',6)
    q=g.build(.001);join_into(broken,[q]);OBJECTS.pop('_Burst',None)
    # Wrecks are reductions of the authored healthy pieces, not substitute primitives.
    wh=Geo('Wreck_Hull');wt=Geo('Wreck_Turret',OBJECTS['Turret'].matrix_world.translation)
    def is_turret(o):
        p=o
        while p:
            if p.name=='Turret':return True
            p=p.parent
        return False
    for source in healthy:
        if source.type!='MESH':continue
        name=source.name;ist=is_turret(source)
        if name in ['Part_Skirt_L1','Part_Skirt_L3','Part_Skirt_L5','Part_Skirt_R2','Part_Skirt_R4','Part_Turret_Skirt2','Part_Turret_Skirt4','Part_Turret_Skirt7','Part_Turret_Skirt9','Part_Antenna','Track_L']:continue
        temp=None
        if name=='Hull':
            temp=clone(source,'_WreckHullCut');boolean_cut(temp,(0,-.22,1.50),radius=.68,depth=1.1);source=temp
        target=wt if ist else wh;mat=source.matrix_world.copy()
        if name.startswith('Hatch_'):
            pivot=source.matrix_world.translation;mat=Matrix.Translation(pivot)@Matrix.Rotation(math.radians(-100),4,'X')@Matrix.Translation(-pivot)@mat
        if name in ('Gun','Barrel'):
            p=OBJECTS['Gun'].matrix_world.translation;mat=Matrix.Translation(p)@Matrix.Rotation(math.radians(15),4,'X')@Matrix.Translation(-p)@mat
        vs=[]
        for vertex in source.data.vertices:
            p=mat@vertex.co
            if name.startswith('Wheel_'):p.z-=.05
            if name=='Track_R' and p.z>.85:p.z-=.09*max(0,sin(pi*(p.y+2.43)/4.86))
            if name.startswith('Part_EngineGrille'):p.z+=.20*abs(p.y-1.5)+.12*sin(p.x*8)
            if name.startswith('Part_Skirt'):
                low=max(0,1.3-p.z);p.x+=(.30 if p.x>0 else -.24)*low;p.z-=low*.09
            vs.append(p)
        polys=[p for p in source.data.polygons if not (name.startswith('Wheel_') and source.data.materials[p.material_index].name.split('.')[0]=='Rubber')]
        fs=[tuple(p.vertices) for p in polys];uv=[]
        for poly in polys:uv.append([tuple(source.data.uv_layers.active.data[li].uv) for li in poly.loop_indices])
        target.add(vs,fs,'Burnt',uv=uv)
        if temp:bpy.data.objects.remove(temp,do_unlink=True)
    # Add broken left track to hull wreck.
    broken_track=next(o for o in damage if o.name=='Track_L_Broken')
    wh.add([broken_track.matrix_world@v.co for v in broken_track.data.vertices],[tuple(p.vertices) for p in broken_track.data.polygons],'Burnt')
    wh.ring((0,-.22,1.57),.79,.69,.29,'Burnt',n=48);wh.cyl((0,-.22,.82),.71,.035,'Burnt',n=40)
    wt.ring((0,-.22,1.73),.78,.59,.18,'Burnt',n=48);wt.cyl((0,-.22,1.43),.50,.32,'Burnt',n=40)
    for x in (-.4,.4):wt.beam((x,-.5,1.63),(x,-.5,1.19),.043,'Burnt')
    wreckh=wh.build(0);wreckt=wt.build(0);wreckh.parent=None;wreckt.parent=None
    # Reclose the bottom of the turret-ring well after the cut.
    q=Geo('_WreckWell');q.cyl((0,-.22,.91),.68,.025,'Burnt',n=32);q.ring((0,-.22,1.15),.69,.675,.50,'Burnt',n=40);z=q.build(0);join_into(wreckh,[z]);OBJECTS.pop('_WreckWell',None)
    for o in (wreckh,wreckt):
        damage_materials(o,burnt=True)
        select_only(o);tr=o.modifiers.new('Wreck triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=tr.name);d=o.modifiers.new('Wreck game budget','DECIMATE');d.ratio=.38;bpy.ops.object.modifier_apply(modifier=d.name)
    damage.extend([wreckh,wreckt]);bpy.context.view_layer.update()
    raw=sum(tris(o) for o in damage)
    if raw>108000:
        for ob in damage:
            if ob.name.startswith('Track_') and ob.type=='MESH':
                select_only(ob);mod=ob.modifiers.new('Broken belt budget','DECIMATE');mod.ratio=.45;bpy.ops.object.modifier_apply(modifier=mod.name)
    raw=sum(tris(o) for o in damage)
    if raw>108000:
        for ob in damage:
            if ob.type=='MESH' and tris(ob)>200:
                select_only(ob);mod=ob.modifiers.new('Damage budget','DECIMATE');mod.ratio=106000/raw;bpy.ops.object.modifier_apply(modifier=mod.name)
    assert sum(tris(o) for o in damage)<=110000, 'Damage triangle budget exceeded'
    export(ROOT/'panzer-iv-tank-damage.glb',damage)
    return damage,twins,scars

def optimize_healthy():
    obs=[o for o in OBJECTS.values() if o.type=='MESH' and not o.name.startswith('_')]
    raw=sum(tris(o) for o in obs);ratio=min(1,121000/raw)
    for ob in obs:
        if tris(ob)<100:continue
        select_only(ob);mod=ob.modifiers.new('Game mesh reduction','DECIMATE');mod.ratio=ratio;bpy.ops.object.modifier_apply(modifier=mod.name)
    # Number wheel nodes strictly front-to-back, including drive/idler/rollers.
    info={n:(kind,side) for n,kind,side,i in WHEEL_INFO};WHEEL_INFO.clear()
    for side in ('L','R'):
        wheels=sorted([o for o in obs if o.name.startswith('Wheel_'+side)],key=lambda o:o.location.y)
        old=[o.name for o in wheels]
        for name in old:OBJECTS.pop(name,None)
        for i,o in enumerate(wheels):o.name='_WheelTemp'+side+str(i)
        for i,(o,name) in enumerate(zip(wheels,old)):
            o.name='Wheel_'+side+str(i+1);OBJECTS[o.name]=o;WHEEL_INFO.append((o.name,info[name][0],side,i+1))
    print('OPTIMIZED_HEALTHY',sum(tris(o) for o in obs),flush=True)

def add_wear():
    # Millimetre-thin irregular chips along exposed armour plate edges.
    for name in list(PARTS):
        if 'Skirt' not in name:continue
        ob=OBJECTS[name];g=Geo('_Wear',ob.matrix_world.translation)
        coords=[ob.matrix_world@v.co for v in ob.data.vertices];xmin=min(v.x for v in coords);xmax=max(v.x for v in coords);ymin=min(v.y for v in coords);ymax=max(v.y for v in coords);zmin=min(v.z for v in coords);zmax=max(v.z for v in coords)
        if name.startswith('Part_Skirt_'):
            sx=1 if ob.location.x>0 else -1;x=(xmax+.0009) if sx>0 else xmin-.0009
            for i in range(24):
                y=random.uniform(ymin+.035,ymax-.035);z=zmin+random.uniform(.008,.060) if i<14 else zmax-random.uniform(.008,.032);length=random.uniform(.008,.053);height=random.uniform(.002,.010)
                g.add([(x,y-length,z),(x,y+length,z+.004),(x,y+length*.45,z+height),(x,y-length*.75,z+height*.55)],[(0,1,2,3)],'Steel')
            for i in range(6):
                y=random.uniform(ymin+.06,ymax-.06);z=random.uniform(zmin+.14,zmax-.14);g.pipe([(x,y,z),(x,y+.08,z+.015),(x,y+.17,z+.023)],.0017,'Steel',4)
        else:continue
        if g.f:
            q=g.build(0);join_into(ob,[q]);OBJECTS.pop('_Wear',None)
    # Weld seams and engine panel surrounds sit on continuous underlying armour.
    g=Geo('_Details')
    for s in (-1,1):
        for y in (-1.90,1.41):
            for i in range(29):g.cyl((s*(.04+i*.04),y,1.706),.008,.012,'Steel',(0,0,1),6)
        for y in (1.34,2.58):g.beam((s*.30,y,1.565),(s*1.14,y,1.565),.014,'Steel')
        for y in (1.40,2.51):
            for x in (.37,1.10):bolt(g,(s*x,y,1.585),r=.014)
    q=g.build(.001);join_into(OBJECTS['Hull'],[q]);OBJECTS.pop('_Details',None)

def world_position(o):
    p=o.matrix_world.translation;return [round(p.x,6),round(p.z,6),round(-p.y,6)]

def bounds(objects):
    p=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
    pts=[(v.x,v.z,-v.y) for v in p];lo=[min(v[i] for v in pts) for i in range(3)];hi=[max(v[i] for v in pts) for i in range(3)]
    return {'min':lo,'max':hi,'size':[hi[i]-lo[i] for i in range(3)]}

def metadata(healthy,damage,twins,scars):
    doc={'schema':'tank-damage/v1','units':'metres','axes':{'up':'+Y','forward':'+Z','left':'+X'},'bounding_box':bounds(healthy),'proofs':{'states.png':['healthy','hit: all scars and six swaps','stripped: all detachable parts hidden','immobilised: left track thrown and gun broken','burnt wreck','burnt wreck with turret blown off'],'paints.png':[p[0] for p in PAINTS],'pivots.png':'Turret yaw, gun/wheels/hatches rotated 30 degrees; barrel recoil translated 0.15 m'},'budgets':{'healthy_triangles':[80000,140000],'damage_triangles_max':110000,'file_bytes_max':12000000,'texture_edge_max':1024,'ORM_edge_max':512},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'files':{},'damage':{'parts':[{'part':n,'twin':twins.get(n)} for n in PARTS],'scars':scars,'broken':{'Track_L':'Track_L_Broken','Track_R':'Track_R_Broken','Gun':'Gun_Broken'},'wreck':['Wreck_Hull','Wreck_Turret']},'wheels':[{'node':n,'type':kind,'side':side,'order':i} for n,kind,side,i in WHEEL_INFO]}
    for file,obs in [('panzer-iv-tank.glb',healthy),('panzer-iv-tank-damage.glb',damage)]:
        doc['files'][file]={'triangles':sum(tris(o) for o in obs),'bytes':(ROOT/file).stat().st_size,'nodes':[{'name':o.name,'world_position':world_position(o),'parent':o.parent.name if o.parent else None,'type':o.type,'triangles':tris(o)} for o in obs]}
    (ROOT/'nodes.json').write_text(json.dumps(doc,indent=2))
    (ROOT/'NOTES.md').write_text('Panzer IV Ausf. H style tank; metre-scale Y-up rig; healthy and tank-damage/v1 variants, without markings.\n'+f'Triangles: healthy {sum(tris(o) for o in healthy):,}; damage {sum(tris(o) for o in damage):,}; measured GLB sizes in nodes.json.\n'+'Materials: Paint, TrackSteel, Rubber, Steel, Interior, Lamps; damage also Burnt and Scars; Paint factor supports six listed variants.\n'+'Textures: shared Cycles-baked base/normal tiles, Paint 1024 px, all others and all ORM 512 px; JPEG embedded in GLB.\n'+'Poly Haven CC0: metal_plate, rusty_metal_03, rubber_tiles; https://polyhaven.com/license ; sources fetched from dl.polyhaven.org.\n'+'Reference differences: full five-plate hull skirt sets, no roof AA MG as in sheet; illustrative battle damage and simplified hidden interior.\n')
    return doc

def load_export_pair():
    reset();bpy.ops.import_scene.gltf(filepath=str(ROOT/'panzer-iv-tank.glb'));healthy=list(bpy.context.scene.objects)
    before=set(bpy.context.scene.objects);bpy.ops.import_scene.gltf(filepath=str(ROOT/'panzer-iv-tank-damage.glb'));damage=[o for o in bpy.context.scene.objects if o not in before]
    return healthy,damage

def show_tree(o,show):
    o.hide_render=not show
    for child in o.children:show_tree(child,show)

def assemble_grid(paths,out):
    # Blender-native pixel assembly keeps the reproducible build self-contained.
    import numpy as np
    imgs=[bpy.data.images.load(str(p),check_existing=False) for p in paths];w,h=imgs[0].size;canvas=np.zeros((h*2,w*3,4),dtype=np.float32)
    for i,im in enumerate(imgs):
        arr=np.empty(w*h*4,dtype=np.float32);im.pixels.foreach_get(arr);row=1-i//3;canvas[row*h:(row+1)*h,(i%3)*w:(i%3+1)*w]=arr.reshape(h,w,4)
    outim=bpy.data.images.new(out.name,w*3,h*2,alpha=True);outim.pixels.foreach_set(canvas.ravel());outim.filepath_raw=str(out);outim.file_format='PNG';outim.save()
    for im in imgs:bpy.data.images.remove(im)

def proof():
    healthy,damage=load_export_pair();cam=setup_render();view(cam);cam.data.ortho_scale=10.2;by={o.name:o for o in healthy+damage};doc=json.loads((ROOT/'nodes.json').read_text());paths=[]
    # Fresh scene contains only imported meshes plus studio rig.
    for state in range(6):
        for o in healthy:o.hide_render=state>=4
        for o in damage:o.hide_render=True
        if state==1:
            for name in doc['damage']['scars']:by[name].hide_render=False
            swapped=['Part_Skirt_L1','Part_Skirt_L2','Part_Fender_FL','Part_Skirt_R1','Part_Turret_Skirt2','Part_Turret_Skirt8']
            for name in swapped:
                by[name].hide_render=True;by['Dmg_'+name[5:]].hide_render=False
        elif state==2:
            for o in healthy:
                if o.name.startswith('Part_'):o.hide_render=True
        elif state==3:
            by['Track_L'].hide_render=True;by['Track_L_Broken'].hide_render=False;show_tree(by['Gun'],False);by['Gun_Broken'].hide_render=False
        elif state>=4:
            by['Wreck_Hull'].hide_render=False;by['Wreck_Turret'].hide_render=False
            if state==5:
                by['Wreck_Turret'].rotation_mode='XYZ';by['Wreck_Turret'].location=(3.0,.5,.58);by['Wreck_Turret'].rotation_euler=(math.radians(8),math.radians(-82),math.radians(24));bpy.context.view_layer.update();ob=by['Wreck_Turret'];ob.location.z-=min((ob.matrix_world@v.co).z for v in ob.data.vertices)
        p=ROOT/f'_state{state+1}.png';render_file(p,cam,720,480,20);paths.append(p)
    assemble_grid(paths,ROOT/'states.png')
    for p in paths:p.unlink()
    # Recolour imported healthy glTF materials only, respecting neutral texture.
    for o in damage:o.hide_render=True
    for o in healthy:o.hide_render=False
    paint=next(m for m in bpy.data.materials if m.name=='Paint');bs=paint.node_tree.nodes.get('Principled BSDF')
    # Importer expresses baseColorFactor as a MULTIPLY node. Change its constant input.
    mults=[n for n in paint.node_tree.nodes if n.type=='MIX' or n.type=='MIX_RGB']
    def recolour(c):
        for n in mults:
            if n.type=='MIX_RGB' and n.blend_type=='MULTIPLY':
                for i in (1,2):
                    if not n.inputs[i].is_linked:n.inputs[i].default_value=(*c,1)
            elif n.type=='MIX' and n.blend_type=='MULTIPLY':
                for inp in n.inputs:
                    if inp.type=='RGBA' and not inp.is_linked:inp.default_value=(*c,1)
        if not mults:bs.inputs['Base Color'].default_value=(*c,1)
    paths=[]
    for i,(name,c) in enumerate(PAINTS):
        recolour(c);p=ROOT/f'_paint{i+1}.png';render_file(p,cam,720,480,16);paths.append(p)
    assemble_grid(paths,ROOT/'paints.png')
    for p in paths:p.unlink()
    # Pivot test: every moving assembly has a 30-degree change about its own local pivot.
    recolour(DEFAULT)
    for name in ['Turret','Gun','Barrel','Hatch_Commander','Hatch_Driver','Hatch_Loader','Hatch_Radio']+[o.name for o in healthy if o.name.startswith('Wheel_')]:
        o=by[name];o.rotation_mode='XYZ'
        if name=='Barrel':o.location.y+=.15
        elif name=='Turret':o.rotation_euler.z+=pi/6
        elif name=='Gun' or name.startswith('Hatch_'):o.rotation_euler.x-=pi/6
        else:o.rotation_euler.x+=pi/6
    render_file(ROOT/'pivots.png',cam,1280,800,20)
    print('PROOFS_COMPLETE',flush=True)

if __name__=='__main__':
    print('BUILD_REVISION final-contract-proof',flush=True)
    if '--proof' in sys.argv:proof()
    else:
        healthy=healthy_stage()
        damage,twins,scars=make_damage(healthy)
        metadata(healthy,damage,twins,scars)
        proof()
