"""Escape capsule, authored in metres; Blender 5.2, Cycles/OptiX.
Run: blender -b --python-exit-code 1 --python build_escape-pod-capsule.py
Optional -- --preflight or -- --geometry; full run rebuilds from empty scene.
Photographic inputs: Poly Haven CC0. No generated or external artwork.
"""
import bpy, bmesh, math, os, sys, json, struct, urllib.request, time
from pathlib import Path
from mathutils import Vector, Matrix, Quaternion
from math import sin, cos, pi
import numpy as np
ROOT=Path(__file__).resolve().parent
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
OUT=ROOT
TAU=2*pi
COM=1.38
PAINTS=[('Rescue orange',[0.85,0.35,0.05]),('Ceramic white',[0.78,0.82,0.85]),('Safety yellow',[0.92,0.65,0.055]),('Signal red',[0.64,0.045,0.025]),('Ocean teal',[0.035,0.33,0.32]),('Pale blue',[0.29,0.52,0.72])]
PARTS=[]; MOVING=[]; SOCKETS=[]; MATS={}; FACTORS={}; BAKES={}
start=time.time()
def log(*a): print('CAPSULE',*a,flush=True)
def reset():
 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.unit_settings.system='METRIC'; sc.unit_settings.scale_length=1
 sc.render.engine='CYCLES'; sc.cycles.samples=24; sc.cycles.use_denoising=True
 prefs=bpy.context.preferences.addons['cycles'].preferences
 for typ in ['OPTIX','CUDA']:
  try:
   prefs.compute_device_type=typ; prefs.get_devices()
   if any(d.type==typ for d in prefs.devices):
    for d in prefs.devices: d.use=(d.type==typ)
    sc.cycles.device='GPU'; log('DEVICE',typ); break
  except Exception: pass
 else: raise RuntimeError('No Cycles GPU available')
 sc.render.image_settings.file_format='PNG'; sc.render.image_settings.color_mode='RGBA'
 sc.view_settings.view_transform='AgX'; sc.view_settings.look='AgX - Medium High Contrast'; sc.render.film_transparent=False
 sc.render.bake.margin=12; sc.render.bake.use_clear=True
 return sc
SC=reset()
def select(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def node(nt,kind,**kw):
 n=nt.nodes.new(kind)
 for k,v in kw.items(): setattr(n,k,v)
 return n

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

def newmat(name):
 m=bpy.data.materials.new(name); m.use_nodes=True; nt=m.node_tree; nt.nodes.clear()
 p=node(nt,'ShaderNodeBsdfPrincipled'); out=node(nt,'ShaderNodeOutputMaterial'); nt.links.new(p.outputs['BSDF'],out.inputs['Surface'])
 return m,nt,p,out

def mathn(nt,op,a,b=None):
 n=node(nt,'ShaderNodeMath'); n.operation=op
 for i,x in enumerate([a,b]):
  if x is None: continue
  if isinstance(x,(float,int)): n.inputs[i].default_value=x
  else: nt.links.new(x,n.inputs[i])
 return n.outputs[0]

def mix(nt,a,b,f):
 n=node(nt,'ShaderNodeMixRGB'); n.blend_type='MIX'
 for i,x in [(0,f),(1,a),(2,b)]:
  if isinstance(x,(tuple,list)): n.inputs[i].default_value=(*x[:3],1)
  elif isinstance(x,(float,int)): n.inputs[i].default_value=x
  else: nt.links.new(x,n.inputs[i])
 return n.outputs[0]

def ramp(nt,x,stops):
 n=node(nt,'ShaderNodeValToRGB'); cr=n.color_ramp
 while len(cr.elements)>2: cr.elements.remove(cr.elements[-1])
 for i,(pos,col) in enumerate(stops):
  e=cr.elements[i] if i<2 else cr.elements.new(pos); e.position=pos; e.color=(*col[:3],1)
 nt.links.new(x,n.inputs[0]); return n.outputs[0]

def make_source(name):
 m,nt,p,out=newmat(name+'_source')
 uv=node(nt,'ShaderNodeTexCoord').outputs['UV']
 sep=node(nt,'ShaderNodeSeparateXYZ'); nt.links.new(uv,sep.inputs[0])
 noise=node(nt,'ShaderNodeTexNoise'); noise.inputs['Scale'].default_value=38; noise.inputs['Detail'].default_value=5; nt.links.new(uv,noise.inputs[0])
 asset='rusty_metal_02' if name in ['Paint','Metal'] else ('rusty_metal_04' if name=='HeatShield' else 'rubber_tiles')
 imgs={}
 if name in ['Paint','Metal','HeatShield','Rubber','Interior']:
  photo_uv=uv
  if name=='Paint':
   mapping=node(nt,'ShaderNodeVectorMath'); mapping.operation='MULTIPLY'; nt.links.new(uv,mapping.inputs[0]); mapping.inputs[1].default_value=(2,5,1); photo_uv=mapping.outputs[0]
  for k in ['diff','nor_gl','arm']:
   t=node(nt,'ShaderNodeTexImage'); t.image=source(asset,k); nt.links.new(photo_uv,t.inputs[0]); imgs[k]=t.outputs['Color']
  bw=node(nt,'ShaderNodeRGBToBW'); nt.links.new(imgs['diff'],bw.inputs[0]); lum=bw.outputs[0]
  arm=node(nt,'ShaderNodeSeparateColor'); nt.links.new(imgs['arm'],arm.inputs[0])
  nr=node(nt,'ShaderNodeNormalMap'); nt.links.new(imgs['nor_gl'],nr.inputs['Color']); nr.inputs['Strength'].default_value=.10 if name=='Paint' else .30
  bump=node(nt,'ShaderNodeBump'); bump.inputs['Strength'].default_value=.08 if name=='Paint' else .15; bump.inputs['Distance'].default_value=.0012 if name=='Paint' else .003; nt.links.new(noise.outputs['Fac'],bump.inputs['Height']); nt.links.new(nr.outputs[0],bump.inputs['Normal']); nt.links.new(bump.outputs[0],p.inputs['Normal'])
  ao=mathn(nt,'ADD',.65,mathn(nt,'MULTIPLY',arm.outputs[0],.35))
 else: lum=noise.outputs['Fac']; ao=1.
 if name=='Paint':
  base=ramp(nt,lum,[(0,(.63,.63,.63)),(.38,(.79,.79,.79)),(1,(.90,.90,.90))])
  stretched=node(nt,'ShaderNodeVectorMath'); stretched.operation='MULTIPLY'; nt.links.new(uv,stretched.inputs[0]); stretched.inputs[1].default_value=(6.5,4.5,1)
  streak=node(nt,'ShaderNodeTexNoise'); streak.inputs['Scale'].default_value=2.8; streak.inputs['Detail'].default_value=4; nt.links.new(stretched.outputs[0],streak.inputs[0])
  height=mathn(nt,'SUBTRACT',mathn(nt,'MULTIPLY',streak.outputs['Fac'],.58),sep.outputs['Y'])
  soot=mathn(nt,'MULTIPLY',height,3.7); soot.node.use_clamp=True
  base=mix(nt,base,(.033,.033,.033),soot)
  # Sparse pits: millimetre scale, neutral so faction tint remains valid.
  pits=node(nt,'ShaderNodeTexVoronoi'); pits.inputs['Scale'].default_value=170; nt.links.new(uv,pits.inputs['Vector'])
  mask=mathn(nt,'LESS_THAN',pits.outputs['Distance'],.075)
  base=mix(nt,base,(.27,.27,.27),mask)
  rough=mathn(nt,'ADD',mathn(nt,'ADD',.43,mathn(nt,'MULTIPLY',noise.outputs['Fac'],.19)),mathn(nt,'MULTIPLY',soot,.22)); metal=.025
 elif name=='Metal':
  base=ramp(nt,lum,[(0,(.08,.09,.105)),(.55,(.22,.24,.265)),(1,(.39,.415,.44))]); rough=mathn(nt,'ADD',.42,mathn(nt,'MULTIPLY',arm.outputs[1],.24)); metal=.83
 elif name=='HeatShield':
  base=ramp(nt,lum,[(0,(.009,.010,.012)),(.38,(.025,.020,.016)),(.7,(.070,.042,.023)),(1,(.12,.080,.042))]); rough=mathn(nt,'ADD',.79,mathn(nt,'MULTIPLY',arm.outputs[1],.15)); metal=.015
 elif name in ['Rubber','Interior']:
  base=ramp(nt,lum,[(0,(.009,.013,.017)),(1,(.065,.073,.08))]); rough=.79; metal=.02
 elif name=='Glass': base=(.045,.10,.145); rough=.16; metal=.10
 elif name=='Engines': base=(.22,.035,.008); rough=.52; metal=.62
 else:
  base=ramp(nt,sep.outputs['X'],[(0,(1,.25,.003)),(.24,(1,.25,.003)),(.25,(1,.008,.003)),(.49,(1,.008,.003)),(.5,(.012,1,.16)),(.74,(.012,1,.16)),(.75,(.05,.42,.75)),(1,(.05,.42,.75))]); base=mix(nt,(0,0,0),base,mathn(nt,'ADD',.2,mathn(nt,'MULTIPLY',sep.outputs['Y'],.8))); rough=.34; metal=.02
 if isinstance(base,tuple): p.inputs['Base Color'].default_value=(*base,1)
 else: nt.links.new(base,p.inputs['Base Color'])
 for key,val in [('Roughness',rough),('Metallic',metal)]:
  if isinstance(val,(int,float)): p.inputs[key].default_value=val
  else: nt.links.new(val,p.inputs[key])
 return m,p,out,base,ao,rough,metal

def bake_material(name,size):
 log('BAKE',name,size)
 src,p,out,col,ao,rough,metal=make_source(name); nt=src.node_tree
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,-10)); plane=bpy.context.object; plane.data.materials.append(src)
 SC.cycles.samples=8; select(plane)
 baked={}
 for kind,res in [('base',size),('normal',size),('orm',min(size,512))]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
  if kind!='base': im.colorspace_settings.name='Non-Color'
  target=node(nt,'ShaderNodeTexImage'); target.image=im; nt.nodes.active=target
  em=None
  if kind=='normal':
   nt.links.new(p.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='NORMAL')
  else:
   em=node(nt,'ShaderNodeEmission')
   if kind=='base':
    if isinstance(col,tuple): em.inputs[0].default_value=(*col,1)
    else: nt.links.new(col,em.inputs[0])
   else:
    combine=node(nt,'ShaderNodeCombineColor')
    for i,val in enumerate([ao,rough,metal]):
     if isinstance(val,(int,float)): combine.inputs[i].default_value=val
     else: nt.links.new(val,combine.inputs[i])
    nt.links.new(combine.outputs[0],em.inputs[0])
   nt.links.new(em.outputs[0],out.inputs['Surface']); bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=str(CACHE/(name+'_'+kind+('.jpg' if kind=='base' else '.png'))); im.file_format='JPEG' if kind=='base' else 'PNG'; im.save()
  baked[kind]=im
  nt.nodes.remove(target)
  if em: nt.nodes.remove(em)
 bpy.data.objects.remove(plane,do_unlink=True)
 bpy.data.materials.remove(src)
 m,nt,p,out=newmat(name)
 textures={}
 for kind,im in baked.items():
  t=node(nt,'ShaderNodeTexImage'); t.image=im; t.label='Cycles baked '+kind; textures[kind]=t
 if name=='Paint':
  mult=node(nt,'ShaderNodeMixRGB'); mult.blend_type='MULTIPLY'; mult.inputs[0].default_value=1; mult.inputs[2].default_value=(*PAINTS[0][1],1)
  nt.links.new(textures['base'].outputs['Color'],mult.inputs[1]); nt.links.new(mult.outputs[0],p.inputs['Base Color']); FACTORS['Paint']=mult
 else: nt.links.new(textures['base'].outputs['Color'],p.inputs['Base Color'])
 normal=node(nt,'ShaderNodeNormalMap'); normal.inputs['Strength'].default_value=1; nt.links.new(textures['normal'].outputs[0],normal.inputs['Color']); nt.links.new(normal.outputs[0],p.inputs['Normal'])
 sep=node(nt,'ShaderNodeSeparateColor'); nt.links.new(textures['orm'].outputs[0],sep.inputs[0]); nt.links.new(sep.outputs[1],p.inputs['Roughness']); nt.links.new(sep.outputs[2],p.inputs['Metallic'])
 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')
 occ=node(nt,'ShaderNodeGroup'); occ.node_tree=group; nt.links.new(sep.outputs[0],occ.inputs['Occlusion'])
 if name in ['Lamps','Engines']:
  nt.links.new(textures['base'].outputs[0],p.inputs['Emission Color']); p.inputs['Emission Strength'].default_value=4.5 if name=='Lamps' else .25
 if name=='Glass':
  p.inputs['Alpha'].default_value=.36; p.inputs['Transmission Weight'].default_value=0; p.inputs['IOR'].default_value=1.47; m.surface_render_method='DITHERED'; m.diffuse_color=(.08,.14,.2,.36)
 else: m.diffuse_color=(*((.85,.35,.05) if name=='Paint' else (.15,.17,.2)),1)
 MATS[name]=m; BAKES[name]={'images':{k:Path(i.filepath_raw).name for k,i in baked.items()},'size':size,'orm_size':min(size,512),'samples':8,'method':'Cycles GPU EMIT / tangent NORMAL'}
 return m

# All low geometry gets UVs and actual bevels before assembly. No final moving-node transforms applied.
def uv_project(o,scale=1):
 me=o.data
 uv=me.uv_layers.active or me.uv_layers.new(name='UVMap')
 for p in me.polygons:
  n=p.normal; ax=max(range(3),key=lambda i:abs(n[i])); ids=[i for i in range(3) if i!=ax]
  for li in p.loop_indices:
   co=me.vertices[me.loops[li].vertex_index].co
   uv.data[li].uv=(co[ids[0]]*scale+.5,co[ids[1]]*scale+.62)

def finish(o,name,mat='Metal',bev=0,smooth=False,group=None):
 o.name=name
 o.data.materials.clear(); o.data.materials.append(MATS[mat])
 select(o)
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if bev:
  m=o.modifiers.new('Machined edge radius','BEVEL'); m.width=bev; m.segments=1 if len(o.data.polygons)>6 else 3; m.limit_method='ANGLE'
  bpy.ops.object.modifier_apply(modifier=m.name)
 if smooth:
  for p in o.data.polygons: p.use_smooth=True
 else:
  # Weighted normals keep broad manufactured surfaces flat after bevel.
  try:
   m=o.modifiers.new('Surface normals','WEIGHTED_NORMAL'); m.keep_sharp=True; m.weight=40; bpy.ops.object.modifier_apply(modifier=m.name)
  except: pass
 uv_project(o)
 (PARTS if group is None else group).append(o)
 return o

def mesh(name,verts,faces,mat='Metal',smooth=False,group=None,uv=None):
 me=bpy.data.meshes.new(name); me.from_pydata(verts,[],faces); me.update(); o=bpy.data.objects.new(name,me); bpy.context.collection.objects.link(o)
 o.data.materials.append(MATS[mat]); (PARTS if group is None else group).append(o)
 for p in me.polygons: p.use_smooth=smooth
 if uv:
  layer=me.uv_layers.new(name='UVMap')
  for p in me.polygons:
   for li in p.loop_indices: layer.data[li].uv=uv[li]
 else: uv_project(o)
 return o

def box(name,loc,dims,mat='Metal',bev=.008,rot=None,group=None):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc); o=bpy.context.object; o.scale=dims
 if rot is not None: o.rotation_euler=rot
 return finish(o,name,mat,bev,False,group)

def cyl(name,loc,radius,depth,mat='Metal',axis=(0,0,1),verts=32,bev=.003,group=None):
 bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=radius,depth=depth,location=loc); o=bpy.context.object; o.rotation_euler=Vector(axis).to_track_quat('Z','Y').to_euler()
 return finish(o,name,mat,bev,True,group)

def lathe(name,profile,mat='Metal',segments=96,origin=(0,0,0),axis=(0,0,1),group=None,tile=1,vmap=None):
 # Profile in (radius, axial distance); closed profiles create real solid rings.
 rot=Vector(axis).to_track_quat('Z','Y').to_matrix(); origin=Vector(origin); verts=[]; faces=[]; uvs=[]
 for r,z in profile:
  for i in range(segments): verts.append(origin+rot@Vector((r*cos(i*TAU/segments),r*sin(i*TAU/segments),z)))
 for j in range(len(profile)-1):
  for i in range(segments):
   faces.append((j*segments+i,j*segments+(i+1)%segments,(j+1)*segments+(i+1)%segments,(j+1)*segments+i))
   v0=(vmap(profile[j][1]) if vmap else j/(len(profile)-1)); v1=(vmap(profile[j+1][1]) if vmap else (j+1)/(len(profile)-1))
   uvs.extend([(i/segments*tile,v0),((i+1)/segments*tile,v0),((i+1)/segments*tile,v1),(i/segments*tile,v1)])
 return mesh(name,verts,faces,mat,True,group,uvs)

def tube(name,points,r=.012,mat='Metal',group=None,sides=8,closed=False):
 pts=[Vector(p) for p in points]; verts=[]; faces=[]
 for i,p in enumerate(pts):
  tangent=(pts[(i+1)%len(pts)]-pts[(i-1)%len(pts)]) if closed else (pts[min(i+1,len(pts)-1)]-pts[max(0,i-1)])
  tangent.normalize(); other=Vector((0,0,1)) if abs(tangent.z)<.9 else Vector((1,0,0)); a=tangent.cross(other).normalized(); b=tangent.cross(a).normalized()
  for k in range(sides): verts.append(p+r*(cos(k*TAU/sides)*a+sin(k*TAU/sides)*b))
 for j in range(len(pts) if closed else len(pts)-1):
  for k in range(sides): faces.append((j*sides+k,j*sides+(k+1)%sides,((j+1)%len(pts))*sides+(k+1)%sides,((j+1)%len(pts))*sides+k))
 if not closed: faces.extend([tuple(range(sides-1,-1,-1)),tuple((len(pts)-1)*sides+k for k in range(sides))])
 return mesh(name,verts,faces,mat,True,group)

def sphere(name,loc,scale,mat='Metal',group=None):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=24,ring_count=12,location=loc); o=bpy.context.object; o.scale=scale
 return finish(o,name,mat,0,True,group)

def rr(w,h,r,steps=8):
 pts=[]
 for cx,cy,ang in [(w/2-r,h/2-r,0),(-w/2+r,h/2-r,90),(-w/2+r,-h/2+r,180),(w/2-r,-h/2+r,270)]:
  for i in range(steps+1):
   a=math.radians(ang+i*90/steps); pts.append((cx+r*cos(a),cy+r*sin(a)))
 return pts
def subdivide_outline(points,spacing=.025):
 dense=[]
 for i,p in enumerate(points):
  q=points[(i+1)%len(points)]; length=math.hypot(q[0]-p[0],q[1]-p[1]); n=max(1,math.ceil(length/spacing))
  dense.extend((p[0]+(q[0]-p[0])*j/n,p[1]+(q[1]-p[1])*j/n) for j in range(n))
 return dense
X=Vector((1,0,0)); V=Vector((0,.325,.945714)).normalized(); N=X.cross(V).normalized()
HC=Vector((0,-1.139,1.76))-N*.024
HINGE_AXIS=V.copy()
def hp(u,v,d=0): return HC+X*u+V*v+N*d
def hatch_bend_point(p):
 q=Vector(p)-HC; u=q.dot(X); v=q.dot(V); slope=.579/1.769
 def hit(x):
  b=HC+X*x+V*v; r=hullr(b.z); aa=N.y*N.y-(slope*N.z)**2; bb=2*b.y*N.y+2*r*slope*N.z; cc=x*x+b.y*b.y-r*r
  return (-bb+math.sqrt(max(0,bb*bb-4*aa*cc)))/(2*aa)
 return Vector(p)+N*(hit(u)-hit(0))
def bend_hatch_object(o,hinge_axis=None):
 if 'hinge' in o.name.lower():
  center=o.matrix_world.translation.copy(); q=V.rotation_difference(hinge_axis); o.matrix_world=Matrix.Translation(hatch_bend_point(center))@q.to_matrix().to_4x4()@Matrix.Translation(-center)@o.matrix_world
  return
 if any(s in o.name for s in ['Porthole','Window','window']): return
 # Extra vertices follow the real cone instead of leaving a planar door slab.
 bm=bmesh.new(); bm.from_mesh(o.data); long=[e for e in bm.edges if e.calc_length()>.10]
 if long: bmesh.ops.subdivide_edges(bm,edges=long,cuts=4,use_grid_fill=True)
 bm.to_mesh(o.data); bm.free(); inv=o.matrix_world.inverted()
 for vert in o.data.vertices: vert.co=inv@hatch_bend_point(o.matrix_world@vert.co)
 o.data.update()
 if o.name in ['Pressure coaming','Hatch leaf rim','Hatch pressure leaf','Inner hatch liner']:
  bm=bmesh.new(); bm.from_mesh(o.data); bm.normal_update()
  for face in bm.faces: face.smooth=True
  for edge in bm.edges: edge.smooth=(len(edge.link_faces)==2 and edge.calc_face_angle(0)<.60)
  bm.to_mesh(o.data); bm.free(); o.data.update()

def rr_solid(name,w,h,r,depth,center=HC,mat='Paint',group=None,normal=N,up=V):
 points=rr(w,h,r); right=Vector(up).cross(Vector(normal)).normalized(); up=Vector(up); normal=Vector(normal); center=Vector(center)
 vs=[center+right*u+up*v+normal*d for d in [-depth/2,depth/2] for u,v in points]; l=len(points)
 fs=[tuple(range(l-1,-1,-1)),tuple(range(l,2*l))]+[(i,(i+1)%l,(i+1)%l+l,i+l) for i in range(l)]
 o=mesh(name,vs,fs,mat,False,group); select(o); mod=o.modifiers.new('Edge radii','BEVEL'); mod.width=.006; mod.segments=3; bpy.ops.object.modifier_apply(modifier=mod.name); uv_project(o)
 return o

def hatch_ring(name,w,h,r,width,depth,offset,mat,group=None):
 outer=rr(w,h,r); inner=rr(w-2*width,h-2*width,max(.01,r-width)); L=len(outer); vs=[]
 for pts,d in [(outer,offset-depth),(outer,offset),(inner,offset),(inner,offset-depth)]: vs.extend(hp(u,v,d) for u,v in pts)
 fs=[]
 for j in range(4):
  for i in range(L): fs.append((j*L+i,j*L+(i+1)%L,((j+1)%4)*L+(i+1)%L,((j+1)%4)*L+i))
 o=mesh(name,vs,fs,mat,False,group); select(o); b=o.modifiers.new('Frame radii','BEVEL'); b.width=.004; b.segments=3; bpy.ops.object.modifier_apply(modifier=b.name); uv_project(o); return o

def boolean(o,cutter):
 select(o); mod=o.modifiers.new('Pressure opening','BOOLEAN'); mod.operation='DIFFERENCE'; mod.solver='EXACT'; mod.object=cutter; bpy.ops.object.modifier_apply(modifier=mod.name)
 for group in [PARTS,MOVING]:
  if cutter in group: group.remove(cutter)
 bpy.data.objects.remove(cutter,do_unlink=True)

def radial(theta,z,extra=0): return Vector((sin(theta)*(hullr(z)+extra),-cos(theta)*(hullr(z)+extra),z))
def hullr(z): return 1.45-(z-.741)*(.579/1.769)
def radialn(t): return Vector((sin(t),-cos(t),.59/1.8)).normalized()
def bolt(loc,axis=(0,0,1),radius=.013,group=None):
 rot=Vector(axis).to_track_quat('Z','Y').to_matrix(); origin=Vector(loc); vs=[]
 for r,z in [(radius,-.0045),(radius,.002),(radius*.78,.0045)]:
  vs.extend(origin+rot@Vector((r*cos(k*TAU/6),r*sin(k*TAU/6),z)) for k in range(6))
 fs=[tuple(range(5,-1,-1)),tuple(range(12,18))]
 for j in range(2):
  for k in range(6): fs.append((j*6+k,j*6+(k+1)%6,(j+1)*6+(k+1)%6,(j+1)*6+k))
 return mesh('Captive titanium fastener',vs,fs,'Metal',False,group)

def socket(name,loc,direction=None):
 o=bpy.data.objects.new(name,None); bpy.context.collection.objects.link(o); o.location=loc; o.empty_display_size=.07
 if direction: o.rotation_euler=Vector(direction).to_track_quat('-Z','Y').to_euler()
 SOCKETS.append(o); return o

def nozzle(name,base,axis,r=.053,length=.15):
 # Double-wall bell, throat recessed well inside; no black capped primitive.
 axis=Vector(axis).normalized(); base=Vector(base)
 p=[(r*.47,0),(r*.48,.022),(r*.66,length*.64),(r,length*.94),(r,length),(r*.80,length),(r*.55,length*.66),(r*.32,.032),(r*.32,.018),(r*.47,0)]
 seg=48 if r>.1 else 16
 ob=lathe(name,p,'Metal',seg,base,axis)
 lathe(name+' refractory liner',[(r*.34,.027),(r*.43,.04),(r*.73,length*.86),(r*.74,length*.91)],'HeatShield',seg,base,axis)
 cyl(name+' chamber',base+axis*.026,r*.31,.008,'Engines',axis,seg,0)
 return base+axis*length

def set_uv_region(o,u0,u1,v0=.45,v1=.85):
 uv=o.data.uv_layers.active
 if uv:
  for d in uv.data: d.uv=(u0+(d.uv.x%1)*(u1-u0),v0+(d.uv.y%1)*(v1-v0))

def build_geometry():
 global HINGE_AXIS
 log('BUILD geometry')
 # A continuous, dished 3 m carbon-phenolic heat shield with a rolled retaining rim.
 prof=[(.245,0.0),(.43,.033),(.66,.080),(.90,.165),(1.12,.275),(1.31,.405),(1.44,.515),(1.490,.58),(1.5,.635),(1.493,.675),(1.46,.711),(1.39,.724),(.245,.59),(.245,0.0)]
 shield=lathe('Carbon phenolic ablative dish',prof,'HeatShield',96,tile=4)
 lathe('Heat shield retention collar',[(1.455,.695),(1.470,.715),(1.467,.738),(1.445,.749),(1.433,.733),(1.455,.695)],'HeatShield',128,tile=4)
 shell=lathe('Pressure vessel',[(1.438,.718),(1.45,.741),(.871,2.51),(.858,2.53),(.807,2.53),(.818,2.49),(1.389,.745),(1.39,.718),(1.438,.718)],'Paint',128,tile=3,vmap=lambda z:(z-.72)/1.81)
 # The cabin lining is independent of the game's exterior faction colour.
 shell.data.materials.append(MATS['Interior'])
 for face in shell.data.polygons:
  if face.index//128 in (4,5,6): face.material_index=1
 cutter=rr_solid('Hatch opening tool',.847,1.043,.165,.65,center=HC-N*.10,mat='Metal'); bend_hatch_object(cutter); boolean(shell,cutter)
 # Shoulder and parachute service bay are distinct overlapping construction shells.
 lathe('Shoulder gasket',[(.852,2.514),(.89,2.525),(.89,2.55),(.86,2.566),(.80,2.566),(.852,2.514)],'Rubber',96)
 lathe('Shoulder rolled flange',[(.87,2.537),(.895,2.545),(.897,2.562),(.88,2.58),(.79,2.58),(.87,2.537)],'Metal',96)
 lathe('Parachute service plinth',[(.815,2.563),(.816,2.62),(.745,2.76),(.72,2.773),(.72,2.744),(.775,2.601),(.785,2.563),(.815,2.563)],'Metal',96,tile=3)
 lathe('Parachute housing',[(.741,2.742),(.733,2.785),(.363,3.355),(.347,3.37),(.334,3.37),(.349,3.34),(.711,2.77),(.741,2.742)],'Paint',96,tile=2,vmap=lambda z:.50+(z-2.74)*.65)
 lathe('Parachute crown rim',[(.349,3.351),(.366,3.367),(.365,3.39),(.352,3.4),(.302,3.4),(.291,3.386),(.30,3.366),(.349,3.351)],'Metal',64)
 cyl('Parachute recessed crown',(.0,0,3.358),.298,.021,'HeatShield',verts=64,bev=.006)
 lathe('Crown seal ring',[(.254,3.366),(.269,3.366),(.269,3.378),(.254,3.378),(.254,3.366)],'Rubber',64)
 cyl('Parachute release plate',(0,0,3.370),.248,.015,'Metal',verts=64,bev=.004)
 for j in range(12):
  a=j*TAU/12; bolt((sin(a)*.280,-cos(a)*.280,3.376),radius=.008)
 for j in range(12):
  a=j*TAU/12
  tube('Chute longitudinal seam',[(sin(a)*r,-cos(a)*r,z) for r,z in [(.734,2.784),(.615,2.969),(.496,3.153),(.366,3.352)]],.0018,'Rubber',sides=5)
  bolt((sin(a)*.698,-cos(a)*.698,2.825),axis=(sin(a),-cos(a),.7),radius=.006)
 for a in [0,pi]:
  for j in range(7):
   t=a+(j-3)*.016; z=2.831; r=.706
   tube('Upper parachute filter slot',[(sin(t)*r,-cos(t)*r,z),(sin(t)*(r-.023),-cos(t)*(r-.023),z+.035)],.0038,'Rubber',sides=5)
 # Main hull plate joints: restrained thin seams and shallow access-panel lips.
 for j in range(12):
  a=(j+.5)*TAU/12
  # Front jamb zone stays clear of the hatch cutout.
  if j not in [0,11]: tube('Longitudinal hull joint',[radial(a,z,.0018) for z in [.78,1.20,1.65,2.12,2.48]],.0019,'Rubber',sides=5)
 for z in [1.18,2.47]:
  tube('Circumferential seam',[radial(i*TAU/96,z,.0025) for i in range(96)],.0025,'Rubber',sides=3,closed=True)
 for j in [1,2,3,4,5]:
  a=j*TAU/6
  pts=subdivide_outline(rr(.70,.98,.075,5))
  path=[radial(a+u/1.08,1.88+v,.003) for u,v in pts]
  tube('Recessed service-panel seal',path,.0033,'Rubber',sides=5,closed=True)
  tube('Service-panel turned edge',[radial(a+u/1.08,1.88+v,.005) for u,v in subdivide_outline(rr(.689,.969,.07,5))],.0019,'Metal',sides=5,closed=True)
  for u,v in [(-.29,-.43),(.29,-.43),(-.28,.43),(.28,.43)]: bolt(radial(a+u/1.08,1.88+v,.012),radialn(a+u/1.08),.009)
  for u in [-.11,.11]:
   t=a+u/.97; loc=radial(t,1.34,.01); cyl('Quarter-turn latch',loc,.020,.012,'Metal',radialn(t),16,.002)
 for j in range(40):
  a=j*TAU/40
  for z in [.794,2.485]: bolt(radial(a,z,.006),radialn(a),.007)
 # Fixed hatch coaming surrounds a true cut-through opening.
 fixed_start=len(PARTS)
 hatch_ring('Pressure coaming',.979,1.178,.224,.061,.18,.028,'Metal')
 hatch_ring('Hatch compression gasket',.874,1.072,.175,.022,.07,.022,'Rubber')
 for u,v in rr(.934,1.132,.205,2)[::2]: bolt(hp(u,v,.040),N,.010)
 door=[]
 hatch_ring('Hatch leaf rim',.838,1.033,.162,.025,.055,.030,'Metal',door)
 leaf=rr_solid('Hatch pressure leaf',.797,.994,.146,.048,HC+N*.012,'Paint',door)
 wc=hp(0,.175,.046)
 cutter=cyl('Window bore tool',wc,.151,.40,'Metal',N,64,0)
 boolean(leaf,cutter)
 set_uv_region(leaf,.08,.92,.46,.94)
 lathe('Porthole recessed gasket',[(.145,-.046),(.168,-.046),(.178,-.020),(.176,.009),(.147,.014),(.145,-.046)],'Rubber',64,wc,N,door)
 lathe('Porthole machined frame',[(.151,-.001),(.184,.001),(.193,.016),(.190,.027),(.176,.036),(.148,.034),(.145,.019),(.151,-.001)],'Metal',64,wc,N,door)
 window=cyl('Porthole laminated glass',wc+N*.017,.146,.013,'Glass',N,64,.003,door)
 for j in range(12):
  a=j*TAU/12; bolt(wc+X*(.174*cos(a))+V*(.174*sin(a))+N*.036,N,.008,door).name='Porthole flange fastener'
 # Hinge knuckles interleave along the same physical pivot line.
 pivot=hp(-.438,0,.031)
 for k in [-.37,.37]:
  cyl('Fixed hinge knuckle',pivot+V*(k+.04),.027,.083,'Metal',V,20,.003)
  cyl('Hatch hinge knuckle',pivot+V*(k-.043),.027,.077,'Metal',V,20,.003,door)
  cyl('Hinge end cap',pivot+V*(k+.085),.031,.012,'Metal',V,20,.002)
 rr_solid('Inset latch pocket',.065,.197,.026,.010,hp(.258,-.185,.037),'Rubber',door)
 tube('Hatch cam handle',[hp(.258,-.25,.041),hp(.258,-.23,.056),hp(.258,-.14,.056),hp(.258,-.12,.041)],.010,'Metal',door,sides=10)
 # Inner hatch insulation and rotary cam, visible when opened.
 rr_solid('Inner hatch liner',.73,.91,.13,.025,HC-N*.015,'Interior',door)
 boolean(door[-1],cyl('Inner window bore',wc,.151,.40,'Metal',N,64,0))
 # Long welded grab rails beside the opening, each with mounted feet.
 for sign in [-1,1]:
  path=[hp(sign*.563,-.365,-.015),hp(sign*.568,-.328,.055),hp(sign*.568,.333,.055),hp(sign*.563,.37,-.015)]
  tube('Boarding handrail',path,.017,'Metal',sides=10)
  for v in [-.365,.37]: cyl('Rail welded foot',hp(sign*.563,v,-.007),.030,.025,'Metal',N,24,.004)
 # Conformal pressure leaf/coaming; straight interleaved hinge barrels keep a real axis.
 HINGE_AXIS=(hatch_bend_point(pivot+V*.37)-hatch_bend_point(pivot-V*.37)).normalized()
 for o in PARTS[fixed_start:]+door: bend_hatch_object(o,HINGE_AXIS)
 # Hull-front lower service grille with actual louvres.
 for z in [.93,1.085]:
  center=radial(0,z,.016); base=box('Recessed vent housing',center,(.31,.035,.091),'Rubber',.018)
  for k in range(6):
   zz=z+(k-2.5)*.0115; tube('Vent louvre',[radial(-.108,zz,.04),radial(.108,zz,.04)],.0035,'Metal',sides=6)
 for a in [-.27,.27]:
  tube('Lower service grab',[radial(a,.86,.012),radial(a,.89,.058),radial(a,1.105,.058),radial(a,1.13,.012)],.014,'Metal',sides=8)
 # Shoulder rail broken into functional handhold segments, mounted at both ends.
 for j in range(8):
  a=j*TAU/8; path=[]
  path.append((sin(a-.29)*.862,-cos(a-.29)*.862,2.572))
  for k in range(9):
   t=a-.27+k*.54/8; path.append((sin(t)*.863,-cos(t)*.863,2.622))
  path.append((sin(a+.29)*.862,-cos(a+.29)*.862,2.572))
  tube('Parachute service handhold',path,.010,'Metal',sides=8)
  for da in [-.29,.29]: cyl('Handhold mounting boss',(sin(a+da)*.862,-cos(a+da)*.862,2.578),.021,.023,'Metal',verts=16,bev=.003)
 for a in [0,pi/2,pi,3*pi/2]:
  for k in range(5):
   z=2.635+k*.018; r=.812-(z-2.62)*.50
   pts=[(sin(a+t)*r,-cos(a+t)*r,z) for t in [-.24,-.12,0,.12,.24]]
   tube('Parachute bay cooling slot',pts,.0055,'Rubber',sides=6)
 # Six ring stations, four individually socketed RCS nozzle exits per station.
 rcsid=1
 for j in range(6):
  a=(j+.5)*TAU/6; outward=Vector((sin(a),-cos(a),0)); tangent=Vector((cos(a),sin(a),0)); base=radial(a,1.015,.030)
  pod=box('RCS recessed mounting saddle',base,(.265,.075,.245),'Metal',.025); pod.rotation_euler[2]=a
  for side in [-1,1]:
   for height in [-1,1]:
    b=base+tangent*(side*.065)+Vector((0,0,height*.052))+outward*.02
    axis=(outward*.20+Vector((0,0,-1))) if height<0 else (outward*.40+tangent*(side*.8)+Vector((0,0,.28)))
    axis.normalize()
    if height<0:
     cyl('RCS pressure chamber',b-axis*.035,.048,.10,'Metal',axis,20,.007)
     tube('RCS feed tube',[base+outward*.058+Vector((0,0,.09)),b-axis*.04+outward*.046],.009,'Metal',sides=6)
    exitp=nozzle('RCS bell',b,axis,r=.053 if height<0 else .032,length=.135 if height<0 else .061)
    socket('Rcs_'+str(rcsid),exitp,axis); rcsid+=1
  for side in [-1,1]: bolt(base+tangent*side*.11+outward*.045+Vector((0,0,.084)),outward,.012)
 # Main retro nozzle is recessed into the central thermal well; exhaust points down.
 lathe('Retro-rocket well',[(.244,.02),(.258,.06),(.26,.30),(.231,.36),(.218,.31),(.218,.03),(.244,.02)],'Metal',64)
 nozzle('Main retro-rocket',Vector((0,0,.267)),Vector((0,0,-1)),r=.212,length=.267)
 socket('Thruster_1',(0,0,0.0),(0,0,-1))
 for j in range(12):
  a=j*TAU/12; bolt((.278*cos(a),.278*sin(a),.035),(0,0,-1),.011)
 # Beacon and navigation lenses all share one emissive atlas.
 a=.72; bp=radial(a,2.043,.035); bn=radialn(a)
 cyl('Beacon recessed socket',bp,.106,.048,'Rubber',bn,48,.005)
 cyl('Beacon metal bezel',bp+bn*.029,.093,.033,'Metal',bn,48,.006)
 lens=sphere('Amber rescue beacon',bp+bn*.05,(.074,.074,.038),'Lamps'); lens.rotation_euler=bn.to_track_quat('Z','Y').to_euler(); set_uv_region(lens,.06,.19,.08,.22)
 core=sphere('Beacon luminous core',bp+bn*.078,(.037,.037,.014),'Lamps'); core.rotation_euler=bn.to_track_quat('Z','Y').to_euler(); set_uv_region(core,.06,.19,.86,.98)
 socket('Light_Beacon',bp+bn*.080,bn)
 for a,name,u in [(-pi/2,'Light_Nav_L',.32),(pi/2,'Light_Nav_R',.57)]:
  p=radial(a,1.29,.018); n=radialn(a)
  cyl('Navigation light bezel',p,.040,.025,'Metal',n,24,.003)
  ob=sphere('Navigation lens',p+n*.018,(.029,.029,.018),'Lamps'); ob.rotation_euler=n.to_track_quat('Z','Y').to_euler(); set_uv_region(ob,u,u+.08)
  socket(name,p+n*.04,n)
 # Three crash couches and restrained cockpit controls behind the hatch window.
 cyl('Cabin floor',(0,0,.79),1.31,.055,'Interior',verts=64,bev=.012)
 for j,a in enumerate([-pi/2,pi,pi/2]):
  p=Vector((sin(a)*.66,-cos(a)*.66,1.00)); rot=(0,0,a)
  box('Crash couch chassis',p,(.43,.48,.11),'Metal',.045,rot)
  box('Crash couch cushion',p+Vector((0,0,.09)),(.405,.46,.115),'Interior',.045,rot)
  back=p+Vector((sin(a)*.19,-cos(a)*.19,.44))
  box('Crash couch back',back,(.43,.12,.67),'Interior',.055,rot)
  box('Crash couch head pad',back+Vector((0,0,.38)),(.27,.14,.19),'Interior',.05,rot)
  for sign in [-1,1]:
   off=Vector((cos(a)*sign*.12,sin(a)*sign*.12,0))
   tube('Restraint webbing',[back+off+Vector((-sin(a)*.075,cos(a)*.075,.25)),back+off+Vector((-sin(a)*.105,cos(a)*.105,-.18)),p+off+Vector((-sin(a)*.10,cos(a)*.10,.14))],.014,'Rubber',sides=6)
  cyl('Restraint clasp',p+Vector((0,0,.16)),.043,.022,'Metal',verts=16,bev=.006)
 panel=box('Rescue control console',(0,-.54,1.04),(.44,.24,.16),'Metal',.035,rot=(math.radians(15),0,0))
 for x in [-.105,.105]:
  screen=box('Unmarked control display',(x,-.58,1.132),(.155,.11,.009),'Lamps',.009); set_uv_region(screen,.80,.94)
 tube('Flight stick',[(.24,-.56,1.06),(.24,-.56,1.27),(.24,-.60,1.31)],.021,'Rubber',sides=10)
 socket('Seat_Pilot',(0,.61,1.14))
 socket('Camera_Chase',(0,6.5,4.0),(0,-1,-.3))
 # Geometry accounting before rigid assembly.
 counts={}
 for o in PARTS+door:
  key=o.name.split('.')[0]; counts[key]=counts.get(key,0)+sum(len(p.vertices)-2 for p in o.data.polygons)
 log('COMPONENT_TRIANGLES',sorted(counts.items(),key=lambda t:-t[1])[:35])
 # Join each rigid assembly. Origin shift keeps exact metres and useful centre of mass.
 hatch=join(door,'Hatch'); pivot=hatch_bend_point(hp(-.438,0,.031))
 origin_at(hatch,pivot); MOVING.append(hatch)
 hull=join(PARTS,'Hull'); origin_at(hull,Vector((0,0,COM)))
 hull.location.z-=COM; hatch.location.z-=COM
 for e in SOCKETS: e.location.z-=COM
 bpy.context.view_layer.update()
 hatch.parent=hull; hatch.matrix_parent_inverse=Matrix.Identity(4)
 for e in SOCKETS: e.parent=hull; e.matrix_parent_inverse=Matrix.Identity(4)
 return hull,hatch

def join(obs,name):
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs: o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0]; bpy.ops.object.join(); o=bpy.context.object; o.name=name
 # Discard unused slots, keeping per-material primitives bounded.
 bpy.ops.object.material_slot_remove_unused()
 target=42500 if name=='Hull' else 6000
 triangles=sum(len(p.vertices)-2 for p in o.data.polygons)
 if triangles>target:
  dec=o.modifiers.new('Game geometry reduction','DECIMATE'); dec.ratio=target/triangles; dec.use_collapse_triangulate=True
  bpy.ops.object.modifier_apply(modifier=dec.name)
 # Remove collapsed zero-area faces before glTF triangulation; retain all real surfaces.
 bm=bmesh.new(); bm.from_mesh(o.data); bmesh.ops.triangulate(bm,faces=list(bm.faces))
 dead=[f for f in bm.faces if f.calc_area()<5e-13]
 if dead: bmesh.ops.delete(bm,geom=dead,context='FACES_ONLY')
 loose=[v for v in bm.verts if not v.link_faces]
 if loose: bmesh.ops.delete(bm,geom=loose,context='VERTS')
 bm.to_mesh(o.data); bm.free(); o.data.update()
 log('ASSEMBLY',name,triangles,'to',sum(len(p.vertices)-2 for p in o.data.polygons))
 return o

def origin_at(o,p):
 # Move mesh coordinates, not object transforms. Final moving nodes retain their pivot.
 old=o.matrix_world.copy(); T=Matrix.Translation(Vector(p)); o.data.transform(T.inverted()@old); o.matrix_world=T

def stage_camera(hull):
 meshes=[hull]+MOVING
 pts=[o.matrix_world@Vector(v) for o in meshes for v in o.bound_box]
 lo=Vector([min(p[i] for p in pts) for i in range(3)]); hi=Vector([max(p[i] for p in pts) for i in range(3)]); center=(lo+hi)*.5
 world=bpy.data.worlds.new('Deep blue space'); world.use_nodes=True; wn=world.node_tree; wn.nodes.clear()
 tc=node(wn,'ShaderNodeTexCoord'); sep=node(wn,'ShaderNodeSeparateXYZ'); wn.links.new(tc.outputs['Normal'],sep.inputs[0]); bg=node(wn,'ShaderNodeBackground'); grad=ramp(wn,mathn(wn,'ADD',.55,mathn(wn,'MULTIPLY',sep.outputs['Z'],.65)),[(0,(.003,.005,.012)),(1,(.018,.033,.062))]); wn.links.new(grad,bg.inputs[0]); bg.inputs[1].default_value=.4; wo=node(wn,'ShaderNodeOutputWorld'); wn.links.new(bg.outputs[0],wo.inputs[0]); SC.world=world
 def area(name,loc,power,color,size):
  d=bpy.data.lights.new(name,'AREA'); d.energy=power; d.color=color; d.shape='DISK'; d.size=size; o=bpy.data.objects.new(name,d); bpy.context.collection.objects.link(o); o.location=loc; o.rotation_euler=(center-o.location).to_track_quat('-Z','Y').to_euler()
 area('Large white daylight key',(-4.0,-5.0,6.5),1100,(1,.965,.91),4)
 area('Cool soft fill',(4,-2,3),700,(.59,.75,1),4)
 area('High edge light',(.5,4,5),1450,(.70,.82,1),3)
 area('Thermal dish bounce',(-3,-2,-2.7),220,(.65,.72,.88),3)
 d=bpy.data.cameras.new('Render camera'); cam=bpy.data.objects.new('Render camera',d); bpy.context.collection.objects.link(cam); SC.camera=cam
 cam.data.type='ORTHO'; cam.data.ortho_scale=6.4; cam.location=center+Vector((5.3,-8.6,4.0)); cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler(); cam.data.lens=54
 return cam,center,lo,hi

def render(path,w=1920,h=1080,samples=32):
 SC.render.resolution_x=w; SC.render.resolution_y=h; SC.render.resolution_percentage=100; SC.cycles.samples=samples; SC.render.filepath=str(OUT/path); SC.render.image_settings.color_mode='RGBA'; bpy.ops.render.render(write_still=True); log('RENDER',path)

def export_asset(hull,hatch,lo,hi):
 select(hull); hatch.select_set(True)
 for s in SOCKETS: s.select_set(True)
 # Export only portable image sockets; tint is explicitly serialized as glTF baseColorFactor.
 m=MATS['Paint']; p=next(n for n in m.node_tree.nodes if n.type=='BSDF_PRINCIPLED'); t=next(n for n in m.node_tree.nodes if n.type=='TEX_IMAGE' and n.image.name=='Paint_base'); m.node_tree.links.new(t.outputs[0],p.inputs['Base Color'])
 path=OUT/'escape-pod-capsule.glb'
 bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=False,export_cameras=False,export_lights=False,export_extras=True,export_image_format='AUTO',export_texcoords=True,export_normals=True,export_tangents=True)
 blob=path.read_bytes(); jl,jt=struct.unpack_from('<II',blob,12); doc=json.loads(blob[20:20+jl]); end=20+jl
 binary_data=bytearray(blob[end+8:]); repaired=0
 def accessor_layout(index,components):
  a=doc['accessors'][index]; v=doc['bufferViews'][a['bufferView']]
  assert a['componentType']==5126 and 'sparse' not in a
  return v.get('byteOffset',0)+a.get('byteOffset',0),v.get('byteStride',components*4),a['count']
 # A collapsed UV derivative at a bevel/pole can yield a zero MikkTSpace
 # tangent. Give only those singular vertices a valid orthogonal basis.
 for mesh_doc in doc['meshes']:
  for primitive in mesh_doc['primitives']:
   attrs=primitive['attributes']
   if 'TANGENT' not in attrs: continue
   to,ts,count=accessor_layout(attrs['TANGENT'],4); no,ns,ncount=accessor_layout(attrs['NORMAL'],3)
   assert count==ncount
   for i in range(count):
    tx,ty,tz,hand=struct.unpack_from('<4f',binary_data,to+i*ts)
    if tx*tx+ty*ty+tz*tz<1e-12:
     normal=Vector(struct.unpack_from('<3f',binary_data,no+i*ns)).normalized()
     basis=Vector((1,0,0)) if abs(normal.x)<.8 else Vector((0,1,0))
     tangent=normal.cross(basis).normalized()
     struct.pack_into('<4f',binary_data,to+i*ts,*tangent,-1.0 if hand<0 else 1.0); repaired+=1
 binary=blob[end:end+8]+bytes(binary_data); log('UV singular tangent bases repaired',repaired)
 # Empty sockets retain local axes during glTF's basis conversion. Explicitly
 # orient their conventional local -Z along the exported exhaust/view vector.
 socket_map={o.name:o for o in SOCKETS}
 for nd in doc['nodes']:
  name=nd.get('name','')
  if name=='Thruster_1' or name.startswith('Rcs_') or name=='Camera_Chase':
   direction=socket_map[name].matrix_world.to_quaternion()@Vector((0,0,-1))
   direction=Vector((direction.x,direction.z,-direction.y))
   q=direction.to_track_quat('-Z','Y')
   nd['rotation']=[q.x,q.y,q.z,q.w]
 for mat in doc['materials']:
  if mat['name']=='Paint': mat['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1]
  if mat['name']=='Glass': mat['alphaMode']='BLEND'; mat['doubleSided']=True
 payload=json.dumps(doc,separators=(',',':')).encode(); payload+=b' '*((-len(payload))%4)
 path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(payload)+len(binary))+struct.pack('<II',len(payload),0x4e4f534a)+payload+binary)
 m.node_tree.links.new(FACTORS['Paint'].outputs[0],p.inputs['Base Color'])
 tri=sum(len(p.vertices)-2 for o in [hull,hatch] for p in o.data.polygons)
 def yup(v): return [round(v.x,6),round(v.z,6),round(-v.y,6)]
 bounds={'min':yup(Vector((lo.x,hi.y,lo.z))),'max':yup(Vector((hi.x,lo.y,hi.z))),'dimensions':[round(hi.x-lo.x,6),round(hi.z-lo.z,6),round(hi.y-lo.y,6)]}
 rows={}
 for o in [hull,hatch]+SOCKETS:
  rows[o.name]={'position':yup(o.matrix_world.translation),'type':'mesh' if o.type=='MESH' else 'socket','parent':o.parent.name if o.parent else None}
  if o in SOCKETS and (o.name.startswith('Rcs_') or o.name=='Thruster_1'): rows[o.name]['exhaust_direction']=yup(o.matrix_world.to_quaternion()@Vector((0,0,-1)))
 meta={'units':'metres','up':'+Y','forward':'+Z','origin':'estimated loaded centre of mass; 1.38 m above heat-shield bottom','nodes':rows,'bounding_box':bounds,'axes':{'Hatch':yup(HINGE_AXIS)},'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'triangles':tri,'materials':list(MATS),'bakes':BAKES,'moving_check':{'Hatch':{'degrees':30,'axis_parent':yup(HINGE_AXIS),'test_render':'hatch-open.png'}},'license':{'Poly Haven':'CC0-1.0','ids':['rusty_metal_02','rusty_metal_04','rubber_tiles']}}
 (OUT/'nodes.json').write_text(json.dumps(meta,indent=2))
 notes=["Original three-person escape capsule, 3.40 m tall × 3.00 m heat-shield diameter; hinged Hatch, 24 RCS sockets, retro-rocket and cabin.",f"Triangles: {tri:,}; GLB: {path.stat().st_size/1048576:.2f} MiB; two mesh nodes plus gameplay sockets.","Materials: Paint, Metal, HeatShield, Rubber, Interior, Glass, Engines, Lamps (8); neutral Paint bake × linear rescue-orange factor.","Textures: Paint 1024²; Metal/HeatShield 512²; Rubber/Interior 256²; Glass 128²; Engines/Lamps 64²; ORM ≤512², Cycles GPU baked.","Poly Haven CC0 ids: rusty_metal_02, rusty_metal_04, rubber_tiles; licence https://polyhaven.com/license ; sources https://polyhaven.com/a/<id> .", "Reference differences: rescue-orange default instead of white; rounded pressure hatch, original RCS fittings and recessed retro motor; no text or insignia."]
 (OUT/'NOTES.md').write_text('\n'.join(notes)+'\n'); log('EXPORTED',tri,'triangles',path.stat().st_size,'bytes',bounds)
 return meta

def main():
 if '--preflight' in sys.argv:
  bake_material('Metal',64); bpy.ops.mesh.primitive_cube_add(); o=bpy.context.object; o.data.materials.append(MATS['Metal']); uv_project(o)
  bpy.ops.export_scene.gltf(filepath=str(OUT/'preflight.glb'),export_format='GLB',use_selection=True)
  bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
  for m in list(bpy.data.materials): bpy.data.materials.remove(m)
  for g in list(bpy.data.node_groups):
   if g.name=='glTF Material Output': bpy.data.node_groups.remove(g)
  bpy.ops.import_scene.gltf(filepath=str(OUT/'preflight.glb')); log('PREFLIGHT round-trip OK'); return
 (OUT/'build-plan.json').write_text(json.dumps({'scope':'3-person rescue capsule from sheet.jpg; original construction','cameras':['front three-quarter','orthographic right side','clay three-quarter','hatch 30 degrees','six-paint grid'],'limits':{'triangles':[15000,50000],'materials':9,'image_max':1024,'orm_max':512,'glb_bytes':12582912,'revision_cap':3},'renderer':'Cycles OptiX GPU','source':'Poly Haven CC0','start_epoch':start},indent=2))
 if '--geometry' in sys.argv:
  for n in ['Paint','Metal','HeatShield','Rubber','Interior','Glass','Engines','Lamps']:
   m,nt,p,out=newmat(n); p.inputs['Base Color'].default_value=(.38,.38,.38,1); p.inputs['Roughness'].default_value=.55; MATS[n]=m
 else:
  for n,s in [('Paint',1024),('Metal',512),('HeatShield',512),('Rubber',256),('Interior',256),('Glass',128),('Engines',64),('Lamps',64)]: bake_material(n,s)
 hull,hatch=build_geometry(); cam,center,lo,hi=stage_camera(hull)
 if '--geometry' in sys.argv:
  log('GEOMETRY_TRIANGLES',sum(len(p.vertices)-2 for o in [hull,hatch] for p in o.data.polygons))
  render('clay-preview.png',1280,960,12); bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'geometry.blend')); return
 # Export precedes final render; complete deliverable available early.
 export_asset(hull,hatch,lo,hi)
 bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'escape-pod-capsule.blend'))
 render('final.png',1920,1080,40)
 loc=cam.location.copy(); rot=cam.rotation_euler.copy(); scale=cam.data.ortho_scale
 cam.location=center+Vector((8,0,0)); cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler(); cam.data.ortho_scale=4.0
 render('side.png',1200,1200,24)
 cam.location=loc; cam.rotation_euler=rot; cam.data.ortho_scale=scale
 clay,nt,p,out=newmat('QA_clay'); p.inputs['Base Color'].default_value=(.38,.38,.38,1); p.inputs['Roughness'].default_value=.58; SC.view_layers[0].material_override=clay
 render('clay.png',1920,1080,24); SC.view_layers[0].material_override=None
 # Test the ONLY moving assembly around its true hinge; never apply its transforms.
 before=hatch.matrix_world.translation.copy(); hatch.rotation_mode='QUATERNION'; hatch.rotation_quaternion=Quaternion(HINGE_AXIS,math.radians(-30)); bpy.context.view_layer.update(); assert (hatch.matrix_world.translation-before).length<1e-7
 render('hatch-open.png',1280,960,20); hatch.rotation_quaternion=Quaternion(); bpy.context.view_layer.update()
 # Six identical cameras/exposure; no labels or logos on the object or images.
 panels=[]; cam.data.ortho_scale=5.0
 for i,(name,col) in enumerate(PAINTS):
  FACTORS['Paint'].inputs[2].default_value=(*col,1); render(f'paint-{i}.png',640,540,12)
  im=bpy.data.images.load(str(OUT/f'paint-{i}.png'),check_existing=False); a=np.empty(640*540*4,dtype=np.float32); im.pixels.foreach_get(a); panels.append(a.reshape(540,640,4)); bpy.data.images.remove(im)
 atlas=np.concatenate([np.concatenate(panels[3:6],axis=1),np.concatenate(panels[:3],axis=1)],axis=0)
 im=bpy.data.images.new('Paint variations',width=1920,height=1080,alpha=True); im.pixels.foreach_set(atlas.ravel()); im.filepath_raw=str(OUT/'paints.png'); im.file_format='PNG'; im.save()
 FACTORS['Paint'].inputs[2].default_value=(*PAINTS[0][1],1); cam.data.ortho_scale=scale
 log('DONE',round(time.time()-start,1),'seconds')
if __name__=='__main__': main()
