import bpy, math, os, json, urllib.request, subprocess, struct
import numpy as np
from mathutils import Vector
from math import sin,cos,pi,sqrt
ROOT=os.path.dirname(os.path.abspath(__file__))
os.makedirs(ROOT+'/textures',exist_ok=True)
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
S=bpy.context.scene;S.render.engine='CYCLES';S.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!='CPU'
S.cycles.device='GPU';S.cycles.use_denoising=True
S.render.image_settings.file_format='PNG';S.render.film_transparent=False
S.world.color=(.25,.25,.25)
S.view_settings.view_transform='AgX';S.view_settings.look='AgX - Medium High Contrast';S.view_settings.exposure=-.5
# Coordinates in design space: left, forward, height. Blender uses left, -forward, height.
def V(p):return (p[0],-p[1],p[2])
def activate(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def mesh(name,verts,faces,mat,smooth=True):
 me=bpy.data.meshes.new(name);me.from_pydata([V(p) for p in verts],[],faces);me.update()
 o=bpy.data.objects.new(name,me);S.collection.objects.link(o);o.data.materials.append(mat)
 for p in me.polygons:p.use_smooth=smooth
 # Geometric triplanar UV, shared tiled material library.
 uv=me.uv_layers.new(name='UVMap')
 for p in me.polygons:
  a=max(range(3),key=lambda k:abs(p.normal[k]));axes=[k for k in range(3) if k!=a]
  for li in p.loop_indices:
   co=me.vertices[me.loops[li].vertex_index].co;uv.data[li].uv=(co[axes[0]]*1.5,co[axes[1]]*1.5)
 return o

def bevel(o,w=.01,n=3):
 m=o.modifiers.new('Manufactured radii','BEVEL');m.width=w;m.segments=n
 activate(o);bpy.ops.object.modifier_apply(modifier=m.name)
 return o

def box(name,p,size,mat,b=.01):
 bpy.ops.mesh.primitive_cube_add(size=1,location=V(p));o=bpy.context.object;o.name=name;o.dimensions=(size[0],size[1],size[2]);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);o.data.materials.append(mat)
 if b:bevel(o,b,3)
 for poly in o.data.polygons:poly.use_smooth=True
 mod=o.modifiers.new('Weighted manufacturing normals','WEIGHTED_NORMAL');activate(o);bpy.ops.object.modifier_apply(modifier=mod.name)
 return o

def tube(name,pts,r,mat,res=8):
 c=bpy.data.curves.new(name,'CURVE');c.dimensions='3D';c.resolution_u=2;c.bevel_depth=r;c.bevel_resolution=1
 s=c.splines.new('POLY');s.points.add(len(pts)-1)
 for a,p in zip(s.points,pts):a.co=(*V(p),1)
 o=bpy.data.objects.new(name,c);S.collection.objects.link(o);o.data.materials.append(mat);activate(o);bpy.ops.object.convert(target='MESH');return o

def panel(name,pts,mat,thick=.008,be=.005):
 o=mesh(name,pts,[tuple(range(len(pts)))],mat,False)
 if thick:
  m=o.modifiers.new('Panel thickness','SOLIDIFY');m.thickness=thick;activate(o);bpy.ops.object.modifier_apply(modifier=m.name)
 if be:bevel(o,be,3)
 return o

def cyl(name,p,r,depth,mat,axis='X',n=48):
 bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=depth,location=V(p));o=bpy.context.object;o.name=name
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='F':o.rotation_euler[0]=pi/2
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);o.data.materials.append(mat);bevel(o,.002,1)
 for f in o.data.polygons:f.use_smooth=True
 return o

def lathe(name,center,profile,mat,n=96):
 vv=[];ff=[]
 for x,r in profile:
  for i in range(n):
   a=i*2*pi/n;vv.append((center[0]+x,center[1]+r*sin(a),center[2]+r*cos(a)))
 for j in range(len(profile)-1):
  for i in range(n):a=j*n+i;b=j*n+(i+1)%n;ff.append((a,b,b+n,a+n))
 return mesh(name,vv,ff,mat)

# Required CC0 scans. Curl user agent is required by the public CDN.
for ident in ['metal_plate','fabric_leather_02']:
 for mp in ['diff','nor_gl','arm']:
  dst=f'{ROOT}/textures/{ident}_{mp}_1k.jpg'
  if not os.path.exists(dst):subprocess.run(['curl','-fL','-A','Mozilla/5.0','-s',f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{ident}/{ident}_{mp}_1k.jpg','-o',dst],check=True)

def mat(name,color,rough,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=(*color,1);p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal;return m
Paint=mat('Paint',(.98,.98,.98),.24,.15);Carbon=mat('Carbon',(.025,.028,.032),.3,.25);Rubber=mat('Rubber',(.018,.019,.021),.62);Metal=mat('Metal',(.52,.54,.56),.29,.9);Interior=mat('Interior',(.025,.028,.03),.75);Glass=mat('Glass',(.12,.18,.21),.09);Lamps=mat('Lamps',(1,.9,.73),.18);Panels=mat('Panels',(.82,.83,.79),.36)
mats=[Paint,Carbon,Rubber,Metal,Interior,Glass,Lamps,Panels]
Glass.surface_render_method='BLENDED';gp=Glass.node_tree.nodes.get('Principled BSDF');gp.inputs['Alpha'].default_value=.24
# Build source procedural+photographic shaders, bake on one UV tile per material.
for m in [Paint,Carbon,Rubber,Metal,Interior,Panels]:
 nt=m.node_tree;n=nt.nodes;l=nt.links;p=n.get('Principled BSDF');uv=n.new('ShaderNodeTexCoord')
 tex=n.new('ShaderNodeTexImage');tex.image=bpy.data.images.load(ROOT+'/textures/'+('fabric_leather_02' if m in [Interior,Rubber,Carbon] else 'metal_plate')+'_diff_1k.jpg');l.new(uv.outputs['UV'],tex.inputs['Vector'])
 bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');base=p.inputs['Base Color'].default_value[:3]
 ramp.color_ramp.elements[0].color=(*(v*(.96 if m==Paint else .72) for v in base),1);ramp.color_ramp.elements[1].color=(*base,1);l.new(bw.outputs[0],ramp.inputs[0]);l.new(ramp.outputs[0],p.inputs['Base Color'])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=180;l.new(uv.outputs['UV'],noise.inputs[0]);bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.17;bump.inputs['Distance'].default_value=.00012;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(bump.outputs[0],p.inputs['Normal'])
 if m==Carbon:
  wave=n.new('ShaderNodeTexChecker');wave.inputs['Scale'].default_value=100;wave.inputs['Color1'].default_value=(.012,.016,.02,1);wave.inputs['Color2'].default_value=(.045,.05,.055,1);l.new(uv.outputs['UV'],wave.inputs[0]);l.new(wave.outputs['Color'],p.inputs['Base Color']);l.new(wave.outputs['Fac'],bump.inputs['Height'])
 if m==Paint:
  dirt=n.new('ShaderNodeTexNoise');dirt.inputs['Scale'].default_value=9;dirt.inputs['Detail'].default_value=5;l.new(uv.outputs['UV'],dirt.inputs[0])
  grime=n.new('ShaderNodeValToRGB');grime.color_ramp.elements[0].position=.2;grime.color_ramp.elements[0].color=(.52,.52,.52,1);grime.color_ramp.elements[1].position=.43;grime.color_ramp.elements[1].color=(1,1,1,1);l.new(dirt.outputs['Fac'],grime.inputs[0])
  mul=n.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=1;l.new(ramp.outputs[0],mul.inputs[1]);l.new(grime.outputs[0],mul.inputs[2]);l.new(mul.outputs[0],p.inputs['Base Color'])
 # Integrate aligned scan normal with procedural microsurface.
 scan=nt.nodes.new('ShaderNodeTexImage');scan.image=bpy.data.images.load(ROOT+'/textures/'+('fabric_leather_02' if m in [Interior,Rubber,Carbon] else 'metal_plate')+'_nor_gl_1k.jpg');scan.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],scan.inputs[0])
 normal=nt.nodes.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=0 if m==Paint else (.025 if m in [Metal,Panels] else .15);l.new(scan.outputs[0],normal.inputs[1]);l.new(normal.outputs[0],bump.inputs['Normal'])
 arm=nt.nodes.new('ShaderNodeTexImage');arm.image=bpy.data.images.load(ROOT+'/textures/'+('fabric_leather_02' if m in [Interior,Rubber,Carbon] else 'metal_plate')+'_arm_1k.jpg');arm.image.colorspace_settings.name='Non-Color';l.new(uv.outputs['UV'],arm.inputs[0]);spl=nt.nodes.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],spl.inputs[0]);rough=nt.nodes.new('ShaderNodeMath');rough.operation='MULTIPLY_ADD';rough.inputs[1].default_value=.12;rough.inputs[2].default_value=max(.12,p.inputs['Roughness'].default_value-.06);l.new(spl.outputs[1],rough.inputs[0]);l.new(rough.outputs[0],p.inputs['Roughness'])
 # bake tile fixture via actual Cycles execution
 bpy.ops.mesh.primitive_plane_add(size=1);ob=bpy.context.object;ob.data.materials.append(m)
 outputs={}
 for mode,res in [('base',512),('normal',512),('orm',256)]:
  im=bpy.data.images.new(m.name+'_'+mode,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if mode=='base' else 'Non-Color'
  dest=n.new('ShaderNodeTexImage');dest.image=im;n.active=dest;dest.select=True
  if mode=='base':
   out=n.get('Material Output');em=n.new('ShaderNodeEmission');l.new(p.inputs['Base Color'].links[0].from_socket,em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');l.new(p.outputs[0],out.inputs[0]);n.remove(em)
  elif mode=='normal':bpy.ops.object.bake(type='NORMAL')
  else:
   out=n.get('Material Output');em=n.new('ShaderNodeEmission');em.inputs[0].default_value=(1,p.inputs['Roughness'].default_value,p.inputs['Metallic'].default_value,1);pack=n.new('ShaderNodeCombineColor');pack.inputs[0].default_value=1;pack.inputs[2].default_value=p.inputs['Metallic'].default_value;l.new(rough.outputs[0],pack.inputs[1]);l.new(pack.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT');l.new(p.outputs[0],out.inputs[0]);n.remove(em)
  im.filepath_raw=ROOT+'/textures/'+im.name+'.png';im.file_format='PNG';im.save();outputs[mode]=im
 bpy.data.objects.remove(ob,do_unlink=True)
 # Replace sources with portable baked shaders.
 nt.nodes.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
 for mode,im in outputs.items():
  t=n.new('ShaderNodeTexImage');t.image=im
  if mode=='base':
   if m==Paint:
    mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.8,.03,.02,1);l.new(t.outputs[0],mix.inputs[1]);l.new(mix.outputs[0],p.inputs['Base Color']);mix.name='PaintFactor'
   else:l.new(t.outputs[0],p.inputs['Base Color'])
  elif mode=='normal':
   nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.05 if m==Paint else .65;l.new(t.outputs[0],nm.inputs[1]);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'])
 if m==Paint:p.inputs['Coat Weight'].default_value=1;p.inputs['Coat Roughness'].default_value=.05
print('BAKES COMPLETE',flush=True)
# Lamps share a small emission palette and UV-indexed geometry.
im=bpy.data.images.new('Lamp_palette',width=12,height=4,alpha=True);pix=[]
for y in range(4):
 for x in range(12):pix.extend((*((1,.87,.63) if x<4 else ((.8,.005,.003) if x<8 else (1,.23,.005))),1))
im.pixels=pix;im.filepath_raw=ROOT+'/textures/Lamps.png';im.file_format='PNG';im.save()
nt=Lamps.node_tree;p=nt.nodes.get('Principled BSDF');t=nt.nodes.new('ShaderNodeTexImage');t.image=im;t.interpolation='Closest';nt.links.new(t.outputs[0],p.inputs['Base Color']);nt.links.new(t.outputs[0],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=2

def lamp_uv(o,idx):
 if not o.data.uv_layers:o.data.uv_layers.new()
 for d in o.data.uv_layers.active.data:d.uv=((idx+.5)/3,.5)
 return o

# Sculpted lower shell: dense longitudinal loft with true wheel openings.
def interp(f,knots):
 for i,((a,v),(b,w)) in enumerate(zip(knots,knots[1:])):
  if a<=f<=b:
   t=(f-a)/(b-a)
   m0=(w-knots[max(0,i-1)][1])/(b-knots[max(0,i-1)][0])
   m1=(knots[min(len(knots)-1,i+2)][1]-v)/(knots[min(len(knots)-1,i+2)][0]-a)
   return (2*t**3-3*t*t+1)*v+(t**3-2*t*t+t)*(b-a)*m0+(-2*t**3+3*t*t)*w+(t**3-t*t)*(b-a)*m1
 return knots[0][1] if f<knots[0][0] else knots[-1][1]
def width(f):return interp(f,[(-2.22,.79),(-1.8,.94),(-1.325,.98),(-.6,.85),(.3,.85),(1.325,.98),(1.85,.91),(2.23,.77)])
def top(f):return interp(f,[(-2.22,.60),(-1.6,.77),(-.7,.76),(.3,.73),(1.2,.70),(1.8,.69),(2.23,.56)])
def lower(f):
 z=.095 if abs(f)>1.92 else (.095+.065*(1.92-abs(f))/.20 if abs(f)>1.72 else .16)
 for c in [-1.325,1.325]:
  d=abs(f-c)
  if d<.405:z=max(z,.345+sqrt(.405**2-d*d))
 return z
vv=[];ff=[];N=200;K=36
for i in range(N+1):
 f=-2.22+4.45*i/N;w=width(f);h=top(f);low=lower(f)
 swell=.11*sum(math.exp(-((f-c)/.47)**2) for c in [-1.325,1.325])
 shoulder=max(h+swell-.025,low+.025)
 ring=[]
 for j in range(6):
  t=j/5;ring.append((-w*(.94+.06*sin(t*pi/2)),f,low+(shoulder-.035-low)*t))
 for j in range(25):
  t=-1+2*j/24;x=w*.97*t
  z=h+(shoulder-h)*(abs(t)**3)+.025*(1-t*t)+.024*math.exp(-(x/.28)**4-((f-1.2)/.65)**4)
  ring.append((x,f,z))
 for j in range(5,-1,-1):
  t=j/5;ring.append((w*(.94+.06*sin(t*pi/2)),f,low+(shoulder-.035-low)*t))
 vv.extend(ring)
for i in range(N):
 for j in range(K):
  a=i*(K+1)+j;midf=(vv[a][1]+vv[a+K+2][1])*.5;midx=(vv[a][0]+vv[a+1][0])*.5
  if -1.65<midf<.48 and abs(midx)<.75:continue
  ff.append((a,a+1,a+K+2,a+K+1))
body=mesh('Sculpted monocoque skin',vv,ff,Paint)
# Seal only nose and tail sections, with sculpted dark grille mounted ahead.
for i in [0,N]:
 outer=vv[i*(K+1):(i+1)*(K+1)];f=outer[0][1];front=f>0;inner=[]
 for j,pt in enumerate(outer):
  x=pt[0]/width(f)*(.63 if front else .67)
  if j in [0,K]:z=.17 if front else .13
  elif j<6 or j>K-6:z=(.17 if front else .13)+(.21 if front else .20)*min(j,K-j)/5
  else:z=(.43 if front else .34)+(.035 if front else .01)*(1-(x/(.63 if front else .67))**2)
  inner.append((x,f,z))
 verts2=outer+inner;faces2=[];nn=len(outer)
 for j in range(nn):faces2.append((j,(j+1)%nn,(j+1)%nn+nn,j+nn))
 ob=mesh('Sculpted bumper aperture',verts2,faces2,Paint);mod=ob.modifiers.new('Bumper thickness','SOLIDIFY');mod.thickness=.018;activate(ob);bpy.ops.object.modifier_apply(modifier=mod.name);bevel(ob,.008,3)
 tube('Intake perimeter',inner+[inner[0]],.012,Carbon)
 panel('Radiator dark core',[(x,f+(-.09 if front else .09),z) for x,_,z in inner],Rubber,.005,.003)
box('Floor',(0,0,.16),(1.66,3.9,.06),Carbon,.025)
# Arched carbon widebody strips, bolted lips, no painted block behind tyres.
for s in [-1,1]:
 for f0 in [-1.325,1.325]:
  verts=[];faces=[]
  for i in range(65):
   a=pi*i/64
   for j in range(5):
    rr=.405+.095*j/4;f=f0+rr*cos(a);z=.345+rr*sin(a);x=s*(width(f)+.032*sin(pi*j/8)+.006)
    verts.append((x,f,z))
  for i in range(64):
   for j in range(4):a=i*5+j;faces.append((a,a+1,a+6,a+5))
  ob=mesh('Carbon bolt-on arch',verts,faces,Carbon);m=ob.modifiers.new('Lip thickness','SOLIDIFY');m.thickness=.008;activate(ob);bpy.ops.object.modifier_apply(modifier=m.name)
  for i in range(9):
   a=.12+(pi-.24)*i/8;rr=.48;f=f0+rr*cos(a);z=.345+rr*sin(a);cyl('Arch fastener',(s*(width(f)+.039),f,z),.007,.006,Metal,n=8)
 # side sill with side exit and vents
 box('Carbon rocker',(s*.865,-.05,.145),(.12,1.82,.09),Carbon,.025)
 # Door is integral to the continuous shell
 tube('Door shutline',[(s*.868,.70,.735),(s*.865,.69,.27),(s*.86,-.64,.26),(s*.87,-.75,.72)],.0032,Rubber)
 box('Door latch',(s*.862,-.52,.65),(.022,.17,.025),Carbon,.012)
 # white blank racing number plate, dark backing
 box('Blank panel border',(s*.856,-.08,.495),(.009,.44,.31),Carbon,.025)
 box('Blank number panel',(s*.862,-.08,.495),(.008,.414,.285),Panels,.019)
 # side vent behind front arch
 panel('Side brake extraction',[(s*.907,.85,.58),(s*.9,.55,.58),(s*.895,.62,.32),(s*.92,.89,.30)],Carbon,.01,.012)
 for i in range(4):tube('Extraction blade',[(s*.929,.64+i*.047,.34),(s*.929,.62+i*.047,.54)],.008,Metal)
 # exhaust tube with hollow rim
 lathe('Side exhaust',(s*.893,-.47,.21),[(0,.055),(s*.071,.055),(s*.082,.046),(s*.01,.046)],Metal,48)
 cyl('Soot in exhaust',(s*.9,-.47,.21),.044,.003,Rubber,n=40)
# Inner wheelhouse liners provide real cavity depth and shade behind each tyre.
for ss in [-1,1]:
 for fc in [-1.325,1.325]:
  vs=[];fs=[]
  for i in range(65):
   aa=-.48+(pi+.96)*i/64
   for xx in [.64,.98]:vs.append((ss*xx,fc+.397*cos(aa),.345+.397*sin(aa)))
  for i in range(64):a=2*i;fs.append((a,a+1,a+3,a+2))
  mesh('Wheelhouse liner',vs,fs,Rubber)
# Bonnet vents, central power bulge, captive bonnet pins
for s in [-1,1]:
 panel('Hood extractor',[(s*.34,1.72,.70),(s*.61,1.62,.735),(s*.55,1.05,.745),(s*.31,1.13,.74)],Carbon,.008,.009)
 for i in range(6):tube('Bonnet louvre',[(s*.34,1.16+i*.072,.752-i*.006),(s*.55,1.13+i*.072,.756-i*.006)],.008,Carbon)
 for f in [.85,1.9]:cyl('Flush bonnet pin',(s*.65,f,top(f)+.036),.018,.006,Metal,'Z',24)
def shellheight(x,f):
 h=top(f);w=width(f);low=lower(f);sw=.11*sum(math.exp(-((f-c)/.47)**2) for c in [-1.325,1.325]);shoulder=max(h+sw-.025,low+.025)
 t=min(abs(x)/(w*.97),1);return h+(shoulder-h)*t**3+.025*(1-t*t)+.024*math.exp(-(x/.28)**4-((f-1.2)/.65)**4)
# Unified compound-curved greenhouse: every edge is shared by roof, glazing and pillars.
def roofheight(f):return interp(f,[(-1.88,.70),(-1.6,.95),(-1.17,1.115),(-.75,1.15),(-.22,1.145),(.02,1.10),(.36,.94),(.65,.705)])
def roofwidth(f):return interp(f,[(-1.88,.73),(-1.17,.65),(-.22,.64),(.02,.65),(.65,.76)])
verts=[];faces=[];indices=[];nr=80;nc=36
for i in range(nr+1):
 f=-1.88+2.53*i/nr;h=roofheight(f);w=roofwidth(f);belt=shellheight(.83,f)-.018
 ring=[(-.83,f,belt),(-.821,f,belt+.02)]
 for j in range(33):
  t=-1+2*j/32;ring.append((w*t,f,h+.035*(1-t*t)))
 ring +=[(.821,f,belt+.02),(.83,f,belt)]
 verts.extend(ring)
for i in range(nr):
 f=-1.88+2.53*(i+.5)/nr
 for j in range(nc):
  a=i*(nc+1)+j;faces.append((a,a+1,a+nc+2,a+nc+1))
  side=j in [1,34];topface=2<=j<=33
  glass=(side and -1.12<f<.46) or (topface and ((.055<f<.59) or (-1.80<f<-1.23)))
  indices.append(1 if glass else 0)
o=mesh('Continuous canopy',verts,faces,Paint);o.data.materials.append(Glass)
for p,idx in zip(o.data.polygons,indices):p.material_index=idx
for f in [.65,-1.88]:
 vs=[]
 for j in range(33):
  t=-1+2*j/32;x=roofwidth(f)*t
  vs.extend([(x,f,shellheight(x,f)-.005),(x,f,roofheight(f)+.035*(1-t*t))])
 mesh('Fitted canopy cowl',vs,[(2*j,2*j+1,2*j+3,2*j+2) for j in range(32)],Paint)
# Thin fitted seals follow exact canopy surface, with body-colour structural pillars.
for ss in [-1,1]:
 tube('Window lower seal',[(ss*.823,f,shellheight(.83,f)+.002) for f in [-1.12+i*1.58/40 for i in range(41)]],.009,Rubber)
 tube('Window upper seal',[(ss*roofwidth(f),f,roofheight(f)) for f in [-1.12+i*1.58/60 for i in range(61)]],.009,Rubber)
 for f in [-1.12,.46]:tube('Window closing pillar',[(ss*.822,f,shellheight(.83,f)),(ss*roofwidth(f),f,roofheight(f))],.016,Paint)
 tube('Window B pillar',[(ss*.823,-.91,shellheight(.83,-.91)),(ss*roofwidth(-.91),-.91,roofheight(-.91))],.016,Carbon)
 tube('Mirror stalk',[(ss*.79,.42,.79),(ss*.91,.45,.86)],.015,Carbon)
 box('Mirror housing',(ss*.945,.47,.87),(.15,.20,.095),Paint,.04)
 box('Mirror lens',(ss*.946,.371,.875),(.115,.008,.055),Metal,.015)
for f in [.055,.59,-1.80,-1.23]:
 w=roofwidth(f);h=roofheight(f)
 tube('Glass transverse seal',[(w*(-1+2*j/40),f,h+.035*(1-(-1+2*j/40)**2)) for j in range(41)],.009,Rubber)
# Windscreen wiper and flush hinge hardware.
tube('Wiper arm',[(.14,.57,.818),(.22,.42,.921),(-.03,.30,1.002)],.006,Carbon)
tube('Wiper blade',[(-.28,.32,.989),(.15,.32,.989)],.008,Rubber)
# roof air scoop with visible black mouth, below 1.22
panel('Roof scoop top',[(-.18,-.12,1.218),(.18,-.12,1.218),(.15,-.61,1.187),(-.15,-.61,1.187)],Carbon,.009,.009)
for ss in [-1,1]:panel('Roof scoop side',[(ss*.18,-.12,1.218),(ss*.18,-.12,1.177),(ss*.15,-.61,1.178),(ss*.15,-.61,1.187)],Carbon,.007,.005)
box('Scoop inlet',(0,-.123,1.193),(.30,.009,.029),Rubber,.011)
# Aero front planform and side canards
panel('Front splitter',[(-.99,1.57,.09),(-1.02,2.04,.09),(-.82,2.30,.09),(.82,2.30,.09),(1.02,2.04,.09),(.99,1.57,.09)],Carbon,.024,.015)
# Central angular grille recessed at front face, black mesh with fins.
# Grille is recessed within the physical bumper opening.
for i in range(30):
 x=-.55+1.1*i/29;tube('Grille mesh vertical',[(x,2.205,.21),(x*.87,2.205,.425)],.0023,Metal)
for i in range(9):tube('Grille mesh horizontal',[(-.56,2.206,.22+i*.024),(.56,2.206,.22+i*.024)],.002,Metal)
for x in [-.39,0,.39]:box('Grille rib',(x,2.215,.315),(.012,.022,.23),Carbon,.005)
for s in [-1,1]:
 for h in [.19,.29]:panel('Dive plane',[(s*.80,2.19,h),(s*1.0,1.88,h+.055),(s*.96,1.75,h+.045),(s*.82,2.03,h-.015)],Carbon,.009,.004)
 # Original narrow wedge lights: slanted upward across fender
 pts=[(s*.54,2.15,.548),(s*.83,1.92,.626),(s*.86,1.76,.666),(s*.66,1.94,.620)]
 panel('Headlight housing',pts,Carbon,.018,.012)
 led=[(s*.56,2.145,.558),(s*.81,1.95,.636),(s*.845,1.79,.674)]
 lamp_uv(tube('Angular daytime light',led,.009,Lamps),0)
 for k in range(3):lamp_uv(box('LED projector',(s*(.69+.045*k),2.02-.045*k,.604+.014*k),(.033,.04,.014),Lamps,.006),0)
 lamp_uv(tube('Amber indicator',[(s*.855,1.79,.667),(s*.85,1.85,.653)],.005,Lamps),2)
# Rear diffuser, strakes and tail lamp blades
box('Diffuser deck',(0,-1.94,.13),(1.65,.65,.05),Carbon,.012)
for x in [-.72,-.48,-.24,0,.24,.48,.72]:panel('Diffuser vertical fence',[(x,-1.55,.13),(x,-2.25,.075),(x,-2.25,.28),(x,-1.55,.18)],Carbon,.009,.004)
for s in [-1,1]:
 lamp_uv(tube('Tail light blade',[(s*.34,-2.237,.558),(s*.66,-2.239,.558),(s*.75,-2.22,.56)],.014,Lamps),1)
 box('Rear cooling slot',(s*.69,-2.235,.35),(.19,.018,.20),Carbon,.025)
for ss in [-1,1]:
 box('Rear lamp smoked bezel',(ss*.545,-2.235,.554),(.47,.018,.075),Carbon,.022)
 lamp_uv(tube('Rear lower LED signature',[(ss*.34,-2.249,.535),(ss*.67,-2.249,.535),(ss*.76,-2.229,.55)],.006,Lamps),1)
 lamp_uv(tube('Rear amber indicator',[(ss*.69,-2.249,.56),(ss*.75,-2.229,.56)],.006,Lamps),2)
# Swan neck uprights: connected hooked plates that approach top of wing.
for s in [-1,1]:
 panel('Swan neck wing mount',[(s*.54,-1.85,.72),(s*.54,-2.04,.92),(s*.54,-1.97,1.16),(s*.54,-2.14,1.18),(s*.54,-2.15,1.09),(s*.54,-2.09,.88),(s*.54,-1.94,.71)],Carbon,.022,.012)
 for f,h in [(-1.89,.76),(-2.08,1.13)]:cyl('Wing mounting bolt',(s*.559,f,h),.012,.01,Metal,n=12)
# airfoil wing cross section
vv=[];ff=[]
profile=[(-2.27,1.12),(-2.22,1.155),(-2.02,1.17),(-1.94,1.15),(-2.03,1.137),(-2.23,1.105)]
for x in [-.99,.99]:
 for f,h in profile:vv.append((x,f,h))
for j in range(6):ff.append((j,(j+1)%6,(j+1)%6+6,j+6))
ff.extend([tuple(range(5,-1,-1)),tuple(range(6,12))]);bevel(mesh('Rear wing airfoil',vv,ff,Carbon),.008,3)
for s in [-1,1]:panel('Wing endplate',[(s*1.01,-2.30,1.03),(s*1.01,-2.30,1.21),(s*1.01,-1.93,1.21),(s*1.01,-1.93,1.04)],Carbon,.012,.014)
# Believable interior under transparent greenhouse
box('Cockpit floor',(0,-.47,.27),(1.48,1.85,.04),Interior,.015)
box('Dashboard',(0,.39,.71),(1.44,.32,.17),Interior,.055)
box('Instrument pod',(.38,.24,.79),(.28,.18,.09),Carbon,.035)
box('Instrument screen',(.38,.145,.79),(.19,.012,.045),Glass,.006)
box('Transmission tunnel',(0,-.25,.39),(.20,1.2,.22),Carbon,.035)
for s in [-1,1]:box('Door card',(s*.74,-.35,.56),(.045,1.25,.29),Interior,.028)
# One deep bucket seat, shoulder wings and bolsters
box('Seat squab',(.36,-.59,.39),(.39,.49,.13),Interior,.06)
o=box('Seat back',(.36,-.86,.67),(.40,.13,.57),Interior,.055);o.rotation_euler[0]=-.12
box('Head restraint',(.36,-.91,.98),(.24,.12,.17),Interior,.055)
for s in [-1,1]:
 box('Seat side bolster',(.36+s*.19,-.59,.49),(.07,.48,.16),Carbon,.03)
 box('Shoulder protection',(.36+s*.21,-.82,.86),(.09,.22,.18),Carbon,.035)
 tube('Harness shoulder strap',[(.36+s*.075,-.92,.91),(.36+s*.075,-.78,.75),(.36+s*.065,-.55,.45)],.017,Rubber)
box('Harness buckle',(.36,-.51,.45),(.065,.022,.04),Metal,.009)
for f in [.17,-1.13]:
 tube('Roll cage hoop',[(-.70,f,.28),(-.71,f,.77),(-.58,f,.98 if f>0 else 1.09),(.58,f,.98 if f>0 else 1.09),(.71,f,.77),(.70,f,.28)],.023,Metal)
for s in [-1,1]:
 tube('Cage roof rail',[(s*.58,.17,.98),(s*.58,-1.13,1.09)],.023,Metal)
 tube('Door side impact bar',[(s*.71,.18,.35),(s*.71,-1.12,.75)],.022,Metal)
tube('Rear cage cross brace',[(-.69,-1.13,.31),(.58,-1.13,1.09)],.022,Metal)
tube('Rear cage cross brace',[(.69,-1.13,.31),(-.58,-1.13,1.09)],.022,Metal)
# Steering assembly separately joined below
before=set(bpy.data.objects)
hub=(.38,.08,.74)
tube('Steering rim',[(hub[0]+.135*cos(2*pi*i/64),hub[1],hub[2]+.115*sin(2*pi*i/64)) for i in range(65)],.018,Rubber)
for a in [0,pi,1.5*pi]:tube('Steering spoke',[hub,(hub[0]+.12*cos(a),hub[1],hub[2]+.10*sin(a))],.014,Metal)
cyl('Steering hub',hub,.042,.035,Carbon,'F',32)
steer=list(set(bpy.data.objects)-before)
tube('Steering column',[(.38,.08,.74),(.38,.36,.66)],.027,Metal)
# Wheels: full slick profile, turned rims, ten split spokes and drilled discs.
wheel_groups={}
for axle,f in [('F',1.325),('R',-1.325)]:
 for side,s in [('L',1),('R',-1)]:
  before=set(bpy.data.objects);cx=s*.855;center=(cx,f,.345)
  lathe('Slick tyre',center,[(-.15,.242),(-.169,.267),(-.17,.306),(-.145,.335),(-.10,.345),(.10,.345),(.145,.335),(.17,.306),(.169,.267),(.15,.242)],Rubber,80)
  for xx in [-.155,.155]:
   lathe('Sidewall bead',center,[(xx,.257),(xx+s*.002,.265),(xx,.273)],Rubber,80)
  lathe('Forged rim barrel',center,[(-.15,.244),(-.15,.256),(.15,.256),(.158,.247),(.14,.236),(-.14,.236)],Metal,80)
  outer=cx+s*.158
  # disc visible behind open spokes
  lathe('Brake rotor',(cx+s*.09,f,.345),[(-.009,.075),(-.009,.212),(.009,.212),(.009,.075),(-.009,.075)],Metal,80)
  for ring,r in enumerate([.145,.184]):
   for i in range(26):
    a=2*pi*(i+ring*.5)/26;cyl('Rotor drill',(cx+s*.102,f+r*sin(a),.345+r*cos(a)),.004,.002,Rubber,n=8)
  box('Brake caliper',(cx+s*.065,f-.17,.37),(.07,.065,.20),Carbon,.02)
  for i in range(10):
   a=i*2*pi/10
   for branch in [-1,1]:
    b=a+branch*.065
    pts=[(outer-s*.023,f+.068*sin(a),.345+.068*cos(a)),(outer,f+.236*sin(b),.345+.236*cos(b))]
    tube('Forged split spoke',pts,.012,Metal)
  cyl('Centre lock', (outer+s*.007,f,.345),.052,.028,Carbon,n=12)
  cyl('Axle nut', (outer+s*.026,f,.345),.030,.014,Metal,n=6)
  for i in range(5):
   a=i*2*pi/5;cyl('Hub locking pin',(outer+s*.023,f+.041*sin(a),.345+.041*cos(a)),.004,.005,Metal,n=8)
  wheel_groups['Wheel_'+axle+side]=(list(set(bpy.data.objects)-before),center)
# Fit bonnet and headlamp details to the actual sculpted shell.
fit={'Hood extractor':.003,'Bonnet louvre':.011,'Headlight housing':.004,'Angular daytime light':.019,'LED projector':.019,'Amber indicator':.019,'Flush bonnet pin':.009}
for ob in list(S.objects):
 key=next((k for k in fit if ob.name.startswith(k)),None)
 if key and ob.type=='MESH':
  inv=ob.matrix_world.inverted();world=[ob.matrix_world@v.co for v in ob.data.vertices];low=min(v.z for v in world)
  for ve,co in zip(ob.data.vertices,world):
   co.z=shellheight(co.x,-co.y)+fit[key]+(co.z-low)*.08;ve.co=inv@co
# Replace planar extractor n-gons with tessellated curved inserts.
for ob in list(S.objects):
 if ob.name.startswith('Hood extractor'):bpy.data.objects.remove(ob,do_unlink=True)
for ss in [-1,1]:
 vs=[];fs=[]
 for i in range(17):
  f=1.08+.61*i/16
  for j in range(9):
   x=ss*(.32+.23*j/8);vs.append((x,f,shellheight(x,f)+.004))
 for i in range(16):
  for j in range(8):a=i*9+j;fs.append((a,a+1,a+10,a+9))
 mesh('Flush bonnet extractor well',vs,fs,Carbon)
for ob in list(S.objects):
 if ob.name.startswith('Headlight housing'):bpy.data.objects.remove(ob,do_unlink=True)
for ss in [-1,1]:
 outline=[(.54,2.15),(.83,1.92),(.86,1.76),(.66,1.94)]
 for name,material,offset in [('Sculpted headlamp well',Carbon,.006),('Headlamp clear cover',Glass,.027)]:
  vs=[];fs=[]
  for i in range(13):
   u=i/12
   for j in range(7):
    v=j/6
    x=(1-u)*((1-v)*outline[0][0]+v*outline[1][0])+u*((1-v)*outline[3][0]+v*outline[2][0])
    f=(1-u)*((1-v)*outline[0][1]+v*outline[1][1])+u*((1-v)*outline[3][1]+v*outline[2][1])
    vs.append((ss*x,f,shellheight(x,f)+offset))
  for i in range(12):
   for j in range(6):a=i*7+j;fs.append((a,a+1,a+8,a+7))
  mesh(name,vs,fs,material)
 # Rubber boot closes wheel wells inward of the visible rotors.
 for fc in [-1.325,1.325]:
  vs=[(ss*.64,fc,.345)]+[(ss*.64,fc+.397*sin(2*pi*i/64),max(.08,.345+.397*cos(2*pi*i/64))) for i in range(65)]
  mesh('Inner wheel arch closure',vs,[(0,i+1,i+2) for i in range(64)],Rubber)
for ob in list(S.objects):
 if ob.name.startswith('Flush bonnet pin'):bpy.data.objects.remove(ob,do_unlink=True)
for ss in [-1,1]:
 for f in [.85,1.9]:
  x=ss*.65;z=shellheight(x,f)
  cyl('Bonnet pin recessed grommet',(x,f,z+.001),.013,.003,Carbon,'Z',24)
  cyl('Bonnet locking button',(x,f,z+.004),.006,.004,Metal,'Z',16)
# Sweep the bumper corners inward for a curved, manufactured nose and tail.
for ob in list(S.objects):
 if ob.type=='MESH' and any(ob.name.startswith(n) for n in ['Sculpted monocoque','Sculpted bumper','Intake perimeter','Radiator dark core','Rear lamp','Rear lower LED','Rear amber','Tail light blade','Rear cooling slot']):
  inv=ob.matrix_world.inverted()
  for ve in ob.data.vertices:
   co=ob.matrix_world@ve.co;f=-co.y
   if abs(f)>1.9:co.y += math.copysign(.075*(abs(co.x)/.98)**4*min(1,(abs(f)-1.9)/.33),f);ve.co=inv@co
# Thin door graphics and shut lines follow the curved side skin exactly.
for ob in list(S.objects):
 if ob.type=='MESH' and any(ob.name.startswith(n) for n in ['Blank panel','Blank number','Door shutline','Door latch']):
  inv=ob.matrix_world.inverted();world=[ob.matrix_world@ve.co for ve in ob.data.vertices];base=min(abs(v.x) for v in world)
  for ve,co in zip(ob.data.vertices,world):
   f=-co.y;w=width(f);lo=lower(f);h=top(f);sw=.11*sum(math.exp(-((f-c)/.47)**2) for c in [-1.325,1.325]);sh=max(h+sw-.025,lo+.025)-.035
   t=max(0,min(1,(co.z-lo)/(sh-lo)));skin=w*(.94+.06*sin(t*pi/2))
   offset=.002 if ob.name.startswith('Blank number') else .001
   co.x=math.copysign(skin+offset+(abs(co.x)-base)*.5,co.x);ve.co=inv@co
# Normalize width without changing wheelbase, and keep all pivots consistent.
allpts=[ob.matrix_world@ve.co for ob in S.objects if ob.type=='MESH' for ve in ob.data.vertices]
xfactor=1.02/max(abs(v.x) for v in allpts)
for ob in list(S.objects):
 if ob.type=='MESH':
  ob.location.x*=xfactor
  for ve in ob.data.vertices:ve.co.x*=xfactor
hub=(hub[0]*xfactor,hub[1],hub[2])
wheel_groups={n:(obs,(c[0]*xfactor,c[1],c[2])) for n,(obs,c) in wheel_groups.items()}
for ob in list(S.objects):
 if ob.type=='MESH':
  inv=ob.matrix_world.inverted()
  for ve in ob.data.vertices:
   co=ob.matrix_world@ve.co
   if co.z>1.22:co.z=1.22;ve.co=inv@co
maxz=max((ob.matrix_world@ve.co).z for ob in S.objects if ob.type=='MESH' for ve in ob.data.vertices)
for ob in S.objects:
 if ob.type=='MESH':
  inv=ob.matrix_world.inverted()
  for ve in ob.data.vertices:
   co=ob.matrix_world@ve.co
   if co.z>.95:co.z += (1.22-maxz)*(co.z-.95)/(maxz-.95);ve.co=inv@co
# Assembly, apply transforms, preserve precise pivots.
def join(obs,name,pivot):
 bpy.ops.object.select_all(action='DESELECT')
 for o in obs:o.select_set(True)
 bpy.context.view_layer.objects.active=obs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 S.cursor.location=V(pivot);bpy.ops.object.origin_set(type='ORIGIN_CURSOR');bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 # ensure normals outward and remove any accidental zero-area faces
 import bmesh
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bmesh.ops.dissolve_degenerate(bm,dist=1e-7,edges=list(bm.edges));bm.to_mesh(o.data);bm.free()
 return o
wheel_objs=[]
for name,(obs,c) in wheel_groups.items():wheel_objs.append(join(obs,name,c))
steer_obj=join(steer,'SteeringWheel',hub)
fixed=[o for o in S.objects if o.type=='MESH' and o not in wheel_objs+[steer_obj]]
body=join(fixed,'Body',(0,0,0));asset=[body,*wheel_objs,steer_obj]
# Triangulated final meshes; add surface subdivision only if under game budget.
for o in asset:
 activate(o);mod=o.modifiers.new('Final triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
tri=sum(len(o.data.polygons) for o in asset);assert 60000<=tri<=110000,tri;print('TRIANGLES',tri,flush=True)
# Contract sockets.
sockets={'Seat_Driver':(.36,-.59,.44),'Door_L':(1.06,-.25,.30),'Door_R':(-1.06,-.25,.30),'Exhaust':(.975,-.47,.21),'Light_Head_L':(.73,2.05,.56),'Light_Head_R':(-.73,2.05,.56),'Light_Tail_L':(.57,-2.24,.55),'Light_Tail_R':(-.57,-2.24,.55)}
for name,p in sockets.items():
 o=bpy.data.objects.new(name,None);S.collection.objects.link(o);o.location=V((p[0]*xfactor,p[1],shellheight(p[0],p[1])+.019 if name.startswith('Light_Head') else p[2]));asset.append(o)
paints=[{'name':'Race red','linear_rgb':[.8,.03,.02]},{'name':'Glacier silver','linear_rgb':[.55,.60,.64]},{'name':'Deep ocean','linear_rgb':[.012,.075,.23]},{'name':'British green','linear_rgb':[.014,.16,.085]},{'name':'Endurance yellow','linear_rgb':[.94,.56,.018]},{'name':'Graphite','linear_rgb':[.045,.055,.065]}]
points=[o.matrix_world@Vector(c) for o in asset if o.type=='MESH' for c in o.bound_box]
lo=[min(p[i] for p in points) for i in range(3)];hi=[max(p[i] for p in points) for i in range(3)]
manifest={'units':'metres','axes':'+Z forward, +Y up, +X left','nodes':{o.name:[o.location.x,o.location.z,-o.location.y] for o in asset},'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]},'triangles':tri,'paints':paints}
open(ROOT+'/nodes.json','w').write(json.dumps(manifest,indent=2))
# Export only asset, then explicitly set paint texture/factor, ensuring glTF runtime contract.
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=ROOT+'/gt-racer.glb',export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_texcoords=True,export_normals=True,export_materials='EXPORT')
def patch_glb():
 path=ROOT+'/gt-racer.glb';data=open(path,'rb').read();jl=struct.unpack_from('<I',data,12)[0];j=json.loads(data[20:20+jl]);binary=data[20+jl:]
 for m in j['materials']:
  if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.8,.03,.02,1]
  if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
 raw=json.dumps(j,separators=(',',':')).encode();raw+=b' '*((-len(raw))%4);out=struct.pack('<4sII',b'glTF',2,20+len(raw)+len(binary))+struct.pack('<I4s',len(raw),b'JSON')+raw+binary;open(path,'wb').write(out)
patch_glb()
print('GLB COMPLETE',os.path.getsize(ROOT+'/gt-racer.glb'),flush=True)
bpy.ops.wm.save_as_mainfile(filepath=ROOT+'/asset.blend')
# Render staging is intentionally excluded from export.
ground=mat('Studio floor',(.32,.35,.38),.73);bpy.ops.mesh.primitive_plane_add(size=200);floor=bpy.context.object;floor.location.z=-.003;floor.data.materials.append(ground)
S.world.use_nodes=True;S.world.node_tree.nodes.get('Background').inputs[0].default_value=(.65,.72,.82,1);S.world.node_tree.nodes.get('Background').inputs[1].default_value=.45
for name,pos,power,size in [('Key',(3,4,7),1500,5),('Fill',(-4,1,4),1000,4),('Rim',(1,-5,5),1800,3)]:
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='DISK';d.size=size;o=bpy.data.objects.new(name,d);S.collection.objects.link(o);o.location=V(pos);o.rotation_euler=(Vector((0,0,.5))-o.location).to_track_quat('-Z','Y').to_euler()
d=bpy.data.cameras.new('Camera');d.clip_end=1000;cam=bpy.data.objects.new('Camera',d);S.collection.objects.link(cam);S.camera=cam

def camera(pos,target=(0,0,.57),ortho=6.1):
 cam.location=V(pos);cam.rotation_euler=(Vector(V(target))-cam.location).to_track_quat('-Z','Y').to_euler();d.type='ORTHO';d.ortho_scale=ortho

def render(file,w=1920,h=1080,samples=32):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=ROOT+'/'+file;bpy.ops.render.render(write_still=True)
camera((5.7,7,2.7));render('final.png')
camera((5,-7,2.7));render('rear.png',1280,720,24)
camera((7,0,1.0),ortho=5.4);render('side.png',1920,1080,24)
camera((5.7,7,2.7));clay=mat('Clay',(.36,.36,.36),.6)
saved={}
for o in asset:
 if o.type=='MESH':
  saved[o.name]=list(o.data.materials)
  for i in range(len(o.data.materials)):o.data.materials[i]=clay
render('clay.png',1920,1080,24)
for o in asset:
 if o.type=='MESH':
  for i,m in enumerate(saved[o.name]):o.data.materials[i]=m
# Six same-camera paint renders, assembled inside Blender without external packages.
frames=[]
for i,col in enumerate(paints):
 Paint.node_tree.nodes['PaintFactor'].inputs[2].default_value=(*col['linear_rgb'],1);render('paint_'+str(i)+'.png',640,360,12)
 im=bpy.data.images.load(ROOT+'/paint_'+str(i)+'.png',check_existing=False);frames.append(np.array(im.pixels[:]).reshape(360,640,4))
grid=np.zeros((720,1920,4),dtype=np.float32)
for i,a in enumerate(frames):grid[(1-i//3)*360:(2-i//3)*360,(i%3)*640:(i%3+1)*640]=a
im=bpy.data.images.new('Paints grid',width=1920,height=720);im.pixels.foreach_set(grid.ravel());im.filepath_raw=ROOT+'/paints.png';im.file_format='PNG';im.save()
Paint.node_tree.nodes['PaintFactor'].inputs[2].default_value=(.8,.03,.02,1)
open(ROOT+'/NOTES.md','w').write(f'Original front-engine endurance GT coupe, metres, Garage v2 nodes; clean scripted Cycles bake/export.\nTriangles: {tri:,}.\nMaterials: Paint, Carbon, Rubber, Metal, Interior, Glass, Lamps, Panels (8).\nTextures: six shared baked sets, 512px base/normal and 256px ORM; 12x4 lamps; source scans 1024px.\nPoly Haven CC0: metal_plate, fabric_leather_02; https://polyhaven.com/license ; sources downloaded from official CDN.\nDifferences: original angular lamps, grille and window line; red recolourable paint; simplified cockpit, underbody and lamp optics; body surfacing remains less refined than the reference; no tyre lettering.\n')
print('ALL DELIVERABLES COMPLETE',flush=True)

# Export roundtrip proof: original asset removed, only delivered GLB rendered.
for ob in asset:bpy.data.objects.remove(ob,do_unlink=True)
bpy.ops.import_scene.gltf(filepath=ROOT+'/gt-racer.glb')
camera((5.7,7,2.7));render('roundtrip.png',1280,720,24)
