"""Original endurance prototype. Run with Blender 5.2 --background --python.
Coordinates in helpers: x=left, y=forward, z=up; emitted Blender y=-forward.
All procedural/P oly Haven surface detail baked with Cycles before export.
"""
import bpy, bmesh, math, os, json, struct, urllib.request, subprocess, time
import numpy as np
from mathutils import Vector
from pathlib import Path
ROOT=Path(__file__).resolve().parent
os.chdir(ROOT)
START=time.time()
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene; S.render.engine='CYCLES';S.cycles.samples=16
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.view_settings.view_transform='AgX'
S.view_settings.look='AgX - Medium High Contrast'
S.view_settings.exposure=-.5
M={}; GROUPS={'Body':[],'SteeringWheel':[]}; CURRENT='Body'
PAINTS=[('Obsidian',[.03,.03,.035]),('Glacier silver',[.58,.63,.68]),('Vermilion',[.48,.022,.012]),('Midnight petrol',[.012,.105,.12]),('Champagne',[.43,.31,.15]),('Arctic white',[.8,.82,.78])]

def download(id,mp):
 p=ROOT/'textures'/f'{id}_{mp}_1k.jpg';p.parent.mkdir(exist_ok=True)
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{mp}_1k.jpg'
  subprocess.run(['curl','-fL','--retry','2','-s',url,'-o',str(p)],check=True)
 return bpy.data.images.load(str(p),check_existing=True)
# Source IDs chosen from https://api.polyhaven.com/assets?t=textures (CC0).
SOURCES={id:{mp:download(id,mp) for mp in ('diff','nor_gl','arm')} for id in ('metal_plate_02','rubberized_track','fabric_leather_02')}
for maps in SOURCES.values():
 for k in ('nor_gl','arm'):maps[k].colorspace_settings.name='Non-Color'

def material(name,color,metal,rough,src=None):
 m=bpy.data.materials.new(name);m.use_nodes=True;M[name]=m
 nt=m.node_tree;n=nt.nodes;l=nt.links;bs=n.get('Principled BSDF')
 bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough
 uv=n.new('ShaderNodeTexCoord');noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=130;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.15;ramp.color_ramp.elements[0].color=(*(v*.78 for v in color),1);ramp.color_ramp.elements[1].position=.83;ramp.color_ramp.elements[1].color=(*color,1);l.new(noise.outputs['Fac'],ramp.inputs[0]);col=ramp.outputs['Color']
 if src:
  im=n.new('ShaderNodeTexImage');im.image=SOURCES[src]['diff'];l.new(uv.outputs['UV'],im.inputs['Vector'])
  mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.025;l.new(col,mix.inputs[1]);l.new(im.outputs['Color'],mix.inputs[2]);col=mix.outputs[0]
  norm=n.new('ShaderNodeTexImage');norm.image=SOURCES[src]['nor_gl'];l.new(uv.outputs['UV'],norm.inputs['Vector'])
  nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.012 if name=='Paint' else .04;l.new(norm.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
  arm=n.new('ShaderNodeTexImage');arm.image=SOURCES[src]['arm'];l.new(uv.outputs['UV'],arm.inputs['Vector'])
  sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0]);rr=n.new('ShaderNodeMapRange');rr.inputs['To Min'].default_value=rough*.85;rr.inputs['To Max'].default_value=min(.95,rough*1.18);l.new(sep.outputs['Green'],rr.inputs['Value']);l.new(rr.outputs[0],bs.inputs['Roughness'])
 l.new(col,bs.inputs['Base Color'])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.035;bump.inputs['Distance'].default_value=.00016;l.new(noise.outputs['Fac'],bump.inputs['Height'])
 if bs.inputs['Normal'].is_linked:l.new(bs.inputs['Normal'].links[0].from_socket,bump.inputs['Normal'])
 
 if name not in ('Glass','Lamps'):l.new(bump.outputs[0],bs.inputs['Normal'])
 return m
material('Paint',(.97,.97,.97),.08,.32,'metal_plate_02')
material('Carbon',(.036,.040,.046),.38,.30,'metal_plate_02')
material('Rubber',(.012,.014,.016),0,.76,'rubberized_track')
material('Metal',(.16,.18,.20),.85,.38,'metal_plate_02')
material('Interior',(.032,.036,.04),0,.72,'fabric_leather_02')
material('Rim',(.035,.040,.047),.72,.38,'metal_plate_02')
material('Glass',(.095,.15,.18),.05,.09)
material('Lamps',(1,.90,.73),.15,.24)
# Carbon 2x2 twill weave, baked into a reusable tiling set.
m=M['Carbon'];nt=m.node_tree;n=nt.nodes;l=nt.links;bs=n.get('Principled BSDF');uv=n.get('Texture Coordinate')
w=n.new('ShaderNodeTexWave');w.wave_type='BANDS';w.bands_direction='DIAGONAL';w.inputs['Scale'].default_value=76;w.inputs['Distortion'].default_value=0;l.new(uv.outputs['UV'],w.inputs['Vector'])
ck=n.new('ShaderNodeTexChecker');ck.inputs['Scale'].default_value=110;ck.inputs['Color1'].default_value=(.012,.016,.021,1);ck.inputs['Color2'].default_value=(.055,.062,.07,1);l.new(uv.outputs['UV'],ck.inputs['Vector'])
mx=n.new('ShaderNodeMixRGB');mx.blend_type='MULTIPLY';mx.inputs[0].default_value=.32;l.new(ck.outputs[0],mx.inputs[1]);l.new(w.outputs['Color'],mx.inputs[2]);l.new(mx.outputs[0],bs.inputs['Base Color'])
b=n.new('ShaderNodeBump');b.inputs['Strength'].default_value=.18;b.inputs['Distance'].default_value=.00025;l.new(w.outputs['Color'],b.inputs['Height']);l.new(b.outputs[0],bs.inputs['Normal'])
# Lamps use three UV zones in a single emissive image.
m=M['Lamps'];n=m.node_tree.nodes;l=m.node_tree.links;bs=n.get('Principled BSDF');uv=n.get('Texture Coordinate');sep=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],sep.inputs[0]);r=n.new('ShaderNodeValToRGB');r.color_ramp.interpolation='CONSTANT';r.color_ramp.elements[0].position=0;r.color_ramp.elements[0].color=(1,.88,.68,1);r.color_ramp.elements[1].position=.333;r.color_ramp.elements[1].color=(1,.003,.001,1);r.color_ramp.elements.new(.667).color=(1,.23,.003,1);l.new(sep.outputs[0],r.inputs[0]);l.new(r.outputs[0],bs.inputs['Base Color'])

# Bake each material's final tile. Colour and packed ORM are emission passes;
# tangent-space normals are a Cycles NORMAL pass. AO is texture micro-occlusion.
def bake_material(m):
 name=m.name;size=1024 if name in ('Paint','Carbon') else (64 if name in ('Glass','Lamps') else 512)
 bpy.ops.mesh.primitive_plane_add(size=1);o=bpy.context.object;o.name='BakeTile';o.data.materials.append(m)
 nt=m.node_tree;n=nt.nodes;l=nt.links;bs=n.get('Principled BSDF');out=n.get('Material Output');original=out.inputs['Surface'].links[0].from_socket
 col=bs.inputs['Base Color'].links[0].from_socket
 rough=bs.inputs['Roughness'].links[0].from_socket if bs.inputs['Roughness'].is_linked else None
 em=n.new('ShaderNodeEmission');target=n.new('ShaderNodeTexImage')
 imgs={}
 for typ,res in [('base',size),('normal',size),('orm',min(size,512))]:
  im=bpy.data.images.new(name+'_'+typ,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if typ=='base' else 'Non-Color';target.image=im;n.active=target
  S.cycles.samples=8;S.render.bake.margin=4
  if typ=='normal':l.new(original,out.inputs['Surface']);bpy.ops.object.bake(type='NORMAL')
  else:
   if typ=='base':l.new(col,em.inputs['Color'])
   else:
    combine=n.new('ShaderNodeCombineColor');combine.inputs['Red'].default_value=.97;combine.inputs['Green'].default_value=bs.inputs['Roughness'].default_value;combine.inputs['Blue'].default_value=bs.inputs['Metallic'].default_value
    if rough:l.new(rough,combine.inputs['Green'])
    l.new(combine.outputs[0],em.inputs['Color'])
   l.new(em.outputs[0],out.inputs['Surface']);bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=str(ROOT/'textures'/f'{name}_{typ}.png');im.file_format='PNG';im.save();imgs[typ]=im
 # portable glTF graph
 n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 for typ,im in imgs.items():
  t=n.new('ShaderNodeTexImage');t.image=im;t.name=typ
  if typ=='base':l.new(t.outputs[0],bs.inputs['Base Color'])
  elif typ=='normal':nm=n.new('ShaderNodeNormalMap');l.new(t.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
  else:
   sep=n.new('ShaderNodeSeparateColor');l.new(t.outputs[0],sep.inputs[0]);l.new(sep.outputs['Green'],bs.inputs['Roughness']);l.new(sep.outputs['Blue'],bs.inputs['Metallic'])
   group=bpy.data.node_groups.get('glTF Material Output')
   if not group:
    group=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');group.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
   gn=n.new('ShaderNodeGroup');gn.node_tree=group;l.new(sep.outputs['Red'],gn.inputs['Occlusion'])
 if name=='Paint':bs.inputs['Coat Weight'].default_value=1;bs.inputs['Coat Roughness'].default_value=.05
 if name=='Glass':
  bs.inputs['Alpha'].default_value=.20;m.surface_render_method='DITHERED';m.diffuse_color=(.1,.15,.18,.28)
 if name=='Lamps':l.new(n['base'].outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=3
 bpy.data.objects.remove(o,do_unlink=True)
 print('BAKED',name,flush=True)
for m in M.values():bake_material(m)

# Geometry library. Faceted lofts, bevelled panels, true arched shells.
def V(p):return (p[0],-p[1],p[2])
def register(o,mat,smooth=False,group=None):
 o.data.materials.append(M[mat]);GROUPS.setdefault(group or CURRENT,[]).append(o)
 if smooth:
  for p in o.data.polygons:p.use_smooth=True
 return o
def mesh(name,verts,faces,mat,bev=0,smooth=False):
 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);bpy.context.collection.objects.link(o)
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free();register(o,mat,smooth)
 if bev:bevel(o,bev)
 return o
def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def bevel(o,w=.004,seg=3):
 active(o);m=o.modifiers.new('Machined edge radius','BEVEL');m.width=w;m.segments=seg
 bpy.ops.object.modifier_apply(modifier=m.name)
 # Weighted normals keep broad panel faces planar while roundovers catch light.
 try:
  m=o.modifiers.new('Surface normals','WEIGHTED_NORMAL');m.keep_sharp=True;m.weight=40;bpy.ops.object.modifier_apply(modifier=m.name)
 except:pass
 return o
def box(name,loc,dim,mat,bev=.006):
 bpy.ops.mesh.primitive_cube_add(size=1,location=V(loc));o=bpy.context.object;o.name=name;o.dimensions=dim;active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);register(o,mat)
 if bev:bevel(o,bev)
 return o
def panel(name,pts,mat,thick=.008,bev=.003):
 o=mesh(name,pts,[tuple(range(len(pts)))],mat)
 active(o);mod=o.modifiers.new('Panel thickness','SOLIDIFY');mod.thickness=thick;bpy.ops.object.modifier_apply(modifier=mod.name)
 if bev:bevel(o,bev)
 return o
def rod(name,a,b,r,mat,vertices=12):
 a,b=Vector(V(a)),Vector(V(b));d=b-a
 bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=r,depth=d.length,location=(a+b)/2);o=bpy.context.object;o.name=name;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);register(o,mat,True);bevel(o,min(.0015,r*.15),2);return o
def cylinder(name,loc,r,depth,mat,vertices=96):return rod(name,(loc[0]-depth/2,loc[1],loc[2]),(loc[0]+depth/2,loc[1],loc[2]),r,mat,vertices)
def lathe(name,c,profile,mat,N=128):
 verts=[]
 for x,r in profile:
  for i in range(N):
   a=2*math.pi*i/N;verts.append((c[0]+x,c[1]+r*math.sin(a),c[2]+r*math.cos(a)))
 faces=[]
 for j in range(len(profile)):
  k=(j+1)%len(profile)
  for i in range(N):faces.append((j*N+i,j*N+(i+1)%N,k*N+(i+1)%N,k*N+i))
 return mesh(name,verts,faces,mat,smooth=True)
def cut(o,c):
 active(o);mod=o.modifiers.new('Recess','BOOLEAN');mod.operation='DIFFERENCE';mod.solver='EXACT';mod.object=c;bpy.ops.object.modifier_apply(modifier=mod.name)
 for g in GROUPS.values():
  if c in g:g.remove(c)
 bpy.data.objects.remove(c,do_unlink=True)
def prism(name,pts,x0,x1,mat):
 verts=[(x,y,z) for x in (x0,x1) for y,z in pts];n=len(pts);faces=[tuple(range(n-1,-1,-1)),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 return mesh(name,verts,faces,mat)
# Main painted shell: longitudinal creases with a low central bonnet and arched shoulders.
sections=[(-2.40,.88,.52,.58),(-2.18,.97,.58,.66),(-1.82,.985,.63,.74),(-1.525,.99,.66,.76),(-1.15,.97,.66,.73),(-.78,.93,.62,.65),(-.30,.88,.60,.61),(.25,.88,.59,.63),(.75,.93,.58,.67),(1.15,.975,.56,.74),(1.525,.99,.54,.76),(1.85,.975,.50,.72),(2.12,.94,.44,.60),(2.39,.88,.385,.425),(2.43,.83,.37,.39)]
# Smooth longitudinal curvature while retaining the authored shoulder creases.
original_sections=sections
sections=[]
for j in range(len(original_sections)-1):
 p0=original_sections[max(0,j-1)];p1=original_sections[j];p2=original_sections[j+1];p3=original_sections[min(len(original_sections)-1,j+2)]
 for k in range(4):
  t=k/4
  values=[p1[0]+(p2[0]-p1[0])*t]
  for a in range(1,4):values.append(.5*((2*p1[a])+(-p0[a]+p2[a])*t+(2*p0[a]-5*p1[a]+4*p2[a]-p3[a])*t*t+(-p0[a]+3*p1[a]-3*p2[a]+p3[a])*t*t*t))
  sections.append(tuple(values))
sections.append(original_sections[-1])
verts=[]
for y,w,h,shoulder in sections:
 # full continuous cross section; waist undercut is a real recessed side wall
 ring=[(-w,.15),(-w,.36),(-w,shoulder-.07),(-w*.94,shoulder-.008),(-w*.80,shoulder+.006),(-w*.62,shoulder-.012),(-w*.50,h+.012),(0,h),(w*.50,h+.012),(w*.62,shoulder-.012),(w*.80,shoulder+.006),(w*.94,shoulder-.008),(w,shoulder-.07),(w,.36),(w,.15),(w*.69,.125),(-w*.69,.125)]
 verts += [(x,y,z) for x,z in ring]
n=17;faces=[tuple(range(n-1,-1,-1)),tuple((len(sections)-1)*n+i for i in range(n))]
for j in range(len(sections)-1):
 for i in range(n):faces.append((j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i))
body=mesh('Sculpted monocoque shell',verts,faces,'Paint')
for y in (-1.525,1.525):
 for s in (-1,1):
  c=cylinder('Wheel clearance',(s*.90,y,.355),.374,.49,'Rubber',128);cut(body,c)
# Actual side cavities, their inner carbon skins and pronounced sill.
for s in (-1,1):
 pts=[(-1.08,.285),(-.78,.58),(.38,.56),(.62,.43),(.27,.23),(-.76,.21)]
 c=prism('Duct cutter',pts,s*.61,s*1.12,'Carbon');cut(body,c)
 panel('Duct inner wall',[(s*.635,y,z) for y,z in pts],'Carbon',.012)
 for i in range(len(pts)):
  y,z=pts[i];yy,zz=pts[(i+1)%len(pts)]
  panel('Duct return',[(s*.64,y,z),(s*.92,y,z),(s*.92,yy,zz),(s*.64,yy,zz)],'Carbon',.006)
 # dramatic rear-facing buttress defines intake silhouette
 panel('Intake blade',[(s*.945,-1.08,.22),(s*.95,-.77,.59),(s*.80,-.48,.57),(s*.69,-.32,.24)],'Paint',.018,.005)
 panel('Sill aero rail',[(s*.99,-1.12,.095),(s*.99,.99,.095),(s*.92,.98,.155),(s*.89,-1.11,.19)],'Carbon',.022,.006)
# Front opening under the flat brow.
c=box('Front duct cutter',(0,2.27,.24),(1.62,.55,.22),'Carbon',.045);cut(body,c)
box('Front intake dark core',(0,2.02,.24),(1.57,.035,.20),'Carbon',.015)
# Recess the twin bonnet extractor apertures into the shell.
for ss in (-1,1):
 c=box('Bonnet vent cutter',(ss*.265,1.19,.568),(.18,.42,.07),'Carbon',.015);cut(body,c)
 box('Bonnet vent cavity',(ss*.265,1.19,.525),(.18,.42,.012),'Carbon',.01)
bevel(body,.009,3)
active(body)
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(26),keep_sharp_edges=True)
# Preserve broad panels and deliberate crease lines after curved fender smoothing.
mod=body.modifiers.new('Refined panel normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=25;bpy.ops.object.modifier_apply(modifier=mod.name)

# Splitter perimeter uses rounded polygon, 5.00m full length between ends.
outline=[(-.86,2.50,.075),(-.98,2.32,.075),(-1,1.9,.075),(-.99,1.12,.075),(-.88,1.03,.075),(.88,1.03,.075),(.99,1.12,.075),(1,1.9,.075),(.98,2.32,.075),(.86,2.50,.075)]
splitter=panel('Front splitter',outline,'Carbon',.028,.008)
# Splitter cannot occupy a tyre's sweep; wheel pockets continue to the ground.
for ss in (-1,1):
 cutter=cylinder('Splitter wheel pocket',(ss*.9,1.525,.355),.385,.56,'Rubber',96);cut(splitter,cutter)
for s in (-1,1):
 panel('Front outer duct pylon',[(s*.83,2.40,.105),(s*.93,2.22,.13),(s*.87,2.28,.40),(s*.70,2.40,.37)],'Paint',.032,.007)
 panel('Front intake vane',[(s*.40,2.43,.1),(s*.53,2.17,.32),(s*.54,2.03,.32),(s*.41,2.10,.1)],'Carbon',.014,.003)
 rod('Splitter stay',(s*.62,2.30,.10),(s*.65,2.12,.35),.006,'Metal')
 # Original angular slim lamp pockets, two slits each, slightly swept.
 panel('Headlight pocket',[(s*.41,2.442,.365),(s*.86,2.405,.377),(s*.88,2.395,.43),(s*.44,2.442,.418)],'Carbon',.014,.007)
 for j in range(2):
  x0=.46+j*.18;x1=x0+.152
  panel('LED warm white',[(s*x0,2.454-j*.018,.385),(s*x1,2.441-j*.018,.390),(s*x1,2.441-j*.018,.407),(s*x0,2.454-j*.018,.402)],'Lamps',.006,.003)
 # carbon diveplanes and louvred wheel-arch extraction slots
 for j in range(2):
  panel('Diveplane',[(s*.95,1.96-j*.12,.26+j*.09),(s*.995,2.11-j*.10,.30+j*.09),(s*.97,2.25-j*.10,.31+j*.09)],'Carbon',.009,.003)
 for j in range(5):
  y=1.18+j*.085
  panel('Fender extraction louvre',[(s*.70,y,.731),(s*.87,y,.752),(s*.87,y+.027,.747),(s*.70,y+.027,.727)],'Carbon',.008,.003)
 # bonnet cut lines and flush quick-release pins
 for y in (1.03,1.96):
  cylinder('Bonnet flush fastener',(s*.43,y,.555 if y<1.1 else .49),.012,.004,'Metal',16).rotation_euler[1]=math.pi/2
# Front bonnet central faceting crease, small twin vents.
for s in (-1,1):
 # Recessed bonnet louvres
 for j in range(4):
  y=1.02+j*.073;rod('Bonnet vent slat',(s*.20,y,.568-(y-1)*.05),(s*.32,y,.568-(y-1)*.05),.007,'Carbon')
# Cabin canopy: genuine transparent glazing with a different, swept trapezoid window line.
roof=mesh('Canopy roof', [(-.48,-.54,1.075),(.48,-.54,1.075),(.48,.18,1.065),(-.48,.18,1.065),(-.37,-.61,1.105),(.37,-.61,1.105),(.38,.12,1.105),(-.38,.12,1.105)],[(0,1,2,3),(4,7,6,5),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],'Paint',.018)
wind=[(-.63,.91,.625),(.63,.91,.625),(.465,.19,1.061),(-.465,.19,1.061)]
panel('Windshield',wind,'Glass',.001,0)
for s in (-1,1):
 side=[(s*.642,.82,.635),(s*.478,.18,1.055),(s*.485,-.50,1.055),(s*.636,-.82,.68),(s*.66,-.36,.626)]
 panel('Side glazing',side,'Glass',.001,0)
 for a,b in zip(side,side[1:]+side[:1]):rod('Window structural frame',a,b,.013,'Carbon')
 rod('A pillar',(s*.638,.91,.626),(s*.475,.18,1.064),.022,'Paint')
 rod('B pillar',(s*.485,-.47,1.055),(s*.65,-.39,.635),.021,'Paint')
 panel('Rear canopy buttress',[(s*.49,-.51,1.065),(s*.65,-.84,.68),(s*.77,-1.39,.68),(s*.41,-.88,.91)],'Paint',.018,.008)
 # Door panel edges and release recess on upper shoulder
 rod('Door shut line',(s*.666,-.37,.625),(s*.863,.50,.608),.0025,'Rubber',8)
 box('Door release',(s*.669,-.25,.66),(.009,.075,.016),'Carbon',.005)
 # mirrors mounted on a stalk, streamlined housing
 rod('Mirror stalk',(s*.65,.64,.65),(s*.91,.63,.76),.011,'Carbon')
 ob=box('Mirror shell',(s*.93,.61,.77),(.13,.19,.065),'Carbon',.027)
 box('Mirror glass',(s*.93,.515,.77),(.10,.005,.042),'Metal',.012)
# Windshield seals and wiper
for a,b in zip(wind,wind[1:]+wind[:1]):rod('Windshield gasket',a,b,.010,'Rubber')
rod('Wiper arm',(.10,.916,.636),(.12,.49,.90),.004,'Metal')
rod('Wiper blade',(-.14,.495,.896),(.36,.495,.896),.006,'Rubber')
# Sealed rear canopy bulkhead ties the roof to both buttresses.
panel('Canopy rear bulkhead',[(-.48,-.54,1.075),(.48,-.54,1.075),(.65,-.84,.675),(-.65,-.84,.675)],'Paint',.018,.007)
# Close the cowl below the windshield, then narrow/rake the upper canopy.
panel('Windshield cowl',[(-.63,.91,.625),(.63,.91,.625),(.63,1.015,.586),(-.63,1.015,.586)],'Paint',.016,.006)
canopy_prefixes=('Canopy rear bulkhead','Canopy roof','Windshield','Side glazing','Window structural frame','A pillar','B pillar','Rear canopy buttress','Wiper')
for part in GROUPS['Body']:
 if part.name.startswith(canopy_prefixes):
  inv=part.matrix_world.inverted()
  for vertex in part.data.vertices:
   w=part.matrix_world@vertex.co
   if w.z>.61:
    t=min(1,max(0,(w.z-.61)/.49));w.x*=1-.16*t
    # rake the windshield top backwards, lowering its visual height
    w.y+=.12*t;w.z-=.025*t
    vertex.co=inv@w
# Tapered fastback closes the transition from canopy to engine deck.
for s in (-1,1):
 panel('Fastback shoulder',[(s*.40,-.66,1.045),(s*.64,-.87,.70),(s*.61,-1.44,.71),(s*.28,-1.42,.75)],'Paint',.018,.007)
 panel('Fastback crown',[(0,-.66,1.045),(s*.40,-.66,1.045),(s*.28,-1.42,.75),(0,-1.42,.75)],'Paint',.018,.005)
# Rear deck, roof scoop and shark fin.
box('Roof air scoop',(0,-.42,1.11),(.22,.34,.08),'Carbon',.032)
box('Scoop mouth',(0,-.245,1.113),(.17,.007,.044),'Interior',.012)
finpts=[(-.56,1.132),(-.94,1.10),(-2.19,1.105),(-2.19,.81),(-1.64,.70),(-.88,.83)]
fin=prism('Shark fin',finpts,-.014,.014,'Paint');bevel(fin,.006)
rod('Fin trailing carbon edge',(0,-2.19,.82),(0,-2.19,1.105),.015,'Carbon')
for s in (-1,1):
 for j in range(7):
  y=-1.02-j*.095
  panel('Engine deck vent',[(s*.17,y,.695),(s*.48,y,.699),(s*.48,y-.037,.69),(s*.17,y-.037,.69)],'Carbon',.009,.003)
# Rear aerofoil: cambered airfoil, not a box.
profile=[(-2.48,1.07),(-2.42,1.095),(-2.25,1.11),(-2.05,1.105),(-1.99,1.086),(-2.08,1.069),(-2.28,1.065),(-2.43,1.06)]
wing=prism('Rear wing airfoil',profile,-.965,.965,'Carbon');bevel(wing,.007)
for s in (-1,1):
 end=[(-2.49,.24),(-2.49,1.15),(-2.12,1.15),(-1.99,1.07),(-2.12,.65),(-2.20,.34)]
 ep=prism('Wing endplate',end,s*.974,s*.99,'Carbon');bevel(ep,.005)
 panel('Endplate lower attachment',[(s*.87,-2.40,.27),(s*.985,-2.40,.27),(s*.985,-2.20,.39),(s*.91,-2.20,.43)],'Carbon',.025,.007)
 box('Endplate rear bracket',(s*.935,-2.38,.48),(.11,.10,.06),'Carbon',.008)
 # swan neck mounts
 panel('Wing swan neck',[(s*.48,-1.74,.64),(s*.48,-1.92,.69),(s*.48,-2.15,1.08),(s*.48,-2.27,1.08),(s*.48,-2.08,.68)],'Carbon',.027,.007)
 for y in (-2.13,-2.32):cylinder('Wing bracket bolt',(s*.497,y,1.095),.009,.009,'Metal',12)
# Tail openings, full-width blade with an original central step.
box('Rear grille',(0,-2.408,.40),(1.70,.016,.30),'Carbon',.035)
for s in (-1,1):
 panel('Tail light housing',[(0,-2.427,.574),(s*.73,-2.427,.574),(s*.88,-2.40,.61),(s*.88,-2.40,.56),(s*.73,-2.435,.535),(0,-2.435,.535)],'Carbon',.012,.005)
 tail=panel('LED tail red',[(0,-2.442,.552),(s*.73,-2.442,.552),(s*.86,-2.415,.584),(s*.86,-2.415,.597),(s*.73,-2.442,.565),(0,-2.442,.565)],'Lamps',.005,.003);tail['lamp_zone']=1
 amber=box('Amber repeater',(s*.905,1.04,.56),(.009,.065,.014),'Lamps',.003);amber['lamp_zone']=2
# Fine rear heat mesh, diffuser tunnel geometry.
for i in range(35):rod('Rear grille vertical',(-.77+i*.045,-2.425,.31),(-.77+i*.045,-2.425,.49),.002,'Metal',6)
for i in range(5):rod('Rear grille horizontal',(-.77,-2.429,.32+i*.034),(.77,-2.429,.32+i*.034),.002,'Metal',6)
panel('Rear diffuser floor',[(-.87,-2.50,.12),(.87,-2.50,.12),(.79,-1.73,.065),(-.79,-1.73,.065)],'Carbon',.018,.005)
for x in (-.80,-.53,-.27,0,.27,.53,.80):panel('Diffuser strake',[(x,-2.49,.07),(x,-2.49,.28),(x,-1.78,.12),(x,-1.78,.06)],'Carbon',.012,.004)
# Twin real exhaust tubes axis longitudinal.
for x in (-.24,.24):
 # circles in x/z plane along y
 vs=[];N=48
 for y,r in [(-2.44,.065),(-2.34,.065),(-2.34,.052),(-2.446,.052)]:
  vs.extend([(x+r*math.sin(2*math.pi*i/N),y,.37+r*math.cos(2*math.pi*i/N)) for i in range(N)])
 faces=[(j*N+i,j*N+(i+1)%N,((j+1)%4)*N+(i+1)%N,((j+1)%4)*N+i) for j in range(4) for i in range(N)]
 mesh('Titanium exhaust outlet',vs,faces,'Metal',smooth=True)
 box('Exhaust inner darkness',(x,-2.335,.37),(.10,.004,.10),'Interior',.025)
# Cockpit interior: visible seat, harness, dashboard, cage, controls and door cards.
box('Cockpit floor',(0,-.05,.30),(1.17,1.35,.08),'Carbon',.03)
for s in (-1,1):
 seat=box('Bucket seat cushion',(s*.29,-.13,.38),(.36,.43,.10),'Interior',.052)
 seat=box('Bucket seat back',(s*.29,-.43,.58),(.35,.12,.47),'Interior',.055);seat.rotation_euler[0]=-.13
 for xx in (-.16,.16):
  box('Seat bolster',(s*.29+xx,-.12,.44),(.055,.42,.14),'Interior',.022)
  box('Shoulder bolster',(s*.29+xx,-.43,.66),(.065,.16,.22),'Carbon',.025)
 box('Headrest',(s*.29,-.47,.84),(.22,.10,.14),'Interior',.035)
 for xx in (-.065,.065):
  panel('Harness webbing',[(s*.29+xx-.024,-.35,.82),(s*.29+xx+.024,-.35,.82),(s*.29+xx+.024,-.075,.43),(s*.29+xx-.024,-.075,.43)],'Rubber',.004,.001)
 box('Harness latch',(s*.29,-.075,.44),(.06,.03,.045),'Metal',.005)
 box('Door card',(s*.60,-.05,.47),(.04,.91,.26),'Interior',.025)
 rod('Interior roll hoop',(s*.49,-.55,.37),(s*.44,-.55,.99),.018,'Metal')
rod('Roof roll hoop',(-.44,-.55,.99),(.44,-.55,.99),.018,'Metal')
box('Dashboard',(0,.54,.61),(1.12,.28,.14),'Interior',.045)
box('Digital instrument bezel',(.29,.415,.67),(.24,.055,.12),'Carbon',.012)
box('Instrument display',(.29,.382,.675),(.205,.006,.08),'Glass',.004)
box('Centre console',(0,.13,.44),(.18,.55,.17),'Carbon',.024)
for i in range(5):box('Console toggle',(.0,.04+i*.07,.531),(.028,.025,.013),'Metal',.004)
rod('Steering column',(.29,.42,.55),(.29,.24,.62),.017,'Metal')
CURRENT='SteeringWheel'
# Flat-bottom prototype steering yoke, hub pivot at its centre.
ring=[(-.13,.04),(-.15,.08),(-.13,.13),(.13,.13),(.15,.08),(.13,-.09),(.075,-.12),(-.075,-.12),(-.13,-.09)]
center=(.29,.24,.65)
for a,b in zip(ring,ring[1:]+ring[:1]):rod('Steering grip',(center[0]+a[0],center[1],center[2]+a[1]),(center[0]+b[0],center[1],center[2]+b[1]),.017,'Rubber',16)
box('Steering hub',center,(.22,.035,.12),'Carbon',.016)
for s in (-1,1):
 for j in range(3):box('Steering button',(.29+s*.07,.215,.62+j*.029),(.018,.009,.018),'Metal',.005)
CURRENT='Body'
# Wheel assemblies, axle centres exactly +/-1.525m, contact at z=0.
wheelcentres={}
for axle,y in [('F',1.525),('R',-1.525)]:
 for side,s in [('L',1),('R',-1)]:
  CURRENT=f'Wheel_{axle}{side}';GROUPS[CURRENT]=[];c=(s*.825,y,.355);wheelcentres[CURRENT]=c
  # 24-section slick with a rounded crown, sidewall bulge, shoulder and bead.
  p=[(-.16,.253),(-.167,.278),(-.17,.307),(-.163,.333),(-.15,.347),(-.13,.353),(-.10,.355),(-.06,.355),(-.02,.355),(.02,.355),(.06,.355),(.10,.355),(.13,.353),(.15,.347),(.163,.333),(.17,.307),(.167,.278),(.16,.253),(.145,.246),(.12,.244),(-.12,.244),(-.145,.246)]
  lathe('Slick tyre',c,p,'Rubber',160)
  # concentric moulding lines, no branding or tyre text
  for r in (.267,.321):lathe('Sidewall moulding',c,[(s*.166,r-.001),(s*.168,r),(s*.167,r+.0015),(s*.165,r)],'Rubber',128)
  lathe('Forged rim barrel',c,[(-.153,.25),(-.153,.236),(.153,.236),(.153,.25),(.142,.257),(.13,.254),(-.13,.254),(-.142,.257)],'Rim',128)
  lathe('Rim lip',c,[(s*.157,.234),(s*.162,.238),(s*.162,.248),(s*.156,.251),(s*.149,.245)],'Rim',128)
  cylinder('Brake rotor',(c[0]+s*.075,y,.355),.215,.018,'Metal',128)
  lathe('Rotor outer track',(c[0]+s*.086,y,.355),[(-.002,.151),(.002,.151),(.002,.211),(-.002,.211)],'Metal',128)
  cylinder('Rotor hat',(c[0]+s*.092,y,.355),.105,.024,'Carbon',64)
  # Cross-drilled dimples set flush in rotor face, dark and small.
  for j in range(42):
   a=2*math.pi*j/42
   for r in (.174,.195):
    cylinder('Rotor drilled well',(c[0]+s*.087,y+r*math.sin(a),.355+r*math.cos(a)),.0034,.0012,'Interior',8)
  caliper=box('Brake caliper',(c[0]+s*.105,y-.17,.395),(.095,.083,.21),'Metal',.022)
  GROUPS[CURRENT].remove(caliper);GROUPS['Body'].append(caliper)
  # Twelve split spokes with tapered sides and true bevels.
  for j in range(10):
   a=2*math.pi*j/10
   for delta in (-.037,.037):
    a0=a+delta;a1=a+delta*2
    def pt(r,ang,x):return(c[0]+s*x,y+r*math.sin(ang),.355+r*math.cos(ang))
    vs=[pt(.061,a0-.085,.148),pt(.061,a0+.085,.148),pt(.235,a1+.030,.14),pt(.235,a1-.030,.14),pt(.061,a0-.085,.123),pt(.061,a0+.085,.123),pt(.235,a1+.030,.118),pt(.235,a1-.030,.118)]
    mesh('Split forged spoke',vs,[(0,1,2,3),(4,7,6,5),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],'Rim',.002,False)
  cylinder('Centre hub',(c[0]+s*.148,y,.355),.066,.028,'Rim',64)
  cylinder('Centre lock',(c[0]+s*.17,y,.355),.034,.027,'Metal',12)
  cylinder('Hub inset',(c[0]+s*.185,y,.355),.018,.004,'Carbon',32)
  for j in range(6):
   a=j*math.pi/3;cylinder('Hub fastener',(c[0]+s*.166,y+.05*math.sin(a),.355+.05*math.cos(a)),.005,.005,'Metal',12)
  rod('Tyre valve',(c[0]+s*.15,y+.225,.355),(c[0]+s*.176,y+.225,.355),.004,'Rubber',8)
CURRENT='Body'

# UV tiling at physically consistent scale, plus emissive atlas zones.
def uv_project(o):
 me=o.data;uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers.active
 zone=int(o.get('lamp_zone',0))
 for poly in me.polygons:
  normal=poly.normal;drop=max(range(3),key=lambda i:abs(normal[i]));axes=[i for i in range(3) if i!=drop]
  for li in poly.loop_indices:
   p=o.matrix_world@me.vertices[me.loops[li].vertex_index].co
   if me.materials and me.materials[0].name=='Lamps':uv.data[li].uv=(zone/3+.15,.5)
   else:uv.data[li].uv=(p[axes[0]]*1.6,p[axes[1]]*1.6)
for objects in GROUPS.values():
 for o in objects:uv_project(o)
# Consolidate fixed assemblies, apply transforms, preserve wheel hub pivots.
assemblies=[]
for name,objects in GROUPS.items():
 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
 active(o);bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
 pivot=wheelcentres.get(name,center if name=='SteeringWheel' else (0,0,0));S.cursor.location=V(pivot);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
 # Triangulate deterministically for exporter and audit parity.
 mod=o.modifiers.new('Game triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
 # Collapse redundant bevel tessellation while retaining material/UV boundaries.
 mod=o.modifiers.new('Production triangle budget','DECIMATE');mod.ratio=.425;mod.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=mod.name)
 # Clean numerical slivers after boolean/decimation before export.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=bm.verts,dist=.000001);bmesh.ops.dissolve_degenerate(bm,dist=.0000001,edges=bm.edges[:]);bmesh.ops.triangulate(bm,faces=bm.faces[:]);bm.to_mesh(o.data);bm.free()
 if name=='Body':
  bpy.ops.mesh.customdata_custom_splitnormals_clear()
  bpy.ops.object.shade_smooth_by_angle(angle=math.radians(25),keep_sharp_edges=False)
  mod=o.modifiers.new('Final surface normals','WEIGHTED_NORMAL');mod.keep_sharp=True;mod.weight=50;bpy.ops.object.modifier_apply(modifier=mod.name)
 assemblies.append(o)
# Empty contract points in the same Y-up garage frame after export.
points={'Seat_Driver':(.29,-.14,.40),'Door_L':(1.0,.0,.22),'Door_R':(-1.0,.0,.22),'Exhaust':(.24,-2.45,.37),'Light_Head_L':(.65,2.42,.398),'Light_Head_R':(-.65,2.42,.398),'Light_Tail_L':(.70,-2.445,.56),'Light_Tail_R':(-.70,-2.445,.56)}
for name,pos in points.items():
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=V(pos);assemblies.append(o)
# Normalize exact overall width while keeping applied transforms and local pivots.
bpy.context.view_layer.update()
width_extent=max(abs((o.matrix_world@v.co).x) for o in assemblies if o.type=='MESH' for v in o.data.vertices)
width_factor=1.0/width_extent
for o in assemblies:
 if o.type=='MESH':
  for v in o.data.vertices:v.co.x*=width_factor
 o.location.x*=width_factor
bpy.context.view_layer.update()
# Canonical bounds and real mesh statistics.
meshobjects=[o for o in assemblies if o.type=='MESH']
tri=sum(len(o.data.polygons) for o in meshobjects)
coords=[o.matrix_world@v.co for o in meshobjects for v in o.data.vertices]
lo=[min(v[i] for v in coords) for i in range(3)];hi=[max(v[i] for v in coords) for i in range(3)]
def game(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
manifest={'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','left':'+X','nodes':{o.name:{'position':game(o.location),'type':o.type} for o in assemblies},'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]},'wheelbase':3.05,'triangles':tri,'materials':list(M),'paints':[{'name':name,'linear_rgb':c} for name,c in PAINTS]}
(ROOT/'nodes.json').write_text(json.dumps(manifest,indent=2))
bpy.ops.object.select_all(action='DESELECT')
for o in assemblies:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=str(ROOT/'le-mans-prototype.glb'),export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_animations=False,export_cameras=False,export_lights=False,export_image_format='AUTO')
# glTF factor is the sole tint; neutral baked texture remains unchanged.
p=ROOT/'le-mans-prototype.glb';raw=p.read_bytes();jl,jt=struct.unpack_from('<II',raw,12);doc=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.03,.03,.035,1]
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4);p.write_bytes(struct.pack('<III',0x46546C67,2,20+len(j)+len(binary))+struct.pack('<II',len(j),0x4E4F534A)+j+binary)
print('EXPORTED',tri,p.stat().st_size,'elapsed',time.time()-START,flush=True)
# Render uses exactly the same neutral bake and factor as glTF.
nt=M['Paint'].node_tree;bs=nt.nodes.get('Principled BSDF');mix=nt.nodes.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;nt.links.new(nt.nodes['base'].outputs[0],mix.inputs[1]);mix.inputs[2].default_value=(*PAINTS[0][1],1);nt.links.new(mix.outputs[0],bs.inputs['Base Color'])
# Clean studio daylight, neutral floor, soft reflection cards.
world=bpy.data.worlds.new('Daylight');S.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.60,.68,.80,1);world.node_tree.nodes['Background'].inputs[1].default_value=.24
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.012));floor=bpy.context.object;floor.name='Render floor';mat=bpy.data.materials.new('Studio floor');mat.diffuse_color=(.19,.21,.23,1);mat.use_nodes=True;mat.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.19,.21,.23,1);mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.66;floor.data.materials.append(mat)
def area(name,loc,power,size,color,target=(0,0,.4),size_y=None):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='RECTANGLE';d.size=size;d.size_y=size_y or size;d.color=color;o=bpy.data.objects.new(name,d);bpy.context.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector(target)-o.location).to_track_quat('-Z','Y').to_euler()
area('Large daylight',(1.6,-3.7,6),850,5.0,(.91,.95,1),size_y=3)
area('Soft fill',(-4,-1,3),500,4,(.72,.83,1),size_y=5)
area('Rim strip',(1,4,4),1100,5,(1,.92,.81),size_y=2)
area('Side reflection',(5,1,2.2),450,4,(1,1,1),size_y=1.2)
bpy.ops.object.camera_add();cam=bpy.context.object;S.camera=cam;cam.data.type='ORTHO';cam.data.ortho_scale=6.35
hero=(5.6,-7.8,3.1)
def camera(loc,target=(0,0,.48),scale=6.35):cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale
camera(hero)
def render(file,w=1920,h=1080,samples=40):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=str(ROOT/file);bpy.ops.render.render(write_still=True);print('RENDERED',file,flush=True)
render('final.png')
camera((7,0,.61),(0,0,.61),5.65);render('side.png',1920,1080,32)
camera(hero)
clay=bpy.data.materials.new('Clay QA');clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.43,.43,.43,1);clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.55
S.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);S.view_layers[0].material_override=None
# Useful rear evidence in addition to required views.
camera((4.9,7.1,2.7));render('rear.png',1280,720,24);camera(hero)
# Render and compose 3x2 paint swatches in Blender (no external dependencies).
frames=[]
for i,(name,col) in enumerate(PAINTS):
 mix.inputs[2].default_value=(*col,1);render(f'paint_{i}.png',640,360,16)
 im=bpy.data.images.load(str(ROOT/f'paint_{i}.png'));arr=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);frames.append(arr);bpy.data.images.remove(im)
canvas=np.ones((720,1920,4),np.float32)
for i,arr in enumerate(frames):
 row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640]=arr
im=bpy.data.images.new('Paint colour options',1920,720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
mix.inputs[2].default_value=(*PAINTS[0][1],1)
notes=["Original 5 m closed-cockpit endurance prototype; Garage v2 axes and six moving/fixed mesh assemblies.",f"Triangles: {tri:,}; GLB: {p.stat().st_size/1048576:.2f} MiB.","Materials: Paint, Carbon, Rubber, Metal, Interior, Rim, Glass, Lamps; Paint neutral bake + gloss-black factor + clearcoat.","Cycles GPU baked base/normal tiles 1024/512/64 px; packed ORM <=512 px; eight shared sets.","Poly Haven CC0: metal_plate_02, rubberized_track, fabric_leather_02 (https://polyhaven.com/license).", "Differences: original swept split headlights, trapezoid side windows and stepped rear light; default black instead of reference silver."]
(ROOT/'NOTES.md').write_text('\n'.join(notes)+'\n')
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'source.blend'))
print('COMPLETE',time.time()-START,flush=True)
