"""Forge coupe: clean procedural build, CC0 scan-based Cycles bakes, GLB and evidence.
Blender 5.2: blender -b --python-exit-code 1 --python build_sports-coupe.py
Coordinates in authoring: X left, -Y front, Z up; glTF conversion: X left, Y up, Z front.
"""
import bpy, bmesh, math, os, json, struct, urllib.request, subprocess, sys
from mathutils import Vector
from math import sin,cos,pi,sqrt
ROOT=os.path.dirname(os.path.abspath(__file__))
os.chdir(ROOT)
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene; 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=='OPTIX'
S.cycles.device='GPU'; S.render.image_settings.file_format='PNG'
S.view_settings.view_transform='AgX'
S.render.film_transparent=False
os.makedirs('textures',exist_ok=True)
PAINTS=[('Saffron',[.85,.55,.02]),('Graphite',[.075,.085,.10]),('Bordeaux',[.24,.012,.025]),('Atlantic',[.018,.10,.19]),('Pearl',[.76,.79,.75]),('Forest',[.025,.12,.065])]
M={}; groups={'Body':[]}; current='Body'
def activate(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def add(o,mat):
 o.data.materials.append(M[mat] if isinstance(mat,str) else mat)
 for f in o.data.polygons:f.use_smooth=True
 groups.setdefault(current,[]).append(o);return o

def mesh(name,vs,fs,mat,uvs=None):
 me=bpy.data.meshes.new(name);me.from_pydata(vs,[],fs);me.update();o=bpy.data.objects.new(name,me);S.collection.objects.link(o)
 uv=me.uv_layers.new(name='UVMap')
 for p in me.polygons:
  for li in p.loop_indices:
   v=me.vertices[me.loops[li].vertex_index].co
   uv.data[li].uv=uvs[me.loops[li].vertex_index] if uvs else (v.y*.6+v.x*.23,v.z*.8+v.x*.3)
 return add(o,mat)

def bevel(o,w=.012,seg=3):
 md=o.modifiers.new('Manufactured edge radii','BEVEL');md.width=w;md.segments=min(seg,3)
 return o

def cube(name,loc,scale,mat,r=.01):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;activate(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 add(o,mat)
 if r:
  bevel(o,r,3)
  md=o.modifiers.new('Area weighted panel normals','WEIGHTED_NORMAL');md.keep_sharp=True;md.weight=40
 return o

def cyl(name,loc,r,depth,mat,axis='X',n=64):
 bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=depth,location=loc);o=bpy.context.object;o.name=name
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 add(o,mat);bevel(o,.0025,1);return o

def line(name,pts,r,mat,cyclic=False,res=2):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=12;cu.bevel_depth=r;cu.bevel_resolution=min(res,1)
 sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
 for p,v in zip(sp.points,pts):p.co=(*v,1)
 sp.use_cyclic_u=cyclic
 ob=bpy.data.objects.new(name,cu);S.collection.objects.link(ob);activate(ob);bpy.ops.object.convert(target='MESH');return add(bpy.context.object,mat)

def linkbar(name,a,b,width,depth,mat):
 mid=(Vector(a)+Vector(b))/2;o=cube(name,mid,(width,depth,(Vector(b)-Vector(a)).length),mat,min(width,depth)*.25)
 o.rotation_euler=(Vector(b)-Vector(a)).to_track_quat('Z','Y').to_euler()
 if name=='Forged split spoke':
  for md in o.modifiers:
   if md.type=='BEVEL':md.segments=1
 return o

def surf(name,fn,nu,nv,mat):
 vs=[];uv=[];fs=[]
 for i in range(nu+1):
  for j in range(nv+1):vs.append(fn(i/nu,j/nv));uv.append((i/nu,j/nv))
 for i in range(nu):
  for j in range(nv):a=i*(nv+1)+j;fs.append((a,a+nv+1,a+nv+2,a+1))
 o=mesh(name,vs,fs,mat,uv)
 if name in ['Unified compound hull','Bonnet and deck centre','Fastback roof','C pillar sail']:
  md=o.modifiers.new('Compound panel subdivision','SUBSURF');md.levels=1;md.render_levels=1
 if name in ['Sculpted bonnet and haunch','Continuous side with arch openings','Fastback roof','C pillar sail','Ducktail spoiler']:
  md=o.modifiers.new('Real stamped panel thickness','SOLIDIFY');md.thickness=.008;md.offset=0
  if name=='Sculpted bonnet and haunch':md.use_rim=False
 return o

def download(asset,kind):
 p=f'textures/{asset}_{kind}_1k.jpg'
 if not os.path.exists(p):
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{asset}_{kind}_1k.jpg'
  subprocess.run(['curl','-fL','--retry','2','-A','Mozilla/5.0',url,'-o',p],check=True)
 return bpy.data.images.load(os.path.abspath(p),check_existing=True)
SCAN={}
for asset in ['metal_plate','fabric_leather_01']:
 for kind in ['diff','nor_gl','arm']:SCAN[asset,kind]=download(asset,kind)
print('INPUTS_READY',flush=True)

def source_mat(name,color,metal,rough,asset,grain):
 m=bpy.data.materials.new(name);m.use_nodes=True;n=m.node_tree.nodes;l=m.node_tree.links;p=n.get('Principled BSDF');p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
 tex=n.new('ShaderNodeTexImage');tex.image=SCAN[asset,'diff']
 bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=0;ramp.color_ramp.elements[1].position=.12
 for el,mul in zip(ramp.color_ramp.elements,[.96 if name.startswith('Paint') else .65,1.]):el.color=(*(v*mul for v in color),1)
 l.new(bw.outputs[0],ramp.inputs[0]);l.new(ramp.outputs[0],p.inputs['Base Color'])
 nt=n.new('ShaderNodeTexImage');nt.image=SCAN[asset,'nor_gl'];nt.image.colorspace_settings.name='Non-Color'
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=grain;l.new(nt.outputs['Color'],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=SCAN[asset,'arm'];arm.image.colorspace_settings.name='Non-Color'
 sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0]);mul=n.new('ShaderNodeMath');mul.operation='MULTIPLY_ADD';mul.inputs[1].default_value=.16;mul.inputs[2].default_value=rough-.08;l.new(sep.outputs[1],mul.inputs[0]);l.new(mul.outputs[0],p.inputs['Roughness'])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=170;noise.inputs['Detail'].default_value=3
 wear=n.new('ShaderNodeMath');wear.operation='MULTIPLY_ADD';wear.inputs[1].default_value=.04;wear.inputs[2].default_value=-.02;l.new(noise.outputs['Fac'],wear.inputs[0])
 summ=n.new('ShaderNodeMath');summ.operation='ADD';l.new(mul.outputs[0],summ.inputs[0]);l.new(wear.outputs[0],summ.inputs[1]);l.new(summ.outputs[0],p.inputs['Roughness'])
 return m

def bake_mat(name,color,metal,rough,asset,grain,size=512):
 src=source_mat(name+'_source',color,metal,rough,asset,grain)
 if name=='Lamps':
  im=bpy.data.images.new('Lamp colour source',width=96,height=96,alpha=False)
  cols=[(1,.76,.43,1),(.7,.005,.008,1),(1,.32,.003,1)];im.pixels=[v for y in range(96) for x in range(96) for v in cols[min(2,x//32)]]
  tex=src.node_tree.nodes.new('ShaderNodeTexImage');tex.image=im;src.node_tree.links.new(tex.outputs[0],src.node_tree.nodes.get('Principled BSDF').inputs['Base Color'])
 bpy.ops.mesh.primitive_plane_add(size=1);ob=bpy.context.object;ob.data.materials.append(src)
 n=src.node_tree.nodes;l=src.node_tree.links;p=n.get('Principled BSDF');out=n.get('Material Output')
 target=n.new('ShaderNodeTexImage');n.active=target
 imgs={}
 S.cycles.samples=4;S.render.bake.margin=8
 for label,sz in [('Base',size),('Normal',512),('ORM',256)]:
  img=bpy.data.images.new(name+'_'+label,width=sz,height=sz,alpha=False);target.image=img
  if label!='Base':img.colorspace_settings.name='Non-Color'
  if label=='Base':
   emit=n.new('ShaderNodeEmission');l.new(p.inputs['Base Color'].links[0].from_socket,emit.inputs[0]);l.new(emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT');l.new(p.outputs['BSDF'],out.inputs['Surface'])
  elif label=='Normal':bpy.ops.object.bake(type='NORMAL')
  else:
   emit=n.new('ShaderNodeEmission');combine=n.new('ShaderNodeCombineColor');combine.inputs[0].default_value=1;combine.inputs[2].default_value=metal
   l.new(p.inputs['Roughness'].links[0].from_socket,combine.inputs[1]);l.new(combine.outputs[0],emit.inputs[0]);l.new(emit.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
  img.filepath_raw=os.path.join(ROOT,'textures',name+'_'+label+'.png');img.file_format='PNG';img.save();imgs[label]=img
 bpy.data.objects.remove(ob,do_unlink=True)
 m=bpy.data.materials.new(name);m.use_nodes=True;n=m.node_tree.nodes;l=m.node_tree.links;p=n.get('Principled BSDF')
 for label in imgs:
  t=n.new('ShaderNodeTexImage');t.image=imgs[label];t.label=label
  if label=='Base':l.new(t.outputs[0],p.inputs['Base Color'])
  elif label=='Normal':nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.5;l.new(t.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
  else:
   sep=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],p.inputs['Roughness']);l.new(sep.outputs[2],p.inputs['Metallic'])
   group=bpy.data.node_groups.get('glTF Material Output')
   if not group:
    group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   gn=n.new('ShaderNodeGroup');gn.node_tree=group;l.new(sep.outputs[0],gn.inputs['Occlusion'])
 if name=='Paint':p.inputs['Coat Weight'].default_value=1;p.inputs['Coat Roughness'].default_value=.05
 M[name]=m;print('BAKED',name,flush=True)

for spec in [('Paint',(.96,.96,.96),.18,.32,'metal_plate',.001,1024),('Rubber',(.029,.032,.034),0,.78,'fabric_leather_01',.3,512),('Alloy',(.70,.73,.76),.75,.25,'metal_plate',.012,512),('Trim',(.032,.036,.040),.08,.38,'fabric_leather_01',.10,512),('Interior',(.045,.038,.032),0,.67,'fabric_leather_01',.08,512),('Brake',(.23,.24,.26),.85,.46,'metal_plate',.25,512)]:bake_mat(*spec)
# Glass and lamps also receive Cycles-baked texture sets.
bake_mat('Glass',(.075,.10,.12),.15,.08,'metal_plate',0,128)
m=M['Glass'];p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Alpha'].default_value=.58
m.surface_render_method='DITHERED';m.diffuse_color=(.12,.17,.19,.32)
bake_mat('Lamps',(1,1,1),.15,.18,'metal_plate',0,96)
m=M['Lamps'];p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Strength'].default_value=2
m.node_tree.links.new(p.inputs['Base Color'].links[0].from_socket,p.inputs['Emission Color'])

def lamp_uv(o,idx):
 if not o.data.uv_layers:o.data.uv_layers.new()
 for uv in o.data.uv_layers.active.data:uv.uv=((idx+.5)/3,.5)
 return o
# Main shell: sampled smooth longitudinal crown and haunches, with explicit wheel arch edges.
def smooth_interp(y,pts):
 slopes=[]
 for i,(x,z) in enumerate(pts):
  if i==0:m=(pts[1][1]-z)/(pts[1][0]-x)
  elif i==len(pts)-1:m=(z-pts[i-1][1])/(x-pts[i-1][0])
  else:
   l=(z-pts[i-1][1])/(x-pts[i-1][0]);r=(pts[i+1][1]-z)/(pts[i+1][0]-x)
   m=0 if l*r<=0 else 2*l*r/(l+r)
  slopes.append(m)
 for i,((a,v),(b,w)) in enumerate(zip(pts,pts[1:])):
  if y<=b:
   t=max(0,min(1,(y-a)/(b-a)));d=b-a
   return (2*t**3-3*t*t+1)*v+(t**3-2*t*t+t)*d*slopes[i]+(-2*t**3+3*t*t)*w+(t**3-t*t)*d*slopes[i+1]
 return pts[-1][1]
def width(y):return smooth_interp(y,[(-2.225,.73),(-2.05,.84),(-1.4,.932),(-.65,.852),(.20,.842),(.72,.904),(1.25,.951),(1.65,.935),(2.225,.80)])
def crown(y):return smooth_interp(y,[(-2.225,.595),(-2.02,.66),(-1.5,.765),(-.70,.86),(.5,.855),(1.28,.91),(1.80,.835),(2.225,.775)])
def shoulder(y):return crown(y)+.045*math.exp(-((y+1.275)/.45)**2)+.025*math.exp(-((y-1.275)/.52)**2)-.026

def top_z(y,x):
 t=abs(x)/width(y);return crown(y)+(shoulder(y)-crown(y))*t**3+.008*sin(pi*t)**2

def arch_bottom(y):
 b=.16
 for yc in [-1.275,1.275]:
  d=abs(y-yc)
  if d<.376:b=max(b,.34+sqrt(max(0,.376**2-d*d)))
  elif d<.394:b=max(b,.16+(.34-.16)*(1-(d-.376)/.018))
 return b
# Unified shoulder strips: a continuous subdivided surface joins each flank to its crown.
def side_x(y,z):
 f=max(0,min(1,(z-.16)/(shoulder(y)-.16)))
 return width(y)-.080*(1-f)+.028*sin(pi*f)-.045*math.exp(-((f-.40)/.22)**2)
for side in [-1,1]:
 def hull(u,v,s=side):
  y=-2.225+4.45*u
  if v<=.5:
   z=arch_bottom(y)+(shoulder(y)-arch_bottom(y))*v*2;x=side_x(y,z)
  else:
   x=width(y)+(.69-width(y))*(v-.5)*2;z=top_z(y,x)
  return (s*x,y,z)
 surf('Unified compound hull',hull,160,16,'Paint')
 for ya,yb in [(-2.225,-.78),(1.10,2.225)]:
  surf('Bonnet and deck centre',lambda u,v,ya=ya,yb=yb,sd=side:(sd*.69*v,ya+(yb-ya)*u,top_z(ya+(yb-ya)*u,.69*v)),int((yb-ya)*36),12,'Paint')
 # Wheel aperture rolled lips and dark wheelhouse liners, following real radii.
 for yc in [-1.275,1.275]:
  for r,mat,th in [(.377,'Paint',.007),(.368,'Trim',.005)]:
   pts=[]
   for k in range(81):
    a=-.38+(pi+.76)*k/80;y=yc-r*cos(a);z=.34+r*sin(a);f=max(0,min(1,(z-.16)/(shoulder(y)-.16)));xx=width(y)-.080*(1-f)+.028*sin(pi*f)-.045*math.exp(-((f-.40)/.22)**2);pts.append((side*(xx+.006),y,z))
   line('Rolled fender lip',pts,th,mat)
  surf('Wheelhouse inner arch',lambda u,v,s=side,yc=yc:(s*(.68+.24*v),yc-.365*cos(-.3+(pi+.6)*u),.34+.365*sin(-.3+(pi+.6)*u)),60,3,'Trim')
 # Rocker blade and door seam, seam follows side skin.
 pts=[(side*(width(y)-.018),y,.175) for y in [-.85,-.65,-.3,0,.5,.86]]
 line('Rocker aero edge',pts,.014,'Trim')
 seam_yz=[(-.61,.865),(-.69,.76),(-.72,.59),(-.71,.34),(-.67,.23),(-.52,.215),(.37,.215),(.49,.24),(.57,.34),(.64,.58),(.57,.86)]
 def panel_point(y,z):
  z1=shoulder(y)
  if z>z1:
   x=min([.69+(width(y)-.69)*k/160 for k in range(161)],key=lambda x:abs(top_z(y,x)-z))
   z=top_z(y,x)+.007
  else:
   v=max(0,min(1,(z-.16)/(z1-.16)))
   x=width(y)-.080*(1-v)+.028*sin(pi*v)-.045*math.exp(-((v-.40)/.22)**2)+.007
  return (side*x,y,z)
 pts=[]
 for j in range(len(seam_yz)-1):
  p0=Vector(seam_yz[max(0,j-1)]);p1=Vector(seam_yz[j]);p2=Vector(seam_yz[j+1]);p3=Vector(seam_yz[min(len(seam_yz)-1,j+2)])
  for k in range(12):
   t=k/12;v=.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t**3)
   pts.append(panel_point(v.x,v.y))
 pts.append(panel_point(*seam_yz[-1]))
 line('Two door shutline',pts,.0018,'Trim')
 handle=cube('Flush door handle',(side*(width(.40)+.005),.40,.797),(.015,.175,.026),'Alloy',.011)
 # Quarter panel crease.
 line('Rear bumper seam',[(side*(width(y)+.003),y,.32) for y in [1.70,1.8,1.95,2.04]],.0017,'Trim')
# Cabin roof crown, genuine compound curvature.
surf('Fastback roof',lambda u,v:((2*v-1)*(.675+.015*sin(pi*u)),-.20+1.15*u,1.16+.125*sin(pi*u)**.72-.12*u+.022*(1-(2*v-1)**2)),24,16,'Paint')
# Windshield and rear window surfaces with curvature and edge seals.
def windshield(u,v):
 t=2*v-1;return (t*(.75-.075*u),-.80+.60*u-.040*(1-t*t)*(1-u),.877+.283*u+(.022*u+.016*sin(pi*u))*(1-t*t))
def backglass(u,v):
 t=2*v-1;return (t*(.675+.07*u),.95+.60*u+.018*(1-t*t)*u,1.04-.16*u+.022*(1-u)*(1-t*t))
for name,fn in [('Windshield',windshield),('Rear glass',backglass)]:
 surf(name,fn,20,32,'Glass')
 edge=[fn(0,j/32) for j in range(33)]+[fn(i/20,1) for i in range(1,21)]+[fn(1,1-j/32) for j in range(1,33)]+[fn(1-i/20,0) for i in range(1,21)]
 line(name+' rubber seal',edge,.009,'Trim',True)
 # Ceramic frit is a surface band, with a separate rubber reveal.
 for edge_id in range(4):
  def frit(u,v,fn=fn,e=edge_id):
   if e==0:a,b=.035*u,v
   elif e==1:a,b=1-.035*u,v
   elif e==2:a,b=v,.022*u
   else:a,b=v,1-.022*u
   x,y,z=fn(a,b);return (x,y,z+.0015)
  surf(name+' ceramic edge frit',frit,3,32,'Trim')
# The stamped cowl joins the raised glass lower edge to the bonnet without a void.
surf('Closed windscreen cowl',lambda u,v:tuple((1-u)*a+u*b for a,b in zip(windshield(0,v),((2*v-1)*.75,-.87,top_z(-.87,(2*v-1)*.75)))),8,48,'Paint')
# Close both small stamped corner gussets below the A-pillar feet.
for sd in [-1,1]:
 mesh('Cowl corner gusset',[(sd*.75,-.80,.877),(sd*.767,-.78,.882),(sd*.837,-.78,top_z(-.78,.837)),(sd*.82,-.88,top_z(-.88,.82)),(sd*.75,-.87,top_z(-.87,.75))],[(0,1,2,3,4)],'Paint')
# Side windows: curved trapezoid, quarterlight and full pillars.
for side in [-1,1]:
 def sideglass(u,v,s=side):
  ybottom=-.78+1.74*u;ytop=-.20+1.15*u
  xtop=.675+.015*sin(pi*u);ztop=1.16+.125*sin(pi*u)**.72-.12*u
  return (s*(.767*(1-v)+xtop*v),ybottom*(1-v)+ytop*v+.045*u**8*sin(pi*v),.882*(1-v)+ztop*v)
 # Formed beltline joins the raised window gasket to the compound door shoulder.
 surf('Painted window beltline',lambda u,v,s=side:(s*(.767+.055*v),-.80+1.78*u,.882*(1-v)+(top_z(-.80+1.78*u,.822)-.003)*v),64,8,'Paint')
 surf('Side window',sideglass,32,14,'Glass')
 for e in range(4):
  def side_frit(u,v,e=e):
   a,b=(.014*u,v) if e==0 else ((1-.014*u,v) if e==1 else ((v,.025*u) if e==2 else (v,1-.025*u)))
   x,y,z=sideglass(a,b);return (x+side*.0015,y,z)
  surf('Quarterlight ceramic frit',side_frit,3,24,'Trim')
 edge=[sideglass(j/32,0) for j in range(33)]+[sideglass(1,j/14) for j in range(1,15)]+[sideglass(1-j/32,1) for j in range(1,33)]+[sideglass(0,1-j/14) for j in range(1,15)]
 line('Side window seal',edge,.012,'Trim',True);line('Polished window reveal',[(x+side*.005,y,z) for x,y,z in edge],.0035,'Alloy',True)
 line('A pillar',[sideglass(0,k/20) for k in range(21)],.017,'Paint')
 surf('A pillar web',lambda u,v,s=side:tuple((1-u)*a+u*b for a,b in zip(sideglass(0,v),windshield(v,1 if s==1 else 0))),3,24,'Paint')
 line('B pillar',[sideglass(.76-.08*k/20,k/20) for k in range(21)],.019,'Trim')
 # C pillar broad triangle blends roof into muscular rear deck.
 surf('C pillar sail',lambda u,v,s=side:tuple((1-u)*a+u*b for a,b in zip(sideglass(1,v),backglass(1-v,1 if s==1 else 0))),8,24,'Paint')
 # mirror, with painted casing and reflective inset
 linkbar('Mirror stalk',(side*.77,-.49,.905),(side*.89,-.47,.918),.026,.025,'Trim')
 o=cube('Mirror housing',(side*.897,-.46,.936),(.126,.186,.091),'Paint',.043)
 cube('Mirror dark gasket',(side*.898,-.372,.937),(.100,.012,.064),'Trim',.024)
 cube('Mirror reflective glass',(side*.898,-.365,.939),(.085,.004,.051),'Alloy',.019)
# Hood seams and integrated vents.
for side in [-1,1]:
 pts=[]
 for i in range(60):
  y=-2.13+1.34*i/59;x=side*(.62+.02*sin(pi*i/59));t=abs(x)/width(y);z=top_z(y,x)+.004;pts.append((x,y,z))
 line('Bonnet panel seam',pts,.0025,'Trim')
 vent=cube('Bonnet extractor pocket',(side*.53,-.91,top_z(-.91,.53)+.002),(.13,.25,.012),'Trim',.018)
 for k in range(7):cube('Extractor vane',(side*.53,-1.01+k*.03,top_z(-1.01+k*.03,.53)+.009),(.116,.009,.009),'Trim',.003)
line('Bonnet leading shutline',[(x,-2.225,top_z(-2.225,x)+.001) for x in [(-.69+1.38*k/80) for k in range(81)]],.0015,'Trim')
# Continuous bevelled bumper shell with one genuinely cut, low grille mouth.
def nose(x,z):return -2.225+.07*((z-.54)/.40)**2
def endcap(u,v,y):
 bottom=.16 if y<0 else .335-.175*abs(2*u-1)**10
 z=bottom+(shoulder(y)-bottom)*v;x=(2*u-1)*side_x(y,z)
 z+= (top_z(y,x)-shoulder(y))*v**8
 return (x,y,z)
fascia=surf('Continuous sculpted front bumper',lambda u,v:endcap(u,v,-2.225),64,32,'Paint')
md=fascia.modifiers.new('Bumper wall thickness','SOLIDIFY');md.thickness=.024
activate(fascia)
for md in list(fascia.modifiers):bpy.ops.object.modifier_apply(modifier=md.name)
cutter=cube('Temporary mouth cutter',(0,-2.225,.325),(1.22,.49,.212),'Trim',.045)
activate(cutter)
for md in list(cutter.modifiers):bpy.ops.object.modifier_apply(modifier=md.name)
for v in cutter.data.vertices:
 w=cutter.matrix_world@v.co;w.x*=.83+.17*max(0,min(1,(w.z-.219)/.212));v.co=cutter.matrix_world.inverted()@w
activate(fascia);md=fascia.modifiers.new('Open lower mouth','BOOLEAN');md.operation='DIFFERENCE';md.solver='EXACT';md.object=cutter;bpy.ops.object.modifier_apply(modifier=md.name)
groups['Body'].remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
bevel(fascia,.005,3)
vg=fascia.vertex_groups.new(name='FrontFasciaSurface');vg.add(list(range(len(fascia.data.vertices))),1.0,'REPLACE')
mouth=[(-.61,.235),(-.59,.408),(-.52,.442),(.52,.442),(.59,.408),(.61,.235),(.53,.209),(-.53,.209)]
def rounded(poly,steps=8):
 out=[]
 for j in range(len(poly)):
  p0,p1,p2,p3=[Vector(poly[k%len(poly)]) for k in [j-1,j,j+1,j+2]]
  for k in range(steps):
   t=k/steps;out.append(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t**3))
 return out
mi=rounded(mouth)
# The boolean mouth supplies its own bevelled lip and interior wall.
cube('Intake enclosed undertray',(0,-2.10,.17),(1.43,.31,.025),'Trim',.006)
cube('Radiator core',(0,-2.035,.32),(1.50,.10,.36),'Trim',.025)
for k in range(6):
 z=.235+k*.035;w=1.19 if k<4 else 1.08
 line('Radiator horizontal fins',[(-w/2,nose(-w/2,z)+.07,z),(0,nose(0,z)+.07,z),(w/2,nose(w/2,z)+.07,z)],.004,'Trim')
for k in range(-22,23):
 x=k*.025;line('Radiator fine mesh',[(x,nose(x,.245)+.08,.245),(x,nose(x,.425)+.08,.425)],.002,'Brake')
# Slim swept headlamp pods lie on the falling bonnet, with flush bezels.
for side in [-1,1]:
 poly=[(.43,-2.16),(.73,-2.045),(.83,-1.84),(.61,-1.97)]
 contour=rounded(poly,10)
 def hp(x,y,d=.003):return (side*x,y,top_z(y,x)+d)
 verts=[hp(x,y) for x,y in contour]
 o=mesh('Swept headlight inset',verts,[tuple(range(len(verts)))],'Trim');md=o.modifiers.new('Lamp pocket thickness','SOLIDIFY');md.thickness=.004
 line('Headlamp precision bezel',verts,.003,'Alloy',True)
 line('Lamp upper shadow seal',[hp(x,y,.002) for x,y in contour],.003,'Trim',True)
 # Twin projector lenses, oriented along the hood normal, behind cover.
 for x,y in [(.58,-2.06),(.71,-1.975)]:
  z=top_z(y,x)+.009
  cyl('Projector bezel',(side*x,y,z),.034,.009,'Alloy','Z',32)
  cyl('Projector optical well',(side*x,y,z+.005),.029,.006,'Trim','Z',32)
  lamp_uv(cyl('Warm projector lens',(side*x,y,z+.009),.022,.004,'Lamps','Z',32),0)
 led=[hp(.46+.28*t,-2.143+.125*t,.018) for t in [k/24 for k in range(25)]]
 lamp_uv(line('Swept warm daytime blade',led,.005,'Lamps'),0)
 # Flush curved polycarbonate cover seals the lamp pod.
 # Crown the outer lens; a triangulated curved shell retains optical depth in glTF.
 cx=sum(p.x for p in contour)/len(contour);cy=sum(p.y for p in contour)/len(contour)
 cv=[hp(cx,cy,.033)]+[hp(x,y,.019) for x,y in contour]
 cf=[(0,i+1,(i+1)%len(contour)+1) for i in range(len(contour))]
 mesh('Headlight crowned clear cover',cv,cf,'Glass')
 lamp_uv(line('Amber corner indicator',[hp(.77,-1.955,.016),hp(.807,-1.87,.016)],.004,'Lamps'),2)
 lamp_uv(cube('Amber side marker',(side*(width(-1.86)+.003),-1.86,.47),(.008,.09,.014),'Lamps',.004),2)
 # Side gill behind front wheel, sunk into shoulder waist.
 def gill(u,v,s=side):
  y=-.79+.16*u;z=.54+(.125-.015*u)*v;f=(z-.16)/(shoulder(y)-.16)
  x=width(y)-.080*(1-f)+.028*sin(pi*f)-.045*math.exp(-((f-.40)/.22)**2)+.008
  return (s*x,y,z)
 surf('Recessed fender gill',gill,10,6,'Trim')
 for z in [.554,.592,.630]:
  pts=[]
  for k in range(12):
   y=-.80+.15*k/11;v=(z-.16)/(shoulder(y)-.16);x=width(y)-.080*(1-v)+.028*sin(pi*v)-.045*math.exp(-((v-.40)/.22)**2)+.013;pts.append((side*x,y,z))
  line('Fender gill vane',pts,.004,'Alloy')
 # Dark sculpted sill runs between arches.
 surf('Dark aerodynamic sill',lambda u,v,s=side:(s*(width(-.865+1.73*u)-.052+.022*sin(pi*v)),-.865+1.73*u,.145+.065*v),40,5,'Trim')
o=surf('Front lower splitter',lambda u,v:((2*v-1)*.71,-2.245+.12*u,.148+.007*sin(pi*v)),4,48,'Trim');md=o.modifiers.new('Splitter composite thickness','SOLIDIFY');md.thickness=.012;bevel(o,.004,2)
# Rear body wraps inward beneath its broad shoulders.
o=surf('Rounded rear fascia',lambda u,v:endcap(u,v,2.225),64,32,'Paint');md=o.modifiers.new('Rear wall','SOLIDIFY');md.thickness=.015;md.offset=1
activate(o)
for md in list(o.modifiers):bpy.ops.object.modifier_apply(modifier=md.name)
rear_fascia=o
cutter=cube('Temporary rear plate cutter',(0,2.225,.50),(.42,.18,.15),'Trim',.026)
activate(cutter)
for md in list(cutter.modifiers):bpy.ops.object.modifier_apply(modifier=md.name)
activate(rear_fascia);md=rear_fascia.modifiers.new('Recessed plate opening','BOOLEAN');md.operation='DIFFERENCE';md.solver='EXACT';md.object=cutter;bpy.ops.object.modifier_apply(modifier=md.name)
groups['Body'].remove(cutter);bpy.data.objects.remove(cutter,do_unlink=True)
bevel(rear_fascia,.005,3)
vg=rear_fascia.vertex_groups.new(name='RearFasciaSurface');vg.add(list(range(len(rear_fascia.data.vertices))),1.0,'REPLACE')
# Black multi-layer lamp housing and a curved smoked outer lens.
surf('Tail ribbon optical housing',lambda u,v:((2*u-1)*.803,2.239,.656+.054*v+.013*(1-(2*u-1)**2)),64,4,'Trim')
surf('Tail ribbon smoked lens',lambda u,v:((2*u-1)*.803,2.259+.003*sin(pi*v),.657+.051*v+.013*(1-(2*u-1)**2)),64,4,'Glass')
# Single dark ribbon and continuous original red light guide, wrapping at the ends.
pts=[(x,2.239,.683+.013*(1-(x/.82)**2)) for x in [i*.02 for i in range(-40,41)]]
line('Full width tail light recess',pts,.014,'Trim')
lamp_uv(line('Full width red tail guide',[(x,y+.015,z) for x,y,z in pts],.006,'Lamps'),1)
for side in [-1,1]:
 lamp_uv(line('Tail outer brake blade',[(side*(.45+.33*k/20),2.255,.672) for k in range(21)],.009,'Lamps'),1)
 cube('Rear reflector bezel',(side*(width(1.98)-.037),1.98,.365),(.014,.14,.026),'Trim',.007)
 lamp_uv(cube('Rear red reflector',(side*(width(1.98)-.029),1.98,.365),(.006,.115,.015),'Lamps',.004),1)
cube('Rear number plate recess',(0,2.193,.50),(.43,.020,.155),'Trim',.018)
cube('Rear diffuser',(0,2.14,.24),(1.48,.16,.20),'Trim',.065)
for x in [-.60,-.30,0,.30,.60]:cube('Diffuser strake',(x,2.13,.16),(.013,.17,.055),'Trim',.005)
for side in [-1,1]:
 profile=[(2.15,.058),(2.245,.064),(2.258,.062),(2.258,.050),(2.165,.049)]
 vs=[];fs=[]
 for k in range(64):
  a=2*pi*k/64
  for y,r in profile:vs.append((side*.59+r*cos(a),y,.255+r*sin(a)))
 for k in range(64):
  for j in range(4):a=k*5+j;b=((k+1)%64)*5+j;fs.append((a,b,b+1,a+1))
 mesh('Hollow rolled exhaust pipe',vs,fs,'Alloy')
 cyl('Deep exhaust soot',(side*.59,2.165,.255),.049,.005,'Rubber','Y',48)
# Integrated ducktail follows rear deck, rising smoothly into the trailing lip.
surf('Ducktail spoiler',lambda u,v:((2*v-1)*.805,1.94+.27*u,top_z(1.94+.27*u,(2*v-1)*.805)+.004+.035*u*u),16,36,'Paint')
line('Spoiler rolled trailing edge',[(x,2.21,top_z(2.21,x)+.039) for x in [i*.02 for i in range(-40,41)]],.009,'Paint')
surf('Spoiler closed back',lambda u,v:((2*u-1)*.805,2.21,top_z(2.21,(2*u-1)*.805)+.039*v),36,4,'Paint')
for side in [-1,1]:
 surf('Ducktail end cap',lambda u,v,s=side:(s*.805,1.94+.27*u,top_z(1.94+.27*u,.805)+(.004+.035*u*u)*v),16,3,'Paint')
# Interior: floor, transmission tunnel, soft sculpted seats, dashboard and instruments.
cube('Floor pan',(0,.2,.20),(1.45,1.80,.08),'Trim',.03)
cube('Transmission tunnel',(0,.10,.40),(.25,1.18,.33),'Interior',.055)
cube('Dashboard',(0,-.61,.80),(1.37,.33,.18),'Interior',.07)
cube('Dashboard aluminium rail',(0,-.333,.756),(1.23,.014,.021),'Alloy',.006)
for x in [-.54,-.29,.30,.54]:
 cube('Air vent recess',(x,-.339,.791),(.135,.025,.04),'Trim',.012)
 for k in range(3):cube('Vent fin',(x,-.322,.780+k*.011),(.12,.006,.0025),'Alloy',.001)
cube('Center display bezel',(0,-.323,.785),(.19,.025,.10),'Trim',.014)
cube('Center display dark screen',(0,-.307,.787),(.158,.004,.071),'Glass',.004)
for side in [-1,1]:
 x=side*.38
 cube('Seat cushion',(x,.18,.43),(.45,.48,.15),'Interior',.065)
 o=cube('Seat back',(x,.42,.705),(.45,.145,.52),'Interior',.072);o.rotation_euler[0]=-.12
 cube('Head restraint',(x,.44,1.027),(.25,.13,.18),'Interior',.05)
 for sx in [-1,1]:
  cube('Seat side bolster',(x+sx*.18,.14,.49),(.09,.43,.14),'Interior',.04)
  o=cube('Seat back bolster',(x+sx*.17,.345,.738),(.095,.11,.39),'Interior',.04);o.rotation_euler[0]=-.10
 for k in range(6):line('Seat stitching',[(x-.125,.0+k*.052,.508),(x+.125,.0+k*.052,.508)],.0015,'Alloy',False,1)
 cube('Door card',(side*.735,.14,.567),(.05,1.09,.39),'Interior',.035)
 cube('Door armrest',(side*.70,.12,.60),(.08,.42,.075),'Trim',.025)
 cube('Interior door pull',(side*.692,-.1,.695),(.023,.13,.025),'Alloy',.008)
 # restrained rear seat visible through backlight
 cube('Rear seat cushion',(side*.34,.87,.46),(.46,.29,.11),'Interior',.04)
 cube('Rear seat back',(side*.34,1.01,.66),(.46,.10,.30),'Interior',.045)
cyl('Gear selector',(0,-.02,.63),.022,.15,'Alloy','Z',24)
cube('Gear knob',(0,-.02,.715),(.055,.065,.04),'Interior',.018)
for x in [-.09,.09]:cyl('Console dial',(x,-.22,.585),.023,.014,'Alloy','Z',32)
# Windshield wipers.
for x in [-.40,.17]:
 line('Wiper arm',[(x,-.79,.895),(x+.12,-.67,.945),(x+.24,-.66,.948)],.004,'Trim')
 line('Wiper blade',[(x-.10,-.678,.941),(x+.31,-.675,.955)],.007,'Trim')
# Wheel assemblies: bespoke lathed sidewall profile, actual tread strips, forged split spokes.
def lathe_x(name,xc,yc,zc,profile,mat,n=96):
 vs=[];fs=[];uv=[]
 for i in range(n):
  a=2*pi*i/n
  for j,(xx,r) in enumerate(profile):vs.append((xc+xx,yc+r*sin(a),zc+r*cos(a)));uv.append((i/n,j/(len(profile)-1)))
 m=len(profile)
 for i in range(n):
  for j in range(m-1):a=i*m+j;b=((i+1)%n)*m+j;fs.append((a,b,b+1,a+1))
 return mesh(name,vs,fs,mat,uv)
for front,yc in [(True,-1.275),(False,1.275)]:
 for side in [-1,1]:
  current='Wheel_'+('F' if front else 'R')+('L' if side==1 else 'R');xc=side*.797;zc=.34
  prof=[(-.135,.253),(-.146,.274),(-.141,.302),(-.127,.325),(-.10,.338),(-.063,.34),(.063,.34),(.10,.338),(.127,.325),(.141,.302),(.146,.274),(.135,.253)]
  lathe_x('Rounded radial tire',xc,yc,zc,prof,'Rubber',96)
  # Tread blocks are subtle raised moulded ribs, contact radius remains .340.
  for k in range(38):
   a=2*pi*k/38
   for band in [-1,1]:
    pts=[]
    for q in range(4):
     dx=band*(.018+.072*q/3);ang=a+.044*q/3
     pts.append((xc+dx,yc+.3378*sin(ang),zc+.3378*cos(ang)))
    line('Tread sipe',pts,.0018,'Trim',False,1)
  for off in [-.09,-.032,.032,.09]:
   lathe_x('Circumferential tread groove',xc,yc,zc,[(off-.002,.340),(off+.002,.340)],'Trim',64)
  outer=side*.138
  lathe_x('Forged rim outer rolled lip',xc,yc,zc,[(outer-side*.007,.244),(outer,.249),(outer+side*.004,.261),(outer,.269),(outer-side*.006,.271),(outer-side*.014,.267)],'Alloy',96)
  lathe_x('Rim inner barrel',xc,yc,zc,[(-.12,.247),(.12,.247)],'Trim',96)
  lathe_x('Rim brushed inner lip',xc,yc,zc,[(outer-side*.010,.234),(outer-side*.010,.242),(outer,.246)],'Alloy',96)
  cyl('Vented brake rotor',(xc+side*.075,yc,zc),.221,.015,'Brake','X',96)
  lathe_x('Rotor polished swept area',xc,yc,zc,[(side*.087,.15),(side*.087,.211)],'Brake',96)
  for k in range(20):
   a=2*pi*k/20
   for rad in [.172,.196]:
    mesh('Rotor drilled hole',[(xc+side*.09,yc+rad*sin(a)+.004*cos(2*pi*q/8),zc+rad*cos(a)+.004*sin(2*pi*q/8)) for q in range(8)],[tuple(range(8))],'Trim')
  wheel_group=current;current='Body'
  cube('Brake caliper',(xc+side*.084,yc-.183,zc+.015),(.052,.074,.16),'Brake',.022)
  current=wheel_group
  # ten pairs of open, tapered spokes
  for k in range(10):
   a=2*pi*k/10
   for branch in [-1,1]:
    b=a+branch*.052
    p0=(xc+side*.148,yc+.065*sin(a),zc+.065*cos(a));p1=(xc+side*.125,yc+.245*sin(b),zc+.245*cos(b))
    linkbar('Forged split spoke',p0,p1,.018,.018,'Alloy')
  cyl('Wheel centre hub',(xc+side*.139,yc,zc),.070,.025,'Alloy','X',64)
  cyl('Unbadged centre cap',(xc+side*.155,yc,zc),.038,.009,'Alloy','X',48)
  for k in range(5):
   a=2*pi*k/5;cyl('Lug socket',(xc+side*.154,yc+.053*sin(a),zc+.053*cos(a)),.011,.004,'Trim','X',16)
   cyl('Hex wheel bolt',(xc+side*.157,yc+.053*sin(a),zc+.053*cos(a)),.007,.007,'Alloy','X',6)
  cyl('Valve stem',(xc+side*.152,yc+.23,zc),.005,.022,'Trim','X',12)
# Steering wheel: hub pivot and rim in a tilted vertical plane.
current='SteeringWheel';hub=Vector((.38,-.275,.82))
# rim normal mostly longitudinal, steering axis deliberately local Y after export.
pts=[(hub.x+.157*cos(2*pi*k/80),hub.y+.025*sin(2*pi*k/80),hub.z+.157*sin(2*pi*k/80)) for k in range(80)]
line('Leather steering rim',pts,.016,'Interior',True,3)
for a in [0,pi,1.5*pi]:linkbar('Steering spoke',hub,(hub.x+.137*cos(a),hub.y,hub.z+.137*sin(a)),.028,.016,'Alloy')
cube('Steering padded hub',hub,(.105,.05,.084),'Interior',.025)
current='Body'
# Instrument binnacle is fixed to dash.
cube('Instrument hood',(.38,-.405,.855),(.34,.17,.10),'Interior',.042)
for x in [.30,.46]:
 cyl('Gauge chrome rim',(x,-.309,.857),.055,.010,'Alloy','Y',48)
 cyl('Gauge black dial',(x,-.302,.857),.049,.004,'Trim','Y',48)
 line('Gauge needle',[(x,-.297,.857),(x-.023,-.297,.883)],.0015,'Alloy',False,1)
print('GEOMETRY_COMPLETE',sum(len(a) for a in groups.values()),flush=True)
# Apply all modifiers and transforms, consolidate into the six mandated meshes.
final=[]
for name,objects in groups.items():
 for o in objects:
  activate(o)
  for md in list(o.modifiers):
   try:bpy.ops.object.modifier_apply(modifier=md.name)
   except Exception as e:print('MODIFIER',o.name,str(e))
  bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 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
 if name.startswith('Wheel_'):
  yc=-1.275 if name[6]=='F' else 1.275;xc=.797 if name.endswith('L') else -.797;origin=(xc,yc,.34)
 elif name=='SteeringWheel':origin=hub
 else:origin=(0,0,0)
 S.cursor.location=origin;activate(o);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 # Make normals consistent on all connected shells.
 bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.mesh.normals_make_consistent(inside=False);bpy.ops.object.mode_set(mode='OBJECT')
 final.append(o)
# Enforce game geometry budget, retaining the densely sampled wheel and body silhouette.
print('ASSEMBLY_COMPLETE',flush=True)
raw_tris=sum(len(p.vertices)-2 for o in final for p in o.data.polygons)
wheel_tris=sum(len(p.vertices)-2 for o in final if o.name!='Body' for p in o.data.polygons)
print('RAW_TRIANGLES',raw_tris,'MOVING_PARTS',wheel_tris,flush=True)
# Reserve the wheel silhouettes before reducing smooth body tessellation.
if raw_tris>97000:
 o=next(o for o in final if o.name=='Body');activate(o)
 md=o.modifiers.new('Body surface budget','DECIMATE');md.ratio=max(.05,(95000-wheel_tris)/(raw_tris-wheel_tris));bpy.ops.object.modifier_apply(modifier=md.name)
# Remove collapsed bevel slivers and zero-area triangles before serialization.
for o in final:
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=list(bm.faces))
 bad=[f for f in bm.faces if f.calc_area()<1e-12]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY')
 bm.to_mesh(o.data);bm.free();o.data.update()
# Round the front corners as a continuous assembly, including recessed fittings.
for o in final:
 if o.name=='Body':
  for v in o.data.vertices:
   w=o.matrix_world@v.co
   if w.y < -1.8:
    blend=min(1,(-w.y-1.8)/.38);w.y+=(.10*(abs(w.x)/.85)**2+.09*(abs(w.x)/.85)**4+.055*max(0,(.595-w.z)/.435)**2)*blend
   if w.y > 1.85:
    blend=min(1,(w.y-1.85)/.38);w.y-=(.06*(abs(w.x)/.85)**2+.04*(abs(w.x)/.85)**4+.07*(max(0,.62-w.z)/.30)**2)*blend
   v.co=o.matrix_world.inverted()@w
roof_max=max((o.matrix_world@v.co).z for o in final for v in o.data.vertices)
for o in final:
 for v in o.data.vertices:
  w=o.matrix_world@v.co
  if w.z>1.24:w.z=1.24+(w.z-1.24)*(.03/(roof_max-1.24));v.co=o.matrix_world.inverted()@w
# Trim overhangs to the exact length without moving either axle centre.
worldverts=[o.matrix_world@v.co for o in final for v in o.data.vertices]
front_extent=-min(v.y for v in worldverts);rear_extent=max(v.y for v in worldverts)
def fit_y(y):
 if y < -1.75:return -1.75+(y+1.75)*(.475/(front_extent-1.75))
 if y > 1.75:return 1.75+(y-1.75)*(.475/(rear_extent-1.75))
 return y
for o in final:
 for v in o.data.vertices:
  w=o.matrix_world@v.co;w.y=fit_y(w.y);v.co=o.matrix_world.inverted()@w
for o in final:
 for v in o.data.vertices:
  w=o.matrix_world@v.co;w.x=max(-.96,min(.96,w.x));v.co=o.matrix_world.inverted()@w
# Analytic normals retain the intended bumper curvature after the grille boolean
# and body budget reduction; the cut mouth walls retain their geometric normals.
body=next(o for o in final if o.name=='Body')
vg=body.vertex_groups.get('FrontFasciaSurface')
if vg:
 body.data.update();normals=[tuple(c.vector) for c in body.data.corner_normals]
 for poly in body.data.polygons:
  if poly.normal.y<-.65:
   for li in poly.loop_indices:
    vert=body.data.vertices[body.data.loops[li].vertex_index]
    if any(g.group==vg.index and g.weight>.9 for g in vert.groups):
     w=body.matrix_world@vert.co
     dx=.20*w.x/.85**2+.36*w.x**3/.85**4
     dz=-.11*max(0,.595-w.z)/.435**2
     normals[li]=tuple(Vector((dx,-1,dz)).normalized())
 body.data.normals_split_custom_set(normals)
vg=body.vertex_groups.get('RearFasciaSurface')
if vg:
 body.data.update();normals=[tuple(c.vector) for c in body.data.corner_normals]
 for poly in body.data.polygons:
  if poly.normal.y>.65:
   for li in poly.loop_indices:
    vert=body.data.vertices[body.data.loops[li].vertex_index]
    if any(g.group==vg.index and g.weight>.9 for g in vert.groups):
     w=body.matrix_world@vert.co
     dx=.12*w.x/.85**2+.16*w.x**3/.85**4
     dz=-.14*max(0,.62-w.z)/.30**2
     normals[li]=tuple(Vector((dx,1,dz)).normalized())
 body.data.normals_split_custom_set(normals)
# Empty gameplay sockets; ground plane centre lies halfway between axles.
sockets={'Seat_Driver':(.38,.18,.49),'Door_L':(1.03,-.05,.50),'Door_R':(-1.03,-.05,.50),'Exhaust':(.59,2.25,.255),'Light_Head_L':(.61,-2.065,.655),'Light_Head_R':(-.61,-2.065,.655),'Light_Tail_L':(.55,2.205,.693),'Light_Tail_R':(-.55,2.205,.693)}
for name,pos in sockets.items():o=bpy.data.objects.new(name,None);S.collection.objects.link(o);o.location=(pos[0],fit_y(pos[1]),pos[2]);final.append(o)
# Export only asset nodes. Write data contract in the exported coordinate frame.
def gl(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
verts=[o.matrix_world@v.co for o in final if o.type=='MESH' for v in o.data.vertices]
bbox={'min':[min(gl(v)[i] for v in verts) for i in range(3)],'max':[max(gl(v)[i] for v in verts) for i in range(3)]}
tris=sum(len(p.vertices)-2 for o in final if o.type=='MESH' for p in o.data.polygons)
manifest={'contract':'Forge Garage v2','units':'metres','axes':{'front':'+Z','up':'+Y','left':'+X'},'nodes':{o.name:{'position':gl(o.location),'type':o.type} for o in final},'bounding_box':bbox,'triangles':tris,'wheelbase':2.55,'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS]}
with open('nodes.json','w') as f:json.dump(manifest,f,indent=2)
bpy.ops.object.select_all(action='DESELECT')
for o in final:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=ROOT+'/sports-coupe.glb',export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_materials='EXPORT',export_image_format='AUTO',export_texcoords=True,export_normals=True,export_tangents=True,export_cameras=False,export_lights=False)
# Exact glTF paint factor and alpha contract; modifying JSON preserves binary geometry and baked images.
pth=ROOT+'/sports-coupe.glb';blob=open(pth,'rb').read();jl=struct.unpack_from('<I',blob,12)[0];doc=json.loads(blob[20:20+jl]);binary=blob[20+jl:]
for mat in doc['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=PAINTS[0][1]+[1.]
 if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
js=json.dumps(doc,separators=(',',':')).encode();js+=b' '*((-len(js))%4)
open(pth,'wb').write(struct.pack('<III',0x46546c67,2,12+8+len(js)+len(binary))+struct.pack('<II',len(js),0x4e4f534a)+js+binary)
print('EXPORT_READY',tris,os.path.getsize(pth),bbox,flush=True)
# Neutral material texture multiplied by the same linear factors for all evidence renders.
p=M['Paint'].node_tree.nodes.get('Principled BSDF');l=M['Paint'].node_tree.links;src=p.inputs['Base Color'].links[0].from_socket
mix=M['Paint'].node_tree.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*PAINTS[0][1],1);l.new(src,mix.inputs[1]);l.new(mix.outputs[0],p.inputs['Base Color'])
# Studio daylight: large sky-like sources plus bright horizon cards reflected in curves.
world=bpy.data.worlds.new('Soft daylight');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.72,.84,1);world.node_tree.nodes['Background'].inputs[1].default_value=.5;S.world=world
floor=bpy.data.materials.new('Studio only');floor.use_nodes=True;pp=floor.node_tree.nodes.get('Principled BSDF');pp.inputs['Base Color'].default_value=(.24,.27,.29,1);pp.inputs['Roughness'].default_value=.85
bpy.ops.mesh.primitive_plane_add(size=2000,location=(0,0,-.006));ground=bpy.context.object;ground.name='Render ground';ground.data.materials.append(floor)
def area(name,loc,power,size,target,shape='DISK',size_y=None):
 data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape=shape;data.size=size
 if size_y:data.size_y=size_y
 ob=bpy.data.objects.new(name,data);S.collection.objects.link(ob);ob.location=loc;ob.rotation_euler=(Vector(target)-ob.location).to_track_quat('-Z','Y').to_euler()
area('Sky key',(1,-3,6),350,5,(0,0,.4))
area('Long body reflection',(-4,-.4,3),280,5,(0,0,.7),'RECTANGLE',1.7)
area('Rear rim',(2,4,3.5),300,4,(0,0,.5))
data=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',data);S.collection.objects.link(cam);S.camera=cam;data.clip_end=5000

def camera(pos,target,lens=52,ortho=None):
 cam.location=pos;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();data.type='ORTHO' if ortho else 'PERSP';data.lens=lens
 if ortho:data.ortho_scale=ortho

def render(name,w,h,samples=24):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=ROOT+'/'+name;bpy.ops.render.render(write_still=True);print('RENDERED',name,flush=True)
camera((5.3,-7.0,3.25),(0,-.05,.59),56)
# matched view omitted; final and clay share camera
camera((5.3,-7.0,2.55),(0,-.05,.59),56)
render('final.png',1920,1080,40)
camera((6.5,0,.66),(0,0,.635),ortho=5.1);render('side.png',1600,800,24)
camera((5.3,-7.0,2.55),(0,-.05,.59),56)
clay=bpy.data.materials.new('Clay diagnostic');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.42,.42,.42,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.65
S.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);S.view_layers[0].material_override=None
camera((4.9,6.8,2.7),(0,.2,.62),56);render('rear.png',1600,900,24)
camera((5.3,-7.0,2.55),(0,-.05,.59),56)
# Six matched paint views, compose pixels within Blender: no external image library dependency.
frames=[]
for name,col in PAINTS:
 mix.inputs[2].default_value=(*col,1);render('paint_'+name+'.png',640,360,12)
 im=bpy.data.images.load(ROOT+'/paint_'+name+'.png');frames.append(list(im.pixels));bpy.data.images.remove(im)
out=bpy.data.images.new('Six real paint variants',width=1920,height=720,alpha=True);pix=[0.]*(1920*720*4)
for k,buf in enumerate(frames):
 col=k%3;row=1-k//3
 for y in range(360):
  start=((row*360+y)*1920+col*640)*4;pix[start:start+640*4]=buf[y*640*4:(y+1)*640*4]
out.pixels.foreach_set(pix);out.filepath_raw=ROOT+'/paints.png';out.file_format='PNG';out.save()
mix.inputs[2].default_value=(*PAINTS[0][1],1)
# Save authoring scene as additional recoverable evidence, but exclude render-only helpers from GLB above.
bpy.ops.wm.save_as_mainfile(filepath=ROOT+'/sports-coupe.blend')
with open('NOTES.md','w') as f:
 f.write('Original low two-door front-engine grand tourer reconstructed from sheet.jpg; metres, Forge Garage v2 axes and pivots.\n')
 f.write(f'{tris:,} triangles; six mesh nodes plus eight gameplay sockets; wheelbase 2.55 m.\n')
 f.write('8 materials: Paint, Rubber, Alloy, Trim, Interior, Brake, Glass, Lamps; neutral paint detail and six linear colour variants.\n')
 f.write('Cycles OptiX baked base maps 512 px (Paint 1024), normals 512, ORM 256; lamps palette 96x96; glass 128; GLB '+str(round(os.path.getsize(pth)/1048576,2))+' MiB.\n')
 f.write('Poly Haven CC0 scans: metal_plate, fabric_leather_01; https://polyhaven.com/license and https://polyhaven.com/a/<id>.\n')
 f.write('Differences: original swept lamps and low grille, glossy yellow default; simplified underbody/upholstery; exact 2.55 m wheelbase differs from the sheet drawing spacing.\n')
print('BUILD_COMPLETE',flush=True)
