"""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, 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,2)
 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,2);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();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 ['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,[.88 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,.17,.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,.31,'metal_plate',.006,1024),('Rubber',(.029,.032,.034),0,.78,'fabric_leather_01',.3,512),('Alloy',(.70,.73,.76),.75,.25,'metal_plate',.012,512),('Trim',(.026,.031,.037),.2,.43,'metal_plate',.03,512),('Interior',(.045,.038,.032),0,.67,'fabric_leather_01',.15,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,.78),(-2.05,.86),(-1.4,.928),(-.65,.87),(.45,.88),(1.25,.946),(1.85,.89),(2.225,.79)])
def crown(y):return smooth_interp(y,[(-2.225,.59),(-1.95,.70),(-1.35,.77),(-.63,.81),(.7,.80),(1.28,.81),(2.225,.70)])
def shoulder(y):return crown(y)+.045*math.exp(-((y+1.275)/.45)**2)+.055*math.exp(-((y-1.275)/.52)**2)-.035

def top_z(y,x):
 t=abs(x)/width(y);return crown(y)+(shoulder(y)-crown(y))*t**6+.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
# Top panels omit cabin footprint to leave the interior genuinely open.
for ya,yb,inner in [(-2.225,-.78,0),(-.78,1.10,.69),(1.10,2.225,0)]:
 for side in [-1,1]:
  def top(u,v,ya=ya,yb=yb,inner=inner,side=side):
   y=ya+(yb-ya)*u;w=width(y);x=inner+(w-inner)*v;t=x/w
   z=top_z(y,x)
   return (side*x,y,z)
  surf('Sculpted bonnet and haunch',top,int((yb-ya)*46),16,'Paint')
for side in [-1,1]:
 def flank(u,v,side=side):
  y=-2.225+4.45*u;z0=arch_bottom(y);z1=shoulder(y);z=z0+(z1-z0)*v
  x=width(y)-.033*(1-v)+.015*sin(pi*v)-.015*math.exp(-((v-.46)/.20)**2)
  return (side*x,y,z)
 surf('Continuous side with arch openings',flank,264,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);pts.append((side*(width(y)+.002),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,.018,'Trim')
 seam_yz=[(-.61,.795),(-.69,.72),(-.72,.59),(-.71,.34),(-.67,.23),(-.52,.215),(.37,.215),(.49,.24),(.57,.34),(.64,.58),(.57,.79)]
 line('Two door shutline',[(side*(width(y)+.002),y,z) for y,z in seam_yz],.0035,'Trim')
 handle=cube('Flush door handle',(side*(width(.40)+.005),.40,.727),(.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+.14*sin(pi*u)-.12*u+.022*(1-(2*v-1)**2)),40,32,'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),.807+.353*u+.022*u*(1-t*t))
def backglass(u,v):
 t=2*v-1;return (t*(.675+.07*u),.95+.43*u+.018*(1-t*t)*u,1.04-.23*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)
 line(name+' painted surround',edge,.010,'Paint',True)
# 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+.14*sin(pi*u)-.12*u
  return (s*(.767*(1-v)+xtop*v),ybottom*(1-v)+ytop*v+.045*u**8*sin(pi*v),.812*(1-v)+ztop*v)
 surf('Side window',sideglass,32,14,'Glass')
 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,.845),(side*.89,-.47,.858),.026,.025,'Trim')
 o=cube('Mirror housing',(side*.897,-.46,.876),(.126,.186,.091),'Paint',.043)
 cube('Mirror dark gasket',(side*.898,-.372,.877),(.100,.012,.064),'Trim',.024)
 cube('Mirror reflective glass',(side*.898,-.365,.879),(.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)
# Front/rear closures and original face: open upper grille, twin projector slots.
# Thin front fascia planes are individually lofted and rounded rather than a filled slab behind grille.
def bumper_band(name,y,zlo,zhi,mat,front=True):
 def fn(u,v):
  x=(2*u-1)*.805;yy=y+(.10 if front else -.07)*(abs(2*u-1)**2)
  return (x,yy,zlo+(zhi-zlo)*v+.025*(1-(2*u-1)**2))
 o=surf(name,fn,48,4,mat);sol=o.modifiers.new('Fascia wall thickness','SOLIDIFY');sol.thickness=.023;bevel(o,.012,3)
fascia=cube('Continuous front bumper',(0,-2.075,.382),(1.65,.32,.464),'Paint',.057)
activate(fascia);bpy.ops.object.modifier_apply(modifier=fascia.modifiers[0].name)
for x,z,w,h in [(0,.475,.94,.15),(-.651,.480,.285,.16),(.651,.480,.285,.16),(0,.266,1.27,.132)]:
 cutter=cube('Temporary grille aperture',(x,-2.235,z),(w,.44,h),'Trim',min(.032,h*.2))
 activate(cutter);bpy.ops.object.modifier_apply(modifier=cutter.modifiers[0].name)
 activate(fascia);md=fascia.modifiers.new('True recessed grille 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(fascia,.006,2)
# Back of grille cavities
cube('Upper radiator shadow',(0,-2.172,.467),(1.55,.035,.16),'Trim',.055)
cube('Lower radiator cavity',(0,-2.174,.265),(1.32,.03,.12),'Trim',.035)

for k in range(5):
 cube('Upper grille horizontal slat',(0,-2.213,.421+k*.025),(.88,.023,.009),'Trim',.004)
for x in [i*.035 for i in range(-16,17)]:cube('Lower mesh vertical',(x,-2.205,.264),(.005,.012,.105),'Brake',.002)
for z in [.22,.245,.27,.295,.315]:cube('Lower mesh crosswire',(0,-2.212,z),(1.20,.009,.004),'Brake',.001)
for side in [-1,1]:
 cube('Projector black housing',(side*.651,-2.174,.48),(.269,.054,.144),'Trim',.049)
 for off,r in [(-.056,.042),(.055,.035)]:
  x=side*(.651+off)
  cyl('Optical chrome barrel',(x,-2.205,.482),r+.008,.02,'Alloy','Y',48)
  cyl('Projector dark surround',(x,-2.222,.482),r,.012,'Trim','Y',48)
  lamp_uv(cyl('Warm projector optic',(x,-2.230,.482),r*.68,.006,'Lamps','Y',48),0)
 line('Headlight brow',[(side*.53,-2.215,.548),(side*.65,-2.222,.555),(side*.77,-2.20,.546)],.006,'Alloy')
 lamp_uv(cube('Amber side marker',(side*(width(-1.86)+.004),-1.86,.44),(.009,.115,.017),'Lamps',.005),2)
# Black splitter stays inside length envelope.
cube('Front splitter',(0,-2.10,.145),(1.73,.25,.027),'Trim',.035)
cube('Continuous rear bumper',(0,2.105,.47),(1.61,.255,.45),'Paint',.065)
cube('Rear lamp panel',(0,2.220,.605),(1.49,.018,.135),'Trim',.04)
for side in [-1,1]:
 # Original thin red double bar with small return, different from reference's signature.
 for z in [.578,.626]:lamp_uv(cube('Tail light guide',(side*.545,2.236,z),(.42,.012,.015),'Lamps',.006),1)
 lamp_uv(cube('Tail outer return',(side*.755,2.230,.601),(.016,.012,.06),'Lamps',.006),1)
 cube('Rear reflector bezel',(side*.81,1.98,.365),(.014,.14,.026),'Trim',.007)
 lamp_uv(cube('Rear red reflector',(side*.818,1.98,.365),(.006,.115,.015),'Lamps',.004),1)
cube('Rear number plate recess',(0,2.232,.425),(.28,.011,.10),'Trim',.018)
cube('Rear diffuser',(0,2.08,.233),(1.58,.23,.18),'Trim',.05)
for x in [-.60,-.30,0,.30,.60]:cube('Diffuser strake',(x,2.11,.165),(.013,.22,.09),'Trim',.005)
for side in [-1,1]:
 cyl('Rolled exhaust tip',(side*.59,2.20,.255),.064,.10,'Alloy','Y',64)
 cyl('Exhaust inner soot',(side*.59,2.253,.255),.050,.007,'Trim','Y',64)
# Integrated rear lip spoiler, supported over entire trailing deck.
surf('Ducktail spoiler',lambda u,v:((2*v-1)*.80,1.98+.19*u,.729+.023*u+.012*(1-(2*v-1)**2)),10,36,'Paint')
line('Spoiler rolled trailing edge',[(x,2.17,.752+.012*(1-(x/.80)**2)) for x in [i*.02 for i in range(-40,41)]],.012,'Paint')
for sign in [-1,1]:
 surf('Spoiler bonded end',lambda u,v,s=sign:(s*.80,1.98+.19*u,top_z(1.98+.19*u,.80)*(1-v)+(.729+.023*u)*v),12,3,'Paint')
surf('Spoiler closed back',lambda u,v:((2*u-1)*.8,2.17,top_z(2.17,(2*u-1)*.8)*(1-v)+(.752+.012*(1-(2*u-1)**2))*v),36,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,.74),(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,.835),(x+.12,-.67,.89),(x+.24,-.66,.893)],.004,'Trim')
 line('Wiper blade',[(x-.10,-.678,.886),(x+.31,-.675,.900)],.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',112)
  # Tread blocks are subtle raised moulded ribs, contact radius remains .340.
  for k in range(76):
   a=2*pi*k/76
   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',112)
  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',112)
  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(24):
   a=2*pi*k/24
   for rad in [.172,.196]:
    cyl('Rotor drilled hole',(xc+side*.09,yc+rad*sin(a),zc+rad*cos(a)),.004,.002,'Trim','X',8)
  cube('Brake caliper',(xc+side*.084,yc-.183,zc+.015),(.052,.074,.16),'Brake',.022)
  # 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)
if raw_tris>97000:
 for o in final:
  activate(o);md=o.modifiers.new('Game topology budget','DECIMATE');md.ratio=93000/raw_tris;bpy.ops.object.modifier_apply(modifier=md.name)
# 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+=.085*(abs(w.x)/.85)**4*blend
   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
# 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':(.65,-2.225,.48),'Light_Head_R':(-.65,-2.225,.48),'Light_Tail_L':(.55,2.23,.60),'Light_Tail_R':(-.55,2.23,.60)}
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_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),750,5,(0,0,.4))
area('Long body reflection',(-4,-.4,3),550,4,(0,0,.7),'RECTANGLE',1.7)
area('Rear rim',(2,4,3.5),650,3,(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)
render('matched.png',1920,1080,24)
camera((5.3,-7.0,2.55),(0,-.05,.59),56)
render('final.png',1920,1080,40)
camera((6.5,0,.70),(0,0,.64),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 grille and lamps, yellow default glossy paint instead of matte grey; simplified underbody and upholstery, no branding.\n')
print('BUILD_COMPLETE',flush=True)
