"""Crew shuttle: deterministic Blender 5.2 production builder.
Run: blender -b --python-exit-code 1 --python build_crew-shuttle.py
All source images are downloaded from Poly Haven (CC0); no other image source.
Blender coordinates: +X port, -Y forward, +Z up. Export rotates to glTF Y up.
"""
import bpy, bmesh, math, json, os, sys, time, urllib.request, random
import numpy as np
from pathlib import Path
from mathutils import Vector, Matrix
from math import sin, cos, pi, sqrt
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
START=time.time(); random.seed(17)
FAST='--draft' in sys.argv
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene
S.unit_settings.system='METRIC'; S.unit_settings.scale_length=1.0
S.render.engine='CYCLES'; S.cycles.samples=32; S.cycles.use_denoising=True
S.cycles.max_bounces=6; S.cycles.transparent_max_bounces=6
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices: d.use=(d.type=='OPTIX')
S.cycles.device='GPU'
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.view_settings.exposure=0.35
STATIC=[]; MOVING=[]; BAKES={}; MATS={}
def log(s): print('CREW_SHUTTLE',round(time.time()-START,1),s,flush=True)
def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def download(id,map):
 p=TEX/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'})
  with urllib.request.urlopen(req,timeout=90) as r:p.write_bytes(r.read())
 return p

def image_node(nt,path,noncolor=False):
 n=nt.nodes.new('ShaderNodeTexImage');n.image=bpy.data.images.load(str(path),check_existing=True)
 if noncolor:n.image.colorspace_settings.name='Non-Color'
 n.extension='REPEAT';return n

def node(nt,typ):return nt.nodes.new(typ)
def link(nt,a,b):nt.links.new(a,b)

def source_mat(name,base,rough,metal,ph=None,neutral=False,normal_strength=.25):
 m=bpy.data.materials.new(name+'_source');m.use_nodes=True; nt=m.node_tree
 p=nt.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*base,1);p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal
 uv=node(nt,'ShaderNodeTexCoord')
 noise=node(nt,'ShaderNodeTexNoise');noise.inputs['Scale'].default_value=37;noise.inputs['Detail'].default_value=3;noise.inputs['Roughness'].default_value=.7;link(nt,uv.outputs['UV'],noise.inputs['Vector'])
 if ph:
  diff=image_node(nt,download(ph,'diff'));arm=image_node(nt,download(ph,'arm'),True);nor=image_node(nt,download(ph,'nor_gl'),True)
  bw=node(nt,'ShaderNodeRGBToBW');link(nt,diff.outputs['Color'],bw.inputs[0])
  ramp=node(nt,'ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.05;ramp.color_ramp.elements[1].position=.8
  if neutral:
   ramp.color_ramp.elements[0].color=(.51,.51,.51,1);ramp.color_ramp.elements[1].color=(.92,.92,.92,1)
  else:
   ramp.color_ramp.elements[0].color=(*(a*.5 for a in base),1);ramp.color_ramp.elements[1].color=(*(min(a*1.45,1) for a in base),1)
  link(nt,bw.outputs[0],ramp.inputs[0])
  # Fine neutral streaks and pitting are baked, never colour the faction texture.
  mix=node(nt,'ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.22;link(nt,ramp.outputs[0],mix.inputs[1]);link(nt,noise.outputs['Fac'],mix.inputs[2]);link(nt,mix.outputs[0],p.inputs['Base Color'])
  if neutral:
   streakvec=node(nt,'ShaderNodeVectorMath');streakvec.operation='MULTIPLY';streakvec.inputs[1].default_value=(4,18,1);link(nt,uv.outputs['UV'],streakvec.inputs[0])
   streak=node(nt,'ShaderNodeTexNoise');streak.inputs['Scale'].default_value=1;streak.inputs['Detail'].default_value=4;link(nt,streakvec.outputs[0],streak.inputs['Vector'])
   dirt=node(nt,'ShaderNodeValToRGB');dirt.color_ramp.elements[0].position=.33;dirt.color_ramp.elements[0].color=(.57,.57,.57,1);dirt.color_ramp.elements[1].position=.63;dirt.color_ramp.elements[1].color=(1,1,1,1);link(nt,streak.outputs['Fac'],dirt.inputs[0])
   wear=node(nt,'ShaderNodeMixRGB');wear.blend_type='MULTIPLY';wear.inputs[0].default_value=.24;link(nt,mix.outputs[0],wear.inputs[1]);link(nt,dirt.outputs[0],wear.inputs[2]);link(nt,wear.outputs[0],p.inputs['Base Color'])
  sep=node(nt,'ShaderNodeSeparateColor');link(nt,arm.outputs['Color'],sep.inputs[0])
  rem=node(nt,'ShaderNodeMapRange');rem.inputs['To Min'].default_value=max(.15,rough-.14);rem.inputs['To Max'].default_value=min(.95,rough+.14);link(nt,sep.outputs[1],rem.inputs['Value']);link(nt,rem.outputs[0],p.inputs['Roughness'])
  normal=node(nt,'ShaderNodeNormalMap');normal.inputs['Strength'].default_value=normal_strength;link(nt,nor.outputs[0],normal.inputs['Color'])
  bump=node(nt,'ShaderNodeBump');bump.inputs['Strength'].default_value=.15;bump.inputs['Distance'].default_value=.004;link(nt,noise.outputs['Fac'],bump.inputs['Height']);link(nt,normal.outputs[0],bump.inputs['Normal']);link(nt,bump.outputs[0],p.inputs['Normal'])
  return m,p,sep.outputs[0]
 if name=='Lamps':
  sep=node(nt,'ShaderNodeSeparateXYZ');link(nt,uv.outputs['UV'],sep.inputs[0]);r=node(nt,'ShaderNodeValToRGB');r.color_ramp.interpolation='CONSTANT';r.color_ramp.elements[0].position=0;r.color_ramp.elements[0].color=(.9,.012,.006,1);r.color_ramp.elements[1].position=.333;r.color_ramp.elements[1].color=(.008,.8,.065,1);r.color_ramp.elements.new(.667).color=(.08,.36,.58,1);link(nt,sep.outputs[0],r.inputs[0]);link(nt,r.outputs[0],p.inputs['Base Color'])
 return m,p,None

def bake_material(name,base,rough,metal,ph=None,neutral=False,res=1024,normal_strength=.25,alpha=1,emission=None):
 log('baking '+name)
 src,p,ao=source_mat(name,base,rough,metal,ph,neutral,normal_strength)
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,-40));plane=bpy.context.object;plane.data.materials.append(src)
 nt=src.node_tree;out=nt.nodes.get('Material Output');emit=node(nt,'ShaderNodeEmission')
 maps={}
 for kind,size in [('base',res),('normal',res),('orm',min(res,512))]:
  img=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
  if kind!='base':img.colorspace_settings.name='Non-Color'
  target=node(nt,'ShaderNodeTexImage');target.image=img;nt.nodes.active=target
  S.cycles.samples=8;S.render.bake.margin=4;S.render.bake.use_selected_to_active=False
  if kind=='normal':
   link(nt,p.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
  else:
   if kind=='base':
    incoming=p.inputs['Base Color'].links
    if incoming:link(nt,incoming[0].from_socket,emit.inputs[0])
    else:emit.inputs[0].default_value=(*base,1)
   else:
    c=node(nt,'ShaderNodeCombineColor');c.inputs[0].default_value=1;c.inputs[1].default_value=rough;c.inputs[2].default_value=metal
    if ao:
     r=node(nt,'ShaderNodeMapRange');r.inputs['To Min'].default_value=.8;r.inputs['To Max'].default_value=1;link(nt,ao,r.inputs['Value']);link(nt,r.outputs[0],c.inputs[0])
    if p.inputs['Roughness'].is_linked:link(nt,p.inputs['Roughness'].links[0].from_socket,c.inputs[1])
    link(nt,c.outputs[0],emit.inputs[0])
   link(nt,emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
  img.filepath_raw=str(TEX/(name+'_'+kind+'.png'));img.file_format='PNG';img.save();maps[kind]=img
  nt.nodes.remove(target)
 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')
 for kind in maps:
  n=node(nt,'ShaderNodeTexImage');n.image=maps[kind];n.extension='REPEAT';n.name=kind
  if kind=='base':
   if name=='Paint':
    mix=node(nt,'ShaderNodeMixRGB');mix.name='Faction tint';mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.85,.85,.83,1);link(nt,n.outputs[0],mix.inputs[1]);link(nt,mix.outputs[0],p.inputs['Base Color'])
   else:link(nt,n.outputs[0],p.inputs['Base Color'])
  elif kind=='normal':
   nm=node(nt,'ShaderNodeNormalMap');link(nt,n.outputs[0],nm.inputs[0 if False else 'Color']);link(nt,nm.outputs[0],p.inputs['Normal'])
  else:
   sep=node(nt,'ShaderNodeSeparateColor');link(nt,n.outputs[0],sep.inputs[0]);link(nt,sep.outputs[1],p.inputs['Roughness']);link(nt,sep.outputs[2],p.inputs['Metallic'])
   if 'glTF Material Output' not in bpy.data.node_groups:
    g=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');g.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   gnode=node(nt,'ShaderNodeGroup');gnode.node_tree=bpy.data.node_groups['glTF Material Output'];link(nt,sep.outputs[0],gnode.inputs['Occlusion'])
 p.inputs['Alpha'].default_value=alpha
 if name=='Glass':
  m.surface_render_method='BLENDED';p.inputs['Transmission Weight'].default_value=0;p.inputs['Coat Weight'].default_value=.6;p.inputs['IOR'].default_value=1.46
 if emission:
  p.inputs['Emission Color'].default_value=(*emission,1);p.inputs['Emission Strength'].default_value=2.2
  if name=='Lamps':link(nt,nt.nodes['base'].outputs[0],p.inputs['Emission Color'])
 m.diffuse_color=(*base,alpha);MATS[name]=m;BAKES[name]=maps;return m

def mesh(name,verts,faces,mat='Paint',smooth=True,keep=True):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(name,me);S.collection.objects.link(o)
 if mat:me.materials.append(MATS[mat])
 for p in me.polygons:p.use_smooth=smooth
 if keep:STATIC.append(o)
 return o

def bevel(o,w=.04,seg=3):
 active(o);m=o.modifiers.new('Manufactured edge radii','BEVEL');m.width=w;m.segments=seg
 bpy.ops.object.modifier_apply(modifier=m.name)

def normals(o):
 active(o)
 try:
  m=o.modifiers.new('Weighted face normals','WEIGHTED_NORMAL');m.keep_sharp=True;m.weight=35;bpy.ops.object.modifier_apply(modifier=m.name)
 except:pass

def cube(name,loc,dim,mat='Paint',bev=.04,rot=None):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.scale=dim
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if rot:o.rotation_euler=rot
 o.data.materials.append(MATS[mat]);bevel(o,bev);normals(o);STATIC.append(o)
 if mat=='Lamps':o['lamp_u']=.83
 return o

def tube(name,pts,r,mat='Alloy',sides=8,closed=False):
 pts=[Vector(p) for p in pts];v=[];f=[];N=len(pts)
 for i,p in enumerate(pts):
  t=(pts[(i+1)%N]-pts[(i-1)%N]).normalized() if closed else (pts[min(i+1,N-1)]-pts[max(i-1,0)]).normalized()
  a=t.cross(Vector((0,0,1)))
  if a.length<.01:a=t.cross(Vector((0,1,0)))
  a.normalize();b=t.cross(a).normalized()
  for j in range(sides):v.append(p+r*(a*cos(2*pi*j/sides)+b*sin(2*pi*j/sides)))
 for i in range(N if closed else N-1):
  for j in range(sides):f.append((i*sides+j,i*sides+(j+1)%sides,((i+1)%N)*sides+(j+1)%sides,((i+1)%N)*sides+j))
 if not closed:f.extend([tuple(reversed(range(sides))),tuple((N-1)*sides+j for j in range(sides))])
 return mesh(name,v,f,mat)

def cylinder(name,loc,r,depth,mat='Alloy',axis=(0,0,1),vertices=24,bev=.014):
 bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=r,depth=depth,location=loc);o=bpy.context.object;o.name=name
 o.rotation_euler=Vector(axis).to_track_quat('Z','Y').to_euler();o.data.materials.append(MATS[mat]);bevel(o,bev,2);normals(o);STATIC.append(o);return o

def sphere(name,loc,scale,mat='Paint',seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,radius=1,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);o.data.materials.append(MATS[mat]);STATIC.append(o)
 for p in o.data.polygons:p.use_smooth=True
 if mat=='Lamps':o['lamp_u']=(.16 if loc[0]>=0 else .50)
 return o

# Registered source textures are used for physical micro-detail, rebaked as shared tiling sets.
Paint=bake_material('Paint',(.8,.8,.8),.48,.12,'metal_plate_02',True)
Thermal=bake_material('Thermal',(.023,.026,.03),.77,.06,'rubber_tiles',False,1024,.18)
Alloy=bake_material('Alloy',(.26,.285,.30),.36,.83,'metal_plate_02',False,512,.3)
Mechanisms=bake_material('Mechanisms',(.065,.074,.083),.48,.75,'metal_plate_02',False,512,.35)
Cabin=bake_material('Cabin',(.042,.052,.06),.69,.1,'rubber_tiles',False,512,.1)
Glass=bake_material('Glass',(.026,.065,.085),.23,.12,None,False,64,alpha=.31)
Engines=bake_material('Engines',(.30,.16,.065),.4,.3,None,False,64,emission=(.65,.25,.055))
Lamps=bake_material('Lamps',(.4,.65,.8),.3,.1,None,False,64,emission=(.5,.75,1.0))
log('MATERIAL_BAKES_COMPLETE')

# Longitudinal loft controls. The nose is a softened wedge, the fuselage has a broad parallel pressure cabin.
ST=[(-13,.05,-.72,-.88),(-12.75,.85,-.28,-1.35),(-12,2.10,.32,-1.98),(-10.5,3.35,1.3,-2.43),(-8.5,4.42,2.75,-2.68),(-6.2,5.12,3.52,-2.77),(-3,5.59,3.66,-2.8),(1,5.76,3.66,-2.8),(5,5.74,3.5,-2.72),(8.5,5.45,3.2,-2.52),(10.5,4.98,2.87,-2.25),(11.35,4.72,2.67,-2.05)]
def section(y):
 i=next((i for i in range(len(ST)-1) if y<=ST[i+1][0]),len(ST)-2);a=ST[i];b=ST[i+1];t=max(0,min(1,(y-a[0])/(b[0]-a[0])))
 vals=[]
 for k in [1,2,3]:
  m0=(b[k]-ST[max(0,i-1)][k])/(b[0]-ST[max(0,i-1)][0]);m1=(ST[min(len(ST)-1,i+2)][k]-a[k])/(ST[min(len(ST)-1,i+2)][0]-a[0]);d=b[0]-a[0]
  vals.append((2*t**3-3*t*t+1)*a[k]+(t**3-2*t*t+t)*d*m0+(-2*t**3+3*t*t)*b[k]+(t**3-t*t)*d*m1)
 return vals
EXP=.72
CZ=-.38
def zexp(y):return EXP-(EXP-.30)*max(0,1-abs(y+8.4)/3.0)
def rawsurf(y,t):
 w,top,bot=section(y);cz=min(CZ,(top+bot)/2);s=sin(t);c=cos(t);z=cz+(top-cz if c>=0 else cz-bot)*(abs(c)**(zexp(y) if c>=0 else EXP))*(1 if c>=0 else -1)
 v=Vector((w*(abs(s)**EXP)*(1 if s>=0 else -1),y,z))
 return v
def surf(y,t,off=0):
 v=rawsurf(y,t)
 # Windshield facets are planar, with a shallow brow; the shell aperture follows exactly.
 if -8.95<=y<=-7.25:
  bounds=[-1.0,-.52,0,.52,1.0]
  for a,b in zip(bounds,bounds[1:]):
   if a<=t<=b:
    q=(t-a)/(b-a);f=(y+8.95)/1.70
    v=rawsurf(-8.95,a).lerp(rawsurf(-8.95,b),q).lerp(rawsurf(-7.25,a).lerp(rawsurf(-7.25,b),q),f)
    break
 n=Vector((sin(t),-.08,cos(t))).normalized();return v+n*off

def side_x(y,z):
 w,t,b=section(y);cz=min(CZ,(t+b)/2);q=(z-cz)/(t-cz if z>=cz else cz-b);c=min(.999,abs(q))**(1/(zexp(y) if z>=cz else EXP));return w*(sqrt(max(0,1-c*c))**EXP)
WIN=[(-.96,-.555),(-.485,-.055),(.055,.485),(.555,.96)]
ys=sorted(set([round(-13+i*24.35/84,6) for i in range(85)]+[-11.65,-8.95,-7.25,-6.4,11.35]))
ts=sorted(set([round(-pi+i*2*pi/96,7) for i in range(96)]+[a for win in WIN for a in win]+[-1.65,-1.25,-1.0,-.52,0,.52,1.0,1.25,1.65]))
verts=[surf(y,t) for y in ys for t in ts];faces=[];midx=[];N=len(ts)
for i in range(len(ys)-1):
 for j,t in enumerate(ts):
  tj=ts[(j+1)%N];tm=(t+tj)/2 if j<N-1 else pi;ym=(ys[i]+ys[i+1])/2
  if -8.95<ym<-7.25 and any(a<tm<b for a,b in WIN):continue
  faces.append((i*N+j,i*N+(j+1)%N,(i+1)*N+(j+1)%N,(i+1)*N+j))
  midx.append(1 if abs(tm)>(1.25 if ym<-6.4 else 1.65) or ym<-11.65 else 0)
faces.append(tuple(reversed(range(N))));midx.append(1)
body=mesh('Pressure shell',verts,faces)
body.data.materials.append(Thermal)
for p,i in zip(body.data.polygons,midx):p.material_index=i
bm=bmesh.new();bm.from_mesh(body.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(body.data);bm.free()
log('HULL before solidify '+str(len(body.data.polygons)))
active(body);solid=body.modifiers.new('Pressure shell thickness','SOLIDIFY');solid.thickness=.095;solid.offset=-1;bpy.ops.object.modifier_apply(modifier=solid.name)
# Cylindrical side-hatch opening through the real pressure shell.
bpy.ops.mesh.primitive_cylinder_add(vertices=80,radius=1.29,depth=2.6,location=(5.6,-.7,.52),rotation=(0,pi/2,0));cut=bpy.context.object
active(body);mod=body.modifiers.new('Crew hatch aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cut;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(cut,do_unlink=True)
log('HULL after aperture '+str(len(body.data.polygons)))

# Four optically separate forward panes and inset double gaskets; the surrounding shell has matching holes.
for wi,(a,b) in enumerate(WIN):
 y0,y1=-8.95,-7.25
 edge=[surf(y0,a+(b-a)*j/16,.025) for j in range(17)]+[surf(y0+(y1-y0)*j/12,b,.025) for j in range(1,13)]+[surf(y1,b-(b-a)*j/16,.025) for j in range(1,17)]+[surf(y1-(y1-y0)*j/12,a,.025) for j in range(1,12)]
 tube('Window pressure gasket',edge,.052,'Mechanisms',10,True);tube('Window titanium trim',[p+Vector((0,0,.015)) for p in edge],.018,'Alloy',8,True)
 v=[surf(y0+(y1-y0)*i/10,a+(b-a)*j/10,-.014) for i in range(11) for j in range(11)]
 f=[(i*11+j,i*11+j+1,(i+1)*11+j+1,(i+1)*11+j) for i in range(10) for j in range(10)]
 pane=mesh('Forward glazing '+str(wi+1),v,f,'Glass',smooth=False);active(pane);sol=pane.modifiers.new('Laminated pane','SOLIDIFY');sol.thickness=.018;bpy.ops.object.modifier_apply(modifier=sol.name)
# Cockpit under the transparent panes: twin shock-mounted seats, coaming, consoles, sticks.
cube('Cockpit floor',(0,-7.65,-.48),(5.4,4.5,.16),'Cabin',.08)
cube('Instrument coaming',(0,-9.0,.68),(4.5,.8,.5),'Mechanisms',.16,(-.18,0,0))
for x in [-1.12,1.12]:
 cylinder('Seat pedestal',(x,-7.25,-.05),.19,.8,'Alloy')
 cube('Seat cushion',(x,-7.15,.34),(.92,.87,.26),'Cabin',.13)
 cube('Seat back',(x,-6.73,1.03),(.95,.24,1.28),'Cabin',.14,(-.15,0,0))
 cube('Head restraint',(x,-6.67,1.8),(.5,.25,.36),'Cabin',.1)
 for dx in [-.53,.53]:tube('Seat frame',[(x+dx,-7.65,.15),(x+dx,-6.65,.2),(x+dx,-6.52,1.55)],.055,'Alloy',8)
 cube('Multi-function display surround',(x,-8.88,1.04),(1.0,.36,.17),'Alloy',.07,(-.3,0,0))
 cube('Multi-function display',(x,-8.91,1.13),(.85,.27,.035),'Glass',.035,(-.3,0,0))
 tube('Flight stick',[(x+.25,-7.6,.48),(x+.25,-7.7,.95),(x+.25,-7.81,1.10)],.055,'Mechanisms',10)
 for k in range(5):cube('Console indicator',(x-.33+.15*k,-9.18,1.15),(.045,.025,.02),'Lamps',.008)
cube('Centre console',(0,-7.5,.19),(.4,2.0,.65),'Mechanisms',.1)
for z in [.9,1.2]:tube('Cabin roof rail',[(-2.2,-6.3,z),(2.2,-6.3,z)],.045,'Alloy',10)

# Airframe panel breaks: gently recessed seams and visible captive fasteners.
for y in [-10.8,-6.4,-3.7,.2,4.3,7.6,9.6]:
 for aa,bb in [(-1.74,-.15),(.15,1.74)]:
  # Leave the hatch flange a clean interruption in the seam pattern.
  pts=[surf(y,aa+(bb-aa)*i/48,.005) for i in range(49)]
  tube('Pressure panel seam',pts,.008,'Mechanisms',5)
  for t in np.linspace(aa+.10,bb-.10,7):
   if abs(y+.7)<1.6 and t>1.1:continue
   p=surf(y+.09,t,.006);cylinder('Captive panel fastener',p,.022,.012,'Alloy',axis=(sin(t),-.05,cos(t)),vertices=10,bev=.004)
for t in [-1.69,-1.19,-.42,.42,1.19,1.69]:
 for ya,yb in ([(-6.4,-2.1),(1.1,9.6)] if t>1 else [(-6.4,9.6)]):tube('Longitudinal panel seam',[surf(y,t,.005) for y in np.linspace(ya,yb,55)],.008,'Mechanisms',5)
# Heat shield ceramic tile field: staggered restrained fine seams on the underside.
for y in np.arange(-10.3,10.9,.83):
 tube('Belly tile course',[surf(y,t,.005) for t in np.linspace(1.82,4.46,48)],.009,'Mechanisms',4)
for t in np.linspace(1.86,4.42,16):tube('Belly tile joints',[surf(y,t,.005) for y in np.linspace(-10.2,10.9,38)],.008,'Mechanisms',4)

# Broad, short blended lifting shoulders. Section thickness and swept leading edge carry the form.
for sign in [-1,1]:
 stations=[(3.6,-8.0,10.85,1.00),(4.7,-4.2,10.9,.80),(5.8,.4,10.8,.50),(7.2,4.8,10.6,.28),(8.75,6.6,10.2,.18),(9,7.2,9.9,.075)]
 v=[];nn=48
 for x,lead,trail,thick in stations:
  for j in range(nn):
   a=2*pi*j/nn;u=(1-cos(a))/2;y=lead+(trail-lead)*u
   z=-1.30 + thick*sin(a)*(0.82+.18*cos(a))+.12*(x-4)/5
   v.append((sign*x,y,z))
 f=[]
 for i in range(len(stations)-1):
  for j in range(nn):f.append((i*nn+j,i*nn+(j+1)%nn,(i+1)*nn+(j+1)%nn,(i+1)*nn+j))
 f+=[tuple(reversed(range(nn))),tuple((len(stations)-1)*nn+j for j in range(nn))]
 o=mesh('Blended lifting shoulder',v,f);o.data.materials.append(Thermal)
 for p in o.data.polygons:
  if p.center.z<-1.29 or min(v[j%nn][1] for j in p.vertices)<-6:p.material_index=1
 # Solid titanium leading edge with no long delta span.
 tube('Shoulder thermal leading edge',[(sign*x,le,-1.30+.12*(x-4)/5) for x,le,tr,th in stations],.055,'Thermal',10)
 for u in [.62,.87]:
  pts=[]
  for x,le,tr,th in stations[1:]:
   a=math.acos(1-2*u);pts.append((sign*x,le+(tr-le)*u,-1.30+th*sin(a)*(0.82+.18*cos(a))+.12*(x-4)/5+.013))
  tube('Shoulder control skin seam',pts,.016,'Mechanisms',6)
 # Tail fins: closed airfoil wedges, canted outward, swept rearward, no central vertical tail.
 rootA=Vector((sign*4.55,6.5,.47));rootB=Vector((sign*4.7,11.18,.5));tipA=Vector((sign*7.02,10.13,4.2));tipB=Vector((sign*7.11,12.12,4.08))
 vv=[];n=40
 for i in range(9):
  u=i/8;A=rootA.lerp(tipA,u);B=rootB.lerp(tipB,u)
  for j in range(n):
   a=2*pi*j/n;q=(1-cos(a))/2;pt=A.lerp(B,q);pt.x+=sign*(.22*(1-u)+.085*u)*sin(a);vv.append(pt)
 ff=[(i*n+j,i*n+(j+1)%n,(i+1)*n+(j+1)%n,(i+1)*n+j) for i in range(8) for j in range(n)];ff+=[tuple(reversed(range(n))),tuple(8*n+j for j in range(n))]
 fin=mesh('Canted tail stabilizer',vv,ff)
 tube('Fin thermal leading edge',[rootA.lerp(tipA,u) for u in np.linspace(0,1,20)],.06,'Thermal',10)
 for u in [.74]:tube('Fin rudder separation',[rootA.lerp(tipA,q).lerp(rootB.lerp(tipB,q),u)+Vector((sign*((.22*(1-q)+.085*q)*sin(math.acos(1-2*u))+.006),0,0)) for q in np.linspace(.08,.98,22)],.016,'Mechanisms',6)
 sphere('Fin root fairing',(sign*4.75,8.6,.4),(.5,2.55,.5),'Paint')

# Conformal flush radiator cassettes and narrow cooling slats.
for sign in [-1,1]:
 a,b=sign*.34,sign*.69;ya,yb=4.3,7.6;nn=18
 vv=[surf(ya+(yb-ya)*i/nn,a+(b-a)*j/8,.025) for i in range(nn+1) for j in range(9)]
 ff=[(i*9+j,i*9+j+1,(i+1)*9+j+1,(i+1)*9+j) for i in range(nn) for j in range(8)]
 core=mesh('Flush radiator core',vv,ff,'Mechanisms');active(core);g=core.modifiers.new('Radiator cassette thickness','SOLIDIFY');g.thickness=.06;g.offset=-1;bpy.ops.object.modifier_apply(modifier=g.name)
 outline=[surf(ya,t,.037) for t in np.linspace(a,b,17)]+[surf(y,b,.037) for y in np.linspace(ya,yb,20)[1:]]+[surf(yb,t,.037) for t in np.linspace(b,a,17)[1:]]+[surf(y,a,.037) for y in np.linspace(yb,ya,20)[1:-1]]
 tube('Radiator perimeter',outline,.019,'Alloy',6,True)
 for y in np.linspace(ya+.09,yb-.09,29):tube('Radiator cooling slat',[surf(y,t,.047) for t in np.linspace(a+.015*sign,b-.015*sign,12)],.024,'Alloy',6)
# Recessed nose attitude-control panels with bored nozzles.
def nozzle(name,center,axis,profile,mat='Mechanisms',n=48):
 axis=Vector(axis).normalized();u=axis.cross(Vector((0,0,1)))
 if u.length<.1:u=axis.cross(Vector((0,1,0)))
 u.normalize();v=axis.cross(u).normalized();c=Vector(center);verts=[]
 for d,r in profile:
  for j in range(n):verts.append(c+axis*d+r*(u*cos(2*pi*j/n)+v*sin(2*pi*j/n)))
 faces=[(i*n+j,i*n+(j+1)%n,(i+1)*n+(j+1)%n,(i+1)*n+j) for i in range(len(profile)-1) for j in range(n)]
 return mesh(name,verts,faces,mat)
rcs_cutters=[]
def rcs_cut(center,axis,radius):
 bpy.ops.mesh.primitive_cylinder_add(vertices=24,radius=radius,depth=.8,location=Vector(center)-Vector(axis).normalized()*.16)
 c=bpy.context.object;c.rotation_euler=Vector(axis).to_track_quat('Z','Y').to_euler();rcs_cutters.append(c)
for sign in [-1,1]:
 for y,z in [(-10.7,-.42),(8.4,1.6)]:
  x=side_x(y,z)*sign
  # Small recessed sockets follow the actual pressure shell.
  for k in range(3):
   yy=y+(k-1)*.38;xx=side_x(yy,z)*sign
   rcs_cut((xx,yy,z),(sign,0,.06),.148)
   nozzle('RCS socket',(xx,yy,z),(sign,0,.06),[(-.05,.18),(.025,.19),(.04,.15),(-.13,.115),(-.20,.04)],'Mechanisms',24)
   nozzle('RCS rim',(xx,yy,z),(sign,0,.06),[(.015,.188),(.032,.192),(.045,.17)],'Alloy',24)
  yy=y+.0;zz=z+.41;xx=side_x(yy,zz)*sign
  rcs_cut((xx,yy,zz),(sign*.75,0,.66),.118)
  nozzle('RCS vertical jet',(xx,yy,zz),(sign*.75,0,.66),[(-.06,.16),(.025,.17),(.04,.12),(-.1,.065)],'Mechanisms',24)
 # Service coupling and covered fill port on the forward shoulder.
 yy=-5.9;zz=-.15;xx=side_x(yy,zz)*sign
 cylinder('Service coupling surround',(xx,yy,zz),.36,.12,'Alloy',(sign,0,0),40)
 cylinder('Service coupling cap',(xx+sign*.08,yy,zz),.235,.05,'Mechanisms',(sign,0,0),32)
 tube('Service port lock',[(xx+sign*.14,yy-.12,zz-.08),(xx+sign*.14,yy+.08,zz+.12)],.035,'Alloy')

# One joined cutter keeps the tiny attitude-control cavities robust and reproducible.
bpy.ops.object.select_all(action='DESELECT')
for c in rcs_cutters:c.select_set(True)
bpy.context.view_layer.objects.active=rcs_cutters[0];bpy.ops.object.join();rcs_cutter=bpy.context.object
active(body);mod=body.modifiers.new('Bored RCS outlets','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=rcs_cutter;bpy.ops.object.modifier_apply(modifier=mod.name);bpy.data.objects.remove(rcs_cutter,do_unlink=True)
# Human-scale access panels, conformal to the pressure skin and interrupted around the crew hatch.
for sign in [-1,1]:
 for yc,tc,dy,dt in [(-4.9,1.18,.74,.105),(2.8,1.20,1.20,.13),(6.1,1.15,.82,.10),(-3.1,.64,.50,.075)]:
  a,b=sign*(tc-dt),sign*(tc+dt);ya,yb=yc-dy/2,yc+dy/2
  outline=[surf(ya,t,.005) for t in np.linspace(a,b,9)]+[surf(y,b,.005) for y in np.linspace(ya,yb,9)[1:]]+[surf(yb,t,.005) for t in np.linspace(b,a,9)[1:]]+[surf(y,a,.005) for y in np.linspace(yb,ya,9)[1:-1]]
  tube('Flush maintenance cover seam',outline,.008,'Mechanisms',5,True)
  for yy in [ya+.09,yb-.09]:
   for t in [a+(b-a)*.13,b-(b-a)*.13]:
    cylinder('Quarter turn cover fastener',surf(yy,t,.005),.020,.014,'Alloy',(sin(t),0,cos(t)),10,.004)
  t=sign*tc;pt=surf(yc,t,.013)
  tube('Recessed service latch',[pt+Vector((0,-.09,0)),pt+Vector((0,.09,0))],.018,'Alloy',8)

# Conformal side hatch, jamb, pressure seal, hinge pins and integrated pull handle.
HY=-.7;HZ=.52
def hp(a,r,off=0):
 y=HY+r*cos(a);z=HZ+r*sin(a);return Vector((side_x(y,z)+off,y,z))
for r,w,mat,off in [(1.38,.09,'Paint',.025),(1.27,.055,'Mechanisms',.025),(1.23,.035,'Alloy',.046)]:tube('Crew hatch flange',[hp(a,r,off) for a in np.linspace(0,2*pi,81)[:-1]],w,mat,10,True)
hatch_parts=[];before=len(STATIC)
v=[hp(0,0,.053)]+[hp(a,1.20,.055) for a in np.linspace(0,2*pi,81)[:-1]]
f=[(0,j+1,(j+1)%80+1) for j in range(80)]
h=mesh('Hatch leaf',v,f);active(h);sol=h.modifiers.new('Pressure door thickness','SOLIDIFY');sol.thickness=.11;bpy.ops.object.modifier_apply(modifier=sol.name);bevel(h,.025,2)
# Handle and a tiny observation port are part of the moving leaf.
xx=side_x(HY+.55,HZ)+.12
cube('Hatch latch pocket',(xx,HY+.55,HZ),(.08,.25,.50),'Mechanisms',.09)
tube('Hatch release grip',[(xx+.07,HY+.48,HZ-.15),(xx+.13,HY+.46,HZ+.05),(xx+.07,HY+.48,HZ+.19)],.045,'Alloy',10)
for a in np.linspace(0,2*pi,13)[:-1]:
 p=hp(a,1.10,.064);cylinder('Hatch fastener',p,.035,.025,'Alloy',(1,0,0),10,.005)
hatch_parts=STATIC[before:];STATIC[before:]=[]
hinge=Vector((side_x(HY-1.16,HZ)+.12,HY-1.16,HZ))
for zz in [HZ-.65,HZ+.65]:
 cylinder('Hatch hinge knuckle',(hinge.x,hinge.y,zz),.12,.34,'Alloy',(0,0,1),24)
 cube('Hatch hinge pad',(hinge.x-.05,hinge.y+.12,zz),(.17,.36,.27),'Paint',.05)

# A recessed aft pressure bulkhead and closed ramp underneath the engines.
# Bulkhead rounded outline follows the fuselage rim.
outline=[surf(11.35,t) for t in np.linspace(-pi,pi,97)[:-1]]
rear=mesh('Aft bulkhead',outline,[tuple(range(96))],'Mechanisms',False)
# Cut the cargo opening from the bulkhead; leaves a true aperture when the ramp moves.
cutter=cube('temporary ramp cut',(0,11.35,-.78),(6.4,1.0,2.12),'Mechanisms',.16)
active(rear);so=rear.modifiers.new('Bulkhead gauge','SOLIDIFY');so.thickness=.13;bpy.ops.object.modifier_apply(modifier=so.name)
mod=rear.modifiers.new('Cargo aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name);STATIC.remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
cube('Cargo bay floor',(0,8.85,-1.89),(6.3,5.0,.15),'Mechanisms',.05)
cube('Cargo bay front wall',(0,6.5,-.68),(6.3,.15,2.4),'Cabin',.05)
for x in [-3.3,3.3]:cube('Cargo jamb',(x,11.45,-.77),(.17,.22,2.30),'Alloy',.06)
for z in [-1.91,.39]:cube('Cargo lintel',(0,11.45,z),(6.7,.22,.17),'Alloy',.05)
before=len(STATIC)
ramp=cube('Cargo ramp pressure panel',(0,11.43,-.77),(6.36,.22,2.10),'Paint',.16)
cube('Ramp lower thermal face',(0,11.565,-1.50),(6.05,.04,.51),'Thermal',.04)
for x in [-2.68,-1.36,0,1.36,2.68]:
 cube('Ramp reinforcement rib',(x,11.57,-.66),(.065,.045,1.63),'Alloy',.018)
for x in [-2.83,2.83]:cube('Ramp lock housing',(x,11.59,-.32),(.20,.12,.46),'Mechanisms',.04)
ramp_parts=STATIC[before:];STATIC[before:]=[]
for x in [-2.5,-.85,.85,2.5]:cylinder('Ramp hinge',(x,11.46,-1.88),.13,.48,'Alloy',(1,0,0),24)

# Twin recessed high-expansion main engines: ribbed refractory bells, clamp rings and deep hot throats.
for idx,x in enumerate([-2.05,2.05],1):
 z=1.55
 nozzle('Engine mounting collar',(x,10.9,z),(0,1,0),[(0,1.31),(.18,1.34),(.34,1.22),(.5,1.18)],'Alloy',72)
 nozzle('Main engine bell',(x,10.95,z),(0,1,0),[(0,.79),(.32,.89),(.7,1.02),(1.25,1.11),(1.82,1.15),(2.05,1.13),(2.05,1.04),(1.77,.98),(1.3,.84),(.8,.62),(.5,.42)],'Mechanisms',80)
 for d,r in [(.44,.94),(1.19,1.115),(1.82,1.17),(2.01,1.145)]:
  nozzle('Nozzle reinforcement ring',(x,10.95,z),(0,1,0),[(d-.045,r-.02),(d-.025,r+.025),(d+.025,r+.025),(d+.045,r-.02)],'Alloy',72)
 for a in np.linspace(0,2*pi,33)[:-1]:
  pts=[(x+r*cos(a),10.95+d,z+r*sin(a)) for d,r in [(.44,.94),(.8,1.055),(1.25,1.13),(1.78,1.17)]];tube('Engine cooling channel',pts,.026,'Alloy',6)
 cylinder('Recessed engine throat',(x,11.47,z),.41,.05,'Engines',(0,1,0),48,.02)
 nozzle('Throat concentrator',(x,11.46,z),(0,1,0),[(0,.48),(.06,.45),(.08,.37)],'Alloy',48)
 for a in np.linspace(0,2*pi,9)[:-1]:
  p=(x+1.27*cos(a),11.12,z+1.27*sin(a));cylinder('Engine collar bolt',p,.065,.08,'Mechanisms',(0,1,0),12,.008)
# Flush closed landing gear doors, their outlines sit on the belly curvature.
for yc,xc,length,width in [(-8,0,2.3,1.05),(4.5,-2.3,3.3,1.50),(4.5,2.3,3.3,1.50)]:
 def belly(x,y,off=.016):
  w,top,bot=section(y);ss=min(.999,abs(x/w))**(1/EXP);cc=sqrt(1-ss*ss);return (x,y,CZ-(CZ-bot)*cc**EXP-off)
 pts=[belly(xc+width/2,yc-length/2),belly(xc+width/2,yc+length/2),belly(xc-width/2,yc+length/2),belly(xc-width/2,yc-length/2)]
 tube('Flush gear door perimeter',pts,.023,'Alloy',6,True)
 tube('Flush gear door split',[belly(xc,yc-length/2),belly(xc,yc+length/2)],.015,'Mechanisms',6)
 for y in [yc-length*.32,yc+length*.32]:
  p=belly(xc+width/2,y);cube('Gear door concealed hinge',p,(.15,.33,.04),'Mechanisms',.015)
# Antenna blisters and navigation luminaires, no markings anywhere.
for x in [-.53,.53]:sphere('Dorsal sensor blister',(x,-5.4,3.55),(.25,.54,.11),'Mechanisms',24,12)
for x in [-8.88,8.88]:
 sphere('Wing navigation housing',(x,9.0,-1.13),(.14,.35,.10),'Mechanisms',24,12)
 sphere('Navigation lens',(x,8.9,-1.04),(.095,.19,.06),'Lamps',20,10)
sphere('Dorsal beacon mount',(0,1.6,3.68),(.25,.31,.08),'Mechanisms',24,12)
sphere('Dorsal beacon lens',(0,1.6,3.77),(.14,.18,.07),'Lamps',24,12)
log('GEOMETRY_COMPLETE '+str(len(STATIC))+' fixed pieces')

# Correct winding, apply construction modifiers. Cubic metre UVs tile the same baked micro-detail sets.
def finish_geo(o):
 active(o)
 # Negative handed wing construction is corrected without changing authored pivots.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
 if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
 uv=o.data.uv_layers.active.data
 for p in o.data.polygons:
  normal=p.normal;k=max(range(3),key=lambda i:abs(normal[i]));axes=[i for i in range(3) if i!=k]
  for li in p.loop_indices:
   co=o.matrix_world@o.data.vertices[o.data.loops[li].vertex_index].co
   uv[li].uv=(o['lamp_u'],.5) if 'lamp_u' in o else (co[axes[0]]/2.4,co[axes[1]]/2.4)
 # Solid glass uses a constant baked optical set, UV is still valid.
for o in STATIC+hatch_parts+ramp_parts:finish_geo(o)

def assemble(name,objects,pivot=(0,0,0),moving=False):
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.select_set(True)
 bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 # Set the pivot by translating mesh coordinates directly, not applying moving-node transforms.
 world=o.matrix_world.copy();o.data.transform(world);o.matrix_world=Matrix.Identity(4)
 o.data.transform(Matrix.Translation(-Vector(pivot)));o.location=pivot
 bm=bmesh.new();bm.from_mesh(o.data)
 # Remove negligible cap degeneracy before export.
 bmesh.ops.dissolve_degenerate(bm,dist=1e-6,edges=bm.edges[:]);bmesh.ops.triangulate(bm,faces=bm.faces[:]);bm.to_mesh(o.data);bm.free()
 if name=='Hull' and len(o.data.polygons)>87500:
  active(o);dec=o.modifiers.new('Game topology budget','DECIMATE');dec.ratio=87500/len(o.data.polygons);dec.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=dec.name)
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.dissolve_degenerate(bm,dist=1e-6,edges=bm.edges[:]);bmesh.ops.triangulate(bm,faces=bm.faces[:]);bm.to_mesh(o.data);bm.free()
 if moving:MOVING.append(o)
 return o
Hull=assemble('Hull',STATIC)
Hatch=assemble('Hatch',hatch_parts,hinge,True)
Ramp=assemble('Ramp',ramp_parts,(0,11.46,-1.88),True)
for o in MOVING:o.parent=Hull
ASSET=[Hull,Hatch,Ramp]
# Empty game sockets. Positions are written in glTF coordinates in nodes.json.
def socket(name,p,parent=Hull):
 o=bpy.data.objects.new(name,None);S.collection.objects.link(o);o.empty_display_type='ARROWS';o.empty_display_size=.3;o.location=p;o.parent=parent;ASSET.append(o);return o
socket('Thruster_1',(-2.05,13,1.55));socket('Thruster_2',(2.05,13,1.55))
socket('Seat_Pilot',(1.12,-7.15,.50));socket('Camera_Chase',(0,29,12))
socket('Light_Nav_L',(8.88,8.9,-1.04));socket('Light_Nav_R',(-8.88,8.9,-1.04));socket('Light_Beacon',(0,1.6,3.84))

# Enforce the metre envelope in mesh coordinates before export. Moving node transforms
# are never applied; their pivot translations follow the same measured coordinate map.
bpy.context.view_layer.update()
_all=[o.matrix_world@v.co for o in [Hull,Hatch,Ramp] for v in o.data.vertices]
_l=Vector([min(p[i] for p in _all) for i in range(3)]);_h=Vector([max(p[i] for p in _all) for i in range(3)])
_scale=Vector((18/(_h.x-_l.x),26/(_h.y-_l.y),7/(_h.z-_l.z)))
_offset=Vector((-9,-13,-2.8))-Vector(tuple(_l[i]*_scale[i] for i in range(3)))
for o in ASSET:
 if o.type=='MESH':
  for v in o.data.vertices:
   v.co=Vector(tuple(v.co[i]*_scale[i] for i in range(3)))+(_offset if o==Hull else Vector((0,0,0)))
 if o!=Hull:o.location=Vector(tuple(o.location[i]*_scale[i] for i in range(3)))+_offset
bpy.context.view_layer.update()
log('METRE_ENVELOPE_NORMALIZED '+str(list(_scale)))

# glTF exporter recognizes texture x factor through a Multiply node.
# Patch exported factor explicitly as a guard against shader traversal changes in Blender 5.x.
def export_glb():
 bpy.ops.object.select_all(action='DESELECT')
 for o in ASSET:o.select_set(True)
 bpy.context.view_layer.objects.active=Hull
 bpy.ops.export_scene.gltf(filepath=str(ROOT/'crew-shuttle.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO',export_cameras=False,export_lights=False,export_extras=True)
 import struct
 p=ROOT/'crew-shuttle.glb';data=p.read_bytes();jl=struct.unpack_from('<I',data,12)[0];g=json.loads(data[20:20+jl]);binary=data[20+jl:]
 for m in g.get('materials',[]):
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.85,.85,.83,1]
  if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
 encoded=json.dumps(g,separators=(',',':')).encode();encoded+=b' '*((-len(encoded))%4)
 p.write_bytes(struct.pack('<4sII',b'glTF',2,20+len(encoded)+len(binary))+struct.pack('<I4s',len(encoded),b'JSON')+encoded+binary)
 return g
G=export_glb()
tri=sum(len(o.data.polygons) for o in [Hull,Hatch,Ramp])
log('FIRST_COMPLETE_GLB triangles='+str(tri)+' bytes='+str((ROOT/'crew-shuttle.glb').stat().st_size))
# Metadata is in the exported frame, not Blender's Z-up frame.
def gl(v):return [round(v[0],5),round(v[2],5),round(-v[1],5)]
bpy.context.view_layer.update();pts=[o.matrix_world@v.co for o in [Hull,Hatch,Ramp] for v in o.data.vertices]
lo=Vector(tuple(min(p[i] for p in pts) for i in range(3)));hi=Vector(tuple(max(p[i] for p in pts) for i in range(3)))
paints=[('Ceramic white',[.85,.85,.83]),('Industrial orange',[.82,.22,.045]),('Safety yellow',[.86,.60,.055]),('Deep teal',[.055,.33,.31]),('Pale blue',[.34,.54,.69]),('Charcoal',[.095,.115,.14])]
meta={'units':'metres','forward':'+Z','up':'+Y','left':'+X','origin':'Hull centre of mass, level flight','bounding_box':{'min':[lo.x,lo.z,-hi.y],'max':[hi.x,hi.z,-lo.y],'dimensions':[hi.x-lo.x,hi.z-lo.z,hi.y-lo.y]},'triangle_count':tri,'materials':list(MATS),'nodes':{},'axes':{'Hatch':[0,-1,0],'Ramp':[1,0,0]},'paints':[{'name':n,'linear_rgb':v} for n,v in paints], 'bakes':{'engine':'Cycles OptiX','samples':8,'base_normal_max':1024,'orm_max':512,'source_ids':['metal_plate_02','rubber_tiles'],'mapping':'shared 2.4 metre tiling PBR sets'},'state':'closed hatch and ramp; all landing gear retracted'}
for o in ASSET:meta['nodes'][o.name]={'position':gl(o.matrix_world.translation),'parent':o.parent.name if o.parent else None,'kind':'mesh' if o.type=='MESH' else 'empty'}
meta['nodes']['Thruster_1']['direction']=[0,0,-1];meta['nodes']['Thruster_2']['direction']=[0,0,-1]
meta['nodes']['Light_Nav_L']['color']=[1,0,0];meta['nodes']['Light_Nav_R']['color']=[0,1,0]
(ROOT/'nodes.json').write_text(json.dumps(meta,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join([
'Original 26 m near-future lifting-body crew shuttle; real cockpit, closed hinged crew hatch/ramp, twin engines, retracted gear.',
f'{tri:,} triangles; three mesh nodes; GLB {(ROOT/"crew-shuttle.glb").stat().st_size/1048576:.2f} MiB; dimensions '+str(meta['bounding_box']['dimensions'])+' metres.',
'Eight materials: Paint, Thermal, Alloy, Mechanisms, Cabin, Glass (BLEND), Engines and Lamps; one neutral faction-colour texture.',
'Cycles GPU baked shared tiling PBR: base/normal up to 1024 px, ORM up to 512 px; optical/emissive sets 64 px.',
'Poly Haven CC0: metal_plate_02, rubber_tiles; https://polyhaven.com/license ; source maps https://polyhaven.com/a/metal_plate_02 and https://polyhaven.com/a/rubber_tiles .',
'Differs from sheet: original nose glazing/engine details, closed cargo ramp, restrained wear; no markings or deployed landing gear.'
])+'\n')

# Dark orbital studio: camera-only blue gradient, soft white upper-forward-port key.
world=bpy.data.worlds.new('Near black space');S.world=world;world.use_nodes=True;nt=world.node_tree;nt.nodes.clear()
out=node(nt,'ShaderNodeOutputWorld');lp=node(nt,'ShaderNodeLightPath');mix=node(nt,'ShaderNodeMixShader');ambient=node(nt,'ShaderNodeBackground');ambient.inputs[0].default_value=(.16,.22,.34,1);ambient.inputs[1].default_value=.24
bg=node(nt,'ShaderNodeBackground');bg.inputs[1].default_value=.32;tc=node(nt,'ShaderNodeTexCoord');sep=node(nt,'ShaderNodeSeparateXYZ');link(nt,tc.outputs['Normal'],sep.inputs[0]);ramp=node(nt,'ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.0;ramp.color_ramp.elements[0].color=(.005,.009,.019,1);ramp.color_ramp.elements[1].position=.7;ramp.color_ramp.elements[1].color=(.025,.045,.085,1);link(nt,sep.outputs['Z'],ramp.inputs[0]);link(nt,ramp.outputs[0],bg.inputs[0]);link(nt,lp.outputs['Is Camera Ray'],mix.inputs[0]);link(nt,ambient.outputs[0],mix.inputs[1]);link(nt,bg.outputs[0],mix.inputs[2]);link(nt,mix.outputs[0],out.inputs[0])
def area(name,pos,energy,size,color):
 dat=bpy.data.lights.new(name,'AREA');o=bpy.data.objects.new(name,dat);S.collection.objects.link(o);o.location=pos;o.rotation_euler=(Vector((0,0,0))-o.location).to_track_quat('-Z','Y').to_euler();dat.energy=energy;dat.shape='DISK';dat.size=size;dat.color=color
area('White daylight key',(8,-15,21),4100,11,(1,.96,.91));area('Cool fill',(-13,-8,7),2300,13,(.60,.75,1));area('Aft edge',(5,15,13),4300,10,(.7,.8,1));area('Belly bounce',(4,-3,-13),650,10,(.5,.62,.85))
d=bpy.data.cameras.new('Review camera');cam=bpy.data.objects.new('Review camera',d);S.collection.objects.link(cam);S.camera=cam;d.type='ORTHO';d.lens=50
TARGET=Vector((0,0,.3))
def camera(pos,scale):
 cam.location=pos;cam.rotation_euler=(TARGET-cam.location).to_track_quat('-Z','Y').to_euler();d.ortho_scale=scale
HERO=(30,-47,17)
def render(name,w=1920,h=1080,samples=32):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);log('RENDER '+name)
camera(HERO,34)
# Required plain clay image precedes texture judging.
clay=bpy.data.materials.new('Review clay');clay.diffuse_color=(.43,.45,.47,1);clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.43,.45,.47,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.65
S.view_layers[0].material_override=clay
render('clay.png',1920,1080,20)
S.view_layers[0].material_override=None
render('final.png',1920,1080,40 if not FAST else 16)
camera((38,0,.3),31);render('side.png',1920,1080,24)
camera((25,36,18),33);render('rear.png',1440,900,24)
# Both movable nodes are rotated about their declared axes for an actual rendered hinge proof.
Hatch.rotation_euler.z=math.radians(-30);Ramp.rotation_euler.x=math.radians(-30)
camera((30,32,15),34);render('pivot-check.png',1440,900,20)
Hatch.rotation_euler.z=0;Ramp.rotation_euler.x=0
# Six paint proof frames composed without any image source other than the actual 3D render.
camera(HERO,34);tile_images=[]
for i,(n,col) in enumerate(paints):
 Paint.node_tree.nodes['Faction tint'].inputs[2].default_value=(*col,1)
 fn=f'paint-{i}.png';render(fn,640,360,12)
 im=bpy.data.images.load(str(ROOT/fn),check_existing=False);buf=np.empty(640*360*4,dtype=np.float32);im.pixels.foreach_get(buf);tile_images.append(buf.reshape(360,640,4));bpy.data.images.remove(im)
canvas=np.ones((720,1920,4),np.float32)
for i,tile in enumerate(tile_images):
 row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=tile
im=bpy.data.images.new('Paint catalogue',1920,720,alpha=True);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
Paint.node_tree.nodes['Faction tint'].inputs[2].default_value=(.85,.85,.83,1)
# Final source retains closed transforms and default faction factor.
camera(HERO,34);S.cycles.samples=40
for o in MOVING:
 assert all(abs(v)<1e-7 for v in o.rotation_euler),o.name+' did not return to closed pose'
 assert all(abs(v-1)<1e-7 for v in o.scale),o.name+' has unapplied scale'
for o in [Hull,Hatch,Ramp]:o.data.name=o.name
G=export_glb()
assert 50000<=tri<=110000
assert (ROOT/'crew-shuttle.glb').stat().st_size<=12000000
assert len(MATS)<=9
log('FINAL_EXPORT_AFTER_PIVOT_CHECK')
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'crew-shuttle.blend'))
log('COMPLETE')
