"""Reproducible F/A-18C-style asset. Blender 5.2; run with --python-exit-code 1.
Axes while authoring: X aircraft left, -Y nose, Z up. glTF converts to Y-up.
Scope: 60-160k triangles, <=8 materials, 1024 colour/normal, 512 ORM, <=12MB.
Camera gates: front three-quarter, orthographic side, clay, six paint variants.
Three primary revisions plus final inspection repairs. PH blue_metal_plate is CC0: https://polyhaven.com/a/blue_metal_plate
"""
import bpy, math, os, json, struct, subprocess, sys, hashlib
from mathutils import Vector, Matrix
from math import sin,cos,pi
from pathlib import Path
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures'; TEX.mkdir(exist_ok=True)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
for m in list(bpy.data.materials): bpy.data.materials.remove(m)
sc=bpy.context.scene; sc.render.engine='CYCLES'; sc.cycles.samples=24
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices: d.use=d.type in {'OPTIX','CUDA'}
sc.cycles.device='GPU'; sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG'; sc.render.film_transparent=False
sc.view_settings.view_transform='AgX'; sc.view_settings.exposure=-.35; sc.render.bake.margin=8
sc.world.color=(.35,.35,.35)
PH={}
for kind in ('diff','nor_gl','arm'):
 p=TEX/f'blue_metal_plate_{kind}_1k.jpg'
 if not p.exists(): subprocess.run(['curl','-fL','--retry','3','-sS',f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/blue_metal_plate/blue_metal_plate_{kind}_1k.jpg','-o',str(p)],check=True)
 PH[kind]=bpy.data.images.load(str(p),check_existing=True)
 if kind!='diff': PH[kind].colorspace_settings.name='Non-Color'

def mat_simple(name,col,rough=.5,metal=0):
 m=bpy.data.materials.new(name);m.use_nodes=True;p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*col,1);p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal
 return m

def bake_mat(name,col,rough,metal,normal_strength):
 m=mat_simple(name,col,rough,metal);nt=m.node_tree;n=nt.nodes;l=nt.links;p=n.get('Principled BSDF');out=n.get('Material Output')
 uv=n.new('ShaderNodeTexCoord');diff=n.new('ShaderNodeTexImage');diff.image=PH['diff'];l.new(uv.outputs['UV'],diff.inputs['Vector'])
 bw=n.new('ShaderNodeRGBToBW');l.new(diff.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.03;ramp.color_ramp.elements[1].position=.65
 low=.90 if name=='Paint' else .58
 ramp.color_ramp.elements[0].color=(*(v*low for v in col),1);ramp.color_ramp.elements[1].color=(*col,1);l.new(bw.outputs[0],ramp.inputs[0])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=38;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.10;l.new(ramp.outputs[0],mix.inputs[1]);l.new(noise.outputs['Fac'],mix.inputs[2]);l.new(mix.outputs[0],p.inputs['Base Color'])
 # Layer coarse salt/grime mottling and narrow rain streaks into the neutral tile.
 if name=='Paint':
  broad=n.new('ShaderNodeTexNoise');broad.inputs['Scale'].default_value=5.5;broad.inputs['Detail'].default_value=5;broad.inputs['Roughness'].default_value=.72;l.new(uv.outputs['UV'],broad.inputs['Vector'])
  stain=n.new('ShaderNodeValToRGB');stain.color_ramp.elements[0].position=.27;stain.color_ramp.elements[0].color=(.40,.40,.40,1);stain.color_ramp.elements[1].position=.68;stain.color_ramp.elements[1].color=(1,1,1,1);l.new(broad.outputs['Fac'],stain.inputs[0])
  weather=n.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=.47;l.new(mix.outputs[0],weather.inputs[1]);l.new(stain.outputs[0],weather.inputs[2]);mix=weather
  stretch=n.new('ShaderNodeVectorMath');stretch.operation='MULTIPLY';stretch.inputs[1].default_value=(32,2,1);l.new(uv.outputs['UV'],stretch.inputs[0])
  streak=n.new('ShaderNodeTexNoise');streak.inputs['Scale'].default_value=3;streak.inputs['Detail'].default_value=2;l.new(stretch.outputs[0],streak.inputs['Vector'])
  sr=n.new('ShaderNodeValToRGB');sr.color_ramp.elements[0].position=.29;sr.color_ramp.elements[0].color=(.55,.55,.55,1);sr.color_ramp.elements[1].position=.48;l.new(streak.outputs['Fac'],sr.inputs[0])
  weather=n.new('ShaderNodeMixRGB');weather.blend_type='MULTIPLY';weather.inputs[0].default_value=.24;l.new(mix.outputs[0],weather.inputs[1]);l.new(sr.outputs[0],weather.inputs[2]);mix=weather
  l.new(mix.outputs[0],p.inputs['Base Color'])
 normal=n.new('ShaderNodeTexImage');normal.image=PH['nor_gl'];nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=normal_strength;l.new(normal.outputs['Color'],nm.inputs['Color']);l.new(nm.outputs['Normal'],p.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=PH['arm'];sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs['Color'],sep.inputs[0])
 rr=n.new('ShaderNodeMapRange');rr.inputs['To Min'].default_value=rough-.06;rr.inputs['To Max'].default_value=min(1,rough+.06);l.new(sep.outputs['Green'],rr.inputs['Value'])
 l.new(rr.outputs[0],p.inputs['Roughness'])
 bpy.ops.mesh.primitive_plane_add(size=2);plane=bpy.context.object;plane.name='BakeFixture';plane.data.materials.append(m)
 target=n.new('ShaderNodeTexImage');n.active=target
 imgs={}
 for typ,res in [('base',1024),('normal',1024),('orm',512)]:
  img=bpy.data.images.new(name+'_'+typ,width=res,height=res,alpha=False);target.image=img
  if typ!='base':img.colorspace_settings.name='Non-Color'
  if typ=='normal':
   l.new(p.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
  else:
   emit=n.new('ShaderNodeEmission')
   if typ=='base':l.new(mix.outputs[0],emit.inputs['Color'])
   else:
    comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1;comb.inputs[2].default_value=metal;l.new(rr.outputs[0],comb.inputs[1]);l.new(comb.outputs[0],emit.inputs[0])
   l.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');n.remove(emit)
  img.filepath_raw=str(TEX/(name+'_'+typ+('.jpg' if typ=='base' else '.png')));img.file_format='JPEG' if typ=='base' else 'PNG';img.save();imgs[typ]=img
 bpy.data.objects.remove(plane,do_unlink=True)
 n.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
 b=n.new('ShaderNodeTexImage');b.image=imgs['base'];l.new(b.outputs[0],p.inputs['Base Color'])
 if name=='Paint':
  mult=n.new('ShaderNodeMixRGB');mult.name='PaintTint';mult.blend_type='MULTIPLY';mult.inputs[0].default_value=1;mult.inputs[2].default_value=(.38,.40,.43,1);l.new(b.outputs[0],mult.inputs[1]);l.new(mult.outputs[0],p.inputs['Base Color'])
 no=n.new('ShaderNodeTexImage');no.image=imgs['normal'];nn=n.new('ShaderNodeNormalMap');l.new(no.outputs[0],nn.inputs[1]);l.new(nn.outputs[0],p.inputs['Normal'])
 a=n.new('ShaderNodeTexImage');a.image=imgs['orm'];s=n.new('ShaderNodeSeparateColor');l.new(a.outputs[0],s.inputs[0]);l.new(s.outputs[1],p.inputs['Roughness']);l.new(s.outputs[2],p.inputs['Metallic'])
 # Official glTF occlusion socket.
 group=bpy.data.node_groups.get('glTF Material Output')
 if not group:
  group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
 gn=n.new('ShaderNodeGroup');gn.node_tree=group;l.new(s.outputs[0],gn.inputs['Occlusion'])
 return m

sc.cycles.samples=8
Paint=bake_mat('Paint',(1,1,1),.68,0,.14)
if '--probe' in sys.argv:
 bpy.ops.mesh.primitive_uv_sphere_add();bpy.context.object.data.materials.append(Paint)
 bpy.ops.export_scene.gltf(filepath=str(ROOT/'probe.glb'),export_format='GLB')
 bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
 bpy.data.node_groups.remove(bpy.data.node_groups['glTF Material Output'],do_unlink=True)
 bpy.ops.import_scene.gltf(filepath=str(ROOT/'probe.glb'))
 print('PROBE_OK',len(bpy.context.selected_objects));sys.exit(0)
Metal=bake_mat('Metal',(.29,.30,.31),.34,.82,.3)
Rubber=bake_mat('Rubber',(.026,.029,.032),.86,0,.4)
Interior=bake_mat('Interior',(.065,.075,.080),.66,.15,.18)
Glass=mat_simple('Glass',(.17,.23,.27),.085,.1)
p=Glass.node_tree.nodes.get('Principled BSDF');p.inputs['Transmission Weight'].default_value=.52;p.inputs['IOR'].default_value=1.45;p.inputs['Alpha'].default_value=.82
Glass.surface_render_method='DITHERED';Glass.diffuse_color=(.17,.23,.27,.82)
Lamps=mat_simple('Lamps',(.65,.8,.78),.21,.1);p=Lamps.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Color'].default_value=(.25,.48,.4,1);p.inputs['Emission Strength'].default_value=2
MATS=[Paint,Metal,Rubber,Interior,Glass,Lamps]
G={}; pivots={}
def register(o,group,mat,smooth=False):
 o.data.materials.append(mat);G.setdefault(group,[]).append(o)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 return o

def mesh(name,verts,faces,group='Fuselage',mat=Paint,smooth=False,bevel=0):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update();o=bpy.data.objects.new(name,me);sc.collection.objects.link(o);register(o,group,mat,smooth)
 # Recalculate outward winding consistently.
 import bmesh
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 if bevel:
  mod=o.modifiers.new('Manufactured edge radii','BEVEL');mod.width=bevel;mod.segments=3
 return o

def box(name,loc,size,group='Fuselage',mat=Paint,bev=.025):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.scale=size;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);register(o,group,mat)
 if bev:
  b=o.modifiers.new('Edge roundover','BEVEL');b.width=bev;b.segments=3
 return o

def ell(name,loc,scale,group='Fuselage',mat=Paint,seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);return register(o,group,mat,True)

def rod(name,a,b,r,group='Fuselage',mat=Metal,verts=16):
 a,b=Vector(a),Vector(b);bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=r,depth=(b-a).length,location=(a+b)*.5);o=bpy.context.object;o.name=name;o.rotation_euler=(b-a).to_track_quat('Z','Y').to_euler();register(o,group,mat,True)
 be=o.modifiers.new('Machined rims','BEVEL');be.width=min(.008,r*.16);be.segments=2
 return o

def line(name,pts,r=.0038,group='Fuselage',mat=Interior):
 c=bpy.data.curves.new(name,'CURVE');c.dimensions='3D';c.resolution_u=1;c.bevel_depth=r;c.bevel_resolution=1;s=c.splines.new('POLY');s.points.add(len(pts)-1)
 for p,co in zip(s.points,pts):p.co=(*co,1)
 o=bpy.data.objects.new(name,c);sc.collection.objects.link(o);o.data.materials.append(mat);G.setdefault(group,[]).append(o);return o

def panel(name,poly,t=.08,group='Fuselage',mat=Paint,bev=.02):
 n=len(poly);v=[(x,y,z+t/2) for x,y,z in poly]+[(x,y,z-t/2) for x,y,z in poly];f=[tuple(range(n)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 return mesh(name,v,f,group,mat,False,bev)

def foil_panel(name,poly,t,group):
 center=sum((Vector(p) for p in poly),Vector())/len(poly);n=len(poly);factors=[1,.93,.68,.32,0];vs=[]
 for sign in [1,-1]:
  for fac in factors:
   for p in poly:
    co=center+(Vector(p)-center)*fac;co.z+=sign*t*.5*cos(fac*pi/2);vs.append(tuple(co))
 nr=len(factors);fs=[]
 for side in [0,1]:
  off=side*nr*n
  for ring in range(nr-1):
   for i in range(n):fs.append((off+ring*n+i,off+ring*n+(i+1)%n,off+(ring+1)*n+(i+1)%n,off+(ring+1)*n+i))
 return mesh(name,vs,fs,group,Paint,True)

# Longitudinal loft: measured full-length silhouette, flattened centrebody.
sections=[(-11.2,.012,.015,1.78),(-10.9,.18,.14,1.80),(-10.3,.36,.27,1.84),(-9.5,.48,.40,1.9),(-8.7,.55,.46,1.95),(-7.7,.62,.48,2.0),(-6.5,.69,.51,2.02),(-5.2,.75,.56,2.06),(-3.8,.9,.58,2.06),(-2.5,1.13,.6,2.04),(-1,1.25,.59,2.02),(1,1.30,.55,2.03),(2.6,1.21,.50,2.01),(4.4,1.12,.43,1.99),(5.34,1.05,.40,1.97)]
def section(y):
 for k in range(len(sections)-1):
  if sections[k][0]<=y<=sections[k+1][0]:
   a,b=sections[k],sections[k+1];u=(y-a[0])/(b[0]-a[0]);return tuple(a[j]*(1-u)+b[j]*u for j in range(1,4))
 return sections[0][1:] if y<sections[0][0] else sections[-1][1:]
ys=np.linspace(-11.2,5.34,155);N=80
v=[]
for y in ys:
 w,h,z=section(y)
 for i in range(N):
  a=2*pi*i/N;v.append((w*cos(a),float(y),z+h*sin(a)))
f=[(j*N+i,j*N+(i+1)%N,(j+1)*N+(i+1)%N,(j+1)*N+i) for j in range(len(ys)-1) for i in range(N)]
f.extend([tuple(reversed(range(N))),tuple((len(ys)-1)*N+i for i in range(N))]);mesh('Continuous semi-monocoque hull',v,f,smooth=True)
# Panel breaks follow the actual fuselage curvature.
for y in [-9.5,-8.65,-7.9,-6.6,-5.4,-4.4,-3.25,-1.9,-.45,1.0,2.45,3.75,4.75]:
 w,h,z=section(y);line('Circumferential service seam',[(w*1.001*cos(a),y,z+h*1.002*sin(a)) for a in np.linspace(0,2*pi,97)])
for ang in [0,.55,-.60,pi,pi-.55,pi+.60]:
 line('Longeron seam',[(section(float(y))[0]*1.002*cos(ang),float(y),section(float(y))[2]+section(float(y))[1]*1.003*sin(ang)) for y in np.linspace(-9.4,5.1,100)],.004)
for side in [0,pi]:
 for yy,hh,ww in [(-8.25,.33,.30),(-7.3,.43,.32),(-4.9,.36,.34),(-3.5,.30,.29)]:
  pts=[]
  for a in np.linspace(0,2*pi,25):
   y=yy+hh*math.copysign(abs(cos(a))**.4,cos(a));ang=side+ww*math.copysign(abs(sin(a))**.4,sin(a));w,h,z=section(y);pts.append((w*1.002*cos(ang),y,z+h*1.002*sin(ang)))
  line('Flush avionics access hatch',pts,.0025)
# Nose radome tip and recessed gun vents.
ell('Dielectric nose cap', (0,-10.98,1.785),(.15,.235,.12),mat=Paint)
for s in [-1,1]:
 for j in range(4):
  y=-8.85+j*.12;w,h,z=section(y);box('Nose cooling vent',(s*.23,y,z+h*.91),(.15,.038,.012),mat=Interior,bev=.006)
 for y in [-8.4,-7.6]:
  w,h,z=section(y);ell('Angle of attack fitting',(s*w,y,z+.09),(.055,.13,.044),mat=Metal,seg=16,rings=8)
# Cockpit longitudinal placement measured from the side reference.
def cockpit_y(o):
 o.matrix_world=Matrix.Translation((0,1.2792,0))@Matrix.Diagonal((1,1.12,1,1))@o.matrix_world
cockpit_start=len(G['Fuselage'])
# Canopy is an open-bottom curved shell over a visible cockpit.
# Mask central hull with cockpit coaming; small upper hull opening is physically cut.
# Boolean cockpit well before detail consolidation.
hull=G['Fuselage'][0]
cut=box('Cockpit cutting volume',(0,-6.73,2.68),(.92,2.32,.86),mat=Interior,bev=.20)
cockpit_y(cut)
bpy.context.view_layer.objects.active=cut;cut.select_set(True)
for mo in list(cut.modifiers):bpy.ops.object.modifier_apply(modifier=mo.name)
mod=hull.modifiers.new('Recessed cockpit tub','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cut
bpy.context.view_layer.objects.active=hull;bpy.ops.object.modifier_apply(modifier=mod.name)
G['Fuselage'].remove(cut);bpy.data.objects.remove(cut,do_unlink=True)
box('Cockpit tub',(0,-6.7,2.18),(.87,2.23,.10),mat=Interior)
box('Ejection seat pan',(0,-6.24,2.32),(.53,.52,.11),mat=Interior)
seat=box('Ejection seat back',(0,-5.99,2.55),(.52,.16,.64),mat=Interior,bev=.06);seat.rotation_euler.x=math.radians(-12)
box('Headrest',(0,-5.91,2.82),(.32,.19,.2),mat=Rubber,bev=.05)
for s in [-1,1]:
 rod('Ejection seat rail',(s*.27,-5.90,2.15),(s*.27,-5.92,2.84),.028,mat=Metal)
 line('Harness webbing',[(s*.13,-6.09,2.85),(s*.14,-6.18,2.62),(s*.19,-6.43,2.41)],.035,mat=Rubber)
 box('Side console',(s*.36,-6.76,2.31),(.15,1.0,.14),mat=Interior)
box('Instrument shroud',(0,-7.4,2.47),(.70,.30,.24),mat=Interior,bev=.075)
for x in [-.22,0,.22]:box('MFD dark screen',(x,-7.235,2.49),(.16,.012,.15),mat=Glass,bev=.015)
rod('Flight stick',(0,-6.8,2.22),(0,-6.83,2.46),.035,mat=Interior)
# Canopy profile, windshield and opening hood with rear hinge.
cp=[(-8.16,.04,2.40,.025),(-7.93,.25,2.43,.23),(-7.54,.40,2.46,.45),(-7.19,.45,2.48,.54),(-6.55,.46,2.49,.58),(-5.95,.40,2.51,.52),(-5.48,.26,2.55,.28),(-5.19,.025,2.59,.02)]
def canopy_piece(rows,group):
 vs=[];ns=40
 for y,w,z,h in rows:
  vs.extend([(w*cos(a),y,z+h*sin(a)) for a in np.linspace(0,pi,ns)])
 fs=[(j*ns+i,j*ns+i+1,(j+1)*ns+i+1,(j+1)*ns+i) for j in range(len(rows)-1) for i in range(ns-1)]
 return mesh('Optical canopy glazing',vs,fs,group,Glass,True)
# interpolate canopy rows for smoothness
rows=[]
for i in range(len(cp)-1):
 for t in np.linspace(0,1,8,endpoint=False):rows.append(tuple(cp[i][j]*(1-t)+cp[i+1][j]*t for j in range(4)))
rows.append(cp[-1]);canopy_piece(rows[:25],'Fuselage');canopy_piece(rows[24:],'Canopy');pivots['Canopy']=(0,-5.19,2.59)
for idx in [0,2,3,7]:
 y,w,z,h=cp[idx];line('Canopy arch',[(w*cos(a),y,z+h*sin(a)) for a in np.linspace(0,pi,49)],.027,'Fuselage' if idx<3 else 'Canopy',Paint)
for s in [-1,1]:
 line('Windshield sill',[(s*w,y,z) for y,w,z,h in rows[:25]],.028,'Fuselage',Paint)
 line('Opening canopy sill',[(s*w,y,z) for y,w,z,h in rows[24:]],.028,'Canopy',Paint)
rod('Canopy rear hinge',(-.10,-5.19,2.59),(.10,-5.19,2.59),.022,'Canopy',Metal)
for part in G['Fuselage'][cockpit_start:]+G['Canopy']:cockpit_y(part)
pivots['Canopy']=(0,pivots['Canopy'][1]*1.12+1.2792,pivots['Canopy'][2])
# LEX, wings and articulated trailing edges.
for s,label in [(1,'L'),(-1,'R')]:
 def pp(poly):return [(s*x,y,z) for x,y,z in poly]
 lex=[]
 for t in np.linspace(0,1,25):
  a=Vector((.44,-8.05,2.35));b=Vector((.75,-6.2,2.40));c=Vector((.80,-4.1,2.39));d=Vector((2.0,-2.37,2.25));lex.append(tuple((1-t)**3*a+3*(1-t)**2*t*b+3*(1-t)*t*t*c+t**3*d))
 panel('Swept leading edge root extension',pp(lex+[(1.4,-.95,2.2),(.75,-3.0,2.27)]),.075,bev=.025)
 # Continuous thin airfoil rather than an extruded flat plank.
 wingstations=[(1.13,-3.08,.77,2.20,2.14),(2.25,-2.48,1.11,2.18,2.12),(4.2,-1.40,1.28,2.137,2.09),(6.08,-.38,1.30,2.10,2.05)]
 def wing_z(x,y):
  le,te,zle,zte=[float(np.interp(x,[v[0] for v in wingstations],[v[j] for v in wingstations])) for j in range(1,5)]
  t=max(0,min(1,(y-le)/(te-le)))
  return zle*(1-t)+zte*t+.068*sin(pi*t)**.68+.003
 vs=[];chords=25
 for x in np.linspace(1.13,6.08,15):
  le,te,zle,zte=[float(np.interp(x,[v[0] for v in wingstations],[v[j] for v in wingstations])) for j in range(1,5)]
  for sign in [1,-1]:
   for t in (np.linspace(0,1,chords) if sign==1 else np.linspace(1,0,chords)):
    vs.append((s*x,le+(te-le)*t,zle*(1-t)+zte*t+sign*(.068 if sign==1 else .048)*sin(pi*t)**.68))
 nw=2*chords
 fs=[(j*nw+i,j*nw+(i+1)%nw,(j+1)*nw+(i+1)%nw,(j+1)*nw+i) for j in range(14) for i in range(nw)]+[tuple(range(nw)),tuple(range(14*nw,15*nw))]
 mesh('Cambered main wing torsion box',vs,fs,smooth=True)
 panel('Inner trailing flap',pp([(1.38,.79,2.13),(3.60,1.24,2.08),(3.65,2.07,1.99),(1.48,1.62,2.03)]),.09,'Flap_'+label,bev=.021);pivots['Flap_'+label]=(s*1.38,.79,2.13)
 panel('Outboard aileron',pp([(3.68,1.27,2.08),(6.06,1.32,2.05),(6.07,1.86,2.00),(3.75,2.08,1.99)]),.065,'Aileron_'+label,bev=.015);pivots['Aileron_'+label]=(s*3.68,1.27,2.08)
 # Leading edge flap seam and structural folding joint, no fictional extra movable node.
 line('Leading edge hinge',pp([(x,y,wing_z(x,y)) for x,y in [(1.5,-2.75),(2.3,-2.24),(5.98,-.10)]]),.006)
 line('Wing fold separation',pp([(4.72,float(y),wing_z(4.72,float(y))) for y in np.linspace(-1.04,1.29,15)]),.009)
 for y in [-.8,-.48,.55,.91]:
  rod('Fold hinge barrel',(s*4.69,y,wing_z(4.69,y)+.011),(s*4.78,y,wing_z(4.78,y)+.011),.032,mat=Metal)
 for x in [2.05,2.85,3.7,4.7,5.65]:
  yle=-3.08+(x-1.13)*2.70/4.86
  line('Wing rib panel seam',pp([(x,float(y),wing_z(x,float(y))) for y in np.linspace(yle+.25,1.19,12)]),.0045)
 for y in [-.25,.48]:
  line('Wing spar seam',pp([(float(x),y+.05*(x-2.25),wing_z(float(x),y+.05*(x-2.25))) for x in np.linspace(2.25,5.9,20)]),.004)
 # Wingtip launch rails stop at the exact span.
 box('Wingtip rail',(s*6.075,.62,2.105),(.12,2.60,.08),bev=.015)
 panel('Tapered launch rail nose',pp([(6.02,-.66,2.105),(6.075,-.90,2.105),(6.13,-.66,2.105)]),.065,bev=.012)
 line('Launch rail mounting channel',[(s*6.136,-.50,2.105),(s*6.136,1.82,2.105)],.009,mat=Interior)
 for x,y in [(2.2,-.1),(3.35,.28)]:
  panel('Empty stores pylon',pp([(x-.04,y-.70,2.11),(x+.04,y-.68,2.11),(x+.04,y+.82,1.59),(x-.04,y+.78,1.59)]),.095,bev=.02)
  box('Pylon lower rail',(s*x,y+.12,1.58),(.14,1.31,.09),bev=.02)
 # Stabilators separate all-moving tailplane.
 tail=pp([(.88,3.0,2.02),(1.40,2.84,2.06),(3.65,4.46,1.99),(3.55,5.42,1.91),(2.96,5.58,1.92),(1.03,4.67,1.99)])
 foil_panel('All moving stabilator',tail,.15,'Stabilator_'+label);pivots['Stabilator_'+label]=(s*1.03,3.86,2.02)
 rod('Stabilator pivot cuff',(s*1.01,3.86,2.02),(s*1.39,3.86,2.02),.115,'Stabilator_'+label,Paint,32)
 # Fin surfaces are thick 3D extrusions around swept/canted planform.
 def finpoint(y,z):return (s*(.80+(z-2.28)*.30),y,z)
 def fin(name,coords,group):
  pts=[finpoint(y,z) for y,z in coords];vs=[(x+s*.044,y,z) for x,y,z in pts]+[(x-s*.044,y,z) for x,y,z in pts];n=len(pts);return mesh(name,vs,[tuple(range(n)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],group,Paint,False,.025)
 fin('Canted vertical stabilizer',[(1.35,2.28),(3.24,4.66),(4.48,4.66),(4.50,4.34),(4.10,4.34),(3.74,2.40)],'Fuselage')
 fin('Rudder trailing surface',[(4.12,4.32),(3.77,2.42),(4.39,2.40),(4.49,4.32)],'Rudder_'+label);pivots['Rudder_'+label]=finpoint(3.77,2.42)
 line('Fin inset perimeter',[(x+s*.047,y,z) for x,y,z in [finpoint(1.68,2.43),finpoint(3.39,4.48),finpoint(3.96,4.48),finpoint(3.62,2.55)]],.005)
 for z in [4.16,4.33,4.50]:
  a=finpoint(4.02,z);b=finpoint(4.47,z);rod('Fin tip antenna fairing',a,b,.030,mat=Paint)
 # Intake hollow lips and deep black compressor ducts, front at -3.85.
 intake_start=len(G['Fuselage'])
 cx=s*1.03;cy=-3.87;cz=1.80
 outline=[(-.39,-.34),(-.31,-.42)]+[(-.10+.58*cos(a),.43*sin(a)) for a in np.linspace(-pi/2,pi/2,19)]+[(-.31,.42),(-.39,.34)]
 nlip=len(outline)
 vs=[]
 for y,scale in [(cy,1),(cy+.16,.91),(cy+1.5,.76)]:
  vs.extend([(cx+s*x*scale,y,cz+z*scale) for x,z in outline])
 fs=[(j*nlip+i,j*nlip+(i+1)%nlip,(j+1)*nlip+(i+1)%nlip,(j+1)*nlip+i) for j in range(2) for i in range(nlip)]
 mesh('Intake inner trunk',vs,fs,mat=Interior,bevel=.025)
 line('Intake rolled lip',[(cx+s*x,cy,cz+z) for x,z in outline+[outline[0]]],.045,mat=Paint)
 mesh('Intake darkness',[(cx+s*x*.76,cy+1.49,cz+z*.76) for x,z in outline],[tuple(range(nlip))],mat=Rubber)
 # exterior duct shoulders run into broad fuselage
 panel('Intake outer cheek',pp([(1.43,-3.87,1.48),(1.50,-3.80,2.21),(1.53,-1.45,2.18),(1.30,1.7,1.97),(1.20,.4,1.42)]),.08,bev=.06)
 box('Splitter plate',(s*.59,-3.84,1.85),(.065,.65,.81),bev=.04)
 for part in G['Fuselage'][intake_start:]:part.location.y+=.60
 # Twin engines and open petal nozzles.
 cx=s*.61
 ell('Blended fin root fairing',(s*.83,3.10,2.33),(.25,1.75,.21),mat=Paint,seg=32,rings=12)
 rings=[(2.6,.56),(3.4,.55),(4.65,.52),(5.24,.49),(5.50,.46),(5.87,.43)]
 vs=[(cx+r*cos(a),y,1.96+r*sin(a)) for y,r in rings for a in np.linspace(0,2*pi,65)[:-1]]
 fs=[(j*64+i,j*64+(i+1)%64,(j+1)*64+(i+1)%64,(j+1)*64+i) for j in range(len(rings)-1) for i in range(64)]
 mesh('Engine nacelle',vs[:4*64],fs[:3*64],mat=Paint,smooth=True)
 for j in range(20):
  a=2*pi*j/20+.009;b=2*pi*(j+1)/20-.009
  vv=[(cx+r*cos(q),y,1.96+r*sin(q)) for y,r in [(5.18,.50),(5.48,.472),(5.84,.435)] for q in [a,(a+b)*.5,b]]
  petal=mesh('Exhaust variable area petal',vv,[(0,1,4,3),(1,2,5,4),(3,4,7,6),(4,5,8,7)],mat=Metal)
  solid=petal.modifiers.new('Petal sheet thickness','SOLIDIFY');solid.thickness=.009
  edge=petal.modifiers.new('Petal rim bevel','BEVEL');edge.width=.003;edge.segments=2
 for y,r in [(5.22,.501),(5.80,.435)]:line('Nozzle retaining ring',[(cx+r*cos(a),y,1.96+r*sin(a)) for a in np.linspace(0,2*pi,81)],.016,mat=Metal)
 vs=[(cx+r*cos(a),y,1.96+r*sin(a)) for y,r in [(5.86,.420),(4.70,.35)] for a in np.linspace(0,2*pi,65)[:-1]]
 mesh('Exhaust dark inner tube',vs,[(i,(i+1)%64,(i+1)%64+64,i+64) for i in range(64)],mat=Interior,smooth=True)
 for j in range(24):
  a=j*2*pi/24;rod('Afterburner flameholder',(cx+.06*cos(a),4.74,1.96+.06*sin(a)),(cx+.33*cos(a+.14),4.74,1.96+.33*sin(a+.14)),.016,mat=Metal,verts=8)
 ell('Exhaust centre cone',(cx,4.70,1.96),(.14,.18,.14),mat=Interior)
 # Main landing gear bays and detailed trailing link legs.
 group='Gear_'+label;pivots[group]=(s*.98,-.67,1.51)
 box('Main gear well',(s*.94,-.15,1.43),(.72,1.52,.09),mat=Interior)
 rod('Main gear shock body',(s*.91,-.67,1.47),(s*1.15,-.33,.76),.095,group,Paint)
 rod('Oleo polished piston',(s*1.15,-.33,.85),(s*1.33,.04,.43),.059,group,Metal)
 rod('Main trailing arm',(s*1.22,-.25,.51),(s*1.51,0,.41),.076,group,Paint)
 rod('Main folding brace',(s*.62,-.69,1.40),(s*1.25,-.08,.55),.037,group,Metal)
 rod('Main lateral brace',(s*.62,.48,1.37),(s*1.23,.12,.67),.043,group,Paint)
 line('Hydraulic hose',[(s*.97,-.68,1.43),(s*1.18,-.40,.87),(s*1.37,-.13,.47)],.015,group,Rubber)
 rod('Main torque link upper',(s*1.19,-.37,.83),(s*1.34,-.50,.61),.033,group,Metal)
 rod('Main torque link lower',(s*1.34,-.50,.61),(s*1.39,-.03,.45),.033,group,Metal)
 door=box('Main gear door',(s*.78,.30,1.10),(.065,1.17,.64),group,Paint,.035);door.rotation_euler.y=s*.22
 for y in [-.11,.68]:rod('Door actuator',(s*.63,y,1.42),(s*.85,y,.86),.024,group,Metal)
 # main wheels
 def wheel(x,y,z,r,width,group):
  bpy.ops.mesh.primitive_torus_add(major_radius=r*.73,minor_radius=r*.27,major_segments=64,minor_segments=16,location=(x,y,z),rotation=(0,pi/2,0));o=bpy.context.object;o.name='Rounded aviation tire';register(o,group,Rubber,True)
  # tread grooves in the crown, and concentric hub rims
  for dx in [-.075,0,.075]:line('Tire longitudinal tread',[(x+dx,y+(r-.009)*cos(a),z+(r-.009)*sin(a)) for a in np.linspace(0,2*pi,97)],.008,group,Interior)
  rod('Wheel hub',(x-width*.51,y,z),(x+width*.51,y,z),r*.51,group,Metal,48)
  for side in [-1,1]:
   xx=x+side*width*.52
   rod('Hub cap',(xx,y,z),(xx+side*.019,y,z),r*.19,group,Paint,32)
   for j in range(10):
    a=2*pi*j/10;yy=y+r*.34*cos(a);zz=z+r*.34*sin(a)
    rod('Recessed hub perforation',(xx,yy,zz),(xx+side*.008,yy,zz),r*.066,group,Interior,12)
  return
 wheel(s*1.53,0,.41,.41,.25,group)
 # Service access plate lines, fasteners on wing root.
 for yy in [-2.7,-1.5,.3,1.8,3.0]:
  w,h,z=section(yy)
  for ang in [.20,.75,2.39,2.94]:
   if (s==1 and cos(ang)<0) or (s==-1 and cos(ang)>0):continue
   p=(w*1.005*cos(ang),yy,z+h*1.005*sin(ang));ell('Flush skin fastener',p,(.012,.012,.012),mat=Metal,seg=8,rings=4)
# Nose gear, dual tyres, oleo, torque links and launch shuttle bar.
pivots['Gear_Nose']=(0,-6.51,1.59)
box('Nose gear bay',(0,-5.81,1.51),(.54,1.65,.10),mat=Interior)
rod('Nose strut casing',(0,-6.54,1.53),(0,-6.54,.73),.084,'Gear_Nose',Paint,24)
rod('Nose oleo',(0,-6.54,.85),(0,-6.54,.38),.052,'Gear_Nose',Metal,24)
rod('Nose axle',(-.28,-6.54,.295),(.28,-6.54,.295),.065,'Gear_Nose',Metal)
for s in [-1,1]:
 wheel(s*.205,-6.54,.295,.295,.16,'Gear_Nose')
 box('Nose door',(s*.36,-6.65,1.30),(.055,1.37,.45),'Gear_Nose',Paint,.025)
 rod('Nose fork',(s*.12,-6.54,.63),(s*.12,-6.54,.295),.041,'Gear_Nose',Paint)
 rod('Launch bar arm',(s*.11,-6.54,.43),(s*.15,-7.17,.19),.034,'Gear_Nose',Metal)
rod('Launch shuttle crossbar',(-.20,-7.17,.19),(.20,-7.17,.19),.053,'Gear_Nose',Metal)
rod('Nose diagonal folding brace',(0,-5.44,1.52),(0,-6.54,.78),.055,'Gear_Nose',Paint)
line('Nose brake hose',[(.1,-6.55,1.35),(.14,-6.63,.9),(.13,-6.67,.48)],.013,'Gear_Nose',Rubber)
for a,b in [((0,-6.63,.97),(0,-6.84,.76)),((0,-6.84,.76),(0,-6.64,.55))]:rod('Torque scissor link',a,b,.025,'Gear_Nose',Metal)
rod('Landing lamp body',(0,-6.64,1.12),(0,-6.75,1.12),.10,'Gear_Nose',Metal,24)
ell('Landing light lens',(0,-6.76,1.12),(.078,.018,.078),'Gear_Nose',Lamps,24,12)
for part in G['Gear_Nose']:part.location.y+=.85
pivots['Gear_Nose']=(0,-5.66,1.59)
rod('Nose gear trunnion',(-.15,-5.66,1.59),(.15,-5.66,1.59),.085,'Gear_Nose',Metal)
# Bore both engine exits through the structural aft bulkhead.
for side in [-1,1]:
 cutter=rod('Temporary exhaust bore',(side*.61,4.67,1.96),(side*.61,6.1,1.96),.425,mat=Interior,verts=64)
 bpy.context.view_layer.objects.active=hull
 mod=hull.modifiers.new('Open exhaust bulkhead','BOOLEAN');mod.operation='DIFFERENCE';mod.object=cutter
 bpy.ops.object.modifier_apply(modifier=mod.name)
 G['Fuselage'].remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
# Retracted tailhook with real hinged shank and hook tip.
pivots['Hook']=(0,2.87,1.44)
rod('Tailhook hinge axle',(-.15,2.87,1.44),(.15,2.87,1.44),.085,'Hook',Metal)
rod('Arrestor hook shank',(0,2.87,1.44),(0,5.14,1.38),.054,'Hook',Metal,24)
line('Arrestor hook shoe',[(0,5.10,1.38),(0,5.32,1.32),(0,5.39,1.39),(0,5.33,1.48)],.076,'Hook',Metal)
# Dorsal antennas and belly vents.
panel('Dorsal radio blade',[(0,-3.24,2.62),(0,-3.02,2.95),(0,-2.89,2.95),(0,-2.98,2.62)],.05,bev=.015)
for s in [-1,1]:
 for yy in [-4.3,-4.09,-3.88,-3.67]:box('LEX cooling slot',(s*.78,yy,2.57),(.22,.06,.023),mat=Interior,bev=.01)
 for yy in [1.2,1.39,1.58,1.77]:box('Aft cooling louvre',(s*1.06,yy,1.73),(.026,.095,.15),mat=Interior,bev=.012)
 ell('Navigation lamp',(s*6.075,1.68,2.12),(.059,.12,.045),mat=Lamps,seg=24,rings=12)
ell('Dorsal beacon',(0,.65,2.595),(.09,.12,.039),mat=Lamps,seg=24,rings=12)
# Consolidate rigid assemblies, modifiers, planar tiled UVs.
objects=[]
for name,parts in G.items():
 bpy.ops.object.select_all(action='DESELECT')
 for o in parts:o.select_set(True)
 bpy.context.view_layer.objects.active=parts[0];bpy.ops.object.convert(target='MESH');bpy.ops.object.join();o=bpy.context.object;o.name=name
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 # Remove collapsed bevel slivers and curve seam duplicates before export.
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data)
 bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=0.000001)
 bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=0.0000001)
 bmesh.ops.triangulate(bm,faces=list(bm.faces))
 tiny=[f for f in bm.faces if f.calc_area()<1e-10]
 if tiny:bmesh.ops.delete(bm,geom=tiny,context='FACES')
 bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces))
 bm.to_mesh(o.data);bm.free();o.data.update()
 # seam normals remain smooth where authored, plates remain crisp.
 for layer in list(o.data.uv_layers):o.data.uv_layers.remove(layer)
 uv=o.data.uv_layers.new(name='Tiled_2m');o.data.uv_layers.active=uv
 for poly in o.data.polygons:
  axis=max(range(3),key=lambda a:abs(poly.normal[a]));axes=[a for a in range(3) if a!=axis]
  for li in poly.loop_indices:
   co=o.matrix_world@o.data.vertices[o.data.loops[li].vertex_index].co;uv.data[li].uv=(co[axes[0]]/2.6,co[axes[1]]/2.6)
 target=Vector(pivots.get(name,(0,0,0)));sc.cursor.location=target;bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 objects.append(o)
# Empty attachment nodes are part of the exported contract.
def empty(name,loc):
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=loc;o.empty_display_size=.15;objects.append(o)
empty('Seat_Pilot',(0,-6.27*1.12+1.2792,2.42));empty('Center_Of_Mass',(0,-.95,2.04));empty('Nozzle_L',(.61,5.87,1.96));empty('Nozzle_R',(-.61,5.87,1.96))
for s,l in [(1,'L'),(-1,'R')]:
 for i,(x,y,z) in enumerate([(6.075,.55,2.1),(3.35,.40,1.54),(2.2,.02,1.54),(1.04,-2.2,1.35)]):empty('Hardpoint_'+l+'_'+str(i+1),(s*x,y,z))
 empty('Light_Nav_'+l,(s*6.075,1.68,2.12))
empty('Hardpoint_Center',(0,-1.1,1.37));empty('Light_Beacon',(0,.65,2.63))
# Enforce the requested envelope after bevel evaluation, keeping axle-ground origin.
pts=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
bmin=[min(v[a] for v in pts) for a in range(3)];bmax=[max(v[a] for v in pts) for a in range(3)]
factors=Vector((12.31/(bmax[0]-bmin[0]),17.07/(bmax[1]-bmin[1]),4.66/(bmax[2]-bmin[2])))
for o in objects:
 if o.type=='MESH':
  for v in o.data.vertices:
   v.co.x*=factors.x;v.co.y*=factors.y;v.co.z*=factors.z
 o.location.x*=factors.x;o.location.y*=factors.y;o.location.z=(o.location.z-bmin[2])*factors.z
bpy.context.view_layer.update()
paints=[('Navy blue-grey',[.38,.40,.43]),('Air-superiority light grey',[.60,.63,.65]),('Dark grey',[.20,.23,.26]),('Desert sand',[.57,.45,.30]),('Olive',[.25,.29,.18]),('Black',[.055,.063,.073])]
# Evaluated mesh checks and export.
bpy.ops.object.select_all(action='DESELECT')
for o in objects:o.select_set(True)
path=ROOT/'f18-hornet.glb'
bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO',export_extras=True)
# Ensure factor is carried in standard metallic-roughness JSON, never a shader-only tint.
data=path.read_bytes();jl=struct.unpack_from('<I',data,12)[0];doc=json.loads(data[20:20+jl]);tail=data[20+jl:]
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.38,.40,.43,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);path.write_bytes(struct.pack('<III',0x46546c67,2,20+len(j)+len(tail))+struct.pack('<II',len(j),0x4e4f534a)+j+tail)
tri=sum(len(p.vertices)-2 for o in objects if o.type=='MESH' for p in o.data.polygons)
assert 60000<=tri<=160000, f'Triangle budget exceeded: {tri}'
assert path.stat().st_size<=12*1024*1024, 'GLB exceeds 12 MiB'
coords=[o.matrix_world@Vector(c) for o in objects if o.type=='MESH' for c in o.bound_box]
lo=[min(c[a] for c in coords) for a in range(3)];hi=[max(c[a] for c in coords) for a in range(3)]
def game(v):return [round(v[0],5),round(v[2],5),round(-v[1],5)]
manifest={'builder_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'units':'metres','axes':'+X left, +Y up, +Z nose','nodes':{o.name:{'position':game(o.location),'type':o.type} for o in objects},'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]],'dimensions':[hi[0]-lo[0],hi[2]-lo[2],hi[1]-lo[1]]},'paints':[{'name':n,'linear_rgb':c} for n,c in paints],'triangles':tri,'materials':[m.name for m in MATS]}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
(ROOT/'NOTES.md').write_text('F/A-18C-style carrier fighter; articulated controls, canopy, gear and retracted hook; metre-scale Y-up GLB.\n'+f'{tri:,} triangles; {path.stat().st_size/1048576:.2f} MiB GLB.\n'+'6 materials: Paint, Metal, Rubber, Interior, Glass, Lamps; Paint factor defaults to [0.38, 0.40, 0.43].\n'+'Cycles baked shared tiling base/normal 1024 px, ORM 512 px; glass/lights use glTF optical factors.\n'+'Poly Haven blue_metal_plate (CC0): https://polyhaven.com/a/blue_metal_plate ; https://polyhaven.com/license .\n'+'Reference differences: no markings or weapons by request; cockpit instrumentation, skin panel layout and internal engine plumbing simplified.\n')
print('ASSET_EXPORTED',tri,path.stat().st_size,manifest['bounding_box'],flush=True)
# Presentation only: never in export.
sc.cycles.samples=32;sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
floor=mat_simple('Presentation floor',(.22,.245,.27),.87)
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.018));bpy.context.object.data.materials.append(floor)
world=sc.world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs[0].default_value=(.64,.73,.84,1);world.node_tree.nodes.get('Background').inputs[1].default_value=.45
for name,loc,power,size in [('Soft daylight',(3,-9,13),2400,9),('Sky fill',(-9,-1,8),1600,10),('Tail rim',(4,8,10),2200,8)]:
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,-2,1.5))-o.location).to_track_quat('-Z','Y').to_euler()
d=bpy.data.cameras.new('Presentation camera');cam=bpy.data.objects.new('Presentation camera',d);sc.collection.objects.link(cam);sc.camera=cam
center=Vector(((lo[0]+hi[0])/2,(lo[1]+hi[1])/2,(lo[2]+hi[2])/2))
cam.data.type='ORTHO';cam.data.ortho_scale=20.7
cam.location=center+Vector((14,-21,11));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler()
def render(name):sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True)
render('final.png')
# Clay keeps the exact camera and hides all authored textures.
clay=mat_simple('Clay inspection',(.42,.42,.42),.72)
slots={o.name:list(o.data.materials) for o in objects if o.type=='MESH'}
for o in objects:
 if o.type=='MESH':
  for i in range(len(o.data.materials)):o.data.materials[i]=clay
render('clay.png')
for o in objects:
 if o.type=='MESH':
  for i,m in enumerate(slots[o.name]):o.data.materials[i]=m
cam.location=center+Vector((22,0,0));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=19.4
render('side.png')
cam.location=center+Vector((12,20,8));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=20.7
render('textures/rear_check.png')
cam.location=center+Vector((14,-21,11));cam.rotation_euler=(center-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=20.7
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=12
# Composite six renders without external dependencies; pixels are linear scene values.
canvas=np.zeros((720,1920,4),dtype=np.float32)
for idx,(name,col) in enumerate(paints):
 Paint.node_tree.nodes['PaintTint'].inputs[2].default_value=(*col,1);sc.render.filepath=str(TEX/f'paint_{idx}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(sc.render.filepath);arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);row=1-idx//3;column=idx%3;canvas[row*360:(row+1)*360,column*640:(column+1)*640]=arr;bpy.data.images.remove(im)
Paint.node_tree.nodes['PaintTint'].inputs[2].default_value=(.38,.4,.43,1)
im=bpy.data.images.new('Six paint finishes',width=1920,height=720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
print('BUILD_COMPLETE',flush=True)
