"""UH-60-style utility helicopter. Clean deterministic Blender 5.2 build.
Run: blender -b --python-exit-code 1 --python build_blackhawk-helicopter.py
Optional -- --preflight exercises GPU bake/export/reimport; -- --draft uses small renders.
Scope: hero/front, orthographic port, clay, six paints; 60-140k triangles,
8 materials max, 1024 colour/normal and 512 ORM max, 12MB GLB. Revision cap 3.
CC0 source textures are downloaded into a task cache, all shader detail Cycles-baked.
"""
import bpy, bmesh, math, os, sys, json, struct, urllib.request, random, time
from mathutils import Vector, Matrix
from pathlib import Path
import numpy as np
import hashlib
BUILD_SOURCE_SHA256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
ROOT=Path(__file__).resolve().parent
CACHE=ROOT/'textures'; CACHE.mkdir(exist_ok=True)
random.seed(60)
START=time.time()
DRAFT='--draft' in sys.argv
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for dat in list(bpy.data.materials): bpy.data.materials.remove(dat)
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 backend in ('OPTIX','CUDA'):
 try:
  prefs.compute_device_type=backend; prefs.get_devices()
  if any(d.type!='CPU' for d in prefs.devices): break
 except Exception: pass
for d in prefs.devices: d.use=d.type!='CPU'
sc.cycles.device='GPU'
print('GPU',[(d.name,d.type,d.use) for d in prefs.devices],flush=True)
sc.render.image_settings.file_format='PNG'
sc.view_settings.view_transform='AgX'
sc.render.bake.margin=8

def active(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def source(asset,mp):
 p=CACHE/f'{asset}_{mp}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{mp}_1k.jpg'
  urllib.request.urlretrieve(url,p)
 im=bpy.data.images.load(str(p),check_existing=True)
 if mp!='diff': im.colorspace_settings.name='Non-Color'
 return im

# Tiny real GPU bake/export/import before any full pipeline execution.
if '--preflight' in sys.argv:
 bpy.ops.mesh.primitive_plane_add(); o=bpy.context.object
 m=bpy.data.materials.new('probe');m.use_nodes=True;o.data.materials.append(m)
 im=bpy.data.images.new('probe',32,32); n=m.node_tree.nodes.new('ShaderNodeTexImage');n.image=im;m.node_tree.nodes.active=n
 sc.cycles.samples=1;bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
 bpy.ops.export_scene.gltf(filepath='/tmp/blackhawk-probe.glb',export_format='GLB',use_selection=True)
 bpy.ops.object.delete();bpy.ops.import_scene.gltf(filepath='/tmp/blackhawk-probe.glb')
 assert any(o.type=='MESH' for o in sc.objects)
 print('PREFLIGHT_OK');sys.exit(0)

MATS={};FACTORS={}
def baked_material(name,asset,color,rough,metal=0,scale=1,normal_strength=.15):
 """Bake a seamless shared material tile: photo + procedural wear, actual Cycles passes."""
 mat=bpy.data.materials.new(name);mat.use_nodes=True
 nt=mat.node_tree;nt.nodes.clear();ns=nt.nodes;lk=nt.links
 out=ns.new('ShaderNodeOutputMaterial');bs=ns.new('ShaderNodeBsdfPrincipled');lk.new(bs.outputs['BSDF'],out.inputs['Surface'])
 uv=ns.new('ShaderNodeTexCoord')
 tex=ns.new('ShaderNodeTexImage');tex.image=source(asset,'diff');lk.new(uv.outputs['UV'],tex.inputs['Vector'])
 bw=ns.new('ShaderNodeRGBToBW');lk.new(tex.outputs['Color'],bw.inputs[0])
 ramp=ns.new('ShaderNodeValToRGB')
 if name=='Paint':
  ramp.color_ramp.elements[0].position=.08;ramp.color_ramp.elements[0].color=(.62,.62,.62,1)
  ramp.color_ramp.elements[1].position=.65;ramp.color_ramp.elements[1].color=(.97,.97,.97,1)
 else:
  ramp.color_ramp.elements[0].color=tuple(c*.65 for c in color)+(1,)
  ramp.color_ramp.elements[1].color=tuple(min(1,c*1.3) for c in color)+(1,)
 lk.new(bw.outputs[0],ramp.inputs[0])
 noise=ns.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=38;noise.inputs['Detail'].default_value=3
 lk.new(uv.outputs['UV'],noise.inputs['Vector'])
 mix=ns.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.13
 lk.new(ramp.outputs['Color'],mix.inputs[1]);lk.new(noise.outputs['Fac'],mix.inputs[2]);lk.new(mix.outputs[0],bs.inputs['Base Color'])
 if name=='Paint':
  broad=ns.new('ShaderNodeTexNoise');broad.inputs['Scale'].default_value=5.5;broad.inputs['Detail'].default_value=4;lk.new(uv.outputs['UV'],broad.inputs['Vector'])
  grime=ns.new('ShaderNodeValToRGB');grime.color_ramp.elements[0].position=.23;grime.color_ramp.elements[0].color=(.40,.40,.40,1);grime.color_ramp.elements[1].position=.72;grime.color_ramp.elements[1].color=(1,1,1,1);lk.new(broad.outputs['Fac'],grime.inputs[0])
  vm=ns.new('ShaderNodeVectorMath');vm.operation='MULTIPLY';vm.inputs[1].default_value=(90,3,1);lk.new(uv.outputs['UV'],vm.inputs[0])
  rain=ns.new('ShaderNodeTexNoise');rain.inputs['Scale'].default_value=1;rain.inputs['Detail'].default_value=2;lk.new(vm.outputs[0],rain.inputs['Vector'])
  weather=ns.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=.65;lk.new(mix.outputs[0],weather.inputs[1]);lk.new(grime.outputs[0],weather.inputs[2])
  streak=ns.new('ShaderNodeMixRGB');streak.blend_type='MULTIPLY';streak.inputs[0].default_value=.22;lk.new(weather.outputs[0],streak.inputs[1]);lk.new(rain.outputs['Fac'],streak.inputs[2]);lk.new(streak.outputs[0],bs.inputs['Base Color'])
 nor=ns.new('ShaderNodeTexImage');nor.image=source(asset,'nor_gl');lk.new(uv.outputs['UV'],nor.inputs['Vector'])
 norm=ns.new('ShaderNodeNormalMap');norm.inputs['Strength'].default_value=normal_strength;lk.new(nor.outputs['Color'],norm.inputs['Color']);lk.new(norm.outputs[0],bs.inputs['Normal'])
 arm=ns.new('ShaderNodeTexImage');arm.image=source(asset,'arm');lk.new(uv.outputs['UV'],arm.inputs['Vector'])
 sep=ns.new('ShaderNodeSeparateColor');lk.new(arm.outputs['Color'],sep.inputs['Color'])
 mr=ns.new('ShaderNodeMapRange');mr.inputs['To Min'].default_value=max(.08,rough-.12);mr.inputs['To Max'].default_value=min(.96,rough+.12);lk.new(sep.outputs['Green'],mr.inputs['Value']);lk.new(mr.outputs[0],bs.inputs['Roughness'])
 bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=2,location=(0,0,-30));tile=bpy.context.object;tile.data.materials.append(mat)
 target=ns.new('ShaderNodeTexImage')
 baked={}
 texres=1024 if name in ('Paint','Metal') else (128 if name in ('Glass','Lamps') else 512)
 for kind,res in [('base',texres),('normal',texres),('orm',min(512,texres))]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False)
  if kind!='base': im.colorspace_settings.name='Non-Color'
  target.image=im;ns.active=target;active(tile);sc.cycles.samples=4
  if kind=='base': bpy.ops.object.bake(type='DIFFUSE',pass_filter={'COLOR'})
  elif kind=='normal': bpy.ops.object.bake(type='NORMAL')
  else:
   combine=ns.new('ShaderNodeCombineColor');combine.inputs['Red'].default_value=1;combine.inputs['Blue'].default_value=metal;lk.new(mr.outputs[0],combine.inputs['Green'])
   em=ns.new('ShaderNodeEmission');lk.new(combine.outputs[0],em.inputs[0]);lk.new(em.outputs[0],out.inputs['Surface'])
   bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=str(CACHE/(name+'_'+kind+'.png'));im.file_format='PNG';im.save();baked[kind]=im
 bpy.data.objects.remove(tile,do_unlink=True)
 nt.nodes.clear();out=ns.new('ShaderNodeOutputMaterial');bs=ns.new('ShaderNodeBsdfPrincipled');lk.new(bs.outputs[0],out.inputs[0])
 bc=ns.new('ShaderNodeTexImage');bc.name='Baked Base';bc.image=baked['base'];lk.new(bc.outputs['Color'],bs.inputs['Base Color'])
 normal=ns.new('ShaderNodeTexImage');normal.image=baked['normal'];nm=ns.new('ShaderNodeNormalMap');lk.new(normal.outputs['Color'],nm.inputs['Color']);lk.new(nm.outputs[0],bs.inputs['Normal'])
 orm=ns.new('ShaderNodeTexImage');orm.image=baked['orm'];ss=ns.new('ShaderNodeSeparateColor');lk.new(orm.outputs['Color'],ss.inputs['Color']);lk.new(ss.outputs['Green'],bs.inputs['Roughness']);lk.new(ss.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=ns.new('ShaderNodeGroup');gn.node_tree=group;lk.new(ss.outputs['Red'],gn.inputs['Occlusion'])
 MATS[name]=mat
 print('BAKED',name,flush=True)
 return mat

Paint=baked_material('Paint','blue_metal_plate',(1,1,1),.8,0,normal_strength=.09)
Metal=baked_material('Metal','metal_plate',(.19,.21,.22),.42,.78,normal_strength=.13)
Rubber=baked_material('Rubber','fabric_leather_01',(.018,.021,.022),.87,0,normal_strength=.12)
Interior=baked_material('Interior','fabric_leather_01',(.095,.105,.092),.86,0,normal_strength=.23)
Rotor=baked_material('Rotor','blue_metal_plate',(.035,.04,.045),.65,.18,normal_strength=.07)
Glass=baked_material('Glass','blue_metal_plate',(.11,.18,.21),.14,.06,normal_strength=.002)
gbs=Glass.node_tree.nodes.get('Principled BSDF');gbs.inputs['Alpha'].default_value=.48;gbs.inputs['Transmission Weight'].default_value=.12;gbs.inputs['IOR'].default_value=1.45
Glass.surface_render_method='BLENDED';Glass.use_backface_culling=False
Lamps=baked_material('Lamps','blue_metal_plate',(.65,.68,.57),.2,.05,normal_strength=.03)
lbs=Lamps.node_tree.nodes.get('Principled BSDF');lbs.inputs['Emission Color'].default_value=(.7,.58,.38,1);lbs.inputs['Emission Strength'].default_value=.6

GROUPS={'Fuselage':[],'MainRotor':[],'TailRotor':[],'Door_L':[],'Door_R':[]}
GROUP='Fuselage'
def register(o,mat):
 o.data.materials.append(mat);GROUPS[GROUP].append(o);return o

def mesh(name,verts,faces,mat=Paint,smooth=False,bevel=0):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 o=bpy.data.objects.new(name,me);sc.collection.objects.link(o);register(o,mat)
 for p in me.polygons:p.use_smooth=smooth
 if bevel: bevel_obj(o,bevel)
 return o

def bevel_obj(o,width=.025,seg=3):
 active(o);m=o.modifiers.new('Machined edge radii','BEVEL');m.width=width;m.segments=seg
 bpy.ops.object.modifier_apply(modifier=m.name)
 # Weighted normals retain broad planar surfaces.
 if len(o.data.polygons)<12000:
  m=o.modifiers.new('Area weighted normals','WEIGHTED_NORMAL');m.keep_sharp=True;m.weight=35
  try:bpy.ops.object.modifier_apply(modifier=m.name)
  except: o.modifiers.remove(m)

def box(name,loc,dim,mat=Paint,bevel=.025):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.dimensions=dim
 active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);register(o,mat)
 if bevel:bevel_obj(o,bevel)
 return o

def ellipsoid(name,loc,scale,mat=Paint,segments=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=segments,ring_count=rings,location=loc);o=bpy.context.object;o.name=name;o.scale=scale
 active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);register(o,mat)
 for p in o.data.polygons:p.use_smooth=True
 return o

def cyl(name,a,b,r,mat=Metal,vertices=20,r2=None):
 a,b=Vector(a),Vector(b);v=b-a
 bpy.ops.mesh.primitive_cone_add(vertices=vertices,radius1=r,radius2=r if r2 is None else r2,depth=v.length,location=(a+b)/2)
 o=bpy.context.object;o.name=name;o.rotation_euler=v.to_track_quat('Z','Y').to_euler();register(o,mat)
 for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
 bevel_obj(o,min(.012,r*.15),2);return o

def tube(name,pts,r=.012,mat=Metal,cyclic=False):
 # Explicit tube with parallel-ish frames, robust UV and geometry export.
 verts=[];faces=[];N=8
 for i,p in enumerate(pts):
  p=Vector(p);t=Vector(pts[min(i+1,len(pts)-1)])-Vector(pts[max(0,i-1)])
  if t.length<1e-5:t=Vector((0,0,1))
  t.normalize();ref=Vector((0,0,1)) if abs(t.z)<.9 else Vector((1,0,0));u=t.cross(ref).normalized();v=t.cross(u).normalized()
  verts.extend([p+r*(u*math.cos(j*2*math.pi/N)+v*math.sin(j*2*math.pi/N)) for j in range(N)])
 for i in range(len(pts)-1+(1 if cyclic else 0)):
  k=(i+1)%len(pts)
  for j in range(N):faces.append((i*N+j,i*N+(j+1)%N,k*N+(j+1)%N,k*N+j))
 if not cyclic:faces.extend([tuple(reversed(range(N))),tuple((len(pts)-1)*N+j for j in range(N))])
 return mesh(name,verts,faces,mat,True)

def panel(name,pts,mat=Paint,thick=.035,bev=.018):
 o=mesh(name,pts,[tuple(range(len(pts)))],mat)
 active(o);so=o.modifiers.new('Skin thickness','SOLIDIFY');so.thickness=thick;bpy.ops.object.modifier_apply(modifier=so.name)
 if bev:bevel_obj(o,bev,3)
 return o

def window(name,pts,group=None):
 # True transparent aperture, gasket and structural surround.
 c=sum((Vector(p) for p in pts),Vector())/len(pts)
 inner=[c+(Vector(p)-c)*1.005 for p in pts]
 panel(name+' glazing',inner,Glass,.008,.008)
 tube(name+' rubber seal',pts,.027,Rubber,True)
 outer=[c+(Vector(p)-c)*1.075 for p in pts]
 verts=list(pts)+outer;n=len(pts)
 o=mesh(name+' structural frame',verts,[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],Paint)
 active(o);so=o.modifiers.new('Frame extrusion','SOLIDIFY');so.thickness=.04;bpy.ops.object.modifier_apply(modifier=so.name);bevel_obj(o,.01,2)

def loft(name,sections,mat=Paint,N=48,power=2.5,sub=1,cap_start=True):
 # (longitudinal y, half width, bottom, top); elliptical/superelliptic rings.
 vs=[];fs=[]
 for y,w,bot,top in sections:
  for j in range(N):
   a=j*2*math.pi/N;cx=math.cos(a);sz=math.sin(a)
   vs.append((w*math.copysign(abs(cx)**(2/power),cx),y,(bot+top)/2+(top-bot)/2*math.copysign(abs(sz)**(2/power),sz)))
 for i in range(len(sections)-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))
 
 if cap_start:fs.append(tuple(reversed(range(N))))
 fs.append(tuple((len(sections)-1)*N+j for j in range(N)))
 o=mesh(name,vs,fs,mat,True)
 if sub:
  active(o);m=o.modifiers.new('Smooth pressure formed skin','SUBSURF');m.levels=sub;bpy.ops.object.modifier_apply(modifier=m.name)
 return o

# Lower nose and cabin bathtub; upper cockpit is genuinely open behind the glass.
lower_hull=loft('Compound nose and lower cabin',[
 (-4.40,.11,1.20,1.40),(-4.34,.34,1.04,1.63),(-4.12,.69,.91,1.80),(-3.78,.96,.82,1.98),(-3.30,1.13,.79,2.07),(-2.75,1.23,.79,1.97),
 (-2.15,1.27,.78,1.27),(-1.55,1.28,.78,1.05),(0,1.28,.78,1.03),(1.7,1.25,.8,1.05),(2.65,1.16,.88,1.15),(3.25,.97,1.0,1.85),(3.65,.78,1.18,2.02)],N=48,sub=2)
# Compound lower cheek/footwell glazing, boolean apertures through the nose skin.
for s in (-1,1):
 cheek=[(s*.79,-4.03,1.30),(s*1.02,-3.55,1.12),(s*1.14,-3.29,1.38),(s*.99,-3.30,1.81),(s*.80,-3.85,1.79)]
 # Match glazing to the compound skin before cutting its aperture.
 fitted=[]
 for x,y,z in cheek:
  hit,co,normal,face=lower_hull.ray_cast(Vector((s*3,y,z)),Vector((-s,0,0)))
  fitted.append((co.x+s*.014,y,z) if hit else (x,y,z))
 cheek=fitted
 outer=[(x+s*.30,y,z) for x,y,z in cheek];inner=[(0,y,z) for x,y,z in cheek]
 cv=outer+inner;n=len(cheek);cf=[tuple(range(n)),tuple(reversed(range(n,2*n)))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 cutter=mesh('Temporary cheek aperture',cv,cf,Rubber)
 active(lower_hull);mod=lower_hull.modifiers.new('Open cheek glazing aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
 GROUPS['Fuselage'].remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
 window('Lower cockpit cheek',cheek)
# Centre windshield mullion and nose service hatch fitted to the curved skin.
panel('Windscreen centre pillar',[(-.07,-3.64,2.00),(.07,-3.64,2.00),(.07,-2.80,3.025),(-.07,-2.80,3.025)],Paint,.045,.012)
nose_outline=[]
for x,y in [(-.30,-4.025),(.30,-4.025),(.46,-3.75),(-.46,-3.75)]:
 hit,co,normal,face=lower_hull.ray_cast(Vector((x,y,4)),Vector((0,0,-1)))
 if hit:nose_outline.append(co+normal*.008)
if len(nose_outline)==4:
 fitted_outline=[]
 for index,start in enumerate(nose_outline):
  end=nose_outline[(index+1)%len(nose_outline)]
  for t in np.linspace(0,1,9,endpoint=False):
   point=start.lerp(end,float(t));hit,co,normal,face=lower_hull.ray_cast(Vector((point.x,point.y,4)),Vector((0,0,-1)))
   fitted_outline.append(co+normal*.008 if hit else point)
 tube('Nose avionics hatch seam',fitted_outline,.006,Rubber,True)
 for point in nose_outline:ellipsoid('Nose flush quarter turn fastener',point+Vector((0,0,.006)),(.012,.012,.006),Metal,12,6)
# Rear cabin with taper joining boom.
loft('Aft cabin pressure shell',[(1.95,1.23,.84,3.04),(2.03,1.23,.84,3.04),(2.7,1.15,.89,2.99),(3.35,.93,1.04,2.72),(4.0,.70,1.25,2.48),(4.4,.60,1.34,2.42)],N=48,sub=2,cap_start=False)
loft('Tapered tail boom',[(3.25,.82,1.05,2.62),(3.7,.73,1.19,2.54),(4.6,.57,1.35,2.43),(6,.43,1.47,2.28),(7.5,.30,1.53,2.11),(8.65,.22,1.62,2.04),(9.35,.20,1.79,2.13),(9.65,.15,1.93,2.18)],N=40,power=2.3,sub=2)
# Roof fairing independent of window openings.
crown=loft('Cabin crown', [(-2.70,.83,2.87,3.04),(-2.5,1.06,2.89,3.16),(-1.6,1.17,2.91,3.19),(1.8,1.19,2.91,3.18),(2.55,1.07,2.85,3.12)],N=32,sub=2)
# Cut two real roof apertures before placing brow glazing.
for side in (-1,1):
 brow=[(side*.12,-2.79,3.045),(side*.86,-2.61,3.02),(side*.84,-2.19,3.15),(side*.14,-2.27,3.20)]
 cv=[(x,y,z+.18) for x,y,z in brow]+[(x,y,2.6) for x,y,z in brow]
 cf=[(0,1,2,3),(7,6,5,4)]+[(i,(i+1)%4,(i+1)%4+4,i+4) for i in range(4)]
 cutter=mesh('Temporary brow cutter',cv,cf,Rubber);active(crown)
 mod=crown.modifiers.new('Eyebrow aperture','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=cutter;bpy.ops.object.modifier_apply(modifier=mod.name)
 GROUPS['Fuselage'].remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
# Cockpit four main glazing panes and two brow panes.
for s in (-1,1):
 window('Windshield',[(s*.065,-3.62,2.01),(s*.98,-3.42,2.03),(s*.89,-2.61,2.93),(s*.065,-2.80,3.00)])
 window('Pilot side window',[(s*1.04,-3.30,2.06),(s*1.235,-2.63,2.08),(s*1.235,-1.83,2.10),(s*1.17,-1.83,2.93),(s*.98,-2.51,2.94)])
 window('Cockpit eyebrow',[(s*.12,-2.79,3.045),(s*.86,-2.61,3.02),(s*.84,-2.19,3.15),(s*.14,-2.27,3.20)])
 # Sealed cockpit joints and broad roof eyebrow apertures.
 panel('Cockpit front brow bridge',[(s*.02,-2.82,2.98),(s*.96,-2.61,2.96),(s*.93,-2.52,3.06),(s*.02,-2.74,3.08)],Paint,.04)
 panel('Windshield side pillar',[(s*.96,-3.43,2.00),(s*1.065,-3.30,2.02),(s*1.00,-2.50,2.96),(s*.88,-2.62,2.98)],Paint,.045)
 panel('Pilot window lower sill',[(s*.98,-3.45,1.96),(s*1.10,-3.20,1.90),(s*1.25,-1.77,1.99),(s*1.25,-1.77,2.10),(s*1.04,-3.30,2.07)],Paint,.045)
 panel('Nose upper skin',[(s*.02,-3.69,1.89),(s*.98,-3.43,1.95),(s*.99,-3.43,2.04),(s*.02,-3.64,2.035)],Paint,.04)
 # Pilots' solid lower doors and hinges.
 panel('Pilot lower door',[(s*1.10,-3.19,1.23),(s*1.245,-1.79,1.01),(s*1.25,-1.79,2.09),(s*1.09,-3.21,2.04)],Paint,.04)
 tube('Pilot door edge',[(s*1.12,-3.19,1.25),(s*1.27,-1.77,1.03),(s*1.27,-1.77,2.96),(s*.99,-2.52,2.97)],.012,Rubber)
 for z in (1.4,1.9):box('Cockpit hinge',(s*1.26,-1.80,z),(.055,.14,.08),Metal,.012)
 tube('Pilot handle',[(s*1.26,-2.08,1.84),(s*1.31,-2.08,1.89),(s*1.31,-1.91,1.89),(s*1.26,-1.91,1.84)],.018,Metal)
 # Gunner's bay forward of sliding door.
 box('Gunner lower skin',(s*1.255,-1.32,1.31),(.08,.89,.65),Paint,.027)
 box('Gunner forward jamb',(s*1.252,-1.755,1.96),(.09,.13,1.98),Paint,.022)
 box('Cabin eaves',(s*1.20,.0,2.99),(.17,4.3,.18),Paint,.035)
 box('Gunner station sill',(s*1.255,-1.32,1.73),(.075,.82,.38),Paint,.035)
 box('Gunner station aft post',(s*1.25,-.88,2.30),(.07,.085,1.32),Paint,.025)
 window('Gunner observation window',[(s*1.265,-1.67,1.99),(s*1.265,-.96,1.99),(s*1.22,-.96,2.87),(s*1.22,-1.67,2.87)])
 # Tall passenger doorway surround, real opening underneath sliding panel.
 for y in (-.81,1.89):box('Door aperture post',(s*1.26,y,1.94),(.12,.23,2.06),Paint,.03)
 for z in (1.01,2.96):box('Door aperture sill',(s*1.27,.54,z),(.10,2.76,.10),Paint,.025)
 for z in (1.1,2.95):
  cyl('Sliding door rail',(s*1.34,-.88,z),(s*1.34,2.87,z),.029,Metal,16)
  for y in (-.7,.4,1.5,2.7):box('Rail stand off',(s*1.30,y,z),(.12,.12,.07),Paint,.016)
 # Sliding door, panel skin plus windows with empty space behind.
 GROUP='Door_L' if s==1 else 'Door_R'
 x=s*1.325
 box('Sliding door lower panel',(x,.52,1.46),(.07,2.49,.87),Paint,.045)
 box('Sliding door header',(x,.52,2.82),(.07,2.49,.20),Paint,.035)
 box('Sliding window lower weather sill',(x,.52,1.929),(.075,2.49,.086),Paint,.012)
 for y in (-.705,.52,1.745):box('Sliding door mullion',(x,y,2.31),(.075,.12,.91),Paint,.028)
 for y in (-.09,1.13):
  window('Troop door window',[(x+s*.01,y-.50,1.95),(x+s*.01,y+.50,1.95),(x+s*.01,y+.50,2.70),(x+s*.01,y-.50,2.70)])
 tube('Door perimeter gasket',[(x+s*.03,-.74,1.04),(x+s*.03,1.78,1.04),(x+s*.03,1.78,2.92),(x+s*.03,-.74,2.92)],.013,Rubber,True)
 for y in (-.62,1.65):box('Door roller housing',(x,y,2.97),(.15,.22,.10),Metal,.025)
 tube('Sliding door pull',[(x+s*.055,1.59,1.70),(x+s*.105,1.59,1.77),(x+s*.105,1.59,1.96),(x+s*.055,1.59,2.02)],.018,Metal)
 GROUP='Fuselage'
 # Boarding step folds and tubular hand rails.
 tube('Boarding step',[(s*1.16,-.73,1.01),(s*1.58,-.64,.78),(s*1.58,.74,.78),(s*1.18,.86,1.02)],.035,Metal)
 box('Tread step',(s*1.58,.06,.80),(.18,1.20,.055),Rubber,.013)
# Interior floor, sidewall liners, roof ribs.
box('Cabin floor',(0,.0,1.01),(2.30,5.7,.11),Interior,.04)
for y in (-1.7,-.1,1.6):
 tube('Cabin roof rib',[(-1.17,y,1.10),(-1.17,y,2.9),(-.83,y,3.05),(.83,y,3.05),(1.17,y,2.9),(1.17,y,1.10)],.033,Interior)
# cockpit seats, consoles, pedals and cyclics.
SOCKETS=[]
def socket(name,pos):
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=pos;o.empty_display_size=.12;SOCKETS.append(o);return o
for s in (-1,1):
 x=s*.57
 box('Pilot seat pedestal',(x,-2.10,1.27),(.35,.42,.4),Metal,.04)
 box('Armoured seat cushion',(x,-2.13,1.55),(.58,.64,.15),Interior,.09)
 back=box('Armoured pilot seat back',(x,-1.86,1.97),(.63,.18,.81),Interior,.08);back.rotation_euler.x=-.12
 box('Pilot headrest',(x,-1.84,2.40),(.34,.19,.22),Interior,.045)
 for dx in (-.16,.16):tube('Shoulder harness',[(x+dx,-1.985,2.29),(x+dx*.7,-2.02,1.84),(x+dx*.8,-2.2,1.62)],.025,Rubber)
 cyl('Cyclic stick',(x,-2.53,1.20),(x,-2.46,1.91),.025,Metal)
 box('Cyclic grip',(x,-2.46,1.95),(.065,.075,.16),Rubber,.02)
 for dx in (-.16,.16):box('Rudder pedal',(x+dx,-3.06,1.32),(.20,.10,.13),Metal,.015)
 socket('Seat_Pilot' if s==1 else 'Seat_Copilot',(x,-2.12,1.63))
# instrument panel with recessed displays and small gauge geometry, no text.
box('Instrument coaming',(0,-2.96,1.98),(1.83,.33,.45),Rubber,.10)
for x in (-.62,-.23,.23,.62):
 p=box('Display bezel',(x,-2.745,2.08),(.29,.055,.23),Metal,.023)
 box('Dark instrument screen',(x,-2.71,2.09),(.24,.012,.18),Glass,.012)
 for k in range(4):box('Display key',(x-.10+k*.065,-2.70,1.96),(.025,.017,.02),Interior,.004)
for x in (-.81,0,.81):
 cyl('Round cockpit dial',(x,-2.70,1.93),(x,-2.68,1.93),.045,Metal,24)
box('Centre console',(0,-2.31,1.35),(.29,1.0,.48),Rubber,.04)
for y in (-2.6,-2.43,-2.26,-2.09):
 for x in (-.075,.075):cyl('Console toggle',(x,y,1.6),(x,y,1.64),.009,Metal,8)
# Eleven troop seats with frames and belts.
for i,(s,y) in enumerate([(s,y) for s in (-1,1) for y in (-.50,.28,1.06,1.84)]+[(-1,2.47),(0,2.47),(1,2.47)]):
 x=s*.88 if i<8 else s*.61
 box('Troop seat pad',(x,y,1.39),(.48,.58,.12),Interior,.055)
 box('Troop seat back',(x+s*.15 if i<8 else x,y if i<8 else y+.19,1.78),(.12,.59,.66) if i<8 else (.50,.12,.66),Interior,.05)
 for dy in (-.22,.22):cyl('Seat tube frame',(x,y+dy,1.04),(x,y+dy,1.35),.022,Metal,10)
 tube('Lap restraint',[(x-.19,y,1.48),(x,y+.07,1.46),(x+.19,y,1.48)],.024,Rubber)
 socket('Troop_'+str(i+1),(x,y,1.49))
# Engine deck and sculpted transmission fairing.
loft('Transmission roof fairing',[(-2.19,.30,3.04,3.15),(-1.6,.67,3.02,3.49),(-.7,.72,3.04,3.66),(.55,.69,3.04,3.68),(1.30,.48,3.07,3.42)],N=40,sub=2)
for s in (-1,1):
 # nacelles aligned along y; intake ring and buried dark intake fan.
 loft('T700 engine cowling',[(-.87,.26,3.06,3.67),(-.71,.40,3.0,3.82),(-.40,.43,3.0,3.85),(1.55,.42,3.01,3.78),(2.0,.34,3.04,3.70),(2.19,.26,3.11,3.61)],N=40,sub=2)
 # move most recently registered nacelle off centre
 GROUPS[GROUP][-1].location.x=s*.78
 cyl('Intake shadow',(s*.78,-.86,3.43),(s*.78,-.79,3.43),.306,Rubber,48)
 # curved lip torus oriented along Y
 bpy.ops.mesh.primitive_torus_add(major_radius=.315,minor_radius=.048,major_segments=48,minor_segments=12,location=(s*.78,-.86,3.43),rotation=(math.pi/2,0,0));register(bpy.context.object,Paint)
 cyl('Intake spinner',(s*.78,-1.02,3.43),(s*.78,-.72,3.43),.18,Rotor,32,r2=.27)
 for a in range(14):
  ang=a*math.tau/14
  tube('Intake radial grille',[(s*.78+.13*math.cos(ang),-.88,3.43+.13*math.sin(ang)),(s*.78+.29*math.cos(ang+.15),-.88,3.43+.29*math.sin(ang+.15))],.014,Rotor)
 # Black Hawk angular exhaust suppression housing.
 x=s*.91
 panel('Exhaust upper shroud',[(x-.41,1.57,3.85),(x+.41,1.57,3.85),(x+.44,2.52,3.63),(x-.44,2.52,3.63)],Paint,.10,.08)
 for dx in (-.41,.41):panel('Exhaust cheek',[(x+dx,1.64,3.75),(x+dx,2.48,3.64),(x+dx,2.59,3.12),(x+dx,1.82,3.10)],Paint,.09,.065)
 panel('Exhaust lower return',[(x-.40,1.82,3.08),(x+.40,1.82,3.08),(x+.40,2.59,3.11),(x-.40,2.59,3.11)],Paint,.075,.04)
 box('Exhaust soot cavity',(x,2.42,3.35),(.70,.10,.42),Rubber,.10)
 for j in range(6):box('Exhaust diffuser',(x-.29+j*.115,2.50,3.34),(.025,.13,.33),Metal,.01)
 socket('Exhaust_L' if s==1 else 'Exhaust_R',(x,2.56,3.34))
 for y in (-.34,.65,1.54):
  # transverse band fine seams conform visually to rounded engine shell.
  pts=[(s*.78+.431*math.cos(a),y,3.425+.429*math.sin(a)) for a in np.linspace(0,math.pi,22)]
  tube('Engine cowling seam',pts,.008,Rubber)
 # Engine latch hardware.
 for y in (-.2,1.15):box('Cowling latch',(s*1.205,y,3.34),(.035,.15,.065),Metal,.012)
# Main landing gear: front pair and rear tail wheel, tyres rest on ground.
def wheel(name,x,y,z,r,width):
 # torus about X, bulbous tyre with real circumferential grooves
 bpy.ops.mesh.primitive_torus_add(major_radius=r*.73,minor_radius=r*.27,major_segments=56,minor_segments=16,location=(x,y,z),rotation=(0,math.pi/2,0))
 o=bpy.context.object;o.name=name+' tyre';o.scale.z=width/(r*.54);register(o,Rubber)
 for p in o.data.polygons:p.use_smooth=True
 cyl(name+' wheel rim',(x-width*.52,y,z),(x+width*.52,y,z),r*.48,Metal,40)
 for s in (-1,1):
  cyl(name+' painted hub',(x+s*width*.53,y,z),(x+s*width*.58,y,z),r*.33,Paint,32)
  cyl(name+' axle cap',(x+s*width*.59,y,z),(x+s*width*.64,y,z),r*.16,Metal,24)
  for a in range(8):
   ang=a*math.tau/8;cyl('Wheel bolt',(x+s*width*.585,y+math.cos(ang)*r*.245,z+math.sin(ang)*r*.245),(x+s*width*.62,y+math.cos(ang)*r*.245,z+math.sin(ang)*r*.245),.018,Metal,8)
 # fine tread ribs wrapped over tyre crown
 for a in range(44):
  ang=a*math.tau/44
  pts=[]
  for t in np.linspace(-.8,.8,5):
   rr=r*.73+(r*.27-.009)*math.cos(t);pts.append((x+width*.49*math.sin(t),y+rr*math.sin(ang+.025*t),z+rr*math.cos(ang+.025*t)))
  tube('Tyre tread rib',pts,.009,Rubber)
for s in (-1,1):
 wheel('Main gear',s*1.59,-1.03,.45,.45,.31)
 cyl('Main gear lower oleo',(s*1.57,-1.03,.49),(s*1.45,-.93,1.19),.082,Metal,24)
 cyl('Main gear upper strut',(s*1.45,-.93,.93),(s*1.21,-.84,1.66),.12,Paint,24)
 cyl('Gear drag brace',(s*1.13,-2.24,1.06),(s*1.59,-1.03,.55),.060,Paint,20)
 cyl('Gear cross brace',(s*.79,-.10,1.08),(s*1.56,-1.03,.53),.05,Metal,16)
 tube('Brake hydraulic hose',[(s*1.25,-.84,1.47),(s*1.50,-.87,1.04),(s*1.64,-.94,.61)],.012,Rubber)
 ellipsoid('Gear shoulder fairing',(s*1.15,-1.91,1.02),(.51,.24,.115),Paint)
wheel('Tail gear',0,8.59,.265,.265,.19)
for s in (-1,1):
 cyl('Tail wheel fork',(s*.16,8.56,.27),(s*.16,8.35,.61),.035,Metal,16)

panel('Tail gear trailing arm',[(-.10,7.56,1.59),(-.10,7.79,1.68),(-.10,8.63,.38),(-.10,8.45,.30)],Paint,.20,.055)
cyl('Tail gear upper oleo',(0,7.96,1.62),(0,8.22,.98),.095,Paint,24)
cyl('Tail gear polished piston',(0,8.20,1.05),(0,8.43,.52),.057,Metal,24)
cyl('Tail gear keel pivot',(-.21,7.72,1.58),(.21,7.72,1.58),.115,Metal,24)
# Fin: swept asymmetric tapered solid airfoil with bevels.
finpts=[(-.12,8.22,1.73),(-.12,9.02,2.38),(-.12,10.26,4.24),(-.12,10.76,4.20),(-.12,10.93,3.92),(-.12,9.73,1.84),(-.12,9.53,1.68)]
panel('Swept tail pylon',finpts,Paint,.26,.065)
# Leading-edge erosion shield follows swept fin.
panel('Tail leading edge boot',[(-.263,8.53,2.10),(-.263,10.26,4.24),(-.263,10.47,4.22),(-.263,8.76,2.07)],Rotor,.018,.015)
# Tail stabilator airfoils, dihedral low on fin.
def wing(name,start,end,chordroot,chordtip,mat=Paint):
 st,en=Vector(start),Vector(end);axis=(en-st).normalized();long=Vector((0,1,0));up=axis.cross(long).normalized();vs=[];fs=[];N=24
 for t in (0,.15,.65,1):
  c=chordroot*(1-t)+chordtip*t;cent=st.lerp(en,t)
  for j in range(N):
   a=math.tau*j/N;vs.append(cent+long*(math.cos(a)*c*.5)+up*(math.sin(a)*c*.07))
 for k in range(3):
  for j in range(N):fs.append((k*N+j,k*N+(j+1)%N,(k+1)*N+(j+1)%N,(k+1)*N+j))
 fs.extend([tuple(reversed(range(N))),tuple(3*N+j for j in range(N))]);return mesh(name,vs,fs,mat,True)
for s in (-1,1):wing('Horizontal stabilator',(s*.15,8.82,2.20),(s*2.14,8.88,2.30),1.13,.53)
# External stores stub wings and braces; hardpoint empty pairs.
for s in (-1,1):
 wing('External stores pylon',(s*1.08,-1.16,2.93),(s*2.55,-.94,2.72),1.02,.48)
 cyl('Pylon brace',(s*1.16,-.85,1.48),(s*2.29,-.91,2.70),.065,Paint,20)
 for j,x in enumerate((1.90,2.38),1):
  box('Pylon shackle',(s*x,-.88,2.56),(.14,.44,.20),Metal,.025)
  for yy in (-1.06,-.71):cyl('Store release lug',(s*(x-.10),yy,2.44),(s*(x+.10),yy,2.44),.027,Metal,12)
  socket(('Hardpoint_L' if s==1 else 'Hardpoint_R')+str(j),(s*x,-.88,2.42))
# Door-mounted general purpose machine guns, receivers, barrels, pintles, belts (not a chin turret).
for s in (-1,1):
 cyl('Gunner pintle',(s*1.37,-1.26,1.53),(s*1.54,-1.25,2.05),.041,Metal,16)
 cyl('Gun traverse bearing',(s*1.47,-1.25,2.00),(s*1.63,-1.25,2.00),.085,Metal,24)
 box('Door gun receiver',(s*1.68,-1.25,2.14),(.16,.49,.17),Rotor,.025)
 cyl('Machine gun barrel',(s*1.69,-1.46,2.18),(s*1.79,-2.36,2.18),.025,Metal,20)
 cyl('Barrel shroud',(s*1.69,-1.48,2.18),(s*1.75,-1.98,2.18),.045,Rotor,20)
 cyl('Muzzle flash hider',(s*1.79,-2.33,2.18),(s*1.80,-2.45,2.18),.040,Metal,20)
 for y in (-1.64,-1.79,-1.94):
  cyl('Barrel cooling collar',(s*1.71,y,2.18),(s*1.72,y-.028,2.18),.047,Metal,16)
 tube('Gun carry handle',[(s*1.67,-1.48,2.19),(s*1.67,-1.39,2.34),(s*1.67,-1.19,2.34)],.018,Metal)
 box('Gun rear grip',(s*1.68,-.96,2.06),(.085,.10,.22),Rubber,.02)
 box('Ammunition box',(s*1.78,-.91,1.87),(.24,.35,.28),Rotor,.025)
 for k in range(13):
  t=k/12;tube('Ammunition feed link',[(s*(1.78+.12*math.sin(t*math.pi)),-.94-.2*t,2.00+.17*t),(s*(1.87+.12*math.sin(t*math.pi)),-.94-.2*t,2.00+.17*t)],.019,Metal)
# Mast and swashplate, stationary below rotor node.
cyl('Mast housing',(0,0,3.36),(0,0,3.89),.22,Paint,40)
cyl('Mast shaft',(0,0,3.78),(0,0,4.30),.115,Metal,32)
for z,r in ((3.81,.29),(3.88,.26),(4.12,.21)):
 cyl('Swashplate',(0,0,z-.035),(0,0,z+.035),r,Metal,40)
for a in (0,math.pi/2,math.pi,math.pi*1.5):
 cyl('Rotor servo rod',(.27*math.cos(a),.27*math.sin(a),3.62),(.35*math.cos(a+.2),.35*math.sin(a+.2),4.18),.024,Metal,12)
# Four true closed NACA-like airfoil blades, twist root to tip, tapered/swept tips.
def blade(name,origin,rad,chord,angle,axis=(0,0,1),tail=False):
 axis=Vector(axis).normalized();u=Vector((1,0,0)) if abs(axis.x)<.8 else Vector((0,1,0));u=(u-axis*u.dot(axis)).normalized();v=axis.cross(u)
 u,v=u*math.cos(angle)+v*math.sin(angle),-u*math.sin(angle)+v*math.cos(angle)
 vs=[];fs=[];sections=20;profile=24
 for k in range(sections):
  t=k/(sections-1);r=(.23 if tail else .53)+(rad-(.23 if tail else .53))*t
  c=chord*(.74 if t<.07 else 1)* (1-.24*max(0,(t-.83)/.17))
  sweep=.26*max(0,(t-.81)/.19) if not tail else .07*t
  twist=math.radians(10-15*t)
  for j in range(profile):
   a=math.tau*j/profile;xx=(1-math.cos(a))*.5
   thickness=5*.12*(.2969*math.sqrt(max(xx,0))-.126*xx-.3516*xx*xx+.2843*xx**3-.1036*xx**4)
   zz=math.copysign(thickness,math.sin(a))+.018*math.sin(math.pi*xx)
   q=(xx-.28)*c;h=zz*c
   q,h=q*math.cos(twist)-h*math.sin(twist),q*math.sin(twist)+h*math.cos(twist)
   droop=-.105*t*t if not tail else 0
   vs.append(Vector(origin)+u*r+v*(q+sweep)+axis*(h+droop))
 for k in range(sections-1):
  for j in range(profile):fs.append((k*profile+j,k*profile+(j+1)%profile,(k+1)*profile+(j+1)%profile,(k+1)*profile+j))
 fs.extend([tuple(reversed(range(profile))),tuple((sections-1)*profile+j for j in range(profile))])
 o=mesh(name,vs,fs,Rotor,True)
 # Thin leading edge metal erosion strip laid as a closed narrow cross section.
 return o
GROUP='MainRotor';hub=(0,0,4.30)
cyl('Rotating hub',(0,0,4.20),(0,0,4.40),.27,Metal,40)
ellipsoid('Rotor hub cap',(0,0,4.40),(.23,.23,.06),Rotor)
for i in range(4):
 a=math.radians(-12)+i*math.pi/2
 d=Vector((math.cos(a),math.sin(a),0))
 cyl('Blade spindle',Vector(hub)+d*.15,Vector(hub)+d*.65,.08,Metal,24)
 cyl('Blade root grip',Vector(hub)+d*.41,Vector(hub)+d*.78,.10,Rotor,20)
 blade('Twisted composite main rotor',hub,8.2,.49,a)
 for rr in (.32,.49,.65):
  p=Vector(hub)+d*rr;cyl('Rotor bolt',(p.x,p.y,4.30),(p.x,p.y,4.43),.03,Metal,10)
 cyl('Pitch change link',(.26*math.cos(a-.2),.26*math.sin(a-.2),4.06),(.53*math.cos(a+.15),.53*math.sin(a+.15),4.32),.021,Metal,12)
GROUP='TailRotor';tailhub=Vector((-.28,10.47,4.14));tailaxis=Vector((-.94,0,.342)).normalized()
cyl('Tail rotor shaft',tailhub-tailaxis*.18,tailhub+tailaxis*.15,.095,Metal,24)
ellipsoid('Tail rotor gearbox',tailhub-tailaxis*.1,(.26,.23,.23),Paint)
for i in range(4):
 a=math.radians(31)+i*math.pi/2
 blade('Canted tail airfoil',tailhub+tailaxis*.18,1.13,.205,a,tailaxis,True)
ellipsoid('Tail rotor hub cover',tailhub+tailaxis*.22,(.12,.16,.16),Paint)
GROUP='Fuselage'
# Navigation lights and beacon, electrical fairings and aerials.
for s in (-1,1):
 ellipsoid('Navigation lamp housing',(s*2.54,-.95,2.74),(.11,.15,.065),Rotor)
 ellipsoid('Navigation lamp lens',(s*2.56,-.99,2.78),(.065,.08,.038),Lamps)
 socket('Light_Nav_L' if s==1 else 'Light_Nav_R',(s*2.57,-.98,2.78))
box('Beacon foot',(0,10.38,4.24),(.13,.16,.055),Rotor,.02)
ellipsoid('Anti collision beacon',(0,10.38,4.30),(.055,.065,.07),Lamps)
socket('Light_Beacon',(0,10.38,4.30))
for x in (-.27,.27):
 ellipsoid('Nose landing lamp recess',(x,-4.29,1.32),(.13,.075,.11),Rubber)
 ellipsoid('Nose landing lamp glass',(x,-4.355,1.32),(.092,.022,.078),Lamps)
# Ventral and roof antennas.
panel('Dorsal antenna',[(0,2.4,3.1),(0,2.49,3.54),(0,2.63,3.53),(0,2.69,3.1)],Rotor,.035,.008)
panel('Nose roof antenna',[(0,-1.36,3.37),(0,-1.78,3.79),(0,-1.17,3.46)],Paint,.025,.008)
tube('Tail antenna cable',[(.19,4.0,1.34),(.25,5.45,1.19),(.23,7.6,1.32)],.012,Metal)
for y in (3.9,6.1,7.5):box('Tail antenna anchor',(.19,y,1.49),(.09,.11,.16),Paint,.016)
# Panel seam networks with rivet rows: calm broad panels, local construction detail.
for s in (-1,1):
 # Aft maintenance hatch, raised lip around inset dark line.
 for yy,w,h in ((2.43,.57,.81),(3.0,.32,.45)):
  xx=s*(1.18 if yy<2.6 else 1.03)
  pts=[(xx,yy-w/2,1.61),(xx,yy+w/2,1.61),(xx,yy+w/2,1.61+h),(xx,yy-w/2,1.61+h)]
  panel('Flush maintenance hatch',pts,Paint,.012,.014);tube('Recessed hatch seam',pts,.009,Rubber,True)
  box('Hatch latch',(xx+s*.025,yy,1.72),(.027,.11,.05),Metal,.008)
 # skin rivets follow the long lower cabin sill and crown.
 for y in np.arange(-2.05,2.0,.115):
  xx=s*(1.25 if y>-2.2 else 1.20)
  for z in (1.14,2.98):
   # low-profile hemispherical rivets, not large bolts
   ellipsoid('Skin flush rivet',(xx,y,z),(.007,.013,.013),Metal,8,4)
 # Door rivets belong to door so they travel with it.
 GROUP='Door_L' if s==1 else 'Door_R'
 for y in np.arange(-.65,1.73,.13):
  for z in (1.10,1.83,2.85):ellipsoid('Door rivet',(s*1.369,y,z),(.006,.012,.012),Metal,8,4)
 GROUP='Fuselage'
 # boom panel bands and top access covers.
 for y in (4.25,5.65,7.0,8.15):
  t=(y-4.25)/4;w=.63*(1-t)+.25*t;bot=1.32+.25*t;top=2.44-.34*t
  pts=[(s*w*math.cos(a),y,(bot+top)/2+(top-bot)*.5*math.sin(a)) for a in np.linspace(-math.pi/2,math.pi/2,22)]
  tube('Tail service joint',pts,.0065,Rubber)
 for y in (3.36,4.55,6.05):box('Tail access cap',(s*.21,y,2.49-(y-3.36)*.105),(.25,.35,.025),Paint,.025)
 # cabin top ventilation louvres.
 for yy in np.arange(2.31,2.82,.082):box('Vent louvre',(s*1.12,yy,2.76),(.03,.037,.19),Rotor,.01)
# Windscreen wipers and pivots.
for s in (-1,1):
 cyl('Wiper pivot',(s*.63,-3.51,2.01),(s*.63,-3.54,2.06),.034,Metal,16)
 tube('Wiper arm',[(s*.63,-3.54,2.06),(s*.43,-3.19,2.46),(s*.46,-2.95,2.71)],.012,Metal)
 tube('Wiper blade',[(s*.25,-3.26,2.44),(s*.62,-3.12,2.52)],.018,Rubber)

print('GEOMETRY_BUILT',len(bpy.data.objects),'elapsed',time.time()-START,flush=True)
# Apply all transforms and explicit metre-scaled tiled UVs, then collapse rigid assemblies.
def make_uv(o):
 active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 me=o.data;uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers.active
 # UV coordinates use dominant axis per polygon, shared 1.6m tile. No huge unique atlas.
 for p in me.polygons:
  n=p.normal;ax=max(range(3),key=lambda i:abs(n[i]));axes=[i for i in range(3) if i!=ax]
  for li in p.loop_indices:
   co=o.matrix_world @ me.vertices[me.loops[li].vertex_index].co
   uv.data[li].uv=(co[axes[0]]/1.6,co[axes[1]]/1.6)

def merge(name,objects,origin):
 for o in objects:make_uv(o)
 bpy.ops.object.select_all(action='DESELECT')
 for o in objects:o.select_set(True)
 bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 sc.cursor.location=origin;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 # Triangulate deterministically and discard tiny numerical degenerate faces.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=bm.faces[:]);tiny=[f for f in bm.faces if f.calc_area()<1e-11]
 if tiny:bmesh.ops.delete(bm,geom=tiny,context='FACES')
 bm.to_mesh(o.data);bm.free();return o
ORIGINS={'Fuselage':(0,0,0),'MainRotor':hub,'TailRotor':tuple(tailhub),'Door_L':(1.325,-.74,2.95),'Door_R':(-1.325,-.74,2.95)}
ASSEMBLIES=[merge(name,obs,ORIGINS[name]) for name,obs in GROUPS.items()]
tri=sum(len(o.data.polygons) for o in ASSEMBLIES)
if tri>135000:
 body=next(o for o in ASSEMBLIES if o.name=='Fuselage');other=tri-len(body.data.polygons)
 active(body);dec=body.modifiers.new('Game geometry budget','DECIMATE');dec.ratio=(132000-other)/len(body.data.polygons);dec.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=dec.name)
 bm=bmesh.new();bm.from_mesh(body.data);tiny=[f for f in bm.faces if f.calc_area()<1e-11]
 if tiny:bmesh.ops.delete(bm,geom=tiny,context='FACES')
 bm.to_mesh(body.data);bm.free()
 tri=sum(len(o.data.polygons) for o in ASSEMBLIES)
assert 60000<=tri<=140000,tri
print('TRIANGLES',tri,flush=True)
# Add semantic metadata to exported nodes.
for o in ASSEMBLIES:
 if o.name.startswith('Door_'):o['motion']='slide';o['slide_axis']=[0,0,-1];o['slide_distance_m']=2.45
 if o.name=='MainRotor':o['spin_axis']=[0,1,0]
 if o.name=='TailRotor':o['spin_axis']=[tailaxis.x,tailaxis.z,-tailaxis.y]
# Export uncompressed interoperable GLB and inject the exact neutral paint tint contract.
bpy.ops.object.select_all(action='DESELECT')
for o in ASSEMBLIES+SOCKETS:o.select_set(True)
OUT=ROOT/'blackhawk-helicopter.glb'
bpy.ops.export_scene.gltf(filepath=str(OUT),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_extras=True,export_image_format='AUTO',export_cameras=False,export_lights=False)
raw=OUT.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);rest=raw[20+jl:]
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.08,.09,.07,1];m['pbrMetallicRoughness']['metallicFactor']=0;m.get('extensions',{}).pop('KHR_materials_clearcoat',None)
 if m['name']=='Glass':m['alphaMode']='BLEND'
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);OUT.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(rest))+struct.pack('<II',len(j),0x4e4f534a)+j+rest)
assert OUT.stat().st_size<12*1024*1024,OUT.stat().st_size
paints=[{'name':'Black olive (default)','linear_rgb':[.08,.09,.07]},{'name':'Air-superiority light grey','linear_rgb':[.43,.47,.49]},{'name':'Dark grey','linear_rgb':[.12,.135,.145]},{'name':'Navy blue-grey','linear_rgb':[.085,.14,.19]},{'name':'Desert sand','linear_rgb':[.48,.36,.22]},{'name':'Black','linear_rgb':[.018,.021,.023]}]
def glpos(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
pts=[o.matrix_world@v.co for o in ASSEMBLIES for v in o.data.vertices];gpts=[glpos(p) for p in pts]
manifest={'build_source_sha256':BUILD_SOURCE_SHA256,'asset':'UH-60 style utility helicopter','units':'metres','axes':{'up':'+Y','nose':'+Z','left':'+X'},'nodes':[{'name':o.name,'world_position':glpos(o.matrix_world.translation),'kind':'mesh' if o.type=='MESH' else 'socket'} for o in ASSEMBLIES+SOCKETS], 'bounding_box':{'min':[min(p[i] for p in gpts) for i in range(3)],'max':[max(p[i] for p in gpts) for i in range(3)]},'dimensions_nominal':{'fuselage_length':15.3,'rotor_diameter':16.4,'rotors_turning_length':19.8,'height':5.1},'rotors':{'MainRotor':{'axis':[0,1,0],'pivot':glpos(Vector(hub))},'TailRotor':{'axis':glpos(tailaxis),'pivot':glpos(tailhub),'cant_degrees':20}},'doors':{'Door_L':{'slide_axis':[0,0,-1],'travel':2.45},'Door_R':{'slide_axis':[0,0,-1],'travel':2.45}},'paints':paints,'triangles':tri,'materials':[m['name'] for m in doc['materials']],'glb_bytes':OUT.stat().st_size,'bakes':{'engine':'Cycles','device':prefs.compute_device_type,'samples':4,'base':'DIFFUSE COLOR 128-1024','normal':'NORMAL tangent 128-1024','ORM':'EMIT packed 128-512','tile_metres':1.6,'sources':['blue_metal_plate','metal_plate','fabric_leather_01']}}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('\n'.join([
'UH-60 Black Hawk-style utility helicopter, metre scale, opening sliding doors, four-blade rotors, cockpit and 11 troop seats; no markings.',
f'{tri:,} triangles; {OUT.stat().st_size/1024/1024:.2f} MiB GLB; five named mesh assemblies and gameplay sockets.',
'Materials: Paint, Metal, Rubber, Interior, Rotor, Glass (BLEND), Lamps; neutral Paint tile with linear default [0.08, 0.09, 0.07].',
'Textures: seven shared Cycles-baked tiling sets, 128-1024 base/normal, 128-512 ORM; 1.6 m tile; no clearcoat.',
'Poly Haven CC0: blue_metal_plate, metal_plate, fabric_leather_01 — https://polyhaven.com/license ; sources https://polyhaven.com/a/<id>.',
'Differences: reference-inspired utility fit; no optional rocket/missile payload; simplified internal avionics and engine mechanisms; baked wear is tiled, panel seams/rivets are geometry.'
])+'\n')
print('FIRST_COMPLETE_GLB',OUT.stat().st_size,'elapsed',time.time()-START,flush=True)
# Render from the exact exported material setup, with matching paint multiplication.
bs=Paint.node_tree.nodes.get('Principled BSDF');tex=Paint.node_tree.nodes.get('Baked Base');tint=Paint.node_tree.nodes.new('ShaderNodeMixRGB');tint.name='Game paint factor';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;tint.inputs[2].default_value=(.08,.09,.07,1);Paint.node_tree.links.new(tex.outputs['Color'],tint.inputs[1]);Paint.node_tree.links.new(tint.outputs[0],bs.inputs['Base Color'])
# Neutral daylight studio floor only in renders.
floor=bpy.data.materials.new('Studio only');floor.diffuse_color=(.24,.265,.29,1);floor.use_nodes=True;floor.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.24,.265,.29,1);floor.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.9
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.017));ground=bpy.context.object;ground.name='Render ground (not exported)';ground.data.materials.append(floor)
sc.world.use_nodes=True;sc.world.node_tree.nodes.get('Background').inputs[0].default_value=(.65,.74,.85,1);sc.world.node_tree.nodes.get('Background').inputs[1].default_value=.6

def area(name,loc,power,size,color):
 dat=bpy.data.lights.new(name,'AREA');dat.energy=power;dat.shape='DISK';dat.size=size;dat.color=color;o=bpy.data.objects.new(name,dat);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,1,1.8))-o.location).to_track_quat('-Z','Y').to_euler()
area('Large soft daylight',(3,-8,14),2400,9,(1,.94,.86));area('Cool sky fill',(-9,-1,8),1800,10,(.75,.85,1));area('Aft rim',(2,11,11),2900,8,(1,.97,.9))
dat=bpy.data.lights.new('Daylight sun','SUN');dat.energy=1.35;dat.angle=.15;o=bpy.data.objects.new('Daylight sun',dat);sc.collection.objects.link(o);o.rotation_euler=(.35,-.4,-.35)
camdat=bpy.data.cameras.new('Render camera');cam=bpy.data.objects.new('Render camera',camdat);sc.collection.objects.link(cam);sc.camera=cam;camdat.type='ORTHO'
def camera(loc,target,scale):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camdat.ortho_scale=scale
# Fit from geometry projected onto desired camera, retaining useful frame margin.
center=Vector((0,2.1,2.0));direction=Vector((1.15,-1.65,.67)).normalized()
cam.location=center+direction*35;cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler()
bpy.context.view_layer.update();inv=cam.matrix_world.inverted();localpts=[inv@p for p in pts]
spanx=max(p.x for p in localpts)-min(p.x for p in localpts);spany=max(p.y for p in localpts)-min(p.y for p in localpts)
hero_scale=max(spanx,spany*1920/1080)*1.12
mid_x=(max(p.x for p in localpts)+min(p.x for p in localpts))*.5
mid_y=(max(p.y for p in localpts)+min(p.y for p in localpts))*.5
shift=cam.matrix_world.to_quaternion()@Vector((mid_x,mid_y,0))
center+=shift;cam.location+=shift
hero_loc=cam.location.copy();hero_target=center.copy();camera(hero_loc,hero_target,hero_scale)
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=65 if DRAFT else 100;sc.cycles.samples=24 if DRAFT else 48
sc.render.filepath=str(ROOT/'final.png');bpy.ops.render.render(write_still=True)
# Clay uses material override, no texture trickery.
clay=bpy.data.materials.new('Clay QA');clay.use_nodes=True;cb=clay.node_tree.nodes.get('Principled BSDF');cb.inputs['Base Color'].default_value=(.42,.42,.42,1);cb.inputs['Roughness'].default_value=.72
sc.view_layers[0].material_override=clay;sc.render.filepath=str(ROOT/'clay.png');bpy.ops.render.render(write_still=True);sc.view_layers[0].material_override=None
camera((28,1.7,2.6),(0,1.7,2.6),21.5);sc.render.filepath=str(ROOT/'side.png');bpy.ops.render.render(write_still=True)
camera(hero_loc,hero_target,hero_scale)
# 3 by 2 recolour proof, same view and lighting. Assemble using Blender pixel buffers.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.render.resolution_percentage=100;sc.cycles.samples=12
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,p in enumerate(paints):
 tint.inputs[2].default_value=tuple(p['linear_rgb'])+(1,);sc.render.filepath=str(CACHE/f'paint_{i}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(sc.render.filepath,check_existing=False);arr=np.array(im.pixels[:],dtype=np.float32).reshape((360,640,4));row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=arr;bpy.data.images.remove(im)
out=bpy.data.images.new('Six paints',1920,720,alpha=True);out.pixels.foreach_set(canvas.ravel());out.filepath_raw=str(ROOT/'paints.png');out.file_format='PNG';out.save()
tint.inputs[2].default_value=(.08,.09,.07,1)
print('BUILD_COMPLETE',time.time()-START,flush=True)
