"""Original oval stock car. Run with Blender 5.2 -b --python this_file.py.
All dimensions metres. Native Blender front=-Y, up=Z; glTF front=+Z, up=Y.
Photo textures: Poly Haven CC0. Clean build includes Cycles material tile bakes.
"""
import bpy, bmesh, math, os, json, urllib.request, struct, random
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)
random.seed(12)
bpy.ops.wm.read_factory_settings(use_empty=True)
sc=bpy.context.scene
sc.render.engine='CYCLES'; sc.cycles.samples=24
prefs=bpy.context.preferences.addons['cycles'].preferences
prefs.compute_device_type='OPTIX'; prefs.get_devices()
for d in prefs.devices: d.use=d.type=='OPTIX'
sc.cycles.device='GPU'; sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG'
sc.view_settings.view_transform='AgX'
sc.render.film_transparent=False
sc.view_settings.exposure=-.35
M={}; GROUP={}; current='Body'
COLORS=[('Atlantic blue',[.02,.10,.45]),('Ivory',[.78,.74,.63]),('Vermilion',[.55,.025,.012]),('Forest',[.018,.16,.075]),('Graphite',[.055,.065,.08]),('Competition yellow',[.9,.55,.015])]

def image_load(id,map):
    p=f'{ROOT}/textures/{id}_{map}_1k.jpg'
    if not os.path.exists(p):
        url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg'
        print('DOWNLOAD',url,flush=True); urllib.request.urlretrieve(url,p)
    im=bpy.data.images.load(p,check_existing=True)
    if map!='diff': im.colorspace_settings.name='Non-Color'
    return im

def bake_mat(name,base,rough,metal,id=None,strength=.1):
    """Bake reusable 1k color/normal + 512 packed ORM on a UV tile with Cycles."""
    mat=bpy.data.materials.new(name);mat.use_nodes=True
    n=mat.node_tree.nodes;l=mat.node_tree.links;n.clear()
    out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs['BSDF'],out.inputs['Surface'])
    p.inputs['Base Color'].default_value=(*base,1);p.inputs['Roughness'].default_value=rough;p.inputs['Metallic'].default_value=metal
    uv=n.new('ShaderNodeTexCoord')
    noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=135;noise.inputs['Detail'].default_value=3;l.new(uv.outputs['UV'],noise.inputs['Vector'])
    ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.18;ramp.color_ramp.elements[1].position=.85
    ramp.color_ramp.elements[0].color=(*(v*(1-strength) for v in base),1);ramp.color_ramp.elements[1].color=(*base,1)
    l.new(noise.outputs['Fac'],ramp.inputs[0]);color=ramp.outputs[0]
    if id:
        tex=n.new('ShaderNodeTexImage');tex.image=image_load(id,'diff');l.new(uv.outputs['UV'],tex.inputs['Vector'])
        bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs['Color'],bw.inputs[0])
        photo=n.new('ShaderNodeMixRGB');photo.blend_type='MULTIPLY';photo.inputs[0].default_value=.03 if name=='Paint' else .32
        l.new(color,photo.inputs[1]);l.new(bw.outputs[0],photo.inputs[2]);color=photo.outputs[0]
        normal=n.new('ShaderNodeTexImage');normal.image=image_load(id,'nor_gl');l.new(uv.outputs['UV'],normal.inputs['Vector'])
        nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=.006 if name=='Paint' else (.018 if name=='Rubber' else .065);l.new(normal.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
        if name=='Paint':
            bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.08;bump.inputs['Distance'].default_value=.00005;l.new(noise.outputs['Fac'],bump.inputs['Height']);l.new(bump.outputs[0],p.inputs['Normal'])
        arm=n.new('ShaderNodeTexImage');arm.image=image_load(id,'arm');l.new(uv.outputs['UV'],arm.inputs['Vector'])
        sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
        mult=n.new('ShaderNodeMath');mult.operation='MULTIPLY_ADD';mult.inputs[1].default_value=.16;mult.inputs[2].default_value=rough-.08;l.new(sep.outputs[1],mult.inputs[0]);l.new(mult.outputs[0],p.inputs['Roughness'])
    l.new(color,p.inputs['Base Color'])
    bpy.ops.mesh.primitive_plane_add(size=1);tile=bpy.context.object;tile.data.materials.append(mat)
    sc.cycles.samples=8;sc.render.bake.margin=4
    images={}
    emit=n.new('ShaderNodeEmission')
    for kind,size in [('base',1024 if name=='Paint' else 512),('normal',512),('orm',512)]:
        im=bpy.data.images.new(name+'_'+kind,width=size,height=size,alpha=False)
        if kind!='base':im.colorspace_settings.name='Non-Color'
        target=n.new('ShaderNodeTexImage');target.image=im;n.active=target
        if kind=='normal':
            l.new(p.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
        else:
            if kind=='base':l.new(color,emit.inputs['Color'])
            else:
                comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1;comb.inputs[1].default_value=rough;comb.inputs[2].default_value=metal
                if id:l.new(mult.outputs[0],comb.inputs[1])
                l.new(comb.outputs[0],emit.inputs['Color'])
            l.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
        ext='jpg' if kind=='base' else 'png'
        im.filepath_raw=f'{ROOT}/textures/{name}_{kind}.{ext}';im.file_format='JPEG' if kind=='base' else 'PNG';im.save();images[kind]=im
    bpy.data.objects.remove(tile,do_unlink=True)
    n.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0])
    t=n.new('ShaderNodeTexImage');t.image=images['base']
    if name=='Paint':
        mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*COLORS[0][1],1);mix.name='PAINT_FACTOR';l.new(t.outputs[0],mix.inputs[1]);l.new(mix.outputs[0],p.inputs['Base Color'])
        p.inputs['Coat Weight'].default_value=1;p.inputs['Coat Roughness'].default_value=.05
    elif name=='Lamps':
        attr=n.new('ShaderNodeVertexColor');attr.layer_name='Color';l.new(attr.outputs['Color'],p.inputs['Base Color']);l.new(attr.outputs['Color'],p.inputs['Emission Color']);p.inputs['Emission Strength'].default_value=.08
    else:l.new(t.outputs[0],p.inputs['Base Color'])
    normal=n.new('ShaderNodeTexImage');normal.image=images['normal'];nm=n.new('ShaderNodeNormalMap');l.new(normal.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
    orm=n.new('ShaderNodeTexImage');orm.image=images['orm'];sep=n.new('ShaderNodeSeparateColor');l.new(orm.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],p.inputs['Roughness']);l.new(sep.outputs[2],p.inputs['Metallic'])
    # glTF AO socket recognised by exporter.
    ng=bpy.data.node_groups.get('glTF Material Output')
    if not ng:
        ng=bpy.data.node_groups.new('glTF Material Output','ShaderNodeTree');ng.interface.new_socket(name='Occlusion',in_out='INPUT',socket_type='NodeSocketFloat')
    g=n.new('ShaderNodeGroup');g.node_tree=ng;l.new(sep.outputs[0],g.inputs['Occlusion'])
    if name=='Glass':
        p.inputs['Alpha'].default_value=.24;p.inputs['Roughness'].default_value=.08;mat.surface_render_method='BLENDED'
    M[name]=mat
    print('BAKED',name,flush=True)

for args in [('Paint',(.94,.94,.94),.32,.12,'metal_plate',.06),('Rubber',(.007,.008,.009),.78,0,'rubber_tiles',.25),('Metal',(.22,.235,.25),.38,.82,'metal_plate',.14),('Carbon',(.006,.008,.01),.42,.1,'denim_fabric',.22),('Interior',(.006,.007,.009),.83,0,'fabric_leather_01',.25),('Glass',(.18,.25,.29),.09,.05,None,.01),('Lamps',(.9,.9,.9),.22,.25,None,.01),('Roundel',(.88,.875,.84),.38,0,'metal_plate',.025)]: bake_mat(*args)
sc.cycles.samples=24

def add(o,name,mat):
    o.name=name;o.data.materials.append(M[mat]);GROUP.setdefault(current,[]).append(o)
    return o

def mesh(name,v,f,mat,uv=None,smooth=True):
    me=bpy.data.meshes.new(name);me.from_pydata(v,[],f);me.update();o=bpy.data.objects.new(name,me);sc.collection.objects.link(o);add(o,name,mat)
    layer=me.uv_layers.new(name='UVMap')
    for poly in me.polygons:
        poly.use_smooth=smooth
        for li in poly.loop_indices:
            vi=me.loops[li].vertex_index
            if uv:layer.data[li].uv=uv[vi]
            else:
                co=me.vertices[vi].co;ax=max(range(3),key=lambda j:abs(poly.normal[j]));layer.data[li].uv=[(co.y,co.z),(co.x,co.z),(co.x,co.y)][ax]
    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=.01,s=3):
    active(o);m=o.modifiers.new('Manufactured edge radii','BEVEL');m.width=w;m.segments=s
    bpy.ops.object.modifier_apply(modifier=m.name)
    return o

def box(name,loc,scale,mat,bev=.008):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.scale=scale;active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);add(o,name,mat)
    if bev:bevel(o,bev,2)
    return o

def cyl(name,loc,r,depth,mat,axis=(0,0,1),verts=32,bev=.002):
    bpy.ops.mesh.primitive_cylinder_add(vertices=verts,radius=r,depth=depth,location=loc);o=bpy.context.object;o.rotation_mode='QUATERNION';o.rotation_quaternion=Vector(axis).to_track_quat('Z','Y');add(o,name,mat)
    if bev:bevel(o,bev,2)
    for p in o.data.polygons:p.use_smooth=len(p.vertices)==4
    return o

def tube(name,points,r,mat,segments=10):
    v=[];f=[];uv=[]
    for i,pt in enumerate(points):
        tangent=Vector(points[min(i+1,len(points)-1)])-Vector(points[max(i-1,0)])
        tangent.normalize();ref=Vector((0,0,1))
        if abs(tangent.dot(ref))>.95:ref=Vector((0,1,0))
        u=tangent.cross(ref).normalized();w=tangent.cross(u).normalized()
        for j in range(segments):
            v.append(Vector(pt)+r*(u*cos(j*2*pi/segments)+w*sin(j*2*pi/segments)));uv.append((j/segments,i/max(1,len(points)-1)))
    for i in range(len(points)-1):
        for j in range(segments):a=i*segments+j;b=i*segments+(j+1)%segments;f.append((a,b,b+segments,a+segments))
    f.extend([tuple(reversed(range(segments))),tuple((len(points)-1)*segments+j for j in range(segments))])
    return mesh(name,v,f,mat,uv)

def lathe(name,x,y,z,profile,mat,N=96):
    v=[];uv=[];f=[]
    for i,(ax,r) in enumerate(profile):
        for j in range(N):
            a=j*2*pi/N;v.append((x+ax,y+r*sin(a),z+r*cos(a)));uv.append((j/N,i/(len(profile)-1)))
    for i in range(len(profile)-1):
        for j in range(N):a=i*N+j;b=i*N+(j+1)%N;f.append((a,b,b+N,a+N))
    return mesh(name,v,f,mat,uv)

def patch(name,rows,mat):
    v=[p for row in rows for p in row];n=len(rows[0]);f=[];uv=[]
    for i,row in enumerate(rows):
        for j,p in enumerate(row):uv.append((j/(n-1),i/(len(rows)-1)))
    for i in range(len(rows)-1):
        for j in range(n-1):a=i*n+j;f.append((a,a+1,a+n+1,a+n))
    return mesh(name,v,f,mat,uv)

def interp(y,vals):
    # Cubic Hermite sections: continuous tangents rather than faceted loft stations.
    for i,((a,v),(b,w)) in enumerate(zip(vals,vals[1:])):
        if y<=b:
            t=max(0,min(1,(y-a)/(b-a)))
            prev=vals[max(0,i-1)];nxt=vals[min(len(vals)-1,i+2)]
            m0=(w-prev[1])/(b-prev[0]);m1=(nxt[1]-v)/(nxt[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 vals[-1][1]

widths=[(-2.61,.78),(-2.48,.9),(-2.15,.965),(-1.5,.99),(-.8,.97),(.7,.965),(1.4,.99),(2.15,.96),(2.44,.88)]
tops=[(-2.61,.44),(-2.48,.59),(-2.15,.70),(-1.5,.77),(-.8,.80),(.7,.79),(1.4,.78),(2.15,.75),(2.44,.65)]
def W(y):return interp(y,widths)
def H(y):return interp(y,tops)
def bottom(y):
    z=.16
    for a in [-1.395,1.395]:
        d=abs(y-a)
        if d<.395:z=max(z,.355+sqrt(.395**2-d*d))
    return z
# Painted flanks: dense longitudinal strips follow real open wheel cutouts.
for side in [-1,1]:
    rows=[]
    for i in range(241):
        y=-2.61+5.05*i/240;lo=bottom(y);hi=max(H(y),lo+.018)
        row=[]
        for j in range(9):
            t=j/8;z=lo+(hi-lo)*t
            x=side*(W(y)-.025*(1-t)**2+.012*sin(t*pi))
            row.append((x,y,z))
        rows.append(row)
    o=patch('Seamless formed side shell',rows,'Paint')
    if side==1:
        for poly in o.data.polygons: poly.flip()
    sol=o.modifiers.new('Steel shell thickness','SOLIDIFY');sol.thickness=.009;sol.offset=-1;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
    # rolled wheel-arch return; tyre can be seen through genuine opening
    for yc in [-1.395,1.395]:
        pts=[]
        for i in range(73):
            a=pi*i/72;y=yc-.395*cos(a);z=.355+.395*sin(a);pts.append((side*(W(y)+.001),y,z))
        tube('Rolled wheel arch lip',pts,.008,'Paint',8)
        # inside wheel tub lip black
        tube('Wheel housing edge',[(side*.949,p[1],p[2]-.012) for p in pts],.014,'Interior',8)
# Bonnet and rear deck have smoothly crowned cross sections.
for name,ya,yb in [('Bonnet',-2.61,-.73),('Rear deck',1.43,2.44)]:
    rows=[]
    for i in range(33):
        y=ya+(yb-ya)*i/32;row=[]
        for j in range(49):
            t=-1+2*j/48;row.append((W(y)*t,y,H(y)+.065*sqrt(max(0,1-t*t))))
        rows.append(row)
    patch(name,rows,'Paint')
# Cowl and shoulder rails bridge top body to cabin.
for s in [-1,1]:
    rows=[]
    for i in range(61):
        y=-.73+2.16*i/60
        rows.append([(s*W(y),y,H(y)),(s*.85,y,.823),(s*.825,y,.84)])
    patch('Cabin beltline',rows,'Paint')
# Roof elliptical crown with curved shoulder.
rows=[]
for i in range(41):
    y=-.06+.99*i/40;row=[]
    for j in range(49):
        t=-1+2*j/48;row.append((.735*t,y,1.296-.03*t*t-.035*((y-.435)/.495)**4))
    rows.append(row)
o=patch('Pressed roof',rows,'Paint');sol=o.modifiers.new('Roof metal thickness','SOLIDIFY');sol.thickness=.009;sol.offset=-1;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
# A and C pillars as wide tapered ribbons.
for s in [-1,1]:
    A=[(s*.85,-.77,.81),(s*.735,-.065,1.231)]
    tube('A pillar structure',A,.028,'Paint',20)
    tube('Roof side rail',[(s*.735,y,1.296-.03-.035*((y-.435)/.495)**4-.005) for y in [-.06,.04,.18,.36,.55,.73,.85,.93]],.018,'Paint',16)
    tube('Rear pillar edge',[(s*.735,.93,1.225),(s*.77,1.12,1.07),(s*.82,1.43,.84)],.022,'Paint',16)
    mesh('Swept rear sail panel',[(s*.74,.72,1.25),(s*.74,.91,1.24),(s*.86,1.49,.80),(s*.85,1.12,.825)],[(0,1,2,3)],'Paint')
    box('B pillar',(s*.788,.62,1.037),(.058,.075,.43),'Paint',.012)
    # quarter window dark pane, separate from white sail panel
    mesh('Quarter glazing',[(s*.789,.69,.87),(s*.752,.69,1.21),(s*.77,.86,1.20),(s*.835,1.15,.87)],[(0,1,2,3)],'Glass')
    tube('Quarter window gasket',[(s*.795,.69,.87),(s*.758,.69,1.21),(s*.776,.86,1.20),(s*.841,1.15,.87),(s*.795,.69,.87)],.009,'Rubber',8)
# Front/rear glass are curved, inset and fully framed.
for name,y0,y1,z0,z1,w0,w1 in [('Windshield',-.74,-.065,.84,1.232,.82,.708),('Rear screen',1.43,.93,.83,1.24,.81,.71)]:
    rows=[]
    for i in range(21):
        t=i/20;row=[]
        for j in range(33):
            u=-1+2*j/32;row.append(((w0*(1-t)+w1*t)*u,y0*(1-t)+y1*t-.025*(1-u*u),z0*(1-t)+z1*t+.012*(1-u*u)))
        rows.append(row)
    patch(name,rows,'Glass')
    for pts in [rows[0],rows[-1],[r[0] for r in rows],[r[-1] for r in rows]]:tube(name+' gasket',pts,.012,'Rubber',8)
    for u in [-.62,.62]:
        pts=[(u*(w0*(1-t)+w1*t),y0*(1-t)+y1*t-.015,z0*(1-t)+z1*t+.012) for t in [0,.25,.5,.75,1]]
        tube('Screen retaining strap',pts,.009,'Metal',6)
# Right open-window polycarbonate with border. Driver opening is an actual net.
mesh('Passenger side window',[(-.847,-.67,.855),(-.744,-.02,1.21),(-.76,.565,1.21),(-.846,.565,.855)],[(0,1,2,3)],'Glass')
for s in [-1,1]:
    tube('Window rubber surround',[(s*.85,-.67,.85),(s*.744,-.02,1.212),(s*.76,.565,1.212),(s*.847,.565,.85),(s*.85,-.67,.85)],.01,'Rubber',8)
# Heavy woven safety webbing, shaped to the taper of the opening.
for j in range(10):
    y=-.04+j*.061
    x=.769
    box('Window net vertical',(x,y,1.028),(.008,.019,.337),'Interior',.002)
for j in range(7):
    z=.868+j*.053
    box('Window net horizontal',(.773,.235,z),(.009,.57,.018),'Interior',.002)
for y in [-.045,.515]:tube('Net anchor',[(.79,y,.84),(.77,y,1.22)],.012,'Interior',8)
# Splitter and sealed nose fascia. Front -Y.
box('Front splitter',(0,-2.42,.135),(1.89,.41,.035),'Carbon',.035)
# Rounded bumper front, contoured to meet shell.
rows=[]
for i in range(65):
    t=-1+2*i/64;x=.88*t;y=-2.61+.135*abs(t)**6
    ztop=H(y)+.065*sqrt(max(0,1-(x/W(y))**2))
    rows.append([(x,y,.16),(x,y-.008,.26),(x,y,.38),(x,y+.005,ztop)])
patch('Nose bumper',rows,'Paint')
# Low intake in black plus protective wire mesh and fasteners.
box('Central radiator mouth',(0,-2.610,.285),(1.02,.018,.205),'Interior',.04)
for x in [-.66,.66]:box('Brake cooling mouth',(x,-2.612,.285),(.15,.018,.18),'Interior',.022)
for i in range(37):tube('Intake grille wire',[(-.48+i*.027,-2.621,.202),(-.48+i*.027,-2.621,.368)],.0018,'Metal',5)
for z in [.22,.25,.28,.31,.34]:tube('Grille cross wire',[(-.48,-2.623,z),(.48,-2.623,z)],.0016,'Interior',5)
box('Upper nose inlet',(0,-2.588,.461),(.72,.02,.065),'Interior',.025)
# Original flat graphic headlamp polygons conform to swept bonnet surface.
def lamp(name,verts,color):
    o=mesh(name,verts,[tuple(range(len(verts)))],'Lamps',smooth=False)
    attr=o.data.color_attributes.new(name='Color',type='FLOAT_COLOR',domain='CORNER')
    for d in attr.data:d.color=(*color,1)
    return o
for s in [-1,1]:
    corners=[(s*.43,-2.47),(s*.78,-2.42),(s*.88,-2.22),(s*.58,-2.28)]
    for label,scale,mat,off in [('Headlight graphic border',1,'Interior',.002),('Headlight flat print',.90,'Lamps',.0032)]:
        center=Vector((sum(p[0] for p in corners)/4,sum(p[1] for p in corners)/4))
        cs=[center+(Vector(p)-center)*scale for p in corners];rows=[]
        for i in range(13):
            t=i/12;row=[]
            for j in range(25):
                u=j/24;p0=cs[0].lerp(cs[1],u);p1=cs[3].lerp(cs[2],u);x,y=p0.lerp(p1,t)
                z=H(y)+.065*sqrt(max(0,1-(x/W(y))**2))+off
                row.append((x,y,z))
            rows.append(row)
        o=patch(label,rows,mat)
        if mat=='Lamps':
            attr=o.data.color_attributes.new(name='Color',type='FLOAT_COLOR',domain='CORNER')
            for poly in o.data.polygons:
                # Flat printed reflector facets and amber edge. No headlamp housings.
                for li in poly.loop_indices:
                    vi=o.data.loops[li].vertex_index;u=(vi%25)/24;t=(vi//25)/12
                    c=(.95,.30,.014) if u>.86 else ((.10,.12,.14) if (int(t*12)%4==0) else (.25,.29,.33))
                    attr.data[li].color=(*c,1)
    for xc,yc,rad in [(s*.59,-2.375,.057),(s*.735,-2.335,.049)]:
        vs=[];fs=[];N=48
        radii=[0,.35,.56,.72,.84,1]
        for r in radii:
            for j in range(N):
                a=2*pi*j/N;x=xc+r*rad*cos(a);y=yc+r*rad*1.18*sin(a)
                z=H(y)+.065*sqrt(max(0,1-(x/W(y))**2))+.0048
                vs.append((x,y,z))
        for k in range(len(radii)-1):
            for j in range(N):a=k*N+j;b=k*N+(j+1)%N;fs.append((a,b,b+N,a+N))
        o=mesh('Headlight printed reflector',vs,fs,'Lamps')
        attr=o.data.color_attributes.new(name='Color',type='FLOAT_COLOR',domain='CORNER')
        tones=[(.82,.79,.67),(.19,.22,.26),(.62,.67,.70),(.10,.12,.14),(.53,.58,.62)]
        for poly in o.data.polygons:
            c=tones[poly.index//N]
            shade=.75+.25*(poly.index%N%3)/2
            for li in poly.loop_indices:attr.data[li].color=(*(v*shade for v in c),1)
# Back bumper, flat tail graphics, fuel filler.
box('Rear closure',(0,2.405,.425),(1.78,.07,.43),'Paint',.075)
for s in [-1,1]:
    box('Tail graphic surround',(s*.69,2.447,.60),(.27,.009,.13),'Interior',.025)
    verts=[(s*.57,2.454,.55),(s*.80,2.454,.55),(s*.80,2.454,.65),(s*.57,2.454,.65)]
    lamp('Tail red graphic',verts,(.52,.008,.008))
    for z in [.574,.607,.635]:tube('Tail print ribs',[(s*.58,2.457,z),(s*.79,2.457,z)],.0025,'Interior',5)
cyl('Fuel filler ring',(.958,1.99,.676),.056,.012,'Metal',(1,0,0),48)
cyl('Fuel dry break',(.966,1.99,.676),.039,.015,'Interior',(1,0,0),40)
# Broad rear blade mounted directly along deck, not a floating wing.
o=box('Rear spoiler',(0,2.24,.881),(1.83,.028,.31),'Carbon',.014);o.rotation_euler.x=math.radians(-17)
for x in [-.79,-.4,0,.4,.79]:
    mesh('Spoiler bracket',[(x,2.05,.775),(x,2.28,.77),(x,2.30,.994)],[(0,1,2)],'Metal')
    for z in [.80,.94]:cyl('Spoiler bolt',(x,2.225,z),.009,.012,'Metal',(0,1,0),12,.001)
# Black skirts and exposed captive fasteners.
for s in [-1,1]:
    for ya,yb in [(-2.43,-1.79),(-1.0,1.0),(1.80,2.34)]:
        box('Rocker abrasion skirt',(s*.966,(ya+yb)/2,.155),(.025,yb-ya,.092),'Carbon',.008)
    for i in range(27):
        y=-2.42+i*.182
        if bottom(y)>.23:continue
        cyl('Skirt rivet',(s*.984,y,.185),.006,.008,'Metal',(s,0,0),10,.001)
    # Conformal blank door roundels sit directly on the formed steel skin.
    for name,r,mat,offset in [('Roundel outline',.257,'Interior',.0025),('Blank number panel',.247,'Roundel',.0035)]:
        vs=[];fs=[]
        for rad in [0,r*.33,r*.67,r]:
            for j in range(96):
                a=2*pi*j/96;y=.07+rad*cos(a);z=.505+rad*sin(a)
                t=(z-bottom(y))/(H(y)-bottom(y));x=W(y)-.025*(1-t)**2+.012*sin(t*pi)
                vs.append((s*(x+offset),y,z))
        for k in range(3):
            for j in range(96):a=k*96+j;b=k*96+(j+1)%96;fs.append((a,b,b+96,a+96))
        mesh(name,vs,fs,mat)
for name,r,mat,offset in [('Roof roundel outline',.323,'Interior',.003),('Blank roof number panel',.313,'Roundel',.004)]:
    vs=[];fs=[]
    for rad in [0,r*.25,r*.5,r*.75,r]:
        for j in range(96):
            a=2*pi*j/96;x=rad*cos(a);y=.4+rad*sin(a);z=1.296-.03*(x/.735)**2-.035*((y-.435)/.495)**4+offset
            vs.append((x,y,z))
    for k in range(4):
        for j in range(96):a=k*96+j;b=k*96+(j+1)%96;fs.append((a,b,b+96,a+96))
    mesh(name,vs,fs,mat)
# Hood pins, retaining cords and two original rearward bonnet vents.
for x in [-.65,.65]:
    for y in [-2.02,-.91]:
        z=H(y)+.065*sqrt(max(0,1-(x/W(y))**2))+.003
        cyl('Flush hood pin washer',(x,y,z),.023,.003,'Metal',(0,0,1),24,.001)
        cyl('Hood locking post',(x,y,z+.006),.006,.014,'Metal',(0,0,1),12,.001)
        tube('Hood pin tether',[(x,y,z+.01),(x+.04,y+.025,z+.007),(x+.055,y+.07,z+.005)],.0018,'Metal',6)
for x in [-.38,.38]:
    for i in range(5):
        y=-.91-i*.035;z=H(y)+.065*sqrt(max(0,1-(x/W(y))**2))+.003
        box('Bonnet louver',(x,y,z),(.23,.015,.009),'Interior',.005)
# Race abrasion: fine irregular scars concentrated behind the wheel openings.
for side in [-1,1]:
    for k in range(55):
        y=random.choice([random.uniform(-.95,-.25),random.uniform(1.82,2.27)])
        z=random.uniform(.22,.39);length=random.uniform(.008,.065);dz=random.uniform(-.006,.006)
        t=(z-bottom(y))/(H(y)-bottom(y));x=W(y)-.025*(1-t)**2+.012*sin(t*pi)
        if 0<t<1:
            mesh('Racing abrasion',[(side*(x+.002),y,z),(side*(x+.002),y+length,z+dz),(side*(x+.002),y+length*.72,z+dz+.0008)],[(0,1,2)],'Metal',smooth=False)
# Racing cockpit: floor, dashboard, single bucket, harness, fire system, door cards.
box('Chassis floor',(0,.1,.205),(1.69,3.78,.065),'Interior',.03)
box('Dashboard',(0,-.58,.735),(1.62,.31,.16),'Interior',.045)
for s in [-1,1]:box('Interior side card',(s*.90,.22,.5),(.035,1.72,.47),'Carbon',.025)
box('Transmission tunnel',(0,.12,.34),(.25,1.82,.25),'Metal',.045)
# Single seat left-hand drive
box('Seat base',(.42,.36,.36),(.46,.54,.11),'Interior',.045)
o=box('Bucket seat back',(.42,.61,.63),(.45,.12,.59),'Carbon',.045);o.rotation_euler.x=math.radians(10)
box('Seat cushion back',(.42,.529,.64),(.33,.04,.43),'Interior',.025)
for x in [.18,.66]:
    box('Seat side bolster',(x,.36,.44),(.075,.55,.19),'Interior',.03)
    box('Head restraint',(x,.61,.89),(.10,.24,.17),'Interior',.04)
for x in [.32,.52]:
    tube('Shoulder harness',[(x,.57,.91),(x,.50,.70),(x,.40,.43)],.024,'Rubber',6)
box('Harness buckle',(.42,.32,.425),(.065,.045,.026),'Metal',.006)
# Roll cage main hoops and diagonals: structural, visible through glass.
for y in [-.05,.82]:
    foot=y-.50 if y<0 else y
    tube('Cage hoop',[(-.76,foot,.25),(-.74,foot,.78),(-.66,y,1.18),(.66,y,1.18),(.74,foot,.78),(.76,foot,.25)],.022,'Metal',12)
for s in [-1,1]:
    tube('Roof cage rail',[(s*.66,-.05,1.18),(s*.66,.82,1.18)],.023,'Metal',12)
    tube('Rear cage brace',[(s*.70,.82,1.10),(s*.70,1.73,.27)],.023,'Metal',12)
    for z in [.39,.60]:tube('Door intrusion bar',[(s*.77,-.48,z),(s*.79,.78,z+.03)],.021,'Metal',12)
    tube('Door X brace',[(s*.78,-.48,.36),(s*.78,.78,.68)],.018,'Metal',10)
tube('Main hoop diagonal',[(-.74,.84,.29),(.72,.84,1.13)],.022,'Metal',12)
tube('Rear cross brace',[(.72,.86,.29),(-.72,.86,1.13)],.021,'Metal',12)
tube('Dash cross bar',[(-.76,-.46,.67),(.76,-.46,.67)],.025,'Metal',12)
# Gauges inset on dash facing driver
for x,r in [(.42,.065),(.27,.036),(.57,.036)]:
    cyl('Gauge bezel',(x,-.408,.77),r,.018,'Metal',(0,1,.2),40)
    cyl('Gauge dark dial',(x,-.396,.774),r*.86,.005,'Interior',(0,1,.2),40)
    tube('Gauge needle',[(x,-.39,.775),(x+r*.45,-.39,.79)],.002,'Roundel',5)
tube('Gear lever',[(.05,.05,.43),(.06,-.02,.62)],.012,'Metal',12)
cyl('Gear knob',(.06,-.02,.63),.027,.043,'Interior',(0,0,1),24,.01)
cyl('Fire bottle',(-.42,.54,.31),.075,.39,'Metal',(0,1,0),40,.01)
tube('Extinguisher pipe',[(-.42,.32,.31),(-.43,-.1,.28),(-.2,-.6,.4)],.007,'Rubber',8)
# Steering wheel its own mesh and exact hub origin.
current='SteeringWheel';hub=Vector((.42,-.28,.77));axis=Vector((0,.90,.44)).normalized()
u=Vector((1,0,0));v=axis.cross(u).normalized()
pts=[hub+.145*(u*cos(i*2*pi/96)+v*sin(i*2*pi/96)) for i in range(97)]
tube('Steering wheel rim',pts,.016,'Rubber',12)
for a in [pi/2,pi/2+2*pi/3,pi/2+4*pi/3]:tube('Steering wheel spoke',[hub,hub+.133*(u*cos(a)+v*sin(a))],.012,'Metal',8)
cyl('Steering quick release',hub,.035,.06,'Metal',axis,32)
current='Body';tube('Steering column',[hub-axis*.05,hub-axis*.26],.019,'Metal',12)
# Exhaust outlet on right ahead of rear tyre.
tube('Exhaust pipe',[(-.3,.6,.22),(-.66,.89,.21),(-.97,.97,.22)],.049,'Metal',32)
cyl('Exhaust hollow opening',(-.979,.973,.22),.041,.003,'Interior',(-1,0,0),40,.001)
# Four standalone wheel assemblies, correct pivots and rounded slick profiles.
for s,side in [(1,'L'),(-1,'R')]:
 for y,axle in [(-1.395,'F'),(1.395,'R')]:
    current='Wheel_'+axle+side;x=s*.826;z=.355
    prof=[(-.148,.233),(-.154,.266),(-.155,.297),(-.145,.326),(-.128,.346),(-.103,.354),(-.075,.355),(.075,.355),(.103,.354),(.128,.346),(.145,.326),(.155,.297),(.154,.266),(.148,.233)]
    lathe('Slick tyre carcass',x,y,z,prof,'Rubber',128)
    # Mould seam rings with no lettering and no tread blocks.
    for a in [-.146,.146]:
        for r in [.275,.307]:lathe('Tyre sidewall mould ring',x,y,z,[(a-.001,r-.001),(a,r+.001),(a+.001,r-.001)],'Rubber',96)
    # Steel bead rolled edges and deep wheel barrel.
    out=s*.158
    profile=[(-s*.13,.225),(-s*.13,.237),(s*.125,.237),(s*.148,.232),(s*.158,.223),(s*.151,.215),(s*.130,.214),(s*.112,.218)]
    lathe('Rolled steel rim',x,y,z,profile,'Metal',96)
    # Dish constructed with ten open ventilation slots, rounded corners by bevel.
    N=120;rs=[.055,.078,.13,.154,.194,.211,.219];axs=[s*.057,s*.062,s*.088,s*.115,s*.140,s*.145,s*.132]
    vs=[];fs=[]
    for r,a in zip(rs,axs):
        for j in range(N):q=2*pi*j/N;vs.append((x+a,y+r*sin(q),z+r*cos(q)))
    for k in range(len(rs)-1):
        for j in range(N):
            if k==3 and j%12 in [3,4,5,6,7,8]:continue
            a=k*N+j;b=k*N+(j+1)%N;fs.append((a,b,b+N,a+N))
    o=mesh('Pressed ventilated steel wheel',vs,fs,'Metal');sol=o.modifiers.new('Pressed steel thickness','SOLIDIFY');sol.thickness=.004;active(o);bpy.ops.object.modifier_apply(modifier=sol.name);bevel(o,.0025,2)
    cyl('Wheel centre hub',(x+s*.067,y,z),.055,.035,'Metal',(1,0,0),48)
    for i in range(5):
        a=2*pi*i/5;yy=y+.075*sin(a);zz=z+.075*cos(a)
        cyl('Lug recess',(x+s*.075,yy,zz),.018,.007,'Interior',(1,0,0),20,.001)
        cyl('Five lug nut',(x+s*.086,yy,zz),.012,.024,'Metal',(1,0,0),6,.001)
    # Visible brake rotor and caliper inside ventilation holes.
    lathe('Brake rotor',x,y,z,[(s*.04,.08),(s*.04,.192),(s*.055,.192),(s*.055,.08)],'Metal',96)
    for i in range(24):
        a=2*pi*i/24
        cyl('Rotor drilled hole',(x+s*.058,y+.168*sin(a),z+.168*cos(a)),.004,.002,'Interior',(1,0,0),8,0)
    box('Brake caliper',(x+s*.07,y+.147,z+.02),(.065,.065,.15),'Interior',.018)
# Join objects into the six game meshes, applying every modifier and transform.
current='Body'
PIVOTS={'Body':(0,0,0),'SteeringWheel':tuple(hub),'Wheel_FL':(.826,-1.395,.355),'Wheel_FR':(-.826,-1.395,.355),'Wheel_RL':(.826,1.395,.355),'Wheel_RR':(-.826,1.395,.355)}
asset=[]
for name,obs in GROUP.items():
    for part in obs:
        if len(part.data.polygons)>80 and not any(k in part.name.lower() for k in ['roundel','number panel','headlight','formed side','bonnet','rear deck','pressed roof','nose bumper']):
            active(part);dec=part.modifiers.new('Detail triangle budget','DECIMATE');dec.ratio=.46;dec.use_collapse_triangulate=True;bpy.ops.object.modifier_apply(modifier=dec.name)
    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.convert(target='MESH');bpy.ops.object.join();o=bpy.context.object;o.name=name
    sc.cursor.location=PIVOTS[name];bpy.ops.object.origin_set(type='ORIGIN_CURSOR');bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
    # Recalculate winding for every connected manufactured component.
    bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.000001);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free()
    # Budget-controlled collapse retains the dense primary silhouettes.
    # Primary body curves and conformal graphics remain at their authored resolution.
    # Triangulate deterministically, export exactly what is counted.
    m=o.modifiers.new('Final game triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=m.name)
    asset.append(o)
# Preserve hard manufactured boundaries while keeping curved surfaces smooth.
for obj in asset:
    if obj.name.startswith('Wheel_'):obj.data.set_sharp_from_angle(angle=math.radians(50))
# Bake a unique neutral paint atlas: photo microdetail plus lower-body race grime.
body=next(o for o in asset if o.name=='Body')
paint_obj=body.copy();paint_obj.data=body.data.copy();sc.collection.objects.link(paint_obj)
paint_index=next(i for i,m in enumerate(body.data.materials) if m==M['Paint'])
bm=bmesh.new();bm.from_mesh(paint_obj.data);bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.material_index!=paint_index],context='FACES');bm.to_mesh(paint_obj.data);bm.free()
paint_obj.data.materials.clear();paint_obj.data.materials.append(M['Paint'])
for f in paint_obj.data.polygons:f.material_index=0
active(paint_obj)
old=paint_obj.data.uv_layers.active;old.name='SourceUV'
new=paint_obj.data.uv_layers.new(name='UVMap');paint_obj.data.uv_layers.active=new;new.active_render=True
bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.uv.smart_project(angle_limit=1.15,island_margin=.012);bpy.ops.object.mode_set(mode='OBJECT')
mat=M['Paint'];n=mat.node_tree.nodes;l=mat.node_tree.links
uv=n.new('ShaderNodeUVMap');uv.uv_map='SourceUV'
for t in list(n):
    if t.type=='TEX_IMAGE':l.new(uv.outputs[0],t.inputs['Vector'])
p=next(t for t in n if t.type=='BSDF_PRINCIPLED');out=next(t for t in n if t.type=='OUTPUT_MATERIAL')
base=n['PAINT_FACTOR'].inputs[1].links[0].from_socket
geo=n.new('ShaderNodeNewGeometry');sepz=n.new('ShaderNodeSeparateXYZ');l.new(geo.outputs['Position'],sepz.inputs[0])
height=n.new('ShaderNodeMapRange');height.clamp=True;height.inputs['From Min'].default_value=.18;height.inputs['From Max'].default_value=.68;height.inputs['To Min'].default_value=1;height.inputs['To Max'].default_value=0;l.new(sepz.outputs['Z'],height.inputs['Value'])
noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=68;noise.inputs['Detail'].default_value=4;noise.inputs['Roughness'].default_value=.8;l.new(geo.outputs['Position'],noise.inputs['Vector'])
ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].position=.40;ramp.color_ramp.elements[1].position=.70;l.new(noise.outputs['Fac'],ramp.inputs[0])
mask=n.new('ShaderNodeMath');mask.operation='MULTIPLY';l.new(height.outputs[0],mask.inputs[0]);l.new(ramp.outputs[0],mask.inputs[1])
mix=n.new('ShaderNodeMixRGB');mix.blend_type='MIX';l.new(mask.outputs[0],mix.inputs[0]);l.new(base,mix.inputs[1]);mix.inputs[2].default_value=(.38,.38,.38,1)
rough=n.new('ShaderNodeMath');rough.operation='MULTIPLY_ADD';rough.inputs[1].default_value=.36;rough.inputs[2].default_value=.30;l.new(mask.outputs[0],rough.inputs[0])
emit=n.new('ShaderNodeEmission');atlas={};sc.cycles.samples=12;sc.render.bake.margin=6
for kind,size in [('base',1024),('normal',512),('orm',512)]:
    im=bpy.data.images.new('PaintAtlas_'+kind,width=size,height=size,alpha=False)
    if kind!='base':im.colorspace_settings.name='Non-Color'
    target=n.new('ShaderNodeTexImage');target.image=im;n.active=target
    if kind=='normal':
        l.new(p.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
    else:
        if kind=='base':l.new(mix.outputs[0],emit.inputs[0])
        else:
            combine=n.new('ShaderNodeCombineColor');combine.inputs[0].default_value=1;combine.inputs[2].default_value=.12;l.new(rough.outputs[0],combine.inputs[1]);l.new(combine.outputs[0],emit.inputs[0])
        l.new(emit.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
    im.filepath_raw=ROOT+'/textures/PaintAtlas_'+kind+('.jpg' if kind=='base' else '.png');im.file_format='JPEG' if kind=='base' else 'PNG';im.save();atlas[kind]=im
n.clear();out=n.new('ShaderNodeOutputMaterial');p=n.new('ShaderNodeBsdfPrincipled');l.new(p.outputs[0],out.inputs[0]);p.inputs['Coat Weight'].default_value=1;p.inputs['Coat Roughness'].default_value=.05
base=n.new('ShaderNodeTexImage');base.image=atlas['base'];mix=n.new('ShaderNodeMixRGB');mix.name='PAINT_FACTOR';mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(*COLORS[0][1],1);l.new(base.outputs[0],mix.inputs[1]);l.new(mix.outputs[0],p.inputs['Base Color'])
normal=n.new('ShaderNodeTexImage');normal.image=atlas['normal'];nm=n.new('ShaderNodeNormalMap');l.new(normal.outputs[0],nm.inputs[1]);l.new(nm.outputs[0],p.inputs['Normal'])
orm=n.new('ShaderNodeTexImage');orm.image=atlas['orm'];sep=n.new('ShaderNodeSeparateColor');l.new(orm.outputs[0],sep.inputs[0]);l.new(sep.outputs[1],p.inputs['Roughness']);l.new(sep.outputs[2],p.inputs['Metallic'])
g=n.new('ShaderNodeGroup');g.node_tree=bpy.data.node_groups['glTF Material Output'];l.new(sep.outputs[0],g.inputs['Occlusion'])
paint_obj.data.uv_layers.remove(paint_obj.data.uv_layers['SourceUV']);paint_obj.data.uv_layers.active=paint_obj.data.uv_layers['UVMap']
bm=bmesh.new();bm.from_mesh(body.data);bmesh.ops.delete(bm,geom=[f for f in bm.faces if f.material_index==paint_index],context='FACES');bm.to_mesh(body.data);bm.free()
active(body);paint_obj.select_set(True);bpy.ops.object.join()
print('BAKED unique body paint atlas',flush=True)

# Exact 1.98 m width and 1.30 m height; long-axis end correction on shell only.
for o in asset:
    if o.type!='MESH':continue
    for vert in o.data.vertices:
        world=o.matrix_world@vert.co
        world.x=max(-.99,min(.99,world.x))
        world.z=max(0,world.z)
        if o.name=='Body':
            world.z=min(world.z,1.30)
            world.y=max(-2.625,min(2.455,world.y))
        vert.co=o.matrix_world.inverted()@world
# Ground-contact and socket contract native -> glTF mapping is x,z,-y.
SOCKETS={'Seat_Driver':(.42,.34,.43),'Door_L':(1.02,.05,.36),'Door_R':(-1.02,.05,.36),'Exhaust':(-.982,.973,.22),'Light_Head_L':(.67,-2.38,.66),'Light_Head_R':(-.67,-2.38,.66),'Light_Tail_L':(.69,2.455,.6),'Light_Tail_R':(-.69,2.455,.6)}
for name,pos in SOCKETS.items():
    o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=pos;o.empty_display_type='PLAIN_AXES';o.empty_display_size=.1;asset.append(o)
tri=sum(len(o.data.polygons) for o in asset if o.type=='MESH');print('TRIANGLES',tri,flush=True)
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=ROOT+'/stock-car.glb',export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO',export_extras=True)
# Set exact runtime factors, independent of exporter graph pattern support.
p=ROOT+'/stock-car.glb';raw=open(p,'rb').read();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);binary=raw[20+jl:]
for m in doc['materials']:
    if m['name']=='Paint':
        m['pbrMetallicRoughness']['baseColorFactor']=COLORS[0][1]+[1]
        m.setdefault('extensions',{})['KHR_materials_clearcoat']={'clearcoatFactor':1,'clearcoatRoughnessFactor':.05}
    if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
for ext in ['KHR_materials_clearcoat']:
    if ext not in doc.setdefault('extensionsUsed',[]):doc['extensionsUsed'].append(ext)
j=json.dumps(doc,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
open(p,'wb').write(struct.pack('<4sII',b'glTF',2,12+8+len(j)+len(binary))+struct.pack('<I4s',len(j),b'JSON')+j+binary)

def gp(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
coords=[o.matrix_world@Vector(c) for o in asset if o.type=='MESH' for c in o.bound_box]
bounds=[gp(Vector((min(v.x for v in coords),max(v.y for v in coords),min(v.z for v in coords)))),gp(Vector((max(v.x for v in coords),min(v.y for v in coords),max(v.z for v in coords))))]
metadata={'contract':'Forge Garage v2','units':'metres','forward':'+Z','up':'+Y','left':'+X','nodes':[{'name':o.name,'type':'mesh' if o.type=='MESH' else 'empty','world_position':gp(o.location)} for o in asset],'bounding_box':{'min':bounds[0],'max':bounds[1]},'wheelbase':2.79,'triangles':tri,'paints':[{'name':n,'linear_rgb':c} for n,c in COLORS]}
json.dump(metadata,open(ROOT+'/nodes.json','w'),indent=2)
open(ROOT+'/NOTES.md','w').write('Original full-size oval-track stock car, built in metres; front +Z, up +Y, Garage v2 pivots and sockets.\n'+f'{tri:,} triangles; six named mesh nodes plus eight attachment empties.\n'+'8 materials: Paint, Rubber, Metal, Carbon, Interior, Glass, Lamps, Roundel; neutral baked Paint detail with linear tint and clearcoat.\n'+'Cycles GPU body paint atlas and shared material tile bakes: base colour up to 1024 px; tangent normal and packed ORM 512 px; shared by parts of each material.\n'+'Poly Haven CC0 ids: metal_plate, rubber_tiles, denim_fabric, fabric_leather_01 (https://polyhaven.com/license).\n'+'Original headlight graphics, rear lights and side window line differ deliberately; default blue replaces reference white; restrained wear and simplified underbody.\n')
# Studio render rig, deliberately excluded from GLB.
world=bpy.data.worlds.new('Soft daylight');sc.world=world;world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.62,.69,.8,1);world.node_tree.nodes['Background'].inputs[1].default_value=.3
floor_mat=bpy.data.materials.new('Studio floor');floor_mat.diffuse_color=(.19,.205,.22,1);floor_mat.use_nodes=True;floor_mat.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.82
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.006));floor=bpy.context.object;floor.name='STUDIO floor';floor.data.materials.append(floor_mat)
for name,loc,power,size in [('Key',(3,-4,7),1100,5),('Fill',(-4,-1,4),600,4),('Rim',(1,5,5),1000,4)]:
    data=bpy.data.lights.new(name,'AREA');data.energy=power;data.shape='DISK';data.size=size;o=bpy.data.objects.new(name,data);sc.collection.objects.link(o);o.location=loc;o.rotation_euler=(Vector((0,0,.5))-o.location).to_track_quat('-Z','Y').to_euler()
data=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',data);sc.collection.objects.link(cam);sc.camera=cam
def camera(loc,target,ortho=None):
    cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();data.type='ORTHO' if ortho else 'PERSP';data.lens=48
    if ortho:data.ortho_scale=ortho

def render(name,w,h,samples=32):
    sc.render.resolution_x=w;sc.render.resolution_y=h;sc.render.resolution_percentage=100;sc.cycles.samples=samples;sc.render.filepath=ROOT+'/'+name;bpy.ops.render.render(write_still=True)
camera((6.35,-8.1,3.25),(0,-.12,.61))
# Save reproducible source scene for independent verification.
bpy.ops.wm.save_as_mainfile(filepath=ROOT+'/stock-car.blend')
render('final.png',1920,1080,40)
camera((7,0,.65),(0,0,.65),5.8);render('side.png',1920,1080,24)
camera((6.35,-8.1,3.25),(0,-.12,.61))
clay=bpy.data.materials.new('Clay override');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=.7
sc.view_layers[0].material_override=clay;render('clay.png',1920,1080,24);sc.view_layers[0].material_override=None
# Six matched cameras, joined into a 3x2 proof sheet using Blender image pixels.
import numpy as np
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,(name,color) in enumerate(COLORS):
    M['Paint'].node_tree.nodes['PAINT_FACTOR'].inputs[2].default_value=(*color,1)
    render('paint_'+str(i)+'.png',640,360,12)
    im=bpy.data.images.load(ROOT+'/paint_'+str(i)+'.png',check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);row=1-i//3;col=i%3;canvas[row*360:(row+1)*360,col*640:(col+1)*640,:]=a;bpy.data.images.remove(im)
M['Paint'].node_tree.nodes['PAINT_FACTOR'].inputs[2].default_value=(*COLORS[0][1],1)
im=bpy.data.images.new('Six paint finishes',width=1920,height=720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=ROOT+'/paints.png';im.file_format='PNG';im.save()
print('COMPLETE',tri,os.path.getsize(ROOT+'/stock-car.glb'),flush=True)
