"""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.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,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 tube(name,pts,r,mat,group='Body',closed=False,res=3):
 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-.06);mr.inputs['To Max'].default_value=min(1,rough+.06);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',.045,.025,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',.10,.06)
Interior=bakedmat('Interior',(.066,.058,.048),0,.63,'fabric_leather_02',.20,.30)
Glass,gp=matbase('Glass',(.095,.145,.17),.12,.12);gp.inputs['Alpha'].default_value=.31
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

# 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):return 1.09+.015*y-.095*max(0,(-y-1.3)/1.05)
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
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(11):
   t=j/10;z=lo+(hi-lo)*t
   x=width(y)-.026*(1-t)+.025*sin(pi*t)-.008*math.exp(-((t-.76)/.09)**2)
   v.append((sign*x,y,z))
 for i in range(N):
  for j in range(10):a=i*11+j;f.append((a,a+1,a+12,a+11) if sign==1 else (a+11,a+12,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)
 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+.72*k/24;y=yy+.012*sin(k*pi/24)
   if z<lower(y)+.014:continue
   pts.append((sign*(width(y)+.003),y,z))
  if len(pts)>1:tube('Door vertical shutline',pts,.0028,Rubber,res=2)
 # two handles, recessed pockets and painted pull
 for yy in [.14,1.10]:
  box('Handle shadow pocket',(sign*.892,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)

# 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;v.append((w*u,y,z+bulge*(1-u*u)))
 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);return o
crown('Bonnet',-2.27,-.66,1.003,1.105,.798,.866,.035,Paint)
for sign in [-1,1]:
 tube('Bonnet shutline',[(sign*(.69+.07*k/32),-2.19+1.50*k/32,1.02+.095*k/32+.018*sin(pi*k/32)) for k in range(33)],.0025,Rubber,res=2)
 tube('Bonnet pressed ridge',[(sign*(.55+.065*k/32),-2.17+1.43*k/32,1.039+.090*k/32+.018*sin(pi*k/32)) for k in range(33)],.004,Paint,res=2)
crown('Roof outer skin',-.01,1.53,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):
 # Quad grid bilinear with subtle convexity, retaining original corner shape.
 if len(points)==4:
  v=[];f=[];n=12
  for i in range(n+1):
   u=i/n
   for j in range(n+1):
    t=j/n;p=Vector(points[0])*(1-u)*(1-t)+Vector(points[1])*u*(1-t)+Vector(points[2])*u*t+Vector(points[3])*(1-u)*t
    if abs(p.x)>.6:p.x+=math.copysign(.004*sin(pi*u)*sin(pi*t),p.x)
    v.append(tuple(p))
  for i in range(n):
   for j in range(n):a=i*(n+1)+j;f.append((a,a+1,a+n+2,a+n+1))
  o=mesh(name,v,f,mat)
 else:o=mesh(name,points,[tuple(range(len(points)))],mat)
 if border:tube(name+' rubber seal',points,.014,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.53,1.553),(.73,1.53,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.61,1.466),(1.239,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 [
 ('A pillar',[(-.696,1.106),(-.597,1.128),(.042,1.559),(-.015,1.592)],Paint),
 ('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.24,1.55),(1.153,1.55)],Trim),
 ('D pillar',[(1.94,1.14),(2.10,1.12),(1.58,1.57),(1.48,1.572)],Paint)]:
  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)
 # upper and lower bright window perimeter
 perimeter=[(-.659,1.116),(-.34,1.36),(.00,1.578),(.5,1.58),(1.20,1.571),(1.58,1.508),(1.99,1.164),(1.94,1.13),(.5,1.118)]
 tube('Continuous satin window surround',[sidept(sign,y,z) for y,z in perimeter],.009,Alloy,closed=True,res=3)
 # 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')
# spoiler and shark-fin antenna
box('Roof rear spoiler',(0,1.574,1.565),(1.48,.205,.062),Paint,.027)
box('Spoiler underside',(0,1.646,1.531),(1.33,.067,.023),Trim,.012)
o=box('High mounted stop lamp',(0,1.683,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)

# Front and rear fascia built from rounded transverse contour lofts.
def fascia(name,front,mat):
 sign=-1 if front else 1
 rings=[(.30,.71,2.19),(.35,.79,2.28),(.45,.835,2.34),(.59,.845,2.342),(.76,.831,2.315),(.94,.817,2.288),(1.012,.788,2.268)]
 v=[];f=[];nx=40
 for z,w,y in rings:
  for i in range(nx+1):u=-1+2*i/nx;v.append((w*u,sign*(y-.11*abs(u)**3),z))
 for j in range(len(rings)-1):
  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,mat);sol=o.modifiers.new('Bumper shell','SOLIDIFY');sol.thickness=.015;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
fascia('Rounded front bumper',True,Paint);fascia('Rounded rear bumper',False,Paint)
# black grille and lower apertures are recessed-looking rounded wells with shaped frames
box('Main grille dark cavity',(0,-2.297,.872),(1.12,.035,.213),Rubber,.066)
for z in [.80,.947]:tube('Grille satin perimeter',[(-.48,-2.319,z),(-.25,-2.331,z-.005),(0,-2.333,z-.007),(.25,-2.331,z-.005),(.48,-2.319,z)],.009,Alloy,res=3)
for x in [-.45,-.35,-.25,-.15,-.05,.05,.15,.25,.35,.45]:box('Grille vertical vane',(x,-2.322,.869),(.010,.016,.125),Trim,.004)
for z in [.843,.899]:box('Grille floating blade',(0,-2.336,z),(1.005,.020,.012),Alloy,.005)
box('Lower intake centre',(0,-2.349,.521),(1.13,.013,.145),Rubber,.038)
for z in [.483,.523,.563]:box('Lower intake louvre',(0,-2.359,z),(1.01,.012,.012),Trim,.005)
for sign in [-1,1]:
 box('Bumper outboard air duct',(sign*.690,-2.297,.57),(.195,.022,.232),Rubber,.047)
 for z in [.491,.548,.605]:box('Side duct vane',(sign*.685,-2.317,z),(.158,.022,.016),Trim,.006)
 # Lamp pods have dark reflectors and two optical projector modules.
 box('Headlight black bezel',(sign*.651,-2.261,.982),(.345,.044,.105),Trim,.026)
 for x in [.565,.708]:
  box('Headlight reflector',(sign*x,-2.287,.982),(.112,.018,.062),Alloy,.017)
  o=box('LED projector',(sign*x,-2.300,.985),(.075,.010,.029),Lamps,.009);lamp_tag(o,'white')
 o=tube('Headlight DRL',[(sign*.497,-2.295,1.011),(sign*.764,-2.26,1.016),(sign*.804,-2.237,.974)],.008,Lamps,res=3);lamp_tag(o,'white')
 o=box('Front indicator',(sign*.803,-2.231,.97),(.018,.008,.039),Lamps,.006);lamp_tag(o,'amber')
 # rear split lamp bars wrap onto the quarter
 box('Rear lamp bezel',(sign*.657,2.246,1.025),(.337,.034,.118),Trim,.03)
 o=box('Tail lamp lens',(sign*.657,2.266,1.026),(.305,.016,.086),Lamps,.020);lamp_tag(o,'red')
 for z in [1.002,1.044]:
  o=tube('Rear LED signature',[(sign*.503,2.279,z),(sign*.766,2.255,z),(sign*.837,2.145,z)],.008,Lamps,res=3);lamp_tag(o,'red')
 o=box('Rear indicator',(sign*.71,2.283,1.017),(.155,.005,.012),Lamps,.004);lamp_tag(o,'amber')
 # Parking sensors are body coloured disks
 for xx in [.33,.75]:cyl('Flush parking sensor',(sign*xx,-2.336+.065*(xx/.75)**3,.698),.014,.004,Paint,axis='Y',verts=24)
 cyl('Rear parking sensor',(sign*.63,2.32,.667),.014,.005,Paint,axis='Y',verts=24)
 # rear exhaust surrounds and inner dark openings
 box('Exhaust satin surround',(sign*.645,2.270,.364),(.207,.104,.087),Alloy,.033)
 box('Exhaust outlet void',(sign*.645,2.329,.363),(.171,.009,.054),Rubber,.023)
 o=box('Rear bumper reflector',(sign*.692,2.30,.492),(.175,.008,.024),Lamps,.008);lamp_tag(o,'red')
box('Lower front chin',(0,-2.267,.334),(1.60,.17,.07),Trim,.027)
box('Front undertray silver guard',(0,-2.315,.376),(1.09,.061,.039),Alloy,.016)
box('Rear valance',(0,2.256,.347),(1.49,.15,.13),Trim,.041)
tube('Rear bumper satin strip',[(-.79,2.26,.53),(-.5,2.344,.526),(0,2.353,.526),(.5,2.344,.526),(.79,2.26,.53)],.011,Alloy,res=3)
# Fifth door seam, rear plate recess (blank, no text), release and reversing camera.
tube('Tailgate shutline',[(-.735,2.163,1.132),(-.717,2.279,.918),(-.685,2.316,.726),(-.58,2.335,.683),(0,2.347,.677),(.58,2.335,.683),(.685,2.316,.726),(.717,2.279,.918),(.735,2.163,1.132)],.003,Rubber,res=2)
box('Tailgate plate recess',(0,2.317,.83),(.48,.015,.136),Trim,.018)
box('Blank plate',(0,2.329,.83),(.396,.007,.089),Alloy,.009)
box('Tailgate release',(0,2.311,.936),(.168,.022,.018),Trim,.007)
cyl('Rear camera',(0,2.329,.938),.009,.008,Glass,axis='Y',verts=24)
# 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(88):
   a=2*pi*k/88
   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*.132
  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,group)
  # 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)

# UVs, normals, modifiers and rigid assembly naming.
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)
 # 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()

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.30,.985),'Light_Head_R':(-.65,-2.30,.985),'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('Original realistic five-door midsize crossover, reference proportions, Garage v2 wheel pivots and five-seat cabin.\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 and https://polyhaven.com/a/<id>.\n'+'Differences: original grille and lamp graphics, simpler cabin controls; no branding; width includes mirrors; restrained wear rather than reference dirt.\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)
box('Studio ground',(0,0,-.043),(200,200,.08),ground,.0,None)
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),1400,5,(1,.93,.84))
area('Long cool rim',(-4,1,4),1100,4,(.78,.86,1))
area('Roof ribbon',(1,4,5),1200,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,3.55));render('final',1920,1080,32)
camera((7,0,1.0),target=(0,0,.84),ortho=5.65);render('side',1920,1080,24)
camera((6.15,-7.2,3.55))
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')
print('BUILD_COMPLETE',flush=True)
