"""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-.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',.022,.001,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',.06,.012)
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):
 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=interp(z,[(.30,-.030),(.39,-.017),(.49,-.032),(.57,-.036),(.73,-.007),(.91,.010),(1.015,.012),(1.135,-.012)])
 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(11):
   t=j/10;z=lo+(hi-lo)*t
   x=surface_x(y,z)
   yp=y
   if abs(y)>1.96:
    endy=interp(z,[(.30,2.08),(.35,2.17),(.45,2.23),(.59,2.232),(.76,2.205),(.94,2.178),(1.012,2.158),(1.14,2.15)])
    endx=interp(z,[(.30,.71),(.35,.79),(.45,.835),(.59,.845),(.76,.831),(.94,.817),(1.012,.788),(1.14,.785)])
    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(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*(surface_x(y,z)+.0028),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)

# 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):
  y=-2.20+1.54*k/64;t=(y+2.27)/1.61;z=1.003+.102*t+.018*sin(pi*t)
  xinner=.790+(.876-.790)*t
  v.extend([(sign*xinner,y,z),(sign*(surface_x(y,z)),y,z-.003)])
 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;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,.790,.876,.025,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.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 [
 ('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.37,1.55),(1.153,1.55)],Trim),
 ('D pillar',[(1.94,1.14),(2.16,1.12),(1.71,1.57),(1.55,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.69,1.495),(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')
# 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)

# 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)
# Vertical bumper corners bridge the fascia into the longitudinal wings.
for sign in [-1,1]:
 for front in [True,False]:
  sy=-1 if front else 1;v=[];f=[]
  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)]
  for z,w,y in rings:
   for k in range(7):
    t=k/6;v.append((sign*(w+.015*sin(pi*t)),sy*(y-.11+t*.025),z))
  for j in range(len(rings)-1):
   for k in range(6):a=j*7+k;f.append((a,a+1,a+8,a+7))
  mesh('Bumper wing rounded corner',v,f,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.
 points=[(sign*.486,-2.295,1.001),(sign*.796,-2.245,1.029),(sign*.844,-2.166,.966),(sign*.520,-2.297,.905)]
 o=mesh('Wraparound headlight housing',roundpoly(points,.13,6),[tuple(range(28))],Trim);sol=o.modifiers.new('Lamp housing thickness','SOLIDIFY');sol.thickness=.018;active(o);bpy.ops.object.modifier_apply(modifier=sol.name)
 for xx,yy,zz in [(.577,-2.310,.959),(.727,-2.279,.978)]:
  box('Headlight reflector',(sign*xx,yy,zz),(.108,.021,.049),Alloy,.016)
  o=box('LED projector',(sign*xx,yy-.013,zz),(.079,.012,.029),Lamps,.010);lamp_tag(o,'white')
 o=tube('Headlight DRL',[(sign*.496,-2.309,.998),(sign*.637,-2.291,1.009),(sign*.792,-2.258,1.026),(sign*.837,-2.181,.969)],.007,Lamps,res=3);lamp_tag(o,'white')
 o=tube('Front amber indicator',[(sign*.528,-2.311,.923),(sign*.746,-2.254,.951)],.004,Lamps,res=3);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(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*.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)

print('GEOMETRY_COMPLETE',sum(len(v) for v in GROUPS.values()),flush=True)
# 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()

# Preserve silhouette while keeping a deterministic game triangle budget.
count=sum(len(o.data.polygons) for o in GROUPS.values())
if count>96000:
 ratio=95000/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)
# Exact envelope: overhangs are calibrated independently so wheelbase remains 2.75m.
body=GROUPS['Body']
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
# 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)
 for v in o.data.vertices:v.co.y*=.375/radius;v.co.z*=.375/radius

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)
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),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,.84),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)
