"""Family SUV — clean, deterministic Blender 5.2 build. Run with blender -b --python this.py.
Authoring axes: X left, -Y front, Z up; glTF converts to X left, Y up, Z front.
CC0 photographic sources: Poly Haven metal_plate, rubberized_track, fabric_leather_02.
"""
import bpy, bmesh, math, os, json, struct, urllib.request, sys, time
from mathutils import Vector
from math import sin,cos,pi,sqrt
ROOT=os.path.dirname(os.path.abspath(__file__))
os.makedirs(ROOT+'/textures',exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
S=bpy.context.scene;S.unit_settings.system='METRIC';S.render.engine='CYCLES'
S.cycles.samples=24;S.cycles.use_denoising=True;S.cycles.preview_samples=8
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'
S.cycles.device='GPU';S.render.image_settings.file_format='PNG'
S.view_settings.view_transform='AgX';S.view_settings.look='AgX - Medium High Contrast';S.view_settings.exposure=-.45
S.world=bpy.data.worlds.new('Daylight');S.world.use_nodes=True
S.world.node_tree.nodes['Background'].inputs[0].default_value=(.65,.73,.85,1)
S.world.node_tree.nodes['Background'].inputs[1].default_value=.45
GROUPS={'Body':[],'Wheel_FL':[],'Wheel_FR':[],'Wheel_RL':[],'Wheel_RR':[],'SteeringWheel':[]}
PAINTS=[('Silver grey',[.62,.63,.64]),('Midnight blue',[.025,.065,.13]),('Forest green',[.055,.12,.085]),('Burgundy',[.21,.032,.048]),('Pearl white',[.88,.87,.81]),('Graphite',[.075,.082,.092])]

def active(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,group='Body',smooth=True):
 me=bpy.data.meshes.new(name);me.from_pydata(v,[],f);me.update();o=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(o)
 if mat:me.materials.append(mat)
 for p in me.polygons:p.use_smooth=smooth
 if group:GROUPS[group].append(o)
 return o

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

def box(name,loc,dim,mat,bev=.01,group='Body',rot=None):
 bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.dimensions=dim
 active(o);bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
 if rot:o.rotation_euler=rot
 o.data.materials.append(mat)
 if bev:bevel(o,bev,1 if name=='Tread shoulder block' else 3)
 for p in o.data.polygons:p.use_smooth=True
 # weighted normals preserve broad flat surfaces
 m=o.modifiers.new('Face-weighted normals','WEIGHTED_NORMAL');m.keep_sharp=True
 try:bpy.ops.object.modifier_apply(modifier=m.name)
 except:pass
 if group:GROUPS[group].append(o)
 return o

def roundpoly(pts,f=.12,steps=6):
 out=[]
 for i,p in enumerate(pts):
  a=Vector(pts[i-1]).lerp(Vector(p),1-f);b=Vector(p);c=Vector(p).lerp(Vector(pts[(i+1)%len(pts)]),f)
  for j in range(steps+1):
   t=j/steps;out.append(tuple(a*(1-t)**2+b*2*t*(1-t)+c*t*t))
 return out

def tube(name,pts,r,mat,group='Body',closed=False,res=3):
 if name=='Continuous satin window surround':pts=roundpoly(pts,.13,8)
 if name=='Raised roof rail':
  ext=[pts[0]]+pts+[pts[-1]];new=[]
  for i in range(1,len(ext)-2):
   p0,p1,p2,p3=map(Vector,ext[i-1:i+3])
   for j in range(10):
    t=j/10;new.append(tuple(.5*((2*p1)+(-p0+p2)*t+(2*p0-5*p1+4*p2-p3)*t*t+(-p0+3*p1-3*p2+p3)*t*t*t)))
  pts=new+[pts[-1]]
 cu=bpy.data.curves.new(name,'CURVE');cu.dimensions='3D';cu.resolution_u=12;cu.bevel_depth=r;cu.bevel_resolution=res
 sp=cu.splines.new('POLY');sp.points.add(len(pts)-1)
 for p,co in zip(sp.points,pts):p.co=(*co,1)
 sp.use_cyclic_u=closed
 o=bpy.data.objects.new(name,cu);bpy.context.collection.objects.link(o);o.data.materials.append(mat);active(o);bpy.ops.object.convert(target='MESH')
 if group:GROUPS[group].append(o)
 return o

def cyl(name,loc,r,depth,mat,group='Body',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
 elif axis=='Y':o.rotation_euler[0]=pi/2
 o.data.materials.append(mat);bevel(o,.002,2)
 for p in o.data.polygons:p.use_smooth=True
 if group:GROUPS[group].append(o)
 return o

def matbase(name,color,metal,rough):
 m=bpy.data.materials.new(name);m.use_nodes=True;p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*color,1);p.inputs['Metallic'].default_value=metal;p.inputs['Roughness'].default_value=rough
 return m,p

def image_load(asset,kind):
 fn=f'{asset}_{kind}_1k.jpg';path=ROOT+'/textures/'+fn
 if not os.path.exists(path):
  req=urllib.request.Request(f'https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/{asset}/{fn}',headers={'User-Agent':'Mozilla/5.0'})
  with urllib.request.urlopen(req,timeout=90) as r:open(path,'wb').write(r.read())
 im=bpy.data.images.load(path,check_existing=True)
 if kind!='diff':im.colorspace_settings.name='Non-Color'
 return im

# Bake small seamless photographic + node materials on a UV tile. All parts share these sets.
def bakedmat(name,col,metal,rough,asset,variation=.15,normal_strength=.12,size=512):
 m,p=matbase(name,col,metal,rough);n=m.node_tree.nodes;l=m.node_tree.links
 uv=n.new('ShaderNodeTexCoord');tex={}
 for kind in ['diff','nor_gl','arm']:
  t=n.new('ShaderNodeTexImage');t.image=image_load(asset,kind);l.new(uv.outputs['UV'],t.inputs['Vector']);tex[kind]=t
 bw=n.new('ShaderNodeRGBToBW');l.new(tex['diff'].outputs['Color'],bw.inputs[0])
 ramp=n.new('ShaderNodeValToRGB');ramp.color_ramp.elements[0].color=(*(c*(1-variation) for c in col),1);ramp.color_ramp.elements[1].color=(*col,1);l.new(bw.outputs[0],ramp.inputs[0])
 noise=n.new('ShaderNodeTexNoise');noise.inputs['Scale'].default_value=180;noise.inputs['Detail'].default_value=2;l.new(uv.outputs['UV'],noise.inputs['Vector'])
 mix=n.new('ShaderNodeMixRGB');mix.blend_type='MULTIPLY';mix.inputs[0].default_value=.055;l.new(ramp.outputs['Color'],mix.inputs[1]);l.new(noise.outputs['Color'],mix.inputs[2]);l.new(mix.outputs[0],p.inputs['Base Color'])
 nm=n.new('ShaderNodeNormalMap');nm.inputs['Strength'].default_value=normal_strength;l.new(tex['nor_gl'].outputs['Color'],nm.inputs['Color']);l.new(nm.outputs[0],p.inputs['Normal'])
 sep=n.new('ShaderNodeSeparateColor');l.new(tex['arm'].outputs['Color'],sep.inputs[0]);mr=n.new('ShaderNodeMapRange');mr.inputs['To Min'].default_value=max(.03,rough-(.006 if name=='Paint' else .03));mr.inputs['To Max'].default_value=min(1,rough+(.006 if name=='Paint' else .03));l.new(sep.outputs['Green'],mr.inputs['Value']);l.new(mr.outputs[0],p.inputs['Roughness'])
 bpy.ops.mesh.primitive_plane_add(size=1);o=bpy.context.object;o.name='BakeTile';o.data.materials.append(m);active(o)
 out=n.get('Material Output');target=n.new('ShaderNodeTexImage');em=n.new('ShaderNodeEmission')
 def bake(kind,sz,socket=None):
  im=bpy.data.images.new(name+'_'+kind,width=sz,height=sz,alpha=False)
  if kind!='BaseColor':im.colorspace_settings.name='Non-Color'
  target.image=im;n.active=target
  if kind=='Normal':l.new(p.outputs[0],out.inputs['Surface']);bt='NORMAL'
  else:l.new(socket,em.inputs[0]);l.new(em.outputs[0],out.inputs['Surface']);bt='EMIT'
  S.cycles.samples=4;S.render.bake.margin=8;bpy.ops.object.bake(type=bt)
  im.filepath_raw=ROOT+'/textures/'+name+'_'+kind+'.png';im.file_format='PNG';im.save();return im
 base=bake('BaseColor',size,mix.outputs[0]);normal=bake('Normal',size)
 combine=n.new('ShaderNodeCombineColor');combine.inputs[0].default_value=1;l.new(mr.outputs[0],combine.inputs[1]);combine.inputs[2].default_value=metal
 orm=bake('ORM',min(size,512),combine.outputs[0]);bpy.data.objects.remove(o,do_unlink=True)
 n.clear();p=n.new('ShaderNodeBsdfPrincipled');out=n.new('ShaderNodeOutputMaterial');l.new(p.outputs[0],out.inputs[0]);nodes={}
 for k,im in [('BaseColor',base),('Normal',normal),('ORM',orm)]:
  t=n.new('ShaderNodeTexImage');t.image=im;nodes[k]=t
 l.new(nodes['BaseColor'].outputs[0],p.inputs['Base Color']);nm=n.new('ShaderNodeNormalMap');l.new(nodes['Normal'].outputs[0],nm.inputs[0 if False else 'Color']);l.new(nm.outputs[0],p.inputs['Normal'])
 sp=n.new('ShaderNodeSeparateColor');l.new(nodes['ORM'].outputs[0],sp.inputs[0]);l.new(sp.outputs['Green'],p.inputs['Roughness']);l.new(sp.outputs['Blue'],p.inputs['Metallic'])
 # Exporter's standard AO group socket binds the red ORM channel.
 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')
 gn=n.new('ShaderNodeGroup');gn.node_tree=ng;l.new(sp.outputs['Red'],gn.inputs['Occlusion'])
 if name=='Paint':p.inputs['Coat Weight'].default_value=1;p.inputs['Coat Roughness'].default_value=.05
 print('BAKED',name,flush=True)
 return m

Paint=bakedmat('Paint',(.96,.96,.96),.52,.27,'metal_plate',.008,0,1024)
Rubber=bakedmat('Rubber',(.027,.030,.032),0,.76,'rubberized_track',.25,.22)
Trim=bakedmat('Trim',(.052,.059,.064),.08,.48,'rubberized_track',.14,.10)
Alloy=bakedmat('Alloy',(.56,.59,.62),.95,.24,'metal_plate',.02,.0005)
Interior=bakedmat('Interior',(.066,.058,.048),0,.63,'fabric_leather_02',.20,.30)
Glass,gp=matbase('Glass',(.075,.09,.10),.12,.12);gp.inputs['Alpha'].default_value=.62
Glass.surface_render_method='BLENDED' if 'BLENDED' in [x.identifier for x in Glass.bl_rna.properties['surface_render_method'].enum_items] else 'DITHERED'
Glass.use_transparency_overlap=False
# Lamps are one UV palette, baked with Cycles; three strips white/red/amber.
Lamps,lp=matbase('Lamps',(1,.8,.5),.15,.19)
n=Lamps.node_tree.nodes;l=Lamps.node_tree.links
uv=n.new('ShaderNodeTexCoord');se=n.new('ShaderNodeSeparateXYZ');l.new(uv.outputs['UV'],se.inputs[0]);ra=n.new('ShaderNodeValToRGB');ra.color_ramp.interpolation='CONSTANT'
ra.color_ramp.elements.remove(ra.color_ramp.elements[1]);ra.color_ramp.elements[0].color=(1,.83,.62,1)
ra.color_ramp.elements.new(.333).color=(.65,.008,.012,1);ra.color_ramp.elements.new(.666).color=(1,.24,.008,1);l.new(se.outputs['X'],ra.inputs[0])
em=n.new('ShaderNodeEmission');l.new(ra.outputs[0],em.inputs[0]);l.new(em.outputs[0],n.get('Material Output').inputs[0])
bpy.ops.mesh.primitive_plane_add();bo=bpy.context.object;bo.data.materials.append(Lamps)
li=bpy.data.images.new('Lamps_BaseColor',256,256,alpha=False);tn=n.new('ShaderNodeTexImage');tn.image=li;n.active=tn;bpy.ops.object.bake(type='EMIT');li.filepath_raw=ROOT+'/textures/Lamps_BaseColor.png';li.file_format='PNG';li.save();bpy.data.objects.remove(bo,do_unlink=True)
l.new(lp.outputs[0],n.get('Material Output').inputs[0]);l.new(tn.outputs[0],lp.inputs['Base Color']);l.new(tn.outputs[0],lp.inputs['Emission Color']);lp.inputs['Emission Strength'].default_value=.7
# Palette UV tag is applied after all modifiers.
def lamp_tag(o,kind):o['lamp_u']={'white':.16,'red':.5,'amber':.84}[kind];return o

FRONT_RINGS=[(.285,.710,2.170),(.32,.767,2.230),(.38,.813,2.285),(.46,.837,2.314),(.56,.846,2.318),(.68,.844,2.309),(.80,.830,2.291),(.91,.811,2.278),(1.012,.788,2.268)]
REAR_RINGS=[(.285,.710,2.17),(.34,.780,2.255),(.44,.831,2.315),(.55,.845,2.330),(.70,.839,2.319),(.86,.827,2.293),(1.00,.803,2.273),(1.09,.799,2.270)]
def ringvalue(z,rings,col):
 # Hermite interpolation rounds the broad bumper without smoothing its silhouette away.
 for i in range(len(rings)-1):
  if z<=rings[i+1][0]:
   a,b=rings[i],rings[i+1];t=max(0,(z-a[0])/(b[0]-a[0]));h=b[0]-a[0]
   prev=rings[max(0,i-1)];nxt=rings[min(len(rings)-1,i+2)]
   ma=(b[col]-prev[col])/(b[0]-prev[0]);mb=(nxt[col]-a[col])/(nxt[0]-a[0])
   return (2*t**3-3*t*t+1)*a[col]+(t**3-2*t*t+t)*h*ma+(-2*t**3+3*t*t)*b[col]+(t**3-t*t)*h*mb
 return rings[-1][col]

# Width / beltline functions form the continuous body, with true open wheel arches.
def width(y):
 return .885-.085*max(0,(abs(y)-1.65)/.70)**1.4

def belt(y):
 if y<-.66:
  t=max(0,min(1,(y+2.27)/1.61));return 1.003+.102*t+.018*sin(pi*t)
 return 1.115+.006*y
def lower(y):
 z=.30
 for ay in [-1.375,1.375]:
  d=abs(y-ay)
  if d<.438:z=max(z,.375+sqrt(.438**2-d*d))
 return z
def interp(z,knots):
 for (a,x),(b,y) in zip(knots,knots[1:]):
  if z<=b:return x+(y-x)*max(0,(z-a)/(b-a))
 return knots[-1][1]
def surface_x(y,z):
 off=ringvalue(z,[(.30,-.030),(.39,-.017),(.49,-.032),(.57,-.036),(.73,-.012),(.91,.005),(.978,.008),(1.006,.009),(1.034,.001),(1.135,-.012)],1)
 return width(y)+off
N=190
for sign in [-1,1]:
 v=[];f=[]
 for i in range(N+1):
  y=-2.29+4.58*i/N;lo=lower(y);hi=belt(y)
  for j in range(19):
   t=j/18
   if j<=10:z=lo+(.91-lo)*j/10
   elif j==18:z=hi
   else:
    zz=[.94,.97,.985,1.00,1.012,1.028,1.055][j-11];z=.91+(zz-.91)*min(1,(hi-.91)/.205)
   x=surface_x(y,z)
   yp=y
   if abs(y)>1.96:
    endrings=FRONT_RINGS if y<0 else REAR_RINGS
    endy=ringvalue(z,endrings,2)-.11
    endx=ringvalue(z,endrings,1)
    fedge=(abs(y)-1.96)/(.33)
    yp=math.copysign(abs(y)+(endy-2.29)*fedge,y)
    x=x*(1-fedge)+endx*fedge
   v.append((sign*x,yp,z))
 for i in range(N):
  for j in range(18):a=i*19+j;f.append((a,a+1,a+20,a+19) if sign==1 else (a+19,a+20,a+1,a))
 o=mesh('Sculpted flank',v,f,Paint);sol=o.modifiers.new('Hemmed panel thickness','SOLIDIFY');sol.thickness=.012;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
 # wheel arches: shaped black cladding band follows physical cut edge
 for ay in [-1.375,1.375]:
  v=[];f=[]
  for k in range(81):
   a=pi*k/80
   for r in [.440,.486]:
    y=ay-r*cos(a);z=.375+r*sin(a);v.append((sign*(width(y)+.013),y,z))
  for k in range(80):a=2*k;f.append((a,a+1,a+3,a+2))
  arch=mesh('Textured wheel arch moulding',v,f,Trim);sol=arch.modifiers.new('Moulded lip','SOLIDIFY');sol.thickness=.022;active(arch);bpy.ops.object.modifier_apply(modifier=sol.name);bevel(arch,.006,2)
  for dy in [-1,1]:box('Arch foot',(sign*.874,ay+dy*.464,.333),(.031,.066,.12),Trim,.018)
  # recessed wheel well, black inner curved liner
  pts=[(sign*.80,ay-.444*cos(pi*k/64),.375+.444*sin(pi*k/64)) for k in range(65)]
  tube('Inner wheel-well return',pts,.025,Rubber,res=2)
  vv=[];ff=[]
  for kk in range(65):
   aa=pi*kk/64
   for xx in [.655,.873]:vv.append((sign*xx,ay-.444*cos(aa),.375+.444*sin(aa)))
  for kk in range(64):aa=2*kk;ff.append((aa,aa+1,aa+3,aa+2))
  mesh('Full wheel tub arch',vv,ff,Rubber)
  vv=[(sign*.655,ay,.34)]+[(sign*.655,ay-.447*cos(pi*kk/64),.375+.447*sin(pi*kk/64)) for kk in range(65)]
  mesh('Wheel tub inner wall',vv,[(0,kk+1,kk+2) for kk in range(64)],Rubber)

 box('Rocker moulding',(sign*.858,0,.305),(.085,1.80,.15),Trim,.035)
 tube('Rocker bright line',[(sign*.901,-.89,.382),(sign*.910,0,.387),(sign*.899,.88,.386)],.009,Alloy,res=2)
 # door panel shutlines are dark recessed seams, shaped around shoulder
 for yy in [-.77,.35,1.26]:
  pts=[]
  for k in range(25):
   z=.36+.765*k/24;y=yy+.003*sin(k*pi/24)
   if z<lower(y)+.014:continue
   pts.append((sign*(surface_x(y,z)+.014),y,z))
  if len(pts)>1:tube('Door vertical shutline',pts,.0016,Rubber,res=2)
 # two handles, recessed pockets and painted pull
 for yy in [.14,1.10]:
  box('Handle shadow pocket',(sign*.909,yy,.971),(.016,.224,.046),Rubber,.02)
  box('Painted pull handle',(sign*.909,yy-.005,.978),(.032,.179,.031),Paint,.014)
  box('Handle key cap',(sign*.926,yy+.067,.978),(.005,.018,.019),Alloy,.005)
 # front fender decorative seam and side indicator
 tube('Fender shoulder trim',[(sign*.893,-1.04,1.022),(sign*.898,-.87,1.035)],.006,Alloy,res=2)
 # fuel door right rear quarter
 if sign==-1:
  pts=[(sign*(width(1.88)+.009),1.88+.105*cos(2*pi*k/64),.97+.075*sin(2*pi*k/64)) for k in range(64)]
  tube('Fuel filler door seam',pts,.0027,Rubber,closed=True,res=2)

# Shoulder return closes the shell below the glass and catches the beltline highlight.
for sign in [-1,1]:
 v=[];f=[]
 for k in range(97):
  y=-.70+2.79*k/96
  v.extend([(sign*width(y),y,belt(y)),(sign*.865,y,1.122+.01*(y+.70)/2.79)])
 for k in range(96):a=2*k;f.append((a,a+1,a+3,a+2))
 mesh('Continuous belt shoulder',v,f,Paint)

# Rounded fender top return, spanning the bonnet shutline.
for sign in [-1,1]:
 v=[];f=[]
 for k in range(65):
  t=k/64;y=-2.27+1.61*t+.11*(1-t)**3;z=1.003+.102*t+.018*sin(pi*t)
  xinner=.790+(.876-.790)*t
  yr=-2.29+1.63*t;zr=belt(yr);blend=max(0,min(1,(-yr-1.96)/.33))
  xr=surface_x(yr,zr)*(1-blend)+ringvalue(zr,FRONT_RINGS,1)*blend
  yo=yr-(ringvalue(zr,FRONT_RINGS,2)-.11-2.29)*blend
  v.extend([(sign*xinner,y,z),(sign*(xr+.012*(1-blend)),yo,zr)])
 for k in range(64):a=2*k;f.append((a,a+1,a+3,a+2))
 mesh('Bonnet fender return',v,f,Paint)
# Gently crowned hood, roof and tail deck.
def crown(name,y0,y1,z0,z1,w0,w1,bulge,mat,rows=32,cols=32):
 v=[];f=[]
 for i in range(rows+1):
  t=i/rows;y=y0+(y1-y0)*t;w=w0+(w1-w0)*t;z=z0+(z1-z0)*t+.018*sin(pi*t)
  for j in range(cols+1):
   u=-1+2*j/cols;yp=y+(.11*abs(u)**3*(1-t)**3 if name=='Bonnet' else (-.11*abs(u)**3*t**3 if name=='Tailgate shoulder' else 0));v.append((w*u,yp,z+bulge*(1-u*u)+(.018*sin(pi*t)*(1-u*u)+.008*math.exp(-((abs(u)-.74)/.085)**2)*sin(pi*t) if name=='Bonnet' else 0)))
 for i in range(rows):
  for j in range(cols):a=i*(cols+1)+j;f.append((a,a+1,a+cols+2,a+cols+1))
 o=mesh(name,v,f,mat);so=o.modifiers.new('Panel inner return','SOLIDIFY');so.thickness=.008;active(o);bpy.ops.object.modifier_apply(modifier=so.name);bevel(o,.004,2);return o
crown('Bonnet',-2.27,-.66,1.003,1.105,.790,.876,.025,Paint)
# Continuous rolled nose edge closes the bonnet's crown onto the bumper header.
v=[];f=[]
for k in range(65):
 u=-1+2*k/64;y=-2.27+.11*abs(u)**3;z=1.003+.025*(1-u*u)
 v.extend([(.790*u,y,z),(.788*u,y+.002,1.007)])
for k in range(64):a=2*k;f.append((a,a+1,a+3,a+2))
o=mesh('Rolled bonnet leading edge',v,f,Paint)

crown('Roof outer skin',-.01,1.67,1.578,1.566,.729,.73,.026,Paint,48,32)
crown('Tailgate shoulder',1.99,2.27,1.105,1.09,.844,.799,.025,Paint,8,24)

# Glass with lightly bowed surfaces and continuous black seals / metallic surrounds.
def window_surface(name,points,mat=Glass,border=True):
 if mat==Glass:
  points=roundpoly(points,.10,8)
  center=sum((Vector(p) for p in points),Vector())/len(points)
  verts=[tuple(center)];faces=[];n=len(points);rings=5
  for j in range(1,rings+1):
   for p in points:
    q=center.lerp(Vector(p),j/rings)
    if abs(center.x)>.6:q.x+=math.copysign(.006*(1-(j/rings)**2),center.x)
    verts.append(tuple(q))
  for k in range(n):faces.append((0,1+k,1+(k+1)%n))
  for j in range(rings-1):
   for k in range(n):
    a=1+j*n+k;b=1+j*n+(k+1)%n;faces.append((a,b,b+n,a+n))
  o=mesh(name,verts,faces,mat)
 else:o=mesh(name,points,[tuple(range(len(points)))],mat)
 if border:tube(name+' rubber seal',points,.012,Rubber,closed=True,res=3)
 return o

wind=[(-.817,-.681,1.124),(.817,-.681,1.124),(.711,.004,1.557),(-.711,.004,1.557)]
window_surface('Laminated windscreen',wind)
rear=[(-.73,1.67,1.553),(.73,1.67,1.553),(.836,2.074,1.121),(-.836,2.074,1.121)]
window_surface('Tailgate glass',rear)
def sidept(sign,y,z):return (sign*(.866-(z-1.12)*.329),y,z)
for sign in [-1,1]:
 for nam,coords in [('Front door glass',[(-.604,1.134),(.337,1.14),(.335,1.553),(.035,1.55)]),('Rear door glass',[(.414,1.14),(1.22,1.151),(1.15,1.545),(.414,1.55)]),('Quarter glass',[(1.30,1.153),(1.938,1.157),(1.755,1.431),(1.37,1.536)])]:
  pts=[sidept(sign,y,z) for y,z in coords];window_surface(nam,pts)
 # broad painted A / D pillars, black B and C pillars
 for name,coords,mat in [
 
 ('B pillar',[(.341,1.13),(.413,1.13),(.413,1.56),(.342,1.56)],Trim),
 ('C pillar',[(1.221,1.14),(1.3,1.14),(1.37,1.55),(1.153,1.55)],Trim),
 ]:
  o=window_surface(name,[sidept(sign,y,z) for y,z in coords],mat,False);sol=o.modifiers.new('Pillar thickness','SOLIDIFY');sol.thickness=.025;active(o);bpy.ops.object.modifier_apply(modifier=sol.name);bevel(o,.006,2)
 # Continuous stamped greenhouse frame with rounded roof/D-pillar transition.
 outer=[(-.737,1.108),(-.08,1.57),(.17,1.586),(1.46,1.576),(1.75,1.522),(2.145,1.115)]
 inner=[(-.61,1.14),(.035,1.539),(.20,1.555),(1.39,1.550),(1.685,1.490),(1.98,1.148)]
 a=roundpoly([sidept(sign,y,z) for y,z in outer],.10,8)
 b=roundpoly([sidept(sign,y,z) for y,z in inner],.10,8)
 a=[(x+sign*.010,y,z) for x,y,z in a];b=[(x+sign*.008,y,z) for x,y,z in b]
 nn=len(a);v=a+b;f=[(i,(i+1)%nn,(i+1)%nn+nn,i+nn) for i in range(nn)]
 frame=mesh('One-piece painted window frame',v,f,Paint);so=frame.modifiers.new('Stamped frame return','SOLIDIFY');so.thickness=.014;active(frame);bpy.ops.object.modifier_apply(modifier=so.name);bevel(frame,.005,3)
 tube('Window frame satin inlay',[(x+sign*.003,y,z) for x,y,z in b],.006,Alloy,closed=True,res=3)
 # upper and lower bright window perimeter
 perimeter=[(-.659,1.116),(-.34,1.36),(.00,1.578),(.5,1.58),(1.20,1.571),(1.69,1.495),(1.99,1.164),(1.94,1.13),(.5,1.118)]
 # Continuous bright trim is the inlay on the one-piece frame above.
 # roof rails, mounts are seated on roof
 for yy in [.18,1.36]:box('Roof rail foot',(sign*.65,yy,1.62),(.068,.16,.056),Trim,.025)
 tube('Raised roof rail',[(sign*.65,.08,1.61),(sign*.65,.16,1.658),(sign*.65,.35,1.665),(sign*.65,1.24,1.665),(sign*.65,1.43,1.64),(sign*.65,1.48,1.59)],.015,Alloy,res=4)
 # mirror mounted to lower A pillar; overall width remains 1.88m
 tube('Mirror pedestal',[(sign*.829,-.53,1.14),(sign*.887,-.55,1.158)],.028,Trim,res=3)
 box('Mirror housing',(sign*.889,-.569,1.194),(.102,.208,.114),Paint,.039)
 box('Mirror lower housing',(sign*.893,-.558,1.16),(.094,.186,.042),Trim,.02)
 box('Mirror reflective insert',(sign*.891,-.469,1.196),(.078,.008,.075),Alloy,.023)
 o=box('Mirror turn signal',(sign*.941,-.588,1.19),(.004,.110,.009),Lamps,.003);lamp_tag(o,'amber')
for sign in [-1,1]:
 tube('Roof edge seam',[(sign*.729,-.01,1.578),(sign*.736,.4,1.589),(sign*.733,1.15,1.581),(sign*.73,1.67,1.566)],.011,Paint,res=3)
# Painted roof headers close the glass-to-roof joints.
tube('Front roof header',[(-.728,.008,1.568),(-.5,-.009,1.581),(0,-.012,1.597),(.5,-.009,1.581),(.728,.008,1.568)],.019,Paint,res=4)
tube('Rear roof header',[(-.73,1.67,1.564),(0,1.67,1.596),(.73,1.67,1.564)],.019,Paint,res=4)
# spoiler and shark-fin antenna
box('Roof rear spoiler',(0,1.709,1.565),(1.48,.205,.062),Paint,.027)
box('Spoiler underside',(0,1.781,1.531),(1.33,.067,.023),Trim,.012)
o=box('High mounted stop lamp',(0,1.818,1.537),(.50,.009,.012),Lamps,.004);lamp_tag(o,'red')
fin=mesh('Shark fin antenna',[(-.025,1.18,1.59),(.025,1.18,1.59),(.019,1.36,1.589),(-.019,1.36,1.589),(0,1.32,1.673)],[(0,1,4),(1,2,4),(2,3,4),(3,0,4),(3,2,1,0)],Trim);bevel(fin,.005,3)

# V2: sculpted front and rear skins with actual recessed apertures.
# Every optical/intake detail follows the same doubly curved bumper surface.
def facepoint(x,z,front=True,inset=0):
 rings=FRONT_RINGS if front else REAR_RINGS;w=ringvalue(z,rings,1);y=ringvalue(z,rings,2)-.11*(abs(x)/w)**3
 return (x,(-1 if front else 1)*(y-inset),z)
def make_skin(front):
 rings=FRONT_RINGS if front else REAR_RINGS;v=[];f=[];nx=80;nz=56
 for j in range(nz+1):
  z=rings[0][0]+(rings[-1][0]-rings[0][0])*j/nz;w=ringvalue(z,rings,1)
  for i in range(nx+1):v.append(facepoint(w*(-1+2*i/nx),z,front))
 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))
 if not front:f=[tuple(reversed(face)) for face in f]
 o=mesh('Integrated front skin' if front else 'Integrated rear skin',v,f,Paint)
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
 sol=o.modifiers.new('Continuous moulded skin thickness','SOLIDIFY');sol.thickness=.035;sol.offset=-1;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
 return o
frontskin=make_skin(True);rearskin=make_skin(False)
def outline2(pts):return roundpoly(pts,.19,5)
def cut_opening(skin,pts,front):
 n=len(pts);ys=(-2.7,-1.9) if front else (1.9,2.7)
 v=[(x,y,z) for y in ys for x,z in pts];f=[tuple(reversed(range(n))),tuple(range(n,2*n))]+[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)]
 c=mesh('Aperture cutting tool',v,f,None,None)
 bm=bmesh.new();bm.from_mesh(c.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(c.data);bm.free()
 active(skin);m=skin.modifiers.new('Real recessed aperture','BOOLEAN');m.operation='DIFFERENCE';m.solver='EXACT';m.object=c;bpy.ops.object.modifier_apply(modifier=m.name);bpy.data.objects.remove(c,do_unlink=True)
def patch(name,pts,mat,front=True,inset=.035):
 # Radial tessellation follows curvature rather than one planar polygon.
 n=len(pts);cx=sum(p[0] for p in pts)/n;cz=sum(p[1] for p in pts)/n
 v=[facepoint(cx,cz,front,inset)]+[facepoint(x,z,front,inset) for x,z in pts]
 return mesh(name,v,[(0,i+1,(i+1)%n+1) for i in range(n)],mat)
def recess(name,pts,front=True,depth=.052,mat=Rubber):
 cut_opening(frontskin if front else rearskin,pts,front)
 n=len(pts);v=[facepoint(x,z,front,d) for d in [0,depth] for x,z in pts]
 mesh(name+' internal return',v,[(i,(i+1)%n,(i+1)%n+n,i+n) for i in range(n)],Trim)
 return patch(name+' shadow back',pts,mat,front,depth)
def lineface(name,pts,r,mat,front=True,inset=0,closed=False):
 dense=[]
 for a,b in zip(pts,pts[1:]):
  steps=max(1,int(math.hypot(b[0]-a[0],b[1]-a[1])/.012))
  dense.extend([(a[0]+(b[0]-a[0])*j/steps,a[1]+(b[1]-a[1])*j/steps) for j in range(steps)])
 dense.append(pts[-1])
 return tube(name,[facepoint(x,z,front,inset) for x,z in dense],r,mat,closed=closed,res=2)
# One broad opening with a restrained original six-sided contour, surrounded by painted skin.
grille=outline2([(-.548,.982),(.548,.982),(.557,.872),(.49,.765),(-.49,.765),(-.557,.872)])
recess('Main radiator grille',grille,depth=.068)
lineface('Recessed grille satin reveal',grille,.0045,Alloy,inset=.009,closed=True)
for z in [.793,.834,.875,.916,.955]:
 w=interp(z,[(.765,.46),(.872,.535),(.982,.525)])
 pts=[(-w+2*w*i/40,z) for i in range(41)]
 lineface('Curved graphite radiator blade',pts,.010,Trim,inset=.019)
 lineface('Blade fine machined leading edge',[(x,z+.003) for x,z in pts],.0028,Alloy,inset=.010)
for x in [-.50,-.40,-.30,-.20,-.10,0,.10,.20,.30,.40,.50]:
 zlo=.800 if abs(x)>.48 else .782
 lineface('Recessed vertical grille web',[(x,zlo),(x,.958)],.0045,Trim,inset=.040)
# Lower air slot and outer brake cooling pockets are sunk into the same painted moulding.
lower_slot=outline2([(-.54,.547),(.54,.547),(.492,.414),(-.492,.414)])
recess('Lower cooling aperture',lower_slot,depth=.054)
for z in [.448,.487,.523]:lineface('Lower intake recessed louvre',[(-.49+i*.98/40,z) for i in range(41)],.007,Trim,inset=.021)
for sign in [-1,1]:
 duct=outline2([(sign*.611,.668),(sign*.770,.682),(sign*.799,.454),(sign*.59,.424)])
 recess('Outboard cooling duct',duct,depth=.050)
 for z in [.47,.505,.54]:lineface('Outboard duct blade',[(sign*.627,z),(sign*.777,z+.006)],.006,Trim,inset=.020)
 fog=outline2([(sign*.646,.580),(sign*.752,.586),(sign*.749,.622),(sign*.646,.617)])
 o=patch('Integrated fog optic',fog,Lamps,inset=.019);lamp_tag(o,'white')
 lineface('Fog lens frame',fog,.004,Alloy,inset=.018,closed=True)
 # Wing-set lamps; swept outboard tips join the fender rather than protruding from it.
 lamp=outline2([(sign*.570,1.002),(sign*.781,1.008),(sign*.795,.976),(sign*.784,.910),(sign*.568,.918)])
 recess('Wing headlamp',lamp,depth=.042,mat=Trim)
 lineface('Headlamp flush dark gasket',lamp,.0035,Rubber,inset=-.001,closed=True)
 for xx in [.607,.677,.750]:
  lens=outline2([(sign*(xx-.025),.945),(sign*(xx+.025),.948),(sign*(xx+.025),.962),(sign*(xx-.025),.960)])
  patch('Individual LED reflector',[(x*1.001,z) for x,z in lens],Alloy,inset=.012)
  o=patch('Recessed LED optical module',lens,Lamps,inset=.008);lamp_tag(o,'white')
 drl=[(sign*.578,.988),(sign*.690,.994),(sign*.777,.996),(sign*.786,.970)]
 o=lineface('Continuous white DRL signature',drl,.005,Lamps,inset=-.003);lamp_tag(o,'white')
 o=lineface('Flush amber turn segment',[(sign*.580,.930),(sign*.744,.932)],.0035,Lamps,inset=.000);lamp_tag(o,'amber')
 # Thin glazing follows the bumper surface; unlike v1 it has no projecting pod.
 patch('Optical protective glazing',lamp,Glass,inset=-.001)
 # Rear lamps follow both the tailgate and the outboard corner of the quarter panel.
 tail=outline2([(sign*.48,1.065),(sign*.784,1.071),(sign*.800,1.027),(sign*.772,.961),(sign*.505,.972)])
 recess('Tailgate split lamp',tail,False,.031,Trim)
 lens=[(x,(z-1.02)*.78+1.02) for x,z in tail]
 o=patch('Wrapped red tail lens',lens,Lamps,False,.005);lamp_tag(o,'red')
 for zz in [.982,1.049]:
  o=lineface('Rear double LED ribbon',[(sign*.514,zz),(sign*.666,zz+.003),(sign*.793,zz+.009)],.0045,Lamps,False,-.001);lamp_tag(o,'red')
 # Lens follows the exact quarter-panel loft through the rear corner.
 def quarterpoint(y,z,offset=.016):
  t=max(0,min(1,(y-1.96)/.33));x=surface_x(y,z)*(1-t)+ringvalue(z,REAR_RINGS,1)*t
  yp=y+(ringvalue(z,REAR_RINGS,2)-.11-2.29)*t
  return (sign*(x+offset),yp,z)
 v=[];f=[]
 for j in range(21):
  t=j/20;y=2.025+.265*t
  ztop=1.035+.030*min(1,t*3);zbottom=1.030-.045*min(1,t*3)
  v.extend([quarterpoint(y,zbottom),quarterpoint(y,ztop)])
 for j in range(20):a=j*2;f.append((a,a+1,a+3,a+2))
 o=mesh('Rear quarter optical housing',v,f,Trim)
 edge=[v[i] for i in range(0,len(v),2)]+[v[i] for i in range(len(v)-1,0,-2)]
 tube('Rear quarter lens gasket',edge,.003,Rubber,closed=True,res=2)
 for zbase in [1.008,1.043]:
  pts=[quarterpoint(2.055+.235*j/20,zbase+.010*j/20,.020) for j in range(21)]
  o=tube('Continuous quarter red LED ribbon',pts,.006,Lamps,res=2);lamp_tag(o,'red')
 # Sensors lie on the moulded skin; no floating cylinders.
 for xx in [.31,.73]:
  pos=facepoint(sign*xx,.66,True,-.001);cyl('Flush front ultrasonic sensor',pos,.011,.003,Paint,axis='Y',verts=20)
 pos=facepoint(sign*.65,.65,False,-.001);cyl('Flush rear ultrasonic sensor',pos,.011,.003,Paint,axis='Y',verts=20)
 reflect=outline2([(sign*.60,.49),(sign*.758,.50),(sign*.752,.474),(sign*.602,.465)])
 o=patch('Integrated rear reflector',reflect,Lamps,False,-.002);lamp_tag(o,'red')
# A continuous lower valance, curved around the bumper corners.
for front in [True,False]:
 v=[];f=[]
 for i in range(65):
  u=-1+2*i/64
  for z in [.294,.315,.354,.378]:
   w=ringvalue(z,FRONT_RINGS if front else REAR_RINGS,1);v.append(facepoint(w*u,z,front,-.001))
 for i in range(64):
  for j in range(3):a=i*4+j;f.append((a,a+1,a+5,a+4))
 ob=mesh('Wrapped lower protective valance',v,f,Trim);so=ob.modifiers.new('Valance thickness','SOLIDIFY');so.thickness=.012;active(ob);bpy.ops.object.modifier_apply(modifier=so.name);bevel(ob,.005,2)
 # Satin skid shield is a formed plate following the bumper curvature, not a bumper bar.
 skid=outline2([(-.48,.378),(.48,.378),(.42,.316),(-.42,.316)])
 ob=patch('Formed satin skid plate',skid,Alloy,front,-.008)
 so=ob.modifiers.new('Folded skid plate gauge','SOLIDIFY');so.thickness=.006;active(ob);bpy.ops.object.modifier_apply(modifier=so.name);bevel(ob,.003,2)
# Exhaust opening sunk into the integrated rear valance.
for sign in [-1,1]:
 ex=outline2([(sign*.56,.374),(sign*.744,.374),(sign*.729,.320),(sign*.574,.320)])
 patch('Exhaust outlet',ex,Rubber,False,-.005)
 lineface('Exhaust outlet rolled lip',ex,.005,Alloy,False,-.010,True)
# Bevel actual hole edges after all cuts. Broad skin stays smooth and continuous.
for ob in [frontskin,rearskin]:bevel(ob,.006,3)
# Tailgate return and panel seams stay flush with the curved rear skin.
v=[];f=[]
for k in range(65):
 u=-1+2*k/64;y=2.27-.11*abs(u)**3
 v.extend([(.799*u,y,1.09+.025*(1-u*u)),(.799*u,y,1.088)])
for k in range(64):a=2*k;f.append((a,a+1,a+3,a+2))
mesh('Tailgate upper folded return',v,f,Paint)
lineface('Tailgate perimeter seam',[(-.733,1.087),(-.712,.935),(-.682,.741),(-.58,.683),(0,.673),(.58,.683),(.682,.741),(.712,.935),(.733,1.087)],.0022,Rubber,False,-.001)
plate=outline2([(-.24,.877),(.24,.877),(.232,.751),(-.232,.751)])
recess('Tailgate plate pocket',plate,False,.019,Trim)
patch('Blank rear registration panel',outline2([(-.196,.854),(.196,.854),(.196,.773),(-.196,.773)]),Alloy,False,.013)
lineface('Tailgate release',[(-.08,.921),(.08,.921)],.006,Trim,False,-.002)
cyl('Rear camera',facepoint(0,.926,False,-.008),.008,.008,Glass,axis='Y',verts=20)
# windscreen and rear wipers, cowl, washer jets
box('Windscreen cowl',(0,-.66,1.107),(1.52,.07,.024),Trim,.01)
for sign in [-1,1]:
 tube('Wiper arm',[(sign*.29,-.647,1.135),(sign*.23,-.578,1.172),(sign*.50,-.555,1.19)],.006,Trim,res=2)
 tube('Wiper blade',[(sign*.12,-.565,1.187),(sign*.61,-.56,1.188)],.008,Rubber,res=2)
 box('Washer nozzle',(sign*.39,-.798,1.132),(.023,.026,.010),Trim,.005)
tube('Rear wiper',[(.05,2.067,1.155),(.33,2.027,1.191),(.52,2.005,1.211)],.007,Trim,res=2)

# Interior: floor, five contoured seats with headrests, stitched inserts, dash and door cards.
box('Cabin floor',(0,.45,.391),(1.62,2.77,.15),Interior,.04)
box('Dashboard',(0,-.52,1.005),(1.55,.40,.205),Interior,.064)
box('Upper dash brow',(0,-.57,1.098),(1.52,.27,.056),Trim,.026)
box('Centre console',(0,-.04,.664),(.27,.95,.38),Interior,.044)
box('Console bright edge',(0,-.025,.849),(.285,.56,.025),Alloy,.012)
box('Console top',(0,-.025,.867),(.26,.55,.025),Trim,.012)
for yy in [.04,.18]:cyl('Cupholder',(0,yy,.885),.045,.008,Rubber,axis='Z',verts=32)
box('Gear selector',(0,-.17,.92),(.067,.073,.09),Trim,.021)
box('Central display',(0,-.326,1.104),(.255,.022,.153),Trim,.018)
box('Dark display glass',(0,-.31,1.109),(.227,.005,.124),Glass,.010)
for xx in [-.58,-.23,.23,.58]:
 box('Dash vent',(xx,-.299,1.015),(.14,.017,.049),Rubber,.01)
 for dz in [-.014,0,.014]:box('Vent vane',(xx,-.287,1.015+dz),(.121,.006,.003),Alloy,.001)
for row,yy in [('front',.13),('rear',1.18)]:
 for xx in ([-.43,.43] if row=='front' else [-.47,0,.47]):
  w=.44 if row=='front' else .405
  box('Seat cushion',(xx,yy,.61),(w,.49,.15),Interior,.06)
  ob=box('Seat back',(xx,yy+.205,.898),(w,.15,.56),Interior,.065,rot=(-.11,0,0))
  box('Seat back centre insert',(xx,yy+.111,.898),(w*.63,.028,.399),Trim,.035,rot=(-.11,0,0))
  for dx in [-w*.33,w*.33]:
   box('Seat side bolster',(xx+dx,yy+.08,.875),(.092,.109,.43),Interior,.039,rot=(-.11,0,0))
   tube('Seat double stitched piping',[(xx+dx,yy+.019,.701),(xx+dx,yy+.067,.965),(xx+dx,yy+.093,1.064)],.0023,Alloy,res=1)
  for dx in [-.06,.06]:cyl('Headrest post',(xx+dx,yy+.222,1.18),.008,.11,Alloy,axis='Z',verts=16)
  box('Adjustable headrest',(xx,yy+.23,1.262),(.245,.115,.185),Interior,.045)
for sign in [-1,1]:
 for yy in [.0,.97]:
  box('Door card',(sign*.806,yy,.78),(.077,.82,.45),Interior,.028)
  box('Door armrest',(sign*.745,yy,.846),(.13,.50,.07),Trim,.027)
  box('Interior handle',(sign*.737,yy-.19,.955),(.027,.106,.023),Alloy,.009)
  box('Door speaker',(sign*.756,yy+.20,.677),(.009,.194,.11),Trim,.025)
# Steering wheel hub on vehicle left. Ring lies approximately XZ; tilt follows column.
steer_center=Vector((.43,-.22,1.02))
ringpts=[tuple(steer_center+Vector((.166*cos(2*pi*k/80),.035*sin(2*pi*k/80),.166*sin(2*pi*k/80)))) for k in range(80)]
tube('Leather steering rim',ringpts,.018,Interior,'SteeringWheel',True,4)
box('Steering airbag hub',steer_center,(.14,.085,.09),Trim,.032,'SteeringWheel')
for a in [0,pi,1.5*pi]:tube('Steering spoke',[tuple(steer_center),tuple(steer_center+Vector((.143*cos(a),.03*sin(a),.143*sin(a))))],.016,Alloy,'SteeringWheel',res=3)
tube('Steering column',[(.43,-.30,1.02),(.43,-.46,.94)],.035,Trim,res=3)
box('Instrument binnacle',(.43,-.40,1.101),(.315,.17,.132),Trim,.045)
for x in [.353,.505]:cyl('Instrument gauge',(x,-.309,1.107),.057,.005,Glass,axis='Y',verts=48)

# Wheels are detailed lathed profiles. All four have local origin at axle centre.
def lathe_x(name,cx,cy,profile,mat,group,n=96):
 v=[];f=[]
 for x,r in profile:
  for k in range(n):a=2*pi*k/n;v.append((cx+x,cy+r*cos(a),.375+r*sin(a)))
 for j in range(len(profile)-1):
  for k in range(n):a=j*n+k;b=j*n+(k+1)%n;f.append((a,b,b+n,a+n))
 return mesh(name,v,f,mat,group)
for sign in [1,-1]:
 for front,ay in [(True,-1.375),(False,1.375)]:
  group='Wheel_'+('F' if front else 'R')+('L' if sign==1 else 'R');cx=sign*.805
  profile=[(-.125,.266),(-.128,.292),(-.124,.326),(-.116,.348),(-.100,.366),(-.083,.373),(-.060,.375),(-.041,.375),(-.037,.370),(-.030,.370),(-.026,.375),(-.005,.375),(.005,.375),(.009,.370),(.016,.370),(.020,.375),(.041,.375),(.045,.370),(.052,.370),(.056,.375),(.080,.373),(.100,.366),(.116,.348),(.124,.326),(.128,.292),(.125,.266)]
  lathe_x('Radial tyre carcass',cx,ay,profile,Rubber,group,112)
  # Four rows of small diagonal tread blocks, continuous around circumference.
  for k in range(72):
   a=2*pi*k/72
   for j in [-1,1]:
    x=cx+j*.065;r=.374
    o=box('Tread shoulder block',(x,ay+r*cos(a),.375+r*sin(a)),(.058,.017,.006),Rubber,.0015,group,rot=(a-pi/2,0,j*.22))
  # subtle concentric moulding rings on outer tyre sidewall
  outer=cx+sign*.126
  for rr in [.283,.306,.339]:
   tube('Sidewall mould line',[(outer,ay+rr*cos(2*pi*k/96),.375+rr*sin(2*pi*k/96)) for k in range(96)],.0018,Rubber,group,True,2)
  for k in range(56):
   a=2*pi*k/56
   tube('Sidewall radial micro ribs',[(outer,ay+.314*cos(a),.375+.314*sin(a)),(outer,ay+.331*cos(a+.014),.375+.331*sin(a+.014))],.001,Rubber,group,res=1)
  # alloy rim barrel, outer polished lip, dark rotor and drilled disc
  rimprofile=[(-.116,.249),(-.10,.253),(.10,.253),(.119,.261),(.127,.263),(.131,.255),(.126,.244),(.110,.241)]
  lathe_x('Cast alloy barrel',cx,ay,rimprofile,Alloy,group,96)
  xface=cx+sign*.096
  cyl('Brake rotor',(cx+sign*.065,ay,.375),.209,.016,Alloy,group,verts=96)
  cyl('Rotor centre',(cx+sign*.078,ay,.375),.101,.021,Trim,group,verts=64)
  for rr in [.158,.186]:
   for k in range(24):
    a=2*pi*k/24+(rr-.15)*5;cyl('Rotor drilled hole',(cx+sign*.077,ay+rr*cos(a),.375+rr*sin(a)),.0038,.0015,Rubber,group,verts=8)
  box('Brake caliper',(cx+sign*.089,ay+.159,.388),(.051,.07,.16),Trim,.016,'Body')
  # Ten swept split spokes, geometry tapers and catches highlights.
  for k in range(5):
   for split in [-1,1]:
    a=2*pi*k/5+split*.09
    r0=.057;r1=.244;w0=.014;w1=.019
    coords=[]
    for dx in [-.012,.012]:
     for rr,ang,w in [(r0,a-.1,w0),(r1,a+split*.06,w1)]:
      for edge in [-1,1]:
       coords.append((xface+sign*dx,ay+rr*cos(ang)+edge*w*(-sin(ang)),.375+rr*sin(ang)+edge*w*cos(ang)))
    o=mesh('Machined split spoke',coords,[(0,2,3,1),(4,5,7,6),(0,4,6,2),(1,3,7,5),(0,1,5,4),(2,6,7,3)],Alloy,group);bevel(o,.004,3)
  cyl('Unbranded wheel hub',(xface+sign*.013,ay,.375),.061,.034,Alloy,group,verts=64)
  cyl('Centre hub insert',(xface+sign*.033,ay,.375),.031,.005,Trim,group,verts=48)
  for k in range(5):
   a=2*pi*k/5;cyl('Hex wheel bolt',(xface+sign*.034,ay+.047*cos(a),.375+.047*sin(a)),.009,.008,Trim,group,verts=6)
  tube('Valve stem',[(xface,ay-.218,.41),(xface+sign*.012,ay-.229,.412)],.004,Rubber,group,res=2)
# Underbody, axles and suspension sit behind the wheel geometry.
box('Underbody',(0,0,.26),(1.49,3.83,.10),Trim,.035)
for ay in [-1.375,1.375]:
 cyl('Axle',(0,ay,.375),.044,1.57,Trim,axis='X',verts=24)
 for sign in [-1,1]:tube('Suspension strut',[(sign*.73,ay,.39),(sign*.71,ay,.72)],.034,Trim,res=3)

print('GEOMETRY_COMPLETE',sum(len(v) for v in GROUPS.values()),flush=True)
# UVs, normals, modifiers and rigid assembly naming.
protected_panels=[]
for g,obs in GROUPS.items():
 for o in obs:
  active(o);bpy.ops.object.transform_apply(location=False,rotation=True,scale=True)
  # Reliable normals; UV generated after all bevels. Preserve lamp palette UVs.
  bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.recalc_face_normals(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()
  if not o.data.uv_layers:o.data.uv_layers.new(name='UVMap')
  if 'lamp_u' in o:
   for d in o.data.uv_layers.active.data:d.uv=(o['lamp_u'],.5)
  else:
   # Box projection in real metres: shared repeatable photographic material, no unique atlas cost.
   uv=o.data.uv_layers.active.data
   for p in o.data.polygons:
    a=max(range(3),key=lambda i:abs(p.normal[i]));axes=[i for i in range(3) if i!=a]
    for li in p.loop_indices:
     co=o.data.vertices[o.data.loops[li].vertex_index].co;uv[li].uv=(co[axes[0]]*.65,co[axes[1]]*.65)
 # Retain broad stamped panels verbatim: collapse decimation otherwise turns
 # their narrow curvature bands into long, visibly dark diagonal triangles.
 if g=='Body':
  protected_panels=[o for o in obs if o.name.startswith(('Sculpted flank','Bonnet fender return','Bonnet','Continuous belt shoulder'))]
  obs=[o for o in obs if o not in protected_panels]
  for po in protected_panels:
   bm=bmesh.new();bm.from_mesh(po.data);bmesh.ops.triangulate(bm,faces=bm.faces);bm.to_mesh(po.data);bm.free()
 # Join exactly one mesh per moving rigid node.
 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=g
 if g.startswith('Wheel_'):pos=Vector((.805 if g.endswith('L') else -.805,-1.375 if g[6]=='F' else 1.375,.375))
 elif g=='SteeringWheel':pos=steer_center
 else:pos=Vector((0,0,0))
 S.cursor.location=pos;bpy.ops.object.origin_set(type='ORIGIN_CURSOR');GROUPS[g]=o
 # Remove tiny degenerate geometry and apply triangulation for authoritative count.
 bm=bmesh.new();bm.from_mesh(o.data);bmesh.ops.dissolve_degenerate(bm,dist=.000001,edges=bm.edges);bmesh.ops.triangulate(bm,faces=bm.faces);bm.to_mesh(o.data);bm.free()

# Preserve silhouette while keeping a deterministic game triangle budget.
count=sum(len(o.data.polygons) for o in GROUPS.values())
protected_count=sum(len(o.data.polygons) for o in protected_panels)
if count+protected_count>96000:
 ratio=(95000-protected_count)/count
 for o in GROUPS.values():
  active(o);d=o.modifiers.new('Game mesh budget','DECIMATE');d.ratio=ratio;d.use_collapse_triangulate=True
  bpy.ops.object.modifier_apply(modifier=d.name)
# Restore the protected smooth shell to the rigid Body, preserving its origin.
active(GROUPS['Body'])
for po in protected_panels:po.select_set(True)
bpy.ops.object.join();GROUPS['Body']=bpy.context.object
# Exact envelope: overhangs are calibrated independently so wheelbase remains 2.75m.
body=GROUPS['Body']
# Coherent body shaping: descending bonnet, raked nose and tapered rear roof.
# Applied to the entire fixed assembly so lamp, panel and glazing joints remain aligned.
for v in body.data.vertices:
 co=body.matrix_world@v.co
 if co.y<-.68 and co.z>.70:
  t=min(1,(-co.y-.68)/1.67);co.z-=.105*t**1.15*min(1,(co.z-.70)/.25)
 if co.y<-1.90:
  co.y+=.085*min(1,max(0,(co.z-.35)/.65))*min(1,(-co.y-1.90)/.35)
 if co.y>.25 and co.z>1.10:
  co.z-=.055*min(1,(co.y-.25)/1.60)*min(1,(co.z-1.10)/.45)
 v.co=body.matrix_world.inverted()@co
ends=[max(abs((body.matrix_world@v.co).y) for v in body.data.vertices if (body.matrix_world@v.co).y*sign>0) for sign in [-1,1]]
for v in body.data.vertices:
 co=body.matrix_world@v.co
 if abs(co.y)>1.80:
  co.y=math.copysign(1.80+(abs(co.y)-1.80)*(2.35-1.80)/(ends[0 if co.y<0 else 1]-1.80),co.y)
 if abs(co.x)>.94:co.x=math.copysign(.94,co.x)
 v.co=body.matrix_world.inverted()@co
top=max((body.matrix_world@v.co).z for v in body.data.vertices)
for v in body.data.vertices:
 if v.co.z>1.58:v.co.z=1.58+(v.co.z-1.58)*(.10/(top-1.58))
# Tread blocks and carcass share an exactly 375mm outer radius and ground contact.
for name,o in GROUPS.items():
 if not name.startswith('Wheel_'):continue
 radius=max(math.hypot(v.co.y,v.co.z) for v in o.data.vertices)
 zextent=max(abs(v.co.z) for v in o.data.vertices)
 for v in o.data.vertices:v.co.y*=.375/radius;v.co.z*=.375/zextent

sockets={'Seat_Driver':(.43,.13,.68),'Door_L':(1.03,.13,.34),'Door_R':(-1.03,.13,.34),'Exhaust':(-.645,2.32,.364),'Light_Head_L':(.65,-2.26,.885),'Light_Head_R':(-.65,-2.26,.885),'Light_Tail_L':(.65,2.27,1.025),'Light_Tail_R':(-.65,2.27,1.025)}
for name,pos in sockets.items():
 o=bpy.data.objects.new(name,None);bpy.context.collection.objects.link(o);o.location=pos;o.empty_display_size=.08
assets=list(GROUPS.values())+[bpy.data.objects[n] for n in sockets]
# Bake Paint is neutral. glTF factor is explicitly set after export, and renders use identical multiplication.
active(GROUPS['Body'])
for o in assets:o.select_set(True)
S.cycles.samples=24
bpy.ops.export_scene.gltf(filepath=ROOT+'/family-suv.glb',export_format='GLB',use_selection=True,export_apply=True,export_yup=True,export_image_format='AUTO',export_materials='EXPORT',export_cameras=False,export_lights=False)
# Enforce portable contracts at serialization: no shader-dependent recolouring assumptions.
p=ROOT+'/family-suv.glb';data=open(p,'rb').read();jl,jt=struct.unpack_from('<II',data,12);doc=json.loads(data[20:20+jl]);binary=data[20+jl:]
for m in doc['materials']:
 if m['name']=='Paint':m['pbrMetallicRoughness']['baseColorFactor']=[.62,.63,.64,1];m.setdefault('extensions',{})['KHR_materials_clearcoat']={'clearcoatFactor':1,'clearcoatRoughnessFactor':.05}
 if m['name']=='Glass':m['alphaMode']='BLEND';m['doubleSided']=True
enc=json.dumps(doc,separators=(',',':')).encode();enc+=b' '*((-len(enc))%4);open(p,'wb').write(struct.pack('<III',0x46546C67,2,20+len(enc)+len(binary))+struct.pack('<II',len(enc),0x4E4F534A)+enc+binary)
# World positions and bounds in exported glTF axes.
def gl(v):return [round(v[0],6),round(v[2],6),round(-v[1],6)]
pts=[o.matrix_world@Vector(c) for o in GROUPS.values() for c in o.bound_box]
gpts=[gl(p) for p in pts];lo=[min(p[i] for p in gpts) for i in range(3)];hi=[max(p[i] for p in gpts) for i in range(3)]
tris=sum(len(o.data.polygons) for o in GROUPS.values())
manifest={'contract':'Forge Garage v2','units':'metres','front':'+Z','up':'+Y','left':'+X','nodes':{o.name:{'world_position':gl(o.matrix_world.translation),'type':'MESH' if o.type=='MESH' else 'EMPTY'} for o in assets},'bounding_box':{'min':lo,'max':hi,'size':[round(hi[i]-lo[i],6) for i in range(3)]},'wheelbase':2.75,'triangles':tris,'materials':[m['name'] for m in doc['materials']],'paints':[{'name':n,'linear_rgb':c} for n,c in PAINTS],'bake':{'engine':'Cycles','device':'OPTIX','samples':4,'photographic_sources':['metal_plate','rubberized_track','fabric_leather_02'],'method':'shared seamless UV tiles: EMIT base colour + tangent NORMAL + packed ORM'}}
json.dump(manifest,open(ROOT+'/nodes.json','w'),indent=2)
open(ROOT+'/NOTES.md','w').write('Revised five-door crossover: sculpted recessed front/rear fascia, integrated LED optics, preserved stance, rails and Garage v2 pivots.\n'+f'{tris:,} triangles; {os.path.getsize(p)/1048576:.2f} MiB GLB.\n'+'7 materials: Paint, Rubber, Trim, Alloy, Interior, Glass, Lamps.\n'+'Paint colour/normal 1024; other colour/normal 512; ORM 512; lamp palette 256 pixels; shared Cycles-baked maps.\n'+'Poly Haven CC0: metal_plate, rubberized_track, fabric_leather_02; https://polyhaven.com/license ; https://polyhaven.com/a/metal_plate ; https://polyhaven.com/a/rubberized_track ; https://polyhaven.com/a/fabric_leather_02 .\n'+'Differences: original grille and LED signatures; simpler cabin controls and side stampings; width includes mirrors; cleaner finish than reference.\n')
print('EXPORT_COMPLETE',tris,os.path.getsize(p),manifest['bounding_box'],flush=True)
# Neutral material factor is applied identically for offline pictures.
nt=Paint.node_tree;bs=nt.nodes.get('Principled BSDF');link=bs.inputs['Base Color'].links[0];source=link.from_socket;nt.links.remove(link)
paintmix=nt.nodes.new('ShaderNodeMixRGB');paintmix.blend_type='MULTIPLY';paintmix.inputs[0].default_value=1;nt.links.new(source,paintmix.inputs[1]);paintmix.inputs[2].default_value=(*PAINTS[0][1],1);nt.links.new(paintmix.outputs[0],bs.inputs['Base Color'])
# Photographic daylight studio setup, excluded from delivered model.
ground,gg=matbase('Studio ground',(.235,.255,.28),0,.78)
bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,-.004));bpy.context.object.name='Studio ground';bpy.context.object.data.materials.append(ground)
def area(name,pos,power,size,color,scale=None):
 d=bpy.data.lights.new(name,'AREA');o=bpy.data.objects.new(name,d);bpy.context.collection.objects.link(o);o.location=pos;d.energy=power;d.shape='DISK';d.size=size;d.color=color;o.rotation_euler=(Vector((0,0,.8))-o.location).to_track_quat('-Z','Y').to_euler()
area('Large soft daylight', (3,-4,6),900,5,(1,.93,.84))
area('Long cool rim',(-4,1,4),800,4,(.78,.86,1))
area('Roof ribbon',(1,4,5),220,3,(1,.97,.90))
d=bpy.data.cameras.new('Camera');cam=bpy.data.objects.new('Camera',d);bpy.context.collection.objects.link(cam);S.camera=cam

def camera(pos,target=(0,0,.82),ortho=None):
 cam.location=pos;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();d.type='ORTHO' if ortho else 'PERSP';d.lens=53
 if ortho:d.ortho_scale=ortho

def render(name,w,h,samples=24):
 S.render.resolution_x=w;S.render.resolution_y=h;S.render.resolution_percentage=100;S.cycles.samples=samples;S.render.filepath=ROOT+'/'+name+'.png';bpy.ops.render.render(write_still=True);print('RENDERED',name,flush=True)
camera((6.15,-7.2,2.85));render('final',1920,1080,32)
camera((5.8,7.2,2.65));render('rear',1280,720,20)
camera((7,0,.84),target=(0,0,.84),ortho=5.65);render('side',1920,1080,24)
camera((6.15,-7.2,2.85))
clay,cp=matbase('Clay',(.42,.42,.42),0,.55);bpy.context.view_layer.material_override=clay;render('clay',1920,1080,24);bpy.context.view_layer.material_override=None
for i,(name,col) in enumerate(PAINTS):
 paintmix.inputs[2].default_value=(*col,1);render('paint_'+str(i),640,360,12)
paintmix.inputs[2].default_value=(*PAINTS[0][1],1)
# Build the grid directly with Blender image buffers; no extra dependency.
import numpy as np
canvas=np.ones((720,1920,4),dtype=np.float32)
for i in range(6):
 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)
im=bpy.data.images.new('Paint variations',1920,720);im.pixels.foreach_set(canvas.ravel());im.filepath_raw=ROOT+'/paints.png';im.file_format='PNG';im.save()
bpy.ops.wm.save_as_mainfile(filepath=ROOT+'/family-suv.blend')
# Validate portability visually: reopen the actual GLB in the same daylight setup.
for o in assets:
 bpy.data.objects.remove(o,do_unlink=True)
# The importer creates its full current schema instead of reusing our export-only AO group.
export_group=bpy.data.node_groups.get('glTF Material Output')
if export_group:export_group.name='Baked export occlusion'
bpy.ops.import_scene.gltf(filepath=ROOT+'/family-suv.glb')
render('roundtrip',1280,720,24)
print('ROUNDTRIP_OK',flush=True)
print('BUILD_COMPLETE',flush=True)
