"""IS-3 style heavy tank. Blender 5.2; clean scene -> CC0 tiled Cycles bakes -> GLB and QA renders.
Author coordinates X left, -Y forward, Z up; glTF +X left,+Y up,+Z forward.
Run: blender -b --python-exit-code 1 --python build_is3-heavy-tank.py
"""
import bpy, bmesh, math, os, json, urllib.request, subprocess, struct, random, sys
import numpy as np
from mathutils import Vector, Matrix
from math import sin, cos, pi
ROOT=os.path.dirname(os.path.abspath(__file__))
TEX=os.path.join(ROOT,'textures');os.makedirs(TEX,exist_ok=True)
random.seed(33)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES';sc.cycles.samples=24
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.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG';sc.render.film_transparent=False
sc.view_settings.view_transform='AgX'
MATS=[];paint_factor=None
PAINTS=[('Olive drab',[.13,.15,.08]),('NATO green',[.075,.115,.065]),('Desert tan',[.48,.36,.20]),('Field grey',[.18,.205,.18]),('Winter white',[.72,.74,.69]),('Dark green-black',[.035,.049,.031])]

def image_source(asset,kind):
    p=os.path.join(TEX,asset+'_'+kind+'_1k.jpg')
    if not os.path.exists(p):
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
        subprocess.run(['curl','-f','-L','--retry','3','-s',url,'-o',p],check=True)
    im=bpy.data.images.load(p,check_existing=True)
    if kind!='diff':im.colorspace_settings.name='Non-Color'
    return im

def bake_mat(name,asset,color,metal,rough,normal_strength):
    global paint_factor
    mat=bpy.data.materials.new(name);mat.use_nodes=True
    nt=mat.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=image_source(asset,'diff');l.new(uv.outputs['UV'],diff.inputs['Vector'])
    arm=n.new('ShaderNodeTexImage');arm.image=image_source(asset,'arm');l.new(uv.outputs['UV'],arm.inputs['Vector'])
    nor=n.new('ShaderNodeTexImage');nor.image=image_source(asset,'nor_gl');l.new(uv.outputs['UV'],nor.inputs['Vector'])
    bw=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],bw.inputs[0])
    ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.07;ramp.color_ramp.elements[1].position=.8
    if name=='Paint':
        ramp.color_ramp.elements[0].position=.008;ramp.color_ramp.elements[1].position=.16
        cols=((.25,.25,.25,1),(.96,.96,.96,1))
    else:cols=(tuple(c*.50 for c in color)+(1,),tuple(c*1.25 for c in color)+(1,))
    for e,c in zip(ramp.color_ramp.elements,cols):e.color=c
    if name=='Paint':
        mid=ramp.color_ramp.elements.new(.03);mid.color=(.80,.80,.80,1)
    l.new(bw.outputs[0],ramp.inputs[0])
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=230 if name=='Paint' else 75;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
    mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.19;l.new(ramp.outputs[0],mix.inputs[1]);l.new(noise.outputs['Fac'],mix.inputs[2]);l.new(mix.outputs[0],bs.inputs['Base Color'])
    if name=='Paint':
        # Neutral baked rain streaks and ingrained grime remain recolourable.
        stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(1,1,1);l.new(uv.outputs['UV'],stretch.inputs[0])
        streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=5;streak.inputs['Detail'].default_value=3;l.new(stretch.outputs[0],streak.inputs['Vector'])
        sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.45;sr.color_ramp.elements[0].color=(1,1,1,1);sr.color_ramp.elements[1].position=.72;sr.color_ramp.elements[1].color=(.48,.48,.48,1);l.new(streak.outputs['Fac'],sr.inputs[0])
        weather=n.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=.4;l.new(mix.outputs[0],weather.inputs[1]);l.new(sr.outputs[0],weather.inputs[2]);l.new(weather.outputs[0],bs.inputs['Base Color']);mix=weather
    nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=normal_strength;l.new(nor.outputs[0],nm.inputs['Color'])
    bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.55 if name=='Paint' else .12;bump.inputs['Distance'].default_value=.008 if name=='Paint' else .003;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
    sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
    rm=n.new('ShaderNodeMapRange');rm.inputs['To Min'].default_value=rough-.09;rm.inputs['To Max'].default_value=min(.98,rough+.08);l.new(sep.outputs[1],rm.inputs['Value']);l.new(rm.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
    bpy.ops.mesh.primitive_plane_add(size=2);plane=bpy.context.object;plane.data.materials.append(mat)
    target=n.new('ShaderNodeTexImage');em=n.new('ShaderNodeEmission')
    baked={}
    for kind,res in [('BaseColor',1024 if name in {'Paint','Steel','Tracks'} else 256),('Normal',512 if name in {'Paint','Steel','Tracks'} else 256),('ORM',512 if name in {'Paint','Steel','Tracks'} else 128)]:
        im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
        if kind!='BaseColor':im.colorspace_settings.name='Non-Color'
        target.image=im;n.active=target
        sc.cycles.samples=8;sc.render.bake.margin=8;sc.render.bake.use_clear=True
        if kind=='Normal':
            l.new(bs.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
        else:
            l.new(em.outputs[0],out.inputs['Surface'])
            if kind=='BaseColor':l.new(mix.outputs[0],em.inputs['Color'])
            else:
                comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1.;l.new(rm.outputs[0],comb.inputs[1]);comb.inputs[2].default_value=metal;l.new(comb.outputs[0],em.inputs['Color'])
            bpy.ops.object.bake(type='EMIT')
        im.filepath_raw=os.path.join(TEX,name+'_'+kind+('.jpg' if kind=='BaseColor' else '.png'));im.file_format='JPEG' if kind=='BaseColor' else 'PNG';im.save();baked[kind]=im
    bpy.data.objects.remove(plane,do_unlink=True)
    n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0]);bs.inputs['Roughness'].default_value=rough
    tx=n.new('ShaderNodeTexImage');tx.image=baked['BaseColor'];l.new(tx.outputs[0],bs.inputs['Base Color'])
    if name=='Paint':
        paint_factor=n.new('ShaderNodeMixRGB');paint_factor.blend_type='MULTIPLY';paint_factor.inputs[0].default_value=1;paint_factor.inputs[2].default_value=(*PAINTS[0][1],1);l.new(tx.outputs[0],paint_factor.inputs[1]);l.new(paint_factor.outputs[0],bs.inputs['Base Color'])
    nn=n.new('ShaderNodeTexImage');nn.image=baked['Normal'];nn.image.colorspace_settings.name='Non-Color';normal=n.new('ShaderNodeNormalMap');l.new(nn.outputs[0],normal.inputs['Color']);l.new(normal.outputs[0],bs.inputs['Normal'])
    orm=n.new('ShaderNodeTexImage');orm.image=baked['ORM'];orm.image.colorspace_settings.name='Non-Color';sp=n.new('ShaderNodeSeparateColor');l.new(orm.outputs[0],sp.inputs[0]);l.new(sp.outputs[1],bs.inputs['Roughness']);l.new(sp.outputs[2],bs.inputs['Metallic'])
    # Exporter-recognised occlusion socket.
    group=bpy.data.node_groups.get('glTF Material Output')
    if not group:
        group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    gn=n.new('ShaderNodeGroup');gn.node_tree=group;l.new(sp.outputs[0],gn.inputs['Occlusion'])
    if name=='Lamps':bs.inputs['Emission Color'].default_value=(.32,.27,.17,1);bs.inputs['Emission Strength'].default_value=.2
    MATS.append(mat);print('BAKED',name,flush=True)

# Shared tile bakes, retaining 1k texel density over large plates.
for args in [('Paint','green_metal_rust',(.8,.8,.8),.12,.72,.32),('Steel','rusty_metal_03',(.17,.16,.14),.78,.57,.5),('Tracks','rusty_metal_03',(.17,.13,.085),.68,.82,.65),('Rubber','metal_plate',(.028,.031,.029),0,.91,.12),('Glass','metal_plate',(.035,.085,.10),.4,.25,.035),('Lamps','metal_plate',(.62,.59,.45),.25,.3,.03),('Weapon','green_metal_rust',(.14,.16,.10),.5,.65,.12),('DryMud','brown_mud_dry',(.145,.12,.083),0,.94,.55)]:bake_mat(*args)
P,S,T,R,G,L,W,D=range(8)

class Mesh:
    def __init__(self,name):self.name=name;self.v=[];self.f=[];self.mi=[];self.sm=[];self.uv=[]
    def face(self,ids,mat,smooth=False,uv=None):
        self.f.append(ids);self.mi.append(mat);self.sm.append(smooth)
        if uv is None:
            a,b,c=[Vector(self.v[i]) for i in ids[:3]];normal=(b-a).cross(c-a);axis=max(range(3),key=lambda i:abs(normal[i]));axes=[i for i in range(3) if i!=axis]
            uv=[(self.v[i][axes[0]]*.8,self.v[i][axes[1]]*.8) for i in ids]
        self.uv.append(uv)
    def box(self,c,size,mat=P,rot=None):
        base=len(self.v);c=Vector(c)
        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)]:
            v=Vector((x*size[0]/2,y*size[1]/2,z*size[2]/2));self.v.append(tuple(c+(rot@v if rot else v)))
        for f in [(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)]:self.face([base+i for i in f],mat)
    def cyl(self,a,b,r,mat=P,n=32,r2=None,cap=True):
        a,b=Vector(a),Vector(b);axis=(b-a).normalized();u=axis.cross(Vector((0,0,1)))
        if u.length<.01:u=axis.cross(Vector((0,1,0)))
        u.normalize();v=axis.cross(u);base=len(self.v);r2=r if r2 is None else r2
        for c,rad in [(a,r),(b,r2)]:
            for i in range(n):self.v.append(tuple(c+rad*(cos(2*pi*i/n)*u+sin(2*pi*i/n)*v)))
        for i in range(n):j=(i+1)%n;self.face([base+i,base+j,base+n+j,base+n+i],mat,True,[(i/n,0),((i+1)/n,0),((i+1)/n,(b-a).length), (i/n,(b-a).length)])
        if cap:self.face([base+i for i in reversed(range(n))],mat);self.face([base+n+i for i in range(n)],mat)
    def ring(self,c,axis,r,t,mat=P,n=48,k=8):
        axis=Vector(axis).normalized();u=axis.cross(Vector((0,0,1)))
        if u.length<.01:u=axis.cross(Vector((0,1,0)))
        u.normalize();v=axis.cross(u);c=Vector(c);base=len(self.v)
        for i in range(n):
            radial=cos(2*pi*i/n)*u+sin(2*pi*i/n)*v
            for j in range(k):self.v.append(tuple(c+(r+t*cos(2*pi*j/k))*radial+t*sin(2*pi*j/k)*axis))
        for i in range(n):
            for j in range(k):self.face([base+i*k+j,base+((i+1)%n)*k+j,base+((i+1)%n)*k+(j+1)%k,base+i*k+(j+1)%k],mat,True)
    def tube(self,pts,r,mat=S,n=8):
        for a,b in zip(pts,pts[1:]):self.cyl(a,b,r,mat,n)
    def mesh(self,verts,faces,mat=P,smooth=False):
        base=len(self.v);self.v.extend(verts)
        for f in faces:self.face([base+i for i in f],mat,smooth)
    def obj(self,origin=(0,0,0),bevel=.005):
        me=bpy.data.meshes.new(self.name);me.from_pydata(self.v,[],self.f);me.update();o=bpy.data.objects.new(self.name,me);sc.collection.objects.link(o)
        for m in MATS:me.materials.append(m)
        layer=me.uv_layers.new(name='UVMap')
        for poly,mat,smooth,uv in zip(me.polygons,self.mi,self.sm,self.uv):
            poly.material_index=mat;poly.use_smooth=smooth
            for li,co in zip(poly.loop_indices,uv):layer.data[li].uv=co
        # Recalculate consistent exterior winding before bevel/export.
        bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
        bpy.context.view_layer.objects.active=o;o.select_set(True)
        if bevel:
            mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=bevel;mod.segments=1;mod.limit_method='ANGLE';mod.angle_limit=.65
            bpy.ops.object.modifier_apply(modifier=mod.name)
        o.data.transform(Matrix.Translation(-Vector(origin)));o.location=origin;o.select_set(False)
        return o

def bolt(m,p,axis=(0,0,1),r=.014,mat=S):m.cyl(Vector(p),Vector(p)+Vector(axis)*.012,r,mat,6)
def lathe(m,profiles,center=(0,0),mat=P,n=96):
    base=len(m.v)
    for rx,ry,z in profiles:
        for i in range(n):
            a=2*pi*i/n;shape=1+.012*sin(a*3)+.006*cos(a*7)
            m.v.append((center[0]+rx*cos(a)*shape,center[1]+ry*sin(a)*shape,z))
    for j in range(len(profiles)-1):
        for i in range(n):
            k=(i+1)%n
            m.face([base+j*n+i,base+j*n+k,base+(j+1)*n+k,base+(j+1)*n+i],mat,True,[(i/n*6.0,profiles[j][2]*.8),((i+1)/n*6.0,profiles[j][2]*.8),((i+1)/n*6.0,profiles[j+1][2]*.8),(i/n*6.0,profiles[j+1][2]*.8)])
    m.face([base+i for i in reversed(range(n))],mat);m.face([base+(len(profiles)-1)*n+i for i in range(n)],mat)

h=Mesh('Hull')
# Lower armoured tub and pointed pike: two sloping compound glacis plates meet at the nose.
v=[(-.96,-2.82,.37),(.96,-2.82,.37),(1.06,2.9,.43),(-1.06,2.9,.43),(-1.16,-2.64,1.25),(1.16,-2.64,1.25),(1.18,3.10,1.36),(-1.18,3.10,1.36)]
h.mesh(v,[(0,3,2,1),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7),(4,5,6,7)])
v=[(0,-3.35,.80),(-1.16,-2.88,.72),(1.16,-2.88,.72),(0,-2.20,1.57),(-1.12,-2.0,1.57),(1.12,-2.0,1.57),(-1.35,-2.84,1.07),(1.35,-2.84,1.07),(-1.30,2.8,1.44),(1.30,2.8,1.44),(-1.13,3.35,1.12),(1.13,3.35,1.12)]
h.mesh(v,[(0,6,4,3),(0,3,5,7),(0,1,6),(0,7,2),(1,10,8,4,6),(2,7,5,9,11),(4,8,9,5,3),(8,10,11,9),(10,1,0,2,11)])
# Pike welded seams, discreet weld bead at the ridge.
h.tube([(0,-3.33,.81),(0,-2.20,1.577)],.008,P)
# Track fenders with shaped turned-down noses; top leaves suspension open.
for side in [-1,1]:
    x=side*1.29
    ys=[(-3.33,1.05),(-3.18,1.27),(-2.85,1.37),(-2.30,1.40),(2.65,1.40),(3.16,1.26),(3.35,1.06)]
    verts=[]
    for y,z in ys:verts.extend([(side*.995,y,z),(side*1.575,y,z),(side*.995,y,z-.045),(side*1.575,y,z-.045)])
    fs=[]
    for i in range(len(ys)-1):
        a=i*4;b=a+4;fs.extend([(a,a+1,b+1,b),(a+2,b+2,b+3,a+3),(a,b,b+2,a+2),(a+1,a+3,b+3,b+1)])
    fs.extend([(0,2,3,1),(24,25,27,26)]);h.mesh(verts,fs)
    for y in [-2.55,-1.4,0,1.45,2.65]:
        h.box((side*1.30,y,1.428),(.49,.018,.018),S)
        for xx in [1.07,1.51]:bolt(h,(side*xx,y,1.442),r=.012)
    # Stamped nose ribs.
    for xx in [1.12,1.3,1.48]:h.tube([(side*xx,-3.17,1.28),(side*xx,-2.95,1.366),(side*xx,-2.7,1.393)],.011,P)
    for y in [.2,1.12]:
        h.box((side*1.19,y,1.496),(.25,.67,.045),P,Matrix.Rotation(side*.18,3,'Y'))
        for yy in [y-.29,y+.29]:bolt(h,(side*1.20,yy,1.53),r=.012)
    # Towing eyes and shackles at nose.
    h.box((side*.72,-2.78,1.18),(.12,.19,.09),P)
    h.ring((side*.72,-2.84,1.18),(1,0,0),.065,.018,S,20,6)
# Engine deck grilles, surround frames and slats.
for x in [-.65,.65]:
    h.box((x,2.17,1.461),(.91,1.03,.045),S)
    for xx in [x-.46,x+.46]:h.box((xx,2.17,1.496),(.035,1.10,.035),P)
    for yy in [1.62,2.72]:h.box((x,yy,1.496),(.93,.035,.035),P)
    for i in range(20):h.box((x,1.70+i*.049,1.49),(.86,.022,.028),P)
    for xx in [x-.42,x+.42]:
        for yy in [1.65,2.70]:bolt(h,(xx,yy,1.52))
h.box((0,2.20,1.493),(.28,.91,.036),P)
for y in [1.76,2.63]:h.tube([(-.07,y,1.52),(-.07,y,1.56),(.07,y,1.56),(.07,y,1.52)],.012,S)
# Rear inspection covers, exhaust housings.
for x in [-.49,.49]:
    h.cyl((x,3.25,.99),(x,3.28,.99),.24,P,40)
    h.tube([(x-.08,3.29,1.02),(x-.08,3.33,1.10),(x+.08,3.33,1.10),(x+.08,3.29,1.02)],.018,S)
    h.box((x,3.12,1.28),(.36,.20,.12),S)
    h.cyl((x,3.2,1.23),(x,3.36,1.20),.062,S,24)
# External cylindrical rear fuel tanks, two each side; welded cap and bands.
for side in [-1,1]:
    for yy in [1.33,2.42]:
        x=side*1.25;z=1.70
        h.cyl((x,yy-.47,z),(x,yy+.47,z),.218,P,48)
        for end in [-.47,.47]:
            h.cyl((x,yy+end-.012,z),(x,yy+end+.012,z),.222,P,48)
            h.ring((x,yy+end,z),(0,1,0),.195,.009,S,40,6)
        for d in [-.30,.30]:
            h.cyl((x,yy+d-.026,z),(x,yy+d+.026,z),.229,P,48)
            h.box((x,yy+d,1.469),(.36,.082,.11),P)
            bolt(h,(x+side*.20,yy+d,1.60),(side,0,0),.016)
        h.cyl((x,yy,1.908),(x,yy,1.941),.052,S,20)
        h.box((x,yy,1.948),(.065,.018,.018),P)
# Driver hatch, periscopes, lamps with cages.
h.box((0,-1.98,1.602),(.60,.42,.055),P)
for x in [-.25,.25]:h.cyl((x-.05,-1.80,1.632),(x+.05,-1.8,1.632),.035,S,16)
h.box((0,-2.14,1.65),(.28,.08,.065),S);h.box((0,-2.185,1.657),(.23,.008,.029),G)
for side in [-1,1]:
    x=side*.98
    h.cyl((x,-2.35,1.45),(x,-2.48,1.45),.104,P,32)
    h.cyl((x,-2.488,1.45),(x,-2.497,1.45),.081,L,32)
    h.ring((x,-2.50,1.45),(0,1,0),.09,.012,S,32,6)
    h.tube([(x-.13,-2.43,1.36),(x-.13,-2.43,1.59),(x+.13,-2.43,1.59),(x+.13,-2.43,1.36)],.013,S)
    # Spare track links on glacis, aligned to sloping plate.
    rot=Matrix.Rotation(.53,3,'X')
    for k in range(3):
        c=Vector((side*.50,-2.62+k*.16,1.32+k*.095));h.box(c,(.34,.145,.04),T,rot)
        for xx in [-.12,0,.12]:h.box(c+rot@Vector((xx,0,.031)),(.035,.13,.025),S,rot)
# Tow cable along both rear deck edges with eyelets.
for s in [-1,1]:
    pts=[(s*.97,-.15,1.60),(s*1.09,.3,1.58),(s*1.05,1.1,1.52),(s*.97,2.5,1.53),(s*.84,2.90,1.49)]
    h.tube(pts,.022,S,10)
    for p in [pts[0],pts[-1]]:h.ring(p,(0,0,1),.060,.014,S,20,6)
# Small irregular exposed-metal chips on the working edges, not painted decals.
for side in [-1,1]:
    for i in range(90):
        y=random.uniform(-2.22,2.58);x=side*random.uniform(1.54,1.568);w=random.uniform(.008,.028);ln=random.uniform(.012,.075)
        h.mesh([(x,y,1.4015),(x-side*w,y+ln*.2,1.402),(x-side*w*.7,y+ln,1.402),(x,y+ln*.8,1.4015)],[(0,1,2,3)],S)
    # Tight weld bead where track guard meets armour.
    h.tube([(side*1.01,-2.16,1.414),(side*1.01,.65,1.414)],.006,S,6)
# Neutral-to-earth contact grime stays concentrated at fender seams and the rear floor.
for side in [-1,1]:
    for i in range(60):
        y=random.uniform(-2.20,2.6);wid=random.uniform(.015,.065);dep=random.uniform(.02,.085);x=side*1.53
        h.mesh([(x,y,1.404),(x-side*dep,y+.012,1.404),(x-side*dep*.7,y+wid,1.404),(x,y+wid*.8,1.404)],[(0,1,2,3)],D)
# Broad mantlet plate seam bolts and deck service hinges.
for side in [-1,1]:
    for yy in [.05,.92]:
        h.cyl((side*1.06,yy-.07,1.525),(side*1.06,yy+.07,1.525),.024,S,16)
# Armoured fixed collar closes the deck-to-turret bearing joint.
h.cyl((0,-.34,1.48),(0,-.34,1.605),1.14,P,96,r2=1.065)
HULL=h.obj(bevel=.006)
# Cast hemispherical turret, formed as a low asymmetric continuous shell.
t=Mesh('Turret');t.cyl((0,-.33,1.55),(0,-.33,1.66),1.03,S,96)
lathe(t,[(1.05,1.25,1.64),(1.22,1.41,1.69),(1.255,1.46,1.77),(1.23,1.445,1.86),(1.16,1.36,2.00),(.99,1.20,2.15),(.76,.94,2.26),(.48,.62,2.31),(.12,.17,2.325)],(0,-.34),P,112)
t.ring((0,-.34,1.682),(0,0,1),1.09,.012,S,80,6)
# Cast weld mould seam follows dome lower edge.
for s in [-1,1]:
    for yy in [-.95,-.2,.55]:
        # welded grab rails follow the side of the casting.
        z=2.015
        def surf_x(y):return s*1.142*math.sqrt(max(.05,1-((y+.34)/1.344)**2))
        ya,yb=yy-.19,yy+.19
        t.tube([(surf_x(ya),ya,z),(surf_x(ya)+s*.065,ya+.02,z+.025),(surf_x(yb)+s*.065,yb-.02,z+.025),(surf_x(yb),yb,z)],.013,S,10)
    for yy in [-1.1,.65]:
        t.ring((s*.77,yy,2.13),(0,1,0),.056,.018,P,20,8)
# Commander's cupola and second roof hatch.
t.cyl((-.43,-.10,2.255),(-.43,-.10,2.34),.325,P,64)
t.cyl((-.43,-.10,2.34),(-.43,-.10,2.37),.30,S,64)
for a in range(0,360,60):
    rad=math.radians(a);xx=-.43+.301*cos(rad);yy=-.10+.301*sin(rad)
    t.box((xx,yy,2.341),(.085,.025,.033),G,Matrix.Rotation(rad+pi/2,3,'Z'))
t.cyl((.44,-.13,2.26),(.44,-.13,2.318),.245,P,56)
t.ring((.44,-.13,2.322),(0,0,1),.214,.010,S,48,6)
t.tube([(.39,-.13,2.324),(.39,-.13,2.365),(.50,-.13,2.365),(.50,-.13,2.324)],.011,S)
# Periscope housings, ventilator, gunner sight.
for x,y in [(-.73,-.50),(.40,-.67)]:
    t.box((x,y,2.281),(.14,.17,.086),P);t.box((x,y-.088,2.291),(.10,.007,.042),G)
t.cyl((.12,.39,2.295),(.12,.39,2.36),.095,P,32)
t.cyl((.12,.39,2.36),(.12,.39,2.38),.108,P,32)
# Aerial mounting only, reference's flexible whip omitted to preserve specified total height.
t.cyl((-.88,.37,2.08),(-.88,.37,2.17),.052,S,20)
t.cyl((-.88,.37,2.17),(-.88,.37,2.40),.012,S,12)
TURRET=t.obj((0,-.34,1.62),.004)
hatch=Mesh('Hatch_Commander');hatch.cyl((-.43,-.10,2.37),(-.43,-.10,2.403),.294,P,64)
hatch.tube([(-.49,-.10,2.409),(-.49,-.10,2.439),(-.37,-.10,2.439),(-.37,-.10,2.409)],.011,S)
hatch.cyl((-.63,.17,2.385),(-.23,.17,2.385),.029,S,24)
HATCH=hatch.obj((-.43,.17,2.385),.003)
# Gun and mantlet: no telescoping toy barrel; restrained taper and proper hollow brake.
gun=Mesh('Gun')
lathe(gun,[(.1,.1,0)],mat=P) if False else None
gun.cyl((0,-1.59,1.965),(0,-1.93,1.965),.29,P,64,r2=.248)
gun.cyl((0,-1.90,1.965),(0,-2.12,1.965),.18,S,48,r2=.143)
gun.cyl((0,-2.05,1.965),(0,-5.96,1.965),.123,P,64,r2=.083)
gun.cyl((0,-5.90,1.965),(0,-6.01,1.965),.093,S,48)
for a in range(0,360,60):
    an=a*pi/180;bolt(gun,(.257*cos(an),-1.935,1.965+.257*sin(an)),(0,-1,0),.017)
# Double-baffle brake: rounded casting with two real through side ports and a hollow bore.
sections=[-6.50,-6.445,-6.305,-6.235,-6.095,-6.025]
base=len(gun.v);nn=40
for inner in [False,True]:
    for yy in sections:
        for k in range(nn):
            a=2*pi*k/nn;cx,sz=cos(a),sin(a)
            if inner:x=.061*cx;z=.061*sz
            else:x=.133*math.copysign(abs(cx)**.68,cx);z=.124*math.copysign(abs(sz)**.68,sz)
            gun.v.append((x,yy,1.965+z))
def ix(inner,section,k):return base+inner*len(sections)*nn+section*nn+k%nn
def isport(j,k):return j in [1,3] and abs(cos(2*pi*(k+.5)/nn))>.72
for j in range(len(sections)-1):
    for k in range(nn):
        if not isport(j,k):
            gun.face([ix(0,j,k),ix(0,j,k+1),ix(0,j+1,k+1),ix(0,j+1,k)],S,True)
            gun.face([ix(1,j,k+1),ix(1,j,k),ix(1,j+1,k),ix(1,j+1,k+1)],S,True)
        else:
            # Throats have real thickness; only boundaries receive inner-to-outer jambs.
            for sj in [j,j+1]:gun.face([ix(0,sj,k),ix(1,sj,k),ix(1,sj,k+1),ix(0,sj,k+1)],S)
            for kk,neighbor in [(k,k-1),(k+1,k+1)]:
                if not isport(j,neighbor):gun.face([ix(0,j,kk),ix(0,j+1,kk),ix(1,j+1,kk),ix(1,j,kk)],S)
for j in [0,len(sections)-1]:
    for k in range(nn):gun.face([ix(0,j,k),ix(0,j,k+1),ix(1,j,k+1),ix(1,j,k)],S)
gun.cyl((0,-6.024,1.965),(0,-6.02,1.965),.060,R,32)
gun.mi=[W if mi==P else mi for mi in gun.mi]
GUN=gun.obj((0,-1.60,1.965),.005)

# Suspension and individually named road wheels, front to rear (idler then six road wheels then sprocket).
wheel_objects=[]
for side,label in [(1,'L'),(-1,'R')]:
    positions=[(-2.78,.76,.37)]+[(y,.395,.315) for y in [-2.11,-1.27,-.43,.43,1.29,2.13]]+[(2.81,.76,.415)]
    susp=Mesh('susp_'+label)
    for idx,(y,z,r) in enumerate(positions):
        w=Mesh(f'Wheel_{label}{idx+1}');x=side*1.278;out=side*1.552
        # All-steel IS running gear; narrow dark contact rim is metal, no modern fat rubber tyres.
        # Twin steel wheel rims leave a real centre channel for the track guide teeth.
        w.cyl((x-side*.19,y,z),(x-side*.048,y,z),r,T,56)
        w.cyl((x+side*.048,y,z),(x+side*.19,y,z),r,T,56)
        w.cyl((x-side*.20,y,z),(x+side*.20,y,z),r*.31,S,32)
        for face_x in [x-side*.16,x+side*.195]:
            w.cyl((face_x,y,z),(face_x+side*.018,y,z),r*.92,P,56)
            w.ring((face_x+side*.026,y,z),(1,0,0),r*.83,.014,S,48,6)
        w.cyl((out-side*.09,y,z),(out,y,z),r*.33,P,40,r2=r*.28)
        w.ring((out-side*.015,y,z),(1,0,0),r*.33,.012,S,32,6)
        if idx not in [0,7]:
            for j in range(22):
                a=j*2*pi/22;dy,dz=sin(a),cos(a)
                rib=[]
                for xx,halfwidth in [(out-side*.067,.014),(out-side*.023,.006)]:
                    for rr,ww in [(r*.34,-halfwidth),(r*.81,-halfwidth),(r*.81,halfwidth),(r*.34,halfwidth)]:
                        rib.append((xx,y+dy*rr+dz*ww,z+dz*rr-dy*ww))
                w.mesh(rib,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],P)
                bolt(w,(out-side*.014,y+dy*r*.48,z+dz*r*.48),(side,0,0),.010)
        else:
            for j in range(12):
                a=j*2*pi/12
                w.cyl((out-side*.01,y+sin(a)*r*.62,z+cos(a)*r*.62),(out+side*.002,y+sin(a)*r*.62,z+cos(a)*r*.62),.035,S,12)
            if idx==7:
                for j in range(16):
                    a=j*2*pi/16
                    for dx in [-.125,.125]:w.box((x+dx,y+sin(a)*(r+.018),z+cos(a)*(r+.018)),(.125,.085,.067),S,Matrix.Rotation(-a,3,'X'))
        for j in range(6):
            a=j*pi/3;bolt(w,(out+.003*side,y+sin(a)*r*.21,z+cos(a)*r*.21),(side,0,0),.014)
        # Thin irregular dried-earth deposits between ribs, bonded to the face of each wheel.
        for k in range(18):
            a=random.uniform(0,2*pi);rad=random.uniform(r*.70,r*.90);da=random.uniform(.015,.075);dr=random.uniform(.005,.026)
            vx=out-side*.059
            verts=[]
            for aa,rr in [(a-da,rad-dr),(a+da*.5,rad-dr*.6),(a+da,rad+dr*.5),(a,rad+dr)]:verts.append((vx,y+sin(aa)*rr,z+cos(aa)*rr))
            w.mesh(verts,[(0,1,2,3)],D)
        wheel_objects.append(w.obj((x,y,z),.003))
        susp.cyl((side*1.115,y-.32,z+.19),(side*1.20,y,z),.07,S,20)
        if idx not in [0,7]:
            susp.cyl((side*1.035,y-.32,z+.19),(side*1.195,y-.32,z+.19),.102,P,32)
            susp.cyl((side*1.195,y-.32,z+.19),(side*1.209,y-.32,z+.19),.052,S,24)
            for a in [0,2*pi/3,4*pi/3]:bolt(susp,(side*1.209,y-.32+cos(a)*.072,z+.19+sin(a)*.072),(side,0,0),.012)
    # Return rollers sit behind the upper run.
    for y in [-1.75,0,1.75]:
        susp.cyl((side*1.06,y,1.035),(side*1.40,y,1.035),.155,S,32)
        susp.cyl((side*1.40,y,1.035),(side*1.43,y,1.035),.104,P,28)
    so=susp.obj(bevel=.004)
    bpy.ops.object.select_all(action='DESELECT');HULL.select_set(True);so.select_set(True);bpy.context.view_layer.objects.active=HULL;bpy.ops.object.join();HULL.select_set(False)
# Closed belts with exact tangent-aligned links. V is unwrapped arc length around each belt.
# Sample a rounded hull path with Catmull-Rom splines.
anchors=[(-2.18,.075),(2.18,.075),(2.88,.36),(3.17,.70),(3.10,1.03),(2.80,1.215),(1.5,1.22),(0,1.205),(-1.5,1.22),(-2.8,1.19),(-3.12,.99),(-3.17,.69),(-2.88,.36)]
path=[]
for i in range(len(anchors)):
    p0,p1,p2,p3=[Vector(anchors[j%len(anchors)]) for j in [i-1,i,i+1,i+2]]
    for k in range(20):
        t0=k/20;path.append(.5*((2*p1)+(-p0+p2)*t0+(2*p0-5*p1+4*p2-p3)*t0*t0+(-p0+3*p1-3*p2+p3)*t0**3))
# enforce level bottom contact and avoid Catmull overshoot under ground
for p in path:p.y=max(.052,p.y)
lengths=[0.]
for a,b in zip(path,path[1:]+path[:1]):lengths.append(lengths[-1]+(b-a).length)
total=lengths[-1]
def sample(d):
    d%=total;i=next(i for i in range(len(path)) if lengths[i+1]>=d);fac=(d-lengths[i])/(lengths[i+1]-lengths[i]);return path[i].lerp(path[(i+1)%len(path)],fac)
for side,label in [(1,'L'),(-1,'R')]:
    tr=Mesh('Track_'+label);nlinks=96;pitch=total/nlinks
    for i in range(nlinks):
        d=(i+.5)*pitch;p=sample(d);a=sample(d-.015);b=sample(d+.015);tan=(b-a).normalized();ang=math.atan2(tan.y,tan.x);rot=Matrix.Rotation(ang,3,'X');c=Vector((side*1.278,p.x,p.y))
        startface=len(tr.f)
        tr.box(c,(.586,pitch*.94,.055),T,rot)
        # crosswise traction bar and track end shoes.
        tr.box(c+rot@Vector((0,0,-.036)),(.61,.036,.025),S,rot)
        for xx in [-.245,.245]:
            tr.box(c+rot@Vector((xx,0,.031)),(.080,pitch*.88,.020),T,rot)
        # central guide horn, inside belt.
        tr.box(c+rot@Vector((0,0,.075)),(.048,.062,.105),T,rot)
        # exposed pin ends
        for xx in [-.302,.302]:
            cp=c+rot@Vector((xx,pitch*.39,0));tr.cyl(cp-Vector((.016,0,0)),cp+Vector((.016,0,0)),.022,S,10)
        if i%3!=0:
            xx=random.uniform(-.18,.18);wid=random.uniform(.06,.22)
            local=[(xx-wid/2,-pitch*.26,-.028),(xx+wid*.43,-pitch*.32,-.028),(xx+wid/2,pitch*.24,-.028),(xx-wid*.35,pitch*.30,-.028)]
            tr.mesh([tuple(c+rot@Vector(v)) for v in local],[(0,1,2,3)],D)
        for fi in range(startface,len(tr.f)):
            uv=[]
            for vi in tr.f[fi]:
                rel=rot.transposed()@(Vector(tr.v[vi])-c);uv.append((rel.x/.61+.5,(d+rel.y)/.62))
            tr.uv[fi]=uv
    globals()['TRACK_'+label]=tr.obj(bevel=.0025)
# Hierarchy retains zero orientation and hub/hinge local origins.
def parent_keep(o,parent):
    bpy.context.view_layer.update();mw=o.matrix_world.copy();o.parent=parent;o.matrix_world=mw
parent_keep(GUN,TURRET);parent_keep(HATCH,TURRET)
def socket(name,pos,parent=None):
    o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=pos;o.empty_display_size=.12
    if parent:parent_keep(o,parent)
    return o
socket('Muzzle',(0,-6.50,1.965),GUN)
socket('Seat_Commander',(-.43,-.10,2.43),TURRET);socket('Seat_Driver',(0,-1.95,1.52),HULL)
socket('Exhaust',(0,3.35,1.21),HULL)
socket('Light_Head_L',(.98,-2.505,1.45),HULL);socket('Light_Head_R',(-.98,-2.505,1.45),HULL)
# DShK heavy machine gun in its low travel stow beside the commander's cupola.
mg=Mesh('MG')
mg.cyl((-.72,.12,2.23),(-.72,.12,2.34),.042,S,20)
mg.box((-.72,-.02,2.375),(.12,.38,.115),W)
mg.box((-.81,.02,2.365),(.09,.16,.11),S)
mg.cyl((-.72,-.20,2.39),(-.72,-.96,2.39),.028,W,24,r2=.020)
mg.cyl((-.72,-.24,2.355),(-.72,-.79,2.355),.014,S,16)
mg.cyl((-.72,-.96,2.39),(-.72,-1.015,2.39),.031,S,24,cap=False)
for yy in [-.27-i*.042 for i in range(8)]:mg.ring((-.72,yy,2.39),(0,1,0),.028,.004,S,16,5)
mg.box((-.72,.26,2.37),(.14,.12,.055),W)
mg.tube([(-.79,.26,2.37),(-.82,.33,2.34),(-.82,.38,2.34)],.012,S,8)
mg.tube([(-.65,.26,2.37),(-.62,.33,2.34),(-.62,.38,2.34)],.012,S,8)
MG=mg.obj((-.72,.12,2.30),.002);parent_keep(MG,TURRET)
asset=[o for o in sc.objects if o.type in {'MESH','EMPTY'}]
bpy.context.view_layer.update()
# Ground is exactly zero; muzzle extent exactly 9.85m from rear. Preserve all pivot relationships.
# Normalize the tiny construction-edge overshoots to the exact shipping envelope.
pts0=[o.matrix_world@Vector(c) for o in asset if o.type=='MESH' for c in o.bound_box]
mn=[min(v[i] for v in pts0) for i in range(3)];mx=[max(v[i] for v in pts0) for i in range(3)]
scale=Vector((3.15/(mx[0]-mn[0]),9.85/(mx[1]-mn[1]),2.45/(mx[2]-mn[2])))
# Ground-plane hull-centre origin; axial correction does not change articulation axes.
world_matrices={o:o.matrix_world.copy() for o in asset}
for o in asset:
    old=world_matrices[o];pos=old.translation.copy();newpos=Vector((pos.x*scale.x,pos.y*scale.y,(pos.z-mn[2])*scale.z))
    if o.type=='MESH':o.data.transform(Matrix.Diagonal((*scale,1)))
    world_matrices[o]=Matrix.Translation(newpos)
for o in asset: o.matrix_world=world_matrices[o]
bpy.context.view_layer.update()
for o in asset:
    if o.type=='MESH':
        o.data.calc_loop_triangles()
rawtris=sum(len(o.data.loop_triangles) for o in asset if o.type=='MESH')
ratio=min(1.,118000/rawtris)
for o in asset:
    if o.type=='MESH' and ratio<1:
        bpy.context.view_layer.objects.active=o
        mod=o.modifiers.new('Game mesh simplification','DECIMATE');mod.ratio=ratio;mod.use_collapse_triangulate=True
        bpy.ops.object.modifier_apply(modifier=mod.name)
# Triangulate for deterministic exported triangle counts.
for o in asset:
    if o.type=='MESH':
        bpy.context.view_layer.objects.active=o;o.select_set(True)
        mod=o.modifiers.new('Export triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
        bm=bmesh.new();bm.from_mesh(o.data)
        degenerate=[f for f in bm.faces if f.calc_area()<1e-11]
        if degenerate:bmesh.ops.delete(bm,geom=degenerate,context='FACES_ONLY')
        bm.to_mesh(o.data);bm.free();o.data.update();o.select_set(False)
pts=[o.matrix_world@Vector(c) for o in asset if o.type=='MESH' for c in o.bound_box]
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
# Final measured envelope and an exact ground-plane Hull pivot after simplification.
correction=Vector((3.15/(hi[0]-lo[0]),9.85/(hi[1]-lo[1]),2.45/(hi[2]-lo[2])))
old_world={o:o.matrix_world.copy() for o in asset}
for o in asset:
    pos=old_world[o].translation
    if o.type=='MESH':o.data.transform(Matrix.Diagonal((*correction,1)))
    old_world[o]=Matrix.Translation((pos.x*correction.x,(pos.y)*correction.y,(pos.z-lo[2])*correction.z))
for o in asset:o.matrix_world=old_world[o]
bpy.context.view_layer.update()
child_world={o:o.matrix_world.copy() for o in HULL.children}
HULL.data.transform(Matrix.Translation(HULL.matrix_world.translation));HULL.location=(0,0,0)
for o,world_matrix in child_world.items():o.matrix_world=world_matrix
bpy.context.view_layer.update()
pts=[o.matrix_world@Vector(c) for o in asset if o.type=='MESH' for c in o.bound_box]
lo=[min(p[i] for p in pts) for i in range(3)];hi=[max(p[i] for p in pts) for i in range(3)]
tri=sum(len(o.data.polygons) for o in asset if o.type=='MESH')
print('GEOMETRY',tri,'bounds',lo,hi,flush=True)
# Ensure texture + tint uses standard glTF multiply; patch metadata only after export.
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
glb=os.path.join(ROOT,'is3-heavy-tank.glb')
bpy.ops.export_scene.gltf(filepath=glb,export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_image_format='AUTO',export_extras=True)
def patch_glb(path):
    data=open(path,'rb').read();jlen,jtype=struct.unpack_from('<II',data,12);j=json.loads(data[20:20+jlen]);tail=data[20+jlen:]
    for m in j['materials']:
        if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.13,.15,.08,1]
    raw=json.dumps(j,separators=(',',':')).encode();raw+=b' '*((-len(raw))%4)
    open(path,'wb').write(struct.pack('<III',0x46546C67,2,20+len(raw)+len(tail))+struct.pack('<II',len(raw),0x4E4F534A)+raw+tail)
patch_glb(glb)
def game(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
bpy.context.view_layer.update()
manifest={'units':'metres','axes':'+X left, +Y up, +Z forward','triangles':tri,'bounds':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]},'paints':[{'name':name,'linear_rgb':color} for name,color in PAINTS], 'nodes':[{'name':o.name,'world_position':game(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'type':o.type} for o in asset], 'wheel_order':'front idler, six road wheels, rear drive sprocket','track_uv':'U across belt; V is continuous path distance / 0.62m; repeat wrap','articulation':{'Turret':'Y','Gun':'X','Wheel_L1..8 / Wheel_R1..8':'X','Hatch_Commander':'X'},'reference_note':'Low stowed DShK fitted; antenna shortened to preserve 2.45m overall height.'}
open(os.path.join(ROOT,'nodes.json'),'w').write(json.dumps(manifest,indent=2))
open(os.path.join(ROOT,'NOTES.md'),'w').write(f'IS-3 style heavy tank: pike nose, low cast turret, 122mm gun, four rear fuel tanks; metres, +Z forward.\n{tri:,} triangles; articulated turret, gun, commander hatch and sixteen wheels.\n8 materials: Paint, Steel, Tracks, Rubber, Glass, Lamps, Weapon, DryMud; Paint neutral texture × linear Soviet green [0.13,0.15,0.08].\nShared Cycles-baked tiling base up to 1024², normals up to 512², ORM up to 512²; standard uncompressed geometry; GLB {os.path.getsize(glb)/1048576:.2f} MiB.\nPoly Haven CC0 ids: green_metal_rust, metal_plate, rusty_metal_03, brown_mud_dry; https://polyhaven.com/license ; https://polyhaven.com/a/green_metal_rust ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/rusty_metal_03 ; https://polyhaven.com/a/brown_mud_dry .\nDifferences: shortened antenna to hold height; low stowed DShK added (not shown on sheet); small fittings and suspension simplified; no insignia.\n')
print('FIRST_GLB_COMPLETE',os.path.getsize(glb),flush=True)
# Soft daylight presentation, camera on lit side.
world=bpy.data.worlds.new('Daylight');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.83,1);world.node_tree.nodes['Background'].inputs[1].default_value=.55;sc.world=world
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.018));ground=bpy.context.object;ground.name='Render_ground'
mat=bpy.data.materials.new('Ground only');mat.diffuse_color=(.26,.28,.30,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.26,.28,.30,1);mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.85;ground.data.materials.append(mat)
def area(name,pos,power,size):
    data=bpy.data.lights.new(name,'AREA');o=bpy.data.objects.new(name,data);sc.collection.objects.link(o);o.location=pos;o.rotation_euler=(Vector((0,-.5,1))-o.location).to_track_quat('-Z','Y').to_euler();data.energy=power;data.shape='DISK';data.size=size
area('Soft sky key',(2,-5,9),1900,7);area('Rear sky',(-5,4,6),1400,6)
bpy.ops.object.camera_add();cam=bpy.context.object;sc.camera=cam;cam.data.type='ORTHO';cam.data.lens=50
center=Vector((0,-1.25,1.15));cam.location=center+Vector((10,-14,6.1));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=10.4
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100;sc.cycles.samples=32
sc.render.filepath=os.path.join(ROOT,'final.png');bpy.ops.render.render(write_still=True)
# Save native source checkpoint only remotely; not a requested delivery artifact.
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(ROOT,'is3-source.blend'))
cam.location=(12,0,2.2);target=Vector((0,-1.3,1.23));cam.location=(12,-1.3,1.23);cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=11.25
sc.render.filepath=os.path.join(ROOT,'side.png');bpy.ops.render.render(write_still=True)
cam.location=center+Vector((10,-14,6.1));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=10.4
clay=bpy.data.materials.new('Clay QA only');clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.35,.35,.35,1);clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.72
sc.view_layers[0].material_override=clay;sc.render.filepath=os.path.join(ROOT,'clay.png');bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
# Six recolours in one 3x2 contact sheet. Tiles rendered directly using the export material.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
frames=[]
for name,color in PAINTS:
    paint_factor.inputs[2].default_value=(*color,1);sc.render.filepath=os.path.join(TEX,'variant_'+name.replace(' ','_')+'.png');bpy.ops.render.render(write_still=True)
    im=bpy.data.images.load(sc.render.filepath,check_existing=False);frames.append(np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4));bpy.data.images.remove(im)
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,frame in enumerate(frames):
    row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=frame
im=bpy.data.images.new('Six paints',1920,720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=os.path.join(ROOT,'paints.png');im.file_format='PNG';im.save()
paint_factor.inputs[2].default_value=(*PAINTS[0][1],1)
print('BUILD_COMPLETE',flush=True)
