"""Alien harvester: deterministic clean-scene build, Cycles tile bakes, GLB and proof renders.
Run: blender -b --python-exit-code 1 --python build_alien-harvester.py
Optional -- --probe performs a tiny GPU bake/export/reimport before the full job.
Only downloaded inputs are CC0 Poly Haven jpgs. All other detail is geometry/nodes.
"""
import bpy, math, json, os, sys, struct, urllib.request, random, time
from pathlib import Path
from mathutils import Vector, Matrix
import bmesh
import numpy as np

ROOT=Path(__file__).resolve().parent
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
OUT=ROOT
random.seed(119)
T0=time.time()
PAINTS=[('Obsidian carapace',[.10,.11,.10]),('Deep violet',[.115,.055,.18]),('Bone white',[.68,.64,.52]),('Abyssal teal',[.025,.18,.175]),('Oxblood',[.22,.028,.035]),('Jade',[.08,.29,.16])]
PH_IDS=['metal_plate','metal_plate_02','rusty_metal_02']
# Revision budget: bounded by the user's 80-minute authoring window. Hero: front-port elevated, side ortho, clay, close and rear.
# Geometry 80k..200k triangles; <=9 mats; GLB <=12MB; colour/normal <=1024; ORM <=512.

def log(s): print('HARVESTER',round(time.time()-T0,1),s,flush=True)
def select(o):
    bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def init():
    bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
    for d in list(bpy.data.materials): bpy.data.materials.remove(d)
    sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=24
    pref=bpy.context.preferences.addons['cycles'].preferences
    try: pref.compute_device_type='OPTIX'
    except: pref.compute_device_type='CUDA'
    pref.get_devices()
    gpu=[]
    for d in pref.devices:
        d.use=d.type!='CPU'
        if d.use: gpu.append(d.name)
    assert gpu,'GPU required'
    sc.cycles.device='GPU'; sc.cycles.use_denoising=True
    sc.cycles.max_bounces=5; sc.cycles.diffuse_bounces=2; sc.cycles.glossy_bounces=3
    sc.render.image_settings.file_format='PNG'; sc.render.image_settings.color_mode='RGBA'
    sc.render.film_transparent=False
    sc.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
    sc.view_settings.view_transform='AgX'
    sc.render.resolution_percentage=100
    log('GPU '+str(gpu))
    return sc

sc=init()

def fetch(id,map):
    p=CACHE/f'{id}_{map}_1k.jpg'
    if not p.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
        req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0 (Forge asset authoring)'})
        with urllib.request.urlopen(req,timeout=90) as r: p.write_bytes(r.read())
    img=bpy.data.images.load(str(p),check_existing=True)
    if map!='diff': img.colorspace_settings.name='Non-Color'
    return img

def node(nt,kind):return nt.nodes.new(kind)
def mathnode(nt,op,a,b):
    n=node(nt,'ShaderNodeMath');n.operation=op
    for i,v in enumerate((a,b)):
        if isinstance(v,(float,int)): n.inputs[i].default_value=v
        else:nt.links.new(v,n.inputs[i])
    return n.outputs[0]
def color_mix(nt,a,b,fac,mode='MIX'):
    n=node(nt,'ShaderNodeMixRGB');n.blend_type=mode
    for i,v in enumerate((fac,a,b)):
        if isinstance(v,(float,int)):n.inputs[i].default_value=v
        elif isinstance(v,(tuple,list)):n.inputs[i].default_value=v
        else:nt.links.new(v,n.inputs[i])
    return n.outputs[0]

def bake_material(name,kind,ph):
    """Bake a periodic 8m stock surface, then use it at world scale on every part."""
    log('Bake '+name)
    mat=bpy.data.materials.new(name); mat.use_nodes=True
    nt=mat.node_tree;nt.nodes.clear();lk=nt.links
    out=node(nt,'ShaderNodeOutputMaterial');p=node(nt,'ShaderNodeBsdfPrincipled');lk.new(p.outputs[0],out.inputs['Surface'])
    uv=node(nt,'ShaderNodeTexCoord')
    images={}
    for k in ['diff','nor_gl','arm']:
        t=node(nt,'ShaderNodeTexImage');t.image=fetch(ph,k);t.extension='REPEAT';lk.new(uv.outputs['UV'],t.inputs['Vector']);images[k]=t
    bw=node(nt,'ShaderNodeRGBToBW');lk.new(images['diff'].outputs['Color'],bw.inputs[0])
    noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=92;noise.inputs['Detail'].default_value=3
    lk.new(uv.outputs['UV'],noise.inputs['Vector'])
    # Fine pitting and irregular mill finish remain neutral under faction multiplication.
    if kind=='paint':
        lum=mathnode(nt,'MINIMUM',mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',bw.outputs[0],4.6),.16),.96)
        macro=node(nt,'ShaderNodeTexNoise');macro.inputs['Scale'].default_value=3.5;macro.inputs['Detail'].default_value=5;lk.new(uv.outputs['UV'],macro.inputs['Vector'])
        grime=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',macro.outputs['Fac'],.68),.30)
        # Directional oxidation and fine scratches baked into the neutral reflectance.
        stretch=node(nt,'ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(.7,19,1);lk.new(uv.outputs['UV'],stretch.inputs[0])
        streak=node(nt,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=8;streak.inputs['Detail'].default_value=4;lk.new(stretch.outputs[0],streak.inputs['Vector'])
        sr=node(nt,'ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.32;sr.color_ramp.elements[0].color=(.27,.27,.27,1);sr.color_ramp.elements[1].position=.65;sr.color_ramp.elements[1].color=(1,1,1,1);lk.new(streak.outputs['Fac'],sr.inputs[0])
        base=color_mix(nt,color_mix(nt,lum,grime,1,'MULTIPLY'),sr.outputs[0],.32,'MULTIPLY');rough=.28;metal=.72
    elif kind=='metal':
        base=color_mix(nt,images['diff'].outputs[0],(.12,.15,.19,1),.80);rough=.31;metal=.9
    elif kind=='scorch':
        base=color_mix(nt,images['diff'].outputs[0],(.035,.024,.018,1),.93);rough=.43;metal=.76
    else:
        base=color_mix(nt,images['diff'].outputs[0],(.018,.023,.026,1),.94);rough=.64;metal=.38
    sep=node(nt,'ShaderNodeSeparateColor');lk.new(images['arm'].outputs[0],sep.inputs['Color'])
    r=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',sep.outputs['Green'],.23),rough)
    ao=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',sep.outputs['Red'],.22),.78)
    normal=node(nt,'ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.65
    lk.new(images['nor_gl'].outputs[0],normal.inputs['Color'])
    bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=.17;bump.inputs['Distance'].default_value=.018
    lk.new(noise.outputs['Fac'],bump.inputs['Height']);lk.new(normal.outputs[0],bump.inputs['Normal']);lk.new(bump.outputs[0],p.inputs['Normal'])
    lk.new(base,p.inputs['Base Color']);lk.new(r,p.inputs['Roughness']);p.inputs['Metallic'].default_value=metal
    bpy.ops.mesh.primitive_plane_add(size=8);plane=bpy.context.object;plane.name='Bake_stock';plane.data.materials.append(mat)
    target=node(nt,'ShaderNodeTexImage');nt.nodes.active=target
    emit=node(nt,'ShaderNodeEmission')
    baked={}
    for typ,res in [('Base',1024),('Normal',1024 if kind=='paint' else 512),('ORM',512)]:
        img=bpy.data.images.new(name+'_'+typ,width=res,height=res,alpha=False)
        if typ!='Base':img.colorspace_settings.name='Non-Color'
        target.image=img;nt.nodes.active=target
        sc.render.bake.margin=8;sc.cycles.samples=16
        if typ=='Normal':
            lk.new(p.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
        else:
            if typ=='Base':lk.new(base,emit.inputs['Color'])
            else:
                comb=node(nt,'ShaderNodeCombineColor');lk.new(ao,comb.inputs['Red']);lk.new(r,comb.inputs['Green']);comb.inputs['Blue'].default_value=metal;lk.new(comb.outputs[0],emit.inputs['Color'])
            lk.new(emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
        img.filepath_raw=str(CACHE/(name+'_'+typ+('.jpg' if typ=='Base' else '.png')));img.file_format='JPEG' if typ=='Base' else 'PNG';sc.render.image_settings.quality=90;img.save();baked[typ]=img
    bpy.data.objects.remove(plane,do_unlink=True)
    nt.nodes.clear();out=node(nt,'ShaderNodeOutputMaterial');p=node(nt,'ShaderNodeBsdfPrincipled');lk.new(p.outputs[0],out.inputs['Surface'])
    for typ in ['Base','Normal','ORM']:
        t=node(nt,'ShaderNodeTexImage');t.image=baked[typ];t.name=typ;t.label='Cycles baked 8m tiling stock'
        if typ=='Base':lk.new(t.outputs[0],p.inputs['Base Color'])
        elif typ=='Normal':
            n=node(nt,'ShaderNodeNormalMap');lk.new(t.outputs[0],n.inputs['Color']);lk.new(n.outputs[0],p.inputs['Normal'])
        else:
            s=node(nt,'ShaderNodeSeparateColor');lk.new(t.outputs[0],s.inputs[0]);lk.new(s.outputs['Green'],p.inputs['Roughness']);lk.new(s.outputs['Blue'],p.inputs['Metallic'])
            # Official glTF occlusion hook.
            if 'glTF Material Output' not in bpy.data.node_groups:
                ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
            g=node(nt,'ShaderNodeGroup');g.node_tree=bpy.data.node_groups['glTF Material Output'];lk.new(s.outputs['Red'],g.inputs['Occlusion'])
    return mat

if '--probe' in sys.argv:
    m=bake_material('Probe','paint','metal_plate')
    bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8)
    o=bpy.context.object;o.data.materials.append(m)
    bpy.ops.export_scene.gltf(filepath=str(OUT/'probe.glb'),export_format='GLB',use_selection=True)
    bpy.ops.object.delete()
    for mat in list(bpy.data.materials): bpy.data.materials.remove(mat)
    for group in list(bpy.data.node_groups): bpy.data.node_groups.remove(group)
    bpy.ops.import_scene.gltf(filepath=str(OUT/'probe.glb'))
    assert len([x for x in sc.objects if x.type=='MESH'])==1
    bpy.ops.object.camera_add(location=(3,-4,2));cam=bpy.context.object;cam.rotation_euler=(-cam.location).to_track_quat('-Z','Y').to_euler();sc.camera=cam
    bpy.ops.object.light_add(type='AREA',location=(2,-3,4));bpy.context.object.data.energy=500;bpy.context.object.data.size=4
    sc.render.resolution_x=256;sc.render.resolution_y=256;sc.render.filepath=str(OUT/'probe.png');bpy.ops.render.render(write_still=True)
    log('PROBE_OK');sys.exit(0)

MATS=[bake_material('Paint','paint','metal_plate_02'),bake_material('Titanium','metal','metal_plate'),bake_material('HeatScorched','scorch','rusty_metal_02'),bake_material('Recess','recess','metal_plate')]
# Identical metal stock normal bakes share one image in the delivered GLB.
MATS[3].node_tree.nodes['Normal'].image=MATS[1].node_tree.nodes['Normal'].image

for name,col,strength in [('Lamps',(1,.18,.004,1),1.8),('Engines',(1,.24,.009,1),1.8)]:
    m=bpy.data.materials.new(name);m.use_nodes=True;p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=col;p.inputs['Roughness'].default_value=.34;p.inputs['Metallic'].default_value=.3;p.inputs['Emission Color'].default_value=col;p.inputs['Emission Strength'].default_value=strength;MATS.append(m)

class Geo:
    def __init__(self,name,pivot=(0,0,0)):self.name=name;self.v=[];self.f=[];self.mi=[];self.pivot=Vector(pivot)
    def add(self,vs,fs,mat=0):
        n=len(self.v);self.v.extend([tuple(v) for v in vs]);self.f.extend([tuple(n+i for i in f) for f in fs]);self.mi.extend([mat]*len(fs))
    def object(self):
        me=bpy.data.meshes.new(self.name);me.from_pydata(self.v,[],self.f);me.update()
        # Recalculate closed shell normals; all surfaces use welded edge topology.
        bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces)
        # Remove any accidental zero-area faces before serialization.
        bad=[f for f in bm.faces if f.calc_area()<1e-9]
        if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
        bm.to_mesh(me);bm.free()
        for m in MATS:me.materials.append(m)
        for f,idx in zip(me.polygons,self.mi):f.material_index=idx;f.use_smooth=True
        uv=me.uv_layers.new(name='UVMap')
        # World scale projection: repeated baked material every 8m, no 420m atlas.
        for f in me.polygons:
            n=f.normal;ax=max(range(3),key=lambda k:abs(n[k]));axes=[k for k in range(3) if k!=ax]
            for li in f.loop_indices:
                p=me.vertices[me.loops[li].vertex_index].co
                uv.data[li].uv=(p[axes[0]]/8,p[axes[1]]/8)
        # Angle-split only hard creases, smooth the swept surfaces.
        bm=bmesh.new();bm.from_mesh(me)
        for e in bm.edges:
            if len(e.link_faces)==2 and e.calc_face_angle(0)>math.radians(48):e.smooth=False
        bm.to_mesh(me);bm.free();me.update()
        ob=bpy.data.objects.new(self.name,me);sc.collection.objects.link(ob)
        for v in me.vertices:v.co-=self.pivot
        ob.location=self.pivot
        return ob

h=Geo('Hull')

def tube(g,points,radii,mat=1,sides=12,cap=True,ellipse=1):
    pts=[Vector(p) for p in points];vs=[]
    for j,p in enumerate(pts):
        t=(pts[min(j+1,len(pts)-1)]-pts[max(j-1,0)]).normalized()
        ref=Vector((0,0,1)) if abs(t.z)<.9 else Vector((1,0,0))
        a=t.cross(ref).normalized();b=t.cross(a).normalized();r=radii[j] if hasattr(radii,'__len__') else radii
        for i in range(sides):
            ang=2*math.pi*i/sides;vs.append(p+a*(r*math.cos(ang))+b*(r*ellipse*math.sin(ang)))
    fs=[]
    for j in range(len(pts)-1):
        for i in range(sides):a=j*sides+i;b=j*sides+(i+1)%sides;fs.append((a,b,b+sides,a+sides))
    if cap:fs.extend([tuple(reversed(range(sides))),tuple((len(pts)-1)*sides+i for i in range(sides))])
    g.add(vs,fs,mat)

def ellipsoid(g,c,scale,mat=0,seg=20,rings=10):
    c=Vector(c);vs=[c+Vector((0,0,scale[2]))]
    for j in range(1,rings):
        a=math.pi*j/rings
        for i in range(seg):
            t=2*math.pi*i/seg;vs.append(c+Vector((scale[0]*math.sin(a)*math.cos(t),scale[1]*math.sin(a)*math.sin(t),scale[2]*math.cos(a))))
    vs.append(c-Vector((0,0,scale[2])));bot=len(vs)-1;fs=[]
    for i in range(seg):fs.append((0,1+i,1+(i+1)%seg))
    for j in range(rings-2):
        for i in range(seg):a=1+j*seg+i;b=1+j*seg+(i+1)%seg;fs.append((a,a+seg,b+seg,b))
    for i in range(seg):fs.append((bot,1+(rings-2)*seg+(i+1)%seg,1+(rings-2)*seg+i))
    g.add(vs,fs,mat)

def ring(g,c,axis,rad,minor,mat=1,n=64,k=8,ratio=1):
    c=Vector(c);ax=Vector(axis).normalized();a=ax.cross(Vector((0,0,1)) if abs(ax.z)<.9 else Vector((1,0,0))).normalized();b=ax.cross(a)
    vs=[]
    for i in range(n):
        t=2*math.pi*i/n;out=a*math.cos(t)+b*(math.sin(t)*ratio)
        norm=(a*math.cos(t)+b*math.sin(t)).normalized()
        for j in range(k):s=2*math.pi*j/k;vs.append(c+out*rad+norm*(minor*math.cos(s))+ax*(minor*math.sin(s)))
    fs=[]
    for i in range(n):
        for j in range(k):fs.append((i*k+j,((i+1)%n)*k+j,((i+1)%n)*k+(j+1)%k,i*k+(j+1)%k))
    g.add(vs,fs,mat)

def catmull(points,num):
    p=[Vector(points[0])]+[Vector(x) for x in points]+[Vector(points[-1])];res=[]
    for i in range(1,len(p)-2):
        for j in range(num):
            t=j/num;res.append(.5*((2*p[i])+(-p[i-1]+p[i+1])*t+(2*p[i-1]-5*p[i]+4*p[i+1]-p[i+2])*t*t+(-p[i-1]+3*p[i]-3*p[i+1]+p[i+2])*t*t*t))
    res.append(p[-2]);return res

def rounded_box(g,c,size,mat=1,bevel=.3,basis=None):
    # Six gridded faces projected onto a rounded cuboid, welded by coordinate.
    c=Vector(c);basis=basis or Matrix.Identity(3);half=Vector(size)/2;r=min(bevel,min(half)*.45)
    vals=[-1,-.72,.72,1];vs=[];fs=[];lookup={}
    for ax in range(3):
        other=[k for k in range(3) if k!=ax]
        for sign in [-1,1]:
            grid=[]
            for u in range(4):
                row=[]
                for v in range(4):
                    raw=Vector((0,0,0));raw[ax]=sign*half[ax]
                    for k,t in zip(other,(vals[u],vals[v])):raw[k]=t*half[k] if abs(t)==1 else math.copysign(half[k]-r,t)
                    inn=Vector([max(-half[k]+r,min(half[k]-r,raw[k])) for k in range(3)])
                    delta=raw-inn;pt=inn+(delta.normalized()*r if delta.length else delta);key=tuple(round(x,6) for x in pt)
                    if key not in lookup:lookup[key]=len(vs);vs.append(c+basis@pt)
                    row.append(lookup[key])
                grid.append(row)
            for u in range(3):
                for v in range(3):fs.append((grid[u][v],grid[u+1][v],grid[u+1][v+1],grid[u][v+1]))
    g.add(vs,fs,mat)

# Smooth non-capsular hull loft: blunt face, high shoulder, long torso, pinched tail.
profile=[(-200,31,25,0),(-188,37,28,1),(-158,43,28,2),(-112,51,35,3),(-55,55,40,4),(0,55,40,4),(55,51,34,3),(105,43,28,1),(150,32,20,0),(185,21,13,-1),(202,12,8,-1)]
def shape(y):
    for i in range(len(profile)-1):
        a,b=profile[i:i+2]
        if a[0]<=y<=b[0]:
            # C1-continuous loft: no flattened shoulder or pinched ring at each
            # control station, and the overlaid armour uses the identical skin.
            prev=profile[max(0,i-1)];nxt=profile[min(len(profile)-1,i+2)]
            span=b[0]-a[0];t=(y-a[0])/span
            return tuple((2*t**3-3*t*t+1)*a[k]+(t**3-2*t*t+t)*span*(b[k]-prev[k])/(b[0]-prev[0])+(-2*t**3+3*t*t)*b[k]+(t**3-t*t)*span*(nxt[k]-a[k])/(nxt[0]-a[0]) for k in range(1,4))
    return profile[0][1:] if y<profile[0][0] else profile[-1][1:]
def surface(y,t,offset=0):
    w,z,c=shape(y);return Vector(((w*1.05+offset)*math.sin(t),y,c+(z*1.30+offset)*math.cos(t)))
# Inner continuous skin: intentionally open nose, recessed rear closure.
vs=[];N=96;Y=[-199+i*397/62 for i in range(63)]
for y in Y:
    for i in range(N):vs.append(surface(y,2*math.pi*i/N,-.7))
fs=[]
for j in range(len(Y)-1):
    for i in range(N):a=j*N+i;b=j*N+(i+1)%N;fs.append((a,b,b+N,a+N))
fs.append(tuple((len(Y)-1)*N+i for i in range(N)));h.add(vs,fs,3)

def plate(g,ya,yb,ta,tb,offset=1.3,mat=0,sweep=0):
    # Curved, thick shell patch with rounded rim and subtly swept panel ends.
    nu=10;nv=12;vs=[]
    for layer in [0,1]:
        for j in range(nu+1):
            u=j/nu
            for i in range(nv+1):
                v=i/nv;t=ta+(tb-ta)*v+(.018 if ya<-200 else .042)*math.sin(u*math.pi)*(1 if ta+tb>0 else -1)
                edge=min(u*nu,(1-u)*nu,v*nv,(1-v)*nv,1)
                y=ya+(yb-ya)*u+sweep*math.sin(v*math.pi)*(0.3+.7*u)
                bulge=(.3 if ya<-200 else 1.0)*math.sin(u*math.pi)*math.sin(v*math.pi)
                d=(offset+bulge+.6*edge) if layer==0 else (-.7 if offset>=3 else offset-1.0)
                vs.append(surface(y,t,d))
    fs=[];stride=nv+1;L=(nu+1)*stride
    for j in range(nu):
        for i in range(nv):
            a=j*stride+i;fs.append((a,a+1,a+1+stride,a+stride));fs.append((a+L,a+stride+L,a+stride+1+L,a+1+L))
    border=list(range(stride))+[j*stride+nv for j in range(1,nu+1)]+[nu*stride+i for i in range(nv-1,-1,-1)]+[j*stride for j in range(nu-1,0,-1)]
    for i,a in enumerate(border):b=border[(i+1)%len(border)];fs.append((a,a+L,b+L,b))
    g.add(vs,fs,mat)
    # Fine abrasion rail set into the exposed aft lip.
    p=[surface(yb+sweep*math.sin(i/nv*math.pi),ta+(tb-ta)*i/nv,offset+.13) for i in range(nv+1)]
    tube(g,p,.19,1,6)

segments=[(-197,-169),(-168,-126),(-125,-72),(-71,-26),(-25,17),(18,58),(59,98),(99,133),(134,166),(167,200)]
for j,(a,b) in enumerate(segments):
    # Continuous crown is broad, the lateral lanes step to an oblique shoulder.
    plate(h,a,b,-.63,.63,1.6,0,4 if j>1 else -2)
    for s in [-1,1]:
        for lane,(lo,hi) in enumerate([(.655,1.28),(1.31,1.91),(1.94,2.59)]):
            aa=max(-197,a+(6 if lane%2 else -3));bb=min(200,b+(6 if lane%2 else -3))
            plate(h,aa,bb,s*lo,s*hi,1.3,0,(-7 if lane==0 else 5))
        # Belly overlap plates, exposed between harvesting roots.
        plate(h,a,b,s*2.62,s*3.12,.8,0,-2)

# Wide wrapping shoulder reinforcements break the long axial seams into organic S curves.
for side in [-1,1]:
    for yy in [-78,-3,72]:
        plate(h,yy-10,yy+3,side*.40,side*1.94,3.5,0,-17)
# Forward ventral rib armour: interlocking plates with pressure seals and steel heel strips.
for side in [-1,1]:
    for j,yy in enumerate([-178,-157,-136,-115,-94,-73]):
        plate(h,yy-6,yy+10,side*1.97,side*3.12,3.0,0,-3)
        pts=[surface(yy+10,side*(2.03+i*.102),3.05) for i in range(11)]
        tube(h,pts,.48,2,7)
        for i in [1,5,9]:
            pp=pts[i];nn=Vector((side*math.sin(2.03+i*.102),0,math.cos(2.03+i*.102)))
            tube(h,[pp,pp+nn*.33],.28,1,8)
# Secondary flush access outlines and polished damaged edges at human scale.
for side in [-1,1]:
    for j,yy in enumerate([-177,-143,-111,-84,-49,-15,20,50,91,123,151]):
        for tt in [.35,.91,2.26]:
            theta=side*tt;length=7.0+(j%3)*2.5;dt=.09
            coords=[(yy-length,theta-dt),(yy+length,theta-dt),(yy+length+1,theta-dt*.5),(yy+length+1,theta+dt*.55),(yy+length,theta+dt),(yy-length,theta+dt),(yy-length-1,theta+dt*.5),(yy-length-1,theta-dt*.5)]
            pts=[surface(y,t,3.0) for y,t in coords];tube(h,pts+[pts[0]],.085,3,5)
            # Short chipped/worn segments on the aft edge rather than a uniform silver border.
            for q in range(3):
                t=theta-dt*.7+q*dt*.55;pp=[surface(yy+length+.7,t+k*.012,3.06) for k in range(3)];tube(h,pp,.08,1,5)

# LONG RECESSED LATERAL ORGANS, rounded asymmetric outlines, two scales per side.
def vent_pod(sign,cy,cz,L,H):
    # Outline in length-height coordinates, rounded contour via Catmull Rom.
    raw=[(-L*.53,-H*.12),(-L*.41,H*.35),(-L*.10,H*.53),(L*.38,H*.38),(L*.52,H*.10),(L*.39,-H*.40),(-L*.17,-H*.48)]
    if cy>0:raw=[(-L*.53,-H*.29),(-L*.36,H*.45),(L*.41,H*.42),(L*.52,H*.23),(L*.45,-H*.31),(L*.21,-H*.46),(-L*.35,-H*.43)]
    p2=catmull([(y,0,z) for y,z in raw+[raw[0],raw[1]]],4)[:-4]
    p2=p2[:-1] if (p2[0]-p2[-1]).length<.01 else p2
    # Seat the back into the shell, but keep the entire opening in front of all
    # overlapping armour, including the broad shoulder at the forward end.
    cx=max(shape(cy-L*.58+i*L*1.16/32)[0] for i in range(33))*1.05+3.3
    ringspec=[(.96,-8.0), (1.08,1.2),(1.04,5.4),(.87,6.5),(.79,4.8),(.79,1.8)]
    vs=[];n=len(p2)
    for scale,depth in ringspec:
        for p in p2:vs.append((sign*(cx+depth),cy+p.x*scale,cz+p.z*scale))
    fs=[]
    for j in range(len(ringspec)-1):
        for i in range(n):a=j*n+i;b=j*n+(i+1)%n;fs.append((a,b,b+n,a+n))
    h.add(vs,fs,0)
    # Interior dark lens with actual depth behind the separate luminous slats.
    gvs=[(sign*(cx+2),cy+p.x*.79,cz+p.z*.79) for p in p2];h.add(gvs,[tuple(range(n))],3)
    # Rim binding/scorched seal follows opening.
    rp=[Vector((sign*(cx+6),cy+p.x*.85,cz+p.z*.85)) for p in p2];tube(h,rp+[rp[0]],.42,2,7)
    for k in range(11):
        t=(k+.5)/11;yy=cy-L*.37+t*L*.72
        hh=H*.26*math.sin(math.pi*t)**.45
        # Amber organ fins are inset, with thin black horizontal heat-grille bars.
        rounded_box(h,(sign*(cx+3.2),yy,cz+.3),(1.25,L*.048,hh*2),4,.5)
        for r in range(6):
            zz=cz-hh+(r+.5)*2*hh/6
            rounded_box(h,(sign*(cx+4.0),yy,zz),(.55,L*.054,.28),2,.11)
    # Four parallel inclined armoured louvers, all sharing the same angle and height.
    for k in range(4):
        yy=cy+(k-1.5)*L*.145;hh=H*.245
        axis=Vector((0,3.8,2*hh)).normalized();depth=Vector((sign,0,0));across=axis.cross(depth).normalized()
        B=Matrix(((depth.x,across.x,axis.x),(depth.y,across.y,axis.y),(depth.z,across.z,axis.z)))
        rounded_box(h,(sign*(cx+5.0),yy,cz),(1.85,2.4,2*hh+1),0,.55,B)
    for yy in [cy-L*.28,cy+L*.27]:
        for zz in [cz-H*.35,cz+H*.34]:
            tube(h,[(sign*(cx+4.6),yy,zz),(sign*(cx+5.1),yy,zz)],.4,1,8)
for s in [-1,1]:
    vent_pod(s,-37,0,62,22);vent_pod(s,66,6,67,24)
    # Flush dorsal cooling slots mounted in the curved shell tangent plane.
    for j,cy in enumerate([94,119,143,165]):
        theta=s*.94;p=surface(cy,theta,1.85)
        n=Vector((math.sin(theta),0,math.cos(theta)));aa=Vector((0,1,0));bb=n.cross(aa).normalized();B=Matrix(((aa.x,bb.x,n.x),(aa.y,bb.y,n.y),(aa.z,bb.z,n.z)))
        rounded_box(h,p,(18-j*1.6,7.2,1.9),2,.6,B)
        rounded_box(h,p+n*.7,(15.8-j*1.6,5.2,.9),3,.35,B)
        for k in range(5):
            pp=p+aa*((k-2)*(2.7-j*.24))+n*1.2;rounded_box(h,pp,(1.5,4.5,.45),4,.18,B)

# ANNULAR MAW: thick lips, deep dark conical gullet, nested mechanical rings.
ring(h,(0,-199,0),(0,1,0),28.5,2.1,2,96,10,.89)
ring(h,(0,-201,0),(0,1,0),30.2,.58,1,96,8,.89)
ring(h,(0,-195.4,0),(0,1,0),27.8,.8,0,96,8,.89)
# Funnel behind the intake: ring strips are open; back cap deep inside.
vs=[];n=96
for yy,r in [(-201,27.2),(-195,25.5),(-186,20.2),(-177,13.6),(-169,8.8)]:
    for i in range(n):t=i*math.tau/n;vs.append((r*math.cos(t),yy,r*.89*math.sin(t)))
fs=[]
for j in range(4):
    for i in range(n):a=j*n+i;b=j*n+(i+1)%n;fs.append((a,b,b+n,a+n))
fs.append(tuple(4*n+i for i in range(n)));h.add(vs,fs,2)
ring(h,(0,-171,0),(0,1,0),9.2,1.5,3,64,8,.89)
ring(h,(0,-182,0),(0,1,0),16.6,.5,1,64,6,.89)
for i in range(56):
    t=math.tau*i/56
    # Curved inward-pointing amber vanes, segmented like the reference.
    pts=[]
    for j in range(6):
        u=j/5;r=26.1-7*u;a=t+.085*u;pts.append((r*math.cos(a),-201+11*u,r*.89*math.sin(a)))
    tube(h,pts,[.90,1.05,.98,.81,.54,.23],2,9)
    lit=[Vector(p)+Vector((-.16*math.cos(t),-.98,-.16*math.sin(t))) for p in pts]
    tube(h,lit,[.46,.65,.61,.47,.27,.08],4,8)
    # Dark rib continuing down the gullet from each luminous tooth.
    pts2=[]
    for j in range(6):
        u=j/5;r=24-15*u;a=t+.085+.12*u;pts2.append((r*math.cos(a),-193+23*u,r*.89*math.sin(a)))
    tube(h,pts2,[.6,.72,.68,.55,.4,.28],2,6)
    if i%4==0:
        t2=t;pos=(30.7*math.cos(t2),-202,27.2*math.sin(t2));tube(h,[pos,(pos[0],-202.5,pos[2])],.42,1,8)
ring(h,(0,-178.8,0),(0,1,0),14.2,.42,4,64,6,.89)
# Heavy overhanging brow and lower mandibles swallow the maw rim into the carapace.
# These are closed, curved armour shells, not rods floating beside the cheek.
plate(h,-207,-157,-.93,.93,3.1,0,-6)
for side in [-1,1]:
    plate(h,-205,-151,side*.98,side*1.94,3.2,0,7)
    plate(h,-207,-154,side*2.01,side*3.10,3.0,0,-4)
    # Three recessed hydraulic channels and layered reinforcement around the maw cheek.
    for k in range(3):
        theta=side*(1.35+k*.17)
        pts=[surface(y,theta,3.42) for y in [-198,-195,-185,-180]]
        tube(h,pts,[.38,.68,.68,.2],2,8)
        tube(h,[surface(y,theta,3.9) for y in [-194,-190,-186]],[.12,.24,.12],1,6)
    for theta in [side*.95,side*1.97]:
        pts=[surface(-203+j*5.1,theta,3.4) for j in range(10)]
        tube(h,pts,.5,2,8)
# Irregular impact chips catch light along exposed plate lips, with dark undercuts.
# Chip strips are attached to the armour rim and do not change faction colour regions.
for jj,(ya,yb) in enumerate(segments[:-1]):
    for side in [-1,1]:
        for kk in range(7):
            theta=side*(.20+kk*.355+random.uniform(-.07,.07))
            yy=yb-3+random.uniform(-1,1);dt=random.uniform(.018,.046)
            pp=[surface(yy,theta+i*dt/4,2.0) for i in range(5)]
            tube(h,pp,[.035,.09,.12,.07,.025],1,5)

# Dorsal blades: swept taper, armoured root collars; clustered aft of shoulder.
for row in [-1,0,1]:
    for i,yy in enumerate([-35,-7,21,49,77,104,129,151]):
        if row and i in [0,7]:continue
        theta=row*.43;base=surface(yy+row*3,theta,2.0);ht=(23 if row==0 else 18)*math.sin((i+1)/9*math.pi)**.5*(1+.10*math.sin(i*2.7))
        pts=catmull([base,base+Vector((row*2,3,ht*.4)),base+Vector((row*5,11,ht*.78)),base+Vector((row*7,21,ht))],5)
        rr=[3.6*(1-j/(len(pts)-1))**1.05+.035 for j in range(len(pts))]
        tube(h,pts,rr,2,12,ellipse=.66);ellipsoid(h,base,(5.3,7.8,2.8),0,18,8)
        ring(h,base+Vector((0,0,1.8)),(0,0,1),4.1,.6,2,24,7,1.25)
        tube(h,[base+Vector((row*2,0,1)),base+Vector((row*3,4,ht*.36)),base+Vector((row*5,11,ht*.7))],[.35,.28,.05],1,6)

# Tail split vanes around the engine cluster: swept, no familiar human ship tail.
for s in [-1,1]:
    for zz in [-14,15]:
        pts=catmull([(s*28,132,zz),(s*31,163,zz*1.1),(s*26,198,zz*1.08),(s*21,226,zz*.9)],6)
        rr=[6*(1-i/(len(pts)-1))**.8+.07 for i in range(len(pts))];tube(h,pts,rr,0,14,ellipse=.58)
        tube(h,[p+Vector((0,0,2.2 if zz>0 else -2.2)) for p in pts[:-3]],[.30]*len(pts[:-3]),1,6)

# Three main engines, bevelled ring exits, recessed glowing interiors.
THRUSTERS=[]
for i,(x,z,r,end) in enumerate([(-24,-7,12,214),(24,-7,12,214),(0,15,9,218)]):
    # Nozzles extend clear of the tail skin, with an open casing and a closed
    # emissive bowl. No hidden cap may cut across the glowing exit aperture.
    tube(h,[(x,133,z),(x,147,z),(x,169,z),(x,end-4,z)],[r*.83,r*1.21,r*1.1,r],0,48,cap=False)
    ring(h,(x,151,z),(0,1,0),r*1.16,.9,2,48,8)
    # Exposed hot chamber between overlapping casing collars, visible from the side.
    for yy in [165,167.3,169.6,171.9]:ring(h,(x,yy,z),(0,1,0),r*1.115,.82,5,48,6)
    for yy in [162,175]:ring(h,(x,yy,z),(0,1,0),r*1.15,1.2,2,48,8)
    for k in range(8):
        t=k*math.tau/8
        tube(h,[(x+r*1.17*math.cos(t),161,z+r*1.17*math.sin(t)),(x+r*1.17*math.cos(t),176,z+r*1.17*math.sin(t))],.7,2,7)
    ring(h,(x,end-3,z),(0,1,0),r,1.4,2,64,8)
    ring(h,(x,end,z),(0,1,0),r,.55,1,64,8)
    # Hollow exit bowl in front of the solid engine body.
    vv=[]
    for yy,rr in [(end+.2,r*.9),(end-3,r*.82),(end-6,r*.58)]:
        for k in range(48):t=k*math.tau/48;vv.append((x+rr*math.cos(t),yy,z+rr*math.sin(t)))
    ff=[]
    for j in range(2):
        for k in range(48):a=j*48+k;b=j*48+(k+1)%48;ff.append((a,b,b+48,a+48))
    ff.append(tuple(96+k for k in range(48)));h.add(vv,ff,5)
    ring(h,(x,end-1,z),(0,1,0),r*.65,.85,5,48,8)
    for k in range(16):
        t=k*math.tau/16
        p1=(x+(r+.3)*math.cos(t),end-18,z+(r+.3)*math.sin(t));p2=(x+(r+.4)*math.cos(t),end-1,z+(r+.4)*math.sin(t))
        tube(h,[p1,p2],.6,2,6)
    THRUSTERS.append((x,end+.4,z))

# Six articulated harvesting tentacles, each a single rigid moving node at its socket.
arms=[]
for side in [-1,1]:
    for j,y in enumerate([-54,5,64]):
        idx=len(arms)+1;root=Vector((side*(44-j*1.5),y+(34 if side<0 else 0),-29+j*.4));g=Geo('Arm_'+str(idx),root)
        ellipsoid(h,root+Vector((0,0,2)),(10.2,10.8,5.7),0,24,10)
        axis=Vector((side*.45,0,-.89)).normalized()
        ring(h,root+axis*1.0,axis,8.3,1.55,0,40,8)
        ring(h,root+axis*2.0,axis,7.3,.8,2,32,8)
        ellipsoid(g,root,(6.3,6.3,6.5),1,24,12)
        dy=[[-3,-3,11,18,7],[4,8,13,6,-4],[6,25,39,42,28]][j]
        if side<0:dy=[v+5*math.sin(k*1.4+j) for k,v in enumerate(dy)]
        curl=[(6,dy[0],-12),(18-j,dy[1],-26),(30-j*4,dy[2],-39-j*2),(26-j*4,dy[3],-50-j*2),(16+j,dy[4],-54-j+(3 if side<0 else 0))]
        if j==2:curl=[(5,dy[0],-12),(13,dy[1],-23),(28,dy[2],-34),(28,dy[3],-48),(14,dy[4],-59)]
        if side<0:
            # A second, lower fan gives six distinct hanging silhouettes, with
            # different tip heights rather than three mirrored hidden limbs.
            curl=[(x*1.13,dy,dz*(1.24+.055*j)) for x,dy,dz in curl]
        points=[root]+[root+Vector((side*x,dy,dz)) for x,dy,dz in curl]
        pts=catmull(points,7);N=len(pts)
        rr=[6.5*(1-i/(N-1))+.95 for i in range(N)]
        tube(g,pts,rr,3,16)
        # Overlapping armoured vertebrae with a narrow band of tendon visible.
        for k in range(0,N-2,2):
            a,b=pts[k],pts[min(k+2,N-1)];mid=(a+b)/2;direction=(b-a).normalized();length=(b-a).length
            r=rr[k]+.4
            tube(g,[a,a+direction*.5,b-direction*.55,b],[r*.91,r*1.08,r*1.02,r*.85],0,14,ellipse=.88)
            ring(g,a+direction*.6,direction,r*1.02,.22,1,14,6,.88)
            # One narrow spinal rail down every link; no free-floating greebles.
            lateral=Vector((side,0,0));tube(g,[a+lateral*r*.89,mid+lateral*(r+ .15),b+lateral*r*.79],[.3,.34,.22],2,6)
        # Twin flexor cables running continuously along the lower arm.
        for c in [-1,1]:
            cable=[p+Vector((c*rr[i]*.65,-rr[i]*.72,0)) for i,p in enumerate(pts)];tube(g,cable,.40,2,7)
        end=pts[-1];ellipsoid(g,end,(1.85,2.2,2.3),2,16,8)
        # Three hooked, cutting talons.
        for k in range(3):
            t=k*math.tau/3
            off=Vector((math.cos(t)*5.6,math.sin(t)*5.6,0))
            claw=catmull([end+off*.45,end+off*1.4+Vector((0,1,-3)),end+off*1.2+Vector((0,2,-7)),end+off*.55+Vector((0,2,-10))],4)
            tube(g,claw,[1.65*(1-i/(len(claw)-1))+.025 for i in range(len(claw))],2,10)
            edge=[p+off.normalized()*.23 for p in claw[-7:]];tube(g,edge,[.25,.23,.20,.16,.12,.08,.02],1,6)
        arms.append(g)

# Fine-scale industrial fittings: inset inspection panels, cooling combs, fasteners, pipes.
# All are seated into plates, spaced in belts leaving broad quiet areas of armour.
for side in [-1,1]:
    for j,yy in enumerate([-175,-136,-92,-60,-9,30,108,139,171]):
        theta=side*1.2;p=surface(yy,theta,2)
        n=Vector((math.sin(theta),0,math.cos(theta)));a=Vector((0,1,0));b=n.cross(a).normalized();B=Matrix(((a.x,b.x,n.x),(a.y,b.y,n.y),(a.z,b.z,n.z)))
        rounded_box(h,p,(2.5,3.3,.35),3,.18,B)
        rounded_box(h,p+n*.2,(2.15,2.8,.25),0,.13,B)
        for aa in [-.9,.9]:
            for bb in [-1.15,1.15]:
                q=p+a*aa+b*bb+n*.35;tube(h,[q,q+n*.10],.12,1,7)
        # Small slit receiver: the ship has no windows or cockpit.
        q=p+n*.4+b*(-.7);rounded_box(h,q,(.7,.14,.12),2,.045,B)
    for yy in [-150,-102,-63,-20,20,60,97,132]:
        theta=side*2.04;p=surface(yy,theta,1.5)
        n=Vector((math.sin(theta),0,math.cos(theta)));a=Vector((0,1,0));b=n.cross(a);B=Matrix(((a.x,b.x,n.x),(a.y,b.y,n.y),(a.z,b.z,n.z)))
        rounded_box(h,p,(14,4.4,1.2),3,.4,B)
        for k in range(8):rounded_box(h,p+a*((k-3.5)*1.45)+n*.7,(.68,3.5,.6),1,.18,B)
    # Long braided trunk pipes tucked into the shoulder seams.
    for theta in [side*.66,side*2.61]:
        pts=[surface(-145+i*300/36,theta,1.0) for i in range(37)];tube(h,pts,.55,2,8)
        for k in range(2,35,5):
            pp=pts[k];ring(h,pp,(0,1,0),.67,.12,1,12,6)
# Fine seam rivets follow aft upper-panel lips: human-scale on enormous plates.
for ya,yb in segments[1:-1]:
    for side in [-1,1]:
        for k in range(9):
            t=side*(.72+k*.055);p=surface(yb+4*math.sin(k/8*math.pi),t,2.0);n=Vector((math.sin(t),0,math.cos(t)))
            tube(h,[p,p+n*.26],.22,1,7)

log('Geometry authored '+str(sum(len(g.f) for g in [h]+arms))+' faces')
objs=[g.object() for g in [h]+arms];hull=objs[0]
# Normalize complete visible extents exactly to the requested envelope while keeping hull centre as origin.
bpy.context.view_layer.update()
points=[o.matrix_world@v.co for o in objs for v in o.data.vertices]
lo=Vector([min(v[k] for v in points) for k in range(3)]);hi=Vector([max(v[k] for v in points) for k in range(3)])
scale=Vector((160/(hi.x-lo.x),420/(hi.y-lo.y),120/(hi.z-lo.z)))
yshift=(hi.y+lo.y)/2
for ob in objs:
    pivot=ob.location.copy()
    for v in ob.data.vertices:
        wp=v.co+pivot;wp.y-=yshift;v.co=Vector([wp[k]*scale[k] for k in range(3)])-Vector([((pivot[k]-yshift) if k==1 else pivot[k])*scale[k] for k in range(3)])
    ob.location=Vector([((pivot[k]-yshift) if k==1 else pivot[k])*scale[k] for k in range(3)])
    if ob!=hull:ob.parent=hull
# Hull origin stays exactly zero; compensate the small centre shift into its mesh.
if hull.location.length:
    shift=hull.location.copy()
    for v in hull.data.vertices:v.co+=shift
    hull.location=(0,0,0)
# Explicitly no transforms applied to the moving nodes.
for ob in objs:
    select(ob);mod=ob.modifiers.new('Triangulated runtime surface','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
    if ob==hull:
        dec=ob.modifiers.new('Runtime surface reduction','DECIMATE');dec.ratio=min(.90,(178000-sum(len(a.data.polygons)*2 for a in objs[1:]))/len(ob.data.polygons));dec.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=dec.name)
    if ob==hull:
        weighted=ob.modifiers.new('Area weighted armour shading','WEIGHTED_NORMAL');weighted.keep_sharp=True;weighted.mode='FACE_AREA_WITH_ANGLE';weighted.weight=35
        bpy.ops.object.modifier_apply(modifier=weighted.name)
    me=ob.data;me.update();uv=me.uv_layers.active
    for f in me.polygons:
        ax=max(range(3),key=lambda k:abs(f.normal[k]));axes=[k for k in range(3) if k!=ax]
        for li in f.loop_indices:
            p=me.vertices[me.loops[li].vertex_index].co+ob.location;uv.data[li].uv=(p[axes[0]]/8,p[axes[1]]/8)

def worldp(p):
    p=Vector(p);p.y-=yshift;return Vector([p[k]*scale[k] for k in range(3)])
def empty(name,p,parent=hull):
    o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=worldp(p);o.parent=parent;o.empty_display_size=2;return o
for i,p in enumerate(THRUSTERS):empty('Thruster_'+str(i+1),p)
empty('Camera_Chase',(0,315,110))
empty('Light_Nav_L',(63,5,1))['color']=[1.0,0.0,0.0];empty('Light_Nav_R',(-63,5,1))['color']=[0.0,1.0,0.0];empty('Light_Beacon',surface(34,0,5))['color']=[1.0,.12,.0]
asset=[o for o in sc.objects]
bpy.context.view_layer.update()
tri=sum(len(o.data.polygons) for o in objs)
log('Triangles '+str(tri))
assert 80000<=tri<=200000,tri

# Renderer setup: dark blue space gradient, white upper-left key and cool fill.
world=bpy.data.worlds.new('Deep space');sc.world=world;world.use_nodes=True
nt=world.node_tree;nt.nodes.clear();out=node(nt,'ShaderNodeOutputWorld');bg=node(nt,'ShaderNodeBackground');bg.inputs['Strength'].default_value=.32
tex=node(nt,'ShaderNodeTexCoord');sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(tex.outputs['Normal'],sep.inputs[0])
ramp=node(nt,'ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.2;ramp.color_ramp.elements[0].color=(.007,.012,.024,1);ramp.color_ramp.elements[1].position=.9;ramp.color_ramp.elements[1].color=(.055,.085,.14,1);nt.links.new(sep.outputs['Z'],ramp.inputs[0]);nt.links.new(ramp.outputs[0],bg.inputs['Color']);nt.links.new(bg.outputs[0],out.inputs[0])
def area(name,loc,power,size,col,target=(0,0,0)):
    d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;d.color=col;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler();return o
area('Key daylight',(180,-240,280),850000,180,(1,.93,.84))
area('Cool fill',(-190,-50,70),480000,230,(.51,.68,1))
area('Aft rim',(100,220,200),1250000,170,(.68,.8,1))
area('Under bounce',(40,-80,-200),290000,220,(.64,.72,.9))
area('Arm reflection',(220,-160,-120),650000,220,(.73,.82,1),target=(0,20,-40))
camd=bpy.data.cameras.new('Render camera');cam=bpy.data.objects.new('Render camera',camd);sc.collection.objects.link(cam);sc.camera=cam;camd.clip_end=3000

def camera(kind='hero'):
    if kind=='hero':pos=(510,-665,195);target=(0,-6,-3);camd.type='ORTHO';camd.ortho_scale=440
    elif kind=='side':pos=(650,0,24);target=(0,0,-7);camd.type='ORTHO';camd.ortho_scale=466
    elif kind=='rear':pos=(325,490,180);target=(0,25,-3);camd.type='ORTHO';camd.ortho_scale=450
    else:pos=(139,-234,54);target=(34,-150,0);camd.type='ORTHO';camd.ortho_scale=130
    cam.location=pos;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler()

def render(path,w=1920,hgt=1080,samples=32):
    sc.render.resolution_x=w;sc.render.resolution_y=hgt;sc.cycles.samples=samples;sc.render.filepath=str(OUT/path);log('Render '+str(path));bpy.ops.render.render(write_still=True)

# Paint is exported as a standard image + factor; the factor is patched into the binary JSON.
def set_paint(col):
    nt=MATS[0].node_tree;p=nt.nodes.get('Principled BSDF');t=nt.nodes.get('Base');mix=nt.nodes.get('Faction multiplier')
    if not mix:mix=node(nt,'ShaderNodeMixRGB');mix.name='Faction multiplier';mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(t.outputs[0],mix.inputs[1])
    mix.inputs[2].default_value=(*col,1);nt.links.new(mix.outputs[0],p.inputs['Base Color'])

def export():
    nt=MATS[0].node_tree;p=nt.nodes.get('Principled BSDF');nt.links.new(nt.nodes['Base'].outputs[0],p.inputs['Base Color'])
    bpy.ops.object.select_all(action='DESELECT')
    for o in asset:o.select_set(True)
    bpy.context.view_layer.objects.active=hull
    fp=OUT/'alien-harvester.glb'
    bpy.ops.export_scene.gltf(filepath=str(fp),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_tangents=True,export_materials='EXPORT',export_image_format='AUTO',export_cameras=False,export_lights=False,export_extras=True)
    data=fp.read_bytes();ln,tp=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+ln]);binary=data[28+ln:]
    # Normal/tangent vectors use signed normalized 16-bit values (<0.004 degree error).
    # Position/UV remain float32 in metres. KHR_mesh_quantization is supported by glTF loaders.
    compact={}
    for mesh in doc['meshes']:
        for prim in mesh['primitives']:
            for key in ['NORMAL','TANGENT']:
                if key not in prim['attributes']:continue
                ai=prim['attributes'][key];acc=doc['accessors'][ai];vi=acc['bufferView']
                if vi in compact:continue
                view=doc['bufferViews'][vi];dim=3 if key=='NORMAL' else 4
                assert acc['componentType']==5126 and acc.get('byteOffset',0)==0 and not view.get('byteStride')
                vals=np.frombuffer(binary,dtype='<f4',count=acc['count']*dim,offset=view['byteOffset']).reshape(-1,dim)
                if key=='TANGENT':
                    vals=vals.copy();bad=np.linalg.norm(vals[:,:3],axis=1)<.5
                    if bad.any():
                        na=doc['accessors'][prim['attributes']['NORMAL']];nv=doc['bufferViews'][na['bufferView']]
                        normals=np.frombuffer(binary,dtype='<f4',count=na['count']*3,offset=nv['byteOffset']).reshape(-1,3)[bad]
                        reference=np.eye(3)[np.argmin(np.abs(normals),axis=1)];fallback=np.cross(normals,reference);fallback/=np.linalg.norm(fallback,axis=1)[:,None]
                        vals[bad,:3]=fallback;vals[bad,3]=1;log('Repaired '+str(int(bad.sum()))+' collapsed UV tangent frames')
                packed=np.zeros((len(vals),4),dtype='<i2');packed[:,:dim]=np.rint(np.clip(vals,-1,1)*32767).astype('<i2')
                compact[vi]=packed.tobytes();acc['componentType']=5122;acc['normalized']=True;view['byteStride']=8
    rebuilt=bytearray()
    for vi,view in enumerate(doc['bufferViews']):
        payload=compact.get(vi,binary[view.get('byteOffset',0):view.get('byteOffset',0)+view['byteLength']])
        rebuilt.extend(b'\x00'*((-len(rebuilt))%4));view['byteOffset']=len(rebuilt);view['byteLength']=len(payload);rebuilt.extend(payload)
    rebuilt.extend(b'\x00'*((-len(rebuilt))%4));doc['buffers'][0]['byteLength']=len(rebuilt)
    for key in ['extensionsUsed','extensionsRequired']:
        doc.setdefault(key,[])
        if 'KHR_mesh_quantization' not in doc[key]:doc[key].append('KHR_mesh_quantization')
    for m in doc['materials']:
        if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
    doc['asset']['copyright']='Original Forge alien harvester. CC0 Poly Haven: '+', '.join(PH_IDS)
    blob=json.dumps(doc,separators=(',',':')).encode();blob+=b' '*((-len(blob))%4)
    rest=struct.pack('<II',len(rebuilt),0x004e4942)+rebuilt
    fp.write_bytes(struct.pack('<III',0x46546c67,2,20+len(blob)+len(rest))+struct.pack('<II',len(blob),0x4e4f534a)+blob+rest)
    log('GLB bytes '+str(fp.stat().st_size));assert fp.stat().st_size<=12_000_000
    return doc

doc=export();set_paint(PAINTS[0][1])
comp=bpy.data.node_groups.new('Photographic emissive halo','CompositorNodeTree');sc.compositing_node_group=comp
comp.interface.new_socket(name='Image',in_out='OUTPUT',socket_type='NodeSocketColor')
rl=comp.nodes.new('CompositorNodeRLayers');glare=comp.nodes.new('CompositorNodeGlare');glare.inputs['Type'].default_value='Fog Glow';glare.inputs['Quality'].default_value='High';glare.inputs['Threshold'].default_value=1.1;glare.inputs['Strength'].default_value=.20;glare.inputs['Size'].default_value=.22
co=comp.nodes.new('NodeGroupOutput');comp.links.new(rl.outputs['Image'],glare.inputs['Image']);comp.links.new(glare.outputs['Image'],co.inputs['Image'])

def game(v):return [round(v.x,5),round(v.z,5),round(-v.y,5)]
points=[o.matrix_world@v.co for o in objs for v in o.data.vertices]
lo=[min(game(v)[k] for v in points) for k in range(3)];hi=[max(game(v)[k] for v in points) for k in range(3)]
nodeinfo={o.name:{'position':game(o.matrix_world.translation),'type':'mesh' if o.type=='MESH' else 'empty','parent':o.parent.name if o.parent else None} for o in asset}
meta={'asset':'alien-harvester','units':'metres','forward':'+Z','up':'+Y','left':'+X','origin':'Hull centre of mass approximation','nodes':nodeinfo,'bounding_box':{'min':lo,'max':hi,'size':[round(hi[k]-lo[k],4) for k in range(3)]},'axes':{o.name:[0,0,1] for o in objs[1:]},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'triangles':tri,'materials':[m.name for m in MATS],'texture_world_repeat_metres':8,'bake':{'engine':'Cycles','device':'GPU','samples':16,'base_size':1024,'normal_sizes':[1024,512],'orm_size':512,'sources':PH_IDS},'checks':{'moving_transforms_applied':False,'pivot_rotation_degrees':30,'export_helpers':False}}
(OUT/'nodes.json').write_text(json.dumps(meta,indent=2))
(OUT/'NOTES.md').write_text('\n'.join([
'Original 420 × 160 × 120 m alien harvester: layered carapace, intake maw, amber cooling vents, dorsal spines, six pivoted arms and three engine sockets.',
f'{tri:,} triangles; seven named mesh nodes; GLB { (OUT/"alien-harvester.glb").stat().st_size/1048576:.2f} MiB; nose +Z, up +Y, left +X, metres.',
'Materials: Paint, Titanium, HeatScorched, Recess, Lamps, Engines; Paint neutral texture × linear faction colour, default [0.10, 0.11, 0.10].',
'Cycles GPU baked tiling sets: 1024² base-colour and Paint normal; shared 512² metal normals; 512² ORM; 8 m world-scale UV repeats; no unique hull atlas.',
'Poly Haven CC0 IDs: metal_plate, metal_plate_02, rusty_metal_02; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/metal_plate_02 ; https://polyhaven.com/a/rusty_metal_02 .',
'Reference differences: six harvesting arms as requested, cleaner broad armour plates, bespoke vent fairings and three recessed rear nozzles; no windows, lettering or logos.'
])+'\n')
# Clay first: verifies actual built silhouettes before the material presentation.
camera();clay=bpy.data.materials.new('QA clay');clay.diffuse_color=(.36,.36,.36,1);clay.use_nodes=True;clay.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value=.72;clay.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value=(.36,.36,.36,1)
sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);sc.view_layers[0].material_override=None
render('final.png',1920,1080,40)
camera('side');render('side.png',1920,1080,28)
camera('rear');render('rear.png',1280,720,24)
camera('close');render('close.png',1280,720,28)
# Proof of whole-assembly articulation: rotate each root 30 degrees about game +Z = Blender -Y.
camera()
for ob in objs[1:]:ob.rotation_euler.y=math.radians(-30)
bpy.context.view_layer.update()
for ob in objs[1:]:
    assert max(abs(a-b) for a,b in zip(game(ob.matrix_world.translation),nodeinfo[ob.name]['position']))<1e-4, 'Root pivot moved'
render('arms-test.png',1280,720,24)
for ob in objs[1:]:ob.rotation_euler=(0,0,0)
# Six same-view paint renders assembled losslessly in Blender (no external dependency).
W,H=640,360;canvas=np.zeros((H*2,W*3,4),dtype=np.float32)
for i,(name,col) in enumerate(PAINTS):
    set_paint(col);render('paint-'+str(i)+'.png',W,H,16)
    im=bpy.data.images.load(str(OUT/('paint-'+str(i)+'.png')),check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(H,W,4)
    row=1-i//3;column=i%3;canvas[row*H:(row+1)*H,column*W:(column+1)*W]=a;bpy.data.images.remove(im)
img=bpy.data.images.new('Paint comparison',width=W*3,height=H*2,alpha=True);img.pixels.foreach_set(canvas.ravel());img.filepath_raw=str(OUT/'paints.png');img.file_format='PNG';img.save()
set_paint(PAINTS[0][1]);camera();sc.render.resolution_x=1920;sc.render.resolution_y=1080
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'harvester-source.blend'))
log('COMPLETE')
