"""Hop City original vinyl heroes. Blender 5.2; no external assets.
Run: blender -b --python-exit-code 1 --python build_critters.py
Optional after --: raccoon | pigeon | tuxedo-cat | corgi | duckling | squirrel | lineup
Author coordinates: X right, F forward, H up; stored in Blender as (X,-F,H).
GLB conversion is (X,H,F), so +Y up and +Z forward. All output is beside script.
"""
import bpy, math, json, sys, os, struct
from pathlib import Path
from mathutils import Vector
OUT=Path(__file__).resolve().parent
SLUGS=['raccoon','pigeon','tuxedo-cat','corgi','duckling','squirrel']
PARTS=[]; M={}; COLORS={}; FACE=[]
def reset():
    bpy.ops.wm.read_factory_settings(use_empty=True)
    global PARTS,M,COLORS,FACE
    PARTS=[]; M={}; COLORS={}; FACE=[]
def linear(v): return v/12.92 if v<=.04045 else ((v+.055)/1.055)**2.4
def mat(role,col,rough=.55):
    m=bpy.data.materials.new(role);m.use_nodes=True
    rgb=[linear(int(col[i:i+2],16)/255) for i in (1,3,5)]
    p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*rgb,1)
    p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=0
    m.diffuse_color=(*rgb,1); M[role]=m;COLORS[role]=col
    return m
def xyz(p): return (p[0],-p[1],p[2])
def mesh(name,verts,faces,role):
    me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
    ob=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(ob)
    me.materials.append(M[role])
    for p in me.polygons:p.use_smooth=True
    PARTS.append(ob);return ob
def sphere(name,c,r,role,n=12,k=8,power=1,tilt=0):
    # Reserve subdivisions for primary silhouette; small details stay economical.
    if n < 16: k=max(4,k-2)
    # Shared poles and seam, closed mesh, no zero-area pole triangles.
    def signed(x):return math.copysign(abs(x)**power,x)
    def point(x,y,z):
        x,y,z=x*r[0],y*r[1],z*r[2]
        x,z=x*math.cos(tilt)+z*math.sin(tilt),-x*math.sin(tilt)+z*math.cos(tilt)
        return xyz((x+c[0],y+c[1],z+c[2]))
    vs=[point(0,0,-1)]
    for j in range(1,k):
        a=-math.pi/2+math.pi*j/k
        for i in range(n):
            b=2*math.pi*i/n
            vs.append(point(signed(math.cos(a))*signed(math.cos(b)),signed(math.cos(a))*signed(math.sin(b)),signed(math.sin(a))))
    vs.append(point(0,0,1));top=len(vs)-1;fs=[]
    # semantic frame has negative determinant; reverse standard winding
    for i in range(n):fs.append((0,1+i,1+(i+1)%n))
    for j in range(k-2):
        a=1+j*n;b=a+n
        for i in range(n):q=(i+1)%n;fs.append((a+i,b+i,b+q,a+q))
    a=1+(k-2)*n
    for i in range(n):fs.append((a+i,top,a+(i+1)%n))
    return mesh(name,vs,fs,role)
def soften_path(ctrl):
    path=[]
    for j in range(len(ctrl)-1):
        a,b,c,d=[Vector(ctrl[q]) for q in [max(0,j-1),j,j+1,min(len(ctrl)-1,j+2)]]
        for t in [0,.5]:path.append(tuple(.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t)))
    return path+[ctrl[-1]]
def tube(name,path,role,n=12,roles=None,band=None):
    # path = x,f,h,radius; continuous rounded tail with material rings.
    vs=[];fs=[];prev=None
    for j,p in enumerate(path):
        c=Vector(xyz(p[:3]));t=Vector(xyz(path[min(j+1,len(path)-1)][:3]))-Vector(xyz(path[max(0,j-1)][:3]))
        t.normalize();a=t.cross(Vector((0,0,1)))
        if name in ['CurledUprightTail','HugePlume']:
            a=(prev-t*prev.dot(t)) if prev is not None else t.cross(Vector((1,0,0)))
        if a.length<.01:a=t.cross(Vector((0,1,0)))
        a.normalize();prev=a.copy();b=t.cross(a).normalized()
        for i in range(n):
            ang=2*math.pi*i/n;vs.append(tuple(c+p[3]*(a*math.cos(ang)+b*math.sin(ang))))
    fs.append(tuple(reversed(range(n))))
    for j in range(len(path)-1):
        for i in range(n):q=(i+1)%n;fs.append((j*n+i,j*n+q,(j+1)*n+q,(j+1)*n+i))
    fs.append(tuple((len(path)-1)*n+i for i in range(n)))
    ob=mesh(name,vs,fs,role)
    if roles:
        for r in roles:ob.data.materials.append(M[r])
        for p in ob.data.polygons:
            if 0<p.index<len(fs)-1:p.material_index=band[(p.index-1)//n]
            elif p.index==len(fs)-1:p.material_index=band[-1]
    return ob
def eye(x,f,h,white='Belly',size=.037):
    ob=sphere('BeadEye',(x,f,h),(size,.025,size),'Eye',12,8)
    if white:sphere('EyeGlint',(x-.009,f+.023,h+.011),(.010,.006,.010),white,8,6)
    return ob
def nose(c,r=(.045,.038,.031),role='Eye'):
    ob=sphere('ForwardNose',c,r,role,12,8);FACE.append(ob);return ob
def paws(role='FurDark',f=.11):
    for x in [-.14,.14]:sphere('GroundPaw',(x,f,.075),(.115,.145,.075),role,12,8,power=.86)
def body(role='Fur',r=(.245,.215,.29),c=(0,-.035,.365)):
    sphere('PearBody',c,r,role,16,10,power=.92)
def head(role='Fur',c=(0,.055,.715),r=(.315,.255,.235)):
    sphere('BigHead',c,r,role,20,12,power=.87)
def arm(x,role='Fur'):
    sphere('LittleArm',(x,.10,.355),(.075,.09,.16),role,12,8,tilt=-math.copysign(.20,x))
def roundear(x,role='Fur',inner='FurDark'):
    sphere('RoundEar',(x,.01,.916),(.097,.065,.105),role,12,8)
    sphere('EarInset',(x,.065,.928),(.059,.023,.066),inner,10,6)
def pointedear(x,role,inner,height=.22,width=.115):
    # Rounded taper, its volume overlaps head; tips lean slightly outward.
    sg=1 if x>0 else -1
    tube('UprightEar',[(x,.015,.83,width),(x+sg*.012,.017,.91,width*.88),(x+sg*.025,.014,.83+height*.80,width*.45),(x+sg*.029,.01,.83+height,.015)],role,10)
    sphere('EarInset',(x+sg*.012,.092,.91),(width*.48,.022,height*.29),inner,10,8,tilt=sg*.13)
def raccoon():
    mat('Fur','#89949f');mat('FurDark','#30343d');mat('Belly','#f4efe1');mat('Eye','#101722',.15)
    body();paws();sphere('CreamBib',(0,.154,.40),(.17,.079,.18),'Belly',12,8)
    for x in [-.25,.25]:arm(x)
    head()
    for x in [-.235,.235]:roundear(x)
    for x in [-.142,.142]:
        sphere('Mask',(x,.275,.737),(.157,.048,.101),'FurDark',16,6,tilt=math.copysign(.12,x))
        sphere('WhiteBrow',(x,.248,.829),(.126,.039,.031),'Belly',12,6)
        eye(x,.319,.755)
    sphere('WhiteMuzzle',(0,.292,.644),(.17,.107,.098),'Belly',14,8)
    nose((0,.389,.674))
    tube('RingedTail',[(0,-.18,.22,.07),(.045,-.25,.23,.104),(.09,-.33,.25,.12),(.145,-.405,.28,.13),(.205,-.463,.33,.132),(.257,-.49,.39,.124),(.286,-.477,.451,.105),(.294,-.444,.499,.066),(.288,-.419,.52,.016)],'FurDark',12,['Fur'],[0,1,0,1,0,1,0,1])
    for v in PARTS[-1].data.vertices:v.co.y *= .86
def pigeon():
    mat('Feather','#8195b0');mat('Eye','#202935',.45);mat('NeckGreen','#368d77');mat('NeckPurple','#73509c');mat('Feet','#e991a6');mat('Beak','#f9a545')
    for x in [-.125,.125]:
        sphere('PinkFoot',(x,.09,.055),(.105,.145,.055),'Feet',12,8)
    body('Feather',(.27,.235,.31),(0,-.025,.38))
    tube('TwoToneNeck',[(0,.04,.555,.175),(0,.04,.60,.225),(0,.04,.635,.238),(0,.04,.675,.24),(0,.04,.72,.219),(0,.04,.76,.185)],'NeckPurple',16,['NeckGreen'],[0,0,1,1,1])
    head('Feather',(0,.105,.818),(.255,.22,.205))
    for x in [-1,1]:
        sphere('FoldedWing',(x*.214,-.07,.418),(.083,.225,.233),'Feather',16,10)
        for ta,tb in [(-.76,-.53),(-.17,.055)]:
            vs=[];fs=[]
            for t in [ta,tb]:
                for j in range(9):
                    phi=-1.12+2.24*j/8
                    rr=math.sqrt(1-t*t)
                    vs.append(xyz((x*(.214+.085*rr*math.cos(phi)),-.07+.227*t,.418+.235*rr*math.sin(phi))))
            for j in range(8):
                face=(j,j+1,9+j+1,9+j)
                fs.append(face if x>0 else tuple(reversed(face)))
            mesh('FlushWingBar',vs,fs,'Eye')
        sphere('OrangeEyeRing',(x*.204,.243,.848),(.052,.029,.055),'Beak',12,8)
        eye(x*.213,.267,.852,None,.039)
        # White catchlight geometry uses pale blue body role, keeping six materials.
        sphere('EyeGlint',(x*.213-.01,.291,.865),(.010,.006,.010),'Feather',8,6)
    nose((0,.334,.801),(.086,.105,.044),'Beak')
    sphere('TailFan',(0,-.25,.30),(.16,.23,.075),'Eye',12,8)
def tuxedo_cat():
    mat('Fur','#242933');mat('Belly','#f6f0df');mat('Eye','#0b121b',.15);mat('Nose','#efa0ae')
    body();paws('Belly');sphere('TuxedoBib',(0,.155,.41),(.17,.075,.225),'Belly',14,10)
    for x in [-.25,.25]:
        arm(x);sphere('WhiteMitten',(x,.116,.242),(.071,.078,.071),'Belly',10,6)
    head()
    for x in [-.224,.224]:pointedear(x,'Fur','Nose',.22,.096)
    for x in [-.143,.143]:eye(x,.294,.755)
    for x in [-.069,.069]:sphere('Muzzle',(x,.285,.661),(.095,.074,.073),'Belly',12,8)
    nose((0,.361,.69),(.036,.025,.024),'Nose')
    sphere('WhiteNape',(0,-.055,.535),(.239,.23,.075),'Belly',12,6)
    ctrl=[(.03,-.185,.20,.062),(.18,-.31,.32,.065),(.315,-.36,.48,.061),(.40,-.375,.66,.057),(.43,-.365,.80,.057),(.465,-.35,.865,.055),(.52,-.33,.865,.048),(.535,-.317,.827,.015)]
    path=[]
    for j in range(len(ctrl)-1):
        a,b,c,d=[Vector(ctrl[q]) for q in [max(0,j-1),j,j+1,min(len(ctrl)-1,j+2)]]
        for t in [0,.5]:path.append(tuple(.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t)))
    path.append(ctrl[-1])
    tube('CurledUprightTail',path,'Fur',10,['Belly'],[0]*10+[1]*4)
def corgi():
    mat('Fur','#d79043');mat('Belly','#fff2d5');mat('Eye','#1c212a',.15);mat('Ear','#b96048')
    body('Fur',(.26,.235,.24),(0,-.025,.31));paws('Belly')
    sphere('WhiteChest',(0,.155,.37),(.184,.11,.205),'Belly',14,8)
    for x in [-.23,.23]:sphere('ShortForeleg',(x,.095,.18),(.087,.108,.12),'Fur',12,8)
    head('Fur',(0,.047,.646),(.327,.263,.238))
    for x in [-.229,.229]:pointedear(x,'Fur','Ear',.25,.112)
    # One continuous paint-like stripe follows the superellipsoid head surface.
    vs=[];fs=[];power=.87
    for j in range(21):
        a=-.30+2.18*j/20
        width=.057*(1-.65*(max(0,j/20-.7)/.3))
        for u in [-1,0,1]:
            x=u*width;sh=(1-abs(x/.327)**(2/power))**(power/2)
            f=.047+.265*sh*math.copysign(abs(math.cos(a))**power,math.cos(a))
            z=.646+.240*sh*math.copysign(abs(math.sin(a))**power,math.sin(a))
            vs.append(xyz((x,f,z)))
    for j in range(20):
        for k in range(2):a=j*3+k;fs.append((a,a+1,a+4,a+3))
    mesh('ContinuousWhiteBlaze',vs,fs,'Belly')
    for x in [-.151,.151]:eye(x,.298,.701)
    sphere('Muzzle',(0,.30,.567),(.178,.123,.091),'Belly',14,8)
    nose((0,.41,.594),(.053,.035,.038))
    sphere('StubTail',(0,-.263,.345),(.10,.115,.10),'Belly',12,8)
def duckling():
    mat('Down','#ffcf26');mat('Bill','#f3932c');mat('Eye','#17232e',.15);mat('Highlight','#fff7d6')
    for x in [-.139,.139]:
        ob=sphere('WebbedFoot',(x,.105,.05),(.133,.16,.05),'Bill',20,8,power=.77)
        for v in ob.data.vertices:
            f=-v.co.y-.105
            if f>0:v.co.y=-.105-f*(1+.10*math.cos(3*math.pi*(v.co.x-x)/.133))
    sphere('PearBody',(0,-.035,.35),(.275,.23,.29),'Down',20,12,power=.92)
    sphere('BigHead',(0,.065,.71),(.309,.254,.253),'Down',28,16,power=.87)
    for x in [-1,1]:
        sphere('WingNub',(x*.25,-.005,.406),(.096,.155,.13),'Down',12,8,tilt=x*-.3)
        eye(x*.167,.284,.747,'Highlight',.041)
    nose((0,.328,.649),(.162,.135,.047),'Bill')
    sphere('TailNub',(0,-.256,.365),(.104,.115,.09),'Down',12,8)
    sphere('HeadTuft',(-.046,.015,.958),(.045,.055,.075),'Down',10,8,tilt=-.35)
    sphere('SmallTuft',(.035,.016,.95),(.035,.045,.059),'Down',10,8,tilt=.25)
def squirrel():
    mat('Fur','#b9673e');mat('Tail','#994726');mat('Belly','#f7dfb5');mat('Eye','#201923',.15)
    body('Fur',(.22,.195,.285),(0,.015,.36));paws('Fur',.15)
    sphere('CreamBelly',(0,.174,.41),(.156,.065,.191),'Belly',12,8)
    for x in [-.224,.224]:arm(x)
    sphere('BigHead',(0,.10,.709),(.281,.229,.218),'Fur',24,12,power=.87)
    for x in [-.194,.194]:
        sphere('LittleEar',(x,.10,.914),(.066,.058,.111),'Fur',12,8)
        sphere('EarInner',(x,.147,.926),(.035,.021,.063),'Belly',10,6)
    for x in [-.137,.137]:eye(x,.313,.754)
    sphere('CreamMuzzle',(0,.297,.644),(.13,.085,.077),'Belly',12,8)
    nose((0,.375,.674),(.039,.029,.027))
    tube('HugePlume',soften_path([(.04,-.16,.21,.065),(.14,-.25,.26,.13),(.215,-.305,.40,.183),(.255,-.31,.58,.202),(.25,-.295,.77,.192),(.21,-.26,.905,.147),(.152,-.208,.946,.088),(.112,-.17,.913,.01)]),'Tail',16)
BUILDERS={'raccoon':raccoon,'pigeon':pigeon,'tuxedo-cat':tuxedo_cat,'corgi':corgi,'duckling':duckling,'squirrel':squirrel}
def bounds(ob):
    vs=[ob.matrix_world@v.co for v in ob.data.vertices]
    return Vector(tuple(min(v[i] for v in vs) for i in range(3))),Vector(tuple(max(v[i] for v in vs) for i in range(3)))
def finish(slug):
    nose_centers=[sum((v.co for v in o.data.vertices),Vector())/len(o.data.vertices) for o in FACE]
    bpy.ops.object.select_all(action='DESELECT')
    for o in PARTS:o.select_set(True)
    bpy.context.view_layer.objects.active=PARTS[0];bpy.ops.object.join();ob=bpy.context.object;ob.name=slug
    lo,hi=bounds(ob);shift=Vector((-(lo.x+hi.x)/2,-(lo.y+hi.y)/2,-lo.z))
    for v in ob.data.vertices:v.co+=shift
    # Applied identity object transform; exact ground-centred local geometry.
    ob.location=(0,0,0);ob.rotation_euler=(0,0,0);ob.scale=(1,1,1)
    mod=ob.modifiers.new('FinalTriangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
    assert len(ob.data.polygons)<=3000,(slug,len(ob.data.polygons))
    forward=nose_centers[0]+shift
    assert forward.y<-.1,(slug,'nose must be forward')
    ob['forward_axis']='+Z';ob['units']='metres';ob['design']='Original Hop City vinyl hero'
    bpy.ops.export_scene.gltf(filepath=str(OUT/f'{slug}.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=False,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_animations=False,export_extras=True)
    palette=dict(COLORS)
    return validate(slug,palette,tuple(forward))
def validate(slug,palette,forward):
    reset();bpy.ops.import_scene.gltf(filepath=str(OUT/f'{slug}.glb'))
    obs=list(bpy.context.scene.objects);assert len(obs)==1 and obs[0].type=='MESH'
    ob=obs[0];lo,hi=bounds(ob);dim=hi-lo
    ob.data.calc_loop_triangles();tris=len(ob.data.loop_triangles)
    assert abs(lo.z)<1e-5 and abs(lo.x+hi.x)<1e-5 and abs(lo.y+hi.y)<1e-5
    assert dim.x<=1.00001 and dim.y<=1.00001 and .8<=dim.z<=1.1,(slug,list(dim))
    assert ob.location.length<1e-5 and max(abs(s-1) for s in ob.scale)<1e-5
    assert len(ob.modifiers)==0 and tris<=3000 and len(ob.data.materials)<=6
    raw=(OUT/f'{slug}.glb').read_bytes();assert len(raw)<=250000
    length,typ=struct.unpack_from('<II',raw,12);doc=json.loads(raw[20:20+length]);assert len(doc['nodes'])==1 and len(doc['meshes'])==1
    assert not doc.get('images') and not doc.get('textures') and not doc.get('animations') and not doc.get('skins')
    assert not any(k in doc['nodes'][0] for k in ['translation','rotation','scale','matrix'])
    # glTF direct POSITION accessors are in actual exported Y-up space.
    pos=[doc['accessors'][p['attributes']['POSITION']] for p in doc['meshes'][0]['primitives']]
    gmin=[min(a['min'][i] for a in pos) for i in range(3)];gmax=[max(a['max'][i] for a in pos) for i in range(3)]
    assert abs(gmin[1])<1e-5
    mats=[m.name for m in ob.data.materials]
    record={'slug':slug,'glb':f'{slug}.glb','size_m':[round(dim.x,5),round(dim.y,5),round(dim.z,5)],'tris':tris,'materials':mats,'colors':palette}
    (OUT/f'{slug}-validation.json').write_text(json.dumps({'passed':True,'record':record,'bytes':len(raw),'bounds_gltf':[gmin,gmax],'origin':[0,0,0],'forward_nose_gltf':[forward[0],forward[2],-forward[1]],'mesh_objects':1,'textures':0,'fresh_scene_reimport':True},indent=2))
    (OUT/f'{slug}-record.json').write_text(json.dumps(record,indent=2))
    print('VALIDATED',json.dumps(record),flush=True)
    return ob,record

def rectangle(name,c,s,material):
    bpy.ops.mesh.primitive_cube_add(size=1,location=c);o=bpy.context.object;o.name=name;o.scale=s;o.data.materials.append(material);return o

def stage(lineup=False):
    sc=bpy.context.scene;sc.render.engine='CYCLES';sc.cycles.samples=48;sc.cycles.use_denoising=True
    # Use CUDA when available; leave a valid CPU fallback for portable script.
    prefs=bpy.context.preferences.addons['cycles'].preferences
    try:
        prefs.compute_device_type='CUDA';prefs.get_devices()
        for d in prefs.devices:d.use=d.type=='CUDA'
        if any(d.type=='CUDA' for d in prefs.devices):sc.cycles.device='GPU'
    except Exception:pass
    sc.render.resolution_x=1600 if lineup else 800;sc.render.resolution_y=800;sc.render.resolution_percentage=100
    sc.render.image_settings.file_format='PNG';sc.render.film_transparent=False
    sc.view_settings.view_transform='AgX';sc.view_settings.look='AgX - Medium High Contrast';sc.view_settings.exposure=.35
    world=bpy.data.worlds.new('SoftDaylight');sc.world=world;world.use_nodes=True
    world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.75,.9,1);world.node_tree.nodes['Background'].inputs[1].default_value=.55
    asphalt=mat('Stage_Asphalt','#616772',.88)
    # Fine asphalt only in the render stage, never in GLBs.
    nt=asphalt.node_tree;p=nt.nodes.get('Principled BSDF');noise=nt.nodes.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=145
    bump=nt.nodes.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.18;bump.inputs['Distance'].default_value=.008
    nt.links.new(noise.outputs['Fac'],bump.inputs['Height']);nt.links.new(bump.outputs['Normal'],p.inputs['Normal'])
    rectangle('Asphalt',(0,0,-.035),(200,200,.06),asphalt)
    paint=mat('Stage_RoadPaint','#e6e5db',.85);grid=mat('Stage_Grid','#777e87',.9)
    rectangle('WhiteRoadLine',(0,-1.16,-.0008),(30,.10,.002),paint)
    centers=[(i-2.5)*1.28 for i in range(6)] if lineup else [0]
    if not lineup:
        for x in [-1,1]:rectangle('TwoMetreGrid',(x,0,0),(0.009,2,.0015),grid)
        for y in [-1,1]:rectangle('TwoMetreGrid',(0,y,0),(2,.009,.0015),grid)
    for name,loc,power,size in [('DaylightKey',(-3,4,7),800,5),('FrontFill',(1,-4,4),270,4)]:
        data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size
        ob=bpy.data.objects.new(name,data);sc.collection.objects.link(ob);ob.location=loc;ob.rotation_euler=(-ob.location).to_track_quat('-Z','Y').to_euler()
    data=bpy.data.cameras.new('PresentationCamera');cam=bpy.data.objects.new('PresentationCamera',data);sc.collection.objects.link(cam);sc.camera=cam;data.type='ORTHO';data.lens=50
    return cam

def camera(cam,front=False,lineup=False):
    target=Vector((0,0,.38 if not lineup else .35))
    if front:direction=Vector((.48,-.77,.63)).normalized()
    else:
        az=math.radians(18);el=math.radians(55)
        direction=Vector((math.sin(az)*math.cos(el),math.cos(az)*math.cos(el),math.sin(el)))
    cam.location=target+direction*(12 if lineup else 6)
    cam.rotation_euler=(target-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=8.9 if lineup else 2.45

def render(slug):
    cam=stage();camera(cam);sc=bpy.context.scene
    sc.render.filepath=str(OUT/f'{slug}.png');bpy.ops.render.render(write_still=True)
    camera(cam,front=True);sc.render.filepath=str(OUT/f'{slug}-front.png');bpy.ops.render.render(write_still=True)

def manifest():
    records=[json.loads((OUT/f'{s}-record.json').read_text()) for s in SLUGS if (OUT/f'{s}-record.json').exists()]
    (OUT/'critters.json').write_text(json.dumps(records,indent=2)+'\n')
    return records

def lineup():
    reset();records=manifest()
    # Match the game azimuth: a row perpendicular to the viewing direction.
    az=math.radians(18)
    for i,r in enumerate(records):
        bpy.ops.import_scene.gltf(filepath=str(OUT/r['glb']))
        t=((len(records)-1)/2-i)*1.28
        for o in bpy.context.selected_objects:o.location=(t*math.cos(az),-t*math.sin(az),0)
    cam=stage(True);camera(cam,lineup=True);bpy.context.scene.render.filepath=str(OUT/'lineup.png');bpy.ops.render.render(write_still=True)

def main():
    args=sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else []
    targets=args or SLUGS
    if targets==['lineup']:lineup();return
    for slug in targets:
        reset();BUILDERS[slug]();ob,record=finish(slug);render(slug);manifest();print('DELIVERABLE_READY',slug,flush=True)
    if not args:lineup()
if __name__=='__main__':main()
