"""Industrial asteroid miner, Blender 5.2, metres. Clean reproducible build.
Run: blender -b --python-exit-code 1 --python build_mining-ship.py
Optional: -- --preflight (small bake/export/render exercise).
Design coordinates are x=left, f=forward, h=up; glTF exports (x,h,f).
All downloaded photographic inputs are Poly Haven CC0. No external image sources.
"""
import bpy, bmesh, math, json, os, sys, time, struct, subprocess
from pathlib import Path
from mathutils import Vector, Matrix
import numpy as np
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT)
T0=time.time(); PREFLIGHT='--preflight' in sys.argv
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
QA=ROOT/'validation'; QA.mkdir(exist_ok=True)
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=24;S.cycles.use_denoising=True
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX';prefs.get_devices()
for d in prefs.devices:d.use=d.type!='CPU'
assert any(d.use and d.type!='CPU' for d in prefs.devices),'GPU unavailable'
S.cycles.device='GPU';S.cycles.max_bounces=6
S.render.image_settings.file_format='PNG';S.render.image_settings.color_mode='RGB'
S.view_settings.view_transform='AgX';S.view_settings.look='AgX - Medium High Contrast'
S.render.film_transparent=False
M={};G={};CURRENT='Hull';BAKES=[];UV_RNG=np.random.default_rng(241)
PAINTS=[('Safety yellow',[.85,.65,.05]),('Porcelain white',[.80,.82,.80]),('Industrial orange',[.80,.19,.035]),('Signal red',[.55,.035,.022]),('Petrol teal',[.035,.24,.22]),('Lunar blue',[.27,.48,.64])]

def log(*s):print(round(time.time()-T0,1),*s,flush=True)
def V(p):return Vector((p[0],-p[1],p[2]))
def game(p):return [round(p[0],5),round(p[2],5),round(-p[1],5)]
def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def download(a,k):
 p=TEX/f'{a}_{k}_1k.jpg'
 if not p.exists():
  u=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{a}/{a}_{k}_1k.jpg'
  subprocess.run(['curl','-fLsS','-A','Mozilla/5.0','--retry','2',u,'-o',str(p)],check=True)
 im=bpy.data.images.load(str(p),check_existing=True)
 if k!='diff':im.colorspace_settings.name='Non-Color'
 return im
IDS=['metal_plate_02','rusty_painted_metal','rusty_metal_03']
SOURCES={a:{k:download(a,k) for k in ['diff','nor_gl','arm']} for a in IDS}

def source_material(name,color,metal,rough,source=None):
 m=bpy.data.materials.new(name);m.use_nodes=True;M[name]=m
 nt=m.node_tree;n=nt.nodes;l=nt.links;bs=n.get('Principled BSDF')
 bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 uv=n.new('ShaderNodeTexCoord')
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=86;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.22;ramp.color_ramp.elements[0].color=(*(c*.65 for c in color),1);ramp.color_ramp.elements[1].position=.82;ramp.color_ramp.elements[1].color=(*color,1);l.new(noise.outputs['Fac'],ramp.inputs[0]);col=ramp.outputs[0]
 if source:
  tex=n.new('ShaderNodeTexImage');tex.image=SOURCES[source]['diff'];l.new(uv.outputs['UV'],tex.inputs['Vector'])
  bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs[0],bw.inputs[0])
  mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.70 if name=='Paint' else .38;l.new(col,mix.inputs[1]);l.new(bw.outputs[0] if name=='Paint' else tex.outputs[0],mix.inputs[2]);col=mix.outputs[0]
  if name=='Paint':
   # Neutral pale oxide, scratches and chips: tint is applied ONLY after baking.
   cr=n.new('ShaderNodeValToRGB');cr.color_ramp.elements[0].position=.012;cr.color_ramp.elements[0].color=(.23,.23,.23,1);cr.color_ramp.elements[1].position=.24;cr.color_ramp.elements[1].color=(.96,.96,.96,1);cr.color_ramp.elements.new(.04).color=(.66,.66,.66,1);cr.color_ramp.elements.new(.10).color=(.85,.85,.85,1);l.new(bw.outputs[0],cr.inputs[0]);col=cr.outputs[0]
  normal=n.new('ShaderNodeTexImage');normal.image=SOURCES[source]['nor_gl'];l.new(uv.outputs['UV'],normal.inputs[0]);nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.16 if name=='Paint' else .38;l.new(normal.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
  arm=n.new('ShaderNodeTexImage');arm.image=SOURCES[source]['arm'];l.new(uv.outputs['UV'],arm.inputs[0]);sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0]);rr=n.new('ShaderNodeMapRange');rr.inputs['To Min'].default_value=rough*.72;rr.inputs['To Max'].default_value=min(.93,rough*1.26);l.new(sep.outputs['Green'],rr.inputs['Value']);l.new(rr.outputs[0],bs.inputs['Roughness'])
  m['ao_source_node']=arm.name
 if name in ['Paint','Alloy','CarbonSteel','ServiceTrim']:
  vec=n.new('ShaderNodeVectorMath');vec.operation='MULTIPLY';vec.inputs[1].default_value=(55,4,1);l.new(uv.outputs['UV'],vec.inputs[0]);st=n.new('ShaderNodeTexNoise');st.inputs['Scale'].default_value=5;st.inputs['Detail'].default_value=3;l.new(vec.outputs[0],st.inputs[0])
  sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.30;sr.color_ramp.elements[0].color=(.18,.18,.18,1);sr.color_ramp.elements[1].position=.49;sr.color_ramp.elements[1].color=(1,1,1,1);l.new(st.outputs['Fac'],sr.inputs[0]);mx=n.new('ShaderNodeMixRGB');mx.blend_type='MULTIPLY';mx.inputs[0].default_value=.20;l.new(col,mx.inputs[1]);l.new(sr.outputs[0],mx.inputs[2]);col=mx.outputs[0]
 l.new(col,bs.inputs['Base Color'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.16;bump.inputs['Distance'].default_value=.004;l.new(noise.outputs['Fac'],bump.inputs['Height'])
 if bs.inputs['Normal'].is_linked:l.new(bs.inputs['Normal'].links[0].from_socket,bump.inputs['Normal'])
 if name not in ['Glass','Lamps','Engines']:l.new(bump.outputs[0],bs.inputs['Normal'])
 return m
source_material('Paint',(.98,.98,.98),.16,.66,'rusty_metal_03')
source_material('Alloy',(.72,.76,.80),.86,.35,'metal_plate_02')
source_material('CarbonSteel',(.24,.27,.30),.75,.52,'metal_plate_02')
source_material('ServiceTrim',(.45,.44,.40),.72,.51,'rusty_metal_03')
source_material('Oxide',(.26,.12,.055),.48,.81,'rusty_painted_metal')
source_material('Glass',(.12,.23,.27),.15,.18)
source_material('Lamps',(1,.87,.65),.04,.27)
source_material('Engines',(1,.18,.015),.25,.38)
n=M['Engines'].node_tree.nodes;l=M['Engines'].node_tree.links;bs=n.get('Principled BSDF');uv=n.get('Texture Coordinate')
dist=n.new('ShaderNodeVectorMath');dist.operation='DISTANCE';dist.inputs[1].default_value=(.5,.5,0);l.new(uv.outputs['UV'],dist.inputs[0]);r=n.new('ShaderNodeValToRGB');r.color_ramp.elements[0].position=.05;r.color_ramp.elements[0].color=(1,.50,.075,1);r.color_ramp.elements[1].position=.5;r.color_ramp.elements[1].color=(.35,.025,.002,1);l.new(dist.outputs['Value'],r.inputs[0]);l.new(r.outputs[0],bs.inputs['Base Color'])
# Single shared lamp strip: white work lamps, red port, green starboard, amber beacon.
n=M['Lamps'].node_tree.nodes;l=M['Lamps'].node_tree.links;bs=n.get('Principled BSDF');uv=n.get('Texture Coordinate');sep=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],sep.inputs[0]);r=n.new('ShaderNodeValToRGB');r.color_ramp.interpolation='CONSTANT'
r.color_ramp.elements[0].position=0;r.color_ramp.elements[0].color=(.78,.88,1,1);r.color_ramp.elements[1].position=.25;r.color_ramp.elements[1].color=(1,.008,.003,1)
r.color_ramp.elements.new(.5).color=(.008,1,.035,1);r.color_ramp.elements.new(.75).color=(1,.25,.008,1);l.new(sep.outputs[0],r.inputs[0]);l.new(r.outputs[0],bs.inputs['Base Color'])

def bake_material(m):
 name=m.name;size=64 if name in ['Lamps','Glass','Engines'] else (512 if name=='Oxide' else 1024)
 if PREFLIGHT:size=64
 bpy.ops.mesh.primitive_plane_add(size=4);o=bpy.context.object;o.name='BakeTile';o.data.materials.append(m)
 nt=m.node_tree;n=nt.nodes;l=nt.links;bs=n.get('Principled BSDF');out=n.get('Material Output');original=out.inputs['Surface'].links[0].from_socket
 col=bs.inputs['Base Color'].links[0].from_socket;rough=bs.inputs['Roughness'].links[0].from_socket if bs.inputs['Roughness'].is_linked else None
 em=n.new('ShaderNodeEmission');target=n.new('ShaderNodeTexImage');imgs={}
 for typ,res in [('base',size),('normal',size),('orm',min(512,size))]:
  im=bpy.data.images.new(name+'_'+typ,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if typ=='base' else 'Non-Color';target.image=im;n.active=target
  S.cycles.samples=8;S.render.bake.margin=2
  if typ=='normal':l.new(original,out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
  else:
   if typ=='base':l.new(col,em.inputs['Color'])
   else:
    co=n.new('ShaderNodeCombineColor');co.inputs['Red'].default_value=1;co.inputs['Green'].default_value=bs.inputs['Roughness'].default_value;co.inputs['Blue'].default_value=bs.inputs['Metallic'].default_value
    if rough:l.new(rough,co.inputs['Green'])
    if 'ao_source_node' in m:
     se=n.new('ShaderNodeSeparateColor');l.new(n[m['ao_source_node']].outputs[0],se.inputs[0]);lift=n.new('ShaderNodeMath');lift.operation='MULTIPLY_ADD';lift.inputs[1].default_value=.3;lift.inputs[2].default_value=.7;l.new(se.outputs['Red'],lift.inputs[0]);l.new(lift.outputs[0],co.inputs['Red'])
    l.new(co.outputs[0],em.inputs['Color'])
   l.new(em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
  ext='jpg' if typ in ['base','normal'] and size>64 else 'png'
  im.filepath_raw=str(TEX/f'{name}_{typ}.{ext}');im.file_format='JPEG' if ext=='jpg' else 'PNG';im.save();im.reload();imgs[typ]=im
  arr=np.array(im.pixels[:],dtype=np.float32).reshape(-1,4)
  BAKES.append({'material':name,'pass':typ,'resolution':res,'device':'OPTIX','samples':8,'min':float(arr[:,:3].min()),'max':float(arr[:,:3].max())})
 n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 for typ,im in imgs.items():
  tex=n.new('ShaderNodeTexImage');tex.image=im;tex.name=typ
  if typ=='base':
   if name=='Paint':
    tint=n.new('ShaderNodeMixRGB');tint.name='Faction paint';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;tint.inputs[2].default_value=(*PAINTS[0][1],1);l.new(tex.outputs[0],tint.inputs[1]);l.new(tint.outputs[0],bs.inputs['Base Color'])
   else:l.new(tex.outputs[0],bs.inputs['Base Color'])
  elif typ=='normal':
   nm=n.new('ShaderNodeNormalMap');l.new(tex.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   se=n.new('ShaderNodeSeparateColor');l.new(tex.outputs[0],se.inputs[0]);l.new(se.outputs['Green'],bs.inputs['Roughness']);l.new(se.outputs['Blue'],bs.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')
   gn=n.new('ShaderNodeGroup');gn.node_tree=group;l.new(se.outputs['Red'],gn.inputs['Occlusion'])
 if name=='Glass':bs.inputs['Alpha'].default_value=.43;m.surface_render_method='DITHERED'
 if name in ['Lamps','Engines']:
  l.new(n['base'].outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=2.0 if name=='Lamps' else 1.6
 bpy.data.objects.remove(o,do_unlink=True);log('BAKED',name)

# Geometry helpers. World-scale box-projection UVs; rounded connected sections.
def mesh(name,verts,faces,mat,bev=0,smooth=False,group=None):
 me=bpy.data.meshes.new(name);me.from_pydata([V(p) for p in verts],[],faces);me.update()
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces)
 if bev:
  edges=[e for e in bm.edges if e.is_manifold and e.calc_face_angle(0)>.35]
  bmesh.ops.bevel(bm,geom=edges,offset=bev,segments=1 if any(t in name.lower() for t in ['fastener','bolt','tooth','chip','clamp']) else 2,affect='EDGES',clamp_overlap=True)
  bmesh.ops.recalc_face_normals(bm,faces=bm.faces)
 bmesh.ops.dissolve_degenerate(bm,dist=1e-7,edges=list(bm.edges))
 bmesh.ops.recalc_face_normals(bm,faces=bm.faces)
 bm.to_mesh(me);bm.free();me.update()
 o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);me.materials.append(M[mat]);G.setdefault(group or CURRENT,[]).append(o)
 for p in me.polygons:p.use_smooth=smooth
 uv=me.uv_layers.new(name='UVMap')
 jitter=UV_RNG.random(2) if name in ['Hopper armoured door','Fitted bow armour','Dorsal segmented centre panel','Bridge visor roof'] else (0,0)
 scale=4.0 if mat=='Paint' else 2.6
 for p in me.polygons:
  axis=max(range(3),key=lambda i:abs(p.normal[i]));inds=[i for i in range(3) if i!=axis]
  for li in p.loop_indices:
   co=me.vertices[me.loops[li].vertex_index].co;uv.data[li].uv=(co[inds[0]]/scale+jitter[0],co[inds[1]]/scale+jitter[1])
 return o

def box(name,c,d,mat='Paint',bev=.08):
 x,f,h=c;a,b,z=[v/2 for v in d]
 vs=[(x+u*a,f+v*b,h+w*z) for u,v,w 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)]]
 return mesh(name,vs,[(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3),(0,2,3,1),(4,5,7,6)],mat,bev)

def panel(name,pts,mat='Paint',thick=.18,bev=.055):
 p=[Vector(v) for v in pts];normal=(p[1]-p[0]).cross(p[2]-p[0]).normalized();n=len(p)
 centre=sum(p,Vector())/n;radial=normal.x*centre.x+normal.z*centre.z
 if radial < -.0001 or (abs(radial)<.0001 and normal.y*centre.y<0):
  p.reverse();normal=-normal
 return mesh(name,[tuple(q) for q in p]+[tuple(q-normal*thick) for q in p],[tuple(range(n)),tuple(reversed(range(n,2*n)))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],mat,bev)

def rod(name,a,b,r,mat='Alloy',N=16,bev=.035,r2=None):
 a,b=Vector(a),Vector(b);d=(b-a).normalized();u=d.cross(Vector((0,0,1)))
 if u.length<.01:u=d.cross(Vector((1,0,0)))
 u.normalize();v=d.cross(u);r2=r if r2 is None else r2
 vs=[tuple(c+rr*(u*math.cos(i*2*math.pi/N)+v*math.sin(i*2*math.pi/N))) for c,rr in [(a,r),(b,r2)] for i in range(N)]
 fs=[tuple(reversed(range(N))),tuple(range(N,2*N))]+[(i,(i+1)%N,(i+1)%N+N,i+N) for i in range(N)]
 o=mesh(name,vs,fs,mat,min(bev,r*.18),True)
 # End faces remain flat; barrel sides are smooth.
 for p in o.data.polygons:
  if len(p.vertices)>4:p.use_smooth=False
 return o

def tube(name,points,r=.12,mat='CarbonSteel',N=10):
 ps=[Vector(p) for p in points];vs=[]
 for i,p in enumerate(ps):
  d=(ps[min(i+1,len(ps)-1)]-ps[max(0,i-1)]).normalized();u=d.cross(Vector((0,0,1)))
  if u.length<.01:u=d.cross(Vector((1,0,0)))
  u.normalize();v=d.cross(u)
  vs.extend(tuple(p+r*(u*math.cos(j*2*math.pi/N)+v*math.sin(j*2*math.pi/N))) for j in range(N))
 fs=[]
 for i in range(len(ps)-1):
  for j in range(N):fs.append((i*N+j,i*N+(j+1)%N,(i+1)*N+(j+1)%N,(i+1)*N+j))
 fs.extend([tuple(reversed(range(N))),tuple(range((len(ps)-1)*N,len(ps)*N))]);return mesh(name,vs,fs,mat,0,True)

def ring(name,c,r,minor,axis='f',mat='Alloy',N=64,K=8):
 x,f,h=c;vs=[]
 for i in range(N):
  a=2*math.pi*i/N
  for j in range(K):
   b=2*math.pi*j/K;rr=r+minor*math.cos(b);off=minor*math.sin(b)
   vs.append((x+rr*math.cos(a),f+off,h+rr*math.sin(a)) if axis=='f' else (x+off,f+rr*math.cos(a),h+rr*math.sin(a)))
 fs=[(i*K+j,((i+1)%N)*K+j,((i+1)%N)*K+(j+1)%K,i*K+(j+1)%K) for i in range(N) for j in range(K)]
 return mesh(name,vs,fs,mat,0,True)

def octagon(w,h,ch=.22):
 a,b=w/2,h/2;c=min(w,h)*ch
 return [(a,b-c),(a-c,b),(-a+c,b),(-a,b-c),(-a,-b+c),(-a+c,-b),(a-c,-b),(a,-b+c)]
def loft(name,sections,mat='Paint',bev=.13):
 vs=[]
 for f,w,h,z in sections:vs.extend((x,f,y+z) for x,y in octagon(w,h))
 fs=[tuple(reversed(range(8))),tuple(range((len(sections)-1)*8,len(sections)*8))]
 for j in range(len(sections)-1):
  for i in range(8):fs.append((j*8+i,j*8+(i+1)%8,(j+1)*8+(i+1)%8,(j+1)*8+i))
 return mesh(name,vs,fs,mat,bev)

def lathe(name,c,profile,mat='Alloy',N=64):
 # Profile is f-offset/radius, around the ship forward axis. Close profile explicitly.
 vs=[(c[0]+r*math.cos(i*2*math.pi/N),c[1]+f,c[2]+r*math.sin(i*2*math.pi/N)) for f,r in profile for i in range(N)]
 fs=[]
 for j in range(len(profile)-1):
  for i in range(N):fs.append((j*N+i,j*N+(i+1)%N,(j+1)*N+(i+1)%N,(j+1)*N+i))
 return mesh(name,vs,fs,mat,0,True)

def bolt(c,axis='x',r=.09):
 d=(.09,0,0) if axis=='x' else ((0,.09,0) if axis=='f' else (0,0,.09));return rod('Captive hex fastener',tuple(c[i]-d[i] for i in range(3)),tuple(c[i]+d[i] for i in range(3)),r,'Alloy',6,.015)
def beam(name,a,b,w,h,mat='Paint',bev=.08):
 a,b=Vector(a),Vector(b);d=(b-a).normalized();u=d.cross(Vector((1,0,0)))
 if u.length<.01:u=d.cross(Vector((0,0,1)))
 u.normalize();v=d.cross(u);vs=[tuple(p+u*w*su/2+v*h*sv/2) for p in (a,b) for su,sv in [(-1,-1),(-1,1),(1,1),(1,-1)]]
 return 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,bev)

def lamp(c,dim=(.5,.16,.3),zone=.125):
 o=box('Sealed lamp lens',c,dim,'Lamps',.045)
 for uv in o.data.uv_layers.active.data:uv.uv=(zone,.5)
 return o

def engine_core(name,c,r):
 x,f,h=c;o=rod(name,(x,f,h),(x,f-.15,h),r,'Engines',64,.025)
 for poly in o.data.polygons:
  for li in poly.loop_indices:
   co=o.data.vertices[o.data.loops[li].vertex_index].co;o.data.uv_layers.active.data[li].uv=((co.x-x)/(2*r)+.5,(co.z-h)/(2*r)+.5)
 return o

def setup_lighting():
 world=bpy.data.worlds.new('Deep blue void');world.use_nodes=True;S.world=world
 nt=world.node_tree;n=nt.nodes;l=nt.links;bg=n.get('Background');bg.inputs['Strength'].default_value=.22
 tc=n.new('ShaderNodeTexCoord');se=n.new('ShaderNodeSeparateXYZ');l.new(tc.outputs['Normal'],se.inputs[0]);ra=n.new('ShaderNodeValToRGB');ra.color_ramp.elements[0].position=.1;ra.color_ramp.elements[0].color=(.002,.004,.009,1);ra.color_ramp.elements[1].position=.9;ra.color_ramp.elements[1].color=(.032,.055,.10,1);l.new(se.outputs['Z'],ra.inputs[0]);l.new(ra.outputs[0],bg.inputs['Color'])
 def sun(name,p,energy,color,angle):
  da=bpy.data.lights.new(name,'SUN');ob=bpy.data.objects.new(name,da);bpy.context.collection.objects.link(ob);ob.location=V(p);ob.rotation_euler=(-ob.location).to_track_quat('-Z','Y').to_euler();da.energy=energy;da.color=color;da.angle=angle
 sun('Upper front left daylight',(40,35,60),2.6,(1,.95,.87),.12)
 sun('Cool soft fill',(-40,12,18),.85,(.59,.74,1),.5)
 sun('Engine rim',(-20,-50,25),1.6,(.65,.79,1),.25)
 sun('Underside bounce',(20,0,-35),.32,(.64,.72,1),.6)
 def area(name,p,energy,size,color):
  da=bpy.data.lights.new(name,'AREA');ob=bpy.data.objects.new(name,da);bpy.context.collection.objects.link(ob);ob.location=V(p);ob.rotation_euler=(-ob.location).to_track_quat('-Z','Y').to_euler();da.energy=energy;da.shape='DISK';da.size=size;da.color=color
 area('Broad white key reflection',(25,38,45),25000,35,(1,.95,.87))
 area('Broad cool fill reflection',(-35,5,28),14000,32,(.65,.77,1))
 out=n.get('World Output');old=out.inputs['Surface'].links[0].from_socket
 lp=n.new('ShaderNodeLightPath');ambient=n.new('ShaderNodeBackground');ambient.inputs['Color'].default_value=(.2,.23,.28,1);ambient.inputs['Strength'].default_value=.35;mx=n.new('ShaderNodeMixShader');l.new(lp.outputs['Is Camera Ray'],mx.inputs[0]);l.new(ambient.outputs[0],mx.inputs[1]);l.new(old,mx.inputs[2]);l.new(mx.outputs[0],out.inputs[0])
 da=bpy.data.cameras.new('Presentation camera');cam=bpy.data.objects.new('Presentation camera',da);bpy.context.collection.objects.link(cam);S.camera=cam;da.type='ORTHO';da.clip_end=1000
 return cam

def camera(cam,p=(145,92,58),target=(0,0,.4),scale=82):
 cam.location=V(p);cam.rotation_euler=(V(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale

def render(path,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(path);bpy.ops.render.render(write_still=True);log('RENDER',path.name)

if PREFLIGHT:
 bake_material(M['Paint']);box('Probe',(0,0,0),(4,8,3),'Paint',.2);cam=setup_lighting();camera(cam,(12,15,10),scale=13)
 bpy.ops.object.select_all(action='DESELECT')
 for o in G['Hull']:o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(QA/'probe.glb'),export_format='GLB',use_selection=True,export_yup=True)
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
 bpy.data.node_groups['glTF Material Output'].name='Authored AO settings';bpy.ops.import_scene.gltf(filepath=str(QA/'probe.glb'));cam=setup_lighting();camera(cam,(12,15,10),scale=13);render(QA/'preflight.png',480,320,8)
 log('PREFLIGHT OK');sys.exit(0)

# --- Primary continuous pressure hull and layered armoured shell ---
loft('Continuous keel',[(f,w*.91,h*.91,z) for f,w,h,z in [(-25,15,10,-.4),(-21,20.0,12.8,0),(-13,21,13.6,0),(7,21.6,13.6,0),(12,21.6,13.6,0),(19.5,17.6,11.2,0),(24.7,11.8,8.7,0)]],'CarbonSteel',.22)
loft('Forward armoured shroud',[(8.5,22.0,13.8,0),(11,22,13.8,0),(19.4,17.9,11.6,0),(23.8,12.5,9.2,0)],'ServiceTrim',.18)
loft('Drill armoured cuff',[(23.5,12.6,9.4,0),(24.5,12.6,9.4,0),(25.1,10.8,8.5,0)],'CarbonSteel',.14)
loft('Main hull armour',[(-20.8,20.6,13.5,0),(-13,21.8,14.1,0),(7.8,21.8,14.1,0),(9,21.6,13.8,0)],'Paint',.16)
loft('Underslung ore processing keel',[(-19.5,12,3.2,-6),(-14,15.8,3.6,-6),(-3,16.4,3.6,-6),(5.8,16,3.1,-5.8)],'CarbonSteel',.15)
# Precisely fitted eight-facet armour plates over the octagonal tapered backing.
# Every seam has a support skin; panel corners follow the actual loft profile.
def bow_section(f):
 sections=[(8.5,22,13.8),(11,22,13.8),(19.4,17.9,11.6),(23.8,12.5,9.2)]
 for (fa,wa,ha),(fb,wb,hb) in zip(sections,sections[1:]):
  if fa<=f<=fb:
   t=(f-fa)/(fb-fa);return octagon(wa+(wb-wa)*t,ha+(hb-ha)*t)
for f0,f1 in [(8.65,10.86),(11.12,15.1),(15.24,19.26),(19.55,23.62)]:
 a=bow_section(f0);b=bow_section(f1)
 for j in range(8):
  k=(j+1)%8
  pts=[Vector((a[j][0],f0,a[j][1])),Vector((a[k][0],f0,a[k][1])),Vector((b[k][0],f1,b[k][1])),Vector((b[j][0],f1,b[j][1]))]
  centre=sum(pts,Vector())/4;normal=(pts[1]-pts[0]).cross(pts[2]-pts[0]).normalized()
  if normal.x*centre.x+normal.z*centre.z<0:normal=-normal
  pts=[q+(centre-q)*.025+normal*.16 for q in pts]
  panel('Fitted bow armour',pts,'Paint' if j not in [4,5,6] else 'ServiceTrim',.15,.06)
  # Edge rails and captive bolts follow the same local plane without floating.
  for t in [.12,.88]:
   aa=pts[0].lerp(pts[1],t).lerp(pts[3].lerp(pts[2],t),.12)
   bb=pts[0].lerp(pts[1],t).lerp(pts[3].lerp(pts[2],t),.88)
   if j in [0,2,3,7]:
    rod('Recessed bow fastener',aa,aa+normal*.09,.085,'Alloy',6,.012)
    rod('Recessed bow fastener',bb,bb+normal*.09,.085,'Alloy',6,.012)
  if j in [0,2]:beam('Chamfer reinforcement',pts[0].lerp(pts[1],.72)+normal*.06,pts[3].lerp(pts[2],.72)+normal*.06,.12,.14,'ServiceTrim',.03)
for side in [-1,1]:
 # Wear band at the bow collar, flanges, and sector fasteners.
 rod('Drill bearing support',(0,24.7,0),(0,25.8,0),4.3,'ServiceTrim',64,.10)
 break
lathe('Drill bearing annulus',(0,25.45,0),[(-.35,3.0),(-.35,4.20),(.2,4.2),(.35,3.95),(.35,3.05),(-.35,3.0)],'Alloy',72)
ring('Bearing seal',(0,25.9,0),3.35,.16,mat='CarbonSteel',N=72)
for i in range(16):
 a=i*2*math.pi/16;bolt((3.78*math.cos(a),25.84,3.78*math.sin(a)),'f',.115)

# Bolted bow service ports, recessed service raceways and captive hatch hardware.
# All fittings follow the taper plane rather than sitting on an arbitrary box.
for side in [-1,1]:
 sgn=side
 def skin(ff,hh):
  # Forward side face x coordinate from the same loft sections as the parent skin.
  sec=bow_section(ff);width=max(v[0] for v in sec)
  return Vector((sgn*(width+.20),ff,hh))
 for fa,fb,za,zb in [(16.0,18.4,.7,3.0),(12.0,14.2,-2.8,-.8)]:
  corners=[skin(fa,za),skin(fb,za),skin(fb,zb),skin(fa,zb)]
  panel('Bow hatch compression gasket',corners,'CarbonSteel',.14,.07)
  centre=sum(corners,Vector())/4
  inner=[centre+(q-centre)*.87+Vector((sgn*.095,0,0)) for q in corners]
  panel('Bow inspection hatch',inner,'Paint',.13,.07)
  for ff in [fa+.23,fb-.23]:
   for zz in [za+.23,zb-.23]:
    cc=skin(ff,zz)+Vector((sgn*.16,0,0));rod('Service cover fastener',cc,cc+Vector((sgn*.065,0,0)),.07,'Alloy',6,.012)
  for ff in [fa+.45,fb-.45]:
   cc=skin(ff,(za+zb)/2)+Vector((sgn*.18,0,0))
   beam('Hatch recessed pull',cc+Vector((0,-.17,0)),cc+Vector((0,.17,0)),.10,.11,'ServiceTrim',.025)
 # Armoured conduit trough immediately ahead of the shoulder bearing.
 fa,fb=11.2,14.8;za,zb=-.15,.52
 panel('Forward service raceway',[skin(fa,za),skin(fb,za),skin(fb,zb),skin(fa,zb)],'CarbonSteel',.12,.06)
 for hh in [.03,.32]:
  aa=skin(fa+.15,hh)+Vector((sgn*.10,0,0));bb=skin(fb-.15,hh)+Vector((sgn*.10,0,0))
  rod('Protected hydraulic feed',aa,bb,.075,'Alloy',12,.015)
 for ff in [11.45,12.8,14.5]:
  cc=skin(ff,.17)+Vector((sgn*.15,0,0));beam('Hydraulic feed clamp',cc+Vector((0,0,-.28)),cc+Vector((0,0,.28)),.12,.10,'ServiceTrim',.02)
 # Paired shallow lifting eyes with a dark recess and load-bearing neck.
 for ff in [20.0,15.5]:
  cc=skin(ff,-2.1)
  box('Bow lifting eye foot',cc,(.25,.6,.8),'ServiceTrim',.07)
  ring('Bow recovery eye',cc+Vector((sgn*.23,0,0)),.23,.075,'x','Alloy',24)

# --- Large side ore hoppers, with real recessed bins, sloped rims and support ribs ---
for side in [-1,1]:
 s=side
 # Exterior walls create a volumetric bin rather than a flat label panel.
 panel('Hopper outer lower wall',[(s*14.95,5.5,-4.8),(s*14.95,4.1,-6.4),(s*14.95,-10.5,-6.4),(s*14.95,-12,-4.8),(s*14.95,-12,2.6),(s*14.95,5.5,2.6)],'CarbonSteel',.30,.16)
 panel('Hopper forward bulkhead',[(s*10.5,5.5,-5.5),(s*14.95,5.5,-4.8),(s*14.95,5.5,3.0),(s*11.2,5.5,6.7),(s*10.5,5.5,5.8)],'Paint',.30,.13)
 panel('Hopper aft bulkhead',[(s*10.5,-12,-5.5),(s*14.95,-12,-4.8),(s*14.95,-12,3),(s*11.2,-12,6.7),(s*10.5,-12,5.8)],'Paint',.30,.13)
 # Closed chamfered underside and inner wall tie the bin into the pressure hull.
 panel('Hopper sealed underside',[(s*10.0,4.1,-6.5),(s*14.7,4.1,-6.5),(s*14.7,-10.5,-6.5),(s*10.0,-10.5,-6.5)],'ServiceTrim',.26,.10)
 for ff in [-10.5,4.1]:
  beam('Hopper lower end chamfer',(s*10.1,ff,-6.4),(s*14.8,ff,-6.4),.42,.45,'ServiceTrim',.08)
 panel('Hopper forward lower transition',[(s*10.5,5.5,-5.5),(s*14.95,5.5,-4.8),(s*14.7,4.1,-6.5),(s*10.0,4.1,-6.5)],'ServiceTrim',.27,.09)
 panel('Hopper aft lower transition',[(s*10.5,-12,-5.5),(s*14.95,-12,-4.8),(s*14.7,-10.5,-6.5),(s*10.0,-10.5,-6.5)],'ServiceTrim',.27,.09)
 # Three open slanted intake bays. Inner dark tray is recessed behind structural coamings.
 for a,b in [(-11.7,-6.3),(-6.0,-.6),(-.3,5.2)]:
  panel('Ore hopper recessed bed',[(s*11.2,a,5.9),(s*14.1,a,3.05),(s*14.1,b,3.05),(s*11.2,b,5.9)],'ServiceTrim',.25,.06)
  for ff in np.arange(a+.45,b-.2,.65):
   beam('Internal hopper skid',(s*11.65,float(ff),5.45),(s*13.75,float(ff),3.35),.09,.13,'CarbonSteel',.025)
  for ff in [a,b]:beam('Raised intake transverse frame',(s*11.12,ff,6.82),(s*15.0,ff,2.95),.28,.32,'Alloy',.07)
  # Transparent impact shields above the recessed intake protect the sorting mechanism.
  panel('Hopper dust shield',[(s*11.22,a+.12,6.48),(s*14.56,a+.12,3.18),(s*14.56,b-.12,3.18),(s*11.22,b-.12,6.48)],'Glass',.07,.03)
  # Chunky irregular ore lies in the protected bins, not on top of their glazing.
  for k in range(6):
   ff=a+.6+k*.77;xx=12.2+.32*math.sin(k*2.3);zz=4.7-.32*math.sin(k*2.3)
   vs=[]
   for level,rr,zoff in [(0,.28,-.12),(1,.42,.05),(2,.22,.29)]:
    for j in range(7):
     ang=j*2*math.pi/7+.17*level;r=rr*(.88+.19*math.sin(j*2.7+k))
     vs.append((s*(xx+r*math.cos(ang)),ff+r*math.sin(ang),zz+zoff))
   fs=[tuple(reversed(range(7))),tuple(range(14,21))]
   for lev in range(2):
    for j in range(7):fs.append((lev*7+j,lev*7+(j+1)%7,(lev+1)*7+(j+1)%7,(lev+1)*7+j))
   mesh('Fractured ore load',vs,fs,'CarbonSteel',.025)
 beam('Hopper upper rim',(s*11.12,-12.1,6.85),(s*11.12,5.6,6.85),.32,.36,'Paint',.08)
 beam('Hopper lower lip',(s*14.94,-12.1,2.98),(s*14.94,5.6,2.98),.34,.38,'Paint',.08)
 for f in [-9.5,-5.5,-1.5,2.5]:
  # Broad service-door faces, bevelled seal surrounds, hinges and latch hardware.
  box('Hopper door gasket',(s*15.0,f,-1.5),(.10,3.65,6.55),'ServiceTrim',.14)
  box('Hopper armoured door',(s*15.08,f,-1.48),(.21,3.32,6.16),'Paint',.17)
  for ff in [f-1.18,f+1.18]:
   box('Access door hinge',(s*15.22,ff,1.08),(.20,.46,.29),'Alloy',.045)
   bolt((s*15.25,ff,-3.55),'x',.085)
  box('Recessed latch well',(s*15.22,f,-.3),(.04,.70,.42),'CarbonSteel',.08)
  rod('Latch handle',(s*15.33,f-.23,-.26),(s*15.33,f+.23,-.26),.06,'Alloy',10,.01)
  box('Hopper lower rub strip',(s*15.26,f,-4.23),(.10,2.65,.20),'ServiceTrim',.04)
 for f in [-10.3,-6.1,-.4,3.9]:
  beam('Bin load-bearing outrigger',(s*10.0,f,-6.3),(s*14.7,f,-6.55),.48,.52,'ServiceTrim',.10)
 # Main dorsal rail, anchored at human-scale stanchions.
 for ff in [-10,-6,-2,2]:
  rod('Walkway rail stanchion',(s*8.3,ff,7.06),(s*8.3,ff,8.0),.065,'Alloy',10,.012)
 tube('Dorsal safety rail',[(s*8.3,-10,8.0),(s*8.3,-6,8.0),(s*8.3,-2,8.0),(s*8.3,2,8.0)],.075,'Alloy')
 # Inboard maintenance walkway dark trim and traction strips.
 box('Walkway sill',(s*8.1,-3.5,7.13),(1.5,15.8,.19),'CarbonSteel',.05)
 for ff in np.arange(-10.8,4,.55):box('Walkway grip',(s*8.1,float(ff),7.26),(1.15,.13,.08),'Alloy',.02)

# Dorsal spine and closed keel access doors.
for f in [-9,-4.7,-.4,3.9,8.2]:
 box('Dorsal segmented centre panel',(0,f,7.1),(11.7,4.04,.28),'Paint',.12)
 for x in [-4.8,4.8]:
  beam('Longitudinal stiffener',(x,f-1.75,7.31),(x,f+1.75,7.31),.22,.23,'ServiceTrim',.05)
 box('Keel service hatch',(0,f,-7.7),(8.4,3.8,.21),'ServiceTrim',.12)
 for x in [-3.6,3.6]:bolt((x,f,-7.84),'h',.105)
# Dorsal service fittings: low housings, radial vents and clamped supply lines.
for ff in [-8.8,-.2,8.0]:
 box('Recessed dorsal service hatch',(-1.5,ff,7.30),(2.7,2.15,.20),'ServiceTrim',.12)
 box('Raised maintenance lid',(-1.5,ff,7.43),(2.42,1.86,.18),'Paint',.10)
 for xx in [-2.48,-.52]:
  for df in [-.67,.67]:bolt((xx,ff+df,7.56),'h',.075)
 box('Dorsal machinery plinth',(2.5,ff,7.37),(2.4,2.8,.42),'CarbonSteel',.10)
 box('Rounded machinery cover',(2.5,ff,7.66),(2.18,2.55,.32),'Paint',.15)
 for dd in [-.8,-.4,0,.4,.8]:box('Machine ventilation fin',(2.5,ff+dd,7.89),(1.65,.12,.13),'Alloy',.035)
for x in [-6.4,6.4]:
 tube('Dorsal coolant line',[(x,-10.5,7.35),(x,-8,7.42),(x,4,7.42),(x,6.5,7.2)],.11,'Alloy',12)
 for ff in [-9,-5,-1,3,6]:box('Coolant pipe clamp',(x,ff,7.4),(.38,.19,.36),'ServiceTrim',.045)
for side in [-1,1]:
 for f in [-9.5,-5.5,-1.5,2.5]:
  # Narrow worn metal edges and a few chips where ore strikes the bin doors.
  for j in range(7):
   ff=f-1.35+j*.39;h=-4.28+.03*math.sin(j*3)
   panel('Door impact chip',[(side*15.192,ff,h),(side*15.192,ff+.19,h+.025),(side*15.192,ff+.12,h+.09),(side*15.192,ff-.04,h+.06)],'Alloy',.009,.003)
# Small irregular edge scrapes and embedded impact rivets give the doors scale.
wear=np.random.default_rng(501)
for side in [-1,1]:
 for f in [-9.5,-5.5,-1.5,2.5]:
  for k in range(13):
   ff=f+float(wear.uniform(-1.35,1.1));hh=float(wear.uniform(-4.2,-2.7));length=float(wear.uniform(.10,.45));height=float(wear.uniform(.012,.045));xx=side*15.193
   panel('Lower door abrasion',[(xx,ff,hh),(xx,ff+length,hh+.018),(xx,ff+length*.81,hh+height),(xx,ff-.03,hh+height*.7)],'Alloy',.009,.002)
  # Corner seal caps, riveted hinge plates and an inset lower pressure bead.
  for df in [-1.32,1.32]:
   for hh in [-4.0,1.12]:
    box('Hopper door corner keeper',(side*15.22,f+df,hh),(.12,.23,.27),'ServiceTrim',.03)
  for df in [-.84,0,.84]:bolt((side*15.27,f+df,-4.19),'x',.055)

for side in [-1,1]:
 for ff in [-11.35,-6.1,-.45,5.0]:
  for hh in [-5.1,-3.8,-2.4,-1.0,.4,1.8]:bolt((side*15.0,ff,hh),'x',.075)
 for ff in [-21.8,-18.7]:
  for hh in [-.7,3.6]:bolt((side*11.57,ff-.84,hh),'x',.085);bolt((side*11.57,ff+.84,hh),'x',.085)
# Human-scale ladder against the aft shoulder.
for side in [-1,1]:
 x=side*10.65
 for ff in [-16.85,-15.6]:rod('Maintenance ladder rail',(x,ff,-3.0),(x,ff,6.2),.08,'Alloy',10,.015)
 for hh in np.arange(-2.7,6.15,.42):rod('Ladder rung',(x,-16.85,float(hh)),(x,-15.6,float(hh)),.055,'Alloy',10,.01)

# --- Aft propulsion machinery and framed radiator bands ---
loft('Engine mounting transom',[(-26,18.4,11.8,-.2),(-22.3,21,13.4,-.2),(-19.4,21,13.4,-.2)],'ServiceTrim',.18)
for side in [-1,1]:
 for f in [-21.8,-18.7]:
  box('Aft equipment cabinet',(side*10.65,f,1.4),(1.45,2.7,6.5),'Paint',.18)
  box('Cabinet raised face',(side*11.42,f,1.5),(.17,2.2,5.1),'Paint',.10)
  for h in [-.5,.1,.7,1.3,1.9,2.5]:box('Equipment cooling slit',(side*11.54,f,h),(.10,1.35,.19),'CarbonSteel',.025)
 # Closely spaced cooling shutters make the dark aft collar read mechanical.
 box('Radiator recess',(side*10.6,-14.6,.0),(.9,2.9,12.1),'CarbonSteel',.18)
 for hh in np.arange(-5.5,5.8,.53):box('Radiator angled louvre',(side*11.15,-14.6,float(hh)),(.24,2.5,.26),'Alloy',.06)
 for ff in [-16.08,-13.12]:box('Radiator outer frame',(side*11.22,ff,0),(.40,.24,12.15),'ServiceTrim',.06)
 for hh in [-6,6]:box('Radiator end frame',(side*11.22,-14.6,hh),(.40,3.1,.24),'ServiceTrim',.05)
 # Pipes feed the engine roots, clamped to the side.
 for hh in [-3.7,3.7]:
  tube('Armoured coolant return',[(side*9.8,-19,hh),(side*10.7,-21.5,hh),(side*10.4,-24,hh),(side*8.1,-26.7,hh)],.18,'Alloy',12)
  for ff in [-21.6,-23]:box('Pipe saddle',(side*10.8,ff,hh),(.42,.25,.55),'ServiceTrim',.05)
 # Two big engine assemblies. Deep bell profile leaves a genuine nozzle cavity.
 cx=side*6.15
 lathe('Engine cowling',(cx,-26.0,0),[(1.0,3.9),(.5,4.25),(-1.0,4.48),(-3.9,4.5),(-4.5,4.2),(-4.5,3.72),(-.3,3.45),(1.0,3.9)],'Paint',72)
 lathe('Heat-blackened nozzle',(cx,-30.0,0),[(.8,3.8),(-.5,4.05),(-2.5,4.14),(-2.5,3.64),(-1.8,3.34),(.35,2.6),(.8,3.8)],'CarbonSteel',96)
 ring('Nozzle lip',(cx,-32.31,0),3.90,.18,mat='Alloy',N=96)
 ring('Engine cowling seam',(cx,-27.3,0),4.45,.13,mat='ServiceTrim',N=72)
 ring('Propulsion flange',(cx,-25.8,0),4.25,.20,mat='Alloy',N=72)
 lathe('Cowling structural band',(cx,-28.7,0),[(-.18,4.46),(-.18,4.57),(.18,4.57),(.18,4.46),(-.18,4.46)],'Alloy',64)
 for i in range(10):
  a=2*math.pi*i/10;x=cx+4.52*math.cos(a);z=4.52*math.sin(a)
  beam('Cowling segmented brace',(x,-26.5,z),(x,-29.5,z),.16,.24,'ServiceTrim',.045)
 # Restrained orange glow sits inside a turbine grid, not on the exterior bell.
 engine_core('Engine luminous throat',(cx,-29.85,0),2.60)
 ring('Internal throat rim',(cx,-30.13,0),2.62,.15,mat='Alloy',N=64)
 for i in range(16):
  a=2*math.pi*i/16
  x=cx+3.98*math.cos(a);z=3.98*math.sin(a)
  beam('Nozzle thermal petal',(x,-30.0,z),(cx+4.15*math.cos(a),-32.1,4.15*math.sin(a)),.20,.34,'ServiceTrim',.055)
  # Turbine radials inside the bell.
  beam('Throat radial vane',(cx+.65*math.cos(a),-30.15,.65*math.sin(a)),(cx+2.50*math.cos(a+.13),-30.15,2.50*math.sin(a+.13)),.10,.16,'CarbonSteel',.025)
  bolt((cx+4.1*math.cos(a),-25.55,4.1*math.sin(a)),'f',.11)
 rod('Engine centre hub',(cx,-30.22,0),(cx,-30.5,0),.69,'ServiceTrim',32,.06)
 for h in [-6.4,6.4]:
  # Compact auxiliary manoeuvring nozzles above and below the main pair.
  lathe('Auxiliary thruster',(side*4.7,-25.5,h),[(1.6,1.22),(-1.1,1.30),(-2.6,1.36),(-2.6,1.01),(-1.5,.70),(1.6,1.22)],'CarbonSteel',40)
  ring('Auxiliary nozzle ring',(side*4.7,-28.1,h),1.18,.12,mat='Alloy',N=40)
  engine_core('Auxiliary engine core',(side*4.7,-27.1,h),.72)

# --- Low armoured bridge on the dorsal aft hull; actual framed glazing and interior ---
loft('Bridge foundation',[(-22,9.8,3.0,6.2),(-18,11.1,3.2,6.5),(-10.3,10.1,2.4,6.3)],'Paint',.16)
# Cabin floor and angled aft/side walls leave a continuous open windscreen strip.
box('Bridge cabin floor',(0,-15.9,7.6),(9.7,8.6,.36),'CarbonSteel',.13)
panel('Bridge aft wall',[(-4.4,-20.2,7.6),(4.4,-20.2,7.6),(3.7,-19.55,10.5),(-3.7,-19.55,10.5)],'Paint',.28,.10)
for side in [-1,1]:
 panel('Bridge cheek armour',[(side*4.8,-18.8,7.8),(side*4.8,-13.0,7.8),(side*4.0,-13.7,9.0),(side*3.9,-18.8,9.0)],'Paint',.23,.09)
 panel('Bridge side glazing',[(side*4.75,-18.5,8.85),(side*4.75,-13,8.85),(side*3.8,-13.75,10.35),(side*3.8,-18.5,10.35)],'Glass',.055,.025)
 panel('Bridge continuous side sill',[(side*4.82,-18.6,8.78),(side*4.82,-12.95,8.78),(side*3.98,-13.70,9.07),(side*3.90,-18.6,9.07)],'ServiceTrim',.20,.045)
 beam('Bridge side mullion',(side*4.77,-16.7,8.85),(side*3.82,-16.7,10.35),.18,.22,'ServiceTrim',.035)
 panel('Bridge rear cheek',[(side*4.75,-18.6,7.8),(side*4.45,-20.2,7.6),(side*3.72,-19.55,10.5),(side*3.8,-18.6,10.5)],'Paint',.22,.10)
# Sloped front glass panes and armoured frames.
for x0,x1 in [(-4.45,-.16),(.16,4.45)]:
 panel('Forward bridge glazing',[(x0,-11.7,8.1),(x1,-11.7,8.1),(x1*.84,-13.15,10.4),(x0*.84,-13.15,10.4)],'Glass',.055,.025)
for x in [-4.5,0,4.5]:beam('Windscreen armoured mullion',(x,-11.65,8.03),(x*.84,-13.2,10.5),.20,.23,'ServiceTrim',.045)
for side in [-1,1]:
 panel('Bridge corner pillar',[(side*4.45,-11.7,8.08),(side*4.77,-13.0,8.85),(side*3.81,-13.75,10.40),(side*3.74,-13.15,10.42)],'ServiceTrim',.20,.045)
 panel('Bridge front lower cheek',[(side*4.8,-13.0,7.78),(side*4.77,-13.0,8.85),(side*4.45,-11.7,8.08),(side*4.45,-11.7,7.75)],'Paint',.22,.06)
beam('Continuous bridge sill',(-4.5,-11.72,7.95),(4.5,-11.72,7.95),.40,.36,'ServiceTrim',.07)
panel('Bridge visor roof',[(-4.2,-19.9,10.55),(4.2,-19.9,10.55),(4.35,-13.0,10.55),(3.8,-12.55,10.55),(-3.8,-12.55,10.55),(-4.35,-13,10.55)],'Paint',.30,.12)
beam('Bridge brow',(-4.35,-12.65,10.5),(4.35,-12.65,10.5),.35,.32,'Alloy',.07)
# Bridge interior scale cues, pilot seats, control pedestals, stick and consoles.
for x in [-2.2,2.2]:
 box('Pilot seat pedestal',(x,-15.65,7.95),(.6,.7,.65),'ServiceTrim',.10)
 box('Pilot seat cushion',(x,-15.6,8.32),(.88,.82,.25),'CarbonSteel',.10)
 box('Pilot seat back',(x,-16.0,8.83),(.95,.24,1.2),'CarbonSteel',.11)
 box('Pilot headrest',(x,-16.0,9.5),(.55,.24,.32),'CarbonSteel',.08)
 box('Bridge control console',(x,-13.2,8.25),(1.7,.7,.8),'ServiceTrim',.12)
 lamp((x,-13.58,8.50),(.55,.04,.19),.125)
 rod('Pilot control stick',(x+.4,-14.7,8.2),(x+.4,-14.65,8.7),.035,'Alloy',8,.008)
for x in [-1.4,0,1.4]:box('Aft electronics cabinet',(x,-19.4,8.5),(1.15,.45,1.6),'ServiceTrim',.08)
# Antenna mast, collision beacon and roof fittings stay modest in silhouette.
rod('Main antenna foot',(0,-17.8,10.5),(0,-17.8,10.9),.33,'ServiceTrim',24,.05)
rod('Main comms mast',(0,-17.8,10.8),(0,-17.8,12.0),.08,'Alloy',12,.015,r2=.035)
rod('Secondary aerial',(-1.0,-18.1,10.5),(-1.0,-18.1,11.55),.05,'Alloy',10,.01)
box('Beacon base',(1.3,-18.1,10.64),(.45,.45,.25),'CarbonSteel',.06);lamp((1.3,-18.1,10.86),(.26,.26,.21),.875)

# --- Floodlight banks on articulated-looking fixed brackets, navigation lamps ---
for side in [-1,1]:
 for f,h in [(12.2,5.75),(-8.5,7.1)]:
  x=side*(8.8 if f>0 else 9.8)
  beam('Floodlight bracket',(x,f,h-.5),(x,f+.3,h+.35),.19,.23,'Alloy',.04)
  box('Floodlight bank housing',(x,f+.4,h+.43),(2.1,.65,.93),'CarbonSteel',.12)
  for dx in [-.68,0,.68]:
   for dz in [-.21,.21]:lamp((x+dx,f+.75,h+.43+dz),(.51,.10,.30),.125)
  for dx in [-1.1,1.1]:rod('Floodlight side trunnion',(x+dx-.1,f+.4,h+.43),(x+dx+.1,f+.4,h+.43),.18,'Alloy',12,.025)
 box('Navigation light mount',(side*14.85,4.75,2.70),(.42,.82,.43),'CarbonSteel',.09)
 lamp((side*15.1,4.75,2.72),(.15,.55,.22),.375 if side>0 else .625)
# Machinery service lines, inspection plates, fasteners, intentional clean areas between detail zones.
for side in [-1,1]:
 x=side*11.12
 for f in [7.8,9.0]:
  box('Shoulder service panel',(x,f,3.4),(.25,.85,2.1),'Paint',.10)
 for h in [-2.2,-1.55]:
  tube('Forward hydraulic feed',[(side*10.8,7,h),(side*11.4,8.3,h),(side*11.4,10.1,h),(side*10.6,11.2,h)],.10,'Alloy',10)
 for f in [-12.5,-17.3,-20.8]:
  box('Hull access hatch',(side*8.3,f,-5.5),(2.8,2.5,.20),'Paint',.10)
# Attached oxide seams sit below drainage points and between wear strips.
for side in [-1,1]:
 for ff in [-9.5,-5.5,-1.5,2.5]:
  panel('Oxidised door sill',[(side*15.195,ff-1.35,-4.35),(side*15.195,ff+1.35,-4.35),(side*15.195,ff+.9,-4.51),(side*15.195,ff-1.35,-4.45)],'Oxide',.016,.008)

# --- Rotating drill: stepped taper core, continuous three-dimensional helical auger ---
CURRENT='Drill'
lathe('Conical drill core',(0,25.8,0),[(0,2.42),(.5,2.35),(1.5,1.95),(2.8,1.48),(4.4,.83),(5.7,.30),(6.5,.06),(6.7,.015),(6.7,.008),(0,.008),(0,2.42)],'CarbonSteel',80)
vs=[];turns=4.7;steps=420
for i in range(steps+1):
 t=i/steps;f=25.95+6.40*t;r=2.50*(1-t)**.92+.045;ang=turns*2*math.pi*t
 for df,dr in [(-.26,-.04),(-.08,1.02*(1-t)+.11),(.12,.86*(1-t)+.09),(.25,-.04)]:
  vs.append(((r+dr)*math.cos(ang),f+df,(r+dr)*math.sin(ang)))
fs=[]
for i in range(steps):
 for j in range(4):fs.append((i*4+j,i*4+(j+1)%4,(i+1)*4+(j+1)%4,(i+1)*4+j))
fs.extend([(3,2,1,0),tuple(range(steps*4,(steps+1)*4))]);mesh('Continuous hardened auger flight',vs,fs,'Alloy',0,True)
for i in range(36):
 t=.015+.96*i/36;f=25.95+6.4*t;r=2.50*(1-t)**.92+1.02*(1-t)+.13;a=turns*2*math.pi*t
 radial=Vector((math.cos(a),0,math.sin(a)));tangent=Vector((-math.sin(a),0,math.cos(a)));c=Vector((r*math.cos(a),f,r*math.sin(a)));w=.19*(1-t)+.06
 vs=[tuple(c+tangent*tt*w+Vector((0,ff*.14,0))+radial*rr) for tt,ff,rr in [(-1,-1,0),(1,-1,0),(1,1,0),(-1,1,0),(-.6,-1,.16),(.6,-1,.16),(.6,.4,.20),(-.6,.4,.20)]]
 mesh('Carbide chisel cutter',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)],'ServiceTrim',.025)
ring('Drill spindle flange',(0,25.9,0),2.91,.17,mat='Alloy',N=72)

# --- Twin grabbers: each segment carries its own working pivot ---
PIVOTS={'Drill':(0,25.75,0)};AXES={'Drill':[0,0,1]};PARENTS={'Drill':'Hull'}
for side,label in [(1,'L'),(-1,'R')]:
 s=side;CURRENT='Hull';shoulder=(s*11.1,8.9,.2)
 rod('Shoulder fixed bearing',(s*10.5,8.9,.2),(s*12.0,8.9,.2),2.55,'ServiceTrim',64,.10)
 ring('Shoulder bearing rim',(s*12.0,8.9,.2),2.31,.16,axis='x',mat='Alloy',N=64)
 for i in range(12):
  a=2*math.pi*i/12;bolt((s*12.18,8.9+2.05*math.cos(a),.2+2.05*math.sin(a)),'x',.10)
 CURRENT='Arm_'+label;PIVOTS[CURRENT]=shoulder;AXES[CURRENT]=[1,0,0];PARENTS[CURRENT]='Hull'
 p0=(s*12.25,8.9,.2);p1=(s*12.65,10.0,-5.5)
 rod('Shoulder rotating hub',(s*11.7,8.9,.2),(s*12.75,8.9,.2),1.62,'Alloy',48,.08)
 beam('Upper arm boxed spar',p0,p1,1.8,1.7,'Paint',.18)
 for off in [-.60,.60]:
  beam('Upper arm side reinforcement',(p0[0]+off,p0[1],p0[2]-.4),(p1[0]+off,p1[1],p1[2]+.4),.26,.4,'ServiceTrim',.06)
 rod('Upper arm ram cylinder',(s*13.95,9.4,-.9),(s*13.95,10.1,-3.5),.28,'CarbonSteel',20,.04)
 rod('Upper arm piston',(s*13.95,10.1,-3.5),(s*13.95,10.3,-4.9),.16,'Alloy',20,.025)
 tube('Shoulder flex hose',[(s*13.05,8.3,-.4),(s*14.35,8.8,-1),(s*14.35,9.3,-2.7),(s*14.0,9.9,-4.9)],.12,'CarbonSteel',12)
 for ff,hh in [(9.4,-.9),(10.3,-4.9)]:
  beam('Ram clevis bracket',(s*12.85,ff,hh),(s*14.1,ff,hh),.32,.38,'ServiceTrim',.07)
  rod('Ram clevis pin',(s*13.65,ff,hh),(s*14.2,ff,hh),.19,'Alloy',16,.025)
 # Elbow child; parent-space axis is ship-left X.
 par=CURRENT;CURRENT='Forearm_'+label;PIVOTS[CURRENT]=p1;AXES[CURRENT]=[1,0,0];PARENTS[CURRENT]=par
 p2=(s*13.8,16.2,-8.2)
 rod('Elbow hinge pin',(s*11.9,10,-5.5),(s*13.45,10,-5.5),.94,'Alloy',40,.06)
 ring('Elbow bearing seal',(s*13.5,10,-5.5),.69,.11,'x','CarbonSteel',40)
 beam('Forearm load spar',p1,p2,1.25,1.4,'Paint',.14)
 for off in [-.46,.46]:beam('Forearm inset brace',(p1[0]+off,10.5,-5.7),(p2[0]+off,15.8,-8),.22,.3,'ServiceTrim',.05)
 rod('Forearm hydraulic cylinder',(s*14.85,10.6,-5.0),(s*14.85,13.4,-6.25),.29,'CarbonSteel',20,.04)
 rod('Forearm chrome piston',(s*14.85,13.4,-6.25),(s*14.85,15.6,-7.5),.17,'Alloy',20,.025)
 tube('Forearm hydraulic cable',[(s*13.3,10,-5.0),(s*14.2,11.1,-5.4),(s*14.45,13.8,-6.7),(s*14.15,15.9,-7.7)],.10,'CarbonSteel',12)
 for xx,ff,hh in [(13.1,10.6,-5.0),(14.0,15.6,-7.5)]:
  beam('Forearm clevis',(s*xx,ff,hh),(s*15.0,ff,hh),.28,.36,'ServiceTrim',.06)
  rod('Forearm ram pin',(s*14.6,ff,hh),(s*15.12,ff,hh),.17,'Alloy',16,.02)
 # Wrist and palm.
 par=CURRENT;CURRENT='Wrist_'+label;PIVOTS[CURRENT]=p2;AXES[CURRENT]=[1,0,0];PARENTS[CURRENT]=par
 rod('Wrist hinge',(s*13.1,16.2,-8.2),(s*14.5,16.2,-8.2),.75,'ServiceTrim',36,.06)
 beam('Grabber palm',p2,(s*13.8,19.2,-8.2),.82,1.0,'Paint',.12)
 for ff in [17.0,18.6]:rod('Palm reinforced collar',(s*13.8,ff-.2,-8.2),(s*13.8,ff+.2,-8.2),.63,'Alloy',32,.05)
 for jaw,j in [('Upper',1),('Lower',-1)]:
  parent=CURRENT;name='Claw_'+label+'_'+jaw;CURRENT=name;pv=(s*13.8,19.0,-8.2+j*.38);PIVOTS[name]=pv;AXES[name]=[1,0,0];PARENTS[name]=parent
  q1=(s*13.8,20.5,-8.2+j*1.38);q2=(s*13.8,22.1,-8.2+j*.68)
  rod('Claw hinge',(s*13.35,19,-8.2+j*.38),(s*14.25,19,-8.2+j*.38),.36,'Alloy',24,.045)
  beam('Forged claw root',pv,q1,.5,.67,'ServiceTrim',.075);beam('Tapered gripping finger',q1,q2,.38,.46,'Alloy',.065)
  for ff in [20.9,21.3,21.7]:
   zz=-8.2+j*(1.38-(ff-20.5)*.43)
   beam('Replaceable jaw tooth',(s*13.8,ff,zz),(s*13.8,ff+.1,zz-j*.20),.24,.24,'CarbonSteel',.025)
  CURRENT=parent
CURRENT='Hull'
log('GEOMETRY',sum(len(v) for v in G.values()),'pieces')

# Exact final dimensions are derived from the actual surface bounds, never assumed.
all_parts=[o for obs in G.values() for o in obs]
coords=[v.co for o in all_parts for v in o.data.vertices]
lo=Vector(tuple(min(v[i] for v in coords) for i in range(3)));hi=Vector(tuple(max(v[i] for v in coords) for i in range(3)))
factors=Vector((30/(hi.x-lo.x),65/(hi.y-lo.y),22/(hi.z-lo.z)))
normalise=Matrix.Diagonal((*factors,1))
for o in all_parts:o.data.transform(normalise)
PIVOTS={k:normalise@V(v) for k,v in PIVOTS.items()}
# Consolidate every rigid assembly into ONE named mesh. No transform application on moving nodes.
OBJECTS={}
for name,obs in G.items():
 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=obs[0];o.name=name;o.data.name=name+'_Mesh'
 pivot=PIVOTS.get(name,Vector((0,0,0)))
 o.data.transform(Matrix.Translation(-pivot));o.location=pivot
 # The bevel geometry carries its highlights; preserve hard boundaries on planar plates.
 o.data.set_sharp_from_angle(angle=math.radians(48))
 OBJECTS[name]=o
for name,parent in PARENTS.items():
 o=OBJECTS[name];o.parent=OBJECTS[parent];o.matrix_parent_inverse=Matrix.Identity(4);o.location=PIVOTS[name]-PIVOTS.get(parent,Vector((0,0,0)))
bpy.context.view_layer.update()
# Sockets are truly empty nodes; the unarmed utility craft needs no muzzle/weapon hardpoints.
SOCKETS={}
def socket(name,p,parent='Hull',role=None):
 ob=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(ob);ob.empty_display_type='PLAIN_AXES';ob.empty_display_size=.5
 pos=normalise@V(p);ob.parent=OBJECTS[parent];ob.matrix_parent_inverse=Matrix.Identity(4);ob.location=pos-PIVOTS.get(parent,Vector((0,0,0)));ob['role']=role or name;SOCKETS[name]=ob;return ob
socket('Thruster_1',(6.15,-32.48,0));socket('Thruster_2',(-6.15,-32.48,0))
for i,(side,h) in enumerate([(1,6.4),(-1,6.4),(1,-6.4),(-1,-6.4)],3):socket('Thruster_'+str(i),(side*4.7,-28.1,h))
socket('Seat_Pilot',(-2.2,-15.65,8.42));socket('Camera_Chase',(0,-51,20))
socket('Light_Nav_L',(15.1,4.75,2.72));socket('Light_Nav_R',(-15.1,4.75,2.72));socket('Light_Beacon',(1.3,-18.1,10.86))
for name in PIVOTS:OBJECTS[name]['rotation_axis_parent_space']=AXES[name]
OBJECTS['Hull']['asset']='Industrial asteroid mining ship';OBJECTS['Hull']['units']='metres'
# Triangulate and remove numerical zero-area remnants from tiny bevel terminations.
for ob in OBJECTS.values():
 bm=bmesh.new();bm.from_mesh(ob.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
 dead=[f for f in bm.faces if f.calc_area()<1e-10]
 if dead:bmesh.ops.delete(bm,geom=dead,context='FACES_ONLY')
 bm.to_mesh(ob.data);bm.free();ob.data.update()
# Measure actual triangle count after all modifiers, before export.
def triangles():
 n=0
 for ob in OBJECTS.values():ob.data.calc_loop_triangles();n+=len(ob.data.loop_triangles)
 return n
TRIS=triangles();log('TRIANGLES',TRIS)
# Only geometric reduction if needed, and retain material borders and working pivots.
if TRIS>138000:
 # Preserve all moving meshes and the drill tip exactly. Reduce only the fixed Hull.
 ob=OBJECTS['Hull'];ob.data.calc_loop_triangles();hull_tris=len(ob.data.loop_triangles)
 ratio=(135000-(TRIS-hull_tris))/hull_tris
 active(ob);mod=ob.modifiers.new('Budget topology reduction','DECIMATE');mod.ratio=ratio;mod.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=mod.name)
 TRIS=triangles();log('REDUCED TRIANGLES',TRIS)
assert 60000<=TRIS<=140000,TRIS
# Recheck calibrated metres after topology reduction. Transform vertex coordinates and
# parent-local pivot translations together; moving object transforms stay unapplied.
bpy.context.view_layer.update()
actual=[ob.matrix_world@v.co for ob in OBJECTS.values() for v in ob.data.vertices]
span=Vector(tuple(max(v[i] for v in actual)-min(v[i] for v in actual) for i in range(3)))
calibration=Matrix.Diagonal((30/span.x,65/span.y,22/span.z,1))
for ob in OBJECTS.values():
 ob.data.transform(calibration);ob.location=calibration.to_3x3()@ob.location
for ob in SOCKETS.values():ob.location=calibration.to_3x3()@ob.location
bpy.context.view_layer.update()
cam=setup_lighting();camera(cam)
# Clay first: the exact geometry is checked without any photographic surface hiding it.
clay=bpy.data.materials.new('Clay inspection only');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.38,.41,.44,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.62
S.view_layers[0].material_override=clay;render(ROOT/'clay.png',1920,1080,20);S.view_layers[0].material_override=None
# Bakes use only a canonical 4m tile, never one atlas stretched over a 65m hull.
for o in OBJECTS.values():o.hide_render=True
for mat in M.values():bake_material(mat)
for o in OBJECTS.values():o.hide_render=False
(QA/'bakes.json').write_text(json.dumps({'source_ids':IDS,'physical_tile_m':4,'passes':BAKES},indent=2))
# Store snapshots before export to permit bounded render-only diagnostics.
bpy.ops.wm.save_as_mainfile(filepath=str(QA/'mining-ship.blend'))
bpy.ops.object.select_all(action='DESELECT')
for o in list(OBJECTS.values())+list(SOCKETS.values()):o.select_set(True)
bpy.context.view_layer.objects.active=OBJECTS['Hull']
bpy.ops.export_scene.gltf(filepath=str(ROOT/'mining-ship.glb'),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_materials='EXPORT')
# Canonical factor and BLEND mode are checked in the serialized artifact itself.
def read_glb(path):
 raw=path.read_bytes();jslen,jstype=struct.unpack_from('<II',raw,12);j=json.loads(raw[20:20+jslen]);off=20+jslen;binlen,bintype=struct.unpack_from('<II',raw,off);return j,raw[off+8:off+8+binlen]
def write_glb(path,j,blob):
 js=json.dumps(j,separators=(',',':')).encode();js+=b' '*((-len(js))%4);blob+=b'\x00'*((-len(blob))%4)
 path.write_bytes(struct.pack('<III',0x46546c67,2,12+8+len(js)+8+len(blob))+struct.pack('<II',len(js),0x4E4F534A)+js+struct.pack('<II',len(blob),0x004E4942)+blob)
j,blob=read_glb(ROOT/'mining-ship.glb')
for mat in j['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=[*PAINTS[0][1],1]
 if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
write_glb(ROOT/'mining-ship.glb',j,blob)
TRIS=sum(j['accessors'][p['indices']]['count']//3 for m in j['meshes'] for p in m['primitives'])
size=(ROOT/'mining-ship.glb').stat().st_size
log('EXPORTED',size/1e6,'MB',len(j['materials']),'materials',len(j['nodes']),'nodes')
assert size<=12000000,f'GLB too large: {size}'
assert len(j['materials'])<=9
bpy.context.view_layer.update()
pts=[ob.matrix_world@v.co for ob in OBJECTS.values() for v in ob.data.vertices]
lo=[min(game(p)[i] for p in pts) for i in range(3)];hi=[max(game(p)[i] for p in pts) for i in range(3)]
nodes={'asset':'mining-ship','units':'metres','coordinate_system':{'forward':'+Z','up':'+Y','left':'+X'},'bounds':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],5) for i in range(3)]},'triangles':TRIS,'materials':[m['name'] for m in j['materials']],'glb_bytes':size,'axes':AXES,'paints':[{'name':n,'linear_rgb':v} for n,v in PAINTS],'nodes':{}}
for name,ob in {**OBJECTS,**SOCKETS}.items():
 nodes['nodes'][name]={'position':game(ob.matrix_world.translation),'parent':ob.parent.name if ob.parent else None,'type':'mesh' if ob.type=='MESH' else 'empty'}
 if name in AXES:nodes['nodes'][name]['axis']=AXES[name]
 if name.startswith('Thruster'):nodes['nodes'][name]['exhaust_direction']=[0,0,-1]
(ROOT/'nodes.json').write_text(json.dumps(nodes,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join([
'Original 65 × 30 × 22 m industrial asteroid miner: helical drill, twin articulated grabbers, side ore hoppers, bridge interior, floodlights and two main engines.',
f'{TRIS:,} triangles; {size/1e6:.2f} MB GLB; metres, Y up, nose +Z; fixed assembly Hull plus pivoted moving nodes and named sockets.',
'8 materials: Paint, Alloy, CarbonSteel, ServiceTrim, Oxide, Glass, Lamps, Engines; Paint has neutral baked detail and safety-yellow factor [0.85, 0.65, 0.05].',
'Cycles OptiX baked tiled base colour and normals at 1024 px maximum; ORM at 512 px maximum; small functional materials 64 px; 4 m hull tile with world-scale UVs.',
'Poly Haven CC0: https://polyhaven.com/a/metal_plate_02 ; https://polyhaven.com/a/rusty_painted_metal ; https://polyhaven.com/a/rusty_metal_03 ; licence https://polyhaven.com/license .',
'Differences from sheet: safety-yellow default, symmetric rotary-jointed grabbers, original twin bell engines and hopper framing; shared tiling wear is less uniquely authored than the painted concept.'
])+'\n')
# Main, side, rear and close-up renders all use the baked portable materials.
camera(cam);render(ROOT/'final.png',1920,1080,48)
camera(cam,(100,0,7),scale=77);render(ROOT/'side.png',1920,1080,32)
camera(cam,(-80,-110,50),scale=78);render(QA/'rear.png',1280,720,24)
camera(cam,(49,52,26),target=(8,12,-.6),scale=31);render(QA/'closeup.png',1280,960,32)
# Individually exercise every moving mesh: 30 degrees about its declared parent-space axis.
# The pivot and every child hinge must remain mechanically attached.
REST={k:ob.matrix_world.copy() for k,ob in OBJECTS.items()};TESTS=[]
for name in AXES:
 ob=OBJECTS[name];before=ob.matrix_world.translation.copy();axis=Vector((AXES[name][0],-AXES[name][2],AXES[name][1]));ob.rotation_mode='AXIS_ANGLE';ob.rotation_axis_angle=(math.radians(30),*axis);bpy.context.view_layer.update()
 drift=(ob.matrix_world.translation-before).length;assert drift<1e-5,(name,drift)
 TESTS.append({'node':name,'rotation_degrees':30,'axis_parent_space':AXES[name],'pivot_drift_m':drift})
 ob.rotation_axis_angle=(0,*axis);bpy.context.view_layer.update()
# A combined posed render exposes detached hoses/parts or accidental off-centre origins.
for name in AXES:
 axis=Vector((AXES[name][0],-AXES[name][2],AXES[name][1]));OBJECTS[name].rotation_axis_angle=(math.radians(30),*axis)
camera(cam,(88,103,65),scale=88);render(QA/'moving-parts-30deg.png',1440,900,24)
for name in AXES:OBJECTS[name].rotation_axis_angle[0]=0
(QA/'pivot-tests.json').write_text(json.dumps(TESTS,indent=2))
mirror_tests=[]
for base in ['Arm','Forearm','Wrist']:
 left=OBJECTS[base+'_L'].matrix_world.translation;right=OBJECTS[base+'_R'].matrix_world.translation
 error=(Vector((-left.x,left.y,left.z))-right).length
 assert error<1e-6
 mirror_tests.append({'pair':base,'rest_pose_mirror_error_m':error})
(QA/'rig-witness.json').write_text(json.dumps({'mirror_checks':mirror_tests,'hydraulics':'Each ram, its two clevis brackets and both retaining pins belong to the same rigid limb mesh; no cylinder bridges a freely rotating child joint. Rotary joint bearings provide traverse.'},indent=2))
# Six recolours, same camera and illumination. Assemble directly with Blender pixels.
camera(cam);imgs=[]
for idx,(name,color) in enumerate(PAINTS):
 M['Paint'].node_tree.nodes['Faction paint'].inputs[2].default_value=(*color,1)
 p=QA/f'paint-{idx}.png';render(p,800,450,16);im=bpy.data.images.load(str(p),check_existing=False);imgs.append(np.array(im.pixels[:],dtype=np.float32).reshape(450,800,4));bpy.data.images.remove(im)
canvas=np.ones((900,2400,4),np.float32)
for i,arr in enumerate(imgs):
 row=1-i//3;col=i%3;canvas[row*450:(row+1)*450,col*800:(col+1)*800,:]=arr
im=bpy.data.images.new('Six faction paints',width=2400,height=900,alpha=False);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
M['Paint'].node_tree.nodes['Faction paint'].inputs[2].default_value=(*PAINTS[0][1],1)
# Persist final scene with the default paint and rest pose, supporting evidence reproduction.
camera(cam);bpy.ops.wm.save_as_mainfile(filepath=str(QA/'mining-ship.blend'))
log('COMPLETE',TRIS,'triangles',size,'bytes',round(time.time()-T0,1),'seconds')
