"""Original family sedan. Blender 5.2, metres; clean-run builder, Cycles bakes and GLB.
Authoring coordinates: X left, -Y front, Z up. glTF conversion gives +Z front.
CC0 photographic inputs: Poly Haven metal_plate and leather_white.
Run: blender -b --python-exit-code 1 --python build_family-sedan.py
"""
import bpy, bmesh, math, os, json, struct, subprocess, time
from pathlib import Path
from mathutils import Vector
from math import sin,cos,pi,sqrt
import numpy as np
ROOT=Path(__file__).resolve().parent
TEX=ROOT/'textures';TEX.mkdir(exist_ok=True)
START=time.time()
bpy.ops.object.select_all(action='SELECT');bpy.ops.object.delete(use_global=False)
for data in list(bpy.data.materials):bpy.data.materials.remove(data)
sc=bpy.context.scene;sc.unit_settings.system='METRIC';sc.render.engine='CYCLES'
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.samples=16;sc.cycles.use_denoising=True
sc.render.bake.margin=8;sc.render.bake.use_clear=True
sc.world.color=(.25,.25,.25)
ASSET=[];GROUP='Body';M={}
COLORS=[('Midnight blue',[.02,.05,.16]),('Pearl silver',[.57,.60,.64]),('Graphite',[.065,.073,.083]),('Burgundy',[.19,.019,.034]),('Pine metallic',[.025,.105,.08]),('Ivory',[.78,.73,.62])]
def log(s):print('SEDAN',round(time.time()-START,1),s,flush=True)
def active(o):
 bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o

def assign(o,mat):
 o.data.materials.append(M[mat] if isinstance(mat,str) else mat);o['assembly']=GROUP;ASSET.append(o)
 for p in o.data.polygons:p.use_smooth=True
 return o

def mesh(name,verts,faces,mat,uv=True):
 me=bpy.data.meshes.new(name);me.from_pydata(verts,[],faces);me.update()
 bm=bmesh.new();bm.from_mesh(me);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(me);bm.free()
 o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o);assign(o,mat)
 if uv:planar_uv(o)
 return o

def planar_uv(o,scale=1):
 me=o.data;uv=me.uv_layers.new(name='UVMap') if not me.uv_layers else me.uv_layers.active
 for p in me.polygons:
  axis=max(range(3),key=lambda a:abs(p.normal[a]));axes=[a for a in range(3) if a!=axis]
  for li in p.loop_indices:
   v=me.vertices[me.loops[li].vertex_index].co;uv.data[li].uv=(v[axes[0]]*scale,v[axes[1]]*scale)

def bevel(o,width=.008,segments=3):
 active(o);m=o.modifiers.new('Manufactured edge radii','BEVEL');m.width=width;m.segments=segments
 bpy.ops.object.modifier_apply(modifier=m.name)
 return o

def box(name,loc,size,mat,r=.01):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.scale=size
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);assign(o,mat)
 if r:bevel(o,r,3)
 # Weighted normals keep machined planes planar.
 m=o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL');m.keep_sharp=True
 active(o);bpy.ops.object.modifier_apply(modifier=m.name)
 return o

def ellipsoid(name,loc,scale,mat,seg=32,rings=16):
 bpy.ops.mesh.primitive_uv_sphere_add(segments=seg,ring_count=rings,location=loc);o=bpy.context.object;o.name=name;o.scale=scale
 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True);return assign(o,mat)

def cyl(name,loc,r,depth,mat,axis='X',n=48):
 bpy.ops.mesh.primitive_cylinder_add(vertices=n,radius=r,depth=depth,location=loc);o=bpy.context.object;o.name=name
 if axis=='X':o.rotation_euler[1]=pi/2
 if axis=='Y':o.rotation_euler[0]=pi/2
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);assign(o,mat);bevel(o,.0015,2);return o

def tube(name,pts,r,mat,closed=False,res=3):
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=1;cu.bevel_depth=r;cu.bevel_resolution=min(res,2)
 sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
 for a,p in zip(sp.points,pts):a.co=(*p,1)
 sp.use_cyclic_u=closed
 o=bpy.data.objects.new(name,cu);bpy.context.collection.objects.link(o);active(o);bpy.ops.object.convert(target='MESH');assign(o,mat);planar_uv(o);return o

def torus(name,loc,major,minor,mat,axis='X',a=64,b=12):
 bpy.ops.mesh.primitive_torus_add(major_radius=major,minor_radius=minor,major_segments=a,minor_segments=b,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
 bpy.ops.object.transform_apply(location=False,rotation=True,scale=True);return assign(o,mat)

def patch(name,corners,mat,nu=16,nv=10,bulge=0):
 # ordered lower-left, lower-right, upper-right, upper-left; smooth cambered quad.
 v=[];f=[];a,b,c,d=map(Vector,corners)
 norm=(b-a).cross(d-a).normalized()
 for j in range(nv+1):
  t=j/nv
  for i in range(nu+1):
   u=i/nu;p=a*(1-u)*(1-t)+b*u*(1-t)+c*u*t+d*(1-u)*t
   p+=norm*(bulge*sin(pi*u)*sin(pi*t));v.append(tuple(p))
 for j in range(nv):
  for i in range(nu):
   k=j*(nu+1)+i;f.append((k,k+1,k+nu+2,k+nu+1))
 return mesh(name,v,f,mat)

def download(id,kind):
 p=TEX/f'{id}_{kind}_1k.jpg'
 if not p.exists():
  url=f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{id}/{id}_{kind}_1k.jpg'
  subprocess.run(['curl','-fL','--retry','2','-A','Mozilla/5.0',url,'-o',str(p)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
 return bpy.data.images.load(str(p),check_existing=True)

# Bake tile materials once; all parts share these compact texture sets.
def bake_material(name,base,rough,metal,source='metal_plate',detail=.04,res=512):
 log('baking '+name)
 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['Surface'])
 uv=N.new('ShaderNodeTexCoord');noise=N.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=13;noise.inputs['Detail'].default_value=4;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=(*[x*(1-detail) for x in base],1);ramp.color_ramp.elements[1].color=(*base,1);L.new(noise.outputs['Fac'],ramp.inputs[0]);color=ramp.outputs[0]
 if source:
  diff=N.new('ShaderNodeTexImage');diff.image=download(source,'diff');L.new(uv.outputs['UV'],diff.inputs['Vector'])
  bw=N.new('ShaderNodeRGBToBW');L.new(diff.outputs['Color'],bw.inputs[0])
  scan=N.new('ShaderNodeValToRGB');scan.color_ramp.elements[0].color=(.80,.80,.80,1);scan.color_ramp.elements[1].color=(1,1,1,1);L.new(bw.outputs[0],scan.inputs[0])
  mul=N.new('ShaderNodeMixRGB');mul.blend_type='MULTIPLY';mul.inputs[0].default_value=.20 if name=='Paint' else .65;L.new(color,mul.inputs[1]);L.new(scan.outputs[0],mul.inputs[2]);color=mul.outputs[0]
  nor=N.new('ShaderNodeTexImage');nor.image=download(source,'nor_gl');nor.image.colorspace_settings.name='Non-Color';L.new(uv.outputs['UV'],nor.inputs['Vector'])
  normal=N.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.003 if name=='Paint' else (.025 if name=='Metal' else .12);L.new(nor.outputs['Color'],normal.inputs['Color']);L.new(normal.outputs[0],bs.inputs['Normal'])
  arm=N.new('ShaderNodeTexImage');arm.image=download(source,'arm');arm.image.colorspace_settings.name='Non-Color';L.new(uv.outputs['UV'],arm.inputs['Vector'])
  sep=N.new('ShaderNodeSeparateColor');L.new(arm.outputs['Color'],sep.inputs[0])
  rr=N.new('ShaderNodeMath');rr.operation='MULTIPLY_ADD';rr.inputs[1].default_value=.055;rr.inputs[2].default_value=rough-.025;L.new(sep.outputs[1],rr.inputs[0]);rs=rr.outputs[0]
 else:
  rv=N.new('ShaderNodeValue');rv.outputs[0].default_value=rough;rs=rv.outputs[0]
 if name=='Paint':
  sepUV=N.new('ShaderNodeSeparateXYZ');L.new(uv.outputs['UV'],sepUV.inputs[0])
  film=N.new('ShaderNodeMapRange');film.inputs['From Min'].default_value=.10;film.inputs['From Max'].default_value=.40;film.inputs['To Min'].default_value=.70;film.inputs['To Max'].default_value=1;film.clamp=True;L.new(sepUV.outputs['Y'],film.inputs['Value'])
  smudge=N.new('ShaderNodeMixRGB');smudge.blend_type='MULTIPLY';smudge.inputs[0].default_value=.8;L.new(color,smudge.inputs[1]);L.new(film.outputs['Result'],smudge.inputs[2]);color=smudge.outputs[0]
 if name=='Lamps':
  separate=N.new('ShaderNodeSeparateXYZ');L.new(uv.outputs['UV'],separate.inputs[0]);ramp2=N.new('ShaderNodeValToRGB');ramp2.color_ramp.interpolation='CONSTANT'
  ramp2.color_ramp.elements.remove(ramp2.color_ramp.elements[1]);ramp2.color_ramp.elements[0].color=(1,.86,.64,1)
  ramp2.color_ramp.elements.new(.333).color=(.65,.004,.009,1);ramp2.color_ramp.elements.new(.666).color=(1,.21,.008,1);L.new(separate.outputs[0],ramp2.inputs[0]);color=ramp2.outputs[0]
 L.new(color,bs.inputs['Base Color']);L.new(rs,bs.inputs['Roughness']);bs.inputs['Metallic'].default_value=metal
 bpy.ops.mesh.primitive_plane_add(size=1);o=bpy.context.object;o.data.materials.append(m);active(o)
 images={}
 for mode in ['base','normal','orm']:
  size=min(res,512) if mode=='orm' else res
  im=bpy.data.images.new(name+'_'+mode,width=size,height=size,alpha=False)
  if mode!='base':im.colorspace_settings.name='Non-Color'
  target=N.new('ShaderNodeTexImage');target.image=im;N.active=target
  if mode=='normal':
   L.new(bs.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='NORMAL')
  else:
   em=N.new('ShaderNodeEmission')
   if mode=='base':L.new(color,em.inputs[0])
   else:
    pack=N.new('ShaderNodeCombineColor');pack.inputs[0].default_value=1;L.new(rs,pack.inputs[1]);pack.inputs[2].default_value=metal;L.new(pack.outputs[0],em.inputs[0])
   L.new(em.outputs[0],out.inputs[0]);bpy.ops.object.bake(type='EMIT')
  im.filepath_raw=str(TEX/(name+'_'+mode+'.png'));im.file_format='PNG';im.save();images[mode]=im
 bpy.data.objects.remove(o,do_unlink=True)
 nt.nodes.clear();out=N.new('ShaderNodeOutputMaterial');bs=N.new('ShaderNodeBsdfPrincipled');L.new(bs.outputs[0],out.inputs[0])
 tex={}
 for mode,im in images.items():
  n=N.new('ShaderNodeTexImage');n.image=im;tex[mode]=n
 L.new(tex['base'].outputs['Color'],bs.inputs['Base Color'])
 sep=N.new('ShaderNodeSeparateColor');L.new(tex['orm'].outputs[0],sep.inputs[0]);L.new(sep.outputs[1],bs.inputs['Roughness']);L.new(sep.outputs[2],bs.inputs['Metallic'])
 nor=N.new('ShaderNodeNormalMap');L.new(tex['normal'].outputs[0],nor.inputs['Color']);L.new(nor.outputs[0],bs.inputs['Normal'])
 # glTF occlusion hookup, sharing the ORM image.
 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')
 g=N.new('ShaderNodeGroup');g.node_tree=group;L.new(sep.outputs[0],g.inputs['Occlusion'])
 if name=='Paint':
  tint=N.new('ShaderNodeMixRGB');tint.name='Paint colour factor';tint.blend_type='MULTIPLY';tint.inputs[0].default_value=1;tint.inputs[2].default_value=(*COLORS[0][1],1);L.new(tex['base'].outputs[0],tint.inputs[1]);L.new(tint.outputs[0],bs.inputs['Base Color']);bs.inputs['Coat Weight'].default_value=1;bs.inputs['Coat Roughness'].default_value=.05
 if name=='Glass':
  bs.inputs['Alpha'].default_value=.36;bs.inputs['Roughness'].default_value=.08;m.surface_render_method='BLENDED';m.use_transparency_overlap=False
 if name=='Lamps':
  L.new(tex['base'].outputs[0],bs.inputs['Emission Color']);bs.inputs['Emission Strength'].default_value=.25;bs.inputs['Coat Weight'].default_value=.6
 M[name]=m

# Source scans are CC0: https://polyhaven.com/license
for args in [('Paint',(.96,.96,.96),.38,.28,'metal_plate',.055,1024),('Rubber',(.025,.027,.03),.72,0,'leather_white',.28,512),('Metal',(.45,.47,.5),.27,1,'metal_plate',.1,512),('Plastic',(.019,.023,.027),.36,.05,'leather_white',.20,512),('Interior',(.095,.083,.069),.70,0,'leather_white',.3,1024),('Brake',(.13,.14,.15),.49,.78,'metal_plate',.16,512),('Glass',(.07,.095,.11),.08,.05,None,.01,64),('Lamps',(1,1,1),.2,.15,None,0,256)]:bake_material(*args)

# Main pressings: continuously curved sides, open cabin and true arch cut-outs.
def interp(y,knots):
 for i in range(len(knots)-1):
  if knots[i][0]<=y<=knots[i+1][0]:
   u=(y-knots[i][0])/(knots[i+1][0]-knots[i][0]);
   a=knots[max(0,i-1)];b=knots[i];c=knots[i+1];d=knots[min(len(knots)-1,i+2)];dt=c[0]-b[0]
   m0=(c[1]-a[1])/(c[0]-a[0]);m1=(d[1]-b[1])/(d[0]-b[0])
   return (2*u**3-3*u*u+1)*b[1]+(u**3-2*u*u+u)*dt*m0+(-2*u**3+3*u*u)*c[1]+(u**3-u*u)*dt*m1
 return knots[0][1] if y<knots[0][0] else knots[-1][1]
WK=[(-2.4,.75),(-2.25,.835),(-1.5,.875),(-.5,.859),(.65,.867),(1.4,.88),(2.18,.847),(2.4,.785)]
ZK=[(-2.4,.790),(-2.18,.825),(-1.45,.856),(-.8,.913),(.65,.915),(1.4,.914),(1.85,.893),(2.4,.83)]
def width(y):return interp(y,WK)
def belt(y):return interp(y,ZK)
def low(y):
 z=.185
 for cy in [-1.4,1.4]:
  d=abs(y-cy)
  if d<.397:z=max(z,.342+sqrt(.397**2-d*d))
 return z

def side_x(y,z):
 h=belt(y);t=(z-.185)/(h-.185)
 flare=sum(.019*math.exp(-((math.hypot(y-cy,z-.342)-.425)/.085)**2) for cy in [-1.4,1.4])
 return width(y)+interp(t,[(0,-.028),(.10,-.011),(.2,-.018),(.3,.005),(.53,.022),(.72,.024),(.87,.016),(1,-.032)])+flare*(1-max(0,min(1,(t-.85)/.15)))
for sign in [-1,1]:
 v=[];f=[];ny=140;nz=14
 for i in range(ny+1):
  y=-2.4+4.8*i/ny;bottom=low(y)
  for j in range(nz+1):
   z=bottom+(belt(y)-bottom)*j/nz;v.append((sign*side_x(y,z),y,z))
 for i in range(ny):
  for j in range(nz):
   a=i*(nz+1)+j;f.append((a,a+nz+1,a+nz+2,a+1))
 o=mesh('Sculpted body side',v,f,'Paint');sol=o.modifiers.new('Hemmed steel thickness','SOLIDIFY');sol.thickness=.006;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
 # Curved arch lips, black inner liners and a turned flange.
 for cy in [-1.4,1.4]:
  pts=[]
  for i in range(81):
   a=-.40+(pi+.8)*i/80;y=cy+.397*cos(a);z=.342+.397*sin(a)
   if z>=.185:pts.append((sign*(side_x(y,z)+.001),y,z))
  tube('Rolled wheel arch lip',pts,.004,'Paint',res=2)
  v=[];f=[]
  for i in range(65):
   a=-.42+(pi+.84)*i/64;y=cy+.395*cos(a);z=.342+.395*sin(a)
   for x in [.68,side_x(y,z)-.002]:v.append((sign*x,y,z))
  for i in range(64):f.append((2*i,2*i+1,2*i+3,2*i+2))
  mesh('Wheelhouse lining',v,f,'Plastic')
 # Rocker below doors, with slim bright sill insert.
 box('Hemmed rocker panel',(sign*.851,0,.195),(.062,2.07,.09),'Paint',.018)
 tube('Sill highlight',[(sign*.887,y,.217) for y in np.linspace(-.92,.94,20)],.004,'Metal',res=2)

# Hood and boot surfaces, cambered across width and down to rounded nose.
def deck(name,y0,y1,kind):
 v=[];f=[];ny=32;nx=24
 for j in range(ny+1):
  y=y0+(y1-y0)*j/ny
  for i in range(nx+1):
   t=-1+2*i/nx;x=t*(width(y)-.032);crown=.035
   if kind=='hood':crown=.004+.031*(1-j/ny)**2
   if kind=='boot':crown=.021+.030*(j/ny)**2
   z=belt(y)+crown*(1-t*t)
   # Two longitudinal bonnet pressure ribs blend into the shoulders.
   if kind=='hood':z+=.004*math.exp(-((abs(t)-.66)/.16)**2)*sin(pi*j/ny)
   v.append((x,y,z))
 for j in range(ny):
  for i in range(nx):
   a=j*(nx+1)+i;f.append((a,a+1,a+nx+2,a+nx+1))
 o=mesh(name,v,f,'Paint');sol=o.modifiers.new('Sheet metal','SOLIDIFY');sol.thickness=.007;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
 return o
deck('Bonnet',-2.4,-.84,'hood');deck('Separate boot lid',1.36,2.4,'boot')
patch('Windshield cowl pressing',[(-.79,-.90,.914),(.79,-.90,.914),(.79,-.84,.921),(-.79,-.84,.921)],'Paint',32,4,.003)
box('Cabin floor',(0,.025,.30),(1.48,2.0,.12),'Plastic',.025)
# Smooth rounded fascia surfaces exactly meet the side and deck boundaries.
for end in [-1,1]:
 v=[];f=[];nx=48;nz=18
 for j in range(nz+1):
  t=j/nz
  for i in range(nx+1):
   u=-1+2*i/nx;z=.185+t*(belt(end*2.4)+.035*(1-u*u)-.185)
   x=u*side_x(end*2.4,.185+t*(belt(end*2.4)-.185));y=end*2.4
   v.append((x,y,z))
 for j in range(nz):
  for i in range(nx):
   a=j*(nx+1)+i;f.append((a,a+1,a+nx+2,a+nx+1))
 o=mesh('Integrated front fascia' if end==-1 else 'Integrated rear fascia',v,f,'Paint')
 sol=o.modifiers.new('Bumper thickness','SOLIDIFY');sol.thickness=.012;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
# A continuous curved roof, broad sheet-metal pillars, and inset curved window outlines.
ARCH=[(-.91,.905),(-.70,1.085),(-.25,1.345),(0,1.411),(.36,1.425),(.72,1.385),(.94,1.302),(1.42,.914)]
def az(y):return interp(y,ARCH)
def ax(z):return .833-.305*(z-.915)
v=[];f=[];ny=36;nx=24
for j in range(ny+1):
 y=-.25+.97*j/ny;z=az(y);w=ax(z)
 for i in range(nx+1):
  t=-1+2*i/nx;v.append((t*w,y,z+.025*(1-t*t)))
for j in range(ny):
 for i in range(nx):
  a=j*(nx+1)+i;f.append((a,a+1,a+nx+2,a+nx+1))
o=mesh('Continuous crowned roof',v,f,'Paint');sol=o.modifiers.new('Roof steel','SOLIDIFY');sol.thickness=.008;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
front=[(-.787,-.89,.915),(.787,-.89,.915),(.667,-.25,az(-.25)),(-.667,-.25,az(-.25))]
rear=[(.783,1.40,.925),(-.783,1.40,.925),(-.668,.72,az(.72)),(.668,.72,az(.72))]
for name,c in [('Windshield',front),('Rear windshield',rear)]:
 def curved_screen(q):
  q=Vector(q);lower=c[0][1];upper=c[2][1];t=max(0,min(1,(q.y-lower)/(upper-lower)))
  q.z+=.025*t*(1-(q.x/ax(az(upper)))**2)
  return tuple(q)
 glass=patch(name,c,'Glass',32,20,.013)
 for vtx in glass.data.vertices:vtx.co=curved_screen(vtx.co)
 outline=[]
 for k in range(4):
  a=Vector(c[k]);b=Vector(c[(k+1)%4])
  for t in np.linspace(0,1,24,endpoint=False):outline.append(curved_screen(a.lerp(b,float(t))))
 tube(name+' rubber reveal',outline,.007,'Plastic',True,2)
for sign in [-1,1]:
 # Wide A and C pillars connect screens to the side of the body.
 for name,pts in [('A pillar',[(sign*.787,-.89,.915),(sign*.833,-.91,.905),(sign*ax(az(-.25)),-.25,az(-.25)),(sign*.667,-.25,az(-.25))]),('C pillar',[(sign*.668,.72,az(.72)),(sign*ax(az(.72)),.72,az(.72)),(sign*.833,1.42,.914),(sign*.783,1.40,.925)])]:
  patch(name,pts,'Paint',14,28,.004)
 # A curved painted strip carries the roof edge into the pillars, without proud tubes.
 pts=[];vs=[];fs=[]
 for i in range(101):
  y=-.90+2.30*i/100;z=az(y);x=ax(z)
  vs.extend([(sign*x,y,z),(sign*(x+.009),y,z-.044)])
  pts.append((sign*(x+.011),y,z-.043))
 for i in range(100):fs.append((2*i,2*i+1,2*i+3,2*i+2))
 mesh('Pressed roof side frame',vs,fs,'Paint');tube('Roof reveal bright trim',pts,.004,'Metal',res=2)
 # broad rear side sail panel fills the surface beyond quarterlight
 sail=[(sign*ax(az(.73)),.73,az(.73)),(sign*ax(az(.94)),.94,az(.94)),(sign*.833,1.42,.914),(sign*ax(.939),1.19,.939),(sign*ax(1.266),.887,1.266)]
 centre=tuple(sum(p[k] for p in sail)/len(sail) for k in range(3));mesh('Broad rear quarter sail',[centre]+sail,[(0,i+1,(i+1)%len(sail)+1) for i in range(len(sail))],'Paint')
 tube('Beltline window seal',[(sign*.834,y,.917) for y in np.linspace(-.89,1.40,40)],.009,'Plastic',res=2)
 tube('Beltline chrome',[(sign*.841,y,.919) for y in np.linspace(-.87,1.38,40)],.004,'Metal',res=2)
 def wp(y,z):return (sign*(ax(z)+.002),y,z)
 # Each window shares one plane x=f(z), with smoothly curved upper contours.
 frontpts=[wp(-.822,.939),wp(.10,.939)]+[wp(y,az(y)-.057) for y in np.linspace(.10,-.77,30)]
 rearpts=[wp(.204,.939),wp(1.04,.939),wp(.904,az(.904)-.057)]+[wp(y,az(y)-.057) for y in np.linspace(.89,.204,28)]
 quarter=[wp(1.08,.939),wp(1.18,.939),wp(.946,1.22)]
 for wi,pts in enumerate([frontpts,rearpts,quarter]):
  centre=tuple(sum(p[k] for p in pts)/len(pts) for k in range(3));vs=[centre]+pts;fs=[(0,i+1,(i+1)%len(pts)+1) for i in range(len(pts))]
  mesh('Side window '+str(wi),vs,fs,'Glass');tube('Window rubber gasket',pts,.006,'Plastic',True,2)
 patch('Black B pillar',[wp(.10,.92),wp(.205,.92),wp(.205,az(.205)-.038),wp(.10,az(.10)-.038)],'Plastic',3,14)
 tube('Quarterlight divider',[wp(1.061,.929),wp(.922,az(.922)-.053)],.011,'Plastic',res=2)
 # Door shut-lines hug the actual pressed skin.
 for yy in [-.87,.16]:
  pts=[]
  for z in np.linspace(.23,.885,35):
   y=yy-.035*sin(pi*(z-.23)/.655);pts.append((sign*(side_x(y,z)+.003),y,z))
  tube('Door vertical shutline',pts,.0027,'Plastic',res=2)
 pts=[]
 for t in np.linspace(0,1,45):
  y=1.12-.43*t+.04*sin(pi*t);z=.884-.65*t;pts.append((sign*(side_x(y,z)+.003),y,z))
 tube('Rear door trailing shutline',pts,.0027,'Plastic',res=2)
 for a,b in [(-.86,.15),(.18,.69)]:
  tube('Door bottom shutline',[(sign*(side_x(y,.235)+.003),y,.235) for y in np.linspace(a,b,30)],.0024,'Plastic',res=2)
 # Recessed handle pocket, body-coloured pull and little chrome top edge.
 for y in [-.045,.93]:
  x=sign*(side_x(y,.785)+.003)
  ellipsoid('Handle recessed pocket',(x,y,.783),(.010,.103,.027),'Plastic',24,12)
  box('Door pull handle',(x+sign*.018,y,.789),(.035,.145,.025),'Paint',.011)
  tube('Handle bright edge',[(x+sign*.038,y-.058,.796),(x+sign*.038,y+.053,.796)],.0035,'Metal',res=2)
 # Side mirrors: small pedestal, two-tone aerodynamic housing and rear-facing mirror.
 tube('Mirror mounting stem',[(sign*.803,-.735,.958),(sign*.875,-.70,.996)],.023,'Plastic',res=3)
 ellipsoid('Mirror lower housing',(sign*.87,-.725,1.024),(.050,.145,.064),'Plastic')
 ellipsoid('Painted mirror cap',(sign*.87,-.735,1.041),(.050,.136,.046),'Paint')
 patch('Mirror reflective insert',[(sign*.831,-.588,1.006),(sign*.906,-.603,1.011),(sign*.905,-.604,1.058),(sign*.839,-.591,1.071)],'Metal',8,5,.002)
 # fuel door on right rear quarter
 if sign==-1:
  pts=[(sign*(side_x(y,z)+.004),y,z) for y,z in [(1.72,.745),(1.91,.745),(1.91,.615),(1.72,.615)]];tube('Fuel filler seam',pts,.0025,'Plastic',True,2)

# Distinct front face: wide shallow grille, separate lower intake, modular projector lamps.
def lamp_uv(o,kind):
 uv=o.data.uv_layers.active
 if not uv:uv=o.data.uv_layers.new()
 u={'white':.16,'red':.50,'amber':.83}[kind]
 for d in uv.data:d.uv=(u,.5)
 return o
# Grille dark inset and a rounded polygon bright frame (no badge).
grille=[(-.438,-2.411,.756),(.438,-2.411,.756),(.39,-2.413,.530),(-.39,-2.413,.530)]
patch('Recessed main grille',grille,'Plastic',32,12);grille_outline=[]
for k in range(4):
 a=Vector(grille[k]);b=Vector(grille[(k+1)%4]);grille_outline.extend([tuple(a.lerp(b,float(t))) for t in np.linspace(0,1,24,endpoint=False)])
tube('Grille perimeter',grille_outline,.006,'Metal',True,3)
for z in [.564,.617,.670,.723]:
 tube('Horizontal satin grille blade',[(x,-2.420,z) for x in np.linspace(-.403,.403,25)],.005,'Metal',res=2)
for x in np.linspace(-.37,.37,15):tube('Inset grille vertical fin',[(x,-2.419,z) for z in np.linspace(.551,.743,10)],.004,'Plastic',res=1)
patch('Lower intake',[(-.49,-2.415,.245),(.49,-2.415,.245),(.52,-2.415,.378),(-.52,-2.415,.378)],'Plastic',32,8)
for z in [.282,.326]:tube('Lower intake louvre',[(x,-2.423,z) for x in np.linspace(-.46,.46,24)],.007,'Plastic',res=2)
for sign in [-1,1]:
 # Slightly sloped trapezoidal headlamp, original twin round projector layout.
 pts=[(sign*.454,-2.419,.642),(sign*.783,-2.419,.663),(sign*.805,-2.419,.790),(sign*.482,-2.419,.762)]
 patch('Headlamp dark housing',pts,'Plastic',16,8);tube('Headlamp surround',pts,.006,'Metal',True,3)
 for x,r in [(.537,.036),(.666,.032)]:
  cyl('Projector bezel',(sign*x,-2.429,.721),r+.012,.009,'Metal','Y',32)
  o=ellipsoid('Projector optical lens',(sign*x,-2.429,.721),(r,.006,r),'Metal',24,12)
  o=ellipsoid('Projector glass element',(sign*x,-2.434,.721),(r*.72,.004,r*.72),'Glass',24,12)
  o=ellipsoid('Projector bulb',(sign*x,-2.431,.721),(r*.22,.004,r*.22),'Lamps',16,8);lamp_uv(o,'white')
 o=tube('Daytime light guide',[(sign*.484,-2.440,.664),(sign*.635,-2.440,.671),(sign*.751,-2.440,.684)],.005,'Lamps',res=3);lamp_uv(o,'white')
 o=tube('Front amber indicator',[(sign*.766,-2.440,.702),(sign*.769,-2.440,.764)],.005,'Lamps',res=2);lamp_uv(o,'amber')
 for xx in [.493,.582,.614,.708,.746]:
  tube('Headlight internal reflector fin',[(sign*xx,-2.431,.693),(sign*(xx+.008),-2.431,.744)],.002,'Metal',res=1)
 # Clear lens glints span the recessed lamp cassette.
 patch('Headlamp clear outer lens',[(x,y-.024,z) for x,y,z in pts],'Glass',16,8)
 box('Lower side inlet',(sign*.675,-2.414,.323),(.265,.032,.137),'Plastic',.025)
 cyl('Fog lamp chrome ring',(sign*.711,-2.438,.323),.036,.010,'Metal','Y',40)
 o=cyl('Fog optical lens',(sign*.711,-2.446,.323),.028,.008,'Lamps','Y',40);lamp_uv(o,'white')
 for z in [.287,.328]:tube('Fog inlet rib',[(sign*.56,-2.439,z),(sign*.653,-2.439,z)],.0035,'Metal',res=2)
 # Side lens returns curve round the fender shoulder.
 wrap=[(sign*.774,-2.417,.666),(sign*.825,-2.191,.696),(sign*.838,-2.168,.795),(sign*.801,-2.417,.788)]
 mesh('Front lamp corner backing',wrap,[(0,1,2,3)],'Plastic')
 mesh('Front lamp corner lens',[(x+sign*.002,y-.003,z) for x,y,z in wrap],[(0,1,2,3)],'Glass')
 tube('Front lamp outer reveal',wrap,.0035,'Metal',True,2)
 o=tube('Lamp corner LED guide',[(sign*.783,-2.425,.681),(sign*.817,-2.245,.702),(sign*.829,-2.184,.716)],.004,'Lamps',res=2);lamp_uv(o,'white')
 # rear red lamps wrap onto the quarter panels
 pts=[(sign*.42,2.414,.735),(sign*.761,2.414,.735),(sign*.774,2.414,.826),(sign*.43,2.414,.824)]
 o=patch('Rear lamp lens',pts,'Lamps',16,6);lamp_uv(o,'red');tube('Rear lamp seal',pts,.008,'Plastic',True,2)
 for z in [.758,.795]:
  o=tube('Rear light guide',[(sign*.445,2.421,z),(sign*.732,2.421,z)],.005,'Lamps',res=2);lamp_uv(o,'red')
 o=box('Rear indicator strip',(sign*.61,2.424,.738),(.24,.009,.012),'Lamps',.004);lamp_uv(o,'amber')
 # wraparound section follows the visible rear quarter
 pts=[(sign*(side_x(y,z)+.009),y,z) for y,z in [(2.388,.743),(2.12,.754),(2.11,.834),(2.38,.825)]]
 o=patch('Wrapped rear tail lens',pts,'Lamps',16,6)
 for vtx in o.data.vertices:vtx.co.x=sign*(side_x(vtx.co.y,vtx.co.z)+.014)
 lamp_uv(o,'red');tube('Tail lens edge',pts,.006,'Plastic',True,2)
 # Exhaust tips with a visibly hollow bore, paired underside mounting.
 cyl('Exhaust dark bore',(sign*.61,2.427,.23),.040,.08,'Plastic','Y')
 torus('Rolled exhaust tip',(sign*.61,2.468,.23),.038,.004,'Metal','Y',48,8)
patch('Rear valance',[(-.71,2.424,.185),(.71,2.424,.185),(.68,2.424,.292),(-.68,2.424,.292)],'Plastic',40,8)
box('Rear numberplate recess',(0,2.404,.61),(.39,.014,.102),'Plastic',.012)
box('Blank rear plate',(0,2.414,.61),(.34,.010,.075),'Metal',.008)
for ob in list(bpy.data.objects):
 if ob.type=='MESH' and ob.name.startswith(('Headlamp','Projector','Daytime','Front amber','Headlight internal')):
  ob.location.y+=.010
# Bonnet, bumper and boot shut lines.
for sign in [-1,1]:
 pts=[]
 for y in np.linspace(-2.16,-.88,42):
  x=sign*(width(y)-.17);t=x/(width(y)-.032);u=(y+2.4)/1.56;z=belt(y)+(.004+.031*(1-u)**2)*(1-t*t)+.001;pts.append((x,y,z))
 tube('Bonnet side seam',pts,.0015,'Plastic',res=2)
for y in [-.85,1.43,2.25]:
 tube('Deck transverse seam',[(x,y,belt(y)+.035*(1-(x/(width(y)-.032))**2)+.001) for x in np.linspace(-width(y)+.04,width(y)-.04,60)],.0025,'Plastic',res=2)
# Discrete parking sensors.
for end in [-1,1]:
 for x in [-.61,-.24,.24,.61]:cyl('Flush parking sensor',(x,end*2.401,.414),.012,.004,'Paint','Y',20)
# Wipers and roof antenna, no branding.
for x in [-.35,.32]:
 tube('Wiper arm',[(x,-.85,.931),(x-.14,-.76,.998)],.007,'Plastic',res=2)
 tube('Wiper blade',[(x-.37,-.763,1.001),(x+.05,-.763,1.001)],.008,'Plastic',res=2)
# antenna intentionally below roof maximum
ellipsoid('Rear roof antenna base',(0,.60,1.438),(.031,.063,.012),'Plastic',24,12)

# Believable cabin: shaped seats, stitched panels, door cards, dashboard, consoles.
for row,y in [('Front',-.12),('Rear',.64)]:
 for sign in [-1,1]:
  x=sign*.40
  box(row+' seat cushion',(x,y,.54),(.46,.48,.15),'Interior',.065)
  back=box(row+' seat back',(x,y+.18,.835),(.45,.14,.55),'Interior',.065);back.rotation_euler[0]=-.12
  box(row+' back centre insert',(x,y+.088,.834),(.30,.020,.34),'Interior',.035)
  for dx in [-.19,.19]:ellipsoid(row+' side bolster',(x+dx,y+.11,.825),(.044,.073,.225),'Interior',24,12)
  for dx in [-.10,.10]:cyl('Headrest post',(x+dx,y+.21,1.112),.009,.14,'Metal','Z',16)
  box(row+' adjustable headrest',(x,y+.21,1.19),(.29,.125,.20),'Interior',.06)
  for dx in [-.13,.13]:tube('Seat stitched welt',[(x+dx,y+.063,z) for z in np.linspace(.67,1.0,16)],.0018,'Interior',res=1)
  box('Seat belt buckle',(x-sign*.24,y,.605),(.026,.035,.045),'Plastic',.008)
box('Rear centre seat',(0,.81,.59),(.37,.38,.20),'Interior',.045)
box('Rear backrest centre',(0,.93,.86),(.37,.15,.44),'Interior',.055)
box('Parcel shelf',(0,1.28,.887),(1.47,.26,.045),'Interior',.014)
box('Dashboard main',(0,-.63,.857),(1.48,.36,.19),'Plastic',.07)
box('Dashboard lower fascia',(0,-.455,.788),(1.39,.11,.12),'Interior',.035)
box('Centre tunnel',(0,-.01,.59),(.21,1.07,.20),'Plastic',.04)
box('Centre armrest',(0,.25,.72),(.20,.34,.09),'Interior',.03)
box('Infotainment blank screen',(0,-.445,.922),(.25,.018,.125),'Glass',.009)
for x in [-.59,-.22,.22,.59]:
 box('Dashboard vent',(x,-.439,.875),(.12,.012,.052),'Plastic',.012)
 for z in [.86,.875,.89]:tube('Vent blade',[(x-.048,-.451,z),(x+.048,-.451,z)],.002,'Metal',res=1)
box('Gear selector',(0,-.17,.734),(.046,.07,.1),'Plastic',.016)
for x in [-.038,.038]:torus('Cupholder lip',(x,.05,.705),.027,.003,'Metal','Z',24,6)
for sign in [-1,1]:
 for y in [-.28,.72]:
  box('Door inner card',(sign*.758,y,.65),(.08,.80,.36),'Interior',.035)
  box('Door armrest',(sign*.711,y,.703),(.12,.54,.065),'Plastic',.025)
  tube('Inside door latch',[(sign*.7,y-.20,.805),(sign*.7,y-.11,.805)],.007,'Metal',res=2)
# Driver cluster and independently pivoted steering wheel.
box('Instrument binnacle',(.40,-.475,.958),(.35,.19,.13),'Plastic',.045)
for x in [.325,.475]:cyl('Gauge dial',(x,-.369,.948),.048,.006,'Plastic','Y',32)
GROUP='SteeringWheel';hub=(.40,-.255,.935)
torus('Leather steering rim',hub,.159,.016,'Plastic','Y',64,12)
cyl('Steering boss',hub,.057,.055,'Plastic','Y',40)
for a in [0,pi,1.5*pi]:
 tube('Steering spoke',[(hub[0]+.04*cos(a),hub[1],hub[2]+.04*sin(a)),(hub[0]+.148*cos(a),hub[1],hub[2]+.148*sin(a))],.016,'Metal',res=3)
GROUP='Body'
tube('Steering column',[(.40,-.31,.93),(.40,-.49,.78)],.027,'Plastic',res=2)

# Wheels: revolved low-profile tyre, tread grooves, alloy split spokes, disc + caliper.
for yy,axle in [(-1.4,'F'),(1.4,'R')]:
 for sign,side in [(1,'L'),(-1,'R')]:
  GROUP='Wheel_'+axle+side;cx=sign*.765;cz=.342
  # Cross-section is rounded but flat in the tread, unlike a doughnut.
  profile=[(-.104,.247),(-.117,.265),(-.117,.304),(-.101,.332),(-.078,.342),(.078,.342),(.101,.332),(.117,.304),(.117,.265),(.104,.247)]
  v=[];f=[];na=96
  for i in range(na):
   a=2*pi*i/na
   for dx,r in profile:v.append((cx+dx,yy+r*cos(a),cz+r*sin(a)))
  for i in range(na):
   for j in range(len(profile)):
    a=i*len(profile)+j;b=((i+1)%na)*len(profile)+j;f.append((a,b,((i+1)%na)*len(profile)+(j+1)%len(profile),i*len(profile)+(j+1)%len(profile)))
  mesh('Tyre carcass',v,f,'Rubber')
  # Thin circumferential shoulders and diagonal tread sipes cast real highlights.
  for dx in [-.07,-.025,.025,.07]:
   torus('Tread circumferential rib',(cx+dx,yy,cz),.3406,.0014,'Rubber','X',96,5)
  for i in range(56):
   a=2*pi*i/56
   for sg in [-1,1]:
    pts=[]
    for j in range(4):
     dx=sg*(.01+.07*j/3);ang=a+.035*j/3;r=.3402;pts.append((cx+dx,yy+r*cos(ang),cz+r*sin(ang)))
    tube('Moulded tread siping',pts,.0018,'Rubber',res=1)
  outer=cx+sign*.119
  for r in [.260,.296,.314]:torus('Tyre sidewall moulding',(outer,yy,cz),r,.0015,'Rubber','X',96,5)
  cyl('Alloy inner barrel',(cx,yy,cz),.249,.155,'Plastic','X',64)
  # black barrel behind spokes prevents a solid silver disc look

  discx=outer-sign*.043
  cyl('Ventilated brake rotor',(discx,yy,cz),.182,.006,'Brake','X',64)
  for i in range(20):
   a=2*pi*i/20
   for r in [.140,.166]:cyl('Rotor drilled hole',(discx+sign*.004,yy+r*cos(a),cz+r*sin(a)),.0032,.0015,'Plastic','X',8)
  box('Brake caliper',(discx+sign*.009,yy-.163,cz+.032),(.038,.07,.12),'Plastic',.012)
  torus('Machined rim outer lip',(outer+sign*.013,yy,cz),.240,.010,'Metal','X',96,8)
  torus('Rim inner step',(outer+sign*.008,yy,cz),.216,.004,'Metal','X',64,6)
  # Ten tapered slightly swept cast spokes with bevelled facets.
  for i in range(10):
   a=2*pi*i/10;vv=[]
   for dx in [-.008,.014]:
    for rad,ang in [(.054,a-.18),(.225,a-.09),(.229,a+.062),(.075,a+.14)]:vv.append((outer+sign*dx,yy+rad*cos(ang),cz+rad*sin(ang)))
   o=mesh('Cast alloy spoke',vv,[(0,1,2,3),(4,7,6,5),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)],'Metal');bevel(o,.003,2)
  cyl('Wheel centre cap',(outer+sign*.023,yy,cz),.061,.020,'Metal','X',48)
  for i in range(5):
   a=2*pi*i/5;cyl('Recessed lug seat',(outer+sign*.024,yy+.078*cos(a),cz+.078*sin(a)),.012,.007,'Plastic','X',16)
   cyl('Wheel lug bolt',(outer+sign*.030,yy+.078*cos(a),cz+.078*sin(a)),.007,.008,'Metal','X',6)
  tube('Valve stem',[(outer,yy+.205,cz-.055),(outer+sign*.026,yy+.208,cz-.058)],.004,'Rubber',res=2)
GROUP='Body'

def belt_lift(p):
 # Correspondence to the reference: about 0.99m at the cowl and 0.80m at the nose.
 z=p.z
 if z<=.745:return p
 rise=interp(p.y,[(-2.45,0),(-1.65,.055),(-.90,.098),(.8,.102),(1.5,.086),(2.45,.010)])
 t=max(0,min(1,(z-.745)/.17));t=t*t*(3-2*t)
 fade=max(0,min(1,(1.445-z)/.49))
 p.z+=rise*t*fade
 return p
for ob in list(bpy.data.objects):
 if ob.type!='MESH' or ob.get('assembly','').startswith('Wheel'):continue
 inv=ob.matrix_world.inverted()
 for vert in ob.data.vertices:vert.co=inv@belt_lift(ob.matrix_world@vert.co)
hub=tuple(belt_lift(Vector(hub)))

# Smooth common deformation keeps fascia details registered with their body skin.
def round_end(q):
 if abs(q.y)>2.08:
  t=min(1.1,max(0,(abs(q.y)-2.08)/.32));blend=t*t*(3-2*min(t,1))
  vertical=.12*((q.z-.48)/.36)**2;corner=(.16 if q.y<0 else .125)*(abs(q.x)/.88)**4
  q.y-=math.copysign((vertical+corner)*blend,q.y)
 return q
for ob in list(bpy.data.objects):
 if ob.type!='MESH' or ob.get('assembly')!='Body':continue
 inv=ob.matrix_world.inverted()
 for vert in ob.data.vertices:vert.co=inv@round_end(ob.matrix_world@vert.co)

# Consolidate exactly the six requested movable / static mesh nodes.
log('joining assemblies')
assemblies=[]
for name in ['Body','Wheel_FL','Wheel_FR','Wheel_RL','Wheel_RR','SteeringWheel']:
 objs=[o for o in list(bpy.data.objects) if o.type=='MESH' and o.get('assembly')==name]
 protected=[o for o in objs if o.name.startswith(('Sculpted body side','Bonnet','Separate boot lid','Continuous crowned roof','Integrated front fascia','Integrated rear fascia'))]
 objs=[o for o in objs if o not in protected]
 bpy.ops.object.select_all(action='DESELECT')
 for o in objs:o.select_set(True)
 bpy.context.view_layer.objects.active=objs[0];bpy.ops.object.join();o=bpy.context.object;o.name=name
 active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
 # Split sheet-metal hem normals from broad curved panels before reduction.
 o.data.set_sharp_from_angle(angle=.7);o.data.update()
 # Keep the original smooth shading when reducing tessellation.
 shading_source=o.copy();shading_source.data=o.data.copy();bpy.context.collection.objects.link(shading_source);shading_source.hide_render=True
 # Preserve silhouettes while removing redundant interior and tube tessellation.
 mod=o.modifiers.new('Game mesh optimization','DECIMATE');mod.decimate_type='COLLAPSE';mod.ratio=.32 if name=='Body' else .34
 bpy.ops.object.modifier_apply(modifier=mod.name)
 # Triangulate and eliminate zero-area slivers before serialization.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=bm.faces[:]);bad=[f for f in bm.faces if f.calc_area()<1e-8]
 if bad:bmesh.ops.delete(bm,geom=bad,context='FACES')
 bm.to_mesh(o.data);bm.free()
 active(o);normal_transfer=o.modifiers.new('Retain smooth source normals','DATA_TRANSFER');normal_transfer.object=shading_source;normal_transfer.use_loop_data=True;normal_transfer.data_types_loops={'CUSTOM_NORMAL'};normal_transfer.loop_mapping='POLYINTERP_NEAREST'
 bpy.ops.object.modifier_apply(modifier=normal_transfer.name);bpy.data.objects.remove(shading_source,do_unlink=True)
 if protected:
  active(o)
  for panel in protected:
   panel.data.set_sharp_from_angle(angle=.7);panel.select_set(True)
  bpy.context.view_layer.objects.active=o;bpy.ops.object.join();o.name=name
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.triangulate(bm,faces=bm.faces[:]);bm.to_mesh(o.data);bm.free()
 if name.startswith('Wheel'):
  pos=(.765 if name.endswith('L') else -.765,-1.4 if '_F' in name else 1.4,.342)
 elif name=='SteeringWheel':pos=hub
 else:pos=(0,0,0)
 sc.cursor.location=pos;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');assemblies.append(o)
# Semantic empties, glTF conversion handles authored -Y forward.
sockets={'Seat_Driver':(.40,-.12,.62),'Door_L':(.89,-.27,.40),'Door_R':(-.89,-.27,.40),'Exhaust':(.61,2.405,.23),'Light_Head_L':(.60,-2.41,.724),'Light_Head_R':(-.60,-2.41,.724),'Light_Tail_L':(.61,2.408,.78),'Light_Tail_R':(-.61,2.408,.78)}
for name,pos in sockets.items():
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=round_end(belt_lift(Vector(pos)));assemblies.append(o)
# Boundaries: exact 4.80 overall length and 1.84 overall width. Body face fittings are included.
bpy.context.view_layer.update()
# Longitudinal envelope is rescaled around the axles only beyond their centre planes.
extents=[o.matrix_world@v.co for o in assemblies if o.type=='MESH' for v in o.data.vertices]
front_extent=-min(p.y for p in extents);rear_extent=max(p.y for p in extents)
# Keep wheel centres and wheelbase exact; preserve wheels as round objects.
for o in assemblies:
 if o.type!='MESH' or o.name.startswith('Wheel'):continue
 for v in o.data.vertices:
  p=o.matrix_world@v.co
  if abs(p.y)>1.4:p.y=math.copysign(1.4+(abs(p.y)-1.4)*(1.0/((front_extent if p.y<0 else rear_extent)-1.4)),p.y)
  v.co=o.matrix_world.inverted()@p
for ob in assemblies:
 if ob.type!='EMPTY':continue
 pnt=ob.location
 if abs(pnt.y)>1.4:pnt.y=math.copysign(1.4+(abs(pnt.y)-1.4)/((front_extent if pnt.y<0 else rear_extent)-1.4),pnt.y)
# Roof correction to dimensioned 1.45m without moving sockets or wheel geometry.
pts=[o.matrix_world@v.co for o in assemblies if o.type=='MESH' for v in o.data.vertices]
lo=[min(v[i] for v in pts) for i in range(3)];hi=[max(v[i] for v in pts) for i in range(3)]
# ± mirrors define full width; very small numerical deviations are normalized.
sx=1.84/(hi[0]-lo[0]);sz=1.45/hi[2]
for o in assemblies:
 if o.type!='MESH':continue
 for v in o.data.vertices:
  p=o.matrix_world@v.co
  if not o.name.startswith('Wheel'):p.x*=sx;p.z*=sz
  v.co=o.matrix_world.inverted()@p
for ob in assemblies:
 if ob.type=='EMPTY':ob.location.x*=sx;ob.location.z*=sz
 if ob.name=='SteeringWheel':
  active(ob);sc.cursor.location=(hub[0]*sx,hub[1],hub[2]*sz);bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
bpy.context.view_layer.update()
pts=[o.matrix_world@v.co for o in assemblies if o.type=='MESH' for v in o.data.vertices]
lo=[min(v[i] for v in pts) for i in range(3)];hi=[max(v[i] for v in pts) for i in range(3)]
for ob in assemblies:
 if ob.type!='MESH':continue
 uv=ob.data.uv_layers.active
 for poly in ob.data.polygons:
  if ob.data.materials[poly.material_index].name!='Paint':continue
  for li in poly.loop_indices:
   q=ob.matrix_world@ob.data.vertices[ob.data.loops[li].vertex_index].co
   uv.data[li].uv=((q.y+2.4)/4.8,q.z/1.45)
tri=sum(len(o.data.polygons) for o in assemblies if o.type=='MESH')
assert 50000<=tri<=100000, tri
assert max(abs((hi[i]-lo[i])-d) for i,d in enumerate([1.84,4.8,1.45]))<.001
log('geometry triangles '+str(tri)+' bounds '+str([hi[i]-lo[i] for i in range(3)]))

def game(p):return [round(p[0],6),round(p[2],6),round(-p[1],6)]
metadata={'contract':'Forge Garage v2','units':'metres','forward':'+Z','up':'+Y','left':'+X','wheelbase':2.8,'nodes':{o.name:{'type':'mesh' if o.type=='MESH' else 'empty','position':game(o.matrix_world.translation)} for o in assemblies},'bounding_box':{'min':[lo[0],lo[2],-hi[1]],'max':[hi[0],hi[2],-lo[1]]},'triangles':tri,'paints':[{'name':n,'linear_rgb':c} for n,c in COLORS]}
(ROOT/'nodes.json').write_text(json.dumps(metadata,indent=2))
bpy.ops.object.select_all(action='DESELECT')
for o in assemblies:o.select_set(True)
bpy.context.view_layer.objects.active=assemblies[0]
bpy.ops.export_scene.gltf(filepath=str(ROOT/'family-sedan.glb'),export_format='GLB',use_selection=True,export_yup=True,export_apply=True,export_extras=False,export_texcoords=True,export_normals=True,export_materials='EXPORT',export_image_format='AUTO')
# Enforce the paint contract in serialized glTF independently of exporter node heuristics.
p=ROOT/'family-sedan.glb';raw=p.read_bytes();jl=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+jl]);binpart=raw[20+jl:]
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.02,.05,.16,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('<4sII',b'glTF',2,20+len(j)+len(binpart))+struct.pack('<I4s',len(j),b'JSON')+j+binpart)
assert p.stat().st_size<=12*1024*1024
log('EXPORTED '+str(p.stat().st_size))
# Studio daylight presentation. These objects are never selected for export.
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.0001));ground=bpy.context.object;ground.name='STUDIO ground'
gm=bpy.data.materials.new('STUDIO warm grey');gm.diffuse_color=(.32,.335,.35,1);gm.use_nodes=True;gm.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.32,.335,.35,1);gm.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value=.8;ground.data.materials.append(gm)
sc.world.use_nodes=True;sc.world.node_tree.nodes.get('Background').inputs[0].default_value=(.65,.73,.85,1);sc.world.node_tree.nodes.get('Background').inputs[1].default_value=.55

def area(name,loc,power,size,color=(1,1,1),sy=None):
 d=bpy.data.lights.new(name,'AREA');d.energy=power;d.shape='RECTANGLE';d.size=size;d.size_y=sy 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((0,0,.7))-o.location).to_track_quat('-Z','Y').to_euler()
area('Large daylight key',(3,-4,6),550,7.0,(1,.95,.88),3)
area('Sky fill',(-4,-1,3.5),400,7,(.78,.86,1),4)
area('Long roof reflection',(1,4,5),500,7,(1,1,1),2)
bpy.ops.object.camera_add();cam=bpy.context.object;cam.name='STUDIO camera';sc.camera=cam;cam.data.type='ORTHO';cam.data.ortho_scale=6.2

def camera(loc,target=(0,0,.70),scale=6.2):
 cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.data.ortho_scale=scale
camera((6,-8,3.15))
sc.render.resolution_x=1920;sc.render.resolution_y=1080;sc.render.resolution_percentage=100
sc.view_settings.view_transform='AgX';sc.view_settings.look='AgX - Medium High Contrast';sc.cycles.samples=48
sc.render.image_settings.file_format='PNG'
def render(name):
 sc.render.filepath=str(ROOT/name);bpy.ops.render.render(write_still=True);log('rendered '+name)
# Save complete authored source for inspectable provenance and repeatable render iteration.
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'family-sedan.blend'))
render('final.png')
camera((7,0,.83),(0,0,.72),5.65);render('side.png')
camera((6,-8,3.15))
clay=bpy.data.materials.new('STUDIO clay');clay.use_nodes=True;bs=clay.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(.32,.32,.32,1);bs.inputs['Roughness'].default_value=.6
sc.view_layers[0].material_override=clay;sc.view_settings.exposure=-.65;render('clay.png');sc.view_settings.exposure=0;sc.view_layers[0].material_override=None
# 3x2 colour proof assembled from six actual rendered frames; no external image package needed.
sc.render.resolution_x=640;sc.render.resolution_y=360;sc.cycles.samples=20
pixels=np.zeros((720,1920,4),dtype=np.float32)
for i,(name,color) in enumerate(COLORS):
 M['Paint'].node_tree.nodes['Paint colour factor'].inputs[2].default_value=(*color,1)
 sc.render.filepath=str(ROOT/f'paint-{i}.png');bpy.ops.render.render(write_still=True)
 im=bpy.data.images.load(str(ROOT/f'paint-{i}.png'),check_existing=False);a=np.array(im.pixels[:],dtype=np.float32).reshape(360,640,4);row=1-i//3;col=i%3;pixels[row*360:(row+1)*360,col*640:(col+1)*640]=a;bpy.data.images.remove(im)
im=bpy.data.images.new('Six paint variants',1920,720,alpha=True);im.pixels.foreach_set(pixels.ravel());im.filepath_raw=str(ROOT/'paints.png');im.file_format='PNG';im.save()
M['Paint'].node_tree.nodes['Paint colour factor'].inputs[2].default_value=(*COLORS[0][1],1)
(ROOT/'NOTES.md').write_text('\n'.join(['Original modern four-door family sedan; 4.80 × 1.84 × 1.45 m; wheelbase 2.80 m; Forge Garage v2 pivots and sockets.',f'{tri:,} triangles; six mesh nodes; eight semantic empty nodes; GLB {p.stat().st_size/1048576:.2f} MiB.','8 materials: Paint, Rubber, Metal, Brake, Plastic, Interior, Glass (BLEND), Lamps (emissive); Paint clearcoat 1.0 / 0.05 roughness.','Cycles OptiX baked tiled base/normal textures: Paint and Interior 1024, others 512 or smaller; all packed ORM ≤512; shared between parts.','Poly Haven CC0 ids: metal_plate, leather_white; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/leather_white ; licence https://polyhaven.com/license .','Reference differences: original grille and lamps, simpler C-pillar and fender transitions, dark blue paint and lighter wear; simplified cabin controls and underbody; mirrors included in 1.84 m envelope.'])+'\n')
log('COMPLETE')
