#!/usr/bin/env python3
"""Original 28 m saucer. Blender 5.2 / Cycles OptiX. Run from an empty scene.
All downloaded inputs are CC0 Poly Haven blue_metal_plate, no other imagery.
Source convention: Blender Z up, -Y nose; glTF +Y up, +Z nose.
Deliverable generation includes real Cycles tile bakes, pivot trial and renders.
"""
import bpy, bmesh, math, os, sys, json, struct, urllib.request, subprocess, time, hashlib
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin, cos, pi
import numpy as np
ROOT=Path(__file__).resolve().parent
BUILD_SCRIPT_SHA256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
START=time.time()
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene; S.unit_settings.system='METRIC';S.unit_settings.scale_length=1
S.render.engine='CYCLES';S.cycles.samples=32;S.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for dev in prefs.devices:dev.use=dev.type=='OPTIX'
S.cycles.device='GPU';S.cycles.max_bounces=7
S.render.image_settings.file_format='PNG';S.render.film_transparent=False
S.view_settings.view_transform='AgX'
S.view_settings.look='AgX - Medium High Contrast'
S.render.resolution_percentage=100
S.render.bake.margin=12
PH='blue_metal_plate'
for channel in ['diff','nor_gl','arm']:
    dest=CACHE/f'{PH}_{channel}_1k.jpg'
    if not dest.exists():
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{PH}/{dest.name}'
        subprocess.run(['curl','--fail','-L','--retry','3','-A','Mozilla/5.0','-o',str(dest),url],check=True)
print('INPUTS_READY',flush=True)
def image_load(path,noncolor=False):
    im=bpy.data.images.load(str(path),check_existing=True)
    if noncolor:im.colorspace_settings.name='Non-Color'
    return im
photo=image_load(CACHE/f'{PH}_diff_1k.jpg')
photo_n=image_load(CACHE/f'{PH}_nor_gl_1k.jpg',True)
photo_orm=image_load(CACHE/f'{PH}_arm_1k.jpg',True)
def node(nt,typ):return nt.nodes.new(typ)
def mathnode(nt,operation,a,b):
    n=node(nt,'ShaderNodeMath');n.operation=operation
    for i,v in enumerate([a,b]):
        if hasattr(v,'is_output'):nt.links.new(v,n.inputs[i])
        else:n.inputs[i].default_value=v
    return n.outputs[0]
def ramp(nt,inp,colors):
    n=node(nt,'ShaderNodeValToRGB');nt.links.new(inp,n.inputs[0])
    for e in list(n.color_ramp.elements)[2:]:n.color_ramp.elements.remove(e)
    for e,(pos,col) in zip(n.color_ramp.elements,colors):e.position=pos;e.color=(*col,1)
    return n.outputs['Color']
def make_source(name,base,metal,rough):
    m=bpy.data.materials.new(name+'_SOURCE');m.use_nodes=True;nt=m.node_tree;nt.nodes.clear()
    out=node(nt,'ShaderNodeOutputMaterial');p=node(nt,'ShaderNodeBsdfPrincipled');nt.links.new(p.outputs[0],out.inputs[0])
    uv=node(nt,'ShaderNodeTexCoord')
    tx=node(nt,'ShaderNodeTexImage');tx.image=photo;nt.links.new(uv.outputs['UV'],tx.inputs[0])
    bw=node(nt,'ShaderNodeRGBToBW');nt.links.new(tx.outputs[0],bw.inputs[0])
    noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=38;noise.inputs['Detail'].default_value=3;noise.inputs['Roughness'].default_value=.68;nt.links.new(uv.outputs['UV'],noise.inputs[0])
    noise2=node(nt,'ShaderNodeTexNoise');noise2.inputs['Scale'].default_value=230;noise2.inputs['Detail'].default_value=2;nt.links.new(uv.outputs['UV'],noise2.inputs[0])
    # Photographic plate detail is desaturated so Paint remains a neutral multiplier.
    detail=mathnode(nt,'ADD',mathnode(nt,'MULTIPLY',bw.outputs[0],3.5),mathnode(nt,'MULTIPLY',noise.outputs['Fac'],.30))
    if name=='Paint':colors=[(.12,(.67,.67,.67)),(.84,(.97,.97,.97))]
    else:colors=[(.12,tuple(c*.68 for c in base)),(.84,tuple(min(1,c*1.22) for c in base))]
    color=ramp(nt,detail,colors)
    if name=='Engines':
        centered=node(nt,'ShaderNodeVectorMath');centered.operation='SUBTRACT';nt.links.new(uv.outputs['UV'],centered.inputs[0]);centered.inputs[1].default_value=(.5,.5,0)
        length=node(nt,'ShaderNodeVectorMath');length.operation='LENGTH';nt.links.new(centered.outputs[0],length.inputs[0])
        optics=node(nt,'ShaderNodeValToRGB');nt.links.new(length.outputs['Value'],optics.inputs[0]);cr=optics.color_ramp
        cr.elements[0].position=.0;cr.elements[0].color=(.32,.82,1,1);cr.elements[1].position=.52;cr.elements[1].color=(.018,.17,.32,1)
        mid=cr.elements.new(.31);mid.color=(.08,.51,.80,1)
        wave=node(nt,'ShaderNodeTexWave');wave.wave_type='RINGS';wave.rings_direction='Z';wave.inputs['Scale'].default_value=7;wave.inputs['Distortion'].default_value=.35;nt.links.new(centered.outputs[0],wave.inputs['Vector'])
        ripple=node(nt,'ShaderNodeMixRGB');ripple.blend_type='MULTIPLY';ripple.inputs[0].default_value=.58;nt.links.new(optics.outputs[0],ripple.inputs[1]);nt.links.new(wave.outputs['Color'],ripple.inputs[2]);color=ripple.outputs[0]
    nt.links.new(color,p.inputs['Base Color'])
    arm=node(nt,'ShaderNodeTexImage');arm.image=photo_orm;nt.links.new(uv.outputs['UV'],arm.inputs[0]);sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(arm.outputs[0],sep.inputs[0])
    roughsock=mathnode(nt,'ADD',rough-.08,mathnode(nt,'MULTIPLY',sep.outputs['Green'],.16));nt.links.new(roughsock,p.inputs['Roughness']);p.inputs['Metallic'].default_value=metal
    nm=node(nt,'ShaderNodeTexImage');nm.image=photo_n;nt.links.new(uv.outputs['UV'],nm.inputs[0]);normal=node(nt,'ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.23 if name not in ['Engines','Glass'] else .02;nt.links.new(nm.outputs[0],normal.inputs['Color'])
    bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=.16;bump.inputs['Distance'].default_value=.012;nt.links.new(noise2.outputs['Fac'],bump.inputs['Height']);nt.links.new(normal.outputs[0],bump.inputs['Normal']);nt.links.new(bump.outputs[0],p.inputs['Normal'])
    if name=='Engines':
        bump.inputs['Strength'].default_value=.035;nt.links.new(wave.outputs['Fac'],bump.inputs['Height'])
    occ=mathnode(nt,'ADD',.82,mathnode(nt,'MULTIPLY',sep.outputs['Red'],.18))
    pack=node(nt,'ShaderNodeCombineColor');nt.links.new(occ,pack.inputs['Red']);nt.links.new(roughsock,pack.inputs['Green']);pack.inputs['Blue'].default_value=metal
    return m,p,out,color,pack.outputs[0]
M={};BAKES={}
# Each repeated material is genuinely baked on a UV tile; large surfaces tile it at 3.4 m.
for name,base,metal,rough,res in [
    ('Paint',(1,1,1),.82,.32,1024),('Titanium',(.40,.44,.48),.94,.26,1024),
    ('Graphite',(.035,.044,.050),.76,.41,512),('Glass',(.10,.20,.23),.18,.16,256),
    ('Engines',(.15,.65,.92),.24,.20,512),('Lamps',(.36,.76,.95),.12,.20,256)]:
    src,p,out,color,orm=make_source(name,base,metal,rough)
    bpy.ops.mesh.primitive_plane_add(size=2);plane=bpy.context.object;plane.name='BakeTile';plane.data.materials.append(src)
    nt=src.node_tree;target=node(nt,'ShaderNodeTexImage');nt.nodes.active=target
    images={}
    for kind,size in [('Base',res),('Normal',res),('ORM',min(512,res))]:
        im=bpy.data.images.new(f'{name}_{kind}',width=size,height=size,alpha=False)
        if kind!='Base':im.colorspace_settings.name='Non-Color'
        target.image=im;nt.nodes.active=target
        S.cycles.samples=8
        if kind=='Normal':
            nt.links.new(p.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL',normal_space='TANGENT')
        else:
            em=node(nt,'ShaderNodeEmission');nt.links.new(color if kind=='Base' else orm,em.inputs[0]);nt.links.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');nt.nodes.remove(em)
        im.filepath_raw=str(CACHE/f'{name}_{kind}.png');im.file_format='PNG';im.save();images[kind]=im
    bpy.data.objects.remove(plane,do_unlink=True)
    m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;p=nt.nodes.get('Principled BSDF')
    tx=node(nt,'ShaderNodeTexImage');tx.image=images['Base']
    if name=='Paint':
        mul=node(nt,'ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;mul.inputs[2].default_value=(.55,.57,.60,1);nt.links.new(tx.outputs[0],mul.inputs[1]);nt.links.new(mul.outputs[0],p.inputs['Base Color']);M['paint_multiply']=mul
        p.inputs['Thin Film Thickness'].default_value=330;p.inputs['Thin Film IOR'].default_value=1.38
    else:nt.links.new(tx.outputs[0],p.inputs['Base Color'])
    packed=node(nt,'ShaderNodeTexImage');packed.image=images['ORM'];sep=node(nt,'ShaderNodeSeparateColor');nt.links.new(packed.outputs[0],sep.inputs[0]);nt.links.new(sep.outputs['Green'],p.inputs['Roughness']);nt.links.new(sep.outputs['Blue'],p.inputs['Metallic'])
    normal=node(nt,'ShaderNodeTexImage');normal.image=images['Normal'];nm=node(nt,'ShaderNodeNormalMap');nt.links.new(normal.outputs[0],nm.inputs['Color']);nt.links.new(nm.outputs[0],p.inputs['Normal'])
    # Standard glTF 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')
    g=node(nt,'ShaderNodeGroup');g.node_tree=group;nt.links.new(sep.outputs['Red'],g.inputs['Occlusion'])
    if name in ['Lamps','Engines']:
        nt.links.new(tx.outputs[0],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=2.2 if name=='Engines' else 3.2
    if name=='Glass':
        p.inputs['Alpha'].default_value=.68;p.inputs['Coat Weight'].default_value=.55
        m.surface_render_method='DITHERED'
    M[name]=m;BAKES[name]={k:str(v.filepath_raw) for k,v in images.items()}
    print('BAKED',name,round(time.time()-START,1),flush=True)
# Geometry tools. Every mesh comes with metre-scaled UVs.
fixed=[];moving=[]
def mesh(name,verts,faces,mat,part='fixed',uv=None):
    me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
    ob=bpy.data.objects.new(name,me);S.collection.objects.link(ob);me.materials.append(M[mat])
    for f in me.polygons:f.use_smooth=True
    layer=me.uv_layers.new(name='UVMap')
    for poly in me.polygons:
        for li in poly.loop_indices:
            vi=me.loops[li].vertex_index;v=verts[vi]
            layer.data[li].uv=uv[vi] if uv else (v[0]/3.4,v[1]/3.4)
    (moving if part=='ring' else fixed).append(ob)
    return ob

def lathe(name,profile,mat='Paint',n=160,part='fixed',center=(0,0,0),start=0,end=2*pi):
    vs=[];uv=[];fs=[];dist=[0]
    for (r,z),(r2,z2) in zip(profile,profile[1:]):dist.append(dist[-1]+math.hypot(r2-r,z2-z))
    for j,(r,z) in enumerate(profile):
        for i in range(n+1):
            a=start+(end-start)*i/n
            vs.append((center[0]+r*cos(a),center[1]+r*sin(a),center[2]+z));uv.append((a*max(r,1)/3.4,dist[j]/3.4))
    for j in range(len(profile)-1):
        for i in range(n):
            a=j*(n+1)+i;b=a+n+1;fs.append((a,a+1,b+1,b))
    ob=mesh(name,vs,fs,mat,part,uv)
    # Recalculate manifold direction; profiles are generally outside-to-inside.
    bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=1e-6);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(ob.data);bm.free()
    return ob

def bevel(ob,width=.03,segments=2):
    bpy.ops.object.select_all(action='DESELECT');ob.select_set(True);bpy.context.view_layer.objects.active=ob
    mod=ob.modifiers.new('Machined edge radius','BEVEL');mod.width=width;mod.segments=segments
    bpy.ops.object.modifier_apply(modifier=mod.name)
    return ob

def box(name,loc,size,mat='Titanium',rounding=.03,part='fixed',angle=0):
    x,y,z=[q*.5 for q in size];vs=[(i*x,j*y,k*z) for i,j,k 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)]]
    vs=[(loc[0]+v[0]*cos(angle)-v[1]*sin(angle),loc[1]+v[0]*sin(angle)+v[1]*cos(angle),loc[2]+v[2]) for v in vs]
    ob=mesh(name,vs,[(0,3,2,1),(4,5,6,7),(0,1,5,4),(1,2,6,5),(2,3,7,6),(3,0,4,7)],mat,part)
    bevel(ob,rounding,2)
    for p in ob.data.polygons:p.use_smooth=False
    return ob

def line(name,points,r=.012,mat='Graphite',part='fixed',closed=False):
    cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=1;cu.bevel_depth=r;cu.bevel_resolution=0
    sp=cu.splines.new('POLY');sp.points.add(len(points)-1)
    for p,v in zip(sp.points,points):p.co=(*v,1)
    sp.use_cyclic_u=closed
    ob=bpy.data.objects.new(name,cu);S.collection.objects.link(ob);cu.materials.append(M[mat])
    bpy.ops.object.select_all(action='DESELECT');ob.select_set(True);bpy.context.view_layer.objects.active=ob;bpy.ops.object.convert(target='MESH')
    (moving if part=='ring' else fixed).append(ob);return ob

def circle(name,r,z,mat='Titanium',th=.025,part='fixed',center=(0,0),n=128):
    return line(name,[(center[0]+r*cos(i*2*pi/n),center[1]+r*sin(i*2*pi/n),z) for i in range(n)],th,mat,part,True)

def capsule_outline(w,h,n=8):
    pts=[];r=h/2
    for cx,a0 in [(w/2-r,-pi/2),(-w/2+r,pi/2)]:
        for i in range(n+1):
            a=a0+pi*i/n;pts.append((cx+r*cos(a),r*sin(a)))
    return pts

def port(name,angle,r,z,w,h,part='ring',lightmat='Lamps'):
    # Nested capsule rings provide actual recess depth, softly radiused bezel, black gasket.
    tangent=Vector((-sin(angle),cos(angle),0));rad=Vector((cos(angle),sin(angle),0));up=Vector((0,0,1))
    profiles=[(w+.08,h+.08,r-.025),(w+.08,h+.08,r+.008),(w+.045,h+.045,r+.025),(w,h,r+.023),(w-.035,h-.035,r+.005)]
    vs=[];uv=[]
    for ww,hh,rr in profiles:
        for x,y in capsule_outline(ww,hh,6):
            v=rad*rr+tangent*x+up*(z+y);vs.append(v);uv.append((x*2,y*2))
    count=len(capsule_outline(w,h,6));fs=[]
    for k in range(len(profiles)-1):
        for i in range(count):j=(i+1)%count;fs.append((k*count+i,k*count+j,(k+1)*count+j,(k+1)*count+i))
    mesh(name+'_bezel',vs,fs,'Titanium',part,uv)
    pts=capsule_outline(w-.045,h-.045,8);vs=[rad*(r+.002)+tangent*x+up*(z+y) for x,y in pts]
    mesh(name+'_recess',vs,[tuple(range(len(vs)))],'Graphite',part)
    pts=capsule_outline(w-.12,h-.07,8);vs=[rad*(r+.008)+tangent*x+up*(z+y) for x,y in pts]
    mesh(name+'_diffuser',vs,[tuple(range(len(vs)))],lightmat,part)
# Primary hull: continuous engineered double-convex shell, no exposed panel gaps.
upper=[(0,2.34),(3.8,2.34),(4.2,2.28),(4.65,2.12),(5.3,2.01),(6.1,1.92),(7.1,1.75),(8.2,1.53),(9.3,1.26),(10.3,.99),(11.4,.70),(11.72,.58)]
lower=[(11.72,-.64),(11.4,-.82),(10.5,-1.12),(9.4,-1.43),(8.1,-1.70),(6.8,-1.90),(5.4,-2.03),(3.8,-2.12),(0,-2.14)]
lathe('Pressure hull',upper+lower+[(0,2.34)],n=160)
# Ring is an annular, closed double-convex shell with its own central axis.
ring_profile=[(11.63,.68),(12.0,.60),(12.6,.47),(13.15,.30),(13.65,.10),(13.91,-.02),(14,-.09),(14,-.16),(13.84,-.19),(13.77,-.25),(13.77,-.60),(13.85,-.66),(14,-.70),(14,-.78),(13.9,-.86),(13.4,-1.04),(12.7,-1.22),(12,-1.36),(11.63,-1.36),(11.63,.68)]
lathe('Rotating annular shell',ring_profile,n=160,part='ring')
lathe('Inset port channel',[(13.78,-.62),(13.78,-.24)],'Graphite',160,'ring')
circle('Upper rolled lip',13.93,-.18,th=.026,part='ring')
circle('Lower rolled lip',13.93,-.685,th=.025,part='ring')
for i in range(32):port(f'Rim light {i+1}',i*2*pi/32,13.80,-.43,1.48,.225)
# Fixed bearing race is captured under the moving rim, inboard of the cosmetic joint.
lathe('Ring bearing',[(11.5,.66),(11.64,.64),(11.68,.58),(11.68,-1.30),(11.60,-1.37),(11.5,-1.35)],'Graphite',160)
circle('Bearing race trim',11.55,.71,th=.035)
# Fine, flush panel joints, deliberately sparse. 12 upper radial bays plus outer ring seams.
def interp(profile,r):
    pts=sorted(profile)
    for (a,z),(b,w) in zip(pts,pts[1:]):
        if a<=r<=b:return z+(w-z)*(r-a)/(b-a) if b>a else z
    return pts[-1][1]
for i in range(24):
    a=i*2*pi/24
    pts=[(r*cos(a),r*sin(a),interp(upper,r)+.006) for r in [4.8,5.3,6.1,7.1,8.2,9.3,10.3,11.4]]
    line('Upper skin joint',pts,.011)
    pts=[(r*cos(a),r*sin(a),z+.004) for r,z in ring_profile[:6]]
    line('Ring skin joint',pts,.010,'Graphite','ring')
    pts=[(r*cos(a+.045),r*sin(a+.045),z-.006) for r,z in lower[:8]]
    line('Lower skin joint',pts,.011)
for r in [5.05,8.20,10.7]:circle('Concentric flush seam',r,interp(upper,r)+.002,'Graphite',.009)
for r in [4.6,8.0,10.8]:circle('Belly flush seam',r,interp(lower,r)-.003,'Graphite',.010)
# Raised dome and narrow observation belt. Window gaps are real openings into the bridge.
lathe('Dome plinth',[(0,2.16),(4.55,2.16),(4.58,2.26),(4.45,2.43),(4.35,2.57),(4.25,2.62),(4.10,2.62),(4.10,2.24),(0,2.24)],n=192)
circle('Bridge sill',4.25,2.64,th=.034)
lathe('Bridge crown rail',[(3.68,3.23),(3.85,3.23),(3.86,3.31),(3.78,3.40),(3.65,3.40)],'Titanium',192)
for i in range(12):
    a=i*2*pi/12;span=2*pi/12
    lathe('Bridge glass',[(4.22,2.67),(3.72,3.21)],'Glass',12,start=a+.038,end=a+span-.038)
    lathe('Window mullion',[(4.22,2.61),(4.31,2.64),(3.80,3.25),(3.70,3.30),(3.68,3.25),(4.16,2.64),(4.22,2.61)],'Paint',3,start=a-.032,end=a+.032)
# Ellipsoid cap with low broad crown.
cap=[]
for i in range(25):
    t=i*pi/2/24;cap.append((3.78*cos(t),3.37+1.83*sin(t)))
cap += [(0,3.32),(3.78,3.32),(3.78,3.37)]
lathe('Central dome',cap,n=192)
circle('Dome equatorial seal',3.76,3.39,'Graphite',.018)
# A modest crown maintenance panel, no text or emblems.
lathe('Crown access seam',[(.78,5.159),(.8,5.154)],'Graphite',128)
# Visible interior bridge: deck, 3 seats, recessed console and control sticks behind tinted belt.
lathe('Bridge interior deck',[(0,2.35),(3.65,2.35),(3.65,2.44),(0,2.44)],'Graphite',96)
for x in [-1.25,0,1.25]:
    box('Seat pedestal',(x,-1.30,2.59),(.24,.25,.3),'Titanium')
    box('Seat cushion',(x,-1.30,2.78),(.57,.52,.14),'Graphite',.06)
    box('Seat back',(x,-1.03,3.08),(.59,.16,.70),'Graphite',.065)
    box('Control console',(x,-2.30,2.89),(.82,.35,.35),'Graphite',.06)
    box('Dim console display',(x,-2.35,3.072),(.58,.20,.012),'Glass',.014)
    line('Control stick',[(x+.3,-1.7,2.6),(x+.3,-1.7,2.92),(x+.3,-1.76,3.0)],.035,'Titanium')
# Four actual recessed service pockets cut into the pressure hull, with rounded rims.
pressure_hull=fixed[0]
for i in range(4):
    a=pi/4+pi/24+i*pi/2;r=8.8;x,y=r*cos(a),r*sin(a);z=interp(upper,r)
    rot=Matrix.Rotation(math.atan(.25),4,Vector((-sin(a),cos(a),0)));pivot=Vector((x,y,z))
    cutter=box('Vent pocket cutter',(x,y,z-.13),(1.13,.59,.43),'Graphite',.065,angle=a)
    for v in cutter.data.vertices:v.co=pivot+rot@(v.co-pivot)
    bpy.ops.object.select_all(action='DESELECT');pressure_hull.select_set(True);bpy.context.view_layer.objects.active=pressure_hull
    mod=pressure_hull.modifiers.new('Recessed service well','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter
    bpy.ops.object.modifier_apply(modifier=mod.name);fixed.remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
    vent_start=len(fixed)
    box('Vent black well',(x,y,z-.27),(1.12,.58,.03),'Graphite',.055,angle=a)
    for sign in [-1,1]:
        d=sign*.322
        box('Vent long rim',(x-d*sin(a),y+d*cos(a),z+.007),(1.23,.055,.045),'Titanium',.018,angle=a)
        d=sign*.59
        box('Vent end rim',(x+d*cos(a),y+d*sin(a),z+.007),(.055,.61,.045),'Titanium',.018,angle=a)
    for j in range(6):
        d=(j-2.5)*.15
        box('Recessed vent blade',(x+d*cos(a),y+d*sin(a),z-.09),(.055,.52,.075),'Titanium',.018,angle=a)
    for ob in fixed[vent_start:]:
        for v in ob.data.vertices:v.co=pivot+rot@(v.co-pivot)
# Flush inspection catches and countersunk fasteners, positioned in coherent service groups.
for i in range(24):
    a=(i+.45)*2*pi/24;r=9.8;z=interp(upper,r)
    box('Panel latch recess',(r*cos(a),r*sin(a),z+.011),(.24,.12,.024),'Graphite',.035,angle=a)
    box('Panel latch',(r*cos(a),r*sin(a),z+.028),(.17,.065,.025),'Titanium',.021,angle=a)
# Three low teardrop/radial landing-leg pods, all doors shut, with sealed centre joint.
for i in range(3):
    a=-pi/2+i*2*pi/3;r=6.50;cx,cy=r*cos(a),r*sin(a)
    pod=lathe('Retracted landing pod',[(0,-1.80),(1.37,-1.80),(1.40,-1.92),(1.35,-2.13),(1.20,-2.36),(.98,-2.47),(.78,-2.49),(0,-2.49)],'Paint',64,center=(cx,cy,0))
    circle('Pod door rim',1.12,-2.405,'Titanium',.022,center=(cx,cy),n=64)
    line('Closed gear-door seal',[(cx-.91*cos(a),cy-.91*sin(a),-2.488),(cx+.91*cos(a),cy+.91*sin(a),-2.488)],.013)
    for sign in [-1,1]:
        box('Pod lock',(cx+sign*.8*cos(a),cy+sign*.8*sin(a),-2.49),(.16,.12,.04),'Titanium',.022,angle=a)
# Belly emitter: annular focusing hardware, a thick protected lens and its optical seal.
lathe('Emitter mount',[(0,-2.05),(3.15,-2.05),(3.21,-2.12),(3.13,-2.32),(2.93,-2.52),(2.72,-2.68),(2.53,-2.76),(2.42,-2.94),(2.25,-3.04),(2.20,-2.93),(0,-2.93)],'Paint',192)
lathe('Emitter focusing trim',[(2.30,-3.01),(2.37,-3.07),(2.38,-3.13),(2.29,-3.20),(2.12,-3.20),(2.10,-3.12),(2.30,-3.01)],'Titanium',192)
lathe('Emitter optical gasket',[(2.12,-3.12),(2.21,-3.17),(2.13,-3.23),(2.02,-3.23)],'Graphite',192)
lens=[]
for i in range(13):
    r=2.11*(1-i/12);lens.append((r,-3.19-.11*(1-(r/2.11)**2)))
lens += [(0,-3.04),(2.11,-3.04),(2.11,-3.19)]
lens_ob=lathe('Belly emitter lens',lens,'Engines',128)
for loop in lens_ob.data.loops:
    v=lens_ob.data.vertices[loop.vertex_index].co
    lens_ob.data.uv_layers.active.data[loop.index].uv=(.5+v.x/4.22,.5+v.y/4.22)
for i in range(16):
    a=i*pi/8
    line('Emitter radial collimator',[(r*cos(a),r*sin(a),z-.008) for r,z in [(2.45,-2.9),(2.55,-2.75),(2.72,-2.68),(2.96,-2.48)]],.017,'Titanium')
# Rear paired manoeuvring/propulsion exits, flush in the lower hull; no invented guns.
for sign in [-1,1]:
    a=pi/2+sign*.165
    shroud=lathe('Aft drive fairing',[(0,-1.10),(.50,-1.10),(.76,-1.21),(.86,-1.40),(.86,-1.56),(.78,-1.73),(.52,-1.85),(0,-1.87)],'Paint',48,center=(-sign*1.68,9.27,0))
    for v in shroud.data.vertices:
        v.co.x=-sign*1.68+(v.co.x+sign*1.68)*1.35
        v.co.y=9.27+(v.co.y-9.27)*1.10
        v.co.z=-1.10+(v.co.z+1.10)*.45
    # Short dark collars meet the fairing and protect the recessed drive diffusers.
    box('Aft port root',(10.30*cos(a),10.30*sin(a),-1.32),(.25,.92,.31),'Graphite',.06,angle=a)
    port('Aft drive',a,10.45,-1.32,.82,.22,'fixed','Engines')
# Assembly joins retain the exact central ring pivot. No transform application touches Ring.
def join_group(objects,name):
    bpy.ops.object.select_all(action='DESELECT')
    for ob in objects:ob.select_set(True)
    bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();ob=bpy.context.object;ob.name=name
    # Geometry already authored in world metres, pivot is identity at centre of mass.
    ob.location=(0,0,0);ob.rotation_euler=(0,0,0);ob.scale=(1,1,1)
    return ob
def shape_z(z):
    if z>3.37:return 3.37+(z-3.37)*(1.23/1.83)
    if z<-.8:return -.8+(z+.8)*1.24
    return z
for ob in fixed+moving:
    for v in ob.data.vertices:v.co.z=shape_z(v.co.z)
hull=join_group(fixed,'Hull');ring=join_group(moving,'Ring')
# Closed pod caps and hull include truly coincident pole verts, welded above. Triangulate explicitly.
for ob in [hull,ring]:
    bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.triangulate(bm,faces=bm.faces);bmesh.ops.dissolve_degenerate(bm,dist=1e-7,edges=bm.edges);bmesh.ops.triangulate(bm,faces=bm.faces);bm.to_mesh(ob.data);bm.free();ob.data.update()
triangles=sum(len(ob.data.polygons) for ob in [hull,ring])
print('GEOMETRY',triangles,flush=True)
assert 30000<=triangles<=80000,triangles
# Required game attachment nodes, coordinates follow the exported glTF axes.
sockets=[]
def empty(name,loc,parent=None):
    ob=bpy.data.objects.new(name,None);S.collection.objects.link(ob);ob.location=(loc[0],loc[1],shape_z(loc[2])) if name!='Camera_Chase' else loc
    if parent:ob.parent=parent
    sockets.append(ob);return ob
empty('Thruster_1',(-10.458*sin(.165),10.458*cos(.165),-1.32),hull);empty('Thruster_2',(10.458*sin(.165),10.458*cos(.165),-1.32),hull)
empty('Seat_Pilot',(0,-1.30,2.83),hull)
empty('Camera_Chase',(0,23,11))
empty('Light_Nav_L',(13.98,0,-.43),hull);empty('Light_Nav_R',(-13.98,0,-.43),hull)
empty('Light_Beacon',(0,0,5.20),hull)
# Lighting: photographic, no ground or backdrop mesh in the exported asset.
world=bpy.data.worlds.new('Near-black blue space');S.world=world;world.use_nodes=True;nt=world.node_tree;nt.nodes.clear()
out=node(nt,'ShaderNodeOutputWorld');bg=node(nt,'ShaderNodeBackground');bg.inputs['Color'].default_value=(.11,.15,.22,1);bg.inputs['Strength'].default_value=.32
tex=node(nt,'ShaderNodeTexCoord');sep=node(nt,'ShaderNodeSeparateXYZ');nt.links.new(tex.outputs['Normal'],sep.inputs[0]);grad=ramp(nt,sep.outputs['Z'],[(0,(.001,.0025,.006)),(1,(.008,.015,.030))])
back=node(nt,'ShaderNodeBackground');nt.links.new(grad,back.inputs[0]);back.inputs['Strength'].default_value=.6
path=node(nt,'ShaderNodeLightPath');mix=node(nt,'ShaderNodeMixShader');nt.links.new(path.outputs['Is Camera Ray'],mix.inputs[0]);nt.links.new(bg.outputs[0],mix.inputs[1]);nt.links.new(back.outputs[0],mix.inputs[2]);nt.links.new(mix.outputs[0],out.inputs[0])
def area(name,loc,power,color,size,target=(0,0,0)):
    data=bpy.data.lights.new(name,'AREA');data.energy=power;data.color=color;data.shape='DISK';data.size=size
    ob=bpy.data.objects.new(name,data);S.collection.objects.link(ob);ob.location=loc;ob.rotation_euler=(Vector(target)-ob.location).to_track_quat('-Z','Y').to_euler();return ob
area('White upper front key',(-15,-21,24),9500,(1,.97,.95),14)
area('Cool broad fill',(17,-8,11),7000,(.63,.78,1),12)
area('Long cold edge',(-7,17,15),12000,(.67,.82,1),10)
area('Belly bounce',(1,-12,-14),5000,(.55,.72,1),11)
area('Soft front reflection',(-3,-28,6),5000,(.90,.94,1),16)
data=bpy.data.cameras.new('Presentation camera');cam=bpy.data.objects.new('Presentation camera',data);S.collection.objects.link(cam);S.camera=cam

def camera(loc,target=(0,0,.6),ortho=35):
    cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();data.type='ORTHO';data.ortho_scale=ortho;data.lens=48

def render(filename,width=1920,height=1080,samples=32):
    S.render.resolution_x=width;S.render.resolution_y=height;S.cycles.samples=samples;S.render.filepath=str(ROOT/filename);bpy.ops.render.render(write_still=True);print('RENDER',filename,flush=True)
camera((30,-45,-9.0),(0,0,.3))
# Clay gate is generated before the textured presentation.
clay=bpy.data.materials.new('QA clay');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.38,.38,.38,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.53
S.view_layers[0].material_override=clay
render('clay.png',1920,1080,24)
S.view_layers[0].material_override=None
# Exercise the only moving node before export; origin remains at its axial bearing.
ring.rotation_euler.z=pi/6
render('ring-test.png',1280,720,20)
ring.rotation_euler.z=0
bpy.context.view_layer.update()
assert ring.location.length<1e-8 and tuple(ring.scale)==(1.,1.,1.)
# Export only contractual mesh/empty nodes. Asset contains no scene decoration.
bpy.ops.object.select_all(action='DESELECT')
for ob in [hull,ring]+sockets:ob.select_set(True)
bpy.context.view_layer.objects.active=hull
output=ROOT/'alien-saucer.glb'
bpy.ops.export_scene.gltf(filepath=str(output),export_format='GLB',use_selection=True,export_apply=False,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_cameras=False,export_lights=False,export_extras=False)
# Ensure the paint contract and portable thin-film extension in the serialized file.
raw=output.read_bytes();jlen,jtyp=struct.unpack_from('<II',raw,12);doc=json.loads(raw[20:20+jlen]);binary=raw[20+jlen:]
for mat in doc['materials']:
    if mat['name']=='Paint':
        mat['pbrMetallicRoughness']['baseColorFactor']=[.55,.57,.60,1]
        mat.setdefault('extensions',{})['KHR_materials_iridescence']={'iridescenceFactor':.6,'iridescenceIor':1.38,'iridescenceThicknessMinimum':330,'iridescenceThicknessMaximum':330}
    if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
used=doc.setdefault('extensionsUsed',[])
if 'KHR_materials_iridescence' not in used:used.append('KHR_materials_iridescence')
jdata=json.dumps(doc,separators=(',',':')).encode();jdata+=b' ' *((-len(jdata))%4)
output.write_bytes(struct.pack('<4sII',b'glTF',2,12+8+len(jdata)+len(binary))+struct.pack('<II',len(jdata),0x4e4f534a)+jdata+binary)
assert output.stat().st_size<=12*1024*1024,output.stat().st_size
# Bounds and node metadata are in glTF coordinates.
def yup(v):return [round(float(v[0]),6),round(float(v[2]),6),round(float(-v[1]),6)]
pts=[ob.matrix_world@v.co for ob in [hull,ring] for v in ob.data.vertices]
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)]
paints=[{'name':'Silver','linear_rgb':[.55,.57,.60]},{'name':'Deep violet','linear_rgb':[.15,.085,.25]},{'name':'Bone white','linear_rgb':[.83,.79,.66]},{'name':'Abyssal teal','linear_rgb':[.035,.19,.20]},{'name':'Oxblood','linear_rgb':[.24,.035,.045]},{'name':'Obsidian','linear_rgb':[.035,.042,.055]}]
metadata={'build_script_sha256':BUILD_SCRIPT_SHA256,'units':'metres','forward':[0,0,1],'up':[0,1,0],'left':[1,0,0], 'origin':'centre of mass, level flight',
 'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]],'dimensions':[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]]},
 'nodes':{ob.name:{'world_position':yup(ob.matrix_world.translation),'parent':ob.parent.name if ob.parent else None,'type':'MESH' if ob.type=='MESH' else 'EMPTY'} for ob in [hull,ring]+sockets},
 'axes':{'Ring':[0,1,0]},'paints':paints,'triangles':triangles,'materials':['Paint','Titanium','Graphite','Glass','Engines','Lamps'],'glb_bytes':output.stat().st_size,
 'textures':{'base_normal_max':1024,'orm_max':512,'method':'Cycles OptiX EMIT base and packed ORM; tangent NORMAL; shared 3.4 m tiles'},
 'motion_test':{'node':'Ring','degrees':30,'axis':[0,1,0],'pivot':[0,0,0],'image':'ring-test.png'},
 'provenance':{'polyhaven':['blue_metal_plate'],'licence':'CC0','source':'https://polyhaven.com/a/blue_metal_plate'}}
(ROOT/'nodes.json').write_text(json.dumps(metadata,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join([
'Original 28 m × 8.5 m flying saucer: double-convex hull, raised bridge dome, inset rim lights, pivoted Ring, belly lens, three closed landing pods.',
f'{triangles:,} triangles; two mesh nodes; {output.stat().st_size/1048576:.2f} MiB GLB; metres, Y up, +Z forward; central origin.',
'Six materials: Paint (neutral texture × silver factor), Titanium, Graphite, Glass (BLEND), Engines, Lamps; portable iridescence extension.',
'Cycles GPU baked shared tiles: Paint/Titanium base+normal 1024, ORM 512; Graphite/Engines 512; Glass/Lamps 256; no image exceeds 1024.',
'Poly Haven ID: blue_metal_plate — CC0, https://polyhaven.com/a/blue_metal_plate ; photographic diffuse, OpenGL normal and ARM inputs.',
'Differences: original recessed vents and flush aft drives, deeper protected emitter to preserve the specified 8.5 m height, restrained wear; no decals or insignia.'
])+'\n')
print('FIRST_COMPLETE_GLB',triangles,output.stat().st_size,round(time.time()-START,1),flush=True)
render('final.png',1920,1080,48)
camera((47,0,1),(0,0,.8),34);render('side.png',1920,1080,32)
# An underside evidence view exposes all three retracted pods and the emitter.
camera((27,-39,-19),(0,0,-.5),34);render('underside.png',1600,1000,32)
camera((30,-45,-9.0),(0,0,.3))
# Render paints into image pixels in Blender; no external compositing dependencies.
paint_tiles=[]
for i,paint in enumerate(paints):
    M['paint_multiply'].inputs[2].default_value=(*paint['linear_rgb'],1)
    render(f'paint-{i}.png',640,400,16)
    im=bpy.data.images.load(str(ROOT/f'paint-{i}.png'));pixels=np.empty(640*400*4,dtype=np.float32);im.pixels.foreach_get(pixels);paint_tiles.append(pixels.reshape(400,640,4));bpy.data.images.remove(im)
M['paint_multiply'].inputs[2].default_value=(.55,.57,.60,1)
grid=np.zeros((800,1920,4),np.float32)
for i,px in enumerate(paint_tiles):
    row=1-i//3;col=i%3;grid[row*400:(row+1)*400,col*640:(col+1)*640]=px
im=bpy.data.images.new('Six faction paints',1920,800);im.pixels.foreach_set(grid.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
for i in range(6):(ROOT/f'paint-{i}.png').unlink()
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'saucer-source.blend'))
print('COMPLETE',round(time.time()-START,1),flush=True)
