"""M1A2-style neutral-faction game tank. Clean build: Blender -b --python-exit-code 1 --python this.py.
Authoring axes: X left, -Y forward, Z up. glTF conversion produces X left/Y up/Z forward.
Cycles OPTIX tiled material bakes. Sources CC0 https://polyhaven.com/license.
"""
import bpy, bmesh, math, os, json, random, subprocess, struct, time
from mathutils import Vector, Matrix
from math import sin, cos, pi
from pathlib import Path
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
random.seed(17)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.unit_settings.scale_length=1
sc.render.engine='CYCLES';sc.cycles.samples=24;sc.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type=='OPTIX'
sc.cycles.device='GPU'
sc.render.image_settings.file_format='PNG';sc.render.film_transparent=False
sc.view_settings.view_transform='AgX';sc.view_settings.exposure=-.55
PAINTS=[('Desert tan',[.45,.36,.22]),('Olive drab',[.17,.19,.085]),('NATO green',[.105,.155,.095]),('Field grey',[.23,.26,.235]),('Winter white',[.79,.81,.76]),('Dark green-black',[.038,.05,.034])]
MATN=['Paint','Rubber','Steel','Tracks','Glass','Lamps','Weapons']
MATS=[]

def download(asset,mp):
    f=TEX/f'{asset}_{mp}_1k.jpg'
    if not f.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mp}_1k.jpg'
        subprocess.run(['curl','-fLsS','--retry','3',url,'-o',str(f)],check=True)
    return bpy.data.images.load(str(f),check_existing=True)

def bake_material(name,asset,base,rough,metal,res=512):
    print('BAKE',name,flush=True)
    m=bpy.data.materials.new(name);m.use_nodes=True
    nt=m.node_tree;n=nt.nodes;l=nt.links;n.clear()
    out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs['Surface'])
    uv=n.new('ShaderNodeTexCoord')
    diff=n.new('ShaderNodeTexImage');diff.image=download(asset,'diff');l.new(uv.outputs['UV'],diff.inputs['Vector'])
    nor=n.new('ShaderNodeTexImage');nor.image=download(asset,'nor_gl');nor.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],nor.inputs['Vector'])
    arm=n.new('ShaderNodeTexImage');arm.image=download(asset,'arm');arm.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],arm.inputs['Vector'])
    gray=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],gray.inputs[0])
    ramp=n.new('ShaderNodeValToRGB');l.new(gray.outputs[0],ramp.inputs[0])
    # Neutral undercoat with scanned speckle and oxidized wear. Paint alone receives a tint at runtime.
    for e,factor in zip(ramp.color_ramp.elements,[.78,1.0]):e.color=tuple(min(1,c*factor) for c in base)+(1,)
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=32;noise.inputs['Detail'].default_value=4;l.new(uv.outputs['UV'],noise.inputs['Vector'])
    grime=n.new('ShaderNodeValToRGB');grime.color_ramp.elements[0].position=.22;grime.color_ramp.elements[0].color=(.38,.38,.38,1);grime.color_ramp.elements[1].position=.48
    l.new(noise.outputs['Fac'],grime.inputs[0])
    mul=n.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=.55;l.new(ramp.outputs[0],mul.inputs[1]);l.new(grime.outputs[0],mul.inputs[2])
    if name=='Paint':
        stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(22,1.8,1);l.new(uv.outputs['UV'],stretch.inputs[0])
        streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=3.;streak.inputs['Detail'].default_value=3;l.new(stretch.outputs[0],streak.inputs[0])
        sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.28;sr.color_ramp.elements[0].color=(.57,.57,.57,1);sr.color_ramp.elements[1].position=.58;l.new(streak.outputs['Fac'],sr.inputs[0])
        sm=n.new('ShaderNodeMixRGB');sm.blend_type='MULTIPLY';sm.inputs[0].default_value=.33;l.new(mul.outputs[0],sm.inputs[1]);l.new(sr.outputs[0],sm.inputs[2]);mul=sm
    l.new(mul.outputs[0],bs.inputs['Base Color'])
    sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
    rmap=n.new('ShaderNodeMapRange');rmap.inputs['To Min'].default_value=max(.1,rough-.12);rmap.inputs['To Max'].default_value=min(1,rough+.1);l.new(sep.outputs[1],rmap.inputs['Value']);l.new(rmap.outputs[0],bs.inputs['Roughness'])
    bs.inputs['Metallic'].default_value=metal
    normal=n.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.16 if name=='Paint' else .35;l.new(nor.outputs[0],normal.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.18;bump.inputs['Distance'].default_value=.0004 if name=='Paint' else .0012;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(normal.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
    bpy.ops.mesh.primitive_plane_add(size=1);plane=bpy.context.object;plane.data.materials.append(m)
    target=n.new('ShaderNodeTexImage');n.active=target
    emission=n.new('ShaderNodeEmission')
    maps={}
    def bake(channel,size,kind='EMIT',source=None):
        im=bpy.data.images.new(name+'_'+channel,width=size,height=size,alpha=False)
        if channel!='basecolor':im.colorspace_settings.name='Non-Color'
        target.image=im;n.active=target
        if kind=='EMIT':
            l.new(source,emission.inputs['Color']);l.new(emission.outputs[0],out.inputs[0])
        else:l.new(bs.outputs[0],out.inputs[0])
        sc.cycles.samples=8;sc.render.bake.margin=8
        bpy.ops.object.bake(type=kind)
        im.filepath_raw=str(TEX/(name+'_'+channel+('.jpg' if channel=='basecolor' else '.png')));im.file_format='JPEG' if channel=='basecolor' else 'PNG';im.save();maps[channel]=im
    bake('basecolor',res,source=mul.outputs[0])
    bake('normal',res,kind='NORMAL')
    comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1.;comb.inputs[2].default_value=metal;l.new(rmap.outputs[0],comb.inputs[1])
    bake('orm',512,source=comb.outputs[0])
    bpy.data.objects.remove(plane,do_unlink=True)
    # Only portable baked textures remain in the shipping shader.
    n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
    texnodes={}
    for k,im in maps.items():
        t=n.new('ShaderNodeTexImage');t.image=im;t.label='Cycles baked '+k;texnodes[k]=t
    tint=n.new('ShaderNodeMixRGB');tint.name='PaintFactor';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1
    tint.inputs[2].default_value=tuple(PAINTS[0][1] if name=='Paint' else [1,1,1])+(1,)
    l.new(texnodes['basecolor'].outputs[0],tint.inputs[1]);l.new(tint.outputs[0],bs.inputs['Base Color'])
    normal=n.new('ShaderNodeNormalMap');l.new(texnodes['normal'].outputs[0],normal.inputs[0 if False else 'Color']);l.new(normal.outputs[0],bs.inputs['Normal'])
    sep=n.new('ShaderNodeSeparateColor');l.new(texnodes['orm'].outputs[0],sep.inputs[0]);l.new(sep.outputs[1],bs.inputs['Roughness']);l.new(sep.outputs[2],bs.inputs['Metallic'])
    # Recognized exporter node group carries packed occlusion.
    g=bpy.data.node_groups.get('glTF Material Output')
    if not g:
        g=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');g.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=n.new('ShaderNodeGroup');gn.node_tree=g;l.new(sep.outputs[0],gn.inputs['Occlusion'])
    if name=='Glass':bs.inputs['Metallic'].default_value=.4
    if name=='Lamps':bs.inputs['Emission Color'].default_value=(.5,.44,.3,1);bs.inputs['Emission Strength'].default_value=.16
    MATS.append(m)

# Shared tiles, 1 metre on large surfaces. No unique low-density full-vehicle atlas.
for args in [
 ('Paint','metal_plate_02',(1,1,1),.82,0,1024),
 ('Rubber','rubberized_track',(.052,.048,.041),.92,0,512),
 ('Steel','metal_plate_02',(.24,.255,.25),.53,.78,1024),
 ('Tracks','rusty_metal_03',(.115,.10,.076),.78,.63,1024),
 ('Glass','metal_plate_02',(.015,.065,.076),.18,.4,256),
 ('Lamps','metal_plate_02',(.75,.73,.62),.26,.12,256),
 ('Weapons','metal_plate_02',(.043,.048,.045),.6,.73,512)]:bake_material(*args)

# Mesh templates are bevelled before assembly; rigid groups remain single named nodes.
GROUP={};CACHE={}
def group(name):
    if name not in GROUP:GROUP[name]={'v':[],'f':[],'m':[],'s':[],'uv':[]}
    return GROUP[name]
def template(v,f,bevel=0,smooth=False):
    bm=bmesh.new()
    vv=[bm.verts.new(co) for co in v]
    for face in f:bm.faces.new([vv[i] for i in face])
    bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
    if bevel:bmesh.ops.bevel(bm,geom=list(bm.edges),offset=bevel,segments=1,affect='EDGES',clamp_overlap=True)
    bm.verts.ensure_lookup_table();bm.verts.index_update();bm.faces.ensure_lookup_table()
    v=[tuple(x.co) for x in bm.verts];f=[tuple(x.index for x in face.verts) for face in bm.faces]
    ss=[smooth and len(face.verts)==4 for face in bm.faces]
    bm.free();return v,f,ss

def add(g,tmp,loc=(0,0,0),rot=None,mat=0,uvmode=None):
    data=group(g);off=len(data['v']);v,f,sm=tmp
    R=rot or Matrix.Identity(3);pos=Vector(loc)
    verts=[R@Vector(p)+pos for p in v];data['v'].extend(tuple(p) for p in verts)
    for face,smooth in zip(f,sm):
        data['f'].append(tuple(i+off for i in face));data['m'].append(mat);data['s'].append(smooth)
        a,b,c=[Vector(v[i]) for i in face[:3]];normal=(b-a).cross(c-a);axis=max(range(3),key=lambda k:abs(normal[k]));axes=[k for k in range(3) if k!=axis]
        if uvmode is None:
            data['uv'].append([(v[i][axes[0]]+loc[axes[0]]*.371,v[i][axes[1]]+loc[axes[1]]*.371) for i in face])
        else:data['uv'].append([uvmode(Vector(v[i]),verts[i]) for i in face])

def box(g,loc,size,mat=0,b=.007,rot=None,uvmode=None):
    key=('box',tuple(size),b)
    if key not in CACHE:
        x,y,z=[a/2 for a in size]
        CACHE[key]=template([(-x,-y,-z),(x,-y,-z),(x,y,-z),(-x,y,-z),(-x,-y,z),(x,-y,z),(x,y,z),(-x,y,z)],[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],b)
    add(g,CACHE[key],loc,rot,mat,uvmode)

def cyl(g,loc,r,depth,mat=0,axis=(0,0,1),n=32,r2=None):
    # bevelled cylinder as a lathe, cap and lip are real geometry
    r2=r if r2 is None else r2;bw=min(.005,depth*.12,r*.15)
    prof=[(-depth/2,0),(-depth/2,r-bw),(-depth/2+bw,r),(depth/2-bw,r2),(depth/2,r2-bw),(depth/2,0)]
    lathe(g,loc,prof,mat,axis,n)

def lathe(g,loc,profile,mat=0,axis=(0,0,1),n=48):
    key=('lathe',tuple(profile),n)
    if key not in CACHE:
        v=[];rings=[];f=[];smooth=[]
        for z,r in profile:
            if r==0:rings.append([len(v)]);v.append((0,0,z))
            else:
                rings.append(list(range(len(v),len(v)+n)));v.extend((r*cos(i*2*pi/n),r*sin(i*2*pi/n),z) for i in range(n))
        for A,B in zip(rings,rings[1:]):
            for i in range(n):
                if len(A)==1:face=(A[0],B[(i+1)%n],B[i]);s=False
                elif len(B)==1:face=(A[i],A[(i+1)%n],B[0]);s=False
                else:face=(A[i],A[(i+1)%n],B[(i+1)%n],B[i]);s=True
                f.append(face);smooth.append(s)
        CACHE[key]=(v,f,smooth)
    R=Vector(axis).to_track_quat('Z','Y').to_matrix()
    add(g,CACHE[key],loc,R,mat,lambda p,w:(math.atan2(p.y,p.x)/(2*pi),p.z))

def rod(g,a,b,r=.015,mat=0,n=12):
    a=Vector(a);b=Vector(b);cyl(g,(a+b)/2,r,(b-a).length,mat,b-a,n)

def poly(g,v,f,mat=0,b=.01):add(g,template(v,f,b),mat=mat)

def prism(g,points,zbottom,ztop,mat=0,b=.012):
    n=len(points);v=[(x,y,zbottom) for x,y in points]+[(x,y,ztop) for x,y in points]
    f=[tuple(reversed(range(n))),tuple(range(n,n*2))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
    poly(g,v,f,mat,b)

def sidepoly(g,x,points,thick,mat=0,b=.009):
    n=len(points);v=[(x-thick/2,y,z) for y,z in points]+[(x+thick/2,y,z) for y,z in points]
    f=[tuple(reversed(range(n))),tuple(range(n,n*2))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
    poly(g,v,f,mat,b)

def torus(g,loc,major,minor,mat=0,axis=(0,0,1),n=48,k=8):
    key=('torus',major,minor,n,k)
    if key not in CACHE:
        v=[((major+minor*cos(j*2*pi/k))*cos(i*2*pi/n),(major+minor*cos(j*2*pi/k))*sin(i*2*pi/n),minor*sin(j*2*pi/k)) for i in range(n) for j in range(k)]
        f=[(i*k+j,((i+1)%n)*k+j,((i+1)%n)*k+(j+1)%k,i*k+(j+1)%k) for i in range(n) for j in range(k)]
        CACHE[key]=(v,f,[True]*len(f))
    add(g,CACHE[key],loc,Vector(axis).to_track_quat('Z','Y').to_matrix(),mat)

def bolt(g,loc,axis=(0,0,1),r=.018,mat=2):cyl(g,loc,r,.014,mat,axis,6)
def handle(g,a,b,height=.045,mat=0):
    a=Vector(a);b=Vector(b);up=Vector((0,0,height));rod(g,a,a+up,.009,mat);rod(g,a+up,b+up,.009,mat);rod(g,b+up,b,.009,mat)

# HULL: genuine sloped glacis and lower nose, shoulders, raised rear powerpack.
sections=[(-3.965,1.37,.89,1.20),(-3.43,1.50,.43,1.30),(-2.35,1.48,.40,1.44),(2.48,1.48,.40,1.44),(3.78,1.47,.53,1.48),(3.965,1.38,.60,1.46)]
v=[]
for y,w,zlo,zhi in sections:v.extend([(-w*.83,y,zlo),(w*.83,y,zlo),(w,y,zhi),(-w,y,zhi)])
f=[(3,2,1,0),(20,21,22,23)]+[(i*4+j,i*4+(j+1)%4,(i+1)*4+(j+1)%4,(i+1)*4+j) for i in range(len(sections)-1) for j in range(4)]
poly('Hull',v,f,b=.023)
# upper fenders and six skirt segments (height leaves wheel faces readable)
for s in [-1,1]:
    box('Hull',(s*1.54,.04,1.405),(.58,7.72,.085),b=.015)
    sidepoly('Hull',s*1.785,[(-3.89,1.36),(-3.47,1.44),(-2.72,1.40),(-2.72,.78),(-3.38,.73)],.09)
    for i in range(6):
        y=-2.72+(i+.5)*1.055
        box('Hull',(s*1.78,y,1.073),(.10,1.045,.652),b=.009)
        for yy in [y-.475,y+.475]:
            box('Hull',(s*1.837,yy,1.33),(.025,.049,.16),mat=0,b=.003)
            for z in [1.28,1.38]:bolt('Hull',(s*1.855,yy,z),(s,0,0),.012)
        # lower skirt latch on alternating plates
        if i%2==0:
            box('Hull',(s*1.837,y+.39,.81),(.028,.075,.052),mat=2,b=.003)
    # forward flexible mud guard and protected headlights
    sidepoly('Hull',s*1.58,[(-3.92,1.21),(-3.97,1.07),(-3.67,1.03),(-3.56,1.23)],.49,1,.008)
    box('Hull',(s*1.22,-3.59,1.34),(.34,.18,.13),b=.018)
    box('Hull',(s*1.22,-3.685,1.343),(.24,.016,.074),1,b=.012)
    for xx in [s*1.22-.073,s*1.22+.073]:cyl('Hull',(xx,-3.70,1.347),.036,.014,5,(0,-1,0),24)
    for xx in [s*1.22-.19,s*1.22+.19]:rod('Hull',(xx,-3.77,1.30),(xx,-3.77,1.46),.018)
    rod('Hull',(s*1.22-.19,-3.77,1.46),(s*1.22+.19,-3.77,1.46),.018)
    # towing lugs and shackles
    for yy,z in [(-3.65,.85),(3.78,.78)]:
        box('Hull',(s*1.11,yy,z),(.115,.13,.17),b=.025)
        torus('Hull',(s*1.11,yy+(-.07 if yy<0 else .10),z-.07),.09,.022,2,(1,0,0),24,8)
    # suspension arms behind road wheels
    for y in [-2.48+i*.827 for i in range(7)]:rod('Hull',(s*1.28,y+.22,.75),(s*1.50,y,.49),.065,2,16)
    # glacis weld bead and cable hangers
    rod('Hull',(s*1.46,-3.22,1.327),(s*1.46,-1.25,1.456),.008,0,8)
    for y in [-2.5,-1.6,2.3,3.1]:handle('Hull',(s*1.58,y-.075,1.452),(s*1.58,y+.075,1.452),.04)
# Driver hatch and three prism sights
prism('Hull',[(-.4,-2.28),(.4,-2.28),(.46,-1.58),(-.46,-1.58)],1.443,1.485,b=.022)
handle('Hull',(-.18,-2.04,1.49),(.02,-2.04,1.49),.028)
for x in [-.27,0,.27]:
    box('Hull',(x,-1.62,1.49),(.24,.17,.09),b=.008)
    box('Hull',(x,-1.71,1.506),(.186,.013,.05),4,b=.003)
# engine deck: two gridded intake beds, hinges, louvered turbine exhaust rear
for x in [-.72,.72]:
    box('Hull',(x,2.88,1.49),(1.22,1.35,.04),2,b=.008)
    for j in range(18):box('Hull',(x,2.28+j*.072,1.518),(1.19,.028,.033),0,b=.003)
    for xx in [x-.6,x+.6]:box('Hull',(xx,2.88,1.532),(.035,1.35,.022),b=.003)
    for yy in [2.27,3.46]:box('Hull',(x,yy,1.532),(1.22,.04,.024),b=.003)
    handle('Hull',(x-.16,3.59,1.49),(x+.16,3.59,1.49),.028)
for x in [-.87,0,.87]:
    box('Hull',(x,3.956,1.01),(.77,.022,.56),2,b=.011)
    for i in range(10):box('Hull',(x,3.981,.78+i*.046),(.72,.045,.021),0,b=.003)
    for xx in [x-.395,x+.395]:box('Hull',(xx,3.99,1.01),(.027,.045,.59),b=.003)
for s in [-1,1]:
    cyl('Hull',(s*1.27,3.977,1.31),.08,.025,1,(0,1,0),24)
    cyl('Hull',(s*1.27,3.998,1.31),.051,.018,5,(0,1,0),24)
# WHEELS (front idler, seven paired road wheels, rear drive sprocket)
for s,label in [(1,'L'),(-1,'R')]:
    for idx,y in enumerate([-3.10]+[-2.48+i*.827 for i in range(7)]+[3.10],1):
        g=f'Wheel_{label}{idx}';z=.79 if idx in [1,9] else .49;r=.445 if idx in [1,9] else .405
        x=s*1.48
        # twin tire shoulders, central gap, dish, lip, hub caps
        for dx in [-.125,.125]:
            lathe(g,(x+s*dx,y,z),[(-.1025,r*.77),(-.1025,r*.96),(-.088,r),(.088,r),(.1025,r*.96),(.1025,r*.77),(-.1025,r*.77)],2 if idx==9 else 1,(s,0,0),40)
            lathe(g,(x+s*(dx+.092),y,z),[(-.012,0),(-.012,r*.84),(.003,r*.91),(.018,r*.91),(.028,r*.83),(.026,r*.69),(-.018,r*.50),(.012,r*.27),(.018,0)],2 if idx==9 else 0,(s,0,0),40)
        cyl(g,(x+s*.249,y,z),r*.21,.056,2,(s,0,0),32)
        cyl(g,(x+s*.28,y,z),r*.14,.018,0,(s,0,0),24)
        for j in range(10):
            a=j*2*pi/10;bolt(g,(x+s*.236,y+cos(a)*r*.48,z+sin(a)*r*.48),(s,0,0),.017,2)
        if idx==9:
            for j in range(16):
                a=j*2*pi/16
                box(g,(x+s*.11,y+sin(a)*r,z+cos(a)*r),(.36,.095,.095),2,b=.009,rot=Matrix.Rotation(-a,3,'X'))
        if idx in [1,9]:
            for j in range(8):
                a=j*pi/4;cyl(g,(x+s*.235,y+sin(a)*r*.65,z+cos(a)*r*.65),.045,.013,2,(s,0,0),12)
# TRACK BELTS: continuous loop of connected double-pad steel shoes.
# Capsule path parameter s maps to V; local X maps to U on every link and connector.
# Sloping lower runs wrap the higher idlers/sprockets, as on the reference.
path=[(-2.48,.060),(2.48,.060),(2.66,.095),(3.10+.5*cos(-pi/4),.79+.5*sin(-pi/4))]
for i in range(1,25):
    a=-pi/4+i*(3*pi/4)/24;path.append((3.10+.5*cos(a),.79+.5*sin(a)))
path.append((-3.10,1.29))
for i in range(1,25):
    a=pi/2+i*(3*pi/4)/24;path.append((-3.10+.5*cos(a),.79+.5*sin(a)))
path.extend([(-2.66,.095),(-2.48,.060)])
lengths=[math.hypot(b[0]-a[0],b[1]-a[1]) for a,b in zip(path,path[1:])];length=sum(lengths);N=92

def trackpoint(t):
    t%=length
    for a,b,seg in zip(path,path[1:],lengths):
        if t<=seg:
            q=t/seg;return a[0]+q*(b[0]-a[0]),a[1]+q*(b[1]-a[1]),math.atan2(b[1]-a[1],b[0]-a[0])
        t-=seg
    return path[0][0],path[0][1],0
for s,label in [(1,'L'),(-1,'R')]:
    g='Track_'+label
    for i in range(N):
        t=(i+.5)*length/N;y,z,a=trackpoint(t);rot=Matrix.Rotation(a,3,'X')
        # local +Y tangent and local -Z outward on bottom
        uv=lambda p,w,tt=t:(p.x/.64+.5,(tt+p.y)/.33)
        box(g,(s*1.48,y,z),(.625,length/N+.002,.086),3,b=.009,rot=rot,uvmode=uv)
        for dx in [-.159,.159]:
            p=Vector((s*1.48,y,z))+rot@Vector((dx,0,-.056))
            box(g,p,(.272,length/N-.032,.036),1,b=.010,rot=rot,uvmode=uv)
        for dx in [-.341,.341]:
            p=Vector((s*1.48,y,z))+rot@Vector((dx,0,0))
            box(g,p,(.064,.113,.095),3,b=.009,rot=rot,uvmode=uv)
        # inward guide tooth, visible at the curving ends
        p=Vector((s*1.48,y,z))+rot@Vector((0,0,.071))
        add(g,template([(-.025,-.04,-.035),(.025,-.04,-.035),(.025,.04,-.035),(-.025,.04,-.035),(-.015,0,.055),(.015,0,.055)],[(0,3,2,1),(0,1,5,4),(1,2,5),(2,3,4,5),(3,0,4)],.002),p,rot,2,uv)

# TURRET bearing and low angular armour. Ring axis is y=.12.
cyl('Turret',(0,.12,1.50),1.12,.105,2,n=96)
cyl('Turret',(0,.12,1.54),1.18,.045,0,n=96)
# footprint: broad cheeks; sloped upper perimeter, not stacked boxes
bottom=[(-.40,-1.94),(.40,-1.94),(1.38,-1.77),(1.52,-.65),(1.41,1.63),(1.07,2.03),(-1.07,2.03),(-1.41,1.63),(-1.52,-.65),(-1.38,-1.77)]
top=[(-.40,-1.69),(.40,-1.69),(1.09,-1.53),(1.28,-.58),(1.24,1.59),(.99,1.92),(-.99,1.92),(-1.24,1.59),(-1.28,-.58),(-1.09,-1.53)]
v=[(x,y,1.61) for x,y in bottom]+[(x,y,2.095 if y<0 else 2.07) for x,y in top]
f=[tuple(reversed(range(10))),tuple(range(10,20))]+[(i,(i+1)%10,(i+1)%10+10,i+10) for i in range(10)]
poly('Turret',v,f,b=.021)
# Weld seams along the chamfer between cheek and side armour.
for s in [-1,1]:
    rod('Turret',(s*1.385,-1.76,1.635),(s*1.10,-1.53,2.07),.007,2,8)
    rod('Turret',(s*.405,-1.938,1.635),(s*.405,-1.70,2.075),.006,0,8)
# armour cheek seams and lower edge trim
for s in [-1,1]:
    rod('Turret',(s*.52,-1.915,1.64),(s*1.34,-1.32,1.64),.013,0)
    rod('Turret',(s*1.45,-.51,1.61),(s*1.33,1.59,1.61),.016,2)
    # six-tube smoke grenade launchers on each shoulder, tilted outward/front
    box('Turret',(s*1.26,-.58,2.017),(.22,.29,.085),b=.014)
    for j in range(6):
        p=(s*(1.29+(j%3)*.058),-.64+(j//3)*.096,2.045+(j//3)*.055)
        axis=Vector((s*.45,-.30,.78)).normalized()
        cyl('Turret',p,.033,.19,0,axis,16)
        cap=Vector(p)+axis*.102;cyl('Turret',cap,.025,.008,6,axis,16)
    # side storage boxes and framing, bustle rail spaced off hull
    box('Turret',(s*1.416,.89,1.928),(.14,1.86,.34),b=.015)
    for yy in [.02,.68,1.38,1.82]:
        rod('Turret',(s*1.52,yy,1.72),(s*1.52,yy,2.135),.016)
        rod('Turret',(s*1.36,yy,1.74),(s*1.54,yy,1.74),.016)
    for zz in [1.735,1.96,2.135]:rod('Turret',(s*1.52,-.03,zz),(s*1.52,1.92,zz),.018)
    # spare cable secured along turret edge
    for j in range(2):
        points=[(s*1.45,-.4,1.66+j*.025),(s*1.47,.3,1.66+j*.025),(s*1.40,1.62,1.69+j*.025),(s*1.1,1.97,1.73+j*.025)]
        for a,b in zip(points,points[1:]):rod('Turret',a,b,.013,2,10)
    # side access plate and hinges
    box('Turret',(s*1.398,-.78,1.85),(.065,.33,.19),b=.008)
    for yy in [-.89,-.67]:bolt('Turret',(s*1.438,yy,1.88),(s,0,0),.012)
# bustle basket floor and open rear tubular rack
box('Turret',(0,2.12,1.78),(2.88,.59,.06),b=.015)
for x in [-1.43,-.95,-.48,0,.48,.95,1.43]:
    rod('Turret',(x,2.38,1.78),(x,2.38,2.15),.016)
    rod('Turret',(x,1.91,1.82),(x,2.38,1.82),.013)
for zz in [1.82,1.98,2.15]:rod('Turret',(-1.43,2.38,zz),(1.43,2.38,zz),.018)
for s in [-1,1]:
    for zz in [1.82,1.98,2.15]:rod('Turret',(s*1.43,1.85,zz),(s*1.43,2.38,zz),.018)
# basket mesh rods coarse real lattice, no alpha cards
for j in range(27):rod('Turret',(-1.38+j*.106,1.94,1.821),(-1.38+j*.106,2.35,1.821),.005,2,6)
# rear bustle boxes, roof blowout panels, fasteners
for x in [-.65,.65]:
    box('Turret',(x,1.15,2.097),(1.15,1.08,.035),b=.010)
    for xx in [x-.50,x+.50]:
        for yy in [.68,1.60]:bolt('Turret',(xx,yy,2.123),r=.012)
    handle('Turret',(x-.10,1.52,2.12),(x+.10,1.52,2.12),.026)
# Cupola with multiple framed observation blocks
cyl('Turret',(-.61,.18,2.14),.385,.10,0,n=64)
for i in range(8):
    a=i*2*pi/8
    p=(-.61+.354*cos(a),.18+.354*sin(a),2.198)
    box('Turret',p,(.18,.076,.095),0,b=.008,rot=Matrix.Rotation(a-pi/2,3,'Z'))
    p=(-.61+.396*cos(a),.18+.396*sin(a),2.203)
    box('Turret',p,(.13,.012,.049),4,b=.003,rot=Matrix.Rotation(a-pi/2,3,'Z'))
cyl('Hatch_Commander',(-.61,.18,2.258),.315,.047,0,n=64)
torus('Hatch_Commander',(-.61,.18,2.28),.27,.009,2,n=48,k=6)
handle('Hatch_Commander',(-.72,.14,2.287),(-.49,.14,2.287),.033)
rod('Turret',(-.93,.46,2.252),(-.35,.46,2.252),.025,0,20)
# Loader hatch
cyl('Turret',(.63,.47,2.12),.31,.065,0,n=48)
cyl('Turret',(.63,.47,2.16),.27,.035,0,n=48)
handle('Turret',(.52,.48,2.184),(.75,.48,2.184),.036)
# Commander independent thermal viewer and gunner primary sight armored housing
cyl('Turret',(.60,-.50,2.16),.185,.16,0,n=40)
box('Turret',(.60,-.50,2.265),(.30,.28,.13),b=.028)
box('Turret',(.60,-.651,2.27),(.20,.018,.083),4,b=.007)
box('Turret',(-.57,-1.05,2.139),(.43,.30,.13),b=.008)
box('Turret',(-.57,-1.206,2.149),(.33,.017,.082),6,b=.004)
for x in [-.67,-.49]:box('Turret',(x,-1.218,2.153),(.132,.012,.061),4,b=.004)
# Low antenna bases/stowed antenna whips to respect exact transport height
for s in [-1,1]:
    cyl('Turret',(s*1.02,1.64,2.15),.055,.13,1,n=20)
    cyl('Turret',(s*1.02,1.64,2.29),.011,.20,2,n=12)
# Remote weapon station supported cradle, ammo box, receiver and long barrel
cyl('Turret',(-.57,.39,2.315),.115,.065,0,n=32)
box('MG',(-.57,.39,2.374),(.17,.21,.11),0,b=.015)
box('MG',(-.57,.20,2.404),(.095,.51,.072),6,b=.007)
box('MG',(-.77,.36,2.382),(.24,.27,.107),0,b=.009)
rod('MG',(-.57,-.07,2.411),(-.57,-.63,2.411),.019,6,24)
cyl('MG',(-.57,-.17,2.411),.031,.15,6,(0,-1,0),24)
cyl('MG',(-.57,-.64,2.411),.025,.05,6,(0,-1,0),24)
for j in range(6):
    cyl('MG',(-.57,-.125-j*.021,2.435),.008,.006,2,(0,0,1),8)
# GUN: trunnion mantlet + thermal sleeves and bore evacuator, truly hollow muzzle
box('Gun',(0,-1.77,1.826),(.70,.36,.38),0,b=.037)
cyl('Gun',(0,-1.972,1.826),.212,.20,0,(0,-1,0),64)
# lathe along forward -Y; axial positions relative to trunnion
profile=[(0,.165),(.10,.173),(.50,.157),(.55,.160),(.61,.160),(.66,.148),(1.15,.144),(1.20,.182),(1.28,.189),(1.83,.189),(1.93,.15),(2.03,.144),(3.54,.105),(3.60,.111),(3.70,.111),(3.725,.102),(3.725,.060),(3.40,.060)]
lathe('Gun',(0,-2.080,1.826),profile,0,(0,-1,0),64)
# internal bore dark cylinder, recessed; outer steel muzzle collar
lathe('Gun',(0,-5.805,1.826),[(0,.102),(-.055,.109),(-.13,.109),(-.13,.062),(0,.062)],2,(0,-1,0),64)
lathe('Gun',(0,-5.805,1.826),[(-.005,.071),(-.36,.071),(-.36,0)],6,(0,-1,0),64)
for y,r in [(-2.64,.162),(-3.22,.15),(-4.02,.16),(-4.14,.15),(-5.60,.113)]:
    torus('Gun',(0,y,1.826),r,.009,2,(0,1,0),64,6)
box('Gun',(0,-5.64,1.954),(.095,.13,.025),6,b=.004)
box('Gun',(0,-5.64,1.981),(.060,.08,.028),2,b=.004)
# Coaxial opening and armoured flap next to main mantlet
cyl('Turret',(.48,-1.842,1.915),.038,.025,6,(0,-1,0),24)
box('Turret',(-.39,-1.53,2.055),(.19,.21,.037),0,b=.004)

# Real optics framing, trim and mounting screws.
for z,r in [(2.098,.22),(2.138,.20),(2.222,.174)]:
    torus('Turret',(.60,-.50,z),r,.012,0,n=40,k=6)
for xx in [.485,.715]:
    box('Turret',(xx,-.668,2.27),(.018,.035,.117),0,b=.003)
    bolt('Turret',(xx,-.687,2.303),(0,-1,0),.009)
for zz in [2.212,2.328]:box('Turret',(.60,-.670,zz),(.25,.046,.020),0,b=.004)
box('Turret',(.60,-.686,2.27),(.196,.008,.076),6,b=.004)
for xx,rr in [(.55,.031),(.646,.020)]:
    cyl('Turret',(xx,-.695,2.27),rr,.01,4,(0,-1,0),24)
for xx in [-.76,-.38]:
    box('Turret',(xx,-1.222,2.153),(.025,.051,.108),0,b=.003)
for zz in [2.095,2.207]:box('Turret',(-.57,-1.223,zz),(.41,.052,.020),0,b=.003)
# Armoured protective discharger backplates with framed circular tube mouths.
for side in [-1,1]:
    axis=Vector((side*.45,-.30,.78)).normalized();rot=axis.to_track_quat('Z','Y').to_matrix()
    pos=Vector((side*1.33,-.57,2.077))
    box('Turret',pos,(.245,.215,.045),0,b=.012,rot=rot)
    for j in range(6):
        p=Vector((side*(1.29+(j%3)*.058),-.64+(j//3)*.096,2.045+(j//3)*.055))+axis*.105
        torus('Turret',p,.029,.006,2,axis,16,6)
# RWS yoke, feed box strapping, ejection port and charging handle.
for x in [-.65,-.49]:
    rod('MG',(x,.36,2.33),(x,.22,2.408),.018,0,12)
    cyl('MG',(x,.21,2.40),.029,.03,2,(1,0,0),16)
for yy in [.26,.46]:box('MG',(-.77,yy,2.385),(.246,.018,.11),2,b=.002)
box('MG',(-.622,.15,2.405),(.008,.10,.026),2,b=.002)
rod('MG',(-.63,.12,2.396),(-.69,.12,2.396),.010,6,10)
for j in range(7):
    cyl('MG',(-.68,.24+j*.017,2.426),.008,.052,2,(1,0,0),8)
# Open basket lattice and bracket gussets: structure, not a floating railing.
for x in [-1.42,1.42]:
    for yy in [.0,.70,1.40]:
        rod('Turret',(x,yy,1.74),(x*.88,yy,1.91),.017,0,10)
for i in range(23):
    x=-1.36+i*.12
    rod('Turret',(x,2.386,1.83),(min(1.42,x+.28),2.386,2.13),.0045,2,6)
    rod('Turret',(x,2.386,2.13),(min(1.42,x+.28),2.386,1.83),.0045,2,6)
# Restrained exposed-steel chipping at skirt lower lips and high-contact fasteners.
for side in [-1,1]:
    for i in range(43):
        y=random.uniform(-2.64,3.54);z=random.choice([.758,1.382])+random.uniform(-.008,.008)
        box('Hull',(side*1.832,y,z),(.0015,random.uniform(.008,.035),random.uniform(.002,.007)),2,b=0)
# Front glacis anti-slip access pads, carrying neutral paint and close-scale texture.
for side in [-1,1]:
    box('Hull',(side*.86,-2.83,1.382),(.48,.70,.006),0,b=.001,rot=Matrix.Rotation(math.atan(.14/1.08),3,'X'))

# Build rigid mesh nodes, apply all transforms, preserve intended articulation origins.
OBJECTS={}
pivots={'Hull':(0,0,0),'Turret':(0,.12,1.50),'Gun':(0,-1.77,1.826),'Hatch_Commander':(-.61,.46,2.252),'MG':(-.57,.39,2.315),'Track_L':(0,0,0),'Track_R':(0,0,0)}
for s,label in [(1,'L'),(-1,'R')]:
    for i,y in enumerate([-3.10]+[-2.48+j*.827 for j in range(7)]+[3.10],1):pivots[f'Wheel_{label}{i}']=(s*1.48,y,.79 if i in [1,9] else .49)
for name,d in GROUP.items():
    origin=Vector(pivots[name]);mesh=bpy.data.meshes.new(name+'_geometry');mesh.from_pydata([Vector(v)-origin for v in d['v']],[],d['f']);mesh.update()
    ob=bpy.data.objects.new(name,mesh);sc.collection.objects.link(ob);ob.location=origin
    for m in MATS:mesh.materials.append(m)
    uv=mesh.uv_layers.new(name='UV0')
    for p,ma,sm,coords in zip(mesh.polygons,d['m'],d['s'],d['uv']):
        p.material_index=ma;p.use_smooth=sm
        for li,co in zip(p.loop_indices,coords):uv.data[li].uv=co
    # Remove collapsed bevel corners and explicitly triangulate for stable runtime topology.
    bm=bmesh.new();bm.from_mesh(mesh)
    bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=0.000001)
    bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=0.000001)
    bmesh.ops.triangulate(bm,faces=list(bm.faces))
    bad=[f for f in bm.faces if f.calc_area()<1e-11]
    if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
    bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(mesh);bm.free()
    if name.startswith(('Track_','Wheel_')):
        ob.location.x*=.97
        for v in mesh.vertices:v.co.x*=.97
    OBJECTS[name]=ob
# all top surfaces/pads contact through positive overlaps, lowest track surface normalized to 0
# Track dimensions define x limits +-1.853; side skirts will be compressed to exact 3.66 width.
allverts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
# dependency graph refresh before world bounds
bpy.context.view_layer.update()
minz=min((o.matrix_world@v.co).z for o in OBJECTS.values() for v in o.data.vertices)
maxz=max((o.matrix_world@v.co).z for o in OBJECTS.values() for v in o.data.vertices)
maxx=max(abs((o.matrix_world@v.co).x) for o in OBJECTS.values() for v in o.data.vertices)
scale_x=1.83/maxx;scale_z=2.44/(maxz-minz)
def convert_point(p):
    x,y,z=p
    if y>3.6:y=3.6+(y-3.6)*(3.965-3.6)/(4.0125-3.6)
    return Vector((x*scale_x,y,(z-minz)*scale_z))
for ob in OBJECTS.values():
    old=ob.location.copy();new=convert_point(old)
    for v in ob.data.vertices:v.co=convert_point(old+v.co)-new
    ob.location=new
    if ob.name in ['Hull','Track_L','Track_R']:
        for v in ob.data.vertices:v.co+=ob.location
        ob.location=(0,0,0)
# Named empty sockets in Blender coordinates; converted on export.
def parent_keep(ob,parent):
    w=ob.matrix_world.copy();ob.parent=parent;ob.matrix_world=w
bpy.context.view_layer.update()
for n in ['Gun','Hatch_Commander','MG']:parent_keep(OBJECTS[n],OBJECTS['Turret'])
SOCKETS={}
for n,p,parent in [('Muzzle',(0,-5.805,1.826),'Gun'),('Seat_Commander',(-.61,.18,2.30),'Turret'),('Seat_Driver',(0,-1.89,1.49),'Hull'),('Exhaust',(0,3.99,1.04),'Hull'),('Light_Head_L',(1.22,-3.70,1.347),'Hull'),('Light_Head_R',(-1.22,-3.70,1.347),'Hull')]:
    e=bpy.data.objects.new(n,None);sc.collection.objects.link(e);e.location=convert_point(p);bpy.context.view_layer.update();parent_keep(e,OBJECTS[parent]);SOCKETS[n]=e
bpy.context.view_layer.update()
assets=list(OBJECTS.values())+list(SOCKETS.values())

def export():
    bpy.ops.object.select_all(action='DESELECT')
    for ob in assets:ob.select_set(True)
    bpy.ops.export_scene.gltf(filepath=str(ROOT/'abrams-tank.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=True)
    # Enforce exact baseColorFactor/neutral texture contract independent of exporter MixRGB heuristics.
    p=ROOT/'abrams-tank.glb';raw=p.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];j=json.loads(raw[20:20+jl]);binpart=raw[20+jl:]
    for m in j['materials']:
        if m.get('name')=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1.0]
    jt=json.dumps(j,separators=(',',':')).encode();jt+=b' '*((-len(jt))%4)
    p.write_bytes(struct.pack('<4sII',b'glTF',2,12+8+len(jt)+len(binpart))+struct.pack('<I4s',len(jt),b'JSON')+jt+binpart)
    return j

for side in ['L','R']:
    master=OBJECTS[f'Wheel_{side}2'].data
    for i in range(3,9):OBJECTS[f'Wheel_{side}{i}'].data=master

j=export()
# Measurements use exported glTF orientation, metres.
def gamepos(v):return [round(v.x,6),round(v.z,6),round(-v.y,6)]
pts=[o.matrix_world@v.co for o in OBJECTS.values() for v in o.data.vertices]
lo=[min(gamepos(v)[i] for v in pts) for i in range(3)];hi=[max(gamepos(v)[i] for v in pts) for i in range(3)]
tris=sum(len(p.vertices)-2 for o in OBJECTS.values() for p in o.data.polygons)
manifest={'asset':'abrams-tank','units':'metres','axes':{'forward':'+Z','up':'+Y','left':'+X'},'bbox':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'triangles':tris,'materials':MATN,'nodes':[{'name':o.name,'world_position':gamepos(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':'mesh' if o.type=='MESH' else 'empty','axis':('X' if o.name.startswith('Wheel') or o.name in ['Gun','Hatch_Commander'] else 'Y' if o.name=='Turret' else None)} for o in assets],'paints':[{'name':n,'linear_rgb':rgb} for n,rgb in PAINTS],'track_uv':'U crosses belt; V follows continuous path in metres / 0.33; 92 modeled shoes per belt','texture_bake':{'engine':'Cycles','device':'OPTIX','samples':8,'method':'shared tiled basecolor emission, tangent normal, packed ORM emission; CC0 scan plus procedural grime','material_tile_metres':1.0},'wheel_roles':{'1':'front idler','2-8':'road wheels front to rear','9':'rear drive sprocket'}}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
print('ASSET_EXPORTED',tris,'triangles',os.path.getsize(ROOT/'abrams-tank.glb'),'bytes','BOUNDS',manifest['bbox'],flush=True)
(ROOT/'NOTES.md').write_text('\n'.join([
'M1A2 Abrams-style neutral-faction tank; articulated game nodes, hollow 120 mm barrel, modeled tracks and bustle rack.',
f'{tris:,} triangles; 25 mesh nodes plus 6 sockets; dimensions in nodes.json.',
'Materials: Paint, Rubber, Steel, Tracks, Glass, Lamps, Weapons; Paint neutral base texture × linear desert tan [0.45, 0.36, 0.22].',
'Textures: base/normal 1024, 512 or 256 px; shared packed ORM 512 px; Cycles OPTIX 8-sample tile bakes.',
'Poly Haven CC0 IDs: metal_plate_02, rusty_metal_03, rubberized_track; https://polyhaven.com/license ; diffuse/nor_gl/arm sources.',
'Differences: lowered roof fittings and short stowed antennas for height budget; generic fine fittings; no reference markings.'
])+'\n')
# Presentation only; excluded from the selected GLB.
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));ground=bpy.context.object;ground.name='Studio_floor'
groundmat=bpy.data.materials.new('Studio');groundmat.diffuse_color=(.22,.245,.27,1);groundmat.use_nodes=True;groundmat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.22,.245,.27,1);groundmat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.87;ground.data.materials.append(groundmat)
sc.world=bpy.data.worlds.new('Soft daylight');sc.world.use_nodes=True;sc.world.node_tree.nodes.get('Background').inputs[0].default_value=(.67,.76,.90,1);sc.world.node_tree.nodes.get('Background').inputs[1].default_value=.5
for name,loc,energy,size in [('Key', (1,-5,9),2200,7),('Fill',(-6,-2,5),1200,6),('Rim',(2,6,7),2400,5)]:
    ld=bpy.data.lights.new(name,'AREA');ld.energy=energy;ld.shape='DISK';ld.size=size;o=bpy.data.objects.new(name,ld);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,1))-o.location).to_track_quat('-Z','Y').to_euler()
camd=bpy.data.cameras.new('Presentation');cam=bpy.data.objects.new('Presentation',camd);sc.collection.objects.link(cam);sc.camera=cam
camd.type='ORTHO';camd.ortho_scale=11.8
TARGET=Vector((0,-.5,1.05))
def view(loc,scale):
    cam.location=loc;cam.rotation_euler=(TARGET-cam.location).to_track_quat('-Z','Y').to_euler();camd.ortho_scale=scale
view((10,-14,5.8),11.8)
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
sc.cycles.samples=32
sc.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
# Save scene for diagnostic iteration; standalone script still rebuilds everything.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'abrams-work.blend'))
clay=bpy.data.materials.new('Clay');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.36,.36,.36,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.76
sc.view_layers[0].material_override=clay;sc.render.filepath=str(ROOT/'clay.png');sc.cycles.samples=16;bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
TARGET=Vector((0,-.75,1.05));view((14,-.75,1.05),11.1);sc.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
# Six matched views, grid composited from actual render pixels inside Blender.
TARGET=Vector((0,-.5,1.05));view((10,-14,5.8),11.8)
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
grid=np.ones((720,1920,4),dtype=np.float32)
for i,(name,color) in enumerate(PAINTS):
    MATS[0].node_tree.nodes['PaintFactor'].inputs[2].default_value=tuple(color)+(1,)
    sc.render.filepath=str(ROOT/f'paint_{i}.png');bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(sc.render.filepath);arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4)
    row=1-i//3;col=i%3;grid[row*360:(row+1)*360,col*640:(col+1)*640]=arr
    bpy.data.images.remove(im)
MATS[0].node_tree.nodes['PaintFactor'].inputs[2].default_value=tuple(PAINTS[0][1])+(1,)
im=bpy.data.images.new('Paint variants',width=1920,height=720);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
print('COMPLETE',flush=True)
