"""Clean reproducible saloon build. Blender 5.2 / Cycles GPU; CC0 inputs only."""
import bpy, bmesh, math, os, json, urllib.request, struct, hashlib
import numpy as np
from mathutils import Vector
from math import sin,cos,pi,sqrt
print('BUILD_REVISION_DELIVERY_PIVOTS',flush=True)
ROOT=os.path.dirname(os.path.abspath(__file__))
SCRIPT_SHA256=hashlib.sha256(open(__file__,'rb').read()).hexdigest()
os.makedirs(ROOT+'/textures',exist_ok=True)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
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!='CPU'
sc.cycles.device='GPU'; sc.cycles.use_denoising=True
sc.render.image_settings.file_format='PNG'; sc.world.color=(.25,.25,.25)
sc.view_settings.view_transform='AgX'
# All authored coordinates: X left, -Y forward, Z up. glTF exporter rotates to Y-up.
def activate(o):
 bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o

def mesh(name,v,f,mat):
 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)
 if mat: me.materials.append(mat)
 for p in me.polygons:p.use_smooth=True
 return o

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

def cube(name,loc,scale,mat,b=.015):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.dimensions=scale
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);o.data.materials.append(mat)
 bevel(o,b,3)
 for p in o.data.polygons:p.use_smooth=True
 m=o.modifiers.new('Weighted panel normals','WEIGHTED_NORMAL');bpy.ops.object.modifier_apply(modifier=m.name)
 return o

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

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

def torus(name,loc,major,minor,mat,axis='X',N=80,M=12):
 bpy.ops.mesh.primitive_torus_add(major_segments=N,minor_segments=M,location=loc,major_radius=major,minor_radius=minor)
 o=bpy.context.object;o.name=name
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 o.data.materials.append(mat)
 for p in o.data.polygons:p.use_smooth=True
 return o

def uv_project(o):
 if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
 uv=o.data.uv_layers.active.data
 detail=None
 if any(m.name=='Paint' for m in o.data.materials):
  layer=o.data.uv_layers.get('DetailUV') or o.data.uv_layers.new(name='DetailUV');detail=layer.data
 for p in o.data.polygons:
  ax=max(range(3),key=lambda i:abs(p.normal[i])); ij=[i for i in range(3) if i!=ax]
  for li in p.loop_indices:
   co=o.data.vertices[o.data.loops[li].vertex_index].co;uv[li].uv=(co[ij[0]]*.8,co[ij[1]]*.8)
   if detail is not None:detail[li].uv=uv[li].uv
   if o.data.materials[p.material_index].name=='Paint':
    world=o.matrix_world@co;uv[li].uv=((world.x+world.y)*.45,world.z/1.42)

def download(id,map):
 path=f'{ROOT}/textures/{id}_{map}_1k.jpg'
 if not os.path.exists(path):urllib.request.urlretrieve(f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{map}_1k.jpg',path)
 im=bpy.data.images.load(path,check_existing=True)
 if map!='diff':im.colorspace_settings.name='Non-Color'
 return im
# Photographic microdetail, neutralised for the recolourable enamel.
sources={id:{k:download(id,k) for k in ['diff','nor_gl','arm']} for id in ['metal_plate','fabric_leather_01']}
materials={}
def baked_material(name,color,rough,metal,source='metal_plate',strength=.08):
 m=bpy.data.materials.new(name);m.use_nodes=True;nt=m.node_tree;nt.nodes.clear();n=nt.nodes;l=nt.links
 out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs['BSDF'],out.inputs[0])
 tc=n.new('ShaderNodeTexCoord');tex=n.new('ShaderNodeTexImage');tex.image=sources[source]['diff'];l.new(tc.outputs['UV'],tex.inputs['Vector'])
 bw=n.new('ShaderNodeRGBToBW');l.new(tex.outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=tuple(c*.82 for c in color)+(1,);ramp.color_ramp.elements[1].color=(*color,1);l.new(bw.outputs[0],ramp.inputs[0]);l.new(ramp.outputs[0],bs.inputs['Base Color'])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=180;l.new(tc.outputs['UV'],noise.inputs['Vector'])
 if name=='Paint':
  grime=n.new('ShaderNodeTexNoise');grime.inputs['Scale'].default_value=23;grime.inputs['Detail'].default_value=4;l.new(tc.outputs['UV'],grime.inputs['Vector'])
  ramp.color_ramp.elements[0].color=(.49,.49,.49,1)
  split=n.new('ShaderNodeSeparateXYZ');l.new(tc.outputs['UV'],split.inputs[0])
  heightinv=n.new('ShaderNodeMath');heightinv.operation='SUBTRACT';heightinv.inputs[0].default_value=.36;l.new(split.outputs['Y'],heightinv.inputs[1])
  mask=n.new('ShaderNodeMath');mask.operation='MULTIPLY';mask.use_clamp=True;mask.inputs[1].default_value=4;l.new(heightinv.outputs[0],mask.inputs[0])
  inv=n.new('ShaderNodeMath');inv.operation='SUBTRACT';inv.inputs[0].default_value=1;l.new(grime.outputs['Fac'],inv.inputs[1])
  mul=n.new('ShaderNodeMath');mul.operation='MULTIPLY';l.new(inv.outputs[0],mul.inputs[0]);l.new(mask.outputs[0],mul.inputs[1])
  combine=n.new('ShaderNodeMath');combine.operation='MULTIPLY_ADD';combine.inputs[1].default_value=-.75;combine.inputs[2].default_value=1;l.new(mul.outputs[0],combine.inputs[0]);l.new(combine.outputs[0],ramp.inputs[0])
 bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=strength;bump.inputs['Distance'].default_value=.0003;l.new(noise.outputs['Fac'],bump.inputs['Height'])
 normal=n.new('ShaderNodeTexImage');normal.image=sources[source]['nor_gl'];l.new(tc.outputs['UV'],normal.inputs['Vector']);nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=strength;l.new(normal.outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bump.inputs['Normal']);l.new(bump.outputs[0],bs.inputs['Normal'])
 arm=n.new('ShaderNodeTexImage');arm.image=sources[source]['arm'];l.new(tc.outputs['UV'],arm.inputs['Vector']);sep=n.new('ShaderNodeSeparateColor');l.new(arm.outputs[0],sep.inputs[0])
 rr=n.new('ShaderNodeMath');rr.operation='MULTIPLY_ADD';rr.inputs[1].default_value=.07;rr.inputs[2].default_value=rough;l.new(sep.outputs[1],rr.inputs[0]);l.new(rr.outputs[0],bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-5));o=bpy.context.object;o.data.materials.append(m);activate(o)
 target=n.new('ShaderNodeTexImage');em=n.new('ShaderNodeEmission')
 maps={}
 for kind,res in [('base',1024),('normal',1024),('orm',512)]:
  im=bpy.data.images.new(name+'_'+kind,width=res,height=res,alpha=False);im.colorspace_settings.name='sRGB' if kind=='base' else 'Non-Color';target.image=im;n.active=target
  if kind=='base':l.new(ramp.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);typ='EMIT'
  elif kind=='normal':l.new(bs.outputs[0],out.inputs[0]);typ='NORMAL'
  else:
   comb=n.new('ShaderNodeCombineColor');comb.inputs[0].default_value=1;comb.inputs[2].default_value=metal;l.new(rr.outputs[0],comb.inputs[1]);l.new(comb.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);typ='EMIT'
  sc.cycles.samples=4;bpy.ops.object.bake(type=typ);im.filepath_raw=f'{ROOT}/textures/{name}_{kind}.png';im.file_format='PNG';im.save();maps[kind]=im
 bpy.data.objects.remove(o,do_unlink=True);n.clear();out=n.new('ShaderNodeOutputMaterial');bs=n.new('ShaderNodeBsdfPrincipled');l.new(bs.outputs[0],out.inputs[0])
 ts={}
 for k,im in maps.items():ts[k]=n.new('ShaderNodeTexImage');ts[k].image=im
 l.new(ts['base'].outputs[0],bs.inputs['Base Color']);nm=n.new('ShaderNodeNormalMap');l.new(ts['normal'].outputs[0],nm.inputs['Color']);l.new(nm.outputs[0],bs.inputs['Normal'])
 sep=n.new('ShaderNodeSeparateColor');l.new(ts['orm'].outputs[0],sep.inputs[0]);l.new(sep.outputs[1],bs.inputs['Roughness']);l.new(sep.outputs[2],bs.inputs['Metallic'])
 if name=='Paint':
  uvbase=n.new('ShaderNodeUVMap');uvbase.uv_map='UVMap';l.new(uvbase.outputs['UV'],ts['base'].inputs['Vector'])
  uvdetail=n.new('ShaderNodeUVMap');uvdetail.uv_map='DetailUV';l.new(uvdetail.outputs['UV'],ts['normal'].inputs['Vector']);l.new(uvdetail.outputs['UV'],ts['orm'].inputs['Vector'])
  mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=1;mix.inputs[2].default_value=(.45,.62,.52,1);l.new(ts['base'].outputs[0],mix.inputs[1]);l.new(mix.outputs[0],bs.inputs['Base Color']);mix.name='PaintFactor';bs.inputs['Coat Weight'].default_value=1;bs.inputs['Coat Roughness'].default_value=.05
 materials[name]=m;return m
P=baked_material('Paint',(.97,.97,.97),.21,.0,strength=.035)
C=baked_material('Chrome',(.68,.7,.72),.10,1,strength=.055)
R=baked_material('Rubber',(.012,.014,.016),.66,0,strength=.22)
I=baked_material('Interior',(.075,.06,.045),.48,0,'fabric_leather_01',.3)
D=baked_material('Steel',(.07,.08,.085),.4,.75,strength=.14)
G=baked_material('Glass',(.65,.77,.78),.05,0,strength=0)
gbs=G.node_tree.nodes.get('Principled BSDF');gbs.inputs['Alpha'].default_value=.15;gbs.inputs['Transmission Weight'].default_value=.35
G.surface_render_method='DITHERED';G.use_transparency_overlap=False
L=baked_material('Lamps',(.9,.84,.66),.12,.38,strength=.08)
# Lamp atlas: bake three physical lens colours through Cycles emission.
nt=L.node_tree;n=nt.nodes;l=nt.links;bs=n.get('Principled BSDF');out=n.get('Material Output');uv=n.new('ShaderNodeTexCoord');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.remove(r.color_ramp.elements[1]);r.color_ramp.elements[0].color=(.88,.83,.66,1);r.color_ramp.elements.new(.333).color=(.55,.006,.002,1);r.color_ramp.elements.new(.666).color=(1,.22,.006,1);l.new(sep.outputs[0],r.inputs[0]);em=n.new('ShaderNodeEmission');l.new(r.outputs[0],em.inputs[0]);l.new(em.outputs[0],out.inputs[0]);im=bpy.data.images.new('Lamp_colours',256,256);target=n.new('ShaderNodeTexImage');target.image=im;n.active=target
bpy.ops.mesh.primitive_plane_add(size=1,location=(0,0,-5));o=bpy.context.object;o.data.materials.append(L);bpy.ops.object.bake(type='EMIT');im.filepath_raw=ROOT+'/textures/Lamp_colours.png';im.file_format='PNG';im.save();bpy.data.objects.remove(o,do_unlink=True);l.new(bs.outputs[0],out.inputs[0]);l.new(target.outputs[0],bs.inputs['Base Color']);l.new(target.outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.045

def lamp_uv(o,band):
 uv_project(o)
 for q in o.data.uv_layers.active.data:q.uv=(band/3+.16,.5)
 o['lamp_uv']=True
# Smooth lower monocoque sides with real arch openings.
def width(y):return .765-.07*(abs(y)/1.91)**5

def shoulder(y):return .915-.095*(abs(y)/1.91)**4

def bottom(y):
 z=.245+.095*(abs(y)/1.91)**18
 for wy in [-1.2,1.2]:
  d=abs(y-wy)
  if d<.355:z=max(z,.29+sqrt(max(0,.355**2-d*d)))
 return z
for s in [-1,1]:
 v=[];f=[];N=180;K=12
 for j in range(N+1):
  y=-1.91+3.82*j/N;b=bottom(y);t=shoulder(y)
  for k in range(K+1):
   u=k/K;z=b+(t-b)*u;vheight=(z-.245)/(.915-.245);x=width(y)-.035*(2*vheight-1)**2+.022*sin(pi*vheight)
   v.append((s*x,y,z))
 for j in range(N):
  for k in range(K):a=j*(K+1)+k;f.append((a,a+1,a+K+2,a+K+1) if s==-1 else (a+K+1,a+K+2,a+1,a))
 o=mesh('Pressed quarter and door skins',v,f,P);mod=o.modifiers.new('Sheet metal return','SOLIDIFY');mod.thickness=.014;activate(o);bpy.ops.object.modifier_apply(modifier=mod.name)
 for wy in [-1.2,1.2]:
  pts=[]
  for a in np.linspace(0,pi,61):
   ay=wy+.355*cos(a);az=.29+.355*sin(a);h=(az-.245)/.670;ax=width(ay)-.035*(2*h-1)**2+.022*sin(pi*h)
   pts.append((s*(ax+.002),ay,az))
  tube('Rolled arch lip',pts,.008,P)
  pts2=[(s*.714,wy+.343*cos(a),.29+.343*sin(a)) for a in np.linspace(0,pi,61)]
  tube('Dark wheel arch return',pts2,.018,R)
 # rocker and belt pressed creases
 tube('Sill folded edge',[(s*.735,y,.253) for y in np.linspace(-.82,.81,40)],.017,P)
 tube('Belt shoulder line',[(s*(width(y)-.035*(2*((shoulder(y)-.075-.245)/.670)-1)**2+.022*sin(pi*((shoulder(y)-.075-.245)/.670))+.002),y,shoulder(y)-.075) for y in np.linspace(-1.82,1.82,90)],.003,P)
# Bonnet, rear deck: transverse crowned surfaces.
def deck(name,ya,yb,za,zb):
 v=[];f=[];N=32;M=30
 for j in range(N+1):
  t=j/N;y=ya+(yb-ya)*t
  for i in range(M+1):
   u=-1+2*i/M;v.append((u*(width(y)-.035*(2*((shoulder(y)-.245)/.670)-1)**2+.022*sin(pi*((shoulder(y)-.245)/.670))),y,shoulder(y)+.038*(1-u*u)+.012*sin(pi*t)))
 for j in range(N):
  for i in range(M):a=j*(M+1)+i;f.append((a,a+1,a+M+2,a+M+1))
 return mesh(name,v,f,P)
deck('Bonnet',-1.91,-.77,.824,.916);deck('Boot lid',1.10,1.91,.914,.824)
# Compound stamped end skins meet the quarter panels without block corner caps.
for endsign in [-1,1]:
 vv=[];ff=[];NX=48;NZ=24
 for iz in range(NZ+1):
  t=iz/NZ
  for ix in range(NX+1):
   u=-1+2*ix/NX;z=.34+(shoulder(1.91)-.34)*t+.038*(1-u*u)*t
   h=(z-.245)/.670;w=width(1.91)-.035*(2*h-1)**2+.022*sin(pi*h)
   vv.append((u*w,endsign*(1.91+.021*(1-u*u)*sin(pi*t)),z))
 for iz in range(NZ):
  for ix in range(NX):
   a=iz*(NX+1)+ix;ff.append((a,a+1,a+NX+2,a+NX+1))
 o=mesh('Crowned front fascia' if endsign<0 else 'Crowned rear fascia',vv,ff,P)
 m=o.modifiers.new('Stamped panel thickness','SOLIDIFY');m.thickness=.012;activate(o);bpy.ops.object.modifier_apply(modifier=m.name)
cube('Floor pan',(0,.05,.255),(1.35,2.65,.08),D,.04)
# Sheet-metal wheel tubs close the cavity behind each tyre.
for s in [-1,1]:
 for wy in [-1.2,1.2]:
  vv=[];ff=[]
  for a in np.linspace(-.10,pi+.10,65):
   for x in [s*.49,s*.716]:vv.append((x,wy+.366*cos(a),.29+.366*sin(a)))
  for i in range(64):ff.append((2*i,2*i+1,2*i+3,2*i+2))
  mesh('Wheelhouse inner tub',vv,ff,R)
  wall=[(s*.49,wy+.367*cos(a),.29+.367*sin(a)) for a in np.linspace(0,pi,65)]
  wall.extend([(s*.49,wy-.367,.205),(s*.49,wy+.367,.205)])
  mesh('Wheelhouse inner closure',wall,[tuple(range(len(wall)))],R)
# Cabin: softly crowned roof and thin framed individual glass panes.
# Pressed roof: nested rounded-rectangle sections, continuous compound crown.
rv=[];rf=[];RN=96
for scale,z in [(1,1.351),(1.005,1.362),(.985,1.379),(.94,1.395),(.84,1.409),(.68,1.417),(.42,1.420),(.12,1.420)]:
 for a in np.linspace(0,2*pi,RN,endpoint=False):
  x=.647*scale*math.copysign(abs(cos(a))**.16,cos(a));y=.22+.714*scale*math.copysign(abs(sin(a))**.16,sin(a));rv.append((x,y,z))
for j in range(7):
 for i in range(RN):a=j*RN+i;b=j*RN+(i+1)%RN;rf.append((a,b,b+RN,a+RN))
rf.append(tuple(range(7*RN,8*RN)));rf.append(tuple(reversed(range(RN))))
mesh('Compound crowned roof',rv,rf,P)
# rounded polygon helper, interpolating corners to keep framed openings smooth
def rounded(poly,rad=.1,steps=6):
 out=[]
 for i,p in enumerate(poly):
  p=Vector(p);a=p+(Vector(poly[i-1])-p)*rad;b=p+(Vector(poly[(i+1)%len(poly)])-p)*rad
  for t in np.linspace(0,1,steps):out.append(tuple((1-t)**2*a+2*(1-t)*t*p+t*t*b))
 return out

def window(name,poly):
 pts=rounded(poly,.1,8);o=mesh(name,pts,[tuple(range(len(pts)))],G)
 tube(name+' rubber seal',pts,.015,R,True);tube(name+' polished surround',pts,.008,C,True)
 return o
for s in [-1,1]:
 def q(y,z):return (s*(.746-(z-.92)*.27),y,z)
 # fixed pillars define actual openings instead of dark decals
 front=[q(-.785,.945),q(-.405,1.328),q(.22,1.328),q(.235,.945)]
 rear=[q(.285,.945),q(.275,1.328),q(.835,1.325),q(1.115,.945)]
 window('Door glass',front);window('Rear quarter glass',rear)
 tube('A pillar',[q(-.84,.918),q(-.43,1.35)],.023,P)
 strip=mesh('A pillar pressed return',[q(-.84,.918),(s*.695,-.811,.94),(s*.584,-.435,1.33),q(-.43,1.35)],[(0,1,2,3)],P)
 mod=strip.modifiers.new('Pillar steel','SOLIDIFY');mod.thickness=.012;activate(strip);bpy.ops.object.modifier_apply(modifier=mod.name)
 tube('B pillar',[q(.26,.918),q(.25,1.35)],.025,P)
 tube('C pillar',[q(1.19,.92),q(.86,1.35)],.042,P)
 tube('Roof side header',[q(y,1.344) for y in np.linspace(-.43,.86,50)],.019,P)
 tube('Roof rain gutter',[q(y,1.365) for y in np.linspace(-.43,.86,50)],.009,C)
 tube('Quarterlight divider',[q(-.55,.953),q(-.53,1.19)],.007,C)
 tube('Window belt infill',[q(y,.923) for y in np.linspace(-.81,1.16,60)],.022,P)
 # large door perimeter recessed black hairline
 poly=[q(-.80,.916),(s*.762,-.86,.80),(s*.759,-.84,.34),(s*.753,-.76,.302),(s*.761,.21,.302),(s*.765,.26,.37),q(.26,.915)]
 seam=[]
 curvepts=rounded(poly,.1,7);dense=[]
 for a,b in zip(curvepts,curvepts[1:]):
  a,b=Vector(a),Vector(b)
  dense.extend(tuple(a.lerp(b,t)) for t in np.linspace(0,1,max(2,int((b-a).length/.012))))
 for co in dense:
  y,z=co[1:];u=max(0,min(1,(z-.245)/(.915-.245)))
  x=width(y)-.035*(2*u-1)**2+.022*sin(pi*u)
  seam.append((s*(x+.001),y,z))
 tube('Door shut line',seam,.002,R,False,2)
 cube('Handle recess',(s*.773,.125,.836),(.012,.155,.033),R,.012)
 cube('Chrome door handle',(s*.787,.12,.843),(.022,.137,.017),C,.008)
 cylinder('Handle button',(s*.798,.18,.843),.012,.013,C,verts=20)
 # c-pillar ventilation louvres
 for j in range(4):tube('Cabin vent',[(s*.739,1.09+j*.014,.916),(s*.743,1.20+j*.014,.916)],.004,R,res=1)
 # mirror stem and housing
 tube('Mirror stalk',[(s*.72,-.70,.94),(s*.775,-.74,1.012)],.008,C)
 cube('Mirror housing',(s*.774,-.75,1.021),(.032,.09,.055),C,.018)
 cube('Mirror glass',(s*.775,-.705,1.021),(.025,.003,.045),G,.008)
tube('Front roof header',[(-.615,-.435,1.343),(.615,-.435,1.343)],.019,P)
tube('Rear roof header',[(-.615,.87,1.343),(.615,.87,1.343)],.019,P)
window('Windscreen',[(-.695,-.811,.94),(.695,-.811,.94),(.584,-.435,1.33),(-.584,-.435,1.33)])
window('Rear windscreen',[(.692,1.145,.945),(-.692,1.145,.945),(-.582,.87,1.33),(.582,.87,1.33)])
# bonnet shut lines, wipers and scuttle vents
for s in [-1,1]:
 tube('Bonnet edge gap',[(s*.625,y,shoulder(y)+.038*(1-(.625/(width(y)-.035))**2)+.012*sin(pi*(y+1.9)/1.13)+.0015) for y in np.linspace(-1.83,-.8,55)],.0025,R,res=1)
 tube('Wiper arm',[(s*.26,-.805,.958),(s*.42,-.73,1.01)],.006,C)
 tube('Wiper blade',[(s*.20,-.729,1.01),(s*.61,-.729,1.01)],.006,R)
 for j in range(7):tube('Scuttle grille',[(s*(.32+j*.028),-.83,.945),(s*(.32+j*.028),-.875,.94)],.0025,R,res=1)
# Bumpers swept in plan, overriders, simple original face with no badges.
for y in [-1.94,1.94]:
 pts=[(x,y+(1 if y<0 else -1)*.12*(abs(x)/.74)**5,.41) for x in np.linspace(-.74,.74,65)]
 tube('Chromed bumper',pts,.041,C,res=5)
 for x in [-.48,.48]:
  cube('Bumper overrider',(x,y,.425),(.055,.067,.165),C,.018)
  cube('Overrider rubber pad',(x,y+(-.041 if y<0 else .041),.426),(.034,.016,.13),R,.012)
# Lamps front reflector bowl, rings, ribbed lens; tail separate circular originals.
for s in [-1,1]:
 for y,z,rad,band in [(-1.928,.739,.112,0),(1.931,.742,.064,1),(1.933,.615,.033,2),(-1.939,.542,.032,2)]:
  cylinder('Lamp bezel',(s*.572,y,z),rad+.012,.021,C,'Y')
  if rad>.1:
   bpy.ops.mesh.primitive_uv_sphere_add(segments=64,ring_count=24,location=(s*.572,y-.022,z));o=bpy.context.object;o.name='Convex prismatic headlamp';o.scale=(rad,.024,rad);o.data.materials.append(G)
   for p in o.data.polygons:p.use_smooth=True
   activate(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
   rv=[];rf=[]
   for rr in np.linspace(.009,rad,9):
    for a in np.linspace(0,2*pi,64,endpoint=False):rv.append((s*.572+rr*sin(a),y-.005-.017*(rr/rad)**2,z+rr*cos(a)))
   for ring in range(8):
    for k in range(64):
     a=ring*64+k;b=ring*64+(k+1)%64;rf.append((a,b,b+64,a+64))
   mesh('Parabolic headlamp reflector',rv,rf,C)
   bulb=cylinder('Headlamp bulb',(s*.572,y-.015,z),.018,.025,L,'Y',32);lamp_uv(bulb,0)
   torus('Headlamp polished rolled rim',(s*.572,y-.022,z),rad+.005,.007,C,'Y',80,10)
  else:
   o=cylinder('Prismatic lamp lens',(s*.572,y+(-.013 if y<0 else .013),z),rad,.017,L,'Y');lamp_uv(o,band)
  if rad>.1:
   for dx in np.linspace(-.085,.085,15):
    h=sqrt(rad*rad-dx*dx)*.92;o=tube('Headlamp fluting',[(s*.572+dx,y-.023-.024*sqrt(max(0,1-(dx*dx+dz*dz)/(rad*rad))),z+dz) for dz in np.linspace(-h,h,15)],.0012,G,res=1)
 cylinder('Side indicator',(s*.765,-1.64,.775),.017,.008,L,'X',32);lamp_uv(bpy.context.object,2)
# Original low narrow intake and polished grille bars
cube('Intake recess',(0,-1.945,.615),(.70,.014,.075),R,.025)
for z in [.594,.615,.636]:tube('Grille blade',[(-.32,-1.956,z),(.32,-1.956,z)],.004,C,res=2)
# Interior, visible through all windows.
for s in [-1,1]:
 cube('Front seat cushion',(s*.335,-.04,.455),(.51,.51,.14),I,.055)
 o=cube('Front seat back',(s*.335,.22,.69),(.51,.13,.47),I,.055);o.rotation_euler[0]=-.10
 for x in np.linspace(s*.335-.19,s*.335+.19,7):
  tube('Seat stitched pleat',[(x,-.23,.526),(x,.14,.526)],.0025,R,res=1)
  tube('Seat back pleat',[(x,.144,.53),(x,.176,.85)],.0025,R,res=1)
 cube('Door card',(s*.717,-.20,.65),(.035,.92,.41),I,.024)
 cube('Door armrest',(s*.67,-.13,.65),(.09,.30,.055),I,.02)
 tube('Window crank',[(s*.681,-.42,.72),(s*.66,-.36,.69)],.008,C)
cube('Rear bench',(0,.89,.47),(1.23,.47,.16),I,.06)
cube('Rear backrest',(0,1.10,.705),(1.23,.15,.39),I,.055)
cube('Dashboard',(0,-.665,.866),(1.34,.27,.13),I,.04)
cube('Paint dashboard face',(0,-.52,.819),(1.26,.04,.15),P,.023)
for x in [.30,.46]:
 cylinder('Gauge chrome ring',(x,-.492,.835),.054,.01,C,'Y',48)
 cylinder('Black gauge face',(x,-.484,.835),.047,.01,R,'Y',48)
 tube('Gauge needle',[(x,-.476,.835),(x+.022,-.476,.856)],.002,C,res=1)
for x in [-.20,-.10,0,.10]:cylinder('Dash knob',(x,-.473,.79),.012,.024,R,'Y',24)
tube('Gear lever',[(0,-.12,.29),(0,-.23,.59)],.01,C);cylinder('Gear knob',(0,-.23,.60),.025,.028,I,'Z',32)
# Wheel assemblies built independently, pivots exact.
def join_objs(obs,name,origin):
 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;sc.cursor.location=origin;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);return o
wheelobjs=[]
for s,lr in [(1,'L'),(-1,'R')]:
 for y,fr in [(-1.2,'F'),(1.2,'R')]:
  before=set(bpy.data.objects);x=s*.68;z=.29
  # tyre shaped profile lathed about axle, shoulder grooves in true geometry
  prof=[(-.09,.194),(-.092,.228),(-.083,.262),(-.064,.283),(-.04,.29),(.04,.29),(.064,.283),(.083,.262),(.092,.228),(.09,.194)]
  v=[];f=[]
  for a in np.linspace(0,2*pi,97)[:-1]:
   for dx,r in prof:v.append((x+dx,y+r*sin(a),z+r*cos(a)))
  for j in range(96):
   for k in range(len(prof)-1):a=j*len(prof)+k;b=((j+1)%96)*len(prof)+k;f.append((a+1,b+1,b,a))
  mesh('Radial tyre',v,f,R)
  for dx in [-.046,-.017,.017,.046]:torus('Tread circumferential groove',(x+dx,y,z),.287,.003,R,N=96,M=6)
  for a in np.linspace(0,2*pi,96)[:-1]:
   for dx in [-.059,.059]:
    pts=[(x+dx+t*.025,y+(.285)*sin(a+t*.04),z+.285*cos(a+t*.04)) for t in [-.5,.5]];tube('Fine tread block',pts,.002,R,res=1)
  cylinder('Brake drum',(x,y,z),.15,.115,D)
  cylinder('Brake disc',(x-s*.055,y,z),.163,.012,D)
  cylinder('Pressed steel wheel',(x+s*.076,y,z),.194,.026,C)
  for rad in [.188,.167,.149]:torus('Wheel rolled concentric bead',(x+s*.093,y,z),rad,.005,C,N=80,M=8)
  # gently domed plain period hubcap, no manufacturer marks
  bpy.ops.mesh.primitive_uv_sphere_add(segments=64,ring_count=20,location=(x+s*.100,y,z));o=bpy.context.object;o.name='Domed chrome hubcap';o.scale=(.030,.145,.145);o.data.materials.append(C)
  for p in o.data.polygons:p.use_smooth=True
  torus('Hubcap centre emboss',(x+s*.131,y,z),.028,.003,C,N=48,M=8)
  for a in np.linspace(0,2*pi,12)[:-1]:
   cylinder('Rim ventilation',(x+s*.095,y+.158*sin(a),z+.158*cos(a)),.008,.006,D,verts=12)
  obs=list(set(bpy.data.objects)-before)
  for o in obs:activate(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);uv_project(o)
  wheelobjs.append(join_objs(obs,'Wheel_'+fr+lr,(x,y,z)))
# Steering rim and spokes in inclined plane
before=set(bpy.data.objects);hub=Vector((.35,-.365,.90));normal=Vector((0,-.75,.66));u=Vector((1,0,0));v=normal.cross(u)
tube('Steering rim',[tuple(hub+.165*(u*cos(a)+v*sin(a))) for a in np.linspace(0,2*pi,81)[:-1]],.012,I,True)
for a in [pi/2,pi/2+2*pi/3,pi/2+4*pi/3]:tube('Steering spoke',[tuple(hub),tuple(hub+.155*(u*cos(a)+v*sin(a)))],.008,C)
cylinder('Steering hub',hub,.035,.033,I,'Y',32)
steering=join_objs(list(set(bpy.data.objects)-before),'SteeringWheel',hub)
# suspension, exhaust and tank underside
for y in [-1.2,1.2]:tube('Axle',[( -.65,y,.29),(.65,y,.29)],.026,D)
tube('Exhaust pipe',[(.40,-.4,.22),(.42,1.5,.20),(.44,1.94,.22)],.022,D)
cube('Silencer',(.40,1.20,.22),(.17,.40,.11),D,.04)
# consolidate static body, preserving the six mesh-node contract
bodyparts=[o for o in sc.objects if o.type=='MESH' and o not in wheelobjs+[steering]]
for o in bodyparts:
 activate(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 if not o.get('lamp_uv'):uv_project(o)
uv_project(steering)
body=join_objs(bodyparts,'Body',(0,0,0));asset=[body]+wheelobjs+[steering]
for name,pos in {'Seat_Driver':(.335,.04,.52),'Door_L':(.9,-.15,.35),'Door_R':(-.9,-.15,.35),'Exhaust':(.44,1.965,.22),'Light_Head_L':(.572,-1.956,.739),'Light_Head_R':(-.572,-1.956,.739),'Light_Tail_L':(.572,1.953,.742),'Light_Tail_R':(-.572,1.953,.742)}.items():
 o=bpy.data.objects.new(name,None);sc.collection.objects.link(o);o.location=pos;asset.append(o)
# Enforce exact body envelope while preserving 2.40 m axle spacing and tyre radius.
bpy.context.view_layer.update()
pts0=[o.matrix_world@v.co for o in asset if o.type=='MESH' for v in o.data.vertices]
sx=1.58/(max(p.x for p in pts0)-min(p.x for p in pts0))
yfront=-min(p.y for p in pts0);yback=max(p.y for p in pts0)
for o in asset:
 if o.type=='MESH':
  inv=o.matrix_world.inverted()
  for v in o.data.vertices:
   p=o.matrix_world@v.co;p.x*=sx
   if o.name=='Body':
    if p.y < -1.6:p.y=-1.6+(p.y+1.6)*(.4/(yfront-1.6))
    elif p.y>1.6:p.y=1.6+(p.y-1.6)*(.4/(yback-1.6))
   v.co=inv@p
  # wheel origin follows lateral scaling without changing world geometry
  if o.name.startswith('Wheel_') or o.name=='SteeringWheel':
   old=o.location.x;o.location.x*=sx
   for v in o.data.vertices:v.co.x+=old-o.location.x
for o in asset:
 if o.type=='EMPTY':
  o.location.x*=sx
  if o.location.y < -1.6:o.location.y=-1.6+(o.location.y+1.6)*(.4/(yfront-1.6))
  elif o.location.y>1.6:o.location.y=1.6+(o.location.y-1.6)*(.4/(yback-1.6))
# stage, camera and lighting (never included in export)
groundmat=bpy.data.materials.new('Stage');groundmat.diffuse_color=(.23,.25,.26,1)
bpy.ops.mesh.primitive_plane_add(size=2000,location=(0,0,-.001));stage=bpy.context.object;stage.name='Stage';stage.data.materials.append(groundmat)
world=sc.world;world.use_nodes=True;world.node_tree.nodes.get('Background').inputs[0].default_value=(.65,.74,.85,1);world.node_tree.nodes.get('Background').inputs[1].default_value=.3
for name,loc,power,size in [('Key',(4,-4,6),800,5),('Rim',(-3,2,4),700,4),('Front',(-2,-5,2),220,3)]:
 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,.7))-o.location).to_track_quat('-Z','Y').to_euler()
camdata=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',camdata);sc.collection.objects.link(cam);sc.camera=cam

def view(loc,target=(0,0,.73),ortho=5.3):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();camdata.type='ORTHO';camdata.ortho_scale=ortho

def render(file,w=1920,h=1080,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+'/'+file;bpy.ops.render.render(write_still=True)
view((5,-7,2.75))
# Early clay evidence, then final material export.
clay=bpy.data.materials.new('Clay');clay.diffuse_color=(.36,.36,.36,1);clay.use_nodes=True;clay.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.6;clay.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.26,.26,.26,1)
sc.view_layers[0].material_override=clay;render('clay.png',samples=20);sc.view_layers[0].material_override=None
# Apply all geometry and export only contract nodes.
for o in asset:
 if o.type=='MESH':
  activate(o)
  mod=o.modifiers.new('Triangulated production mesh','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=mod.name)
total_tri=sum(len(o.data.polygons) for o in asset if o.type=='MESH')
if total_tri>98000:
 for o in asset:
  if o.type=='MESH':
   activate(o);m=o.modifiers.new('Game triangle budget','DECIMATE');m.ratio=94000/total_tri;bpy.ops.object.modifier_apply(modifier=m.name)
   m=o.modifiers.new('Final triangles','TRIANGULATE');bpy.ops.object.modifier_apply(modifier=m.name)
for o in asset:
 if o.type=='MESH':
  bm=bmesh.new();bm.from_mesh(o.data)
  bad=[f for f in bm.faces if f.calc_area()<1e-10]
  if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
  bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(o.data);bm.free();o.data.update()
  for vertex in o.data.vertices:
   if vertex.co.z+o.location.z<0:vertex.co.z=-o.location.z
bpy.ops.object.select_all(action='DESELECT')
for o in asset:o.select_set(True)
bpy.ops.export_scene.gltf(filepath=ROOT+'/classic-saloon.glb',export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_materials='EXPORT')
# Make colour-factor and alpha contracts explicit in the serialized glTF.
p=ROOT+'/classic-saloon.glb';data=open(p,'rb').read();jl=struct.unpack_from('<I',data,12)[0];j=json.loads(data[20:20+jl]);binary=data[28+jl:]
for mat in j['materials']:
 if mat['name']=='Paint':mat['pbrMetallicRoughness']['baseColorFactor']=[.45,.62,.52,1];mat.setdefault('extensions',{})['KHR_materials_clearcoat']={'clearcoatFactor':1,'clearcoatRoughnessFactor':.05}
 if mat['name']=='Glass':mat['alphaMode']='BLEND';mat['doubleSided']=True
j.setdefault('extensionsUsed',[])
if 'KHR_materials_clearcoat' not in j['extensionsUsed']:j['extensionsUsed'].append('KHR_materials_clearcoat')
jb=json.dumps(j,separators=(',',':')).encode();jb+=b' '*((-len(jb))%4);open(p,'wb').write(struct.pack('<4sII',b'glTF',2,28+len(jb)+len(binary))+struct.pack('<I4s',len(jb),b'JSON')+jb+struct.pack('<I4s',len(binary),b'BIN\0')+binary)
paints=[{'name':n,'linearRGB':c} for n,c in [('Mint',[.45,.62,.52]),('Powder blue',[.24,.39,.49]),('Ivory',[.79,.73,.56]),('Burgundy',[.23,.035,.042]),('Graphite',[.055,.068,.075]),('Ochre',[.52,.30,.09])]]
pts=[o.matrix_world@Vector(v) for o in asset if o.type=='MESH' for v in o.bound_box]
def game(p):return [round(p.x,6),round(p.z,6),round(-p.y,6)]
coords=[game(p) for p in pts];bounds={'min':[min(p[i] for p in coords) for i in range(3)],'max':[max(p[i] for p in coords) for i in range(3)]}
tri=sum(len(o.data.polygons) for o in asset if o.type=='MESH')
json.dump({'buildScriptSHA256':SCRIPT_SHA256,'contract':'Forge Garage v2','units':'metres','front':'+Z','left':'+X','up':'+Y','nodes':[{'name':o.name,'worldPosition':game(o.matrix_world.translation),'type':'mesh' if o.type=='MESH' else 'empty'} for o in asset],'bounds':bounds,'triangles':tri,'paints':paints},open(ROOT+'/nodes.json','w'),indent=2)
open(ROOT+'/NOTES.md','w').write('Original 1960s European two-door saloon, rounded sheet-metal shell and furnished cabin; Garage v2 axes.\n'+f'{tri:,} triangles; six mesh nodes plus eight attachment empties; {os.path.getsize(p)/1048576:.2f} MiB GLB.\n'+'Seven materials: Paint, Chrome, Rubber, Interior, Steel, Glass (BLEND), Lamps (emissive).\n'+'Cycles GPU baked base/normal 1024 px, ORM 512 px; lamp colour atlas 256 px; shared tiled sets.\n'+'Poly Haven CC0: metal_plate, fabric_leather_01; https://polyhaven.com/license; source maps downloaded from dl.polyhaven.org.\n'+'Differences: mint default instead of reference blue; original narrow grille, simplified underside and upholstery; no branding.\n')
print('EXPORT_COMPLETE',tri,os.path.getsize(p),bounds,flush=True)
render('final.png');view((6,0,.76),(0,0,.71),4.7);render('side.png',1920,1080,24)
view((5,-7,2.75))
frames=[]
for i,paint in enumerate(paints):
 P.node_tree.nodes['PaintFactor'].inputs[2].default_value=(*paint['linearRGB'],1)
 render(f'paint_{i}.png',640,360,16)
 im=bpy.data.images.load(ROOT+f'/paint_{i}.png',check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);frames.append(a);bpy.data.images.remove(im)
canvas=np.zeros((720,1920,4),dtype=np.float32)
for i,a in enumerate(frames):canvas[(1-i//3)*360:(2-i//3)*360,(i%3)*640:(i%3+1)*640]=a
im=bpy.data.images.new('Paint comparison',1920,720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=ROOT+'/paints.png';im.file_format='PNG';im.save()
for i in range(6):os.remove(ROOT+f'/paint_{i}.png')
P.node_tree.nodes['PaintFactor'].inputs[2].default_value=(.45,.62,.52,1)
bpy.ops.wm.save_as_mainfile(filepath=ROOT+'/classic-saloon.blend')
print('BUILD_COMPLETE',flush=True)
# Serialized-material and transform round trip, rendered through the real GLB importer.
for o in asset:bpy.data.objects.remove(o,do_unlink=True)
bpy.ops.import_scene.gltf(filepath=ROOT+'/classic-saloon.glb')
view((5,-7,2.75));render('roundtrip.png',1920,1080,24)
print('ROUNDTRIP_COMPLETE',flush=True)
